From a104f5c4d911a26650c49ab608169b69d48999c2 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 10:26:36 +0200 Subject: [PATCH 1/7] 101-schema step 1: object and material inventory + 1.65.0 capture fixtures scripts/schema-inventory.cjs lists every object, material, geometry and texture type, image form and userData key the app constructs, sends and saves (src/, the modules repo, the templates, the scenes repo, the test fixtures, captured wire messages). tests/e2e/schema-capture.test.cjs builds every Add-menu object and every switchable material kind on the base build and records the saved scene, a prefab element and the outgoing object messages under tests/fixtures/schema-1.65/ (excluded from the battery: a fixture generator). inventory.json is the fixture list the object schema is written from. Co-Authored-By: Claude Opus 5.5 --- scripts/schema-inventory.cjs | 474 + tests/e2e/battery.json | 2 +- tests/e2e/schema-capture.test.cjs | 185 + tests/fixtures/schema-1.65/capture.tpscene | Bin 0 -> 98863 bytes tests/fixtures/schema-1.65/inventory.json | 12565 +++++++++++++++++++ tests/fixtures/schema-1.65/prefab.json | 1 + tests/fixtures/schema-1.65/wire.json | 1 + 7 files changed, 13227 insertions(+), 1 deletion(-) create mode 100644 scripts/schema-inventory.cjs create mode 100644 tests/e2e/schema-capture.test.cjs create mode 100644 tests/fixtures/schema-1.65/capture.tpscene create mode 100644 tests/fixtures/schema-1.65/inventory.json create mode 100644 tests/fixtures/schema-1.65/prefab.json create mode 100644 tests/fixtures/schema-1.65/wire.json diff --git a/scripts/schema-inventory.cjs b/scripts/schema-inventory.cjs new file mode 100644 index 000000000..b92932569 --- /dev/null +++ b/scripts/schema-inventory.cjs @@ -0,0 +1,474 @@ +#!/usr/bin/env node +// 101-schema step 1 — WHAT OBJECT AND MATERIAL JSON THIS APP ACTUALLY MAKES, SENDS AND SAVES. +// +// Roadmap 101 F2 puts one allowlist (src/lib/sceneSchema.js) in front of every ObjectLoader / +// parseMaterials call fed by a peer or a file. An allowlist is only safe to ship if nothing real +// falls outside it, so this script is the evidence it is written from and the fixture list its +// "0 drops on real content" proof runs over. Two halves: +// +// (a) SOURCE — what the app can SEND: the `objectParameters` parameters it emits, the three +// classes it constructs (objects / materials / geometries / textures), and every `userData` +// key read or written in src/ (and the modules repo when MODULES_REPO is set: modules create +// objects too). +// (b) CONTENT — what is SAVED: every ObjectLoader JSON (`metadata.type === 'Object'`) found +// anywhere inside every .tpscene / .tp / .json given. For each: object, material, geometry +// and texture TYPES with the FIELDS seen on each, the IMAGE forms (data: mime, asset ref, +// URL, typed-array image), and every userData key with its JSON shape and largest size. +// +// node scripts/schema-inventory.cjs [path ...] # dirs are walked for .tpscene/.tp/.json +// node scripts/schema-inventory.cjs --out # also write the machine-readable result +// node scripts/schema-inventory.cjs --md # and a markdown summary +// +// Defaults when no path is given: static/templates, tests/fixtures, and $SCENES_DIR when set. +// Pure Node + fflate (already a dependency); reads files only, never the network. + +const fs = require('fs'); +const path = require('path'); +const { unzipSync, strFromU8 } = require('fflate'); + +const ROOT = path.resolve(__dirname, '..'); + +/** a stable label for a path: repo-relative, or `scenes@/…` under $SCENES_DIR (an extracted scenes repo) + * @param {string} p */ +const rel = (p) => { + const scenes = process.env.SCENES_DIR ? path.resolve(process.env.SCENES_DIR) : null; + if (scenes && (p === scenes || p.startsWith(scenes + path.sep))) + return 'scenes@' + (process.env.SCENES_REF || 'local') + (p === scenes ? '' : '/' + path.relative(scenes, p)); + return path.relative(ROOT, p) || p; +}; + +// --------------------------------------------------------------------------------------------- +// (a) SOURCE +// --------------------------------------------------------------------------------------------- + +/** every .js / .svelte / .ts file under a dir (no node_modules, no build output) @param {string} dir */ +function sourceFiles(dir) { + /** @type {string[]} */ + const out = []; + /** @param {string} d */ + const walk = (d) => { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.') || e.name === 'build' || e.name === 'dist') continue; + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p); + else if (/\.(js|mjs|cjs|ts|svelte)$/.test(e.name)) out.push(p); + } + }; + if (fs.existsSync(dir)) walk(dir); + return out; +} + +/** @param {Map>} map @param {string} key @param {string} where */ +function note(map, key, where) { + if (!map.has(key)) map.set(key, new Set()); + /** @type {Set} */ (map.get(key)).add(where); +} + +const OBJECT_CTORS = new Set([ + 'Object3D', 'Scene', 'Group', 'Mesh', 'SkinnedMesh', 'InstancedMesh', 'BatchedMesh', 'LOD', 'Line', 'LineLoop', + 'LineSegments', 'Points', 'Sprite', 'Bone', 'PerspectiveCamera', 'OrthographicCamera', 'AmbientLight', + 'DirectionalLight', 'PointLight', 'RectAreaLight', 'SpotLight', 'HemisphereLight', 'LightProbe' +]); + +/** @param {string[]} files @param {string} label */ +function scanSource(files, label) { + /** @type {Map>} */ const parameters = new Map(); + /** @type {Map>} */ const appliedParameters = new Map(); + /** @type {Map>} */ const objects = new Map(); + /** @type {Map>} */ const materials = new Map(); + /** @type {Map>} */ const geometries = new Map(); + /** @type {Map>} */ const textures = new Map(); + /** @type {Map>} */ const userData = new Map(); + /** @type {Map>} */ const loaderCalls = new Map(); + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + const where = label + ':' + path.relative(label === 'core' ? ROOT : process.env.MODULES_REPO || ROOT, file); + for (const m of text.matchAll(/parameter:\s*'(\w+)'/g)) note(parameters, m[1], where); + for (const m of text.matchAll(/parameter\s*===?\s*'(\w+)'/g)) note(appliedParameters, m[1], where); + for (const m of text.matchAll(/new\s+(?:THREE\.)?(\w+)\s*\(/g)) { + const c = m[1]; + if (OBJECT_CTORS.has(c)) note(objects, c, where); + else if (/Material$/.test(c) && /^[A-Z]/.test(c)) note(materials, c, where); + else if (/Geometry$/.test(c) && /^[A-Z]/.test(c)) note(geometries, c, where); + else if (/Texture$/.test(c) && /^[A-Z]/.test(c)) note(textures, c, where); + } + // userData.key / userData?.key / userData['key'] / userData["key"] + for (const m of text.matchAll(/userData\??\.(?!\?)([A-Za-z_$][\w$]*)/g)) note(userData, m[1], where); + for (const m of text.matchAll(/userData\??\.?\[\s*['"]([^'"\]]+)['"]\s*\]/g)) note(userData, m[1], where); + for (const m of text.matchAll(/(new\s+(?:THREE\.)?ObjectLoader\(\)|\.parseMaterials\(|new\s+(?:THREE\.)?MaterialLoader\(|\.parseTextures\(|\.parseImages\(|\.parseGeometries\(|\.parseObject\()/g)) + note(loaderCalls, m[1].replace(/\s+/g, ' '), where); + } + return { parameters, appliedParameters, objects, materials, geometries, textures, userData, loaderCalls }; +} + +// --------------------------------------------------------------------------------------------- +// (b) CONTENT +// --------------------------------------------------------------------------------------------- + +/** a JSON value's shape, one level deep for objects (key list) @param {any} v @returns {string} */ +function shapeOf(v) { + if (v === null) return 'null'; + if (Array.isArray(v)) { + const inner = new Set(v.slice(0, 50).map((x) => (x === null ? 'null' : Array.isArray(x) ? 'array' : typeof x))); + return 'array<' + ([...inner].sort().join('|') || 'empty') + '>'; + } + if (typeof v === 'object') return 'object{' + Object.keys(v).sort().join(',') + '}'; + return typeof v; +} + +/** how an image is carried @param {any} url */ +function imageForm(url) { + if (typeof url === 'string') { + const d = /^data:([^;,]*)[;,]/.exec(url); + if (d) return 'data:' + (d[1] || '(none)'); + if (/^blob:/.test(url)) return 'blob-url'; + if (/^https?:/i.test(url)) return 'http-url'; + if (/^[0-9a-f]{16,}$/i.test(url)) return 'hash'; + return 'other-string'; + } + if (Array.isArray(url)) return 'array[' + [...new Set(url.map(imageForm))].join('|') + ']'; + if (url && typeof url === 'object') { + if (url.type && url.data) return 'typed:' + url.type; + return 'object{' + Object.keys(url).sort().join(',') + '}'; + } + return url === undefined ? 'absent' : typeof url; +} + +function newContent() { + return { + files: /** @type {string[]} */ ([]), + errors: /** @type {string[]} */ ([]), + elements: 0, + materialPayloads: 0, + sessionKeys: new Map(), + objectTypes: new Map(), // type -> {count, fields:Set, where:Set} + materialTypes: new Map(), + geometryTypes: new Map(), + geometryData: new Map(), // 'data' / 'attribute' / 'index' / 'morphAttribute' rows of BufferGeometry json + textureTypes: new Map(), + textureFields: new Map(), // field -> count + imageForms: new Map(), // form -> {count, maxBytes, where:Set} + userData: new Map(), // 'object.' / 'material.' / 'geometry.' -> {count, shapes:Set, maxBytes, where:Set} + topLevelKeys: new Map(), // ObjectLoader JSON top-level keys + metadataVersions: new Map(), + maxElementBytes: 0, + maxDepth: 0, + maxChildren: 0 + }; +} + +/** @param {Map} map @param {string} type @param {any} json @param {string} where */ +function typeRow(map, type, json, where) { + if (!map.has(type)) map.set(type, { count: 0, fields: new Set(), kinds: {}, where: new Set() }); + const r = map.get(type); + r.count++; + for (const [k, v] of Object.entries(json)) { + r.fields.add(k); + if (k === 'children' || k === 'userData') continue; + noteKind(r.kinds, k, v); + } + if (r.where.size < 6) r.where.add(where); +} + +/** a field's value kinds and range, merged across every sighting @param {Record} kinds @param {string} k @param {any} v */ +function noteKind(kinds, k, v) { + const row = (kinds[k] ??= { kinds: [], min: null, max: null, maxLen: 0, values: [] }); + const kind = v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v; + if (!row.kinds.includes(kind)) row.kinds.push(kind); + if (kind === 'number') { + row.min = row.min === null ? v : Math.min(row.min, v); + row.max = row.max === null ? v : Math.max(row.max, v); + if (!Number.isFinite(v) && !row.values.includes(String(v))) row.values.push(String(v)); + } else if (kind === 'string') { + row.maxLen = Math.max(row.maxLen, v.length); + if (v.length <= 40 && row.values.length < 12 && !row.values.includes(v)) row.values.push(v); + } else if (kind === 'array') { + row.maxLen = Math.max(row.maxLen, v.length); + } else if (kind === 'object') { + const keys = Object.keys(v).sort().join(','); + if (row.values.length < 12 && !row.values.includes('{' + keys + '}')) row.values.push('{' + keys + '}'); + try { + row.maxLen = Math.max(row.maxLen, JSON.stringify(v).length); + } catch { + /* parsed JSON is never cyclic */ + } + } +} + +/** @param {ReturnType} c @param {string} scope @param {any} ud @param {string} where */ +function noteUserData(c, scope, ud, where) { + if (!ud || typeof ud !== 'object' || Array.isArray(ud)) return; + for (const [k, v] of Object.entries(ud)) { + const key = scope + '.' + k; + if (!c.userData.has(key)) c.userData.set(key, { count: 0, shapes: new Set(), maxBytes: 0, where: new Set() }); + const r = c.userData.get(key); + r.count++; + r.shapes.add(shapeOf(v)); + let bytes = 0; + try { + bytes = JSON.stringify(v)?.length ?? 0; + } catch { + bytes = -1; + } + r.maxBytes = Math.max(r.maxBytes, bytes); + if (r.where.size < 6) r.where.add(where); + } +} + +/** walk one ObjectLoader JSON @param {ReturnType} c @param {any} el @param {string} where */ +function walkElement(c, el, where, payloadOnly = false) { + if (!payloadOnly) c.elements++; + try { + c.maxElementBytes = Math.max(c.maxElementBytes, JSON.stringify(el).length); + } catch { + /* cyclic: impossible for parsed JSON */ + } + for (const k of Object.keys(el)) c.topLevelKeys.set(k, (c.topLevelKeys.get(k) || 0) + 1); + if (!payloadOnly) { + const v = String(el.metadata?.version); + c.metadataVersions.set(v, (c.metadataVersions.get(v) || 0) + 1); + } + for (const g of el.geometries ?? []) { + typeRow(c.geometryTypes, String(g.type), g, where); + if (g.data && typeof g.data === 'object') { + typeRow(c.geometryData, 'data', g.data, where); + for (const [name, a] of Object.entries(g.data.attributes ?? {})) typeRow(c.geometryData, 'attribute', a, where + ' #' + name); + if (g.data.index) typeRow(c.geometryData, 'index', g.data.index, where); + for (const [name, list] of Object.entries(g.data.morphAttributes ?? {})) + for (const a of /** @type {any[]} */ (list)) typeRow(c.geometryData, 'morphAttribute', a, where + ' #' + name); + } + noteUserData(c, 'geometry', g.userData ?? g.data?.userData, where); + } + for (const m of el.materials ?? []) { + typeRow(c.materialTypes, String(m.type), m, where); + noteUserData(c, 'material', m.userData, where); + } + for (const t of el.textures ?? []) { + for (const k of Object.keys(t)) c.textureFields.set(k, (c.textureFields.get(k) || 0) + 1); + typeRow(c.textureTypes, String(t.type ?? '(none)'), t, where); + noteUserData(c, 'texture', t.userData, where); + } + for (const img of el.images ?? []) { + const form = imageForm(img.url); + if (!c.imageForms.has(form)) c.imageForms.set(form, { count: 0, maxBytes: 0, where: new Set() }); + const r = c.imageForms.get(form); + r.count++; + r.maxBytes = Math.max(r.maxBytes, typeof img.url === 'string' ? img.url.length : JSON.stringify(img.url ?? null).length); + if (r.where.size < 6) r.where.add(where); + } + /** @param {any} o @param {number} depth */ + const walkObject = (o, depth) => { + if (!o || typeof o !== 'object') return; + c.maxDepth = Math.max(c.maxDepth, depth); + typeRow(c.objectTypes, String(o.type), o, where); + noteUserData(c, 'object', o.userData, where); + const kids = Array.isArray(o.children) ? o.children : []; + c.maxChildren = Math.max(c.maxChildren, kids.length); + for (const k of kids) walkObject(k, depth + 1); + }; + walkObject(el.object, 1); +} + +/** find every ObjectLoader JSON inside an arbitrary JSON document @param {ReturnType} c @param {any} doc @param {string} where */ +function findElements(c, doc, where) { + /** @param {any} v @param {string} at */ + const visit = (v, at) => { + if (!v || typeof v !== 'object') return; + if (!Array.isArray(v) && v.metadata && typeof v.metadata === 'object' && v.metadata.type === 'Object' && v.object) { + walkElement(c, v, where + (at ? ' @' + at : '')); + return; + } + // an `objectParameters` materials payload: {materials[], textures[], images[]} with no metadata + if (!Array.isArray(v) && Array.isArray(v.materials) && !v.metadata && (v.textures || v.images) && v.materials.every((/** @type {any} */ m) => m && typeof m.type === 'string')) { + c.materialPayloads++; + walkElement(c, { materials: v.materials, textures: v.textures ?? [], images: v.images ?? [] }, where + ' @' + (at || 'materials') + ' (payload)', true); + return; + } + // a glTF document is not ObjectLoader JSON: not walked here + if (!Array.isArray(v) && v.asset && typeof v.asset === 'object' && typeof v.asset.version === 'string') return; + if (Array.isArray(v)) v.forEach((x, i) => visit(x, at.replace(/\[\d+\]$/, '') + '[]')); + else for (const [k, x] of Object.entries(v)) visit(x, at ? at + '.' + k : k); + }; + visit(doc, ''); +} + +/** @param {ReturnType} c @param {Uint8Array} bytes @param {string} where */ +function readZip(c, bytes, where) { + const z = unzipSync(bytes); + for (const [name, data] of Object.entries(z)) { + if (name === 'session.json' || (name.endsWith('.json') && !name.startsWith('assets/'))) { + let doc; + try { + doc = JSON.parse(strFromU8(data)); + } catch (e) { + c.errors.push(where + '!' + name + ': ' + /** @type {Error} */ (e).message); + continue; + } + if (name === 'session.json') for (const k of Object.keys(doc)) c.sessionKeys.set(k, (c.sessionKeys.get(k) || 0) + 1); + findElements(c, doc, where + (name === 'session.json' ? '' : '!' + name)); + } else if (name.endsWith('.tpscene')) { + readZip(c, data, where + '!' + name); + } + } +} + +/** @param {ReturnType} c @param {string} file */ +function readFile(c, file) { + const where = rel(file); + c.files.push(where); + const bytes = new Uint8Array(fs.readFileSync(file)); + try { + if (bytes[0] === 0x50 && bytes[1] === 0x4b) readZip(c, bytes, where); + else findElements(c, JSON.parse(strFromU8(bytes)), where); + } catch (e) { + c.errors.push(where + ': ' + /** @type {Error} */ (e).message); + } +} + +/** @param {string} p @returns {string[]} */ +function contentFiles(p) { + if (!fs.existsSync(p)) return []; + const st = fs.statSync(p); + if (st.isFile()) return [p]; + /** @type {string[]} */ + const out = []; + for (const e of fs.readdirSync(p, { withFileTypes: true })) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const q = path.join(p, e.name); + if (e.isDirectory()) out.push(...contentFiles(q)); + else if (/\.(tpscene|tp)$/.test(e.name) || (/\.json$/.test(e.name) && !/^(package|manifest|index|known|tsconfig|inventory)/.test(e.name))) out.push(q); + } + return out.sort(); +} + +// --------------------------------------------------------------------------------------------- +// output +// --------------------------------------------------------------------------------------------- + +/** @param {Map} map */ +function rows(map) { + return Object.fromEntries( + [...map.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([k, v]) => [ + k, + v instanceof Set + ? [...v].sort() + : typeof v === 'object' + ? Object.fromEntries(Object.entries(v).map(([kk, vv]) => [kk, vv instanceof Set ? [...vv].sort() : vv])) + : v + ]) + ); +} + +function main() { + const args = process.argv.slice(2); + /** @type {string|null} */ let out = null; + /** @type {string|null} */ let md = null; + /** @type {string[]} */ const paths = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--out') out = args[++i]; + else if (args[i] === '--md') md = args[++i]; + else paths.push(path.resolve(args[i])); + } + if (!paths.length) { + paths.push(path.join(ROOT, 'static/templates'), path.join(ROOT, 'tests/fixtures')); + if (process.env.SCENES_DIR) paths.push(path.resolve(process.env.SCENES_DIR)); + } + + const core = scanSource(sourceFiles(path.join(ROOT, 'src')), 'core'); + const modules = process.env.MODULES_REPO ? scanSource(sourceFiles(process.env.MODULES_REPO), 'modules') : null; + + const content = newContent(); + for (const p of paths) for (const f of contentFiles(p)) readFile(content, f); + + const result = { + generatedBy: 'scripts/schema-inventory.cjs', + inputs: paths.map(rel), + source: { + core: Object.fromEntries(Object.entries(core).map(([k, v]) => [k, rows(v)])), + modules: modules ? Object.fromEntries(Object.entries(modules).map(([k, v]) => [k, rows(v)])) : null + }, + content: { + files: content.files, + errors: content.errors, + elements: content.elements, + materialPayloads: content.materialPayloads, + maxElementBytes: content.maxElementBytes, + maxDepth: content.maxDepth, + maxChildren: content.maxChildren, + metadataVersions: rows(content.metadataVersions), + sessionKeys: rows(content.sessionKeys), + topLevelKeys: rows(content.topLevelKeys), + objectTypes: rows(content.objectTypes), + materialTypes: rows(content.materialTypes), + geometryTypes: rows(content.geometryTypes), + geometryData: rows(content.geometryData), + textureTypes: rows(content.textureTypes), + textureFields: rows(content.textureFields), + imageForms: rows(content.imageForms), + userData: rows(content.userData) + } + }; + if (out) fs.writeFileSync(out, JSON.stringify(result, null, '\t') + '\n'); + const summary = markdown(result); + if (md) fs.writeFileSync(md, summary); + if (!out && !md) process.stdout.write(summary); + else + console.log( + `schema-inventory: ${content.files.length} files, ${content.elements} object elements, ` + + `${content.objectTypes.size} object / ${content.materialTypes.size} material / ${content.geometryTypes.size} geometry types, ` + + `${content.userData.size} userData keys, ${content.errors.length} errors` + ); + return result; +} + +/** @param {any} r */ +function markdown(r) { + const c = r.content; + /** @type {string[]} */ + const L = []; + L.push('# Schema inventory (101-schema step 1)', ''); + L.push(`Inputs: ${r.inputs.join(', ')} `, `Files: ${c.files.length} · object elements: ${c.elements} · largest element: ${c.maxElementBytes} bytes · deepest: ${c.maxDepth} · most children: ${c.maxChildren} · errors: ${c.errors.length}`, ''); + /** @param {string} title @param {Record} map */ + const typeTable = (title, map) => { + L.push(`## ${title}`, '', '| type | count | fields |', '|---|---|---|'); + for (const [k, v] of Object.entries(map)) L.push(`| ${k} | ${v.count} | ${v.fields.join(', ')} |`); + L.push(''); + }; + L.push(`Material payloads (objectParameters materials): ${c.materialPayloads}`, ''); + typeTable('Object types (content)', c.objectTypes); + typeTable('Material types (content)', c.materialTypes); + typeTable('Geometry types (content)', c.geometryTypes); + typeTable('BufferGeometry data parts (content)', c.geometryData); + L.push('## Texture fields (content)', '', Object.entries(c.textureFields).map(([k, v]) => `${k} (${v})`).join(', '), ''); + L.push('## Image forms (content)', '', '| form | count | largest bytes |', '|---|---|---|'); + for (const [k, v] of Object.entries(c.imageForms)) L.push(`| ${k} | ${v.count} | ${v.maxBytes} |`); + L.push('', '## userData keys (content)', '', '| key | count | shapes | largest bytes |', '|---|---|---|---|'); + for (const [k, v] of Object.entries(c.userData)) L.push(`| ${k} | ${v.count} | ${v.shapes.join(' / ').slice(0, 300)} | ${v.maxBytes} |`); + const s = r.source.core; + L.push('', '## Source (core src/)', ''); + L.push('- objectParameters sent: ' + Object.keys(s.parameters).join(', ')); + L.push('- objectParameters applied: ' + Object.keys(s.appliedParameters).join(', ')); + L.push('- object classes constructed: ' + Object.keys(s.objects).join(', ')); + L.push('- material classes constructed: ' + Object.keys(s.materials).join(', ')); + L.push('- geometry classes constructed: ' + Object.keys(s.geometries).join(', ')); + L.push('- texture classes constructed: ' + Object.keys(s.textures).join(', ')); + L.push('- userData keys referenced: ' + Object.keys(s.userData).length + ' — ' + Object.keys(s.userData).join(', ')); + L.push('', '### Generic loader call sites (core)', ''); + for (const [k, v] of Object.entries(s.loaderCalls)) L.push(`- \`${k}\`: ${v.join(', ')}`); + if (r.source.modules) { + const m = r.source.modules; + L.push('', '## Source (modules repo)', ''); + L.push('- objectParameters sent: ' + Object.keys(m.parameters).join(', ')); + L.push('- object classes: ' + Object.keys(m.objects).join(', ')); + L.push('- material classes: ' + Object.keys(m.materials).join(', ')); + L.push('- geometry classes: ' + Object.keys(m.geometries).join(', ')); + L.push('- userData keys: ' + Object.keys(m.userData).length + ' — ' + Object.keys(m.userData).join(', ')); + } + if (c.errors.length) L.push('', '## Errors', '', ...c.errors.map((/** @type {string} */ e) => '- ' + e)); + return L.join('\n') + '\n'; +} + +if (require.main === module) main(); +module.exports = { scanSource, newContent, walkElement, findElements, imageForm, shapeOf }; diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index 2d20d6fc7..860dd6943 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -949,7 +949,7 @@ "exclude": { "test data of ~1.3 GB fetched on demand (scripts/fetch-wild-assets.cjs), ~1 h on the pc: run it when importing or Replace changes; SKIPs without the cache": ["import-wild"], "a VR (IWER) editing suite not in the 1.43 integrator battery: triage owed (test-health-1)": ["music-vr-patch","novr-inset","play-here-iwer","vr-ai-panel","vr-ai-voice","vr-bounded-teleport","vr-box-select","vr-chat","vr-collider-edit","vr-controller-handedness","vr-create-face","vr-create-face-facing","vr-edit-caps","vr-edit-menu","vr-editmenu-reposition","vr-editmenu-tabs","vr-editmode-persist","vr-face-correctness","vr-face-edit","vr-face-live-adjust","vr-face-manip","vr-face-move-fix","vr-face-polygon","vr-face-weld","vr-grips-by-mode","vr-helper-eyes","vr-hz-pinch","vr-keyboard","vr-keyboard-search","vr-laser-panels","vr-mesh-undo","vr-multi-prefab","vr-multiselect-ring","vr-nav-suppression","vr-objects-groups","vr-objects-panel","vr-palette","vr-panel-sharpness-all","vr-panels-on-top","vr-peer-approve","vr-peer-hands","vr-peer-hands-net","vr-ping-accuracy","vr-prefabs","vr-props-panel","vr-ptt","vr-radial-stick","vr-ray-hover","vr-reticle-v2","vr-sleeve","vr-snap-menu","vr-source-rebind","vr-stats","vr-stretch","vr-stretch-fix","vr-stretch-sliders","vr-sweep","vr-teleport-handedness","vr-teleport-toggle","vr-two-hand-grab","vr-ungroup","vr-vertex-edit","vr-vertex-handle-bounds","vr-vertex-hold","vr-vertex-polish","vr-vertex-ray","vr-vertex-trigger","vr-window-grab","vr-world-grab","vr-world-reel","vr-worldgrab-presence"], - "a docs / screenshot generator, not a gate": ["flow-node-docs","shader-node-docs","vr-eye-shots"], + "a docs / screenshot generator, not a gate": ["flow-node-docs","shader-node-docs","vr-eye-shots","schema-capture"], "a frame-rate / timing measurement: run by hand on an --exclusive slot, never in a battery half": ["avatar-knocked-perf","avatars-budget","import-budget","mesh-budget","perf-detailed","perf-live","perf-moment","perf-stale-modules","perf-stutter","place-drag-perf","vr-brushes-perf"], "not in the 1.43 integrator battery (listA5/listB5): kept for focused runs, triage owed (test-health-1)": ["add-menu","add-menu-cursor","advanced-mode","ai-assistant","ai-local-model","ai-presets","ai-reference","anim-pack-refs","animated-base","animation-autokey","animation-autokey-material","animation-bake","animation-look-channels","animation-loop-pause","animation-markers","animation-marquee","animation-node","animation-onion","animation-persist","animation-relative-motion","annotation-anchor","approval-timeout","asset-cache-opfs","audio-devices","audio-engine","audio-harness","audio-pack","audio-patch","audio-sdk","author-kit","avatar-fly","avatar-v2","button","camera-bookmarks","camera-clipping","camera-looks","camera-objects","camera-preview-control","checkpoints","clear-scene","code-workspace","collectibles-v2","collision-groups","colocation-anchor","colocation-ar-mode","colocation-calibrate","colocation-core","colocation-nudge","colocation-presence","connect-pin-slide-43","content-index-cache","contest-starters","controls-state","controls-transform-tint","convert-to-mesh","deleted-folders","deselect-unlock","desktop-face-gizmo","dial-metadata","drawing","dungeon","dungeon-play","duplicate-after-create","duplicate-parity","edit-overlay-gaps","editmesh-focus","editmesh-mode-memory","env-fog-sticks","environment-v2","explorer-columns","explorer-duplicate","explorer-duplicate-scene-folder","explorer-expandable","explorer-inline-input","explorer-menu-icons","explorer-mount-open-peers","explorer-mounts","explorer-mounts-edit","explorer-rename","explorer-tree-floor","faces-nested-toolbar","faces-toolbar","far-clipping","fast-channel-fallback","fast-channel-loss","fb-input","fbx-mtl-import","file-windows-esc","flow-adjustable-params","flow-audit","flow-background","flow-contact-filter","flow-curvy-edges","flow-customnode-io","flow-explorer-floating","flow-input-moddefs","flow-mouse-bindings","flow-new-nodes","flow-node-duplicate","flow-node-undocked","flow-nodes-extended","flow-object-embed","flow-palette","flow-palette-reflow","flow-particle","flow-properties","flow-spawner","flow-window-mobile","gallery-submit","game-editor-flow","game-effects","game-feel-nodes","game-haptics","game-loop","game-loop-v2","game-loop-v3","game-presence","geometry-lock","geometry-params","gizmo-orbit-leak","gizmo-recall","grab-key","graph-games-lint","graph-tidy","graph-tree","grid-snapping","hand-models","hud-actions","hud-content","hud-editor-tools","hud-inputs","hud-nodes","hud-visibility","import-duplicates","import-reduce-gate","kit-entities","launch-inbox","level-templates","level-templates-33","light-aim","light-catalog","llms-txt","load-primitives-first-36","load-supersede-36","material-sharing","material-types","mesh-adjust","mesh-advanced-ops","mesh-bevel","mesh-bridge-normals","mesh-cleanup","mesh-edge-bevel","mesh-edge-gizmo","mesh-edge-mode","mesh-edge-vertex-ops","mesh-edit-display","mesh-edit-materials","mesh-edit-popup","mesh-falloff-rotate-scale","mesh-falloff-sync","mesh-fixes-round2","mesh-fixes-round3","mesh-generation","mesh-gizmo-modes","mesh-grab-uv","mesh-inset-move","mesh-loop-cut","mesh-loop-hardening","mesh-loop-select","mesh-multishell-ops","mesh-ops","mesh-ops-p5b","mesh-pivot-gizmo","mesh-proportional","mesh-quad-select","mesh-selection-undo","mesh-session-undo","mesh-symmetrize","mesh-symmetry-live","mesh-uv-preserve","mesh-vertex-bevel","mesh-vertex-slide","model-preview-controls","module-lifecycle-repo","module-node-io","module-pointer-seam","module-sdk","module-sdk-world","module-seed-graph","module-storage","module-update-on-load","module-world-root","modules-discoverability","multi-select","multiselect-menu","music-clock","music-flow-nodes","music-prefabs","music-sampling","music-template","music-toolbox","net-backoff","net-locks","network-quality","node-editor-open-view","node-search","node-ux-library","note-replies","object-list-drag","object-list-raise","object-list-size","object-origin","object-properties","object-search","object-sync","objectlist-hidden-icon","objectlist-search","open-core-m1","origin-rows","outline-scene-swap","pack-behavior","pack-undo","packs-explorer","palette","palette-groups","particles","path-node","peer-ice-config","peer-variables","pick-tiny-objects","ping","ping-highlights","ping-v2","placeholders-peers","play-free-cursor","play-here","play-reach-jump","pong","prefab-explorer","prefab-library","prefab-propagation","preview-animation","private-scene","problem-report","project-file","project-manifest","race-touch-vr","recording","resync-converge","roadmap-13-notifications-notes","roadmap-13-viewport-camera","save-as-formats","scene-adopt","scene-assets","scene-default-material","scene-duplicate","scene-folders","scene-identity","scene-isolation","scene-levels","scene-load","scene-music","scene-poke","scene-presence","scene-rename","scene-rooms","scene-rooms-matrix","scene-rooms-truth","scene-stress","scene-switch","scene-switch-clear","scene-switch-games","script-guard","sdk-game-seams","sdk-input","sdk-pointer-ray","select-through","selection-extras","session-clock","session-download-formats","session-scene-data","session-zip","sessions-open","sessions-open-peers","sessions-packs","shader-editor","shader-inspector","shader-module-flow","shader-persist","shader-scene-default","shadows","share-gate-defer","share-stash","shared-library","shared-library-copies","sidebar-toggle","small-fixes-37","snap-advanced","spatial-voice","spline-tool","stars-clap","start-view","start-view-hold","stroke-streaming","switcher-mux","template-modules","terrain","topo-channel","towers-levels","trackpad-nav","trackpad-pinch","ui-fixes-15b","underlay","uv-dense","uv-live-faces","uv-materials","uv-pen-pressure","uv-slots","uv-slots-persist","uv-smart-unwrap","uv-target","uv-texture-params","uv-texture-tools","uv-transform","uv-unwrap","uv-unwrap-module","value-graph","variadic-nodes","version","vertices-create-face","view-mode","viewport-selection","viewport-tools-menu","walker-sensors","watch-look","waypoint-viz","wheel-classify"], "the 38 R1 behaviour lock: npm run e2e -- @lock (tests/e2e/lock.json), not the release battery": ["dock-tabs-coexist","flow-dock-mixed","flow-dock-tabs","flow-explorer-dock","inspector-multi-edit","node-drag-fields","units"] diff --git a/tests/e2e/schema-capture.test.cjs b/tests/e2e/schema-capture.test.cjs new file mode 100644 index 000000000..c938fe3ab --- /dev/null +++ b/tests/e2e/schema-capture.test.cjs @@ -0,0 +1,185 @@ +// 101-schema step 1 — CAPTURE WHAT THIS BUILD SENDS AND SAVES, AS FIXTURES. +// +// The scenes repo and the templates cover the content people published; they do not cover every +// object kind the Add menu makes, every material type the Inspector switches to, a textured or a +// multi-slot material, or the shape each of those takes on the WIRE. This suite builds all of them +// in one page and writes three files the schema's "0 drops on real content" proof runs over: +// +// /capture.tpscene — the scene saved exactly as the user's Save writes it +// /wire.json — every `object` / `objectParameters` message the edits and a full +// handshake sync (sendObjects) put on the wire, in order +// /prefab.json — a prefab element (the library snapshot shape) +// +// Run it on the BASE tag (v1.65.0) to make the fixtures; run it on the lane later to diff. +// It needs no second peer: the outgoing messages are captured at PeerConnection.broadcast. +// +// Run: APP_URL=https://theprototype.app:7022/ SCHEMA_CAPTURE_OUT=test-results/101-schema/capture node tests/e2e/schema-capture.test.cjs +const fs = require('fs'); +const path = require('path'); +const h = require('./helpers.cjs'); + +const OUT = path.resolve(process.env.SCHEMA_CAPTURE_OUT || path.join(__dirname, '../../test-results/101-schema/capture')); + +h.run(async () => { + fs.mkdirSync(OUT, { recursive: true }); + const browser = await h.launch(); + const peer = await h.setupPage(browser, 'capture'); + const page = peer.page; + await page.waitForFunction(() => !!window.__stores?.commandsHandler && !!window.__stores?.sessions && !!window.__stores?.objectsGroup, null, { + timeout: 60000 + }); + + // ---- the wire tap: every outgoing message, JSON-cloned at the moment it is sent ---------------- + await page.evaluate(() => { + const s = window.__stores; + let pc = null; + s.peers.subscribe((v) => (pc = v))(); + window.__capture = []; + const orig = pc.broadcast.bind(pc); + window.__captureOne = (payload) => { + try { + const t = payload?.type; + if (t === 'object' || t === 'objectParameters' || t === 'group' || t === 'duplicate' || t === 'batch' || t === 'objectfile') { + const el = payload.element; + // a GLTF binary element is bytes: keep its size, not the bytes + const clone = + el instanceof ArrayBuffer || ArrayBuffer.isView(el) + ? { ...payload, element: { __bytes: el.byteLength } } + : JSON.parse(JSON.stringify(payload, (k, v) => (v instanceof ArrayBuffer || ArrayBuffer.isView(v) ? { __bytes: v.byteLength } : v))); + window.__capture.push(clone); + } + } catch (e) { + window.__capture.push({ __error: String(e) }); + } + }; + pc.broadcast = (payload, opts) => { + window.__captureOne(payload); + return orig(payload, opts); + }; + }); + + // ---- every Add-menu item ------------------------------------------------------------------------- + const created = await page.evaluate(async () => { + const s = window.__stores; + const { primitivesCatalog } = await import('/src/lib/primitivesCatalog.js').catch(() => ({ primitivesCatalog: null })); + const catalog = primitivesCatalog ?? s.primitivesCatalog?.primitivesCatalog; + const cmds = catalog ? catalog.flatMap((g) => g.items.map((i) => i.command)) : []; + let i = 0; + for (const c of cmds) { + s.commandsHandler.sceneCommand(c); + i++; + await new Promise((r) => setTimeout(r, 60)); + } + return { count: i, cmds }; + }); + h.check(created.count >= 40, `premise: the Add catalog was walked (${created.count} commands)`); + await page.waitForTimeout(2500); + + // ---- materials: every switchable type, a textured map, a map slot, a tiling, params, slots ---- + const matInfo = await page.evaluate(async () => { + const s = window.__stores; + let group = null; + s.objectsGroup.subscribe((g) => (group = g))(); + const meshes = group.children.filter((o) => o.isMesh && !Array.isArray(o.material)); + const types = ['MeshBasicMaterial', 'MeshStandardMaterial', 'MeshPhysicalMaterial', 'MeshPhongMaterial', 'MeshLambertMaterial', 'MeshToonMaterial', 'MeshMatcapMaterial', 'MeshNormalMaterial', 'MeshDepthMaterial', 'ShadowMaterial']; + const M = s.materialsHandler; + const done = []; + types.forEach((t, k) => { + const m = meshes[k]; + if (!m) return; + M.switchMaterialType(m.uuid, t, true); + done.push([m.name, t]); + }); + // a small PNG as a data URL and as a File + const cv = document.createElement('canvas'); + cv.width = cv.height = 16; + const g2 = cv.getContext('2d'); + g2.fillStyle = '#c84'; + g2.fillRect(0, 0, 16, 16); + g2.fillStyle = '#48c'; + g2.fillRect(0, 0, 8, 8); + const url = cv.toDataURL('image/png'); + const blob = await new Promise((r) => cv.toBlob(r, 'image/png')); + const file = new File([blob], 'checker_normal.png', { type: 'image/png' }); + const textured = meshes[10] ?? meshes[0]; + M.applyMap(textured, url, 0, null); + // the replicated map write (what the Inspector does) + if (s.peers) { + let pc = null; + s.peers.subscribe((v) => (pc = v))(); + pc.send({ type: 'objectParameters', parameter: 'map', uuid: textured.uuid, map: url }); + } + const slotted = meshes[11] ?? meshes[1]; + try { + await s.materialMaps.setObjectsMapSlot([slotted.uuid], 'normalMap', file, 0); + s.materialMaps.setTexTransform(slotted.uuid, { repeat: [2, 3], offset: [0.1, 0.2], rotation: 0.3 }, 0); + s.materialMaps.setNormalStrength(slotted.uuid, 1.5, 0); + } catch (e) { + done.push(['mapSlot error', String(e)]); + } + const tuned = meshes[12] ?? meshes[2]; + for (const [k, v] of [['roughness', 0.3], ['metalness', 0.7], ['opacity', 0.6], ['transparent', true], ['emissive', '#223344'], ['side', 'double'], ['wireframe', false]]) + M.setMaterialParam(tuned.uuid, k, v, true); + const multi = meshes.find((o) => o.geometry?.type === 'BoxGeometry') ?? meshes[3]; + M.addMaterialSlot(multi.uuid); + return { done, textured: textured.name, slotted: slotted.name, multi: multi.name }; + }); + h.check(matInfo.done.length >= 10, `premise: material types switched (${matInfo.done.length})`); + await page.waitForTimeout(1500); + + // ---- a group, a prefab of it, and the full handshake sync --------------------------------------- + const syncInfo = await page.evaluate(async () => { + const s = window.__stores; + let group = null; + s.objectsGroup.subscribe((g) => (group = g))(); + const two = group.children.filter((o) => o.isMesh && o.geometry?.type === 'SphereGeometry').concat(group.children.filter((o) => o.isMesh && o.geometry?.type === 'ConeGeometry')).slice(0, 2); + let groupedName = null; + try { + s.selectedObjects.set(two); + s.objectActions.groupSelection(); + await new Promise((r) => setTimeout(r, 300)); + groupedName = group.children.find((o) => o.type === 'Group')?.name ?? null; + } catch { + /* grouping is a nice-to-have here */ + } + // prefab snapshot shape: what the library stores is `toJSON()` of the object + const first = group.children.find((o) => o.type === 'Group') ?? group.children.find((o) => o.isMesh); + const prefab = first.toJSON(); + const before = window.__capture.length; + // the handshake reply goes to ONE conn (sendObjects resolves `connections[peerId]` after 500 ms), not + // through broadcast: a fake open conn records it, installed only for this send + let pc = null; + s.peers.subscribe((v) => (pc = v))(); + const fake = { open: true, peer: 'capture-peer', send: (m) => window.__captureOne(m) }; + pc.connections['capture-peer'] = fake; + s.commandsHandler.sendObjects('capture-peer'); + await new Promise((r) => setTimeout(r, 8000)); + delete pc.connections['capture-peer']; + return { groupedName, prefab, synced: window.__capture.length - before, objects: group.children.length }; + }); + h.check(syncInfo.synced > 0, `premise: the handshake sync emitted messages (${syncInfo.synced})`); + fs.writeFileSync(path.join(OUT, 'prefab.json'), JSON.stringify(syncInfo.prefab)); + + // ---- the save ------------------------------------------------------------------------------------ + const zipB64 = await page.evaluate(async () => { + const s = window.__stores; + const payload = await s.sessions.buildSessionPayload('schema-capture'); + const bytes = await s.sessions.exportSessionZip(payload, { assets: true, packs: false, flow: true }); + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(await new Response(bytes).arrayBuffer()); + let bin = ''; + for (let i = 0; i < u8.length; i += 0x8000) bin += String.fromCharCode.apply(null, Array.from(u8.subarray(i, i + 0x8000))); + return btoa(bin); + }); + fs.writeFileSync(path.join(OUT, 'capture.tpscene'), Buffer.from(zipB64, 'base64')); + + const wire = await page.evaluate(() => window.__capture); + fs.writeFileSync(path.join(OUT, 'wire.json'), JSON.stringify(wire)); + const kinds = {}; + for (const m of wire) { + const k = m.type + (m.parameter ? ':' + m.parameter : ''); + kinds[k] = (kinds[k] || 0) + 1; + } + console.log('captured: ' + JSON.stringify({ objects: syncInfo.objects, created: created.count, materials: matInfo, kinds })); + h.check(wire.length > 0 && !wire.some((m) => m.__error), `captured ${wire.length} wire messages, none failed to clone`); + await h.finish(browser); +}); diff --git a/tests/fixtures/schema-1.65/capture.tpscene b/tests/fixtures/schema-1.65/capture.tpscene new file mode 100644 index 0000000000000000000000000000000000000000..68d53b7579f174266143b669adfff7cbdd188ee7 GIT binary patch literal 98863 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⩔N@G~k29T$JqGdbSNpppX46}4 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%0AB)k6fokOURRY$Oto$Nein@tT87o5!R7HtGWfOs0 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 iux@mn=++^0<^s*buspz Date: Wed, 11 Nov 2026 10:26:36 +0200 Subject: [PATCH 2/7] 101-schema F7: remove the unused importModuleText export userModules.importModuleText had no callers in core, the tests or the modules repo; behaviours compile through behaviours/source.js compileBehaviour with the caller's importer. CLAUDE.md now names that loader. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/lib/userModules.js | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6f6b5f452..b2ea75413 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2631,7 +2631,7 @@ sub-entry with its line: grep the index for a word, then read this file from tha it hears one, is alone, or `GRACE_MS` (2 s) after its last connection change — under the smallest-id rule a joiner IS the authority on connect, and its initial state would overwrite the game. LOADING: `analyze` (acorn; the LINT bans Math.random/Date/performance.now/storage/DOM/globalThis/network/eval/bare timers/import — an error - stops the load on EVERY peer), then the MODULE LOADER (`userModules.importModuleText`, a fresh blob import) with + stops the load on EVERY peer), then `behaviours/source.js compileBehaviour(source, importer)` (the caller's importer; `dataUrlImporter` in node/tests) with `source.js`'s one-line prologue ON line 1 (`behaviour, kit, dist, clamp, lerp`; `kit` is a proxy bound to the behaviour's traced kit face when it starts) and the 27-D loop guard (counter reset per dispatch by an appended epilogue line). T2: one lifecycle module per behaviour, `behaviour:`, with ONE kit face for its whole diff --git a/src/lib/userModules.js b/src/lib/userModules.js index 026b3f406..f6bb5e9b6 100644 --- a/src/lib/userModules.js +++ b/src/lib/userModules.js @@ -130,12 +130,6 @@ async function importBlob(source, scope) { } } -/** 34 R3: evaluate ES module TEXT through this loader (a behaviour file, behaviours/app.js) — - * the same fresh-blob import an installed module's entry takes. @param {string} source */ -export function importModuleText(source) { - return importBlob(source, null); -} - /** Import a record's entry file as a module object and validate its shape. * A fresh blob URL per call, so re-imports always evaluate fresh code (A2). * 34 R6: evaluated inside a lifecycle SCOPE (sdk/moduleScope.js) — its bare timer names are From e9f4b9a9b2308fe50df685bb8552bfb4fd9401fa Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 15:07:38 +0200 Subject: [PATCH 3/7] 101-schema step 2: object and material schema (src/lib/sceneSchema.js) One pure module, built on loadGuardCore's rules, validates every ObjectLoader JSON and every materials payload in place: object, material and geometry types from fixed lists (an unknown material type becomes the standard fallback, an unknown object type an Object3D, an unknown geometry type an empty buffer geometry), per-type field tables with value rules (unknown or invalid fields are stripped), embedded images under a size cap, userData keys with their inventoried kinds and plain JSON under a size cap, and depth / list caps. Every change is a dropped row with its path and reason. tests/unit/sceneSchema.test.js: every template, every test fixture (the 1.65 capture included) and, with SCENES_DIR, every scene of the scenes repo round-trips with nothing dropped and byte-identical JSON; a set of out-of-schema payloads is cleaned, counted and still parses. Co-Authored-By: Claude Opus 5.5 --- src/lib/sceneSchema.js | 698 +++++++++++++++++++++++++++++++++ tests/unit/sceneSchema.test.js | 270 +++++++++++++ 2 files changed, 968 insertions(+) create mode 100644 src/lib/sceneSchema.js create mode 100644 tests/unit/sceneSchema.test.js diff --git a/src/lib/sceneSchema.js b/src/lib/sceneSchema.js new file mode 100644 index 000000000..c1c90cfbb --- /dev/null +++ b/src/lib/sceneSchema.js @@ -0,0 +1,698 @@ +// 101 F2 — THE SCHEMA FOR OBJECTS AND MATERIALS. Objects and materials are validated on receive and on load. +// +// Every ObjectLoader JSON this app takes from a peer, a file or its own stores passes through +// `validateObjectJson` (via loadGuard.js) before three sees it; a `materials` payload through +// `validateMaterialSet`. The minimum rules every entry shares live in loadGuardCore.js (standard +// material types, embedded images, size caps); this module is the full allowlist on top: +// +// · OBJECT, MATERIAL and GEOMETRY TYPES come from fixed lists. An unknown object type becomes an +// `Object3D` (children kept), an unknown material type the standard fallback material, an +// unknown geometry type an empty BufferGeometry. None of that is silent: every change is a +// `dropped` row with its path and reason, counted by loadGuard (noteWireError + one toast). +// · FIELDS: each type has a table of the fields three writes for it, each with a value rule (a +// finite number, a colour, a uuid, an array of N numbers, ...). An unknown field is stripped; +// a field whose value breaks its rule is stripped too (three then uses its own default). +// · IMAGES are embedded only (loadGuardCore) and each one is capped at MAX_IMAGE_CHARS. +// · userData: the keys the inventory found (scripts/schema-inventory.cjs, the fixture list in +// tests/fixtures/schema-1.65/inventory.json) keep a per-key JSON kind; every key must be plain +// JSON (no `__proto__` / `constructor` / `prototype` keys, bounded depth) under a size cap. +// · CAPS: tree depth, list lengths, and loadGuardCore's number / vertex / node caps. +// +// The input is cleaned IN PLACE (a scene's objects are megabytes; a copy per load would double the +// cost of every join), and the same reference comes back as `json`. Pure: imports only the pure +// loadGuardCore, never three, so the unit test needs no GL context. +// +// EXTENDING IT (a module or a later release that saves a new field): add the field to the type's +// table below with its value rule, and a fixture to tests/unit/sceneSchema.test.js. A new userData +// key needs nothing (plain JSON under the cap always passes); give it a kind in USERDATA_KINDS when +// a wrong kind must not reach its reader. See OPEN-CORE.md "Object schema". + +import { guardObjectJson, guardMaterialSet, measureObjectJson, isStandardMaterial, fallbackMaterial, STANDARD_MATERIAL_TYPES, isEmbeddedUrl, MAX_NODES, MAX_NUMBERS, MAX_GENERATED_VERTICES } from './loadGuardCore.js'; + +/** @typedef {{path: string, reason: string}} Dropped */ +/** @typedef {{ok: boolean, json: any, dropped: Dropped[], reason?: string, materials: number, images: number, textures: number}} SchemaResult */ + +/** Deepest object tree accepted (the shipped scenes are 3 deep; an imported model rarely passes 30). */ +export const MAX_DEPTH = 256; +/** Longest resource list (geometries, materials, textures, images) one object may carry. */ +export const MAX_LIST = 100_000; +/** Characters in one embedded image (the map-slot cap, materialParamRules MAP_SLOT_MAX_URL). */ +export const MAX_IMAGE_CHARS = 24 * 1024 * 1024; +/** Characters of JSON one userData key may hold, and one node's whole userData. */ +export const MAX_USERDATA_KEY_CHARS = 2 * 1024 * 1024; +export const MAX_USERDATA_CHARS = 4 * 1024 * 1024; +/** Nesting depth of a plain-JSON value (userData, a curve, an option block). */ +export const MAX_JSON_DEPTH = 64; +/** Longest string field (names; a long name is not a reason to drop an object, so it is cut). */ +export const MAX_NAME = 1024; +/** How many rows a result lists before it only counts (a hostile object could produce millions). */ +const MAX_ROWS = 200; + +// ---- value rules ------------------------------------------------------------------------------ + +/** @typedef {(v: any) => boolean} Rule */ +/** @type {Rule} */ const num = (v) => typeof v === 'number' && Number.isFinite(v); +/** @type {Rule} */ const int = (v) => Number.isInteger(v); +/** @type {Rule} */ const nonNegInt = (v) => Number.isInteger(v) && v >= 0; +/** @type {Rule} */ const bool = (v) => typeof v === 'boolean'; +/** @type {Rule} */ const str = (v) => typeof v === 'string' && v.length <= 4096; +/** @type {Rule} */ const uuid = (v) => typeof v === 'string' && v.length > 0 && v.length <= 128; +/** @type {Rule} */ const color = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff; +/** @param {number} n @returns {Rule} */ +const nums = (n) => (v) => Array.isArray(v) && (n < 0 || v.length === n) && allFinite(v); +/** @type {Rule} */ const anyNums = nums(-1); +/** an Euler as three writes it: [x, y, z, order] @type {Rule} */ +const euler = (v) => Array.isArray(v) && v.length === 4 && allFinite(v.slice(0, 3)) && typeof v[3] === 'string' && /^[XYZ]{3}$/.test(v[3]); +/** a material's map slot / a light's map: a texture uuid @type {Rule} */ +const texRef = uuid; +/** small plain JSON (a view block, curve, option block, morph dictionary) @type {Rule} */ +const plain = (v) => isPlainJson(v, 0) && jsonChars(v) <= 1024 * 1024; +/** @type {Rule} */ const uuidList = (v) => Array.isArray(v) && v.length <= MAX_LIST && v.every(uuid); +/** a mesh's material: one uuid or an array of them (a multi-slot mesh) @type {Rule} */ +const materialRef = (v) => uuid(v) || (Array.isArray(v) && v.length > 0 && v.length <= 1024 && v.every(uuid)); +/** a BufferAttribute JSON as InstancedMesh writes it @type {Rule} */ +const attributeJson = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.type === 'string' && v.type in TYPED_ARRAYS && Array.isArray(v.array) && allFinite(v.array); +/** @type {Rule} */ const sphere = (v) => !!v && typeof v === 'object' && nums(3)(v.center) && num(v.radius); +/** @type {Rule} */ const box = (v) => !!v && typeof v === 'object' && nums(3)(v.min) && nums(3)(v.max); +/** LOD levels: [{object, distance, hysteresis}] @type {Rule} */ +const lodLevels = (v) => Array.isArray(v) && v.length <= 64 && v.every((l) => l && typeof l === 'object' && uuid(l.object) && num(l.distance) && (l.hysteresis === undefined || num(l.hysteresis))); + +/** every element a finite number (a loop, not `every`: geometry arrays are millions long) @param {any[]} a */ +function allFinite(a) { + for (let i = 0; i < a.length; i++) { + const x = a[i]; + if (typeof x !== 'number' || x !== x || x === Infinity || x === -Infinity) return false; + } + return true; +} + +/** three's BufferGeometryLoader builds `new TYPED_ARRAYS[type](array)`; anything else throws inside the parse. */ +const TYPED_ARRAYS = Object.freeze( + Object.assign(Object.create(null), { + Int8Array: 1, Uint8Array: 1, Uint8ClampedArray: 1, Int16Array: 1, Uint16Array: 1, Int32Array: 1, Uint32Array: 1, Float32Array: 1, Float64Array: 1 + }) +); + +const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** plain JSON: null, booleans, finite numbers, strings, arrays and objects of them, no prototype keys, + * bounded depth. Walked without recursion past MAX_JSON_DEPTH. @param {any} v @param {number} depth */ +export function isPlainJson(v, depth) { + if (v === null || typeof v === 'boolean' || typeof v === 'string') return true; + if (typeof v === 'number') return Number.isFinite(v); + if (typeof v !== 'object' || depth >= MAX_JSON_DEPTH) return false; + if (Array.isArray(v)) { + for (let i = 0; i < v.length; i++) { + const x = v[i]; + // the fast path for number arrays (collider hulls, stored face partitions) + if (typeof x === 'number') { + if (!Number.isFinite(x)) return false; + } else if (!isPlainJson(x, depth + 1)) return false; + } + return true; + } + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) return false; + for (const k of Object.keys(v)) { + if (FORBIDDEN_KEYS.has(k)) return false; + if (!isPlainJson(v[k], depth + 1)) return false; + } + return true; +} + +/** @param {any} v */ +function jsonChars(v) { + try { + return JSON.stringify(v)?.length ?? 0; + } catch { + return Infinity; + } +} + +// ---- the tables ------------------------------------------------------------------------------- + +/** Fields every Object3D carries (Object3D.toJSON, ObjectLoader.parseObject). */ +const OBJECT_BASE = { + uuid, + type: str, + name: str, + castShadow: bool, + receiveShadow: bool, + visible: bool, + frustumCulled: bool, + renderOrder: num, + layers: nonNegInt, + matrix: nums(16), + matrixAutoUpdate: bool, + up: nums(3), + position: nums(3), + rotation: (/** @type {any} */ v) => nums(3)(v) || euler(v), + quaternion: nums(4), + scale: nums(3), + pivot: nums(3), + static: bool, + animations: uuidList, + userData: () => true, // checked by checkUserData + children: () => true // walked +}; +const DRAWABLE = { geometry: uuid, material: materialRef }; +const MORPHS = { morphTargetDictionary: plain, morphTargetInfluences: anyNums }; +const LIGHT = { color, intensity: num }; +const SHADOWED = { shadow: () => true }; // checked by checkShadow +const PERSPECTIVE = { fov: num, zoom: num, near: num, far: num, focus: num, aspect: num, view: plain, filmGauge: num, filmOffset: num }; +const ORTHOGRAPHIC = { left: num, right: num, top: num, bottom: num, zoom: num, near: num, far: num, view: plain }; + +/** The object types this app writes, each with the fields three writes for it. Scene and BatchedMesh + * are deliberately absent (nothing here saves a Scene as an object; loadGuardCore refuses BatchedMesh). */ +export const OBJECT_FIELDS = /** @type {Record>} */ ({ + Object3D: OBJECT_BASE, + Group: OBJECT_BASE, + Bone: OBJECT_BASE, + Mesh: { ...OBJECT_BASE, ...DRAWABLE, ...MORPHS }, + SkinnedMesh: { ...OBJECT_BASE, ...DRAWABLE, ...MORPHS, bindMode: str, bindMatrix: nums(16), skeleton: uuid }, + InstancedMesh: { ...OBJECT_BASE, ...DRAWABLE, ...MORPHS, count: nonNegInt, instanceMatrix: attributeJson, instanceColor: attributeJson, boundingSphere: sphere, boundingBox: box }, + Line: { ...OBJECT_BASE, ...DRAWABLE }, + LineLoop: { ...OBJECT_BASE, ...DRAWABLE }, + LineSegments: { ...OBJECT_BASE, ...DRAWABLE }, + Points: { ...OBJECT_BASE, ...DRAWABLE }, + Sprite: { ...OBJECT_BASE, material: uuid }, + LOD: { ...OBJECT_BASE, levels: lodLevels, autoUpdate: bool }, + PerspectiveCamera: { ...OBJECT_BASE, ...PERSPECTIVE }, + OrthographicCamera: { ...OBJECT_BASE, ...ORTHOGRAPHIC }, + AmbientLight: { ...OBJECT_BASE, ...LIGHT }, + HemisphereLight: { ...OBJECT_BASE, ...LIGHT, groundColor: color }, + DirectionalLight: { ...OBJECT_BASE, ...LIGHT, ...SHADOWED, target: uuid }, + PointLight: { ...OBJECT_BASE, ...LIGHT, ...SHADOWED, distance: num, decay: num }, + SpotLight: { ...OBJECT_BASE, ...LIGHT, ...SHADOWED, target: uuid, distance: num, decay: num, angle: num, penumbra: num, map: texRef }, + RectAreaLight: { ...OBJECT_BASE, ...LIGHT, width: num, height: num }, + LightProbe: { ...OBJECT_BASE, ...LIGHT, sh: nums(27) } +}); + +/** A light's shadow block (LightShadow.toJSON). Its camera is checked as a camera object. */ +const SHADOW_FIELDS = { intensity: num, bias: num, normalBias: num, radius: num, blurSamples: nonNegInt, focus: num, aspect: num, mapSize: nums(2), camera: () => true }; + +/** Material fields three writes (Material.toJSON, every subclass), each with its rule. Map slots are + * texture uuids; program fields are absent on purpose (a material carrying them takes the fallback). */ +const MATERIAL_FIELDS = /** @type {Record} */ ({ + uuid, type: str, name: str, userData: () => true, metadata: plain, + color, emissive: color, specular: color, sheenColor: color, attenuationColor: color, specularColor: color, + roughness: num, metalness: num, sheen: num, sheenRoughness: num, emissiveIntensity: num, specularIntensity: num, + clearcoat: num, clearcoatRoughness: num, dispersion: num, iridescence: num, iridescenceIOR: num, iridescenceThicknessRange: nums(2), + anisotropy: num, anisotropyRotation: num, shininess: num, reflectivity: num, refractionRatio: num, combine: int, + transmission: num, thickness: num, attenuationDistance: (/** @type {any} */ v) => num(v) || v === null, + lightMapIntensity: num, aoMapIntensity: num, bumpScale: num, normalMapType: int, normalScale: nums(2), clearcoatNormalScale: nums(2), + displacementScale: num, displacementBias: num, envMapRotation: euler, envMapIntensity: num, retroreflectivity: num, + map: texRef, matcap: texRef, alphaMap: texRef, lightMap: texRef, aoMap: texRef, bumpMap: texRef, normalMap: texRef, displacementMap: texRef, + roughnessMap: texRef, metalnessMap: texRef, emissiveMap: texRef, specularMap: texRef, specularIntensityMap: texRef, specularColorMap: texRef, + envMap: texRef, gradientMap: texRef, clearcoatMap: texRef, clearcoatRoughnessMap: texRef, clearcoatNormalMap: texRef, iridescenceMap: texRef, + iridescenceThicknessMap: texRef, anisotropyMap: texRef, sheenColorMap: texRef, sheenRoughnessMap: texRef, transmissionMap: texRef, thicknessMap: texRef, + size: num, sizeAttenuation: bool, rotation: num, scale: num, dashSize: num, gapSize: num, linewidth: num, linecap: str, linejoin: str, + depthPacking: int, blending: int, side: (/** @type {any} */ v) => v === 0 || v === 1 || v === 2, shadowSide: (/** @type {any} */ v) => v === null || v === 0 || v === 1 || v === 2, + vertexColors: bool, opacity: num, transparent: bool, alphaHash: bool, alphaTest: num, alphaToCoverage: bool, premultipliedAlpha: bool, + forceSinglePass: bool, allowOverride: bool, visible: bool, toneMapped: bool, fog: bool, flatShading: bool, + wireframe: bool, wireframeLinewidth: num, wireframeLinecap: str, wireframeLinejoin: str, + blendSrc: int, blendDst: int, blendEquation: int, blendSrcAlpha: (/** @type {any} */ v) => v === null || int(v), blendDstAlpha: (/** @type {any} */ v) => v === null || int(v), + blendEquationAlpha: (/** @type {any} */ v) => v === null || int(v), blendColor: color, blendAlpha: num, + depthFunc: int, depthTest: bool, depthWrite: bool, colorWrite: bool, + stencilWrite: bool, stencilWriteMask: int, stencilFunc: int, stencilRef: int, stencilFuncMask: int, stencilFail: int, stencilZFail: int, stencilZPass: int, + polygonOffset: bool, polygonOffsetFactor: num, polygonOffsetUnits: num, dithering: bool, clipIntersection: bool, clipShadows: bool, + clippingPlanes: plain +}); + +/** Texture fields ObjectLoader.parseTextures reads. */ +const TEXTURE_FIELDS = /** @type {Record} */ ({ + uuid, name: str, image: uuid, userData: () => true, metadata: plain, mapping: int, channel: nonNegInt, repeat: nums(2), offset: nums(2), center: nums(2), rotation: num, + wrap: nums(2), format: int, internalFormat: (/** @type {any} */ v) => v === null || str(v), type: int, colorSpace: str, minFilter: int, magFilter: int, + anisotropy: num, flipY: bool, generateMipmaps: bool, premultiplyAlpha: bool, unpackAlignment: int, compareFunction: (/** @type {any} */ v) => v === null || int(v), + normalized: bool +}); + +/** The parametric geometries three rebuilds from parameters (plus the two buffer kinds). */ +export const GEOMETRY_TYPES = new Set([ + 'BufferGeometry', 'InstancedBufferGeometry', + 'BoxGeometry', 'CapsuleGeometry', 'CircleGeometry', 'ConeGeometry', 'CylinderGeometry', 'DodecahedronGeometry', 'ExtrudeGeometry', + 'IcosahedronGeometry', 'LatheGeometry', 'OctahedronGeometry', 'PlaneGeometry', 'PolyhedronGeometry', 'RingGeometry', 'ShapeGeometry', + 'SphereGeometry', 'TetrahedronGeometry', 'TorusGeometry', 'TorusKnotGeometry', 'TubeGeometry' +]); +/** Structured parameters of the parametric geometries (everything else must be a number or a boolean). */ +const GEOMETRY_STRUCTURED = /** @type {Record} */ ({ + points: (v) => Array.isArray(v) && v.length <= 100_000 && v.every((p) => (p && num(p.x) && num(p.y)) || nums(2)(p)), + shapes: (v) => uuid(v) || uuidList(v), + options: plain, + path: plain, + vertices: anyNums, + indices: anyNums +}); +/** A BufferGeometry's `data` block. */ +const GEOMETRY_DATA_FIELDS = new Set(['attributes', 'index', 'groups', 'boundingSphere', 'morphAttributes', 'morphTargetsRelative', 'interleavedBuffers', 'arrayBuffers', 'drawRange']); +const ATTRIBUTE_FIELDS = /** @type {Record} */ ({ + itemSize: (v) => Number.isInteger(v) && v >= 1 && v <= 16, + type: (v) => typeof v === 'string' && v in TYPED_ARRAYS, + array: (v) => Array.isArray(v) && allFinite(v), + normalized: bool, + name: str, + usage: int, + gpuType: int, + updateRanges: plain +}); + +/** userData keys the inventory found, with the JSON kind each must have (scripts/schema-inventory.cjs). + * 'object' = a plain object (not an array). A key not listed is plain JSON under the cap. */ +export const USERDATA_KINDS = /** @type {Record<'object'|'material'|'geometry'|'texture', Record>} */ ({ + object: { + bubbles: 'object', camera: 'object', checkpoint: 'object', device: 'object', drumPad: 'number', drumStep: 'number', flowPath: 'object', + fluid: 'object', fluidEmitter: 'object', fluidInteraction: 'string', geometryParams: 'object', lod: 'object', midi: 'number', + packRef: 'object', packStub: 'boolean', particles: 'object', physics: 'object', pick: 'string', portal: 'object', samplerPad: 'number', + shadow: 'boolean', shadowMapSize: 'number', spline: 'object', terrain: 'boolean', textPath: 'object', transport: 'string', water: 'object', + origin: 'array', measure: 'object', character: 'object', attachTo: 'object', clipRefs: 'array', animGraph: 'object', __uuid: 'string', __localOnly: 'boolean' + }, + material: { mapDataUrl: 'string', mapSource: 'string', fbLit: 'boolean', fbTeam: 'string', tpEdited: 'boolean', wvCore: 'number', __jiggleClone: 'boolean' }, + geometry: { flowLength: 'number', __topo: 'object', shading: 'string' }, + texture: {} +}); + +/** @param {any} v */ +function kindOf(v) { + if (v === null) return 'null'; + if (Array.isArray(v)) return 'array'; + return typeof v; +} + +// ---- the walk --------------------------------------------------------------------------------- + +class Report { + constructor() { + /** @type {Dropped[]} */ + this.rows = []; + this.count = 0; + } + /** @param {string} path @param {string} reason */ + add(path, reason) { + this.count++; + if (this.rows.length < MAX_ROWS) this.rows.push({ path, reason }); + } +} + +/** Strip the fields of `obj` the table does not know or whose value breaks its rule. + * @param {any} obj @param {Record} table @param {string} at @param {Report} r */ +function filterFields(obj, table, at, r) { + for (const key of Object.keys(obj)) { + const rule = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : null; + if (!rule) { + delete obj[key]; + r.add(at + '.' + key, 'field'); + continue; + } + if (key === 'name' && typeof obj.name === 'string' && obj.name.length > MAX_NAME) { + obj.name = obj.name.slice(0, MAX_NAME); + r.add(at + '.name', 'value'); + continue; + } + let ok; + try { + ok = rule(obj[key]); + } catch { + ok = false; + } + if (!ok) { + delete obj[key]; + r.add(at + '.' + key, 'value'); + } + } +} + +/** Check a node's userData in place. @param {any} holder @param {'object'|'material'|'geometry'|'texture'} scope @param {string} at @param {Report} r */ +function checkUserData(holder, scope, at, r) { + if (!('userData' in holder)) return; + const ud = holder.userData; + if (!ud || typeof ud !== 'object' || Array.isArray(ud) || (Object.getPrototypeOf(ud) !== Object.prototype && Object.getPrototypeOf(ud) !== null)) { + delete holder.userData; + r.add(at + '.userData', 'userData'); + return; + } + const kinds = USERDATA_KINDS[scope]; + let total = 0; + for (const key of Object.keys(ud)) { + const v = ud[key]; + const path = at + '.userData.' + key; + if (FORBIDDEN_KEYS.has(key)) { + delete ud[key]; + r.add(path, 'userData'); + continue; + } + const want = Object.prototype.hasOwnProperty.call(kinds, key) ? kinds[key] : null; + if (want && kindOf(v) !== want && v !== null) { + delete ud[key]; + r.add(path, 'userData kind'); + continue; + } + if (!isPlainJson(v, 0)) { + delete ud[key]; + r.add(path, 'userData'); + continue; + } + const chars = jsonChars(v); + if (chars > MAX_USERDATA_KEY_CHARS || total + chars > MAX_USERDATA_CHARS) { + delete ud[key]; + r.add(path, 'userData size'); + continue; + } + total += chars; + } + if (scope === 'material' && typeof ud.mapDataUrl === 'string' && ud.mapDataUrl.length > MAX_IMAGE_CHARS) { + delete ud.mapDataUrl; + r.add(at + '.userData.mapDataUrl', 'image size'); + } +} + +/** @param {any} list @param {string} name @param {Report} r @returns {any[]|null} */ +function listOf(list, name, r) { + if (list === undefined) return null; + if (!Array.isArray(list)) return null; // loadGuardCore refuses a non-array list + if (list.length > MAX_LIST) { + list.length = MAX_LIST; + r.add(name, 'count'); + } + return list; +} + +/** @param {any[]} materials @param {string} at @param {Report} r */ +function checkMaterials(materials, at, r) { + for (let i = 0; i < materials.length; i++) { + const path = `${at}[${i}]`; + let m = materials[i]; + if (!m || typeof m !== 'object' || Array.isArray(m)) { + materials[i] = fallbackMaterial(null); + r.add(path, 'type'); + continue; + } + if (!isStandardMaterial(m)) { + r.add(path, STANDARD_MATERIAL_TYPES.has(m.type) ? 'program' : 'type'); + m = materials[i] = fallbackMaterial(m); + } + filterFields(m, MATERIAL_FIELDS, path, r); + checkUserData(m, 'material', path, r); + } +} + +/** @param {any[]} textures @param {string} at @param {Report} r */ +function checkTextures(textures, at, r) { + for (let i = 0; i < textures.length; i++) { + const t = textures[i]; + if (!t || typeof t !== 'object' || Array.isArray(t)) continue; // loadGuardCore drops it (no image) + filterFields(t, TEXTURE_FIELDS, `${at}[${i}]`, r); + checkUserData(t, 'texture', `${at}[${i}]`, r); + } +} + +/** Drop images over the size cap (the not-embedded ones are loadGuardCore's; their textures follow). + * @param {any[]} images @param {string} at @param {Report} r */ +function checkImages(images, at, r) { + for (let i = images.length - 1; i >= 0; i--) { + const image = images[i]; + const urls = Array.isArray(image?.url) ? image.url : [image?.url]; + const tooBig = urls.some((/** @type {any} */ u) => typeof u === 'string' && u.length > MAX_IMAGE_CHARS); + const notEmbedded = urls.some((/** @type {any} */ u) => typeof u === 'string' && !isEmbeddedUrl(u)); + if (tooBig) { + images.splice(i, 1); + r.add(`${at}[${i}]`, 'image size'); + } else if (notEmbedded) r.add(`${at}[${i}]`, 'image'); + else if (image && typeof image === 'object') { + for (const key of Object.keys(image)) + if (key !== 'uuid' && key !== 'url') { + delete image[key]; + r.add(`${at}[${i}].${key}`, 'field'); + } + } + } +} + +/** @param {any} data @param {string} at @param {Report} r */ +function checkGeometryData(data, at, r) { + for (const key of Object.keys(data)) + if (!GEOMETRY_DATA_FIELDS.has(key)) { + delete data[key]; + r.add(at + '.' + key, 'field'); + } + /** @param {any} a @param {string} path @returns {boolean} keep */ + const attribute = (a, path) => { + if (!a || typeof a !== 'object' || Array.isArray(a)) return false; + if (a.isInterleavedBufferAttribute) return uuid(a.data) && int(a.itemSize) && int(a.offset); + if (!ATTRIBUTE_FIELDS.type(a.type) || !ATTRIBUTE_FIELDS.array(a.array) || !ATTRIBUTE_FIELDS.itemSize(a.itemSize)) return false; + filterFields(a, ATTRIBUTE_FIELDS, path, r); + return true; + }; + if (data.attributes && typeof data.attributes === 'object' && !Array.isArray(data.attributes)) { + for (const name of Object.keys(data.attributes)) + if (FORBIDDEN_KEYS.has(name) || !attribute(data.attributes[name], `${at}.attributes.${name}`)) { + delete data.attributes[name]; + r.add(`${at}.attributes.${name}`, 'value'); + } + } + if (data.morphAttributes && typeof data.morphAttributes === 'object' && !Array.isArray(data.morphAttributes)) { + for (const name of Object.keys(data.morphAttributes)) { + const list = data.morphAttributes[name]; + if (FORBIDDEN_KEYS.has(name) || !Array.isArray(list) || !list.every((a, i) => attribute(a, `${at}.morphAttributes.${name}[${i}]`))) { + delete data.morphAttributes[name]; + r.add(`${at}.morphAttributes.${name}`, 'value'); + } + } + } + if (data.index !== undefined && data.index !== null) { + const ix = data.index; + const ok = ix && typeof ix === 'object' && (ix.type === 'Uint16Array' || ix.type === 'Uint32Array') && Array.isArray(ix.array) && allFinite(ix.array); + if (!ok) { + delete data.index; + r.add(at + '.index', 'value'); + } + } + if (data.groups !== undefined && !(Array.isArray(data.groups) && data.groups.length <= 4096 && data.groups.every((/** @type {any} */ g) => g && num(g.start) && num(g.count) && (g.materialIndex === undefined || nonNegInt(g.materialIndex))))) { + delete data.groups; + r.add(at + '.groups', 'value'); + } + if (data.boundingSphere !== undefined && !sphere(data.boundingSphere)) { + delete data.boundingSphere; + r.add(at + '.boundingSphere', 'value'); + } + if (data.morphTargetsRelative !== undefined && !bool(data.morphTargetsRelative)) { + delete data.morphTargetsRelative; + r.add(at + '.morphTargetsRelative', 'value'); + } + if (data.drawRange !== undefined && !(data.drawRange && num(data.drawRange.start) && num(data.drawRange.count))) { + delete data.drawRange; + r.add(at + '.drawRange', 'value'); + } +} + +/** @param {any[]} geometries @param {string} at @param {Report} r */ +function checkGeometries(geometries, at, r) { + for (let i = 0; i < geometries.length; i++) { + const g = geometries[i]; + const path = `${at}[${i}]`; + if (!g || typeof g !== 'object' || Array.isArray(g)) continue; // loadGuardCore refuses + if (!GEOMETRY_TYPES.has(g.type)) { + geometries[i] = { uuid: typeof g.uuid === 'string' ? g.uuid : undefined, type: 'BufferGeometry', data: { attributes: {} } }; + r.add(path, 'type'); + continue; + } + const buffer = g.type === 'BufferGeometry' || g.type === 'InstancedBufferGeometry'; + for (const key of Object.keys(g)) { + const v = g[key]; + if (key === 'uuid' || key === 'type') continue; + if (key === 'name') { + if (!str(v)) { + delete g.name; + r.add(path + '.name', 'value'); + } + continue; + } + if (key === 'userData') continue; + const structured = Object.prototype.hasOwnProperty.call(GEOMETRY_STRUCTURED, key) ? GEOMETRY_STRUCTURED[key] : null; + if (buffer) { + // a buffer geometry baked from a parametric one keeps its parameters (three ignores them) + if (key === 'data' && v && typeof v === 'object' && !Array.isArray(v)) checkGeometryData(v, path + '.data', r); + else if (structured && structured(v)) continue; + else { + delete g[key]; + r.add(path + '.' + key, key === 'data' ? 'value' : 'field'); + } + continue; + } + const ok = structured ? structured(v) : num(v) || bool(v); + if (!ok) { + delete g[key]; + r.add(path + '.' + key, structured ? 'value' : 'field'); + } + } + checkUserData(g, 'geometry', path, r); + } +} + +/** @param {any} shadow @param {string} at @param {Report} r */ +function checkShadow(shadow, at, r) { + filterFields(shadow, SHADOW_FIELDS, at, r); + const cam = shadow.camera; + if (cam === undefined) return; + if (!cam || typeof cam !== 'object' || (cam.type !== 'OrthographicCamera' && cam.type !== 'PerspectiveCamera')) { + delete shadow.camera; + r.add(at + '.camera', 'type'); + return; + } + filterFields(cam, OBJECT_FIELDS[cam.type], at + '.camera', r); + // a shadow camera has no children or userData of its own worth carrying + for (const k of ['children', 'userData']) + if (k in cam) { + delete cam[k]; + r.add(at + '.camera.' + k, 'field'); + } +} + +/** Walk the object tree (no recursion: a deep chain must not overflow this walk). @param {any} root @param {Report} r */ +function checkObjectTree(root, r) { + /** @type {Array<[any, string, number]>} */ + const stack = [[root, 'object', 1]]; + while (stack.length) { + const [node, at, depth] = /** @type {[any, string, number]} */ (stack.pop()); + if (!node || typeof node !== 'object' || Array.isArray(node)) continue; + let table = Object.prototype.hasOwnProperty.call(OBJECT_FIELDS, node.type) ? OBJECT_FIELDS[node.type] : null; + if (!table) { + r.add(at, 'type'); + node.type = 'Object3D'; + table = OBJECT_FIELDS.Object3D; + } + filterFields(node, table, at, r); + if (node.shadow !== undefined) { + if (node.shadow && typeof node.shadow === 'object' && !Array.isArray(node.shadow)) checkShadow(node.shadow, at + '.shadow', r); + else { + delete node.shadow; + r.add(at + '.shadow', 'value'); + } + } + checkUserData(node, 'object', at, r); + if (node.children !== undefined) { + if (!Array.isArray(node.children)) { + delete node.children; + r.add(at + '.children', 'value'); + } else if (depth >= MAX_DEPTH && node.children.length) { + node.children = []; + r.add(at + '.children', 'depth'); + } else for (let i = node.children.length - 1; i >= 0; i--) stack.push([node.children[i], `${at}.children[${i}]`, depth + 1]); + } + } +} + +/** @param {Report} r @param {any} json @param {{ok: boolean, reason?: string, materials: number, images: number, textures: number}} guard + * @returns {SchemaResult} */ +function result(r, json, guard) { + if (r.count > r.rows.length) r.rows.push({ path: '…', reason: `${r.count - r.rows.length} more` }); + return { ok: guard.ok, json, dropped: guard.ok ? r.rows : [{ path: '', reason: guard.reason ?? 'refused' }, ...r.rows], reason: guard.reason, materials: guard.materials, images: guard.images, textures: guard.textures }; +} + +/** + * Validate one ObjectLoader JSON IN PLACE. `ok: false` = do not parse it at all; otherwise `json` + * (the same reference) is safe to hand to three and `dropped` lists what was changed. + * @param {any} json @returns {SchemaResult} + */ +export function validateObjectJson(json) { + const r = new Report(); + // the size and shape rules first: a refused object is not walked at all + if (!json || typeof json !== 'object' || Array.isArray(json)) return result(r, json, { ok: false, reason: 'shape', materials: 0, images: 0, textures: 0 }); + const sizeOnly = sizeVerdict(json); + if (!sizeOnly.ok) return result(r, json, sizeOnly); + for (const key of Object.keys(json)) + if (!TOP_LEVEL.has(key)) { + delete json[key]; + r.add(key, 'field'); + } + const geometries = listOf(json.geometries, 'geometries', r); + if (geometries) checkGeometries(geometries, 'geometries', r); + const materials = listOf(json.materials, 'materials', r); + if (materials) checkMaterials(materials, 'materials', r); + const textures = listOf(json.textures, 'textures', r); + if (textures) checkTextures(textures, 'textures', r); + const images = listOf(json.images, 'images', r); + if (images) checkImages(images, 'images', r); + // shapes (curves), skeletons (bone uuids + inverse matrices), clips (keyframe tracks): plain JSON entries + for (const key of ['shapes', 'skeletons', 'animations']) { + const list = listOf(json[key], key, r); + if (!list) continue; + for (let i = list.length - 1; i >= 0; i--) + if (!list[i] || typeof list[i] !== 'object' || Array.isArray(list[i]) || !isPlainJson(list[i], 0)) { + list.splice(i, 1); + r.add(`${key}[${i}]`, 'value'); + } + } + if (json.object && typeof json.object === 'object') checkObjectTree(json.object, r); + // then loadGuardCore's own pass (fallback materials it would make are already made, so it only + // drops the images that are not embedded and the textures that read them) + const guard = guardObjectJson(json); + return result(r, json, guard); +} + +/** The keys an ObjectLoader JSON may have at its top. */ +const TOP_LEVEL = new Set(['metadata', 'geometries', 'materials', 'textures', 'images', 'shapes', 'skeletons', 'animations', 'object']); + +/** loadGuardCore's size and shape verdict, before anything is edited. @param {any} json */ +function sizeVerdict(json) { + const size = measureObjectJson(json); + const reason = size.bad ?? (size.nodes > MAX_NODES ? 'nodes' : size.numbers > MAX_NUMBERS ? 'size' : size.vertices > MAX_GENERATED_VERTICES ? 'geometry' : null); + return { ok: !reason, reason: reason ?? undefined, materials: 0, images: 0, textures: 0 }; +} + +/** + * Validate a `{materials, textures, images, mapDataUrls?, mapSources?, groups?}` payload (the + * `objectParameters` materials message) IN PLACE. + * @param {any} payload @returns {SchemaResult} + */ +export function validateMaterialSet(payload) { + const r = new Report(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Array.isArray(payload.materials)) + return result(r, payload, { ok: false, reason: 'shape', materials: 0, images: 0, textures: 0 }); + const known = new Set(['materials', 'textures', 'images', 'mapDataUrls', 'mapSources', 'groups']); + for (const key of Object.keys(payload)) + if (!known.has(key)) { + delete payload[key]; + r.add(key, 'field'); + } + checkMaterials(payload.materials, 'materials', r); + if (Array.isArray(payload.textures)) checkTextures(payload.textures, 'textures', r); + if (Array.isArray(payload.images)) checkImages(payload.images, 'images', r); + if (payload.groups !== undefined && !(Array.isArray(payload.groups) && payload.groups.length <= 4096 && payload.groups.every((/** @type {any} */ g) => g && num(g.start) && num(g.count) && nonNegInt(g.materialIndex)))) { + delete payload.groups; + r.add('groups', 'value'); + } + if (Array.isArray(payload.mapSources)) + payload.mapSources = payload.mapSources.map((/** @type {any} */ s, /** @type {number} */ i) => { + if (s === null || s === undefined || (typeof s === 'string' && s.length <= 128)) return s; + r.add(`mapSources[${i}]`, 'value'); + return null; + }); + const guard = guardMaterialSet(payload); + return result(r, payload, guard); +} + +/** + * The receive-side VERDICT for an ObjectLoader JSON, without editing it (wireValidate's `object` row): + * false = drop the message before any applier runs. The cleaning itself happens in the applier + * (loadGuard.parseCheckedObject), where what it changes is counted. + * @param {any} json @returns {boolean} + */ +export function objectJsonVerdict(json) { + if (!json || typeof json !== 'object' || Array.isArray(json)) return false; + if (!json.object || typeof json.object !== 'object' || Array.isArray(json.object)) return false; + for (const key of ['geometries', 'materials', 'textures', 'images', 'shapes', 'skeletons', 'animations']) + if (json[key] !== undefined && !(Array.isArray(json[key]) && json[key].length <= MAX_LIST)) return false; + return sizeVerdict(json).ok; +} + +/** + * The receive-side verdict for a `materials` payload (wireValidate's objectParameters `materials` row). + * @param {any} payload @returns {boolean} + */ +export function materialSetVerdict(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false; + if (!Array.isArray(payload.materials) || payload.materials.length === 0 || payload.materials.length > 1024) return false; + for (const key of ['textures', 'images', 'mapDataUrls', 'mapSources', 'groups']) + if (payload[key] !== undefined && payload[key] !== null && !(Array.isArray(payload[key]) && payload[key].length <= MAX_LIST)) return false; + return payload.materials.every((/** @type {any} */ m) => !!m && typeof m === 'object' && !Array.isArray(m)); +} diff --git a/tests/unit/sceneSchema.test.js b/tests/unit/sceneSchema.test.js new file mode 100644 index 000000000..50600a900 --- /dev/null +++ b/tests/unit/sceneSchema.test.js @@ -0,0 +1,270 @@ +// @ts-nocheck - hand-built loader JSON fixtures, deliberately out of schema +// 101 F2: the object / material schema (src/lib/sceneSchema.js). +// 1. REAL CONTENT round-trips with nothing dropped: every template, every test fixture (the 1.65 +// capture: every Add-menu object, every material type, maps, slots, the handshake sync and a +// materials message) and, when SCENES_DIR names a scenes checkout, every scene in it. +// 2. OUT-OF-SCHEMA payloads are cleaned, every change is a `dropped` row, and three still parses +// what is left. +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import * as THREE from 'three'; +import { unzipSync, strFromU8 } from 'fflate'; +import { validateObjectJson, validateMaterialSet, MAX_DEPTH, MAX_IMAGE_CHARS, isPlainJson } from '../../src/lib/sceneSchema.js'; + +const ROOT = path.resolve(__dirname, '../..'); +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +/** every .tpscene / .json under a dir @param {string} dir */ +function contentFiles(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...contentFiles(p)); + else if (/\.tpscene$/.test(e.name) || (/\.json$/.test(e.name) && !/^(inventory|package|manifest|index|known)/.test(e.name))) out.push(p); + } + return out; +} + +/** every ObjectLoader element and materials payload inside one JSON document @param {any} doc */ +function findPayloads(doc, out = { elements: [], sets: [] }) { + if (!doc || typeof doc !== 'object') return out; + if (!Array.isArray(doc) && doc.metadata?.type === 'Object' && doc.object) out.elements.push(doc); + else if (!Array.isArray(doc) && Array.isArray(doc.materials) && !doc.metadata && !doc.asset && (doc.textures || doc.images)) out.sets.push(doc); + else for (const v of Array.isArray(doc) ? doc : Object.values(doc)) findPayloads(v, out); + return out; +} + +/** @param {string} file */ +function payloadsOf(file) { + const bytes = new Uint8Array(fs.readFileSync(file)); + const out = { elements: [], sets: [] }; + if (bytes[0] === 0x50 && bytes[1] === 0x4b) { + for (const [name, data] of Object.entries(unzipSync(bytes))) + if (name.endsWith('.json') && !name.startsWith('assets/')) findPayloads(JSON.parse(strFromU8(data)), out); + } else findPayloads(JSON.parse(strFromU8(bytes)), out); + return out; +} + +const SOURCES = [path.join(ROOT, 'static/templates'), path.join(ROOT, 'tests/fixtures'), ...(process.env.SCENES_DIR ? [path.resolve(process.env.SCENES_DIR)] : [])]; + +describe('real content: nothing is dropped', () => { + const files = SOURCES.flatMap(contentFiles); + it('finds the content (premise)', () => { + expect(files.some((f) => f.includes('schema-1.65'))).toBe(true); + expect(files.length).toBeGreaterThan(5); + }); + for (const file of files) { + it(path.relative(ROOT, file), () => { + const { elements, sets } = payloadsOf(file); + for (const el of elements) { + const before = JSON.stringify(el); + const r = validateObjectJson(el); + expect(r.ok).toBe(true); + expect(r.dropped).toEqual([]); + expect(JSON.stringify(el)).toBe(before); // byte-identical: the schema changed nothing + } + for (const set of sets) { + const before = JSON.stringify(set); + const r = validateMaterialSet(set); + expect(r.dropped).toEqual([]); + expect(JSON.stringify(set)).toBe(before); + } + }); + } + it('the 1.65 capture covers every Add-menu kind and every switchable material type', () => { + const { elements, sets } = payloadsOf(path.join(ROOT, 'tests/fixtures/schema-1.65/capture.tpscene')); + const mats = new Set(elements.flatMap((e) => (e.materials ?? []).map((m) => m.type))); + for (const t of ['MeshBasicMaterial', 'MeshStandardMaterial', 'MeshPhysicalMaterial', 'MeshPhongMaterial', 'MeshLambertMaterial', 'MeshToonMaterial', 'MeshMatcapMaterial', 'MeshNormalMaterial', 'MeshDepthMaterial', 'ShadowMaterial']) + expect(mats.has(t)).toBe(true); + expect(elements.length).toBeGreaterThanOrEqual(40); + expect(sets.length).toBe(0); + }); +}); + +/** a minimal valid element around one mesh @param {any} [over] */ +function mesh(over = {}) { + return { + metadata: { version: 4.7, type: 'Object', generator: 'Object3D.toJSON' }, + geometries: [{ uuid: 'g1', type: 'BoxGeometry', width: 1, height: 1, depth: 1 }], + materials: [{ uuid: 'm1', type: 'MeshStandardMaterial', color: 0x336699, roughness: 0.5 }], + object: { uuid: 'o1', type: 'Mesh', name: 'Box', layers: 1, matrix: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], up: [0, 1, 0], geometry: 'g1', material: 'm1' }, + ...over + }; +} +const reasons = (r) => r.dropped.map((d) => d.reason + ' ' + d.path); +const parses = (json) => new THREE.ObjectLoader().parse(json); + +describe('out of schema: cleaned and counted', () => { + it('a valid element passes untouched', () => { + const el = mesh(); + const before = JSON.stringify(el); + const r = validateObjectJson(el); + expect(r).toMatchObject({ ok: true, dropped: [] }); + expect(JSON.stringify(el)).toBe(before); + }); + + it('an unknown material type becomes the standard fallback, counted', () => { + const el = mesh({ materials: [{ uuid: 'm1', type: 'FancyMaterial', color: 0xff0000, name: 'x' }] }); + const r = validateObjectJson(el); + expect(r.ok).toBe(true); + expect(reasons(r)).toContain('type materials[0]'); + expect(el.materials[0]).toMatchObject({ type: 'MeshStandardMaterial', uuid: 'm1', color: 0xff0000 }); + expect(parses(el).material.type).toBe('MeshStandardMaterial'); + }); + + it('a standard type carrying program fields takes the fallback', () => { + const el = mesh({ materials: [{ uuid: 'm1', type: 'MeshStandardMaterial', color: 1, fragmentShader: 'void main(){}' }] }); + const r = validateObjectJson(el); + expect(reasons(r)).toContain('program materials[0]'); + expect('fragmentShader' in el.materials[0]).toBe(false); + }); + + it('unknown fields are stripped at every level', () => { + const el = mesh({ extra: 1 }); + el.object.onBeforeRender = 'x'; + el.materials[0].shaderHook = 'y'; + el.geometries[0].weird = { a: 1 }; + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['field extra', 'field object.onBeforeRender', 'field materials[0].shaderHook', 'field geometries[0].weird'])); + expect(el.extra).toBeUndefined(); + expect(el.object.onBeforeRender).toBeUndefined(); + parses(el); + }); + + it('a field whose value breaks its rule is stripped (three falls back to its default)', () => { + const el = mesh(); + el.object.matrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, null, 0, 0, 1]; + el.object.visible = 'yes'; + el.materials[0].roughness = 'rough'; + el.materials[0].side = 7; + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['value object.matrix', 'value object.visible', 'value materials[0].roughness', 'value materials[0].side'])); + const o = parses(el); + expect(o.matrix.elements.every(Number.isFinite)).toBe(true); + }); + + it('an unknown object type becomes an Object3D and keeps its children', () => { + const el = mesh(); + el.object = { uuid: 's', type: 'Scene', background: 'tex', children: [el.object] }; + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['type object', 'field object.background'])); + const o = parses(el); + expect(o.type).toBe('Object3D'); + expect(o.children[0].type).toBe('Mesh'); + }); + + it('an unknown geometry type becomes an empty buffer geometry', () => { + const el = mesh({ geometries: [{ uuid: 'g1', type: 'MysteryGeometry', n: 1 }] }); + const r = validateObjectJson(el); + expect(reasons(r)).toContain('type geometries[0]'); + expect(parses(el).geometry.type).toBe('BufferGeometry'); + }); + + it('a buffer attribute with an unknown array type or non-numbers is dropped', () => { + const el = mesh({ + geometries: [{ uuid: 'g1', type: 'BufferGeometry', data: { attributes: { position: { itemSize: 3, type: 'Float32Array', array: [0, 0, 0, 1, 0, 0, 0, 1, 0] }, bad: { itemSize: 3, type: 'Function', array: [1, 2, 3] }, strs: { itemSize: 1, type: 'Float32Array', array: ['a'] } }, extra: 1 } }] + }); + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['value geometries[0].data.attributes.bad', 'value geometries[0].data.attributes.strs', 'field geometries[0].data.extra'])); + expect(Object.keys(parses(el).geometry.attributes)).toEqual(['position']); + }); + + it('an image over the cap is dropped with its texture; an address that is not embedded is counted', () => { + const big = 'data:image/png;base64,' + 'A'.repeat(MAX_IMAGE_CHARS); + const el = mesh({ + images: [{ uuid: 'i1', url: big }, { uuid: 'i2', url: 'https://example.com/a.png' }, { uuid: 'i3', url: PNG, extra: 1 }], + textures: [{ uuid: 't1', image: 'i1' }, { uuid: 't2', image: 'i2' }, { uuid: 't3', image: 'i3' }] + }); + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['image size images[0]', 'image images[1]', 'field images[2].extra'])); + expect(el.images.map((i) => i.uuid)).toEqual(['i3']); + expect(el.textures.map((t) => t.uuid)).toEqual(['t3']); + expect(r.images).toBe(1); // loadGuardCore's count: the not-embedded one + }); + + it('userData: a known key of the wrong kind, a prototype key, and non-JSON are dropped; unknown plain JSON stays', () => { + const el = mesh(); + el.object.userData = JSON.parse('{"physics": "dynamic", "__proto__": {"polluted": true}, "mine": {"a": [1, 2]}, "shadow": false}'); + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['userData kind object.userData.physics', 'userData object.userData.__proto__'])); + expect(el.object.userData).toEqual({ mine: { a: [1, 2] }, shadow: false }); + expect({}.polluted).toBeUndefined(); + expect(parses(el).userData.mine.a).toEqual([1, 2]); + }); + + it('userData over the per-key cap is dropped', () => { + const el = mesh(); + el.object.userData = { huge: 'x'.repeat(3 * 1024 * 1024), small: 1 }; + const r = validateObjectJson(el); + expect(reasons(r)).toContain('userData size object.userData.huge'); + expect(el.object.userData).toEqual({ small: 1 }); + }); + + it('a tree deeper than MAX_DEPTH is cut at the cap', () => { + const el = mesh(); + let node = el.object; + for (let i = 0; i < MAX_DEPTH + 5; i++) { + const child = { uuid: 'c' + i, type: 'Group' }; + node.children = [child]; + node = child; + } + const r = validateObjectJson(el); + expect(r.ok).toBe(true); + expect(r.dropped.some((d) => d.reason === 'depth')).toBe(true); + }); + + it('a light keeps its checked shadow block; a bad shadow camera goes', () => { + const el = mesh(); + el.object = { uuid: 'l', type: 'DirectionalLight', color: 0xffffff, intensity: 1, target: 't', shadow: { bias: 0, mapSize: [1024, 1024], camera: { uuid: 'c', type: 'Mesh' }, extra: 1 } }; + const r = validateObjectJson(el); + expect(reasons(r)).toEqual(expect.arrayContaining(['type object.shadow.camera', 'field object.shadow.extra'])); + expect(parses(el).shadow.mapSize.x).toBe(1024); + }); + + it('the size rules still refuse whole objects (loadGuardCore), before anything is edited', () => { + const el = mesh({ geometries: [{ uuid: 'g1', type: 'BoxGeometry', widthSegments: 1e6, heightSegments: 1e6, depthSegments: 1 }] }); + el.object.extra = 1; + const r = validateObjectJson(el); + expect(r.ok).toBe(false); + expect(r.dropped[0].path).toBe(''); + expect(el.object.extra).toBe(1); // refused, not walked + expect(validateObjectJson(null).ok).toBe(false); + expect(validateObjectJson([1]).ok).toBe(false); + }); + + it('a materials payload: unknown keys, bad groups and a program material are cleaned', () => { + const set = { + materials: [{ uuid: 'a', type: 'RawShaderMaterial', vertexShader: 'x' }, { uuid: 'b', type: 'MeshBasicMaterial', color: 1, odd: 2 }], + textures: [], + images: [], + groups: [{ start: 0, count: 'all', materialIndex: 0 }], + mapSources: ['hash', { x: 1 }], + extra: true + }; + const r = validateMaterialSet(set); + expect(reasons(r)).toEqual(expect.arrayContaining(['field extra', 'type materials[0]', 'field materials[1].odd', 'value groups', 'value mapSources[1]'])); + expect(set.materials[0].type).toBe('MeshStandardMaterial'); + expect(set.mapSources).toEqual(['hash', null]); + expect(validateMaterialSet({ materials: 'x' }).ok).toBe(false); + }); + + it('isPlainJson refuses prototype keys, non-finite numbers and deep nesting', () => { + expect(isPlainJson(JSON.parse('{"a":{"constructor":1}}'), 0)).toBe(false); + expect(isPlainJson({ a: Infinity }, 0)).toBe(false); + let deep = 1; + for (let i = 0; i < 100; i++) deep = [deep]; + expect(isPlainJson(deep, 0)).toBe(false); + expect(isPlainJson({ a: [1, 'b', null, { c: true }] }, 0)).toBe(true); + }); + + it('a result lists at most 200 rows and counts the rest', () => { + const el = mesh(); + for (let i = 0; i < 300; i++) el.object['f' + i] = i; + const r = validateObjectJson(el); + expect(r.dropped.length).toBe(201); + expect(r.dropped[200]).toEqual({ path: '…', reason: '100 more' }); + }); +}); + From 5fa9e66179bc9db4e8098cdc54f8053febff41b5 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 15:07:38 +0200 Subject: [PATCH 4/7] 101-schema step 3: the schema runs in front of every object and material load loadGuard.checkObjectJson / checkMaterialSet now run the schema, so every route that already goes through parseCheckedObject (peer objects, scene loads, imports, prefabs, levels, the library, history, autosave) and the new parseCheckedMaterials (the materials message) is validated and counted, with the schema's changes in the same counters and the same one toast per session. The thumbnail fallback in sessions.js parses through the checked helper too. wireValidate: the object row (ObjectLoader form) and the objectParameters materials row call the schema's verdicts instead of allowing anything. scripts/check-loaders.cjs (CI + npm run check:loaders): a generic loader call anywhere in src/ outside src/lib/loadGuard.js fails the build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++ package.json | 1 + scripts/check-loaders.cjs | 73 +++++++++++++++++++++++++++++++++ src/lib/loadGuard.js | 37 +++++++++++++---- src/lib/materialsHandler.js | 11 ++--- src/lib/sessions.js | 2 +- src/lib/wireValidate.js | 9 +++- tests/unit/checkLoaders.test.js | 23 +++++++++++ tests/unit/sceneSchema.test.js | 30 ++++++++++++++ 9 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 scripts/check-loaders.cjs create mode 100644 tests/unit/checkLoaders.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ad364ce8..23e77ea55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,9 @@ jobs: # or a custom SVG. Non-UI text (a chat shortcode table) carries `emoji-ok` / `emoji-ok-file`. - name: no emoji in the UI run: node scripts/check-emoji.cjs + # 101 F2: three's generic loaders only through src/lib/loadGuard.js (the object schema runs first) + - name: object loaders behind the schema + run: node scripts/check-loaders.cjs # 34 B3: scene-lint over every scene core ships (the seed) and every scene of the scenes ref # the perf budget is about (perf/budgets.json), with the packs checkout for the kit rules. diff --git a/package.json b/package.json index e94526554..103d473f6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "sync-llms": "node scripts/sync-llms.cjs", "test:unit": "vitest run", "check:emoji": "node scripts/check-emoji.cjs", + "check:loaders": "node scripts/check-loaders.cjs", "check:storage": "node scripts/check-storage.cjs", "check:tokens": "node scripts/check-tokens.cjs", "check:zindex": "node scripts/check-zindex.cjs" diff --git a/scripts/check-loaders.cjs b/scripts/check-loaders.cjs new file mode 100644 index 000000000..e4eb7fbf7 --- /dev/null +++ b/scripts/check-loaders.cjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// 101 F2: three's generic loaders are reached ONLY through the checked helpers in src/lib/loadGuard.js +// (parseCheckedObject / parseCheckedMaterials), which run the object schema (src/lib/sceneSchema.js) +// first. A new `new THREE.ObjectLoader()`, `.parseMaterials(` or `new MaterialLoader(` anywhere else in +// src/ fails this check. Comment lines are ignored. +// +// node scripts/check-loaders.cjs → exit 1 and one line per call outside the helper +// +// Run by CI (.github/workflows/ci.yml) and tests/unit/checkLoaders.test.js. +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const ALLOWED = new Set(['src/lib/loadGuard.js']); +const PATTERN = /\bObjectLoader\s*\(|\.parseMaterials\s*\(|\bMaterialLoader\s*\(/; + +/** @param {string} dir @returns {string[]} */ +function files(dir) { + /** @type {string[]} */ + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...files(p)); + else if (/\.(js|ts|svelte|mjs)$/.test(e.name) && !/\.(test|spec)\.[jt]s$/.test(e.name)) out.push(p); + } + return out; +} + +/** @param {string} text @returns {number[]} 1-based lines with a call */ +function hitsIn(text) { + /** @type {number[]} */ + const out = []; + let inBlock = false; + text.split('\n').forEach((line, i) => { + let code = line; + if (inBlock) { + const end = code.indexOf('*/'); + if (end < 0) return; + code = code.slice(end + 2); + inBlock = false; + } + code = code.replace(/\/\*.*?\*\//g, ''); + const open = code.indexOf('/*'); + if (open >= 0) { + inBlock = true; + code = code.slice(0, open); + } + code = code.replace(/\/\/.*$/, ''); + // a JSDoc continuation line (` * …`) is comment text even where the opening /** was not seen + if (/^\s*\*/.test(code)) return; + if (PATTERN.test(code)) out.push(i + 1); + }); + return out; +} + +function main() { + /** @type {string[]} */ + const bad = []; + for (const file of files(path.join(ROOT, 'src'))) { + const rel = path.relative(ROOT, file).split(path.sep).join('/'); + if (ALLOWED.has(rel)) continue; + for (const line of hitsIn(fs.readFileSync(file, 'utf8'))) bad.push(`${rel}:${line}`); + } + if (bad.length) { + console.log('check-loaders: three generic loader calls outside src/lib/loadGuard.js (use parseCheckedObject / parseCheckedMaterials):'); + for (const b of bad) console.log(' ' + b); + process.exit(1); + } + console.log('check-loaders — clean (every generic loader call goes through src/lib/loadGuard.js)'); +} + +if (require.main === module) main(); +module.exports = { hitsIn }; diff --git a/src/lib/loadGuard.js b/src/lib/loadGuard.js index 7b1682d12..a882c44f4 100644 --- a/src/lib/loadGuard.js +++ b/src/lib/loadGuard.js @@ -2,15 +2,17 @@ // from JSON it did not just make itself goes through here, so the check, the count and the one // toast are the same everywhere. See loadGuardCore.js for the rules. import * as THREE from 'three'; -import { guardObjectJson, guardMaterialSet, guardChanged, guardGltfElement, isEmbeddedUrl, isLocalImageUrl, stlWithinLimits } from './loadGuardCore.js'; +import { guardChanged, guardGltfElement, isEmbeddedUrl, isLocalImageUrl, stlWithinLimits } from './loadGuardCore.js'; +// 101 F2: the full object / material schema runs here, on top of loadGuardCore's rules +import { validateObjectJson, validateMaterialSet } from './sceneSchema.js'; import { fbxWithinLimits } from './fbxGuard.js'; export { isLocalImageUrl }; import { noteWireError } from './wireErrors.js'; import { showToast } from '../stores/appStore'; let toldThisSession = false; -/** @type {{refused: number, materials: number, images: number, textures: number, fetches: number}} */ -const totals = { refused: 0, materials: 0, images: 0, textures: 0, fetches: 0 }; +/** @type {{refused: number, materials: number, images: number, textures: number, fetches: number, fields: number}} */ +const totals = { refused: 0, materials: 0, images: 0, textures: 0, fetches: 0, fields: 0 }; /** What the checks changed since the page loaded (diagnostics and tests). */ export function loadGuardTotals() { @@ -35,13 +37,19 @@ function tellOnce() { * @param {import('./loadGuardCore.js').GuardResult} result */ function report(from, what, result) { - if (!guardChanged(result)) return; + const rows = /** @type {import('./sceneSchema.js').Dropped[]} */ (/** @type {any} */ (result).dropped ?? []).filter((d) => d.path !== ''); + if (!guardChanged(result) && !rows.length) return; if (!result.ok) totals.refused++; - totals.materials += result.materials; + // the schema's own material replacements are rows (loadGuardCore saw a standard material by then) + const schemaMaterials = rows.filter((d) => /^materials\[\d+\]$/.test(d.path) && (d.reason === 'type' || d.reason === 'program')).length; + const fields = rows.length - schemaMaterials; + totals.materials += result.materials + schemaMaterials; totals.images += result.images; totals.textures += result.textures; + totals.fields += fields; const detail = result.ok - ? `materials ${result.materials}, images ${result.images}, textures ${result.textures}` + ? `materials ${result.materials + schemaMaterials}, images ${result.images}, textures ${result.textures}, fields ${fields}` + + (rows.length ? ' (' + rows.slice(0, 3).map((d) => d.reason + ' ' + d.path).join('; ') + ')' : '') : `refused (${result.reason})`; noteWireError(from || '?', 'checked:' + what, detail, { quiet: true }); tellOnce(); @@ -52,7 +60,7 @@ function report(from, what, result) { * @param {any} json @param {string} from @param {string} what */ export function checkObjectJson(json, from, what) { - const result = guardObjectJson(json); + const result = validateObjectJson(json); report(from, what, result); return result.ok; } @@ -62,7 +70,7 @@ export function checkObjectJson(json, from, what) { * @param {any} payload @param {string} from @param {string} what */ export function checkMaterialSet(payload, from, what) { - const result = guardMaterialSet(payload); + const result = validateMaterialSet(payload); report(from, what, result); return result.ok; } @@ -153,3 +161,16 @@ export function parseCheckedObject(json, from, what) { if (!checkObjectJson(json, from, what)) throw refusal('object'); return new THREE.ObjectLoader(embeddedOnlyManager(from, what)).parse(json); } + +/** + * Check a `{materials, textures, images}` payload, then build its materials with a loader that cannot + * fetch. Returns the materials keyed by uuid, or null when the payload is refused. + * @param {any} payload @param {string} from @param {string} what @returns {Record | null} + */ +export function parseCheckedMaterials(payload, from, what) { + if (!checkMaterialSet(payload, from, what)) return null; + const loader = new THREE.ObjectLoader(embeddedOnlyManager(from, what)); + const images = loader.parseImages(payload.images ?? [], () => {}); + const textures = loader.parseTextures(payload.textures ?? [], images); + return loader.parseMaterials(payload.materials, textures); +} diff --git a/src/lib/materialsHandler.js b/src/lib/materialsHandler.js index b46e15c77..e2f4a1cca 100644 --- a/src/lib/materialsHandler.js +++ b/src/lib/materialsHandler.js @@ -1,4 +1,4 @@ -import { checkMaterialSet, embeddedOnlyManager, isLocalImageUrl, mapPreviewUrl } from './loadGuard.js'; +import { parseCheckedMaterials, isLocalImageUrl, mapPreviewUrl } from './loadGuard.js'; import * as THREE from 'three'; import { get } from 'svelte/store'; import { objectsGroup, pokeScene } from '../stores/sceneStore'; @@ -162,12 +162,9 @@ function serializeMaterials(materials) { */ export function applyMaterials(object, payload, replicate = false, from = 'device') { if (!object || !payload?.materials?.length) return; - // checked in place before three's loaders see it (loadGuardCore.js) - if (!checkMaterialSet(payload, from, 'materials')) return; - const loader = new THREE.ObjectLoader(embeddedOnlyManager(from, 'materials')); - const images = loader.parseImages(payload.images ?? [], () => {}); - const textures = loader.parseTextures(payload.textures ?? [], images); - const materials = loader.parseMaterials(payload.materials, textures); + // checked in place before three's loaders see it (loadGuard.js: sceneSchema + loadGuardCore) + const materials = parseCheckedMaterials(payload, from, 'materials'); + if (!materials) return; const next = payload.materials.map((/** @type {any} */ entry) => materials[entry.uuid]).filter(Boolean); if (!next.length) return; // carry the thumbnail dataURL the UI reads (material.toJSON does not keep it) diff --git a/src/lib/sessions.js b/src/lib/sessions.js index 82b2b16d4..dbf079027 100644 --- a/src/lib/sessions.js +++ b/src/lib/sessions.js @@ -185,7 +185,7 @@ function renderSceneThumbnail(group) { const scene = new THREE.Scene(); scene.background = new THREE.Color('#232a33'); scene.add(new THREE.HemisphereLight(0xffffff, 0x444466, 2.2)); - const clone = new THREE.ObjectLoader().parse(group.toJSON()); + const clone = parseCheckedObject(group.toJSON(), 'device', 'thumbnail'); scene.add(clone); const box = new THREE.Box3().setFromObject(clone); if (!isFinite(box.min.x)) return null; diff --git a/src/lib/wireValidate.js b/src/lib/wireValidate.js index 89e2f604a..87559109b 100644 --- a/src/lib/wireValidate.js +++ b/src/lib/wireValidate.js @@ -25,6 +25,8 @@ // the AI tools, so the three cannot disagree about what a material key takes). import { checkMaterialParam, isMapSlotKey, isMapSlotImage, normalizeTexTransform } from './materialParamRules.js'; import { isPingMessage } from './pingCore.js'; +// 101 F2: the object schema's receive verdicts (a pure leaf; the cleaning runs in the appliers) +import { objectJsonVerdict, materialSetVerdict } from './sceneSchema.js'; import { validPhotoRequest, validPhotoResult } from './photo/photoCore.js'; /** @param {unknown} v */ @@ -236,7 +238,10 @@ export const VALIDATORS = { (d.velocities === undefined || (!!d.velocities && typeof d.velocities === 'object' && Object.values(d.velocities).every((v) => isFiniteArray(v, 6)))), loading: (d) => isArray(d.uuids), - object: (d) => d.element !== undefined, + // 101 F2: the ObjectLoader form (no `uuids` list) passes the schema's size and shape verdict here; + // the applier cleans it field by field (loadGuard.parseCheckedObject). The other form is checked + // by its own rule in the applier. + object: (d) => d.element !== undefined && (d.uuids != null || objectJsonVerdict(d.element)), group: (d) => d.uuid !== undefined, duplicate: (d) => isUuid(d.sourceUuid) && isArray(d.uuids), nodes: (d) => isArray(d.nodes) && isArray(d.edges), @@ -489,6 +494,8 @@ export const VALIDATORS = { ); // a material parameter: a key the app knows, a value of that key's kind (a texture slot only ever // null on the wire — images travel as `map`) + // 101 F2: a slot list + groups — the schema's verdict here, the field-by-field cleaning in applyMaterials + if (d.parameter === 'materials') return isUuid(d.uuid) && materialSetVerdict(d.payload); if (d.parameter === 'materialParam') return isUuid(d.uuid) && checkMaterialParam(null, d.key, d.value).ok; // 72 LK1: a texture map slot takes an inline image or null, never a URL a peer could make us fetch if (d.parameter === 'mapSlot') return isUuid(d.uuid) && isMapSlotKey(d.key) && isMapSlotImage(d.map); diff --git a/tests/unit/checkLoaders.test.js b/tests/unit/checkLoaders.test.js new file mode 100644 index 000000000..c32e887d5 --- /dev/null +++ b/tests/unit/checkLoaders.test.js @@ -0,0 +1,23 @@ +// 101 F2: scripts/check-loaders.cjs — three's generic loaders only through src/lib/loadGuard.js +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import path from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { hitsIn } = require('../../scripts/check-loaders.cjs'); +const ROOT = path.resolve(__dirname, '../..'); + +describe('check-loaders', () => { + it('finds a call, and skips comments', () => { + expect(hitsIn('const o = new THREE.ObjectLoader().parse(x);')).toEqual([1]); + expect(hitsIn('a\nloader.parseMaterials(m, t);')).toEqual([2]); + expect(hitsIn('new MaterialLoader();')).toEqual([1]); + expect(hitsIn('// new THREE.ObjectLoader()\n * ObjectLoader().parse\n/* new MaterialLoader() */')).toEqual([]); + expect(hitsIn('/*\n new ObjectLoader()\n*/ ok()')).toEqual([]); + }); + it('src/ is clean', () => { + const out = execFileSync('node', [path.join(ROOT, 'scripts/check-loaders.cjs')], { encoding: 'utf8' }); + expect(out).toMatch(/clean/); + }); +}); diff --git a/tests/unit/sceneSchema.test.js b/tests/unit/sceneSchema.test.js index 50600a900..9267f9856 100644 --- a/tests/unit/sceneSchema.test.js +++ b/tests/unit/sceneSchema.test.js @@ -268,3 +268,33 @@ describe('out of schema: cleaned and counted', () => { }); }); +describe('wireValidate rows use the schema verdicts', async () => { + const { validateWireMessage } = await import('../../src/lib/wireValidate.js'); + const { objectJsonVerdict, materialSetVerdict } = await import('../../src/lib/sceneSchema.js'); + it('object: the ObjectLoader form needs a sane element; the uuids form is left to its applier check', () => { + expect(validateWireMessage({ type: 'object', element: mesh() })).toBe(true); + expect(validateWireMessage({ type: 'object', element: 'junk' })).toBe(false); + expect(validateWireMessage({ type: 'object', element: { object: null } })).toBe(false); + expect(validateWireMessage({ type: 'object', element: mesh({ materials: { length: 3 } }) })).toBe(false); + expect(validateWireMessage({ type: 'object', element: mesh({ geometries: [{ uuid: 'g', type: 'BoxGeometry', widthSegments: 1e6, heightSegments: 1e6 }] }) })).toBe(false); + expect(validateWireMessage({ type: 'object', element: {}, uuids: ['a'] })).toBe(true); + }); + it('objectParameters materials: refused unless it is a slot list', () => { + const ok = { materials: [{ uuid: 'a', type: 'MeshBasicMaterial' }], textures: [], images: [] }; + expect(validateWireMessage({ type: 'objectParameters', parameter: 'materials', uuid: 'u', payload: ok })).toBe(true); + expect(validateWireMessage({ type: 'objectParameters', parameter: 'materials', uuid: 'u', payload: 'junk' })).toBe(false); + expect(validateWireMessage({ type: 'objectParameters', parameter: 'materials', uuid: 'u', payload: { materials: [] } })).toBe(false); + expect(validateWireMessage({ type: 'objectParameters', parameter: 'materials', uuid: 'u', payload: { materials: [1] } })).toBe(false); + expect(validateWireMessage({ type: 'objectParameters', parameter: 'materials', payload: ok })).toBe(false); + }); + it('the verdicts edit nothing', () => { + const el = mesh({ extra: 1 }); + const before = JSON.stringify(el); + expect(objectJsonVerdict(el)).toBe(true); + const set = { materials: [{ uuid: 'a', type: 'RawShaderMaterial', vertexShader: 'x' }], odd: 1 }; + const setBefore = JSON.stringify(set); + expect(materialSetVerdict(set)).toBe(true); + expect(JSON.stringify(el)).toBe(before); + expect(JSON.stringify(set)).toBe(setBefore); + }); +}); From f28dd5e895b74570d40970a2cf56ee4b7d49322e Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 15:15:44 +0200 Subject: [PATCH 5/7] 101-schema step 2b: glTF elements go through the schema too validateGltfElement (sceneSchema.js): loadGuardCore's glTF rules first, then every extras block three copies into userData (scenes, nodes, meshes, primitives, cameras, animations, materials, textures, punctual lights, the document) under the ObjectLoader userData rules. A parsed document is cleaned in place; a JSON text or .glb that breaks a rule is refused. loadGuard.checkGltf runs it, so every glTF load path is counted the same way. Unit: the 37 glTF messages of the 1.65 handshake capture and every shipped .glb pass unchanged. Co-Authored-By: Claude Opus 5.5 --- src/lib/loadGuard.js | 10 ++-- src/lib/sceneSchema.js | 56 ++++++++++++++++++++++- tests/unit/sceneSchema.test.js | 84 +++++++++++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 7 deletions(-) diff --git a/src/lib/loadGuard.js b/src/lib/loadGuard.js index a882c44f4..d503e2816 100644 --- a/src/lib/loadGuard.js +++ b/src/lib/loadGuard.js @@ -2,9 +2,9 @@ // from JSON it did not just make itself goes through here, so the check, the count and the one // toast are the same everywhere. See loadGuardCore.js for the rules. import * as THREE from 'three'; -import { guardChanged, guardGltfElement, isEmbeddedUrl, isLocalImageUrl, stlWithinLimits } from './loadGuardCore.js'; +import { guardChanged, isEmbeddedUrl, isLocalImageUrl, stlWithinLimits } from './loadGuardCore.js'; // 101 F2: the full object / material schema runs here, on top of loadGuardCore's rules -import { validateObjectJson, validateMaterialSet } from './sceneSchema.js'; +import { validateObjectJson, validateMaterialSet, validateGltfElement } from './sceneSchema.js'; import { fbxWithinLimits } from './fbxGuard.js'; export { isLocalImageUrl }; import { noteWireError } from './wireErrors.js'; @@ -81,9 +81,9 @@ export function checkMaterialSet(payload, from, what) { * @param {any} element @param {string} from @param {string} what @param {{uris?: boolean}} [opts] */ export function checkGltf(element, from, what, opts) { - const r = guardGltfElement(element, opts); - if (!r.ok) report(from, what, { ok: false, reason: r.reason, materials: 0, images: 0, textures: 0 }); - return r.ok; + const result = validateGltfElement(element, opts); + report(from, what, result); + return result.ok; } /** diff --git a/src/lib/sceneSchema.js b/src/lib/sceneSchema.js index c1c90cfbb..51b3c202f 100644 --- a/src/lib/sceneSchema.js +++ b/src/lib/sceneSchema.js @@ -27,7 +27,7 @@ // key needs nothing (plain JSON under the cap always passes); give it a kind in USERDATA_KINDS when // a wrong kind must not reach its reader. See OPEN-CORE.md "Object schema". -import { guardObjectJson, guardMaterialSet, measureObjectJson, isStandardMaterial, fallbackMaterial, STANDARD_MATERIAL_TYPES, isEmbeddedUrl, MAX_NODES, MAX_NUMBERS, MAX_GENERATED_VERTICES } from './loadGuardCore.js'; +import { guardObjectJson, guardMaterialSet, guardGltfElement, gltfJsonOf, measureObjectJson, isStandardMaterial, fallbackMaterial, STANDARD_MATERIAL_TYPES, isEmbeddedUrl, MAX_NODES, MAX_NUMBERS, MAX_GENERATED_VERTICES } from './loadGuardCore.js'; /** @typedef {{path: string, reason: string}} Dropped */ /** @typedef {{ok: boolean, json: any, dropped: Dropped[], reason?: string, materials: number, images: number, textures: number}} SchemaResult */ @@ -671,6 +671,60 @@ export function validateMaterialSet(payload) { return result(r, payload, guard); } +/** + * Where three's GLTFLoader copies a glTF definition's `extras` into `userData`, so they follow the userData + * rules, and the userData scope each one lands in. + * @type {[string, 'object'|'material'|'geometry'|'texture'][]} + */ +const GLTF_EXTRAS = [ + ['scenes', 'object'], + ['nodes', 'object'], + ['meshes', 'object'], + ['cameras', 'object'], + ['animations', 'object'], + ['materials', 'material'], + ['textures', 'texture'] +]; + +/** + * Validate one glTF element (a parsed document, a JSON string or .glb bytes): loadGuardCore's + * glTF rules (embedded buffers and images, sizes), then every `extras` block three turns into + * `userData` under the same rules as an ObjectLoader userData. A parsed document is cleaned IN + * PLACE; a string or .glb cannot be, so a block that breaks a rule refuses it instead. + * `{uris: false}` = sizes only, for a loader that already refuses fetches. + * @param {any} element @param {{uris?: boolean}} [opts] @returns {SchemaResult} + */ +export function validateGltfElement(element, opts) { + const r = new Report(); + const none = { materials: 0, images: 0, textures: 0 }; + const guard = guardGltfElement(element, opts); + if (!guard.ok) return result(r, element, { ok: false, reason: guard.reason, ...none }); + const json = gltfJsonOf(element); + if (!json) return result(r, element, { ok: false, reason: 'shape', ...none }); + /** @param {any} def @param {'object'|'material'|'geometry'|'texture'} scope @param {string} at */ + const extras = (def, scope, at) => { + if (!def || typeof def !== 'object' || !('extras' in def) || def.extras === null || typeof def.extras !== 'object') return; + const holder = { userData: def.extras }; + checkUserData(holder, scope, at, r); + if ('userData' in holder) def.extras = holder.userData; + else delete def.extras; + }; + extras(json, 'object', 'gltf'); + for (const [key, scope] of GLTF_EXTRAS) { + const list = json[key]; + if (!Array.isArray(list)) continue; + list.forEach((def, i) => extras(def, scope, `${key}[${i}]`)); + } + if (Array.isArray(json.meshes)) + json.meshes.forEach((/** @type {any} */ mesh, /** @type {number} */ i) => { + if (Array.isArray(mesh?.primitives)) mesh.primitives.forEach((/** @type {any} */ p, /** @type {number} */ j) => extras(p, 'geometry', `meshes[${i}].primitives[${j}]`)); + }); + const lights = json.extensions?.KHR_lights_punctual?.lights; + if (Array.isArray(lights)) lights.forEach((/** @type {any} */ l, /** @type {number} */ i) => extras(l, 'object', `lights[${i}]`)); + if (r.count && json !== element) return result(r, element, { ok: false, reason: 'userData', ...none }); + return result(r, element, { ok: true, ...none }); +} + /** * The receive-side VERDICT for an ObjectLoader JSON, without editing it (wireValidate's `object` row): * false = drop the message before any applier runs. The cleaning itself happens in the applier diff --git a/tests/unit/sceneSchema.test.js b/tests/unit/sceneSchema.test.js index 9267f9856..d5dc42903 100644 --- a/tests/unit/sceneSchema.test.js +++ b/tests/unit/sceneSchema.test.js @@ -10,7 +10,7 @@ import fs from 'fs'; import path from 'path'; import * as THREE from 'three'; import { unzipSync, strFromU8 } from 'fflate'; -import { validateObjectJson, validateMaterialSet, MAX_DEPTH, MAX_IMAGE_CHARS, isPlainJson } from '../../src/lib/sceneSchema.js'; +import { validateObjectJson, validateMaterialSet, validateGltfElement, MAX_DEPTH, MAX_IMAGE_CHARS, isPlainJson } from '../../src/lib/sceneSchema.js'; const ROOT = path.resolve(__dirname, '../..'); const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; @@ -83,6 +83,88 @@ describe('real content: nothing is dropped', () => { }); }); +/** every .glb under a dir @param {string} dir @returns {string[]} */ +function glbFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? glbFiles(path.join(dir, e.name)) : e.name.endsWith('.glb') ? [path.join(dir, e.name)] : [])); +} + +describe('real glTF: nothing is dropped', () => { + it('every glTF `object` message of the 1.65 handshake capture passes unchanged', () => { + const wire = JSON.parse(fs.readFileSync(path.join(ROOT, 'tests/fixtures/schema-1.65/wire.json'), 'utf8')); + const gltf = wire.filter((m) => m.type === 'object' && m.uuids != null); + expect(gltf.length).toBeGreaterThanOrEqual(30); // premise + for (const m of gltf) { + const before = JSON.stringify(m.element); + const r = validateGltfElement(m.element); + expect(r.ok).toBe(true); + expect(r.dropped).toEqual([]); + expect(JSON.stringify(m.element)).toBe(before); + } + }); + it('every shipped .glb passes (sizes only, as their loaders check them)', () => { + const files = glbFiles(path.join(ROOT, 'static')); + expect(files.length).toBeGreaterThanOrEqual(10); // premise + for (const f of files) { + const r = validateGltfElement(new Uint8Array(fs.readFileSync(f)).buffer, { uris: false }); + expect(r.ok, path.relative(ROOT, f)).toBe(true); + expect(r.dropped).toEqual([]); + } + }); +}); + +describe('glTF out of schema: extras follow the userData rules', () => { + const doc = (over = {}) => ({ + asset: { version: '2.0' }, + scenes: [{ nodes: [0] }], + nodes: [{ name: 'n', extras: { physics: { mass: 1 } } }], + materials: [{ name: 'm', extras: { tpEdited: true } }], + ...over + }); + it('a clean document is unchanged', () => { + const d = doc(); + const before = JSON.stringify(d); + const r = validateGltfElement(d); + expect(r.ok).toBe(true); + expect(r.dropped).toEqual([]); + expect(JSON.stringify(d)).toBe(before); + }); + it('a reserved key in a node\'s extras is dropped, as in userData', () => { + const d = doc({ nodes: [JSON.parse('{"name":"n","extras":{"__proto__":{"physics":{"mass":1e9}},"ok":1}}')] }); + const r = validateGltfElement(d); + expect(r.ok).toBe(true); + expect(r.dropped.map((x) => x.reason + ' ' + x.path)).toEqual(['userData nodes[0].userData.__proto__']); + expect(Object.hasOwn(d.nodes[0].extras, '__proto__')).toBe(false); + expect(d.nodes[0].extras.ok).toBe(1); + }); + it('a known key of the wrong kind is dropped, in every place three reads extras', () => { + const d = doc({ + extras: { physics: 'x' }, + nodes: [{ extras: { physics: 7 } }], + materials: [{ extras: { tpEdited: 'x' } }], + textures: [{ source: 0, extras: [1, 2] }], + meshes: [{ primitives: [{ attributes: {}, extras: { __topo: 3 } }] }], + extensions: { KHR_lights_punctual: { lights: [{ type: 'point', extras: { physics: false } }] } } + }); + const r = validateGltfElement(d); + expect(r.ok).toBe(true); + expect(r.dropped.length).toBe(6); + expect(d.textures[0].extras).toBeUndefined(); + }); + it('a .glb or JSON text that breaks the rule is refused (it cannot be cleaned in place)', () => { + const text = JSON.stringify({ asset: { version: '2.0' }, nodes: [{ extras: { physics: 7 } }] }); + const r = validateGltfElement(text); + expect(r.ok).toBe(false); + expect(r.reason).toBe('userData'); + expect(validateGltfElement(JSON.stringify(doc())).ok).toBe(true); + }); + it('loadGuardCore\'s own glTF rules still refuse first', () => { + const r = validateGltfElement(doc({ buffers: [{ uri: 'https://example.com/b.bin', byteLength: 4 }] })); + expect(r.ok).toBe(false); + expect(r.reason).toBe('uri'); + }); +}); + /** a minimal valid element around one mesh @param {any} [over] */ function mesh(over = {}) { return { From 6911261506b60f6038d1a8d81c1fcefe36ca8601 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 15:51:51 +0200 Subject: [PATCH 6/7] 101-schema steps 4 + 6: A/B driver, two-peer suite, OPEN-CORE object schema tests/e2e/schema-ab.test.cjs: every template, the 1.65 capture and the scenes repo load with the load check changing nothing, and the first frame (frozen clock, the saved view) is compared with the base build's (AB_WRITE=1 on the base first). Battery: excluded as an A/B driver. tests/e2e/schema-two-peers.test.cjs: A builds every Add-menu kind, ten material types, maps, a map slot with tiling, params, a multi-slot material, a group and its duplicate; B (connected) has the same scene, C (late joiner) the same objects, slots and maps; the load check changes nothing on B or C. Battery: optional. OPEN-CORE.md section 6: the object schema is a contract, not a hook. Co-Authored-By: Claude Opus 5.5 --- OPEN-CORE.md | 23 +++++ tests/e2e/battery.json | 3 + tests/e2e/schema-ab.test.cjs | 94 ++++++++++++++++++++ tests/e2e/schema-two-peers.test.cjs | 130 ++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 tests/e2e/schema-ab.test.cjs create mode 100644 tests/e2e/schema-two-peers.test.cjs diff --git a/OPEN-CORE.md b/OPEN-CORE.md index 6528cd3c6..b820eb574 100644 --- a/OPEN-CORE.md +++ b/OPEN-CORE.md @@ -346,6 +346,29 @@ everyone leaving). Each is inert without a plugin: nothing subscribes until `onS A plugin restoring a SCENE uses `loadRemoteScene({sceneUrl, keepGameId: true})` — the owner reopening their own scene keeps its game id instead of forking it as a remix. +### 6. Object schema (roadmap 101 F2) — a contract, not a hook + +Objects and materials are validated on receive and on load. Every three.js object JSON core takes from a +peer, a file or its own stores passes [src/lib/sceneSchema.js](src/lib/sceneSchema.js) (through +`src/lib/loadGuard.js`) before three parses it, and so does every multi-slot `materials` payload. A CI step +(`npm run check:loaders`) fails on a generic three loader called anywhere else in `src/`. + +What a module or plugin can rely on, and what it must keep to: + +| | the rule | +|---|---| +| object types | three's own (`Object3D`, `Group`, `Mesh`, `SkinnedMesh`, `InstancedMesh`, `Line*`, `Points`, `Sprite`, `LOD`, `Bone`, the two cameras, the lights). Anything else arrives as an `Object3D` with its children | +| material types | the standard ones (`Mesh*Material`, `Line*Material`, `PointsMaterial`, `SpriteMaterial`, `ShadowMaterial`). Any other material arrives as the standard fallback material; custom looks are made with the shader graph | +| fields | the fields three's `toJSON()` writes for that type, each with a value rule. An unknown field is stripped; a bad value is stripped and three uses its default | +| images | embedded data URLs only, each under a size cap | +| `userData` | any plain JSON under a size cap (no `__proto__` / `constructor` / `prototype` keys, bounded depth). The keys core and the shipped modules write keep a per-key kind | + +Every change is counted (the wire diagnostics and one toast per session), never silent. The inventory +(`scripts/schema-inventory.cjs`, kept as `tests/fixtures/schema-1.65/inventory.json`) found that the shipped +modules build only the types above and write only plain-JSON `userData`, so modules get no registration +call. A later release that saves a new field adds it to the type's table in `sceneSchema.js` with a value +rule and a fixture in `tests/unit/sceneSchema.test.js`; a new `userData` key needs nothing. + ## Versioning `CLOUD_HOOKS_VERSION` ([src/lib/cloudHooks.js](src/lib/cloudHooks.js)) is the contract diff --git a/tests/e2e/battery.json b/tests/e2e/battery.json index 6c534dfe2..7d8e68280 100644 --- a/tests/e2e/battery.json +++ b/tests/e2e/battery.json @@ -24,6 +24,7 @@ "play-link-devices": "joined from fix-play-link (device-lab play-link fixes, 2026-11-05; IWER Quest + folded/unfolded phone): not yet measured on a GitHub runner, so it reports and does not gate", "play-entry": "joined from 94-play (roadmap 94 slim play runtime, 2026-11-03): not yet measured on a GitHub runner, so it reports and does not gate", "play-hud-layout": "joined from 94-play (roadmap 94 SP6, 2026-11-03): not yet measured on a GitHub runner, so it reports and does not gate", + "schema-two-peers": "joined from 101-schema (roadmap 101 F2, the object schema: two peers + a late joiner, 2026-11-11): not yet measured on a GitHub runner, so it reports and does not gate", "agent-bridge": "red on the GitHub runner (SwiftShader, no retry) with unchanged code: deps-auto run 5, 2026-10-24", "agent-level-proof": "joined from 85b-int (the 1.60 release proof: one agent session builds a level; fetches a CC0 sound from jsDelivr): not yet measured on a GitHub runner, so it reports and does not gate", "agent-diorama2": "joined from mcp-diorama2 (the diorama build report, 2026-11-04): not yet measured on a GitHub runner, so it reports and does not gate", @@ -302,6 +303,7 @@ }, "suites": { "load-check": 240, + "schema-two-peers": 360, "uir-people": 300, "possess": 120, "starter-recipes-78": 300, @@ -951,6 +953,7 @@ "test data of ~1.3 GB fetched on demand (scripts/fetch-wild-assets.cjs), ~1 h on the pc: run it when importing or Replace changes; SKIPs without the cache": ["import-wild"], "a VR (IWER) editing suite not in the 1.43 integrator battery: triage owed (test-health-1)": ["music-vr-patch","novr-inset","play-here-iwer","vr-ai-panel","vr-ai-voice","vr-bounded-teleport","vr-box-select","vr-chat","vr-collider-edit","vr-controller-handedness","vr-create-face","vr-create-face-facing","vr-edit-caps","vr-edit-menu","vr-editmenu-reposition","vr-editmenu-tabs","vr-editmode-persist","vr-face-correctness","vr-face-edit","vr-face-live-adjust","vr-face-manip","vr-face-move-fix","vr-face-polygon","vr-face-weld","vr-grips-by-mode","vr-helper-eyes","vr-hz-pinch","vr-keyboard","vr-keyboard-search","vr-laser-panels","vr-mesh-undo","vr-multi-prefab","vr-multiselect-ring","vr-nav-suppression","vr-objects-groups","vr-objects-panel","vr-palette","vr-panel-sharpness-all","vr-panels-on-top","vr-peer-approve","vr-peer-hands","vr-peer-hands-net","vr-ping-accuracy","vr-prefabs","vr-props-panel","vr-ptt","vr-radial-stick","vr-ray-hover","vr-reticle-v2","vr-sleeve","vr-snap-menu","vr-source-rebind","vr-stats","vr-stretch","vr-stretch-fix","vr-stretch-sliders","vr-sweep","vr-teleport-handedness","vr-teleport-toggle","vr-two-hand-grab","vr-ungroup","vr-vertex-edit","vr-vertex-handle-bounds","vr-vertex-hold","vr-vertex-polish","vr-vertex-ray","vr-vertex-trigger","vr-window-grab","vr-world-grab","vr-world-reel","vr-worldgrab-presence"], "a docs / screenshot generator, not a gate": ["flow-node-docs","shader-node-docs","vr-eye-shots","schema-capture"], + "an A/B driver against a base build (AB_WRITE=1 frames from the base snapshot first), not a gate": ["schema-ab"], "a frame-rate / timing measurement: run by hand on an --exclusive slot, never in a battery half": ["avatar-knocked-perf","avatars-budget","import-budget","mesh-budget","perf-detailed","perf-live","perf-moment","perf-stale-modules","perf-stutter","place-drag-perf","vr-brushes-perf"], "not in the 1.43 integrator battery (listA5/listB5): kept for focused runs, triage owed (test-health-1)": ["add-menu","add-menu-cursor","advanced-mode","ai-assistant","ai-local-model","ai-presets","ai-reference","anim-pack-refs","animated-base","animation-autokey","animation-autokey-material","animation-bake","animation-look-channels","animation-loop-pause","animation-markers","animation-marquee","animation-node","animation-onion","animation-persist","animation-relative-motion","annotation-anchor","approval-timeout","asset-cache-opfs","audio-devices","audio-engine","audio-harness","audio-pack","audio-patch","audio-sdk","author-kit","avatar-fly","avatar-v2","button","camera-bookmarks","camera-clipping","camera-looks","camera-objects","camera-preview-control","checkpoints","clear-scene","code-workspace","collectibles-v2","collision-groups","colocation-anchor","colocation-ar-mode","colocation-calibrate","colocation-core","colocation-nudge","colocation-presence","connect-pin-slide-43","content-index-cache","contest-starters","controls-state","controls-transform-tint","convert-to-mesh","deleted-folders","deselect-unlock","desktop-face-gizmo","dial-metadata","drawing","dungeon","dungeon-play","duplicate-after-create","duplicate-parity","edit-overlay-gaps","editmesh-focus","editmesh-mode-memory","env-fog-sticks","environment-v2","explorer-columns","explorer-duplicate","explorer-duplicate-scene-folder","explorer-expandable","explorer-inline-input","explorer-menu-icons","explorer-mount-open-peers","explorer-mounts","explorer-mounts-edit","explorer-rename","explorer-tree-floor","faces-nested-toolbar","faces-toolbar","far-clipping","fast-channel-fallback","fast-channel-loss","fb-input","fbx-mtl-import","file-windows-esc","flow-adjustable-params","flow-audit","flow-background","flow-contact-filter","flow-curvy-edges","flow-customnode-io","flow-explorer-floating","flow-input-moddefs","flow-mouse-bindings","flow-new-nodes","flow-node-duplicate","flow-node-undocked","flow-nodes-extended","flow-object-embed","flow-palette","flow-palette-reflow","flow-particle","flow-properties","flow-spawner","flow-window-mobile","gallery-submit","game-editor-flow","game-effects","game-feel-nodes","game-haptics","game-loop","game-loop-v2","game-loop-v3","game-presence","geometry-lock","geometry-params","gizmo-orbit-leak","gizmo-recall","grab-key","graph-games-lint","graph-tidy","graph-tree","grid-snapping","hand-models","hud-actions","hud-content","hud-editor-tools","hud-inputs","hud-nodes","hud-visibility","import-duplicates","import-reduce-gate","kit-entities","launch-inbox","level-templates","level-templates-33","light-aim","light-catalog","llms-txt","load-primitives-first-36","load-supersede-36","material-sharing","material-types","mesh-adjust","mesh-advanced-ops","mesh-bevel","mesh-bridge-normals","mesh-cleanup","mesh-edge-bevel","mesh-edge-gizmo","mesh-edge-mode","mesh-edge-vertex-ops","mesh-edit-display","mesh-edit-materials","mesh-edit-popup","mesh-falloff-rotate-scale","mesh-falloff-sync","mesh-fixes-round2","mesh-fixes-round3","mesh-generation","mesh-gizmo-modes","mesh-grab-uv","mesh-inset-move","mesh-loop-cut","mesh-loop-hardening","mesh-loop-select","mesh-multishell-ops","mesh-ops","mesh-ops-p5b","mesh-pivot-gizmo","mesh-proportional","mesh-quad-select","mesh-selection-undo","mesh-session-undo","mesh-symmetrize","mesh-symmetry-live","mesh-uv-preserve","mesh-vertex-bevel","mesh-vertex-slide","model-preview-controls","module-lifecycle-repo","module-node-io","module-pointer-seam","module-sdk","module-sdk-world","module-seed-graph","module-storage","module-update-on-load","module-world-root","modules-discoverability","multi-select","multiselect-menu","music-clock","music-flow-nodes","music-prefabs","music-sampling","music-template","music-toolbox","net-backoff","net-locks","network-quality","node-editor-open-view","node-search","node-ux-library","note-replies","object-list-drag","object-list-raise","object-list-size","object-origin","object-properties","object-search","object-sync","objectlist-hidden-icon","objectlist-search","open-core-m1","origin-rows","outline-scene-swap","pack-behavior","pack-undo","packs-explorer","palette","palette-groups","particles","path-node","peer-ice-config","peer-variables","pick-tiny-objects","ping","ping-highlights","ping-v2","placeholders-peers","play-free-cursor","play-here","play-reach-jump","pong","prefab-explorer","prefab-library","prefab-propagation","preview-animation","private-scene","problem-report","project-file","project-manifest","race-touch-vr","recording","resync-converge","roadmap-13-notifications-notes","roadmap-13-viewport-camera","save-as-formats","scene-adopt","scene-assets","scene-default-material","scene-duplicate","scene-folders","scene-identity","scene-isolation","scene-levels","scene-load","scene-music","scene-poke","scene-presence","scene-rename","scene-rooms","scene-rooms-matrix","scene-rooms-truth","scene-stress","scene-switch","scene-switch-clear","scene-switch-games","script-guard","sdk-game-seams","sdk-input","sdk-pointer-ray","select-through","selection-extras","session-clock","session-download-formats","session-scene-data","session-zip","sessions-open","sessions-open-peers","sessions-packs","shader-editor","shader-inspector","shader-module-flow","shader-persist","shader-scene-default","shadows","share-gate-defer","share-stash","shared-library","shared-library-copies","sidebar-toggle","small-fixes-37","snap-advanced","spatial-voice","spline-tool","stars-clap","start-view","start-view-hold","stroke-streaming","switcher-mux","template-modules","terrain","topo-channel","towers-levels","trackpad-nav","trackpad-pinch","ui-fixes-15b","underlay","uv-dense","uv-live-faces","uv-materials","uv-pen-pressure","uv-slots","uv-slots-persist","uv-smart-unwrap","uv-target","uv-texture-params","uv-texture-tools","uv-transform","uv-unwrap","uv-unwrap-module","value-graph","variadic-nodes","version","vertices-create-face","view-mode","viewport-selection","viewport-tools-menu","walker-sensors","watch-look","waypoint-viz","wheel-classify"], "the 38 R1 behaviour lock: npm run e2e -- @lock (tests/e2e/lock.json), not the release battery": ["dock-tabs-coexist","flow-dock-mixed","flow-dock-tabs","flow-explorer-dock","inspector-multi-edit","node-drag-fields","units"] diff --git a/tests/e2e/schema-ab.test.cjs b/tests/e2e/schema-ab.test.cjs new file mode 100644 index 000000000..3547c60f0 --- /dev/null +++ b/tests/e2e/schema-ab.test.cjs @@ -0,0 +1,94 @@ +// 101 F2 step 4: real scenes LOOK THE SAME and load UNCHANGED with the object schema in front of +// the loaders. Per scene (every template, the 1.65 capture, and every scene under SCENES_DIR): +// · the load check changes nothing (loadGuard totals stay 0: no field, material, image or object +// dropped) — checked on every build that has the hook; +// · the first frame after the load (frozen clock, the scene's saved view, a chrome-free clip) is +// compared with the same scene's frame from the BASE build. +// Two runs: AB_WRITE=1 against the base build writes /.png; then the lane run compares. +// +// Run: APP_URL=https://theprototype.app:7023/ AB_WRITE=1 AB_DIR= node tests/e2e/schema-ab.test.cjs (base snapshot) +// APP_URL=https://theprototype.app:7022/ AB_DIR= node tests/e2e/schema-ab.test.cjs (lane) +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '../..'); +const WRITE = process.env.AB_WRITE === '1'; +const DIR = path.resolve(process.env.AB_DIR || path.join(ROOT, 'test-results/101-schema/ab')); +const MAX_CHANGED = Number(process.env.MAX_CHANGED || 0.002); // share of the clip whose RGB moved by > 24 +const T0 = 1791200000000; +const CLIP = { x: 190, y: 110, width: 900, height: 500 }; + +/** @param {string} dir @returns {string[]} */ +function scenesIn(dir) { + if (!dir || !fs.existsSync(dir)) return []; + /** @type {string[]} */ + const out = []; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) out.push(...scenesIn(p)); + else if (e.name.endsWith('.tpscene')) out.push(p); + } + return out.sort(); +} + +const files = [ + ...scenesIn(path.join(ROOT, 'static/templates')), + path.join(ROOT, 'tests/fixtures/schema-1.65/capture.tpscene'), + ...scenesIn(process.env.SCENES_DIR || '') +].filter((f) => fs.existsSync(f)); +/** a readable, unique slug per file @param {string} f */ +const slugOf = (f) => path.relative(ROOT, f).replace(/^\.\.\/+/g, '').replace(/[^\w.-]+/g, '_').replace(/_scene\.tpscene$|\.tpscene$/, ''); + +h.run(async () => { + h.check(files.length >= 6, `premise: scenes to compare (${files.length})`); + fs.mkdirSync(DIR, { recursive: true }); + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A', { context: { viewport: { width: 1280, height: 720 } }, storage: { 'water:quality': 'high', autoQuality: 'false' } }); + await A.ctx.addInitScript((t) => { + Date.now = () => t; + }, T0); + await A.page.reload({ waitUntil: 'domcontentloaded' }); + await A.page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK && !!window.__stores.sessions, null, { timeout: 60000 }); + /** @type {string[]} */ + const rows = []; + let worst = 0; + for (const file of files) { + const slug = slugOf(file); + const bytes = Array.from(fs.readFileSync(file)); + const totals = await A.page.evaluate(async (arr) => { + const s = window.__stores; + s.loadGuard?.resetLoadGuard?.(); + if (await new Promise((r) => s.physics.simulating.subscribe(r)())) s.physics.stopSimulation(); + const payload = await s.sessions.readSessionZip(new Uint8Array(arr).buffer); + if (payload.physics) delete payload.physics.simOnLoad; + await s.sessions.applySession(payload, { backup: false }); + s.objectActions.deselectObject?.(); + return s.loadGuard?.loadGuardTotals?.() ?? null; + }, bytes); + await A.page.waitForTimeout(4000); + const png = await A.page.screenshot({ clip: CLIP }); + const changedTotals = totals ? Object.entries(totals).filter(([, v]) => v > 0) : []; + if (totals) h.check(changedTotals.length === 0, `${slug}: the load check changed nothing (${JSON.stringify(totals)})`); + const gold = path.join(DIR, slug + '.png'); + if (WRITE) { + fs.writeFileSync(gold, png); + rows.push(`| ${slug} | written | ${totals ? JSON.stringify(totals) : 'no hook'} |`); + continue; + } + fs.writeFileSync(path.join(DIR, slug + '-lane.png'), png); + if (!fs.existsSync(gold)) { + h.check(false, `${slug}: no base frame (run with AB_WRITE=1 on the base build)`); + continue; + } + const d = await h.frameDelta(A.page, fs.readFileSync(gold), png, 24, 0); + worst = Math.max(worst, d.fraction); + rows.push(`| ${slug} | ${(d.fraction * 100).toFixed(3)}% | ${d.mean.toFixed(3)} | ${totals ? JSON.stringify(totals) : 'no hook'} |`); + h.check(d.fraction <= MAX_CHANGED, `${slug}: same first frame as the base (${(d.fraction * 100).toFixed(3)}% changed, max ${(MAX_CHANGED * 100).toFixed(1)}%)`); + } + fs.writeFileSync( + path.join(DIR, WRITE ? 'base.md' : 'ab.md'), + `# 101-schema A/B (${WRITE ? 'base frames' : 'lane vs base'})\n\nFrozen Date.now(), the scene's saved view, clip ${CLIP.width}x${CLIP.height}; changed = RGB moved by > 24.\n\n| scene | changed | mean | load check totals |\n|---|---|---|---|\n${rows.join('\n')}\n\nworst: ${(worst * 100).toFixed(3)}%\n` + ); + await h.finish(browser); +}); diff --git a/tests/e2e/schema-two-peers.test.cjs b/tests/e2e/schema-two-peers.test.cjs new file mode 100644 index 000000000..5b13851db --- /dev/null +++ b/tests/e2e/schema-two-peers.test.cjs @@ -0,0 +1,130 @@ +// 101 F2 step 6: with the object schema in front of every loader, two peers editing every object and +// material kind see the same scene, and a late joiner (the full handshake sync: the `object` +// messages) gets the same scene too. And the load check changes nothing on real edits: its totals +// stay 0 on every receiving page. +// +// A builds: every Add-menu item, every switchable material type, a textured map, a normal map slot +// with tiling, material params, a multi-slot material (the `materials` message), a group, a +// duplicate of the group. B is connected the whole time; C joins at the end. +// +// Run: APP_URL=https://theprototype.app:7022/ node tests/e2e/schema-two-peers.test.cjs +const h = require('./helpers.cjs'); + +/** a comparable summary of a page's scene, keyed by uuid. `types: false` leaves the material TYPE out: + * the handshake sync sends a mesh as glTF, and glTF carries PBR materials only, so a late joiner has + * always received a Phong / Lambert / Toon / ... material as MeshStandardMaterial (measured the same on + * the v1.65.1 base, before this lane) @param {any} page @param {boolean} [types] */ +const sceneOf = (page, types = true) => + page.evaluate(async (types) => { + const s = window.__stores; + const g = await new Promise((r) => s.objectsGroup.subscribe(r)()); + /** @type {Record} */ + const out = {}; + g.traverse((o) => { + if (o === g || o.userData?.__helper) return; + const mats = [].concat(o.material ?? []).map((m) => (types ? m.type : 'material') + (m.map ? '+map' : '') + (m.normalMap ? '+normal' : '')); + const verts = o.geometry?.attributes?.position?.count ?? 0; + out[o.uuid] = [o.type, o.name, mats.join(','), verts, o.parent === g ? 'root' : o.parent?.uuid].join(' | '); + }); + return out; + }, types); +/** @param {any} page */ +const totalsOf = (page) => page.evaluate(() => window.__stores.loadGuard?.loadGuardTotals?.() ?? null); +/** @param {Record} a @param {Record} b */ +const diff = (a, b) => { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + return [...keys].filter((k) => a[k] !== b[k]).map((k) => `${k}: ${a[k] ?? '(missing)'} vs ${b[k] ?? '(missing)'}`); +}; + +h.run(async () => { + const browser = await h.launch({ args: h.GPU_ARGS }); + const A = await h.setupPage(browser, 'A'); + const B = await h.setupPage(browser, 'B'); + await h.connect(B, A); + for (const p of [A, B]) await p.page.evaluate(() => window.__stores.loadGuard?.resetLoadGuard?.()); + + // ---- A builds every kind, B watching ----------------------------------------------------------- + const built = await A.page.evaluate(async () => { + const s = window.__stores; + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const { primitivesCatalog } = await import('/src/lib/primitivesCatalog.js'); + const cmds = primitivesCatalog.flatMap((g) => g.items.map((i) => i.command)); + for (const c of cmds) { + s.commandsHandler.sceneCommand(c); + await wait(80); + } + await wait(1500); + const g = await new Promise((r) => s.objectsGroup.subscribe(r)()); + const meshes = g.children.filter((o) => o.isMesh && !Array.isArray(o.material)); + const M = s.materialsHandler; + const types = ['MeshBasicMaterial', 'MeshStandardMaterial', 'MeshPhysicalMaterial', 'MeshPhongMaterial', 'MeshLambertMaterial', 'MeshToonMaterial', 'MeshMatcapMaterial', 'MeshNormalMaterial', 'MeshDepthMaterial', 'ShadowMaterial']; + types.forEach((t, k) => meshes[k] && M.switchMaterialType(meshes[k].uuid, t, true)); + const cv = document.createElement('canvas'); + cv.width = cv.height = 16; + const c2 = cv.getContext('2d'); + c2.fillStyle = '#c84'; + c2.fillRect(0, 0, 16, 16); + const url = cv.toDataURL('image/png'); + const blob = await new Promise((r) => cv.toBlob(r, 'image/png')); + const textured = meshes[10]; + M.applyMap(textured, url, 0, null); + const pc = await new Promise((r) => s.peers.subscribe(r)()); + pc.send({ type: 'objectParameters', parameter: 'map', uuid: textured.uuid, map: url }); + const slotted = meshes[11]; + await s.materialMaps.setObjectsMapSlot([slotted.uuid], 'normalMap', new File([blob], 'n_normal.png', { type: 'image/png' }), 0); + s.materialMaps.setTexTransform(slotted.uuid, { repeat: [2, 3], offset: [0.1, 0.2], rotation: 0.3 }, 0); + for (const [k, v] of [['roughness', 0.3], ['metalness', 0.7], ['opacity', 0.6], ['transparent', true], ['emissive', '#223344']]) M.setMaterialParam(meshes[12].uuid, k, v, true); + const multi = meshes.find((o) => o.geometry?.type === 'BoxGeometry' && !Array.isArray(o.material) && o.material.type === 'MeshStandardMaterial') ?? meshes[13]; + M.addMaterialSlot(multi.uuid); + await wait(500); + s.selectedObjects.set([meshes[14].uuid, meshes[15].uuid]); + const groupUuid = s.objectActions.groupSelection(); + await wait(500); + const grp = g.getObjectByProperty('uuid', groupUuid); + if (grp) { + s.selectedObjects.set([grp.uuid]); + await s.objectActions.duplicateSelection(); + } + await wait(500); + return { cmds: cmds.length, group: !!grp, multi: Array.isArray(multi.material) ? multi.material.length : 1 }; + }); + h.check(built.cmds >= 40 && built.multi === 2 && built.group, `premise: A built every kind (${JSON.stringify(built)})`); + + let a = {}; + let b = {}; + await h.eventually( + async () => { + a = await sceneOf(A.page); + b = await sceneOf(B.page); + return diff(a, b); + }, + (d) => d.length === 0, + 'B sees the same scene as A', + 60000 + ); + const dAB = diff(a, b); + h.check(dAB.length === 0, `B (connected) has the same ${Object.keys(a).length} objects, kinds and materials as A` + (dAB.length ? ': ' + dAB.slice(0, 5).join(' ; ') : '')); + const bt = await totalsOf(B.page); + h.check(!!bt && Object.values(bt).every((v) => v === 0), `the load check changed nothing on B (${JSON.stringify(bt)})`); + + // ---- C joins late: the whole scene arrives through the handshake sync --------------------------- + const C = await h.setupPageQuiet(browser, 'C', [A, B]); + await C.page.evaluate(() => window.__stores.loadGuard?.resetLoadGuard?.()); + await h.connect(C, A); + let c = {}; + await h.eventually( + async () => { + a = await sceneOf(A.page, false); + c = await sceneOf(C.page, false); + return diff(a, c); + }, + (d) => d.length === 0, + 'C (late joiner) sees the same scene as A', + 120000 + ); + const dAC = diff(a, c); + h.check(dAC.length === 0, `C (late joiner) has the same ${Object.keys(a).length} objects, kinds, slots and maps as A` + (dAC.length ? ': ' + dAC.slice(0, 5).join(' ; ') : '')); + const ct = await totalsOf(C.page); + h.check(!!ct && Object.values(ct).every((v) => v === 0), `the load check changed nothing on C (${JSON.stringify(ct)})`); + await h.finish(browser); +}); From e6a81fa9ac649cd570cd5b5c8ffd49ef8a49f5b0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 11 Nov 2026 17:32:32 +0200 Subject: [PATCH 7/7] 101-schema step 6: the late joiner is held to the base's own glTF gaps The handshake sync sends a mesh as glTF, so on the v1.65.1 base too a late joiner gets non-PBR materials as MeshStandardMaterial, a space in a name as _, and no mesh with a Normal / Depth material (measured: the same 6 / 2 / 2 on both builds). The suite now fails on any difference of another kind. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/schema-two-peers.test.cjs | 66 ++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/tests/e2e/schema-two-peers.test.cjs b/tests/e2e/schema-two-peers.test.cjs index 5b13851db..7bf7baf9f 100644 --- a/tests/e2e/schema-two-peers.test.cjs +++ b/tests/e2e/schema-two-peers.test.cjs @@ -10,24 +10,22 @@ // Run: APP_URL=https://theprototype.app:7022/ node tests/e2e/schema-two-peers.test.cjs const h = require('./helpers.cjs'); -/** a comparable summary of a page's scene, keyed by uuid. `types: false` leaves the material TYPE out: - * the handshake sync sends a mesh as glTF, and glTF carries PBR materials only, so a late joiner has - * always received a Phong / Lambert / Toon / ... material as MeshStandardMaterial (measured the same on - * the v1.65.1 base, before this lane) @param {any} page @param {boolean} [types] */ -const sceneOf = (page, types = true) => - page.evaluate(async (types) => { +/** a comparable summary of a page's scene, keyed by uuid: type | name | material types (+map/+normal) | verts | parent + * @param {any} page */ +const sceneOf = (page) => + page.evaluate(async () => { const s = window.__stores; const g = await new Promise((r) => s.objectsGroup.subscribe(r)()); /** @type {Record} */ const out = {}; g.traverse((o) => { if (o === g || o.userData?.__helper) return; - const mats = [].concat(o.material ?? []).map((m) => (types ? m.type : 'material') + (m.map ? '+map' : '') + (m.normalMap ? '+normal' : '')); + const mats = [].concat(o.material ?? []).map((m) => m.type + (m.map ? '+map' : '') + (m.normalMap ? '+normal' : '')); const verts = o.geometry?.attributes?.position?.count ?? 0; out[o.uuid] = [o.type, o.name, mats.join(','), verts, o.parent === g ? 'root' : o.parent?.uuid].join(' | '); }); return out; - }, types); + }); /** @param {any} page */ const totalsOf = (page) => page.evaluate(() => window.__stores.loadGuard?.loadGuardTotals?.() ?? null); /** @param {Record} a @param {Record} b */ @@ -36,6 +34,39 @@ const diff = (a, b) => { return [...keys].filter((k) => a[k] !== b[k]).map((k) => `${k}: ${a[k] ?? '(missing)'} vs ${b[k] ?? '(missing)'}`); }; +/** + * THE LATE JOINER'S KNOWN GAPS (measured identical on the v1.65.1 base, before this lane): the handshake + * sync sends a mesh as glTF, which carries PBR materials only and three's glTF loader sanitises names, + * so a late joiner gets every non-PBR material as MeshStandardMaterial, a space in a name as `_`, and + * no mesh whose material the glTF exporter cannot write (MeshNormalMaterial / MeshDepthMaterial). + * A difference of any OTHER kind is new. @param {Record} a @param {Record} c + */ +const lateJoinerDiffs = (a, c) => { + /** @type {Record} */ + const known = { type: 0, name: 0, absent: 0 }; + /** @type {string[]} */ + const other = []; + const parts = (/** @type {string} */ row) => row.split(' | '); + for (const k of new Set([...Object.keys(a), ...Object.keys(c)])) { + if (a[k] === c[k]) continue; + if (a[k] && !c[k] && /Mesh(Normal|Depth)Material/.test(parts(a[k])[2])) { + known.absent++; + continue; + } + if (!a[k] || !c[k]) { + other.push(`${k}: ${a[k] ?? '(missing)'} vs ${c[k] ?? '(missing)'}`); + continue; + } + const [pa, pc] = [parts(a[k]), parts(c[k])]; + const mats = (/** @type {string} */ m) => m.replace(/Mesh\w+Material|ShadowMaterial/g, 'M'); + const sameBut = (/** @type {number} */ i) => pa.every((v, j) => j === i || v === pc[j]); + if (sameBut(2) && mats(pa[2]) === mats(pc[2]) && /MeshStandardMaterial/.test(pc[2])) known.type++; + else if (sameBut(1) && pa[1].replace(/\s/g, '_') === pc[1]) known.name++; + else other.push(`${k}: ${a[k]} vs ${c[k]}`); + } + return { known, other }; +}; + h.run(async () => { const browser = await h.launch({ args: h.GPU_ARGS }); const A = await h.setupPage(browser, 'A'); @@ -114,16 +145,21 @@ h.run(async () => { let c = {}; await h.eventually( async () => { - a = await sceneOf(A.page, false); - c = await sceneOf(C.page, false); - return diff(a, c); + a = await sceneOf(A.page); + c = await sceneOf(C.page); + return lateJoinerDiffs(a, c); }, - (d) => d.length === 0, - 'C (late joiner) sees the same scene as A', + (d) => d.other.length === 0 && Object.keys(c).length > 0, + 'C (late joiner) sees the same scene as A, but for the glTF handshake\'s known gaps', 120000 ); - const dAC = diff(a, c); - h.check(dAC.length === 0, `C (late joiner) has the same ${Object.keys(a).length} objects, kinds, slots and maps as A` + (dAC.length ? ': ' + dAC.slice(0, 5).join(' ; ') : '')); + const dAC = lateJoinerDiffs(a, c); + h.check( + dAC.other.length === 0, + `C (late joiner) has A's ${Object.keys(a).length} objects, kinds, slots and maps; the known glTF gaps are the base's (${JSON.stringify(dAC.known)})` + + (dAC.other.length ? ': ' + dAC.other.slice(0, 5).join(' ; ') : '') + ); + h.check(dAC.known.absent <= 2, `premise: only the Normal / Depth material meshes are absent on C (${dAC.known.absent})`); const ct = await totalsOf(C.page); h.check(!!ct && Object.values(ct).every((v) => v === 0), `the load check changed nothing on C (${JSON.stringify(ct)})`); await h.finish(browser);