bireactive 0.3.4 → 0.3.5

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.
@@ -1,10 +1,10 @@
1
1
  export { type Counts, counts, resetCounts, snapshotCounts, withCounts } from "./_counts.js";
2
- export { batch, Cell, type CellOptions, cachedDerive, cell, derive, effect, fieldLens, fieldOf, type Init, type Inner, isCell, isLens, isReadonly, lazy, lens, type Network, network, type Optic, type Read, reader, readNow, SKIP, type Skip, type StatefulBwd, type StatefulBwd1, type StatefulLensSpec, type StatefulLensSpec1, setCellWriteHook, settle, transitiveDeps, untracked, type Val, type Writable, type WritableBrand, } from "./cell.js";
2
+ export { batch, Cell, type CellOptions, cachedDerive, cell, derive, effect, fieldLens, fieldOf, type Init, type Inner, isCell, isLens, isReadonly, lazy, lens, type Network, network, type Optic, type Read, reader, readNow, SKIP, type Skip, setCellWriteHook, settle, transitiveDeps, untracked, type Val, type Writable, type WritableBrand, } from "./cell.js";
3
3
  export { type DumpOpts, dumpGraph, explain, kind as cellKind, label as cellLabel, traceWrites, upstream, } from "./debug.js";
4
4
  export { bezier2, bezier3 } from "./derived-geometry.js";
5
5
  export * from "./lenses/index.js";
6
6
  export { each, type Lifecycle } from "./lifecycle.js";
7
- export { atKey, compose, iso, optic } from "./optic.js";
7
+ export { atKey, iso, optic } from "./optic.js";
8
8
  export { at, fields } from "./optics.js";
9
9
  export { type Store, store } from "./store.js";
10
10
  export { type Equals, type Lerp, type Linear, type Metric, type Pack, type Pivotal, requireEquals, requireLerp, requireLinear, requireMetric, requirePack, requirePivotal, type TraitDict, type Traits, } from "./traits.js";
@@ -4,7 +4,7 @@ export { dumpGraph, explain, kind as cellKind, label as cellLabel, traceWrites,
4
4
  export { bezier2, bezier3 } from "./derived-geometry.js";
5
5
  export * from "./lenses/index.js";
6
6
  export { each } from "./lifecycle.js";
7
- export { atKey, compose, iso, optic } from "./optic.js";
7
+ export { atKey, iso, optic } from "./optic.js";
8
8
  export { at, fields } from "./optics.js";
9
9
  export { store } from "./store.js";
10
10
  export { requireEquals, requireLerp, requireLinear, requireMetric, requirePack, requirePivotal, } from "./traits.js";
@@ -31,28 +31,29 @@ export function remember(sources, opts) {
31
31
  };
32
32
  // biome-ignore lint/suspicious/noExplicitAny: spec is checked structurally
33
33
  return Num.lens(sources, {
34
- init: (vals) => {
34
+ complement: (vals) => {
35
35
  const a = anchor(vals);
36
36
  return { shape: shapeOf(vals, a, feature(vals, a), null) };
37
37
  },
38
- step: (vals, c) => {
38
+ // `get` is the sole refresh: recompute the view and (idempotently) the shape.
39
+ get: (vals, c) => {
39
40
  const a = anchor(vals);
40
- return { shape: shapeOf(vals, a, feature(vals, a), c.shape) };
41
+ const f = feature(vals, a);
42
+ c.shape = shapeOf(vals, a, f, c.shape);
43
+ return f;
41
44
  },
42
- fwd: (vals) => feature(vals, anchor(vals)),
43
- bwd: (target, vals, c) => {
45
+ put: (target, vals, c) => {
44
46
  const a = anchor(vals);
45
47
  const f = feature(vals, a);
46
48
  // Magnitude is lossy (|−f| = f): a same-magnitude target re-projects
47
49
  // to the current feature, so the cluster is left put.
48
- if (magnitude && Math.abs(target) === f) {
49
- return { updates: vals.map(() => SKIP), complement: c };
50
- }
50
+ if (magnitude && Math.abs(target) === f)
51
+ return vals.map(() => SKIP);
51
52
  if (f > eps) {
52
53
  const k = target / f;
53
- return { updates: vals.map(v => lin.add(a, lin.scale(lin.sub(v, a), k))), complement: c };
54
+ return vals.map(v => lin.add(a, lin.scale(lin.sub(v, a), k)));
54
55
  }
55
- return { updates: c.shape.map(s => lin.add(a, lin.scale(s, target))), complement: c };
56
+ return c.shape.map(s => lin.add(a, lin.scale(s, target)));
56
57
  },
57
58
  });
58
59
  }
@@ -66,24 +67,27 @@ export function continuous(sources, opts) {
66
67
  const unwrap = (rawv, prev) => prev + wrap(rawv - prev);
67
68
  // biome-ignore lint/suspicious/noExplicitAny: spec is checked structurally
68
69
  return Num.lens(sources, {
69
- init: (vals) => {
70
+ complement: (vals) => {
70
71
  const r = raw(vals);
71
72
  return { prev: r.defined ? r.value : 0 };
72
73
  },
73
- step: (vals, c) => {
74
+ // `get` is the sole refresh: unwrap relative to `prev` and accumulate the
75
+ // winding. Idempotent — once the source settles, `unwrap` is a fixpoint.
76
+ get: (vals, c) => {
74
77
  const r = raw(vals);
75
- return r.defined ? { prev: unwrap(r.value, c.prev) } : c;
78
+ if (r.defined)
79
+ c.prev = unwrap(r.value, c.prev);
80
+ return c.prev;
76
81
  },
77
- fwd: (vals, c) => {
82
+ put: (target, vals, c) => {
78
83
  const r = raw(vals);
79
- return r.defined ? unwrap(r.value, c.prev) : c.prev;
80
- },
81
- bwd: (target, vals, c) => {
82
- const r = raw(vals);
83
- if (!r.defined)
84
- return { updates: vals.map(() => SKIP), complement: { prev: target } };
84
+ if (!r.defined) {
85
+ c.prev = target;
86
+ return vals.map(() => SKIP);
87
+ }
85
88
  const current = unwrap(r.value, c.prev);
86
- return { updates: apply(target, vals, current), complement: { prev: target } };
89
+ c.prev = target;
90
+ return apply(target, vals, current);
87
91
  },
88
92
  });
89
93
  }
@@ -61,33 +61,37 @@ export function scaleAbout(points, pivot) {
61
61
  });
62
62
  // biome-ignore lint/suspicious/noExplicitAny: variance escape — spec is checked structurally
63
63
  return Num.lens(points, {
64
- init: (vals) => {
64
+ complement: (vals) => {
65
65
  const p = pivot.peek();
66
66
  return { devs: vals.map(v => ({ x: v.x - p.x, y: v.y - p.y })) };
67
67
  },
68
- step: (vals, c) => ({ devs: refresh(c.devs, vals, pivot.peek()) }),
69
- fwd: (vals) => {
68
+ // `get` is the sole refresh: re-derive the offsets (idempotent on a settled
69
+ // source), then emit the radius.
70
+ get: (vals, c) => {
70
71
  const p = pivot.peek();
72
+ c.devs = refresh(c.devs, vals, p);
71
73
  return Math.hypot(vals[0].x - p.x, vals[0].y - p.y);
72
74
  },
73
- bwd: (target, vals, c) => {
75
+ put: (target, vals, c) => {
74
76
  const p = pivot.peek();
75
77
  // Lossy magnitude view: |−r| = r, so a same-magnitude target
76
78
  // re-projects to the current radius and is absorbed (sources put).
77
79
  const rNow = Math.hypot(vals[0].x - p.x, vals[0].y - p.y);
78
80
  if (Math.abs(target) === rNow)
79
- return { updates: vals.map(() => SKIP), complement: c };
80
- const d0 = c.devs[0];
81
+ return vals.map(() => SKIP);
82
+ // Re-derive offsets from the live source (so a sibling move is reflected);
83
+ // the guard keeps the stored direction through a collapse.
84
+ const devs = (c.devs = refresh(c.devs, vals, p));
85
+ const d0 = devs[0];
81
86
  const r0 = Math.hypot(d0.x, d0.y);
82
87
  if (r0 < 1e-12)
83
- return { updates: vals.map(() => SKIP), complement: c };
88
+ return vals.map(() => SKIP);
84
89
  const k = target / r0;
85
- const out = vals.map((v, i) => ({
90
+ return vals.map((v, i) => ({
86
91
  ...v,
87
- x: p.x + k * c.devs[i].x,
88
- y: p.y + k * c.devs[i].y,
92
+ x: p.x + k * devs[i].x,
93
+ y: p.y + k * devs[i].y,
89
94
  }));
90
- return { updates: out, complement: c };
91
95
  },
92
96
  });
93
97
  }
@@ -109,7 +113,7 @@ export function scaleAboutXY(points, pivot) {
109
113
  }));
110
114
  };
111
115
  return Vec.lens(points, {
112
- init: (vals) => {
116
+ complement: (vals) => {
113
117
  const p = pivot.peek();
114
118
  const ox = vals[0].x - p.x;
115
119
  const oy = vals[0].y - p.y;
@@ -120,15 +124,19 @@ export function scaleAboutXY(points, pivot) {
120
124
  })),
121
125
  };
122
126
  },
123
- step: (vals, c) => ({ fracs: refresh(c.fracs, vals, pivot.peek()) }),
124
- fwd: (vals) => {
127
+ // `get` is the sole refresh: re-derive the per-axis fractions, then emit
128
+ // point 0's offset from the pivot.
129
+ get: (vals, c) => {
125
130
  const p = pivot.peek();
131
+ c.fracs = refresh(c.fracs, vals, p);
126
132
  return { x: vals[0].x - p.x, y: vals[0].y - p.y };
127
133
  },
128
- bwd: (target, _vals, c) => {
134
+ put: (target, vals, c) => {
129
135
  const p = pivot.peek();
130
- const out = c.fracs.map(f => ({ x: p.x + f.x * target.x, y: p.y + f.y * target.y }));
131
- return { updates: out, complement: c };
136
+ // Re-derive fractions from the live source (reflect a sibling move); the
137
+ // guard keeps the stored fraction through a per-axis collapse.
138
+ const fracs = (c.fracs = refresh(c.fracs, vals, p));
139
+ return fracs.map(f => ({ x: p.x + f.x * target.x, y: p.y + f.y * target.y }));
132
140
  },
133
141
  });
134
142
  }
@@ -318,7 +326,7 @@ export function pca(points) {
318
326
  return { uX: ux, uY: uy, vX: vx, vY: vy, lenThis, lenOther, projThis, projOther };
319
327
  };
320
328
  return Num.lens(points, {
321
- init: (vals) => {
329
+ complement: (vals) => {
322
330
  const seed = {
323
331
  uX: 1,
324
332
  uY: 0,
@@ -332,26 +340,30 @@ export function pca(points) {
332
340
  const d = decompose(vals);
333
341
  return d ? axisFrom(d, seed, vals) : seed;
334
342
  },
335
- step: (vals, c) => {
343
+ // `get` is the sole refresh: recompute the axis frame (idempotent the
344
+ // sign is pinned to the previous frame), then emit this axis's length.
345
+ get: (vals, c) => {
336
346
  const d = decompose(vals);
337
- return d ? axisFrom(d, c, vals) : c;
347
+ if (d)
348
+ Object.assign(c, axisFrom(d, c, vals));
349
+ return d ? c.lenThis : 0;
338
350
  },
339
- fwd: (vals, c) => (decompose(vals) ? c.lenThis : 0),
340
- bwd: (target, vals, c) => {
351
+ put: (target, vals, c) => {
341
352
  const d = decompose(vals);
353
+ // Re-derive the frame from the live source (reflect a sibling move); when
354
+ // collapsed (`!d`) the stored frame/projections are kept as the fallback.
355
+ if (d)
356
+ Object.assign(c, axisFrom(d, c, vals));
342
357
  if (d && c.lenThis > 1e-12) {
343
358
  // Lossy magnitude view: a same-magnitude target re-projects to
344
359
  // the current axis length and is absorbed (cluster left put).
345
360
  if (Math.abs(target) === c.lenThis)
346
- return { updates: vals.map(() => SKIP), complement: c };
347
- // Non-degenerate fast path: scale current cluster along axis. The scale
348
- // sets the axis length to |target|, so the complement is consistent
349
- // without a post-write `step` (the engine no longer re-steps own writes).
361
+ return vals.map(() => SKIP);
362
+ // Non-degenerate fast path: scale current cluster along the axis.
350
363
  const k = target / c.lenThis;
351
- return {
352
- updates: scaleAlongAxis(vals, d.cx, d.cy, c.uX, c.uY, k),
353
- complement: { ...c, lenThis: Math.abs(target) },
354
- };
364
+ const out = scaleAlongAxis(vals, d.cx, d.cy, c.uX, c.uY, k);
365
+ c.lenThis = Math.abs(target);
366
+ return out;
355
367
  }
356
368
  // Degenerate: reconstruct from complement. Centroid still
357
369
  // derivable from current source (mean translates always work).
@@ -369,7 +381,8 @@ export function pca(points) {
369
381
  const b = c.projOther[i] * c.lenOther;
370
382
  out[i] = { x: cx + a * c.uX + b * c.vX, y: cy + a * c.uY + b * c.vY };
371
383
  }
372
- return { updates: out, complement: { ...c, lenThis: Math.abs(target) } };
384
+ c.lenThis = Math.abs(target);
385
+ return out;
373
386
  },
374
387
  });
375
388
  };
@@ -481,24 +494,27 @@ export function procrustes(points) {
481
494
  });
482
495
  };
483
496
  const scale = Num.lens(points, {
484
- init: (vals) => {
497
+ complement: (vals) => {
485
498
  const c = centroidOf(vals);
486
499
  return { devs: vals.map(v => ({ x: v.x - c.x, y: v.y - c.y })) };
487
500
  },
488
- step: (vals, c) => ({ devs: refreshDevs(c.devs, vals) }),
489
- fwd: (vals) => {
490
- const c = centroidOf(vals);
491
- return Math.hypot(vals[0].x - c.x, vals[0].y - c.y);
501
+ // `get` is the sole refresh: re-derive the centroid offsets, then emit the radius.
502
+ get: (vals, c) => {
503
+ const cen = centroidOf(vals);
504
+ c.devs = refreshDevs(c.devs, vals);
505
+ return Math.hypot(vals[0].x - cen.x, vals[0].y - cen.y);
492
506
  },
493
- bwd: (target, vals, c) => {
507
+ put: (target, vals, c) => {
494
508
  const cen = centroidOf(vals);
495
- const d0 = c.devs[0];
509
+ // Re-derive offsets from the live source (reflect a sibling move); the
510
+ // guard keeps the stored direction through a collapse to the centroid.
511
+ const devs = (c.devs = refreshDevs(c.devs, vals));
512
+ const d0 = devs[0];
496
513
  const r0 = Math.hypot(d0.x, d0.y);
497
514
  if (r0 < 1e-12)
498
- return { updates: vals.map(() => SKIP), complement: c };
515
+ return vals.map(() => SKIP);
499
516
  const k = target / r0;
500
- const out = c.devs.map(d => ({ x: cen.x + k * d.x, y: cen.y + k * d.y }));
501
- return { updates: out, complement: c };
517
+ return devs.map(d => ({ x: cen.x + k * d.x, y: cen.y + k * d.y }));
502
518
  },
503
519
  });
504
520
  return { centroid, rotation, scale };
@@ -556,7 +572,7 @@ export function bbox(points) {
556
572
  }));
557
573
  };
558
574
  const size = Vec.lens(points, {
559
- init: (vals) => {
575
+ complement: (vals) => {
560
576
  const b = computeBox(vals);
561
577
  const halfX0 = b.sx > 1e-12 ? b.sx / 2 : 1;
562
578
  const halfY0 = b.sy > 1e-12 ? b.sy / 2 : 1;
@@ -567,17 +583,20 @@ export function bbox(points) {
567
583
  })),
568
584
  };
569
585
  },
570
- step: (vals, c) => ({ fracs: refreshFracs(c.fracs, vals) }),
571
- fwd: (vals) => {
586
+ // `get` is the sole refresh: re-derive the half-size fractions, then emit the size.
587
+ get: (vals, c) => {
572
588
  const b = computeBox(vals);
589
+ c.fracs = refreshFracs(c.fracs, vals);
573
590
  return { x: b.sx, y: b.sy };
574
591
  },
575
- bwd: (target, vals, c) => {
592
+ put: (target, vals, c) => {
576
593
  const b = computeBox(vals);
577
594
  const halfTx = target.x / 2;
578
595
  const halfTy = target.y / 2;
579
- const out = c.fracs.map(f => ({ x: b.cx + f.x * halfTx, y: b.cy + f.y * halfTy }));
580
- return { updates: out, complement: c };
596
+ // Re-derive fractions from the live source (reflect a sibling move); the
597
+ // guard keeps the stored fraction through a per-axis collapse.
598
+ const fracs = (c.fracs = refreshFracs(c.fracs, vals));
599
+ return fracs.map(f => ({ x: b.cx + f.x * halfTx, y: b.cy + f.y * halfTy }));
581
600
  },
582
601
  });
583
602
  return { center, size };
@@ -127,12 +127,13 @@ export function nearestIndex(pointer, candidates, opts = {}) {
127
127
  const sticky = opts.sticky ?? 0;
128
128
  const parents = [pointer, ...candidates];
129
129
  return lens(parents, {
130
- init: (sources) => ({ index: pick(sources, -1, 0) }),
131
- step: (sources, c) => ({ index: pick(sources, c.index, sticky) }),
132
- fwd: (_sources, c) => c.index,
133
- bwd: (_t, sources, c) => ({
134
- updates: sources.map(() => SKIP),
135
- complement: c,
136
- }),
130
+ complement: (sources) => ({ index: pick(sources, -1, 0) }),
131
+ // `get` is the sole refresh: re-pick with hysteresis from the current pick.
132
+ // Idempotent re-running on a settled source keeps the same index.
133
+ get: (sources, c) => {
134
+ c.index = pick(sources, c.index, sticky);
135
+ return c.index;
136
+ },
137
+ put: (_t, sources, _c) => sources.map(() => SKIP),
137
138
  });
138
139
  }
@@ -192,11 +192,13 @@ function buildCaseComplement(s) {
192
192
  export function caseFold(parent, to = "lower") {
193
193
  const fold = to === "upper" ? (s) => s.toUpperCase() : (s) => s.toLowerCase();
194
194
  return Str.lens(parent, {
195
- init: (s) => buildCaseComplement(s),
196
- fwd: (s) => fold(s),
197
- bwd: (target, _s, c) => ({
198
- update: applyCaseComplement(target, c),
199
- complement: c,
200
- }),
195
+ complement: (s) => buildCaseComplement(s),
196
+ // `get` is the sole refresh: re-derive the case complement from the source
197
+ // (pure idempotent), then fold.
198
+ get: (s, c) => {
199
+ refreshCaseComplement(s, c);
200
+ return fold(s);
201
+ },
202
+ put: (target, _s, c) => applyCaseComplement(target, c),
201
203
  });
202
204
  }
@@ -6,8 +6,3 @@ export declare function optic<A, B>(get: (a: A) => B, put: (b: B, a: A) => A): O
6
6
  export declare function iso<A, B>(to: (a: A) => B, from: (b: B) => A): Optic<A, B>;
7
7
  /** Field optic: project key `K`, putting back with a spread-replace. */
8
8
  export declare function atKey<T, K extends keyof T>(key: K): Optic<T, T[K]>;
9
- /** Compose optics left-to-right into one: `compose(a, b, c)` is `a` then `b` then
10
- * `c`. Typed for up to three; falls back to `Optic<unknown, unknown>` beyond. */
11
- export declare function compose<A, B>(a: Optic<A, B>): Optic<A, B>;
12
- export declare function compose<A, B, C>(a: Optic<A, B>, b: Optic<B, C>): Optic<A, C>;
13
- export declare function compose<A, B, C, D>(a: Optic<A, B>, b: Optic<B, C>, c: Optic<C, D>): Optic<A, D>;
@@ -1,39 +1,27 @@
1
- // Lenses as first-class values, independent of any `Cell`. An `Optic<A, B>` is a
2
- // lens transform *unbound* — a `get`/`put` pair you compose, store, and apply to
3
- // a source with `cell.through(optic)` (≡ `lens(cell, o.get, o.put)`). Composition
4
- // is ordinary lens composition: `(f g).put(c, a) = f.put(g.put(c, f.get(a)),
5
- // a)`, reconstructing the inner source from `a` each back-write. An `iso` is the
6
- // lossless case whose `put` ignores the source (`readsSource = false`), so
7
- // `through` can bind a cheaper 1-arg backward. No complement here — use
8
- // `lens(parent, spec)` when you need one.
1
+ // Lenses as first-class values, independent of any `Cell`. An `Optic<S, V>` is a
2
+ // lens transform *unbound* — a `get`/`put` pair you store and apply to a source
3
+ // with `cell.lens(optic)` / `lens(source, optic)`. Chain several by passing them to
4
+ // one `lens(source, a, b, )` call; the binder folds the chain by re-binding (its
5
+ // `put` reconstructs the inner source on each back-write). An `iso` is the lossless
6
+ // case whose `put` ignores the source (a 1-arg `put`). These constructors build the
7
+ // *pure* (complement-free) optics; complement-carrying optics are plain objects with
8
+ // a `complement` seed (see the Optic type and the stateful-lens header in cell.ts).
9
9
  //
10
- // The `Optic` type lives in cell.ts so that file stays import-free and its
11
- // `Cell.through` can name it; this module is the constructor/algebra surface.
12
- function make(get, put, readsSource) {
13
- return {
14
- get,
15
- put,
16
- readsSource,
17
- through(next) {
18
- // Composed backward reconstructs the inner B from the outer A, so it always
19
- // reads the source regardless of either side's own `readsSource`.
20
- return make(a => next.get(get(a)), (c, a) => put(next.put(c, get(a)), a), true);
21
- },
22
- };
23
- }
10
+ // `optic.ts` imports only *types* from `cell.ts`, so `cell.ts` can apply optics by
11
+ // re-binding without importing this module (no cycle).
24
12
  /** Build an optic from a forward and a backward. A 2-arg `put(b, a)` reads the
25
13
  * source; a 1-arg `put(b)` reconstructs it (and is treated as an `iso`). */
26
14
  export function optic(get, put) {
27
- return make(get, put, put.length >= 2);
15
+ return { get, put: put };
28
16
  }
29
17
  /** A lossless, source-independent optic (an isomorphism): `to`/`from` invert. */
30
18
  export function iso(to, from) {
31
- return make(to, b => from(b), false);
19
+ return { get: to, put: ((b) => from(b)) };
32
20
  }
33
21
  /** Field optic: project key `K`, putting back with a spread-replace. */
34
22
  export function atKey(key) {
35
- return make(t => t[key], (v, t) => ({ ...t, [key]: v }), true);
36
- }
37
- export function compose(...optics) {
38
- return optics.reduce((a, b) => a.through(b));
23
+ return {
24
+ get: (t) => t[key],
25
+ put: ((v, t) => ({ ...t, [key]: v })),
26
+ };
39
27
  }
@@ -61,14 +61,15 @@ export class Audio extends Cell {
61
61
  const tf = reader(target);
62
62
  const self = this;
63
63
  return Audio.lens(self, {
64
- init: s => peak(s),
65
- fwd: s => {
66
- const p = peak(s);
64
+ complement: (s) => ({ peak: peak(s) }),
65
+ // `get` is the sole refresh: re-measure the source peak (pure ⇒ idempotent).
66
+ get: (s, c) => {
67
+ const p = (c.peak = peak(s));
67
68
  return p === 0 ? s : scaled(s, tf() / p);
68
69
  },
69
- bwd: (view, _src, c) => {
70
+ put: (view, _src, c) => {
70
71
  const t = tf();
71
- return { update: t === 0 ? view : scaled(view, c / t), complement: c };
72
+ return t === 0 ? view : scaled(view, c.peak / t);
72
73
  },
73
74
  });
74
75
  }
@@ -189,19 +189,22 @@ export class Canvas extends Cell {
189
189
  };
190
190
  const self = this;
191
191
  return Canvas.lens(self, {
192
- init: s => chromaOf(s),
193
- fwd: s => {
192
+ complement: (s) => chromaOf(s),
193
+ // `get` is the sole refresh: re-run the chroma pass into the complement
194
+ // tex (pure GPU pass ⇒ idempotent), then emit the luma view.
195
+ get: (s, c) => {
196
+ Object.assign(c, chromaOf(s));
194
197
  const out = sf(s.w, s.h);
195
198
  pass(LUMA, out, x => x.tex("u_s", 0, s.tex));
196
199
  return stamp(out.tex, s.w, s.h);
197
200
  },
198
- bwd: (target, s, c) => {
201
+ put: (target, s, c) => {
199
202
  const out = sb(s.w, s.h);
200
203
  pass(RECOLOR, out, x => {
201
204
  x.tex("u_t", 0, target.tex);
202
205
  x.tex("u_c", 1, c.tex);
203
206
  });
204
- return { update: stamp(out.tex, s.w, s.h), complement: c };
207
+ return stamp(out.tex, s.w, s.h);
205
208
  },
206
209
  });
207
210
  }
@@ -218,19 +221,21 @@ export class Canvas extends Cell {
218
221
  };
219
222
  const self = this;
220
223
  return Canvas.lens(self, {
221
- init: s => lumaOf(s),
222
- fwd: s => {
224
+ complement: (s) => lumaOf(s),
225
+ // `get` is the sole refresh: re-run the luma pass into the complement tex.
226
+ get: (s, c) => {
227
+ Object.assign(c, lumaOf(s));
223
228
  const out = sf(s.w, s.h);
224
229
  pass(CHROMA_VIEW, out, x => x.tex("u_s", 0, s.tex));
225
230
  return stamp(out.tex, s.w, s.h);
226
231
  },
227
- bwd: (target, s, c) => {
232
+ put: (target, s, c) => {
228
233
  const out = sb(s.w, s.h);
229
234
  pass(DELUMA, out, x => {
230
235
  x.tex("u_t", 0, target.tex);
231
236
  x.tex("u_c", 1, c.tex);
232
237
  });
233
- return { update: stamp(out.tex, s.w, s.h), complement: c };
238
+ return stamp(out.tex, s.w, s.h);
234
239
  },
235
240
  });
236
241
  }
@@ -305,12 +310,15 @@ export class Canvas extends Cell {
305
310
  };
306
311
  const self = this;
307
312
  return Canvas.lens(self, {
308
- init: s => residualOf(s),
309
- fwd: s => {
313
+ complement: (s) => residualOf(s),
314
+ // `get` is the sole refresh: recompute the Laplacian residual (pure ⇒
315
+ // idempotent), then emit the box-downsampled thumbnail.
316
+ get: (s, c) => {
317
+ Object.assign(c, residualOf(s));
310
318
  const small = down(sdF, s.tex, s.w, s.h);
311
319
  return stamp(small.tex, small.w, small.h);
312
320
  },
313
- bwd: (target, s, c) => {
321
+ put: (target, s, c) => {
314
322
  const up = suB(s.w, s.h);
315
323
  pass(UP, up, x => {
316
324
  x.tex("u_small", 0, target.tex);
@@ -322,7 +330,7 @@ export class Canvas extends Cell {
322
330
  x.tex("u_a", 0, up.tex);
323
331
  x.tex("u_b", 1, c.tex);
324
332
  });
325
- return { update: stamp(out.tex, s.w, s.h), complement: c };
333
+ return stamp(out.tex, s.w, s.h);
326
334
  },
327
335
  });
328
336
  }
@@ -48,6 +48,8 @@ export declare class Field<T> extends Cell<FieldVal> {
48
48
  evolve(frag: string, uniforms?: Record<string, number | readonly number[]>, steps?: number): void;
49
49
  /** Stamp a Gaussian disc of `value` at data pixel `(x, y)`, radius `r`. */
50
50
  splat(x: number, y: number, r: number, value: T, strength?: number): void;
51
+ /** Fill a hard-edged rectangle `(x, y, w, h)` in data pixels with `value`. */
52
+ fillRect(x: number, y: number, w: number, h: number, value: T, strength?: number): void;
51
53
  /** Whole-field mean as a read-only `T` cell. Recomputes per epoch; one 1×1
52
54
  * GPU readback. */
53
55
  mean(): Read<T>;
@@ -44,6 +44,15 @@ void main() {
44
44
  float f = clamp(exp(-(d * d) / (u_r * u_r)) * u_mix, 0.0, 1.0);
45
45
  o = vec4(mix(s.rgb, u_v, f), s.a);
46
46
  }`;
47
+ const FILL_RECT = `${HEAD}
48
+ uniform sampler2D u_src; uniform vec2 u_res; uniform vec2 u_o; uniform vec2 u_wh;
49
+ uniform vec3 u_v; uniform float u_mix;
50
+ void main() {
51
+ vec2 px = v_uv * u_res;
52
+ bool inside = px.x >= u_o.x && px.y >= u_o.y && px.x < u_o.x + u_wh.x && px.y < u_o.y + u_wh.y;
53
+ vec4 s = texture(u_src, v_uv);
54
+ o = inside ? vec4(mix(s.rgb, u_v, u_mix), s.a) : s;
55
+ }`;
47
56
  /** GLSL float literal (always carries a decimal point). */
48
57
  const glf = (n) => (Number.isInteger(n) ? `${n}.0` : String(n));
49
58
  const glv3 = (c) => `${glf(c[0])}, ${glf(c[1])}, ${glf(c[2])}`;
@@ -144,6 +153,22 @@ export class Field extends Cell {
144
153
  });
145
154
  this.value = stamp(dst.tex, v.w, v.h);
146
155
  }
156
+ /** Fill a hard-edged rectangle `(x, y, w, h)` in data pixels with `value`. */
157
+ fillRect(x, y, w, h, value, strength = 1) {
158
+ const ping = this.pingTex();
159
+ const v = this.peek();
160
+ this.kind.pack.read(value, TMP, 0);
161
+ const dst = ping(v.w, v.h, v.tex);
162
+ pass(FILL_RECT, dst, s => {
163
+ s.tex("u_src", 0, v.tex);
164
+ s.v2("u_res", v.w, v.h);
165
+ s.v2("u_o", x, y);
166
+ s.v2("u_wh", w, h);
167
+ s.v3("u_v", TMP[0], TMP[1], TMP[2]);
168
+ s.f("u_mix", strength);
169
+ });
170
+ this.value = stamp(dst.tex, v.w, v.h);
171
+ }
147
172
  /** Whole-field mean as a read-only `T` cell. Recomputes per epoch; one 1×1
148
173
  * GPU readback. */
149
174
  mean() {
@@ -190,9 +190,8 @@ export declare class Reg<V = RegVal, N extends boolean = boolean, F extends Boun
190
190
  /** This grammar as a first-class, composable `Optic<string, V>`: `get`
191
191
  * parses (falling back to the default value off-language), `put` reprints
192
192
  * and round-trip-guards (an off-language source or a non-round-tripping
193
- * value leaves the source untouched). Drops straight into `compose(...)`
194
- * and `cell.through(...)`, so it chains with `atKey`/`iso` and string
195
- * lenses like `caseFold`. */
193
+ * value leaves the source untouched). Drops straight into `cell.lens(...)`,
194
+ * so it chains with `atKey`/`iso` and string lenses like `caseFold`. */
196
195
  optic(): Optic<string, V>;
197
196
  /** The whole abstract value as a writable lens over `source`. */
198
197
  view(source: Cell<string>): Writable<Cell<V>>;
@@ -465,9 +465,8 @@ export class Reg {
465
465
  /** This grammar as a first-class, composable `Optic<string, V>`: `get`
466
466
  * parses (falling back to the default value off-language), `put` reprints
467
467
  * and round-trip-guards (an off-language source or a non-round-tripping
468
- * value leaves the source untouched). Drops straight into `compose(...)`
469
- * and `cell.through(...)`, so it chains with `atKey`/`iso` and string
470
- * lenses like `caseFold`. */
468
+ * value leaves the source untouched). Drops straight into `cell.lens(...)`,
469
+ * so it chains with `atKey`/`iso` and string lenses like `caseFold`. */
471
470
  optic() {
472
471
  const def = defaultVal(this.root);
473
472
  return optic((s) => (this.match(s) ?? def), (v, s) => {
@@ -479,7 +478,7 @@ export class Reg {
479
478
  }
480
479
  /** The whole abstract value as a writable lens over `source`. */
481
480
  view(source) {
482
- return source.through(this.optic());
481
+ return source.lens(this.optic());
483
482
  }
484
483
  bind(source, opts = {}) {
485
484
  const captures = new Map();