@jbrowse/render-core 5.0.0-beta.3 → 5.0.0-beta.7

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 (71) hide show
  1. package/esm/RenderLifecycleMixin.d.ts +1 -0
  2. package/esm/RenderLifecycleMixin.js +8 -0
  3. package/esm/gpuContextLostError.d.ts +30 -0
  4. package/esm/gpuContextLostError.js +40 -0
  5. package/esm/gpuDevice.js +4 -1
  6. package/esm/hal/gpuHalBase.d.ts +3 -3
  7. package/esm/hal/index.d.ts +1 -1
  8. package/esm/hal/mockHal.d.ts +3 -3
  9. package/esm/hal/mockHal.js +3 -2
  10. package/esm/hal/oomReporter.d.ts +7 -0
  11. package/esm/hal/oomReporter.js +10 -0
  12. package/esm/hal/types.d.ts +8 -1
  13. package/esm/hal/webgl2Hal.d.ts +2 -2
  14. package/esm/hal/webgl2Hal.js +8 -1
  15. package/esm/hal/webgpuHal.d.ts +12 -2
  16. package/esm/hal/webgpuHal.js +49 -11
  17. package/esm/marks/barMark.d.ts +28 -0
  18. package/esm/marks/barMark.js +63 -0
  19. package/esm/marks/drawAgainstHit.d.ts +85 -14
  20. package/esm/marks/drawAgainstHit.js +260 -38
  21. package/esm/marks/glyphPaint.d.ts +10 -2
  22. package/esm/marks/glyphPaint.js +21 -0
  23. package/esm/marks/index.d.ts +9 -2
  24. package/esm/marks/index.js +4 -1
  25. package/esm/marks/markBackend.d.ts +21 -9
  26. package/esm/marks/markBackend.js +63 -29
  27. package/esm/marks/markHit.d.ts +32 -0
  28. package/esm/marks/markHit.js +61 -0
  29. package/esm/marks/markInk.d.ts +14 -0
  30. package/esm/marks/markInk.js +34 -0
  31. package/esm/marks/markPlan.d.ts +25 -0
  32. package/esm/marks/markPlan.js +20 -0
  33. package/esm/marks/markRamp.d.ts +43 -0
  34. package/esm/marks/markRamp.js +68 -0
  35. package/esm/marks/pointMark.d.ts +8 -4
  36. package/esm/marks/pointMark.js +39 -23
  37. package/esm/marks/spanMark.d.ts +8 -0
  38. package/esm/marks/spanMark.js +19 -26
  39. package/esm/marks/types.d.ts +101 -4
  40. package/esm/marks/types.js +78 -5
  41. package/esm/renderBlock.d.ts +14 -0
  42. package/esm/renderBlock.js +25 -0
  43. package/esm/shaders/barMark.generated.d.ts +4 -0
  44. package/esm/shaders/barMark.generated.js +6 -0
  45. package/esm/shaders/barMark.iface.generated.d.ts +64 -0
  46. package/esm/shaders/barMark.iface.generated.js +110 -0
  47. package/esm/shaders/markColor.generated.d.ts +3 -0
  48. package/esm/shaders/markColor.generated.js +5 -0
  49. package/esm/shaders/pointMark.generated.d.ts +3 -3
  50. package/esm/shaders/pointMark.generated.js +3 -3
  51. package/esm/shaders/pointMark.iface.generated.d.ts +17 -6
  52. package/esm/shaders/pointMark.iface.generated.js +27 -9
  53. package/esm/shaders/pointMark.js.generated.d.ts +2 -0
  54. package/esm/shaders/pointMark.js.generated.js +42 -0
  55. package/esm/shaders/scoreScale.generated.d.ts +2 -0
  56. package/esm/shaders/scoreScale.generated.js +4 -0
  57. package/esm/useRenderingBackend.d.ts +1 -16
  58. package/esm/useRenderingBackend.js +2 -24
  59. package/package.json +5 -2
  60. package/src/shaders/barMark.generated.ts +10 -0
  61. package/src/shaders/barMark.iface.generated.ts +160 -0
  62. package/src/shaders/barMark.slang +109 -0
  63. package/src/shaders/markColor.generated.ts +8 -0
  64. package/src/shaders/markColor.slang +47 -0
  65. package/src/shaders/pointMark.generated.ts +3 -3
  66. package/src/shaders/pointMark.iface.generated.ts +34 -10
  67. package/src/shaders/pointMark.js.generated.ts +45 -0
  68. package/src/shaders/pointMark.slang +35 -33
  69. package/src/shaders/scoreScale.generated.ts +6 -0
  70. package/src/shaders/scoreScale.slang +1 -0
  71. package/src/shaders/valueScale.slang +52 -0
@@ -1,10 +1,13 @@
1
+ import { inkOnRect, shapeHitNearest } from './markHit.js';
2
+ const IDENTITY = [1, 0, 0, 1, 0, 0];
1
3
  /**
2
- * A `MarkContext2D` that records every rect a painter fills, in paint order —
3
- * `fillRect` and path `rect` alike, so a shape that batches by colour records
4
- * the same list as one that fills per instance. A path traced with
5
- * `moveTo`/`lineTo` and filled records its bounding box, which is what a
6
- * shape whose hit test answers a polygon's box (the variant inversion
7
- * triangle) is held to.
4
+ * A `MarkContext2D` that records every rect a painter fills, in paint order and
5
+ * in CANVAS coordinates — `fillRect` and path `rect` alike, so a shape that
6
+ * batches by colour records the same list as one that fills per instance. A
7
+ * path traced with `moveTo`/`lineTo`/`bezierCurveTo` and filled records its
8
+ * bounding box, which is what a shape whose hit test answers a polygon's box
9
+ * (the variant inversion triangle) is held to; a curve contributes its control
10
+ * points, whose hull contains it.
8
11
  *
9
12
  * A **stroked** path records one rect per `moveTo`/`lineTo` edge, each the
10
13
  * edge's bounding box widened by half the current `lineWidth` — the extent a
@@ -13,34 +16,121 @@
13
16
  * otherwise record the run's hull as a single instance. `fillStyle` on such a
14
17
  * record is the `strokeStyle` it went down in; the field is the colour of the
15
18
  * ink either way.
19
+ *
20
+ * `save`/`restore` bracket the transform and the styles the way a real context
21
+ * does, and every recorded point goes through the CTM. The half-widths — a
22
+ * stroke's and a `strokeRect`'s — are measured AFTER it, so they are exact under
23
+ * a translation and approximate under a scale; no shape in tree strokes under
24
+ * one, and a rotated CTM records the ink's axis-aligned box rather than its
25
+ * silhouette.
16
26
  */
17
27
  export function recordingContext() {
18
28
  const calls = [];
19
29
  const points = [];
20
30
  const edges = [];
31
+ let m = [...IDENTITY];
32
+ const stack = [];
33
+ const px = (x, y) => m[0] * x + m[2] * y + m[4];
34
+ const py = (x, y) => m[1] * x + m[3] * y + m[5];
35
+ const pushPoint = (x, y) => {
36
+ points.push([px(x, y), py(x, y)]);
37
+ };
38
+ // The transformed box of an axis-aligned rect. An axis-aligned CTM scales the
39
+ // extents directly rather than differencing two mapped corners, so an
40
+ // untranslated rect records the width the painter asked for and not a value
41
+ // one ulp off it; only a rotation falls back to the four-corner hull.
42
+ const boxOf = (x, y, w, h) => {
43
+ if (m[1] === 0 && m[2] === 0) {
44
+ const x0 = px(x, y);
45
+ const y0 = py(x, y);
46
+ const dx = m[0] * w;
47
+ const dy = m[3] * h;
48
+ return {
49
+ x: Math.min(x0, x0 + dx),
50
+ y: Math.min(y0, y0 + dy),
51
+ w: Math.abs(dx),
52
+ h: Math.abs(dy),
53
+ };
54
+ }
55
+ const xs = [px(x, y), px(x + w, y), px(x, y + h), px(x + w, y + h)];
56
+ const ys = [py(x, y), py(x + w, y), py(x, y + h), py(x + w, y + h)];
57
+ const left = Math.min(...xs);
58
+ const top = Math.min(...ys);
59
+ return {
60
+ x: left,
61
+ y: top,
62
+ w: Math.max(...xs) - left,
63
+ h: Math.max(...ys) - top,
64
+ };
65
+ };
21
66
  const ctx = {
22
67
  fillStyle: '',
23
68
  strokeStyle: '',
24
69
  lineWidth: 1,
25
70
  lineCap: 'butt',
26
- save() { },
27
- restore() { },
71
+ lineJoin: 'miter',
72
+ save() {
73
+ const { fillStyle, strokeStyle, lineWidth, lineCap } = this;
74
+ stack.push({ m: [...m], fillStyle, strokeStyle, lineWidth, lineCap });
75
+ },
76
+ restore() {
77
+ const saved = stack.pop();
78
+ if (saved) {
79
+ m = saved.m;
80
+ this.fillStyle = saved.fillStyle;
81
+ this.strokeStyle = saved.strokeStyle;
82
+ this.lineWidth = saved.lineWidth;
83
+ this.lineCap = saved.lineCap;
84
+ }
85
+ },
86
+ translate(x, y) {
87
+ m = [m[0], m[1], m[2], m[3], px(x, y), py(x, y)];
88
+ },
89
+ scale(x, y) {
90
+ m = [m[0] * x, m[1] * x, m[2] * y, m[3] * y, m[4], m[5]];
91
+ },
92
+ rotate(angle) {
93
+ const c = Math.cos(angle);
94
+ const s = Math.sin(angle);
95
+ m = [
96
+ m[0] * c + m[2] * s,
97
+ m[1] * c + m[3] * s,
98
+ m[2] * c - m[0] * s,
99
+ m[3] * c - m[1] * s,
100
+ m[4],
101
+ m[5],
102
+ ];
103
+ },
28
104
  beginPath() {
29
105
  points.length = 0;
30
106
  edges.length = 0;
31
107
  },
32
108
  clip() { },
109
+ setLineDash() { },
33
110
  moveTo(x, y) {
34
- points.push([x, y]);
111
+ pushPoint(x, y);
35
112
  },
36
113
  lineTo(x, y) {
37
114
  const from = points.at(-1);
115
+ pushPoint(x, y);
116
+ const to = points.at(-1);
38
117
  if (from) {
39
- edges.push([from[0], from[1], x, y]);
118
+ edges.push([from[0], from[1], to[0], to[1]]);
40
119
  }
41
- points.push([x, y]);
42
120
  },
43
- arc() { },
121
+ bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) {
122
+ pushPoint(cp1x, cp1y);
123
+ pushPoint(cp2x, cp2y);
124
+ pushPoint(x, y);
125
+ },
126
+ arc(x, y, radius) {
127
+ pushPoint(x - radius, y - radius);
128
+ pushPoint(x + radius, y + radius);
129
+ },
130
+ ellipse(x, y, radiusX, radiusY) {
131
+ pushPoint(x - radiusX, y - radiusY);
132
+ pushPoint(x + radiusX, y + radiusY);
133
+ },
44
134
  closePath() { },
45
135
  fill() {
46
136
  if (points.length > 0) {
@@ -76,57 +166,189 @@ export function recordingContext() {
76
166
  edges.length = 0;
77
167
  },
78
168
  rect(x, y, w, h) {
79
- calls.push({ x, y, w, h, fillStyle: this.fillStyle });
169
+ calls.push({ ...boxOf(x, y, w, h), fillStyle: this.fillStyle });
80
170
  },
81
171
  fillRect(x, y, w, h) {
82
- calls.push({ x, y, w, h, fillStyle: this.fillStyle });
172
+ calls.push({ ...boxOf(x, y, w, h), fillStyle: this.fillStyle });
173
+ },
174
+ strokeRect(x, y, w, h) {
175
+ const half = this.lineWidth / 2;
176
+ const box = boxOf(x, y, w, h);
177
+ calls.push({
178
+ x: box.x - half,
179
+ y: box.y - half,
180
+ w: box.w + this.lineWidth,
181
+ h: box.h + this.lineWidth,
182
+ fillStyle: this.strokeStyle,
183
+ });
83
184
  },
84
185
  };
85
- return { ctx: ctx, calls };
186
+ return { ctx, calls };
187
+ }
188
+ function contains(r, x, y, slack = 0) {
189
+ return (x >= r.x - slack &&
190
+ x <= r.x + r.w + slack &&
191
+ y >= r.y - slack &&
192
+ y <= r.y + r.h + slack);
86
193
  }
87
- function contains(r, x, y) {
88
- return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
194
+ function unionBox(rs) {
195
+ let box;
196
+ for (const r of rs) {
197
+ box = box
198
+ ? {
199
+ x: Math.min(box.x, r.x),
200
+ y: Math.min(box.y, r.y),
201
+ w: Math.max(box.x + box.w, r.x + r.w) - Math.min(box.x, r.x),
202
+ h: Math.max(box.y + box.h, r.y + r.h) - Math.min(box.y, r.y),
203
+ }
204
+ : { x: r.x, y: r.y, w: r.w, h: r.h };
205
+ }
206
+ return box;
89
207
  }
208
+ // The float slack the two distance comparisons take. Both sides are px
209
+ // arithmetic over the same projection, so a real disagreement is orders of
210
+ // magnitude above this and a rounding one is below it.
211
+ const EPS = 1e-9;
90
212
  /**
91
- * The draw-against-hit gate for a shape whose instances paint as rects: paint
92
- * the block, then walk it in `step` px and hold `hitNearest` to what the
93
- * painter recorded. A hit at distance 0 has to sit on the rect of the index it
94
- * names, and a point on a rect has to answer that rect — the last-painted one
95
- * where rects overlap, since the sweep hands candidates in back to front.
213
+ * The draw-against-hit gate: paint the block, then walk it in `step` px and
214
+ * hold `hitNearest` to what the painter actually put on the canvas.
215
+ *
216
+ * **What the painter drew for instance `i` has to be attributed to `i`,** and
217
+ * there are two ways to get that. A shape whose every instance is one
218
+ * `fillRect` gets it positionally, which is the default and needs nothing. A
219
+ * shape that batches a colour run into one path (`point`'s glyphs), or that
220
+ * skips an instance the view has scrolled past (`cell`'s off-canvas rows),
221
+ * cannot be read that way at all — the batch has fewer rects than instances, or
222
+ * the wrong ones. `sliceOne` is the way in for those: hand back one instance's
223
+ * channels and the sweep paints it alone, so the attribution holds by
224
+ * construction and a culled instance is visibly an empty painting.
225
+ *
226
+ * Four claims, all of them one-directional and true of every shape:
227
+ *
228
+ * - an instance that painted nothing is never the answer,
229
+ * - `hit.x`/`hit.y` — where the shape says its ink is — lies inside the box the
230
+ * painter drew for the instance the hit names,
231
+ * - `hit.distSq` is no smaller than the distance to that box, since the box
232
+ * contains the ink and cannot be farther than it,
233
+ * - `hit.distSq` IS the distance to `hit.x`/`hit.y`, which is `MarkHit`'s own
234
+ * contract rather than a fact about the painting: a shape that answers a
235
+ * distance its own reported point does not support has two spellings of where
236
+ * its ink is, and the nearest-wins walk is resolved by the one the caller
237
+ * cannot see.
238
+ *
239
+ * The fourth claim is the strongest and is not universal: **a point on the box
240
+ * has to answer that box**, the last-painted one where boxes overlap, since the
241
+ * sweep hands candidates back to front. It holds only where the painted box IS
242
+ * the hit target — every `fillRect` shape, and `cell`'s inversion triangle,
243
+ * which answers as its bounding box on purpose — and not for a glyph, whose box
244
+ * is a superset of its ink. So it runs exactly when the batch painted one rect
245
+ * per instance, which is that set.
246
+ *
247
+ * A shape declaring `ink` is held to a second clause, per instance: the box
248
+ * the painter recorded for `i` lies inside `ink(i)`, and each edge of `ink(i)`
249
+ * lies within `inkSlackPx` (default 1) of the painting's — so the rect is
250
+ * tight to the ink within a pixel on every side, which admits a seam overdraw
251
+ * or a pixel snap and rejects a rect that is the wrong row, the wrong width or
252
+ * the whole canvas. An instance that painted nothing must answer no ink, and
253
+ * one with ink must have painted.
96
254
  *
97
255
  * Returns the violations rather than asserting, so a test reads as
98
256
  * `expect(sweep(...)).toEqual([])`. `maxDistSq` is the caller's, because a
99
257
  * shape whose hit target is wider than its ink (`point`) needs a bound that
100
258
  * admits the whole rect.
101
259
  */
102
- export function sweepDrawAgainstHit(shape, channels, block, frame, params, { maxDistSq, step = 0.5 }) {
260
+ export function sweepDrawAgainstHit(shape, channels, block, frame, params, { maxDistSq, step = 0.5, sliceOne, inkSlackPx = 1, }) {
261
+ const { count } = channels;
103
262
  const violations = [];
104
- const { ctx, calls: rects } = recordingContext();
105
- shape.paintBlock(ctx, channels, block, frame, params);
106
- if (rects.length !== channels.count) {
107
- return [`painted ${rects.length} rects for ${channels.count} instances`];
263
+ const paint = (c) => {
264
+ const { ctx, calls } = recordingContext();
265
+ shape.paintBlock(ctx, c, block, frame, params);
266
+ return calls;
267
+ };
268
+ const rects = paint(channels);
269
+ // Positional attribution, and the licence for the containment claim below.
270
+ const perRect = rects.length === count;
271
+ if (!perRect && !sliceOne) {
272
+ return [`painted ${rects.length} rects for ${count} instances`];
273
+ }
274
+ const boxes = sliceOne
275
+ ? Array.from({ length: count }, (_, i) => unionBox(paint(sliceOne(channels, i))))
276
+ : rects.map(r => ({ x: r.x, y: r.y, w: r.w, h: r.h }));
277
+ if (shape.ink) {
278
+ for (let i = 0; i < count; i++) {
279
+ const r = shape.ink(channels, block, frame, params, i);
280
+ const b = boxes[i];
281
+ if (r && !b) {
282
+ violations.push(`${i} painted nothing but has ink`);
283
+ }
284
+ else if (b && !r) {
285
+ violations.push(`${i} painted but has no ink`);
286
+ }
287
+ else if (b && r) {
288
+ const gaps = [
289
+ b.x - r.left,
290
+ r.left + r.width - (b.x + b.w),
291
+ b.y - r.top,
292
+ r.top + r.height - (b.y + b.h),
293
+ ];
294
+ if (gaps.some(g => g < -EPS)) {
295
+ violations.push(`${i} painted (${b.x}, ${b.y}, ${b.w}, ${b.h}) outside its ink (${r.left}, ${r.top}, ${r.width}, ${r.height})`);
296
+ }
297
+ else if (gaps.some(g => g > inkSlackPx + EPS)) {
298
+ violations.push(`${i}'s ink (${r.left}, ${r.top}, ${r.width}, ${r.height}) is more than ${inkSlackPx}px outside its painting (${b.x}, ${b.y}, ${b.w}, ${b.h})`);
299
+ }
300
+ }
301
+ if (violations.length >= 10) {
302
+ return violations;
303
+ }
304
+ }
108
305
  }
109
- const candidates = Array.from({ length: channels.count }, (_, k) => channels.count - 1 - k);
306
+ const hitNearest = shapeHitNearest(shape);
307
+ if (!hitNearest) {
308
+ return [`${shape.id} has no hit test`];
309
+ }
310
+ const candidates = Array.from({ length: count }, (_, k) => count - 1 - k);
110
311
  let yMin = Infinity;
111
312
  let yMax = -Infinity;
112
- for (const r of rects) {
113
- yMin = Math.min(yMin, r.y);
114
- yMax = Math.max(yMax, r.y + r.h);
313
+ for (const b of boxes) {
314
+ if (b) {
315
+ yMin = Math.min(yMin, b.y);
316
+ yMax = Math.max(yMax, b.y + b.h);
317
+ }
318
+ }
319
+ if (yMin === Infinity) {
320
+ return [`painted nothing for ${count} instances`];
115
321
  }
116
322
  const x0 = Math.min(block.screenStartPx, block.screenEndPx) - 1;
117
323
  const x1 = Math.max(block.screenStartPx, block.screenEndPx) + 1;
118
324
  for (let y = Math.floor(yMin) - 1; y <= yMax + 1; y += step) {
119
325
  for (let x = x0; x <= x1; x += step) {
120
326
  let expected = -1;
121
- for (let k = rects.length - 1; k >= 0; k--) {
122
- if (contains(rects[k], x, y)) {
123
- expected = k;
124
- break;
327
+ if (perRect) {
328
+ for (let k = count - 1; k >= 0; k--) {
329
+ if (contains(boxes[k], x, y)) {
330
+ expected = k;
331
+ break;
332
+ }
125
333
  }
126
334
  }
127
- const hit = shape.hitNearest(channels, block, frame, params, x, y, candidates, maxDistSq);
128
- if (hit && hit.distSq === 0 && !contains(rects[hit.index], x, y)) {
129
- violations.push(`(${x}, ${y}) answered ${hit.index} at distance 0, off its rect`);
335
+ const hit = hitNearest(channels, block, frame, params, x, y, candidates, maxDistSq);
336
+ const box = hit && boxes[hit.index];
337
+ if (hit && !box) {
338
+ violations.push(`(${x}, ${y}) answered ${hit.index}, which painted nothing`);
339
+ }
340
+ else if (hit && box) {
341
+ if (!contains(box, hit.x, hit.y, EPS)) {
342
+ violations.push(`(${x}, ${y}) answered ${hit.index} with ink at (${hit.x}, ${hit.y}), off its painting`);
343
+ }
344
+ const toBox = inkOnRect(x, y, box.x, box.y, box.w, box.h).distSq;
345
+ if (hit.distSq < toBox - EPS) {
346
+ violations.push(`(${x}, ${y}) answered ${hit.index} at ${hit.distSq}, nearer than its painting (${toBox})`);
347
+ }
348
+ const toPoint = (x - hit.x) ** 2 + (y - hit.y) ** 2;
349
+ if (Math.abs(hit.distSq - toPoint) > EPS * Math.max(1, toPoint)) {
350
+ violations.push(`(${x}, ${y}) answered ${hit.index} at ${hit.distSq}, which is not the distance to the ink it named (${toPoint})`);
351
+ }
130
352
  }
131
353
  if (expected !== -1 && hit?.index !== expected) {
132
354
  violations.push(`(${x}, ${y}) is on rect ${expected}, answered ${hit?.index ?? 'nothing'}`);
@@ -1,4 +1,6 @@
1
- import type { MarkContext2D } from './types.ts';
1
+ import type { InkRect, MarkContext2D } from './types.ts';
2
+ /** The path calls a glyph is built from — what a recorder has to answer. */
3
+ export type GlyphPath = Pick<MarkContext2D, 'moveTo' | 'lineTo' | 'closePath' | 'rect' | 'arc'>;
2
4
  /**
3
5
  * Append one of the `point` shape's glyphs to the current path, centred on
4
6
  * `(cx, y)`.
@@ -13,4 +15,10 @@ import type { MarkContext2D } from './types.ts';
13
15
  * hand that consumer the shader's WGSL and GLSL strings. Wiggle's scatter mode
14
16
  * is that consumer.
15
17
  */
16
- export declare function appendGlyph(ctx: MarkContext2D, glyph: number, cx: number, y: number, diameter: number): void;
18
+ export declare function appendGlyph(ctx: GlyphPath, glyph: number, cx: number, y: number, diameter: number): void;
19
+ /**
20
+ * The box `appendGlyph` paints inside, by the same split and snap: the
21
+ * diamond is scaled by `DIAMOND_GLYPH_SCALE` and the small square snaps to
22
+ * the pixel grid, so neither is the diameter about the centre.
23
+ */
24
+ export declare function glyphBox(glyph: number, cx: number, y: number, diameter: number): InkRect;
@@ -39,3 +39,24 @@ export function appendGlyph(ctx, glyph, cx, y, diameter) {
39
39
  ctx.arc(cx, y, r, 0, Math.PI * 2);
40
40
  }
41
41
  }
42
+ /**
43
+ * The box `appendGlyph` paints inside, by the same split and snap: the
44
+ * diamond is scaled by `DIAMOND_GLYPH_SCALE` and the small square snaps to
45
+ * the pixel grid, so neither is the diameter about the centre.
46
+ */
47
+ export function glyphBox(glyph, cx, y, diameter) {
48
+ const r = diameter / 2;
49
+ if (glyph === GLYPH_DIAMOND) {
50
+ const ri = r * DIAMOND_GLYPH_SCALE;
51
+ return { left: cx - ri, top: y - ri, width: 2 * ri, height: 2 * ri };
52
+ }
53
+ if (glyph !== GLYPH_TRIANGLE && diameter <= SMALL_POINT_MAX_DIAMETER) {
54
+ return {
55
+ left: crispSquareTopLeftPx(cx, diameter),
56
+ top: crispSquareTopLeftPx(y, diameter),
57
+ width: diameter,
58
+ height: diameter,
59
+ };
60
+ }
61
+ return { left: cx - r, top: y - r, width: diameter, height: diameter };
62
+ }
@@ -1,9 +1,16 @@
1
1
  export { defineMark } from './types.ts';
2
2
  export { paintMarkBlocks } from './markPaint.ts';
3
+ export { planMarks } from './markPlan.ts';
4
+ export { inkOfInstances } from './markInk.ts';
5
+ export { barMark } from './barMark.ts';
3
6
  export { pointMark } from './pointMark.ts';
4
- export { spanMark } from './spanMark.ts';
7
+ export { blockPx, spanMark } from './spanMark.ts';
5
8
  export { appendGlyph } from './glyphPaint.ts';
6
9
  export { abgrToCssRgba, makeAbgrFill } from './colorFill.ts';
7
- export type { Mark, MarkBand, MarkContext2D, MarkFrame, MarkHit, MarkShape, StagedUniforms, } from './types.ts';
10
+ export type { MarkPlan } from './markPlan.ts';
11
+ export type { MarkInstance } from './markInk.ts';
12
+ export type { Mark, MarkBand, MarkContext2D, MarkFrame, MarkImage, MarkRamp, MarkTexture, MarkValueScaleType, InkRect, MarkHit, MarkShape, PlannedPass, StagedUniforms, } from './types.ts';
13
+ export type { BarChannels, BarParams } from './barMark.ts';
8
14
  export type { PointChannels, PointParams } from './pointMark.ts';
9
15
  export type { SpanChannels, SpanParams } from './spanMark.ts';
16
+ export type { ColorChannel } from './markRamp.ts';
@@ -1,6 +1,9 @@
1
1
  export { defineMark } from './types.js';
2
2
  export { paintMarkBlocks } from './markPaint.js';
3
+ export { planMarks } from './markPlan.js';
4
+ export { inkOfInstances } from './markInk.js';
5
+ export { barMark } from './barMark.js';
3
6
  export { pointMark } from './pointMark.js';
4
- export { spanMark } from './spanMark.js';
7
+ export { blockPx, spanMark } from './spanMark.js';
5
8
  export { appendGlyph } from './glyphPaint.js';
6
9
  export { abgrToCssRgba, makeAbgrFill } from './colorFill.js';
@@ -6,13 +6,9 @@ import type { InstancePass } from '../instancePass.ts';
6
6
  import type { ClearColor, PerRegionRenderingBackend } from '../perRegionRenderingBackend.ts';
7
7
  import type { RenderBlock } from '../renderBlock.ts';
8
8
  import type { FrameDimensions } from '../renderingBackendBase.ts';
9
+ import type { MarkPlan } from './markPlan.ts';
9
10
  import type { Mark } from './types.ts';
10
- /**
11
- * Pack and upload one region's buffers for a mark list — every mark that owns
12
- * one, which is the whole list minus those drawing off another's (`bufferOf`).
13
- * That skip is the reason a display's upload is this call rather than a loop:
14
- * uploading to a borrowed pass is silent, and its buffer is the lender's.
15
- */
11
+ /** Pack and upload one region's buffers for a mark list. */
16
12
  export declare function uploadMarks<TRegion, TState extends FrameDimensions>(hal: GpuHal, regionKey: number, marks: readonly Mark<TRegion, TState>[], data: TRegion): void;
17
13
  /**
18
14
  * Draw one already-clipped block through a mark list.
@@ -23,8 +19,25 @@ export declare function uploadMarks<TRegion, TState extends FrameDimensions>(hal
23
19
  * coverage strip inside the column it hands over. `GpuPerRegionRenderingBackend`
24
20
  * sets both per block already, so for a backend on that scaffold the viewport
25
21
  * set here is the same rect twice.
22
+ *
23
+ * A caller that narrows it must hand over marks declaring no `band`, and that
24
+ * is the one way the two clips do not compose: a banded mark scissors to its
25
+ * strip and hands the BLOCK COLUMN back rather than what the caller had. The
26
+ * split is deliberate — `render-core/CLAUDE.md` §Drawing has which displays
27
+ * take which side — but nothing enforces it, so it is stated at both.
26
28
  */
27
29
  export declare function drawMarks<TRegion, TState extends FrameDimensions>(hal: GpuHal, scratch: ArrayBuffer, marks: readonly Mark<TRegion, TState>[], block: RenderBlock, clip: BlockClipResult, region: TRegion, state: TState, regionKey: number): void;
30
+ /**
31
+ * Draw one block's planned passes off the uniforms the caller has already
32
+ * staged for it. No gate, no lens, no write: `planMarks` settled the frame
33
+ * question and the caller's own `hal.writeUniforms` settled the block's, so
34
+ * what is left per mark is the `drawPass` — the loop a display with its own
35
+ * frame scaffold was already running over a hand-kept pass list.
36
+ *
37
+ * Viewport and scissor are the caller's, as with `drawMarks`' scissor: a
38
+ * plan's marks declare no `band`, so nothing here narrows or restores either.
39
+ */
40
+ export declare function drawPlannedPasses(hal: GpuHal, plan: Pick<MarkPlan<never, never>, 'passes'>, regionKey: number): void;
28
41
  /**
29
42
  * The HAL-side half of `createMarkBackend`, exported so a display's mark list
30
43
  * can be driven against `MockHal`: which marks upload, which draw off another's
@@ -36,9 +49,8 @@ export declare class GpuMarkBackend<TRegion, TState extends FrameDimensions> ext
36
49
  protected regionPasses: InstancePass<TRegion>[];
37
50
  constructor(hal: GpuHal, marks: readonly Mark<TRegion, TState>[], clear?: ((state: TState) => ClearColor) | undefined);
38
51
  protected clearColor(state: TState): ClearColor;
39
- upload(regionKey: number, data: TRegion): void;
40
- private boundRamps;
41
- private bindRamp;
52
+ private boundTextures;
53
+ private bindTexture;
42
54
  protected drawRegion(block: RenderBlock, clip: BlockClipResult, region: TRegion, state: TState): void;
43
55
  }
44
56
  /**
@@ -3,17 +3,17 @@ import { createRenderingBackend } from '../createRenderingBackend.js';
3
3
  import { uploadPass } from '../instancePass.js';
4
4
  import { Canvas2DPerRegionRenderingBackend, GpuPerRegionRenderingBackend, } from '../perRegionRenderingBackend.js';
5
5
  import { paintMarkBlocks } from './markPaint.js';
6
- /**
7
- * Pack and upload one region's buffers for a mark list — every mark that owns
8
- * one, which is the whole list minus those drawing off another's (`bufferOf`).
9
- * That skip is the reason a display's upload is this call rather than a loop:
10
- * uploading to a borrowed pass is silent, and its buffer is the lender's.
11
- */
6
+ // The passes a mark list owns a buffer for: the whole list minus those drawing
7
+ // off another's (`bufferOf`). Uploading to a borrowed pass is silent and its
8
+ // buffer is the lender's, so the skip is stated once, here — module-local,
9
+ // because `marks/backend` is a published subpath and nothing outside wants it.
10
+ function ownedPasses(marks) {
11
+ return marks.filter(m => !m.bufferOf).map(m => m.pass);
12
+ }
13
+ /** Pack and upload one region's buffers for a mark list. */
12
14
  export function uploadMarks(hal, regionKey, marks, data) {
13
- for (const mark of marks) {
14
- if (!mark.bufferOf) {
15
- uploadPass(hal, regionKey, mark.pass, data);
16
- }
15
+ for (const pass of ownedPasses(marks)) {
16
+ uploadPass(hal, regionKey, pass, data);
17
17
  }
18
18
  }
19
19
  /**
@@ -25,6 +25,12 @@ export function uploadMarks(hal, regionKey, marks, data) {
25
25
  * coverage strip inside the column it hands over. `GpuPerRegionRenderingBackend`
26
26
  * sets both per block already, so for a backend on that scaffold the viewport
27
27
  * set here is the same rect twice.
28
+ *
29
+ * A caller that narrows it must hand over marks declaring no `band`, and that
30
+ * is the one way the two clips do not compose: a banded mark scissors to its
31
+ * strip and hands the BLOCK COLUMN back rather than what the caller had. The
32
+ * split is deliberate — `render-core/CLAUDE.md` §Drawing has which displays
33
+ * take which side — but nothing enforces it, so it is stated at both.
28
34
  */
29
35
  export function drawMarks(hal, scratch, marks, block, clip, region, state, regionKey) {
30
36
  hal.setViewport(clip.pxX, 0, clip.pxW, clip.pxH);
@@ -34,9 +40,28 @@ export function drawMarks(hal, scratch, marks, block, clip, region, state, regio
34
40
  mark.drawRegion(hal, scratch, block, clip, region, state, regionKey, staged);
35
41
  }
36
42
  }
43
+ /**
44
+ * Draw one block's planned passes off the uniforms the caller has already
45
+ * staged for it. No gate, no lens, no write: `planMarks` settled the frame
46
+ * question and the caller's own `hal.writeUniforms` settled the block's, so
47
+ * what is left per mark is the `drawPass` — the loop a display with its own
48
+ * frame scaffold was already running over a hand-kept pass list.
49
+ *
50
+ * Viewport and scissor are the caller's, as with `drawMarks`' scissor: a
51
+ * plan's marks declare no `band`, so nothing here narrows or restores either.
52
+ */
53
+ export function drawPlannedPasses(hal, plan, regionKey) {
54
+ const { passes } = plan;
55
+ for (let i = 0; i < passes.length; i++) {
56
+ const pass = passes[i];
57
+ hal.drawPass(pass.id, regionKey, pass.bufferOf);
58
+ }
59
+ }
37
60
  // What a textured pass binds while its mark names no ramp: a shader declares
38
61
  // its sampler unconditionally, and a textured pass with no texture never draws
39
- // on the WebGPU HAL.
62
+ // on the WebGPU HAL. A mark declaring no `texture` at all is the same case —
63
+ // `pointMark` carries a sampler for the shapes that ramp, and Manhattan draws
64
+ // it without one.
40
65
  const INERT_RAMP = new Uint8Array(COLOR_RAMP_LUT_ENTRIES * 4);
41
66
  /**
42
67
  * The HAL-side half of `createMarkBackend`, exported so a display's mark list
@@ -46,37 +71,46 @@ const INERT_RAMP = new Uint8Array(COLOR_RAMP_LUT_ENTRIES * 4);
46
71
  export class GpuMarkBackend extends GpuPerRegionRenderingBackend {
47
72
  marks;
48
73
  clear;
49
- // `upload` runs `uploadMarks` in place of the base's loop, so nothing reads
50
- // this — the `bufferOf` skip is stated once, there
51
- regionPasses = [];
74
+ // The base's upload loop already IS the mark walk, so this backend names its
75
+ // passes instead of overriding `upload` with a second spelling of it.
76
+ regionPasses;
52
77
  constructor(hal, marks, clear) {
53
78
  super(hal);
54
79
  this.marks = marks;
55
80
  this.clear = clear;
81
+ this.regionPasses = ownedPasses(marks);
56
82
  }
57
83
  clearColor(state) {
58
84
  return this.clear ? this.clear(state) : super.clearColor(state);
59
85
  }
60
- upload(regionKey, data) {
61
- uploadMarks(this.hal, regionKey, this.marks, data);
62
- }
63
- // The ramp each textured pass holds, by the table's identity — the mirror of
64
- // the one texture the HAL keeps per pass, so an unchanged ramp costs a frame
65
- // nothing and a backend rebuilt after context loss re-uploads. Per pass and
66
- // per backend, never per region.
67
- boundRamps = new Map();
68
- bindRamp(mark, region, state) {
69
- if (mark.texture) {
70
- const ramp = mark.texture(state, region) ?? INERT_RAMP;
71
- if (ramp !== this.boundRamps.get(mark.pass.id)) {
72
- uploadColorRampLut(this.hal, ramp, [mark.pass.id]);
73
- this.boundRamps.set(mark.pass.id, ramp);
86
+ // The texture each textured pass holds, by identity — the mirror of the one
87
+ // texture the HAL keeps per pass, so an unchanged ramp or strip costs a
88
+ // frame nothing and a backend rebuilt after context loss re-uploads. Per
89
+ // pass and per backend, never per region. A lens answering nothing for a
90
+ // region leaves the pass's texture where it is, and binds the inert table
91
+ // only while the pass has none at all: a ring canvas draws one region per
92
+ // pass, and a lens with no opinion on the other regions must not swap the
93
+ // strip out and back on every frame.
94
+ boundTextures = new Map();
95
+ bindTexture(mark, region, state) {
96
+ if (mark.pass.textures) {
97
+ const bound = this.boundTextures.get(mark.pass.id);
98
+ const texture = mark.texture?.(state, region) ?? bound ?? INERT_RAMP;
99
+ if (texture !== bound) {
100
+ if (texture instanceof Uint8Array) {
101
+ uploadColorRampLut(this.hal, texture, [mark.pass.id]);
102
+ }
103
+ else {
104
+ const { image, width, height } = texture;
105
+ this.hal.uploadTexture(mark.pass.id, image, width, height);
106
+ }
107
+ this.boundTextures.set(mark.pass.id, texture);
74
108
  }
75
109
  }
76
110
  }
77
111
  drawRegion(block, clip, region, state) {
78
112
  for (const mark of this.marks) {
79
- this.bindRamp(mark, region, state);
113
+ this.bindTexture(mark, region, state);
80
114
  }
81
115
  drawMarks(this.hal, this.uniformData, this.marks, block, clip, region, state, block.displayedRegionIndex);
82
116
  }