117 lines
5.4 KiB
Markdown
117 lines
5.4 KiB
Markdown
# Save / load in a browser app (ES module)
|
|
|
|
## State layout
|
|
```js
|
|
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
|
|
```js
|
|
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:
|
|
```js
|
|
// 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
|
|
```js
|
|
$('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)
|
|
```js
|
|
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:
|
|
```js
|
|
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.
|