5.4 KiB
5.4 KiB
Save / load in a browser app (ES module)
State layout
const state = {
version: 1,
settings: { grid: 1, units: 'mm' },
objects: [], // data records only: {id, type, params, position, rotation, scale, color, hole, children}
nextId: 1,
selection: new Set(), // runtime: not saved
};
const runtime = new Map(); // id -> { mesh, ... } (never inside the records)
const RUNTIME = ['selection']; // top-level fields that are NOT saved
toData / load - save everything except RUNTIME
function toData() {
const data = { ...state };
for (const k of RUNTIME) delete data[k];
assertPlain(data); // on the LIVE data: meshes, cycles, class instances -> error now, not a broken file
return { version: state.version, app: 'myapp', state: structuredClone(data) };
}
function assertPlain(v, path = 'state', seen = new Set()) {
if (v === null || typeof v !== 'object') { if (typeof v === 'number' && !Number.isFinite(v)) throw new Error(`${path} is ${v} - JSON has no NaN/Infinity`); return; }
if (seen.has(v)) throw new Error(`${path} is cyclic - a runtime object leaked into the data (add it to RUNTIME or the runtime map)`);
seen.add(v);
const proto = Object.getPrototypeOf(v);
if (proto !== Object.prototype && proto !== Array.prototype && proto !== null)
throw new Error(`${path} is a ${v.constructor?.name ?? 'class instance'} - store plain data, keep the object in the runtime map`);
for (const k of Object.keys(v)) assertPlain(v[k], `${path}.${k}`, seen);
}
function fromRecord(rec) { // the ONE constructor path (add, paste, load)
const obj = { children: [], ...rec }; // defaults first, record wins
runtime.set(obj.id, { mesh: buildMesh(obj) });
obj.children.forEach(fromRecord);
return obj;
}
function load(data) {
data = migrate(data); // old versions -> current
validate(data); // throws -> current state untouched
clearRuntime();
Object.assign(state, data.state); // replaces objects, settings, nextId, ...
state.selection = new Set(); // re-create runtime fields
state.objects = state.objects.map(fromRecord);
state.nextId = Math.max(state.nextId ?? 1, ...allIds(state.objects).map(i => i + 1)); // never reuse an id
render();
}
const allIds = objs => objs.flatMap(o => [o.id, ...allIds(o.children ?? [])]);
function migrate(data) {
if (data.version === undefined) data = { version: 0, state: data }; // very old: bare state
if (data.version < 1) { data.state.objects.forEach(o => { o.scale ??= [1, 1, 1]; }); data.version = 1; }
return data;
}
function validate(data) {
if (!data?.state || !Array.isArray(data.state.objects)) throw new Error('not a myapp file');
}
assertPlain is what turns "the JSON is silently wrong" into an error at save
time: a three.js mesh, a cyclic parent link or a Set in the data record fails
loudly with the path. Map/Set/typed arrays survive structuredClone but
JSON.stringify turns them into {} - convert those explicitly:
// in toData(): data.tags = [...state.tags]; // Set -> array
// in load(): state.tags = new Set(data.state.tags);
// Float32Array -> Array.from(a); Date -> d.toISOString(); back: new Date(s)
Buttons - nothing but a call
$('save').addEventListener('click', () => download('scene.json', JSON.stringify(toData(), null, 2), 'application/json'));
$('load').addEventListener('click', () => pickFile('.json', true, text => {
try { load(JSON.parse(text)); } catch (e) { alert('Load failed: ' + e.message); }
}));
(download/pickFile are in the single-file-webapp skill, patterns.md.)
Round-trip test (browser-test skill, through the REAL buttons)
module.exports = async (h) => {
await h.eval(() => { __app.add('box', { size: 2 }); __app.add('cylinder', { r: 0.5, h: 3 }); });
await h.eval(() => { const o = __app.state.objects[1]; o.position = [3, 0, 1]; o.hole = true; });
const before = await h.eval(() => JSON.stringify(__app.toData()));
const f = await h.download('#save'); // real export path
if (f.text !== JSON.stringify(JSON.parse(before), null, 2)) throw new Error('save button wrote something else than toData()');
require('fs').writeFileSync('fixtures/scene.json', f.text);
await h.eval(() => __app.reset());
await h.upload('#load', 'fixtures/scene.json'); // real import path
const after = await h.eval(() => JSON.stringify(__app.toData()));
if (after !== before) throw new Error('round trip differs:\n' + before + '\n' + after);
};
Expose toData, add, reset on window.__app.
Completeness
Dump the field paths of the live state (runtime fields included) once:
const fields = await h.eval(() => {
const out = new Set();
(function walk(v, p) {
if (Array.isArray(v)) { v.forEach(x => walk(x, p + '[]')); return; }
if (v && typeof v === 'object' && !(v instanceof Set) && !(v instanceof Map)) {
for (const k of Object.keys(v)) walk(v[k], p ? p + '.' + k : k); return;
}
out.add(p);
})(__app.state, '');
return [...out];
});
require('fs').writeFileSync('state_fields.json', JSON.stringify(fields, null, 2));
Then python <skill-dir>/check_roundtrip.py fixtures/scene.json state_fields.json
lists every state field that the file lacks. Each one is either a RUNTIME
field (add it to the list in the app, so the omission is explicit) or a bug.