@descix/frqtl-node 0.6.0-p0.2 → 0.6.0-p4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +8 -4
  3. package/dist/dist-manifest.json +70 -62
  4. package/dist/eyes/index.js +5 -2
  5. package/dist/frqtl/assets/vendor/acorn.LICENSE +21 -0
  6. package/dist/frqtl/assets/vendor/acorn.PINNED.json +9 -0
  7. package/dist/frqtl/assets/vendor/acorn.mjs +6266 -0
  8. package/dist/frqtl/compat/renderer/lo-drawn-center.js +41 -0
  9. package/dist/frqtl/experiments/_shared/boot-failure.js +60 -0
  10. package/dist/frqtl/objects/FrqtlApp.js +136 -30
  11. package/dist/frqtl/objects/ObjectFactory.js +4 -5
  12. package/dist/frqtl/objects/RecipeContext.js +617 -9
  13. package/dist/frqtl/objects/instruments/hosted-eyes.js +268 -57
  14. package/dist/frqtl/objects/objects/QuantumEmitter.js +1 -1
  15. package/dist/frqtl/objects/record/Record.js +326 -0
  16. package/dist/frqtl/objects/record/open-record.js +22 -0
  17. package/dist/frqtl/objects/schema/index.js +72 -0
  18. package/dist/frqtl/parts/DetectorWall.js +1 -1
  19. package/dist/frqtl/parts/FrqtlControlsPanel.js +4 -2
  20. package/dist/frqtl/parts/FrqtlVision.js +21 -6
  21. package/dist/frqtl/parts/Wall.js +2 -0
  22. package/dist/frqtl/scenes/absorbing-wall-3d.js +18 -14
  23. package/dist/frqtl/scenes/absorbing-wall.js +18 -14
  24. package/dist/frqtl/scenes/atomic-model.js +3 -0
  25. package/dist/frqtl/scenes/big-bang.js +3 -0
  26. package/dist/frqtl/scenes/blackbody.js +14 -11
  27. package/dist/frqtl/scenes/circuit-sat.js +7 -4
  28. package/dist/frqtl/scenes/collision-matrix-3d.js +3 -0
  29. package/dist/frqtl/scenes/counting-wall-3d.js +19 -15
  30. package/dist/frqtl/scenes/counting-wall.js +19 -15
  31. package/dist/frqtl/scenes/double-slit.js +3 -0
  32. package/dist/frqtl/scenes/dse-one-emitter.js +64 -58
  33. package/dist/frqtl/scenes/dse-one-slit.js +59 -55
  34. package/dist/frqtl/scenes/dse-photoelectric.js +28 -24
  35. package/dist/frqtl/scenes/dse-tunnel-rebuild.js +124 -99
  36. package/dist/frqtl/scenes/dse-two-emitters.js +64 -58
  37. package/dist/frqtl/scenes/dse-two-slits.js +69 -65
  38. package/dist/frqtl/scenes/empty.js +3 -0
  39. package/dist/frqtl/scenes/laser-buildup.js +23 -16
  40. package/dist/frqtl/scenes/mirror-3d.js +31 -27
  41. package/dist/frqtl/scenes/mirror-route.js +4 -1
  42. package/dist/frqtl/scenes/mirror.js +24 -20
  43. package/dist/frqtl/scenes/omni-3d.js +7 -4
  44. package/dist/frqtl/scenes/omni-source.js +7 -4
  45. package/dist/frqtl/scenes/self-interference.js +13 -10
  46. package/dist/frqtl/scenes/smoking-gun.js +15 -11
  47. package/dist/frqtl/scenes/straight-walk.js +7 -4
  48. package/dist/frqtl/scenes/sun.js +11 -8
  49. package/dist/frqtl/scenes/two-source.js +22 -17
  50. package/dist/frqtl/scenes/wave.js +10 -7
  51. package/dist/shim/bundle/index.js +112 -96
  52. package/dist/shim/bundle/indexHtml.js +66 -39
  53. package/dist/shim/engine/router.js +2 -2
  54. package/dist/shim/index.js +1 -1
  55. package/dist/shim/notebook/format.js +1 -1
  56. package/dist/shim/notebook/store.js +2 -3
  57. package/dist/shim/page-params/index.js +2 -2
  58. package/dist/shim/project/index.js +20 -5
  59. package/dist/shim/project/link.js +182 -37
  60. package/dist/shim/project/manifest.js +151 -0
  61. package/dist/shim/project/parts-module.js +67 -0
  62. package/dist/shim/project/parts-writer.js +457 -0
  63. package/dist/shim/project/record-format.js +345 -0
  64. package/dist/shim/project/specifiers.js +41 -13
  65. package/dist/shim/registry/index.js +1 -1
  66. package/dist/shim/registry/scene-source.js +44 -30
  67. package/dist/shim/registry/walkthrough.js +13 -12
  68. package/dist/shim/render-state/apply.js +11 -14
  69. package/dist/shim/storage/codec-chains.js +88 -0
  70. package/dist/shim/storage/folders.js +205 -0
  71. package/dist/shim/storage/index.js +64 -8
  72. package/dist/shim/storage/make-namespace-store.js +5 -5
  73. package/llms.txt +2 -2
  74. package/package.json +1 -1
  75. package/dist/shim/bundle/frqnb.js +0 -112
  76. package/dist/shim/bundle/manifest.js +0 -95
  77. package/dist/shim/storage/modules.js +0 -249
  78. package/dist/shim/storage/projects.js +0 -150
@@ -0,0 +1,326 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * Record.js — THE live owner of a Record (the sketch-projects design §6): its change rows, its own clock and
5
+ * its clips. Opened by the facade (`const rec = await exp.startRecord()`, RecipeContext); serialized by the one
6
+ * writer, lib/shim/project/record-format.js serializeRecordFolder.
7
+ *
8
+ * rec.changes READ-ONLY: the rows so far, a new deep-frozen copy per read (open or closed).
9
+ * rec.tick READ-ONLY. The Record's OWN clock: 0 when it opens, +1 per world tick the runner
10
+ * delivers (runner.addTickListener, which survives a rebuild), so it climbs across
11
+ * every re-run inside the Record's world (setDeclared, restart) while the runner's
12
+ * own tick restarts at 0. Every row's tick and every clip span is on this clock.
13
+ * await rec.film({ seconds, fps, everyTicks, stage? })
14
+ * ceil(seconds / 10) back-to-back FrqtlVision recordings of equal frame count, each
15
+ * CLIP_SECONDS long; seconds below CLIP_SECONDS.min are refused. Resolves the clip
16
+ * spans it filmed. The host keeps the world ticking (its loop, or a driver).
17
+ * await rec.stop() → { files, record, clips: [{ file, firstTick, lastTick }] } | { empty: true }.
18
+ * REJECTS (the Record stays open): a change and no clip, a change after the last
19
+ * clip ends, a clip still being filmed, any fault recordFolderFaults finds in its
20
+ * own folder.
21
+ * rec.closed a Promise that settles once, when the Record closes: the stop() value plus
22
+ * `closedBy: 'stop'`, or — when the facade's world is re-authored (a build of
23
+ * text that is not the held text) — the folder of the world before, with
24
+ * `closedBy: 'reauthor'` (rejected when that folder is refused, as stop() is).
25
+ * A refused close nobody READ (the getter records the read) makes the facade's
26
+ * next exp.startRecord() reject, naming it.
27
+ * err.record every rejection of stop() and of `closed` carries { changes, clips } — the
28
+ * Record's rows and clip spans — so a host can name them unheld instead of losing them.
29
+ *
30
+ * `closedBy` and the clip spans are the host's (a lab's own `filmed` list); record.json never carries them.
31
+ *
32
+ * WHERE ITS PARTS COME FROM — nothing here captures a second time:
33
+ * rows the writer's own edits at act end (RecipeContext.toSketch / setDeclared call `_row`); only what
34
+ * was WRITTEN makes a row;
35
+ * clips every FrqtlVision.record on this runner while the Record is open (FrqtlVision asks
36
+ * `openRecordOf(runner)` and hands the recording to `_clipFor`); a recording whose frames / fps
37
+ * falls outside CLIP_SECONDS is refused before it is encoded;
38
+ * parts parts.before / parts.after = linkProject(held text).module.parts at open / at close, each kind
39
+ * by the name the kinds registry resolves (kindNamed);
40
+ * project the held files at close, under project/.
41
+ */
42
+
43
+ import { FrqtlVision } from '../../parts/FrqtlVision.js';
44
+ import { linkProject } from '../../../shim/project/link.js';
45
+ import { serializeRecordFolder, CLIP_SECONDS, BY, CHANGE_KEYS } from '../../../shim/project/record-format.js';
46
+ import { kindNamed } from '../kinds.js';
47
+ import { _setOpenRecord, openRecordOf } from './open-record.js';
48
+
49
+ function _fail(msg) {
50
+ throw new Error('[Record] ' + msg + ' (FAIL-LOUD.)');
51
+ }
52
+
53
+ function _bytesOfBase64(b64) {
54
+ var bin = atob(b64);
55
+ var out = new Uint8Array(bin.length);
56
+ for (var i = 0; i < bin.length; i++) { out[i] = bin.charCodeAt(i); }
57
+ return out;
58
+ }
59
+
60
+ /** linkProject(held).module.parts → { name: [registered kind name, props] } in key order. */
61
+ async function _partsOf(held) {
62
+ var linked = await linkProject(held);
63
+ var parts = linked.module && linked.module.parts;
64
+ if (!parts || typeof parts !== 'object') {
65
+ _fail('the held sketch exports no `parts` block — a Record writes parts.before/after from `export const parts`.');
66
+ }
67
+ var out = {};
68
+ Object.keys(parts).forEach(function (name) {
69
+ var e = parts[name];
70
+ var Kind = Array.isArray(e) ? e[0] : null;
71
+ if (typeof Kind !== 'function') { _fail('parts.' + name + ' carries no kind class.'); }
72
+ kindNamed(Kind.name); // the name the registry resolves (THROWS naming an unregistered kind)
73
+ out[name] = [Kind.name, e[1]];
74
+ });
75
+ return out;
76
+ }
77
+
78
+ /** The held project's files, copied (text values). */
79
+ function _heldFiles(held) {
80
+ return Object.assign({}, held.files);
81
+ }
82
+
83
+ /**
84
+ * @param {object} exp — the RecipeContext that opens it (its runner and its held project)
85
+ * @private — construct through exp.startRecord()
86
+ */
87
+ function Record(exp) {
88
+ var self = this;
89
+ this._exp = exp;
90
+ this._runner = exp.runner;
91
+ this._tick = 0;
92
+ this._rows = [];
93
+ this._clips = [];
94
+ this._inflight = 0;
95
+ this._open = true;
96
+ this._stopping = false;
97
+ this._filmArm = null;
98
+ this._before = null;
99
+ this._closedSettle = null;
100
+ this._closedRead = false;
101
+ this._refused = null;
102
+ this._closed = new Promise(function (resolve, reject) { self._closedSettle = { resolve: resolve, reject: reject }; });
103
+ // Observed here so node does not crash on an unhandled rejection; a refused close nobody read is LOUD at the next
104
+ // act instead (RecipeContext.startRecord refuses until `rec.closed` has been read).
105
+ this._closed.then(null, function () {});
106
+ this._settled = this._closed.then(function () {}, function () {});
107
+ this._onTick = function () { self._tick += 1; };
108
+ this._off = this._runner.addTickListener(this._onTick);
109
+ _setOpenRecord(this._runner, this);
110
+ }
111
+
112
+ Object.defineProperty(Record.prototype, 'tick', { get: function () { return this._tick; } });
113
+ /** A deep-frozen copy. */
114
+ function _frozen(v) {
115
+ if (v === null || typeof v !== 'object') { return v; }
116
+ var o = Array.isArray(v) ? v.map(_frozen) : Object.keys(v).reduce(function (a, k) { a[k] = _frozen(v[k]); return a; }, {});
117
+ return Object.freeze(o);
118
+ }
119
+ /**
120
+ * rec.changes — the rows so far, a NEW deep-frozen copy per read (record.json's `changes` vocabulary), on an open or a
121
+ * closed Record. One act's rows: `const n = rec.changes.length; await exp.toSketch({ by }); rec.changes.slice(n)`.
122
+ */
123
+ Object.defineProperty(Record.prototype, 'changes', { get: function () {
124
+ return _frozen(this._rows.map(function (r) { var o = {}; CHANGE_KEYS.forEach(function (k) { o[k] = r[k]; }); return o; }));
125
+ } });
126
+ /** rec.closed — reading it records that the host has heard how the Record closed. */
127
+ Object.defineProperty(Record.prototype, 'closed', { get: function () { this._closedRead = true; return this._closed; } });
128
+
129
+ /** Attach what the Record holds to a refusal — `err.record = { changes, clips }` — so a host can name it unheld. */
130
+ Record.prototype._carry = function (err) {
131
+ var e = (err instanceof Error) ? err : new Error(String(err));
132
+ e.record = {
133
+ changes: this._rows.map(function (r) { return JSON.parse(JSON.stringify(r)); }),
134
+ clips: this._clips.map(function (c) { return { firstTick: c.firstTick, lastTick: c.lastTick }; })
135
+ };
136
+ return e;
137
+ };
138
+
139
+ /** Read parts.before (the held text at open). @private */
140
+ Record.prototype._ready = async function (held) {
141
+ this._held = held;
142
+ this._before = await _partsOf(held);
143
+ return this;
144
+ };
145
+
146
+ /** Throws unless `by` is one of BY (while a Record is open, every act names who acted). */
147
+ Record.requireBy = function (by, where) {
148
+ if (BY.indexOf(by) === -1) {
149
+ throw new TypeError('[' + where + '] a Record is open: the act names who acted — { by: ' + BY.map(function (b) {
150
+ return JSON.stringify(b); }).join(' | ') + ' } (record-format.js BY); got ' + JSON.stringify(by) + '. (FAIL-LOUD.)');
151
+ }
152
+ return by;
153
+ };
154
+
155
+ /** One change row, at the Record's current tick, in the order acted. @private */
156
+ Record.prototype._row = function (row) {
157
+ if (!this._open) { _fail('this Record is closed — no row is written into it.'); }
158
+ if (this._stopping) { _fail('this Record is stopping — an act during stop() belongs to the next Record.'); }
159
+ this._rows.push({ tick: this._tick, name: row.name, op: row.op, by: row.by, from: row.from, to: row.to });
160
+ };
161
+
162
+ /**
163
+ * The clip hook. FrqtlVision calls it with the normalized record() request, BEFORE it subscribes; it THROWS
164
+ * when the request cannot be a CLIP_SECONDS clip, and otherwise returns the recording's session:
165
+ * sample(i) called as frame i is sampled (inside the tick fan-out), reads this Record's tick
166
+ * check(frames) called before the encoder is finalized; THROWS outside CLIP_SECONDS
167
+ * end(envelope) the encoded clip joins the Record (ignored once the Record is closed)
168
+ * drop() the recording failed
169
+ * @private
170
+ */
171
+ Record.prototype._clipFor = function (req) {
172
+ var self = this;
173
+ var span = CLIP_SECONDS.min + '–' + CLIP_SECONDS.max + ' s';
174
+ var advice = 'film inside an open Record with rec.film({ seconds, fps, everyTicks }), or record ' + span +
175
+ ' (frames / fps) per clip';
176
+ var planned = null;
177
+ if (req.stopAt.ticks !== undefined) { planned = Math.min(req.maxFrames, Math.floor(req.stopAt.ticks / req.everyTicks) + 1); }
178
+ var bound = planned === null ? req.maxFrames : planned;
179
+ var secs = function (n) { return n / req.fps; };
180
+ if ((planned !== null && (secs(planned) < CLIP_SECONDS.min || secs(planned) > CLIP_SECONDS.max)) ||
181
+ (planned === null && secs(bound) < CLIP_SECONDS.min)) {
182
+ _fail('a Record is open on this runner, so this recording is one of its clips, and a clip is ' + span +
183
+ ' long: this one is ' + (planned !== null ? secs(planned) : 'at most ' + secs(bound)) + ' s (' + bound +
184
+ ' frames at ' + req.fps + ' fps) — ' + advice + '.');
185
+ }
186
+ var arm = this._filmArm;
187
+ this._filmArm = null;
188
+ var ticks = [];
189
+ var done = false;
190
+ this._inflight += 1;
191
+ function settle() { if (!done) { done = true; self._inflight -= 1; } }
192
+ return {
193
+ sample: function (i) {
194
+ ticks[i] = self._tick;
195
+ if (arm && i === req.maxFrames - 1) { var next = arm.onLast; arm = null; next(); }
196
+ },
197
+ check: function (frames) {
198
+ if (secs(frames) < CLIP_SECONDS.min || secs(frames) > CLIP_SECONDS.max) {
199
+ settle();
200
+ _fail('a Record is open on this runner, so this recording is one of its clips, and a clip is ' + span +
201
+ ' long: this one stopped at ' + secs(frames) + ' s (' + frames + ' frames at ' + req.fps + ' fps) — ' + advice + '.');
202
+ }
203
+ },
204
+ end: function (env) {
205
+ settle();
206
+ if (!self._open) { return; }
207
+ var n = env.track.frames.length;
208
+ self._clips.push({ bytes: _bytesOfBase64(env.encoded.bytes), firstTick: ticks[0], lastTick: ticks[n - 1] });
209
+ },
210
+ drop: function () { settle(); }
211
+ };
212
+ };
213
+
214
+ /**
215
+ * rec.film({ seconds, fps, everyTicks, stage? }) → Promise<[{ file?, firstTick, lastTick }]> — ceil(seconds/10)
216
+ * back-to-back segments of equal frame count. Each segment's recording is started INSIDE the previous one's last
217
+ * sampled tick (the tick fan-out dispatches a snapshot, so the next subscription fires from the next tick): the
218
+ * segments are contiguous on the world's clock whatever drives it. REFUSES: seconds below CLIP_SECONDS.min, a
219
+ * seconds × fps that does not split into equal whole-frame segments.
220
+ */
221
+ Record.prototype.film = function (opts) {
222
+ var self = this;
223
+ try {
224
+ if (!this._open) { _fail('this Record is closed — open the next one (exp.startRecord()) to film.'); }
225
+ if (!opts || typeof opts !== 'object') { _fail('rec.film({ seconds, fps, everyTicks, stage? })'); }
226
+ Object.keys(opts).forEach(function (k) {
227
+ if (['seconds', 'fps', 'everyTicks', 'stage'].indexOf(k) === -1) { _fail('rec.film takes { seconds, fps, everyTicks, stage? }, not "' + k + '".'); }
228
+ });
229
+ var s = opts.seconds, fps = opts.fps, every = opts.everyTicks;
230
+ if (typeof s !== 'number' || !isFinite(s) || s < CLIP_SECONDS.min) {
231
+ _fail('rec.film: seconds is at least ' + CLIP_SECONDS.min + ' (CLIP_SECONDS), got ' + JSON.stringify(s) + '.');
232
+ }
233
+ if (!Number.isInteger(fps) || fps < 1) { _fail('rec.film: fps is a whole number ≥ 1 (the playback rate), got ' + JSON.stringify(fps) + '.'); }
234
+ if (!Number.isInteger(every) || every < 1) { _fail('rec.film: everyTicks is a whole number ≥ 1 (the capture cadence in ticks), got ' + JSON.stringify(every) + '.'); }
235
+ var n = Math.ceil(s / CLIP_SECONDS.max);
236
+ var total = s * fps;
237
+ if (!Number.isInteger(total) || total % n !== 0) {
238
+ _fail('rec.film: ' + s + ' s at ' + fps + ' fps is ' + total + ' frames, which does not split into ' + n +
239
+ ' equal whole-frame segments of at most ' + CLIP_SECONDS.max + ' s — choose seconds × fps divisible by ' + n + '.');
240
+ }
241
+ var f = total / n;
242
+ var req = { everyTicks: every, fps: fps, maxFrames: f, stopAt: { ticks: (f - 1) * every } };
243
+ if (opts.stage !== undefined) { req.stage = opts.stage; }
244
+ var vision = FrqtlVision.attach(this._runner);
245
+ var first = this._clips.length;
246
+ // One deferred per segment: segment k+1 exists once segment k sampled its last frame.
247
+ var segs = [];
248
+ for (var k = 0; k < n; k++) {
249
+ (function () { var d = {}; d.promise = new Promise(function (res, rej) { d.resolve = res; d.reject = rej; }); segs.push(d); })();
250
+ }
251
+ var started = [];
252
+ var start = function (k) {
253
+ started[k] = true;
254
+ self._filmArm = (k < n - 1) ? { onLast: function () { start(k + 1); } } : null;
255
+ var p = vision.record(req);
256
+ segs[k].resolve(p);
257
+ p.then(function () {
258
+ if (k + 1 < n && !started[k + 1]) {
259
+ segs[k + 1].reject(new Error('[Record.film] segment ' + (k + 1) + ' of ' + n + ' ended without sampling its last frame, ' +
260
+ 'so segment ' + (k + 2) + ' never started. (FAIL-LOUD.)'));
261
+ }
262
+ }, function (e) {
263
+ self._filmArm = null;
264
+ for (var j = k + 1; j < n; j++) { if (!started[j]) { started[j] = true; segs[j].reject(e); } }
265
+ });
266
+ };
267
+ start(0);
268
+ return Promise.all(segs.map(function (d) { return d.promise; })).then(function () {
269
+ return self._clips.slice(first).map(function (c) { return { firstTick: c.firstTick, lastTick: c.lastTick }; });
270
+ });
271
+ } catch (e) { return Promise.reject(e); }
272
+ };
273
+
274
+ /** Serialize this Record's folder from `held` (the files it snapshots). @private */
275
+ Record.prototype._folder = async function (held) {
276
+ var after = await _partsOf(held);
277
+ return serializeRecordFolder({
278
+ changes: this._rows.slice(), clips: this._clips.slice(),
279
+ partsBefore: this._before, partsAfter: after, project: _heldFiles(held)
280
+ });
281
+ };
282
+
283
+ /** Detach from the world (no row, tick or clip after this). @private */
284
+ Record.prototype._detach = function () {
285
+ this._open = false;
286
+ if (this._off) { this._off(); this._off = null; }
287
+ _setOpenRecord(this._runner, null);
288
+ this._exp._record = null;
289
+ };
290
+
291
+ /** rec.stop() — see the module docstring. */
292
+ Record.prototype.stop = async function () {
293
+ var self = this;
294
+ var refuse = function (msg) { try { _fail(msg); } catch (e) { throw self._carry(e); } };
295
+ if (!this._open) { refuse('this Record is already closed' + (this._closedBy ? ' (closedBy: ' + this._closedBy + ')' : '') + ' — read rec.closed.'); }
296
+ if (this._stopping) { refuse('stop() is already running.'); }
297
+ if (this._inflight) { refuse(this._inflight + ' clip(s) are still being filmed — await rec.film (or the record()) before stop().'); }
298
+ this._stopping = true;
299
+ var out;
300
+ try { out = await this._folder(this._exp._project); }
301
+ catch (e) { throw this._carry(e); }
302
+ finally { this._stopping = false; }
303
+ this._closedBy = 'stop';
304
+ this._detach();
305
+ this._closedSettle.resolve(Object.assign({}, out, { closedBy: 'stop' }));
306
+ return out;
307
+ };
308
+
309
+ /**
310
+ * The facade re-authors its world: close with the folder of the world BEFORE (`held` = the held project before
311
+ * the build). Synchronous detach; the folder settles `closed`. @private
312
+ */
313
+ Record.prototype._closeOnReauthor = function (held) {
314
+ var self = this;
315
+ this._closedBy = 'reauthor';
316
+ this._detach();
317
+ this._folder(held).then(function (out) {
318
+ self._closedSettle.resolve(Object.assign({}, out, { closedBy: 'reauthor' }));
319
+ }, function (e) {
320
+ self._refused = self._carry(e);
321
+ self._refused.closedBy = 'reauthor';
322
+ self._closedSettle.reject(self._refused);
323
+ });
324
+ };
325
+
326
+ export { Record, openRecordOf };
@@ -0,0 +1,22 @@
1
+ /* Copyright 2023-2026 by Essam Abadir */
2
+
3
+ /**
4
+ * open-record.js — which Record is open on a runner: the ONE store FrqtlVision asks (its clip hook) and the
5
+ * Record writes. One open Record per runner; a second open is refused by the facade (RecipeContext.startRecord).
6
+ *
7
+ * openRecordOf(runner) → Record | null
8
+ */
9
+
10
+ var _open = new WeakMap();
11
+
12
+ /** @param {object} runner @returns {object|null} the Record open on it */
13
+ function openRecordOf(runner) {
14
+ return (runner && _open.get(runner)) || null;
15
+ }
16
+
17
+ /** @private — Record.js only */
18
+ function _setOpenRecord(runner, rec) {
19
+ if (rec) { _open.set(runner, rec); } else { _open.delete(runner); }
20
+ }
21
+
22
+ export { openRecordOf, _setOpenRecord };
@@ -42,6 +42,7 @@ import { wallBehaviorV2ValuesMap as _wallBehaviorV2ValuesMap, bounceDirectionVal
42
42
  import * as FrameDataSchema from '../../../shim/frame-data/index.js';
43
43
  import * as FieldDescriptorSchema from '../../../shim/field-descriptor/index.js';
44
44
  import * as ValueNormalizer from './valueNormalizer.js';
45
+ import * as LoDrawnCenter from '../../compat/renderer/lo-drawn-center.js';
45
46
  import { SCOPE_DESCRIPTORS, shapeValuesMap } from './scopes.js';
46
47
  import { kindOf, registeredKinds, placeableKinds } from '../kinds.js';
47
48
 
@@ -135,6 +136,18 @@ import { kindOf, registeredKinds, placeableKinds } from '../kinds.js';
135
136
  }
136
137
  }
137
138
 
139
+ /** A deep copy of arrays and plain objects; any other value (a primitive, a class instance) as it is. */
140
+ function _plainCopy(v) {
141
+ if (v === null || typeof v !== 'object') { return v; }
142
+ if (Array.isArray(v)) { return v.map(_plainCopy); }
143
+ if (ArrayBuffer.isView(v)) { return Array.from(v); }
144
+ var proto = Object.getPrototypeOf(v);
145
+ if (proto !== Object.prototype && proto !== null) { return v; }
146
+ var out = {};
147
+ Object.keys(v).forEach(function (k) { out[k] = _plainCopy(v[k]); });
148
+ return out;
149
+ }
150
+
138
151
  var ObjectSchema = {
139
152
  /**
140
153
  * get(name) → the descriptor: a kind a module registered in THIS realm (objects/kinds.js),
@@ -212,6 +225,65 @@ import { kindOf, registeredKinds, placeableKinds } from '../kinds.js';
212
225
  * @param {string} kind
213
226
  * @returns {object}
214
227
  */
228
+ /**
229
+ * captureProps(obj, kind) → the plain props bag of a live object, read through its kind's descriptor:
230
+ * every declared field whose value is defined, deep-copied (arrays and plain objects; any other object
231
+ * is left as the value it is, so a writer refuses it by name); a `rect` field read as a {left, top, w, h}
232
+ * shape is captured in its write form: RECT_TUPLE[6] when it carries a z-extent (LoDrawnCenter.hasZ),
233
+ * else RECT_TUPLE[4]. A `writeOnly` field (a live reposition
234
+ * adapter — SphereEmitter.rect, Tunnel.rect) is skipped: the part's real position fields are captured.
235
+ * THROWS for a kind with no descriptor: there is no descriptor-less walk.
236
+ * Consumers: RecipeContext (the parts writer's baseline and its diff), the IDE's inspector reads.
237
+ * @param {object} obj — a live declared object
238
+ * @param {string} kind — its kind (runner.kindOf(obj))
239
+ * @returns {object}
240
+ */
241
+ captureProps: function (obj, kind) {
242
+ var descriptor = _descriptor(kind);
243
+ if (!descriptor || !Array.isArray(descriptor.fields)) {
244
+ throw new Error('[ObjectSchema] captureProps(obj, "' + kind + '"): no descriptor for kind "' + kind +
245
+ '" in this realm — a capture reads the kind\'s declared fields, never the object\'s own keys. (FAIL-LOUD.)');
246
+ }
247
+ var props = {};
248
+ for (var i = 0; i < descriptor.fields.length; i++) {
249
+ var field = descriptor.fields[i];
250
+ if (field.writeOnly === true) { continue; }
251
+ var val = obj[field.name];
252
+ if (val === undefined) { continue; }
253
+ // A `rect` field READS as a {left, top, w, h} shape (a Rectangle) but is WRITTEN as the
254
+ // tuple whose order valueNormalizer.js::RECT_TUPLE owns: the capture is the write form.
255
+ // A rect with a z-extent (LoDrawnCenter.hasZ) is captured in the 6-number form, a flat one in the
256
+ // 4-number form, so a baseline and a live capture agree on form and a z move is a `rect` diff.
257
+ if (field.type === 'rect' && val && typeof val === 'object' && !Array.isArray(val)) {
258
+ var form = LoDrawnCenter.hasZ(val) ? 6 : 4;
259
+ var tuple = ValueNormalizer.RECT_TUPLE[form].map(function (k) {
260
+ return k === 'd' ? LoDrawnCenter.zExtent(val) : val[k];
261
+ });
262
+ if (tuple.every(function (n) { return typeof n === 'number'; })) {
263
+ props[field.name] = tuple;
264
+ continue;
265
+ }
266
+ }
267
+ props[field.name] = _plainCopy(val);
268
+ }
269
+ return props;
270
+ },
271
+ /**
272
+ * derivedReads(kind) → [{ name, field, tuple, key }] — the kind's readonly reads that a write field owns
273
+ * (a descriptor row's `derivedFrom`: e.g. Wall `z` = RECT_TUPLE[6] key 'z' of `rect`). Data only: the
274
+ * write authority stays the named field. THROWS for a kind with no descriptor.
275
+ * @param {string} kind
276
+ * @returns {{name: string, field: string, tuple: number, key: string}[]}
277
+ */
278
+ derivedReads: function (kind) {
279
+ var descriptor = _descriptor(kind);
280
+ if (!descriptor || !Array.isArray(descriptor.fields)) {
281
+ throw new Error('[ObjectSchema] derivedReads("' + kind + '"): no descriptor for kind "' + kind + '". (FAIL-LOUD.)');
282
+ }
283
+ return descriptor.fields.filter(function (f) { return !!f.derivedFrom; }).map(function (f) {
284
+ return { name: f.name, field: f.derivedFrom.field, tuple: f.derivedFrom.tuple, key: f.derivedFrom.key };
285
+ });
286
+ },
215
287
  defaultPropsFor: function (kind) {
216
288
  var row = kindOf(kind);
217
289
  return (row && row.defaults) ? row.defaults : {};
@@ -283,7 +283,7 @@ import { registerKind } from '../objects/kinds.js';
283
283
  if (tickData != null && tickData === this._lastTickData) {
284
284
  throw new Error(
285
285
  '[DetectorWall] getCollisionHistogram() called twice with the SAME TickData ' +
286
- 'object — each tick must be consumed exactly once (fraqtl#61 double-count guard). ' +
286
+ 'object — each tick must be consumed exactly once, or its collisions are counted twice. ' +
287
287
  'Call universe.doTick() again before re-consuming.'
288
288
  );
289
289
  }
@@ -173,8 +173,10 @@ function _dispatchControlsCall(registry, fn, args) {
173
173
  hit.kind + '", not "action". (FAIL-LOUD.)');
174
174
  }
175
175
  var ctxA = registry._controlBindCtx || {};
176
- _ControlActionNode.resolveAction(hit.action, { runner: ctxA.runner }).run();
177
- return { action: hit.action };
176
+ // `done` settles when the action has (a restart replays asynchronously: runner.restart() is a
177
+ // Promise for every scene); a failed replay REJECTS it, so a host that awaits it sees the error.
178
+ var ran = _ControlActionNode.resolveAction(hit.action, { runner: ctxA.runner }).run();
179
+ return { action: hit.action, done: Promise.resolve(ran) };
178
180
  }
179
181
  if (fn === 'setControlValue') {
180
182
  var name2 = args[0], id = args[1], value = args[2];
@@ -183,6 +183,7 @@ import * as CaptureEnvelope from '../../shim/render/capture-envelope.js';
183
183
  import { CameraPath } from './CameraPath.js';
184
184
  import { FrqtlCaptureHost } from '../objects/instruments/capture-host.js';
185
185
  import { vendorUrl } from '../compat/vendor.js';
186
+ import { openRecordOf } from '../objects/record/open-record.js';
186
187
 
187
188
  function _rgba8Format(RS) { return RS.STAGE_FORMATS[RS.finalStage()]; }
188
189
 
@@ -574,16 +575,24 @@ function _record(runner, RS, opts) {
574
575
  'localhost). The encoder is not optional — there is no second path');
575
576
  }
576
577
 
578
+ // THE CLIP HOOK: while a Record is open on this runner, every recording is one of its clips. The Record
579
+ // refuses a request that cannot be a CLIP_SECONDS clip (before anything subscribes) and keeps the clip's
580
+ // ticks and bytes; this recorder holds no Record state of its own (objects/record/Record.js _clipFor).
581
+ var openRec = openRecordOf(runner);
582
+ var clip = openRec ? openRec._clipFor(req) : null;
583
+
577
584
  // Everything that can THROW synchronously happens BEFORE the sampler subscribes: a
578
585
  // CameraPath refused at attach (no pose control, an undeclared pose field) must not leave
579
586
  // a subscribed sampler holding the clock behind a rejection nobody can reach.
580
587
  var path = null;
581
588
  if (req.stopAt.path) {
582
- path = CameraPath.attach(runner, {
583
- waypoints: req.stopAt.path.waypoints,
584
- loop: !!req.stopAt.path.loop,
585
- waypointTimeoutMs: req.stopAt.path.waypointTimeoutMs
586
- });
589
+ try {
590
+ path = CameraPath.attach(runner, {
591
+ waypoints: req.stopAt.path.waypoints,
592
+ loop: !!req.stopAt.path.loop,
593
+ waypointTimeoutMs: req.stopAt.path.waypointTimeoutMs
594
+ });
595
+ } catch (e) { if (clip) { clip.drop(); } throw e; }
587
596
  }
588
597
  var frames = [];
589
598
  var inflight = [];
@@ -628,6 +637,7 @@ function _record(runner, RS, opts) {
628
637
  _fail('record', 'the recording stopped before a single still was captured — nothing to ' +
629
638
  'encode. Is the runner advancing ticks?');
630
639
  }
640
+ if (clip) { clip.check(frames.length); }
631
641
  return encodeChain;
632
642
  }).then(function (enc) {
633
643
  return enc.output.finalize().then(function () { return enc.output.getMimeType(); })
@@ -654,7 +664,11 @@ function _record(runner, RS, opts) {
654
664
  truncated: truncated
655
665
  });
656
666
  });
657
- }).then(settle.resolve, function (e) {
667
+ }).then(function (env) {
668
+ if (clip) { clip.end(env); }
669
+ settle.resolve(env);
670
+ }, function (e) {
671
+ if (clip) { clip.drop(); }
658
672
  encoderReady.then(function (enc) { return enc.output.cancel(); }).then(null, function () {});
659
673
  settle.reject(e);
660
674
  });
@@ -670,6 +684,7 @@ function _record(runner, RS, opts) {
670
684
  return true;
671
685
  },
672
686
  onStill: function (p, i) {
687
+ if (clip) { clip.sample(i); }
673
688
  var handled = p.then(function (still) {
674
689
  if (i === 0) {
675
690
  scene = still.scene;
@@ -969,6 +969,7 @@ import { wallBehaviorV2ValuesMap as _wallBehaviorV2ValuesMap, bounceDirectionVal
969
969
  {
970
970
  name: 'depth',
971
971
  type: 'readonly',
972
+ derivedFrom: { field: 'rect', tuple: 6, key: 'd' },
972
973
  unit: 'px',
973
974
  engineVisible: false,
974
975
  description: 'The wall\'s z-slab depth (z-extent) in physics pixels — 0 for a flat 2D ' +
@@ -978,6 +979,7 @@ import { wallBehaviorV2ValuesMap as _wallBehaviorV2ValuesMap, bounceDirectionVal
978
979
  {
979
980
  name: 'z',
980
981
  type: 'readonly',
982
+ derivedFrom: { field: 'rect', tuple: 6, key: 'z' },
981
983
  unit: 'px',
982
984
  engineVisible: false,
983
985
  description: 'The wall\'s z placement (near-face … far-face CENTER) in physics pixels ' +
@@ -5,29 +5,33 @@
5
5
  *
6
6
  * A sketch file: the WHOLE scene as one ES module, `export default function buildExperiment(exp)`. The world
7
7
  * (its co-init call, `exp.createCanvas` / `exp.createScene` — the one place its size is written),
8
- * the parts (`exp.addObject`) and the render intent are all here; the sugar
8
+ * the parts (`export const parts` + `exp.declare`, or `exp.addObject`) and the render intent are all here; the sugar
9
9
  * objects, renderer and engine are imports. Registered by lib/shim/registry/walkthrough.js
10
10
  * (rung cl1b-absorbing-wall-3d).
11
11
  */
12
12
 
13
- import { SphereEmitter } from '@descix/frqtl-web/parts';
13
+ import { SphereEmitter, Wall } from '@descix/frqtl-web/parts';
14
14
 
15
- export default function buildExperiment(exp) {
16
- // Collision Lab 1B · 3D — a volumetric (spherical) source into a 3D ABSORB wall
17
- // slab (z-slab centered on 0 so the 3D spray collides); orbit the camera to view.
18
- var W = 600, H = 400; // THE WORLD this scene states (its co-init call below)
19
- var cy = Math.round(H / 2);
20
- var SRC = {
15
+ // Collision Lab 1B · 3D — a volumetric (spherical) source into a 3D ABSORB wall
16
+ // slab (z-slab centered on 0 so the 3D spray collides); orbit the camera to view.
17
+ const W = 600, H = 400; // THE WORLD this scene states (its co-init call below)
18
+ const cy = Math.round(H / 2);
19
+
20
+ export const parts = {
21
+ source: [SphereEmitter, {
21
22
  preset: 'spherical', is2D: false, center: [Math.round(W * 0.15), cy],
22
23
  chargeMode: 'random', fundamentalDimension: 2, wavelength: 32,
23
24
  burstSize: 24, emitEveryTicks: 1
24
- };
25
- exp.createScene(W, H, 600, SphereEmitter.worldCfg(SRC)); // THE WORLD: a 3D scene + universe, 600x400x600 — first, then parts
26
- exp.addObject('SphereEmitter', SRC);
27
- exp.addObject('Wall', {
25
+ }],
26
+ absorber: [Wall, {
28
27
  rect: [Math.round(W * 0.6), Math.round(H * 0.2), 20, Math.round(H * 0.6)],
29
28
  z: -80, depth: 160,
30
29
  collisionBehavior: 'absorb', capacity: 64,
31
- name: 'absorber', color: [150, 90, 210]
32
- });
30
+ color: [150, 90, 210]
31
+ }],
32
+ };
33
+
34
+ export default function buildExperiment(exp) {
35
+ exp.createScene(W, H, 600, SphereEmitter.worldCfg(parts.source[1])); // THE WORLD: a 3D scene + universe, 600x400x600 — first, then parts
36
+ exp.declare(parts);
33
37
  }
@@ -5,28 +5,32 @@
5
5
  *
6
6
  * A sketch file: the WHOLE scene as one ES module, `export default function buildExperiment(exp)`. The world
7
7
  * (its co-init call, `exp.createCanvas` / `exp.createScene` — the one place its size is written),
8
- * the parts (`exp.addObject`) and the render intent are all here; the sugar
8
+ * the parts (`export const parts` + `exp.declare`, or `exp.addObject`) and the render intent are all here; the sugar
9
9
  * objects, renderer and engine are imports. Registered by lib/shim/registry/walkthrough.js
10
10
  * (rung cl1b-absorbing-wall).
11
11
  */
12
12
 
13
- import { SphereEmitter } from '@descix/frqtl-web/parts';
13
+ import { SphereEmitter, Wall } from '@descix/frqtl-web/parts';
14
14
 
15
- export default function buildExperiment(exp) {
16
- // Collision Lab 1B (Absorbing Wall) — ABSORB v2 (Track A): the wall LO is backed by
17
- // a REAL leaf frame; a host tick-fn tallies the killed masses into it + radiates.
18
- var W = 600, H = 400; // THE WORLD this scene states (its co-init call below)
19
- var cy = Math.round(H / 2);
20
- var SRC = {
15
+ // Collision Lab 1B (Absorbing Wall) — ABSORB v2 (Track A): the wall LO is backed by
16
+ // a REAL leaf frame; a host tick-fn tallies the killed masses into it + radiates.
17
+ const W = 600, H = 400; // THE WORLD this scene states (its co-init call below)
18
+ const cy = Math.round(H / 2);
19
+
20
+ export const parts = {
21
+ source: [SphereEmitter, {
21
22
  preset: 'spherical', is2D: true, center: [Math.round(W * 0.15), cy],
22
23
  chargeMode: 'random', fundamentalDimension: 2, wavelength: 32,
23
24
  burstSize: 24, emitEveryTicks: 1
24
- };
25
- exp.createCanvas(W, H, SphereEmitter.worldCfg(SRC)); // THE WORLD: a 2D canvas + universe — first, then parts
26
- exp.addObject('SphereEmitter', SRC);
27
- exp.addObject('Wall', {
25
+ }],
26
+ absorber: [Wall, {
28
27
  rect: [Math.round(W * 0.6), Math.round(H * 0.2), 20, Math.round(H * 0.6)],
29
28
  collisionBehavior: 'absorb', capacity: 64,
30
- name: 'absorber', color: [150, 90, 210]
31
- });
29
+ color: [150, 90, 210]
30
+ }],
31
+ };
32
+
33
+ export default function buildExperiment(exp) {
34
+ exp.createCanvas(W, H, SphereEmitter.worldCfg(parts.source[1])); // THE WORLD: a 2D canvas + universe — first, then parts
35
+ exp.declare(parts);
32
36
  }