nodality 1.0.221 → 1.0.224
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.
- package/API.md +227 -0
- package/bin/nodality.js +64 -0
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/animator.esm.js.LICENSE.txt +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.cjs.js.LICENSE.txt +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/audionew.esm.js.LICENSE.txt +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.cjs.js.LICENSE.txt +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/base.esm.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/bundle.umd.js.LICENSE.txt +131 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/button.esm.js.LICENSE.txt +1 -1
- package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/card-getter.esm.js.LICENSE.txt +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.cjs.js.LICENSE.txt +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/center.esm.js.LICENSE.txt +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/checkbox.esm.js.LICENSE.txt +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.cjs.js.LICENSE.txt +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/code.esm.js.LICENSE.txt +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.cjs.js.LICENSE.txt +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js +1 -1
- package/dist/data-list.esm.js.LICENSE.txt +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.cjs.js.LICENSE.txt +97 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +97 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +158 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-card.esm.js.LICENSE.txt +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/flex-row.esm.js.LICENSE.txt +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/floating-input.esm.js.LICENSE.txt +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.cjs.js.LICENSE.txt +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/free.esm.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image-picker.esm.js.LICENSE.txt +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.cjs.js.LICENSE.txt +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/image.esm.js.LICENSE.txt +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.LICENSE.txt +158 -1
- package/dist/index.d.ts +962 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +158 -1
- package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link-getter.esm.js.LICENSE.txt +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.cjs.js.LICENSE.txt +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/link.esm.js.LICENSE.txt +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.cjs.js.LICENSE.txt +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/picker.esm.js.LICENSE.txt +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.cjs.js.LICENSE.txt +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js +1 -1
- package/dist/range.esm.js.LICENSE.txt +1 -1
- package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
- package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-bar.esm.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.cjs.js.LICENSE.txt +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/spacer.esm.js.LICENSE.txt +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.cjs.js.LICENSE.txt +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/stack.esm.js.LICENSE.txt +1 -1
- package/dist/stacker.cjs.js.LICENSE.txt +1 -1
- package/dist/stacker.esm.js.LICENSE.txt +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.cjs.js.LICENSE.txt +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/table.esm.js.LICENSE.txt +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.cjs.js.LICENSE.txt +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text-field.esm.js.LICENSE.txt +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.cjs.js.LICENSE.txt +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/text.esm.js.LICENSE.txt +1 -1
- package/dist/theme.cjs.js.LICENSE.txt +1 -1
- package/dist/theme.esm.js.LICENSE.txt +1 -1
- package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
- package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.cjs.js.LICENSE.txt +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/ulist.esm.js.LICENSE.txt +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.cjs.js.LICENSE.txt +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.LICENSE.txt +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.cjs.js.LICENSE.txt +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/wrap.esm.js.LICENSE.txt +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/examples/custom-raster-op.js +174 -0
- package/layout/animator.js +30 -2
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +3 -2
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +2 -2
- package/layout/circle.js +3 -2
- package/layout/code.js +3 -2
- package/layout/container.js +3 -4
- package/layout/dropdown-2025.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +3 -2
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +2 -2
- package/layout/form-components/floating-input.js +2 -2
- package/layout/form-components/form-all.js +2 -2
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +2 -2
- package/layout/form-components/radio.js +2 -2
- package/layout/form-components/radiogroup.js +2 -2
- package/layout/form-components/range.js +2 -2
- package/layout/free.js +3 -4
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/link.js +5 -5
- package/layout/list.js +3 -4
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1003 -0
- package/layout/multiswitcher.js +11 -3
- package/layout/nav-bar.js +2 -3
- package/layout/nav-factor/custom-div.js +2 -2
- package/layout/new-nav-bar.js +11 -4
- package/layout/polygon.js +3 -4
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +1 -1
- package/layout/side-nav-bar.js +1 -1
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/svg.js +3 -4
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +5 -5
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/codegen.js +62 -0
- package/lib/data.js +37 -1
- package/lib/designer.js +18 -1
- package/lib/element-mapper.js +351 -247
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +424 -0
- package/lib/raster-inspect.js +409 -0
- package/lib/raster-ops.js +1902 -80
- package/lib/raster-presets.js +314 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +28 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +66 -0
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +205 -0
- package/package.json +20 -7
package/lib/raster-ops.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* nodality v1.0.
|
|
2
|
+
* nodality v1.0.224
|
|
3
3
|
* (c) 2026 Filip Vabrousek
|
|
4
4
|
* License: MIT
|
|
5
5
|
*/
|
|
@@ -23,11 +23,21 @@
|
|
|
23
23
|
// so fidelity is high. Static: recaptured on resize (or via
|
|
24
24
|
// handle.refresh()). External images / webfonts do not load inside
|
|
25
25
|
// the SVG image context — system fonts and data: URIs only.
|
|
26
|
-
// live (
|
|
27
|
-
// HTML-in-Canvas API (WICG, Chrome origin trial):
|
|
28
|
-
// moves inside the effect <canvas layoutsubtree> and
|
|
29
|
-
// frame with gl.texElementImage2D(), staying
|
|
30
|
-
// Falls back to snapshot when the API is absent
|
|
26
|
+
// live (the DEFAULT where it applies; opt OUT with `live: false` on any
|
|
27
|
+
// node) — the emerging HTML-in-Canvas API (WICG, Chrome origin trial):
|
|
28
|
+
// the mount subtree moves inside the effect <canvas layoutsubtree> and
|
|
29
|
+
// is uploaded per frame with gl.texElementImage2D(), staying
|
|
30
|
+
// interactive and live. Falls back to snapshot when the API is absent,
|
|
31
|
+
// and again if no paint event arrives within 1500ms.
|
|
32
|
+
//
|
|
33
|
+
// "Where it applies" = any chain that is not PURE overlay. A
|
|
34
|
+
// pure-overlay chain (blobs alone) restructures nothing, so there is
|
|
35
|
+
// no live subtree to sample and it stays on snapshot. See `wantLive`
|
|
36
|
+
// in applyRasterPipeline, which is the authority:
|
|
37
|
+
// !pureOverlay && !nodes.some(n => n.live === false)
|
|
38
|
+
// (This said "opt-in: any raster node carries `live: true`" until
|
|
39
|
+
// 2026-08-12. It had been the opposite of the code for some time —
|
|
40
|
+
// `live: true` is not read anywhere.)
|
|
31
41
|
//
|
|
32
42
|
// Safety: this module touches nothing at import time, so it is inert
|
|
33
43
|
// under jsdom prerender (nodality/ssg). applyRasterPipeline() returns
|
|
@@ -44,8 +54,30 @@
|
|
|
44
54
|
// Ops execute in nodes-array order within their stage. Uniforms are
|
|
45
55
|
// namespaced per node index so the same op can appear twice.
|
|
46
56
|
|
|
57
|
+
// Same "did you mean" matching the element mapper and morph use, so a
|
|
58
|
+
// mistyped stage reads like a mistyped element type. Pure module, no DOM
|
|
59
|
+
// — importing it keeps this file inert at import (phase P1).
|
|
60
|
+
import { didYouMean } from "./suggest.js";
|
|
61
|
+
|
|
47
62
|
const MAX_BLOBS = 12;
|
|
48
63
|
|
|
64
|
+
// The unit vocabulary doc.params draws on. Not enforcement — nothing
|
|
65
|
+
// converts by unit — but it is what lets the inspector label a control
|
|
66
|
+
// and pick a sane step, and a closed list means "pixels" vs "px" vs "PX"
|
|
67
|
+
// cannot drift across 15 ops.
|
|
68
|
+
const RASTER_UNITS = [
|
|
69
|
+
"px", // a length in CSS pixels; the op scales it by dpr itself
|
|
70
|
+
"ratio", // 0..1, or a multiplier — never scaled by dpr
|
|
71
|
+
"deg", // degrees, converted to radians at upload
|
|
72
|
+
"count", // an integer quantity of things
|
|
73
|
+
"color", // "#rrggbb"
|
|
74
|
+
"name", // an identifier: a field name, a driver, a blend mode
|
|
75
|
+
"point", // [x, y], fractions of the element box
|
|
76
|
+
"range", // [lo, hi], a remap
|
|
77
|
+
"bool", // present/absent toggle
|
|
78
|
+
"seconds", // a duration or a rate per second
|
|
79
|
+
];
|
|
80
|
+
|
|
49
81
|
// ── Drivers ──────────────────────────────────────────────────────────
|
|
50
82
|
// What steers a reactive op. Previously "react to the pointer" was
|
|
51
83
|
// hardcoded into offset and duotone; a driver makes the input a value in
|
|
@@ -78,6 +110,24 @@ const DRIVER_NAMES = Object.keys(DRIVERS);
|
|
|
78
110
|
|
|
79
111
|
const REGISTRY = {
|
|
80
112
|
hexalize: {
|
|
113
|
+
doc: {
|
|
114
|
+
summary: "Hexagonal cell grid. Snaps sampling to a hex lattice and "
|
|
115
|
+
+ "draws cell borders; with `lift`, cells near the driver swell "
|
|
116
|
+
+ "toward the viewer and their content magnifies to match.",
|
|
117
|
+
params: {
|
|
118
|
+
size: { default: 24, unit: "px", summary: "lattice pitch — the width of one cell" },
|
|
119
|
+
lift: {
|
|
120
|
+
default: 0, unit: "ratio", structural: true,
|
|
121
|
+
summary: "how much bigger a cell gets at the focus: 0.3 renders the "
|
|
122
|
+
+ "nearest hexagons at 1.3x. 0 compiles the cheap path entirely away, "
|
|
123
|
+
+ "so crossing zero rebuilds.",
|
|
124
|
+
},
|
|
125
|
+
radius: { default: 200, unit: "px", summary: "falloff distance around the driver focus" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
// `lift` at 0 compiles the cheap path; non-zero compiles the
|
|
129
|
+
// seven-neighbour probe. Only crossing zero needs a new shader.
|
|
130
|
+
structuralOnToggle: ["lift"],
|
|
81
131
|
stage: "cell",
|
|
82
132
|
decl: (p) => `
|
|
83
133
|
uniform float ${p}size;
|
|
@@ -181,9 +231,25 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
181
231
|
lift: ["1f", node.lift != null ? node.lift : 0],
|
|
182
232
|
radius: ["1f", (node.radius || 200) * dpr],
|
|
183
233
|
}),
|
|
234
|
+
// Without `lift` the cheap path never writes `warped` — it only
|
|
235
|
+
// sets center/edge — so the coordinate is unchanged. WITH lift it
|
|
236
|
+
// probes seven neighbours to find which grown hexagon covers the
|
|
237
|
+
// fragment, and mirroring that on the CPU would be a second
|
|
238
|
+
// implementation of the subtlest code here. Returns null instead,
|
|
239
|
+
// which routes the query to the GPU readback.
|
|
240
|
+
map: (pt, node) => (node.lift ? null : pt),
|
|
184
241
|
},
|
|
185
242
|
|
|
186
243
|
offset: {
|
|
244
|
+
doc: {
|
|
245
|
+
summary: "Pushes the coordinate space away from the driver focus. Warp "
|
|
246
|
+
+ "stage, so cell grids and borders move WITH the content instead of "
|
|
247
|
+
+ "staying a fixed screen-space lattice.",
|
|
248
|
+
params: {
|
|
249
|
+
strength: { default: 20, unit: "px", summary: "how far the space is pushed at full amount" },
|
|
250
|
+
radius: { default: 260, unit: "px", summary: "falloff distance around the focus" },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
187
253
|
// Warp-stage: displaces the coordinate space before the cell
|
|
188
254
|
// grid is computed, so tiles — content AND borders — move
|
|
189
255
|
// together away from the pointer. (The old displace-stage
|
|
@@ -206,9 +272,136 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
206
272
|
strength: ["1f", (node.strength || 20) * dpr],
|
|
207
273
|
radius: ["1f", (node.radius || 260) * dpr],
|
|
208
274
|
}),
|
|
275
|
+
// Phase I2. The CPU twin of code() above, so a hit-test costs no
|
|
276
|
+
// GPU sync. Checked against the readback rather than trusted —
|
|
277
|
+
// see the property test in raster-probe.spec.js.
|
|
278
|
+
map: (pt, node, ctx) => {
|
|
279
|
+
const strength = (node.strength || 20) * ctx.dpr;
|
|
280
|
+
const radius = (node.radius || 260) * ctx.dpr;
|
|
281
|
+
const vx = pt[0] - ctx.dpos[0], vy = pt[1] - ctx.dpos[1];
|
|
282
|
+
const d = Math.hypot(vx, vy);
|
|
283
|
+
const fall = 1 - smoothstep(0, radius, d);
|
|
284
|
+
if (!(d > 0.5)) return pt;
|
|
285
|
+
const k = (strength * fall * ctx.damt) / d;
|
|
286
|
+
return [pt[0] - vx * k, pt[1] - vy * k];
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// Curl-noise flow: the coordinate space drifts along a divergence-free
|
|
291
|
+
// vector field, so content slides as if carried by a current. Warp
|
|
292
|
+
// stage, which buys two things for free — it is maskable (STAGE_VARS
|
|
293
|
+
// snapshots `warped`) and it composes with every cell and colour op,
|
|
294
|
+
// because those run downstream of it.
|
|
295
|
+
//
|
|
296
|
+
// { op: "flow", strength: 18, scale: 120 } // whole element
|
|
297
|
+
// { op: "flow", by: "mouse", radius: 320 } // a current at the pointer
|
|
298
|
+
// { op: "flow", by: "time", speed: 0.4 } // hands-free drift
|
|
299
|
+
//
|
|
300
|
+
// Divergence-free BY CONSTRUCTION: the field is the curl of a scalar
|
|
301
|
+
// potential ψ, and curl of a gradient-free scalar has zero divergence.
|
|
302
|
+
// That is what makes it read as a fluid without a pressure solve —
|
|
303
|
+
// `stir` pays for a full Stable-Fluids projection to get a field that
|
|
304
|
+
// also responds to input; this one is free and static-by-seed.
|
|
305
|
+
//
|
|
306
|
+
// Without `by:` the current fills the element. With a driver, strength
|
|
307
|
+
// falls off around the focus (the `duotone`/`halftone` convention), so
|
|
308
|
+
// the effect resolves away from the pointer instead of being global.
|
|
309
|
+
flow: {
|
|
310
|
+
doc: {
|
|
311
|
+
summary: "Curl-noise current. The coordinate space drifts along a "
|
|
312
|
+
+ "divergence-free field, so content slides as if carried by water. "
|
|
313
|
+
+ "Divergence-free means the flow never piles up or tears.",
|
|
314
|
+
params: {
|
|
315
|
+
strength: { default: 18, unit: "px", summary: "how far the current carries the space" },
|
|
316
|
+
scale: { default: 120, unit: "px", summary: "size of one eddy — small is turbulent, large is a slow drift" },
|
|
317
|
+
speed: { default: 0.25, unit: "seconds", summary: "how fast the field evolves; 0 freezes it" },
|
|
318
|
+
seed: { default: 0, unit: "count", summary: "picks a different field of the same character" },
|
|
319
|
+
radius: {
|
|
320
|
+
default: 320, unit: "px",
|
|
321
|
+
summary: "falloff around the focus. Only used when `by` names a driver; "
|
|
322
|
+
+ "without one the current covers the whole element.",
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
structural: ["by"],
|
|
327
|
+
stage: "warp",
|
|
328
|
+
decl: (p) => `
|
|
329
|
+
uniform float ${p}scale;
|
|
330
|
+
uniform float ${p}strength;
|
|
331
|
+
uniform float ${p}speed;
|
|
332
|
+
uniform float ${p}seed;
|
|
333
|
+
uniform float ${p}radius;
|
|
334
|
+
uniform vec2 ${p}dpos;
|
|
335
|
+
uniform float ${p}damt;
|
|
336
|
+
// Polynomial bit-mixing hash, deliberately NOT the usual
|
|
337
|
+
// fract(sin(dot(...))). Trig-based hashes disagree across GPU
|
|
338
|
+
// vendors — sin's precision at large arguments is not
|
|
339
|
+
// specified — so the same seed would render a different field
|
|
340
|
+
// on different hardware. This one is multiply/fract only, so
|
|
341
|
+
// the field is at least stable per-device. (Determinism rule,
|
|
342
|
+
// MORPH-IMPL-SPEC §2.9.5a.)
|
|
343
|
+
float ${p}h(vec2 v) {
|
|
344
|
+
vec3 q = fract(vec3(v.xyx) * 0.1031 + ${p}seed);
|
|
345
|
+
q += dot(q, q.yzx + 33.33);
|
|
346
|
+
return fract((q.x + q.y) * q.z);
|
|
347
|
+
}
|
|
348
|
+
float ${p}n(vec2 v) {
|
|
349
|
+
vec2 i = floor(v), f = v - i;
|
|
350
|
+
f = f * f * (3.0 - 2.0 * f);
|
|
351
|
+
return mix(mix(${p}h(i), ${p}h(i + vec2(1.0, 0.0)), f.x),
|
|
352
|
+
mix(${p}h(i + vec2(0.0, 1.0)), ${p}h(i + vec2(1.0, 1.0)), f.x), f.y);
|
|
353
|
+
}
|
|
354
|
+
// The potential. Two octaves: enough structure to read as a
|
|
355
|
+
// current without the cost of a full fBm.
|
|
356
|
+
float ${p}psi(vec2 v) {
|
|
357
|
+
return ${p}n(v) * 0.65 + ${p}n(v * 2.3 + 11.0) * 0.35;
|
|
358
|
+
}`,
|
|
359
|
+
code: (p, node) => `
|
|
360
|
+
{
|
|
361
|
+
float sc = max(${p}scale, 1.0);
|
|
362
|
+
vec2 drift = vec2(0.0, u_time * ${p}speed);${node.by ? `
|
|
363
|
+
float fall = 1.0 - smoothstep(0.0, ${p}radius, length(warped - ${p}dpos));
|
|
364
|
+
float amt = ${p}strength * fall * ${p}damt;` : `
|
|
365
|
+
float amt = ${p}strength;`}
|
|
366
|
+
// Three fixed Euler steps along curl(psi). Fixed count, not
|
|
367
|
+
// adaptive: the step budget is a compile-time property so the
|
|
368
|
+
// shader cost cannot vary with the content.
|
|
369
|
+
for (int i = 0; i < 3; i++) {
|
|
370
|
+
vec2 q = warped / sc + drift;
|
|
371
|
+
float e = 0.35;
|
|
372
|
+
float dy = ${p}psi(q + vec2(0.0, e)) - ${p}psi(q - vec2(0.0, e));
|
|
373
|
+
float dx = ${p}psi(q + vec2(e, 0.0)) - ${p}psi(q - vec2(e, 0.0));
|
|
374
|
+
// curl of a 2D scalar potential = (dpsi/dy, -dpsi/dx)
|
|
375
|
+
warped += vec2(dy, -dx) / (2.0 * e) * amt * 0.3333;
|
|
376
|
+
}
|
|
377
|
+
}`,
|
|
378
|
+
uniforms: (node, dpr) => ({
|
|
379
|
+
// Feature size of the current, in px. Larger = broader, slower
|
|
380
|
+
// -turning eddies.
|
|
381
|
+
scale: ["1f", (node.scale != null ? node.scale : 120) * dpr],
|
|
382
|
+
// Displacement in px. Scaled by dpr: it is a length.
|
|
383
|
+
strength: ["1f", (node.strength != null ? node.strength : 18) * dpr],
|
|
384
|
+
speed: ["1f", node.speed != null ? node.speed : 0.25],
|
|
385
|
+
// NOT dpr-scaled — it selects a field, it is not a length.
|
|
386
|
+
seed: ["1f", node.seed != null ? node.seed : 0],
|
|
387
|
+
radius: ["1f", (node.radius != null ? node.radius : 320) * dpr],
|
|
388
|
+
}),
|
|
209
389
|
},
|
|
210
390
|
|
|
211
391
|
duotone: {
|
|
392
|
+
doc: {
|
|
393
|
+
summary: "Maps luminance onto a two-colour ramp. With `by`, the mapping "
|
|
394
|
+
+ "is confined to a spot around the driver focus and the rest of the "
|
|
395
|
+
+ "element keeps its own colour.",
|
|
396
|
+
params: {
|
|
397
|
+
colors: {
|
|
398
|
+
default: ["#104B87", "#E8FF00"], unit: "color",
|
|
399
|
+
summary: "[shadow, highlight] — the two ends of the ramp",
|
|
400
|
+
},
|
|
401
|
+
radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
structural: ["by"],
|
|
212
405
|
stage: "color",
|
|
213
406
|
decl: (p) => `uniform vec3 ${p}a; uniform vec3 ${p}b; uniform float ${p}radius;
|
|
214
407
|
uniform vec2 ${p}dpos; uniform float ${p}damt;`,
|
|
@@ -239,6 +432,15 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
239
432
|
// e.g. { op: "edges", color: "#FFFFFF" } gives white borders under
|
|
240
433
|
// the text. When present it also replaces the default seam darkening.
|
|
241
434
|
edges: {
|
|
435
|
+
doc: {
|
|
436
|
+
summary: "Draws the cell borders a cell-stage op produced. On its own it "
|
|
437
|
+
+ "shows nothing — it colours `edge`, which only hexalize (or another "
|
|
438
|
+
+ "cell op) writes.",
|
|
439
|
+
params: {
|
|
440
|
+
color: { default: "#FFFFFF", unit: "color", summary: "border colour" },
|
|
441
|
+
strength: { default: 1.0, unit: "ratio", summary: "border opacity" },
|
|
442
|
+
},
|
|
443
|
+
},
|
|
242
444
|
stage: "color",
|
|
243
445
|
decl: (p) => `uniform vec3 ${p}color; uniform float ${p}strength;`,
|
|
244
446
|
code: (p) => `
|
|
@@ -285,9 +487,24 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
285
487
|
// With a driver the screen coarsens toward the focus, like holding a
|
|
286
488
|
// loupe over the sheet.
|
|
287
489
|
halftone: {
|
|
490
|
+
doc: {
|
|
491
|
+
summary: "Print halftone: the image is redrawn as a grid of ink dots on "
|
|
492
|
+
+ "paper, dot size following local luminance.",
|
|
493
|
+
params: {
|
|
494
|
+
size: { default: 6, unit: "px", summary: "dot pitch" },
|
|
495
|
+
angle: { default: 15, unit: "deg", summary: "screen angle of the dot grid" },
|
|
496
|
+
ink: { default: "#0B1B2B", unit: "color", summary: "dot colour" },
|
|
497
|
+
paper: { default: "#FFFFFF", unit: "color", summary: "background colour" },
|
|
498
|
+
softness: { default: 0.08, unit: "ratio", summary: "dot edge softness — 0 is a hard stencil" },
|
|
499
|
+
amount: { default: 1, unit: "ratio", summary: "blend back toward the un-screened image; 0 is identity. Keyframe it to 0 at both ends of a transition so the shader hands over to the real element without a pop." },
|
|
500
|
+
radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
structural: ["by"],
|
|
288
504
|
stage: "color",
|
|
289
505
|
decl: (p) => `
|
|
290
506
|
uniform float ${p}size;
|
|
507
|
+
uniform float ${p}amount;
|
|
291
508
|
uniform float ${p}angle;
|
|
292
509
|
uniform vec3 ${p}ink;
|
|
293
510
|
uniform vec3 ${p}paper;
|
|
@@ -311,10 +528,11 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
311
528
|
// sqrt because ink coverage goes as area, not radius.
|
|
312
529
|
float rr = sqrt(clamp(1.0 - lum, 0.0, 1.0)) * 0.55;
|
|
313
530
|
float dm = smoothstep(rr, rr - ${p}soft, length(cell));
|
|
314
|
-
col = mix(${p}paper, ${p}ink, dm);
|
|
531
|
+
col = mix(col, mix(${p}paper, ${p}ink, dm), clamp(${p}amount, 0.0, 1.0));
|
|
315
532
|
}`,
|
|
316
533
|
uniforms: (node, dpr) => ({
|
|
317
534
|
size: ["1f", (node.size || 6) * dpr],
|
|
535
|
+
amount: ["1f", node.amount != null ? node.amount : 1],
|
|
318
536
|
angle: ["1f", ((node.angle != null ? node.angle : 15) * Math.PI) / 180],
|
|
319
537
|
ink: ["3fv", hexToRgb(node.ink || "#0B1B2B")],
|
|
320
538
|
paper: ["3fv", hexToRgb(node.paper || "#FFFFFF")],
|
|
@@ -341,6 +559,25 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
341
559
|
//
|
|
342
560
|
// `by` coarsens the pattern toward the driver focus, as halftone does.
|
|
343
561
|
dither: {
|
|
562
|
+
doc: {
|
|
563
|
+
summary: "Ordered (Bayer) dithering to a small palette — the look of an "
|
|
564
|
+
+ "8-bit display, and the one op that gets sharper as the element gets "
|
|
565
|
+
+ "smaller.",
|
|
566
|
+
params: {
|
|
567
|
+
size: { default: 1, unit: "px", summary: "size of one dither cell; 1 is per-pixel" },
|
|
568
|
+
levels: { default: 6, unit: "count", summary: "quantisation steps per channel" },
|
|
569
|
+
amount: { default: 1, unit: "ratio", summary: "blend back toward the undithered image" },
|
|
570
|
+
mono: {
|
|
571
|
+
default: false, unit: "bool", structural: true,
|
|
572
|
+
summary: "quantise luminance to ink/paper instead of per-channel colour. "
|
|
573
|
+
+ "A different shader, so toggling rebuilds.",
|
|
574
|
+
},
|
|
575
|
+
ink: { default: "#0B1B2B", unit: "color", summary: "dark end, mono only" },
|
|
576
|
+
paper: { default: "#FFFFFF", unit: "color", summary: "light end, mono only" },
|
|
577
|
+
radius: { default: 220, unit: "px", summary: "spot size when `by` names a driver" },
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
structural: ["by", "mono"],
|
|
344
581
|
stage: "color",
|
|
345
582
|
decl: (p) => `
|
|
346
583
|
uniform float ${p}levels;
|
|
@@ -400,6 +637,20 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
400
637
|
// { op: "aberration", amount: 6 } // radial, lens-like
|
|
401
638
|
// { op: "aberration", amount: 14, by: "mouse" } // focused at the pointer
|
|
402
639
|
aberration: {
|
|
640
|
+
// Displace stage, but it only spreads the per-channel taps
|
|
641
|
+
// (`chroma`) — the primary sample position is untouched. Declared
|
|
642
|
+
// so a hit-test can skip it rather than give up on the CPU path;
|
|
643
|
+
// a unit test checks this against the op's own source.
|
|
644
|
+
movesCoords: false,
|
|
645
|
+
doc: {
|
|
646
|
+
summary: "Chromatic aberration: the three colour channels are sampled "
|
|
647
|
+
+ "at slightly different offsets, like a cheap lens.",
|
|
648
|
+
params: {
|
|
649
|
+
amount: { default: 6, unit: "px", summary: "channel separation" },
|
|
650
|
+
radius: { default: 240, unit: "px", summary: "falloff around the focus when `by` names a driver" },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
structural: ["by"],
|
|
403
654
|
stage: "displace",
|
|
404
655
|
chroma: true,
|
|
405
656
|
decl: (p) => `
|
|
@@ -439,6 +690,30 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
439
690
|
|
|
440
691
|
// Geometric / content masks.
|
|
441
692
|
mask: {
|
|
693
|
+
doc: {
|
|
694
|
+
summary: "Writes a scalar FIELD for later ops to read — draws nothing "
|
|
695
|
+
+ "itself. Give a downstream op `masked: \"name\"` and it applies only "
|
|
696
|
+
+ "where this field is high. This is how any op becomes local without "
|
|
697
|
+
+ "knowing anything about masking.",
|
|
698
|
+
params: {
|
|
699
|
+
as: {
|
|
700
|
+
default: "mask", unit: "name", structural: true,
|
|
701
|
+
summary: "name of the field written. Consumers name it in `masked:`.",
|
|
702
|
+
},
|
|
703
|
+
from: {
|
|
704
|
+
default: "radial", unit: "name", structural: true,
|
|
705
|
+
summary: "field shape — compiled in, so changing it rebuilds",
|
|
706
|
+
},
|
|
707
|
+
at: {
|
|
708
|
+
default: [0.5, 0.5], unit: "point", structural: true,
|
|
709
|
+
summary: "centre as a fraction of the element box; survives resizes",
|
|
710
|
+
},
|
|
711
|
+
radius: { default: 260, unit: "px", summary: "field radius" },
|
|
712
|
+
invert: { default: false, unit: "bool", summary: "swap inside and outside" },
|
|
713
|
+
remap: { default: [0, 1], unit: "range", summary: "rescale the field's output range" },
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
structural: ["as", "from", "at"],
|
|
442
717
|
stage: "field",
|
|
443
718
|
producesField: true,
|
|
444
719
|
defaultDriver: "static",
|
|
@@ -483,6 +758,22 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
483
758
|
// { op: "noise", as: "turbulence", scale: 3, speed: 0.4 },
|
|
484
759
|
// { op: "offset", masked: "turbulence", strength: 30 },
|
|
485
760
|
noise: {
|
|
761
|
+
doc: {
|
|
762
|
+
summary: "Writes an animated noise FIELD. Same contract as `mask` — it "
|
|
763
|
+
+ "draws nothing; downstream ops read it via `masked:`. Use it to make "
|
|
764
|
+
+ "any other op flicker, breathe or crawl.",
|
|
765
|
+
params: {
|
|
766
|
+
as: {
|
|
767
|
+
default: "mask", unit: "name", structural: true,
|
|
768
|
+
summary: "name of the field written",
|
|
769
|
+
},
|
|
770
|
+
scale: { default: 3, unit: "ratio", summary: "noise frequency across the element" },
|
|
771
|
+
speed: { default: 0.3, unit: "seconds", summary: "how fast it evolves; 0 freezes it" },
|
|
772
|
+
amount: { default: 1, unit: "ratio", summary: "blend between flat 1.0 and full noise" },
|
|
773
|
+
remap: { default: [0, 1], unit: "range", summary: "rescale the field's output range" },
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
structural: ["as"],
|
|
486
777
|
stage: "field",
|
|
487
778
|
producesField: true,
|
|
488
779
|
decl: (p) => `
|
|
@@ -535,6 +826,35 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
535
826
|
// earlier colour ops, so a copy of a halftoned element is a copy of
|
|
536
827
|
// the unhalftoned source with the screen applied over it.
|
|
537
828
|
copy: {
|
|
829
|
+
// Phase I4. Draws N stamps of the ORIGINAL texture over the
|
|
830
|
+
// result, so one screen point genuinely shows several sources and
|
|
831
|
+
// `sourceAt` can only report the primary sample. There is no
|
|
832
|
+
// correct single answer here, only a documented one.
|
|
833
|
+
multiSample: true,
|
|
834
|
+
doc: {
|
|
835
|
+
summary: "Stamps extra copies of the element over itself. Copies sample "
|
|
836
|
+
+ "the ORIGINAL texture, not the result of earlier colour ops, so a copy "
|
|
837
|
+
+ "of a halftoned element is a copy of the unhalftoned source.",
|
|
838
|
+
params: {
|
|
839
|
+
count: {
|
|
840
|
+
default: 5, unit: "count", structural: true,
|
|
841
|
+
summary: "stamps arranged in a ring around the focus. The loop is "
|
|
842
|
+
+ "unrolled at compile time, so this rebuilds — not something to animate.",
|
|
843
|
+
},
|
|
844
|
+
points: {
|
|
845
|
+
default: null, unit: "point", structural: true,
|
|
846
|
+
summary: "explicit [[x,y], ...] in fractions of the element box, instead "
|
|
847
|
+
+ "of a ring. Survives resizes. Overrides `count`.",
|
|
848
|
+
},
|
|
849
|
+
radius: { default: 110, unit: "px", summary: "ring radius when using `count`" },
|
|
850
|
+
scale: { default: 0.5, unit: "ratio", summary: "size of each copy" },
|
|
851
|
+
rotate: { default: 0, unit: "deg", summary: "rotation applied per copy" },
|
|
852
|
+
fade: { default: 1, unit: "ratio", summary: "opacity falloff across the set" },
|
|
853
|
+
amount: { default: 1, unit: "ratio", summary: "overall blend of the copies over the original" },
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
// `count`/`points` unroll the stamp loop at compile time.
|
|
857
|
+
structural: ["count", "points"],
|
|
538
858
|
stage: "color",
|
|
539
859
|
defaultDriver: "static",
|
|
540
860
|
decl: (p) => `
|
|
@@ -605,6 +925,22 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
605
925
|
// no extra framebuffer and no second capture of the DOM. It cannot,
|
|
606
926
|
// for the same reason, give the branches different source content.
|
|
607
927
|
merge: {
|
|
928
|
+
doc: {
|
|
929
|
+
summary: "Runs two sub-chains over the same source and blends the "
|
|
930
|
+
+ "results. The only op that takes other ops as arguments — everything "
|
|
931
|
+
+ "else composes by sequence, this composes in parallel.",
|
|
932
|
+
params: {
|
|
933
|
+
a: { default: null, summary: "first branch: an array of nodes", structural: true },
|
|
934
|
+
b: { default: null, summary: "second branch: an array of nodes", structural: true },
|
|
935
|
+
mode: {
|
|
936
|
+
default: "over", unit: "name", structural: true,
|
|
937
|
+
summary: "how the branches combine: over, add, screen, multiply, "
|
|
938
|
+
+ "difference, lighten. Compiled in, so it rebuilds.",
|
|
939
|
+
},
|
|
940
|
+
mix: { default: 1, unit: "ratio", summary: "how far to blend b into a" },
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
structural: ["mode", "a", "b"],
|
|
608
944
|
// Never routed through the normal stage path — emitMerge()
|
|
609
945
|
// handles it — but it still needs decl/uniforms so its `mix`
|
|
610
946
|
// gets declared and uploaded like any other op's.
|
|
@@ -632,6 +968,17 @@ ${NB.map(([x, y]) => probe(x, y)).join("")}
|
|
|
632
968
|
// skeleton composites underneath the content, so the ghosts sit
|
|
633
969
|
// behind the live text rather than veiling it.
|
|
634
970
|
echo: {
|
|
971
|
+
doc: {
|
|
972
|
+
summary: "Temporal accumulation — a fading trail of what CHANGED. Typing "
|
|
973
|
+
+ "smears, a ticking counter leaves a comet. The one op that does nothing "
|
|
974
|
+
+ "on the snapshot backend, because a frozen image has no motion to record.",
|
|
975
|
+
params: {
|
|
976
|
+
strength: { default: 0.85, unit: "ratio", summary: "opacity of the trail" },
|
|
977
|
+
decay: { default: 0.92, unit: "ratio", summary: "per-second persistence — higher is a longer tail" },
|
|
978
|
+
tint: { default: "#7FD4FF", unit: "color", summary: "colour pushed into the trail" },
|
|
979
|
+
tintAmount: { default: 0.5, unit: "ratio", summary: "how far the trail is tinted" },
|
|
980
|
+
},
|
|
981
|
+
},
|
|
635
982
|
stage: "color",
|
|
636
983
|
decl: (p) => `
|
|
637
984
|
uniform sampler2D ${p}hist;
|
|
@@ -747,6 +1094,29 @@ void main() {
|
|
|
747
1094
|
// Needs WebGL2 with float render targets; without them the op
|
|
748
1095
|
// disables itself and the rest of the chain still renders.
|
|
749
1096
|
stir: {
|
|
1097
|
+
doc: {
|
|
1098
|
+
summary: "Stirred liquid: a real fluid simulation the content is dragged "
|
|
1099
|
+
+ "through. STATEFUL — it owns a ping-pong pair of render targets that "
|
|
1100
|
+
+ "evolve frame to frame, so it is the most expensive op here.",
|
|
1101
|
+
params: {
|
|
1102
|
+
strength: { default: 26, unit: "px", summary: "how far the fluid carries the content" },
|
|
1103
|
+
force: { default: 1.0, unit: "ratio", summary: "how hard the pointer pushes the fluid" },
|
|
1104
|
+
radius: { default: 0.1, unit: "ratio", summary: "splat size, as a fraction of the sim grid" },
|
|
1105
|
+
curl: { default: 2.2, unit: "ratio", summary: "vorticity — how much the fluid curls into eddies" },
|
|
1106
|
+
decay: { default: 0.985, unit: "ratio", summary: "per-second velocity persistence" },
|
|
1107
|
+
pressure: { default: 0.8, unit: "ratio", summary: "pressure retained between frames" },
|
|
1108
|
+
pressureIterations: { default: 6, unit: "count", summary: "solver iterations; higher is stiffer and slower" },
|
|
1109
|
+
intensity: { default: 1.6, unit: "ratio", summary: "brightness of the dye layer" },
|
|
1110
|
+
sheen: { default: 0.15, unit: "ratio", summary: "specular highlight on the surface" },
|
|
1111
|
+
tint: { default: "#7FD4FF", unit: "color", summary: "dye colour, unless `rainbow`" },
|
|
1112
|
+
rainbow: { default: false, unit: "bool", summary: "cycle dye hue instead of using `tint`" },
|
|
1113
|
+
dye: { default: 0.85, unit: "ratio", summary: "how much dye colour reaches the final image" },
|
|
1114
|
+
dyeAmount: { default: 0.6, unit: "ratio", summary: "how much dye each splat injects" },
|
|
1115
|
+
dyeFade: { default: 0.97, unit: "ratio", summary: "per-second dye persistence" },
|
|
1116
|
+
resolution: { default: 128, unit: "count", summary: "velocity grid size, clamped 32..512" },
|
|
1117
|
+
dyeResolution: { default: 512, unit: "count", summary: "dye grid size, clamped 64..1024" },
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
750
1120
|
stage: ["warp", "color"],
|
|
751
1121
|
decl: (p) => `
|
|
752
1122
|
uniform sampler2D ${p}vel;
|
|
@@ -1079,6 +1449,32 @@ void main() {
|
|
|
1079
1449
|
// gradient. init()/tick() run the particle sim on the CPU and feed
|
|
1080
1450
|
// the positions in as a per-frame uniform array.
|
|
1081
1451
|
blobs: {
|
|
1452
|
+
doc: {
|
|
1453
|
+
summary: "Liquid-glass blobs that follow the pointer: a chain of "
|
|
1454
|
+
+ "metaballs refracting the content behind them, stretching apart on a "
|
|
1455
|
+
+ "fast flick and relaxing back to a single circle at rest.",
|
|
1456
|
+
params: {
|
|
1457
|
+
count: {
|
|
1458
|
+
default: 1, unit: "count", structural: true,
|
|
1459
|
+
summary: "independent drifting blobs, on top of the trail chain",
|
|
1460
|
+
},
|
|
1461
|
+
trail: {
|
|
1462
|
+
default: 7, unit: "count", structural: true,
|
|
1463
|
+
summary: "blobs in the follow-the-leader chain. Unrolled at compile "
|
|
1464
|
+
+ "time, so it rebuilds.",
|
|
1465
|
+
},
|
|
1466
|
+
radius: { default: 46, unit: "px", summary: "blob radius at rest" },
|
|
1467
|
+
color: { default: "#EAF4EE", unit: "color", summary: "glass tint" },
|
|
1468
|
+
refract: { default: 70, unit: "px", summary: "how far the glass bends what is behind it" },
|
|
1469
|
+
rim: { default: 0.6, unit: "ratio", summary: "brightness of the rim light" },
|
|
1470
|
+
alpha: { default: 1.0, unit: "ratio", summary: "overall opacity" },
|
|
1471
|
+
iridescence: { default: 0.8, unit: "ratio", summary: "colour shift across the rim" },
|
|
1472
|
+
dispersion: { default: 1.0, unit: "ratio", summary: "per-channel refraction spread" },
|
|
1473
|
+
frost: { default: 0.25, unit: "ratio", summary: "blur of what shows through" },
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
// Read by init(), which allocates the particle chain.
|
|
1477
|
+
structural: ["count", "trail"],
|
|
1082
1478
|
stage: "color",
|
|
1083
1479
|
overlay: true,
|
|
1084
1480
|
mips: true,
|
|
@@ -1266,15 +1662,18 @@ void main() {
|
|
|
1266
1662
|
chain[0].y = state.head.y;
|
|
1267
1663
|
const follow = 1 - Math.exp(-dt * 16);
|
|
1268
1664
|
for (let i = 1; i < chain.length; i++) {
|
|
1269
|
-
|
|
1270
|
-
|
|
1665
|
+
// `b` is a blob in the chain, NOT the raster node — it was
|
|
1666
|
+
// called `node` and shadowed the op's own parameter, which
|
|
1667
|
+
// reads as this op having x/y params it does not have.
|
|
1668
|
+
const lead = chain[i - 1], b = chain[i];
|
|
1669
|
+
let dx = lead.x - b.x, dy = lead.y - b.y;
|
|
1271
1670
|
const d = Math.hypot(dx, dy) || 1e-4;
|
|
1272
1671
|
// Cap the lag so a fast flick can't tear the tube apart,
|
|
1273
1672
|
// then ease the remainder — gives an elastic pull.
|
|
1274
1673
|
const maxLag = state.seg;
|
|
1275
|
-
if (d > maxLag) {
|
|
1276
|
-
|
|
1277
|
-
|
|
1674
|
+
if (d > maxLag) { b.x += dx * (1 - maxLag / d); b.y += dy * (1 - maxLag / d); }
|
|
1675
|
+
b.x += (lead.x - b.x) * follow;
|
|
1676
|
+
b.y += (lead.y - b.y) * follow;
|
|
1278
1677
|
}
|
|
1279
1678
|
|
|
1280
1679
|
// Stretch = how far the tail trails the head, normalised by the
|
|
@@ -1320,6 +1719,12 @@ void main() {
|
|
|
1320
1719
|
},
|
|
1321
1720
|
};
|
|
1322
1721
|
|
|
1722
|
+
// GLSL smoothstep, for the CPU coordinate twins (phase I2).
|
|
1723
|
+
function smoothstep(e0, e1, x) {
|
|
1724
|
+
const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0 || 1e-6)));
|
|
1725
|
+
return t * t * (3 - 2 * t);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1323
1728
|
function hexToRgb(c) {
|
|
1324
1729
|
return [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255);
|
|
1325
1730
|
}
|
|
@@ -1333,11 +1738,258 @@ function hexToRgb(c) {
|
|
|
1333
1738
|
// node carries `target:`; the sub-chains inherit whatever it matched.
|
|
1334
1739
|
const RASTER_OP_NAMES = Object.keys(REGISTRY).concat("switch");
|
|
1335
1740
|
|
|
1741
|
+
/**
|
|
1742
|
+
* How an op behaves under hit-testing (phase I4).
|
|
1743
|
+
*
|
|
1744
|
+
* DERIVED from what the op already declares rather than hand-listed, so
|
|
1745
|
+
* the taxonomy cannot drift from the contract the way a maintained table
|
|
1746
|
+
* would. The classes, and what each means for `pipeline.sourceAt`:
|
|
1747
|
+
*
|
|
1748
|
+
* "neutral" does not move the sampling coordinate, so the browser's
|
|
1749
|
+
* own hit-testing was already right (halftone, duotone,
|
|
1750
|
+
* and aberration, which spreads only the per-channel taps)
|
|
1751
|
+
* "analytic" declares a CPU twin that always answers, so a hit-test
|
|
1752
|
+
* costs no GPU sync (offset, and the pixelate example)
|
|
1753
|
+
* "conditional" declares a twin that answers only for some parameters —
|
|
1754
|
+
* hexalize is identity without `lift` and declines with it
|
|
1755
|
+
* "readback" moves coordinates with no twin, so every query is an
|
|
1756
|
+
* exact GPU round trip (flow, stir)
|
|
1757
|
+
* "many-to-one" one screen point maps to several sources; sourceAt
|
|
1758
|
+
* reports the primary sample (copy)
|
|
1759
|
+
*
|
|
1760
|
+
* @param {string} op
|
|
1761
|
+
* @returns {"neutral"|"analytic"|"conditional"|"readback"|"many-to-one"|"unknown"}
|
|
1762
|
+
*/
|
|
1763
|
+
function interactionClass(op) {
|
|
1764
|
+
const def = REGISTRY[op];
|
|
1765
|
+
if (!def) return "unknown";
|
|
1766
|
+
if (def.multiSample) return "many-to-one";
|
|
1767
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
1768
|
+
const movesStage = stages.some((s) => s === "warp" || s === "cell" || s === "displace");
|
|
1769
|
+
if (!movesStage || def.movesCoords === false) return "neutral";
|
|
1770
|
+
if (typeof def.map !== "function") return "readback";
|
|
1771
|
+
// A twin that can decline for some parameter values is not the same
|
|
1772
|
+
// promise as one that always answers, and the difference is exactly
|
|
1773
|
+
// what a caller budgeting for GPU syncs needs to know.
|
|
1774
|
+
return /return\s+null|\?\s*null|null\s*:/.test(String(def.map)) ? "conditional" : "analytic";
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// ── Phase T1: keyframed params ───────────────────────────────────────
|
|
1778
|
+
//
|
|
1779
|
+
// A param may be given as an array of numbers, sampled over transition
|
|
1780
|
+
// progress `t`:
|
|
1781
|
+
//
|
|
1782
|
+
// { op: "flow", strength: [0, 40, 0] } // in, peak, out
|
|
1783
|
+
//
|
|
1784
|
+
// The problem this has to solve: plenty of params are LEGITIMATELY
|
|
1785
|
+
// arrays. `mask.at` is [x, y], `remap` is [lo, hi], `duotone.colors` is
|
|
1786
|
+
// a pair, `copy.points` is a list, `merge.a` is a sub-chain. Guessing
|
|
1787
|
+
// from the value alone would break all of them.
|
|
1788
|
+
//
|
|
1789
|
+
// H4's `doc.params` already carries the answer: a param whose declared
|
|
1790
|
+
// unit is a SCALAR quantity, given as two or more numbers, is keyframes.
|
|
1791
|
+
// Anything else is data. So this is another property bought by the
|
|
1792
|
+
// declaration an op already makes, and an op with no doc never
|
|
1793
|
+
// keyframes — which is the safe direction to be wrong in.
|
|
1794
|
+
const KEYFRAMABLE_UNITS = ["px", "ratio", "deg", "count", "seconds"];
|
|
1795
|
+
|
|
1796
|
+
function isKeyframed(op, key, value) {
|
|
1797
|
+
if (!Array.isArray(value) || value.length < 2) return false;
|
|
1798
|
+
for (const v of value) if (typeof v !== "number" || !Number.isFinite(v)) return false;
|
|
1799
|
+
const def = REGISTRY[op];
|
|
1800
|
+
const meta = def && def.doc && def.doc.params && def.doc.params[key];
|
|
1801
|
+
return !!meta && KEYFRAMABLE_UNITS.includes(meta.unit);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/** Piecewise-linear sample of a keyframe array at t in [0, 1]. */
|
|
1805
|
+
function sampleKeyframes(kf, t) {
|
|
1806
|
+
const n = kf.length - 1;
|
|
1807
|
+
const x = Math.min(1, Math.max(0, t)) * n;
|
|
1808
|
+
const i = Math.min(n - 1, Math.floor(x));
|
|
1809
|
+
return kf[i] + (kf[i + 1] - kf[i]) * (x - i);
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// ── Phase T3: choreography ───────────────────────────────────────────
|
|
1813
|
+
//
|
|
1814
|
+
// Two node-level controls, both acting on the LOCAL progress an op's
|
|
1815
|
+
// keyframes are sampled against. Neither is a new authoring language —
|
|
1816
|
+
// they reshape t, and everything downstream is unchanged.
|
|
1817
|
+
//
|
|
1818
|
+
// window: [0.2, 0.8] this op's keyframes span that slice of t, so
|
|
1819
|
+
// several ops in one chain can stagger
|
|
1820
|
+
// ease: "in-out" easing applied to the local progress
|
|
1821
|
+
//
|
|
1822
|
+
// Easing lives here rather than in the timeline because a chain wants
|
|
1823
|
+
// ops on different curves; the timeline that drives `t` stays linear and
|
|
1824
|
+
// therefore still scrubbable and reversible (P-1).
|
|
1825
|
+
const EASINGS = {
|
|
1826
|
+
linear: (x) => x,
|
|
1827
|
+
in: (x) => x * x * x,
|
|
1828
|
+
out: (x) => 1 - Math.pow(1 - x, 3),
|
|
1829
|
+
"in-out": (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2),
|
|
1830
|
+
// Overshoots past 1 and settles — the classic UI "pop". Keyframes are
|
|
1831
|
+
// sampled with clamping, so an overshoot reads as a hold at the end
|
|
1832
|
+
// unless the author gives it somewhere to go.
|
|
1833
|
+
back: (x) => 1 + 2.70158 * Math.pow(x - 1, 3) + 1.70158 * Math.pow(x - 1, 2),
|
|
1834
|
+
};
|
|
1835
|
+
const EASING_NAMES = Object.keys(EASINGS);
|
|
1836
|
+
|
|
1837
|
+
/** An op's own progress: its window, then its easing. */
|
|
1838
|
+
function localProgress(node, t) {
|
|
1839
|
+
let x = Math.min(1, Math.max(0, t));
|
|
1840
|
+
const w = node.window;
|
|
1841
|
+
if (Array.isArray(w) && w.length === 2 && w[1] !== w[0]) {
|
|
1842
|
+
x = Math.min(1, Math.max(0, (x - w[0]) / (w[1] - w[0])));
|
|
1843
|
+
}
|
|
1844
|
+
const ease = node.ease && EASINGS[node.ease];
|
|
1845
|
+
return ease ? ease(x) : x;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* The node as the op should see it at progress `t` — keyframe arrays
|
|
1850
|
+
* collapsed to scalars, everything else untouched.
|
|
1851
|
+
*
|
|
1852
|
+
* Returns the ORIGINAL object when nothing is keyframed, so the common
|
|
1853
|
+
* case allocates nothing per frame.
|
|
1854
|
+
*/
|
|
1855
|
+
function resolveNode(node, t) {
|
|
1856
|
+
let out = null;
|
|
1857
|
+
const local = (node.window || node.ease) ? localProgress(node, t) : t;
|
|
1858
|
+
for (const key in node) {
|
|
1859
|
+
if (!isKeyframed(node.op, key, node[key])) continue;
|
|
1860
|
+
out = out || Object.assign({}, node);
|
|
1861
|
+
out[key] = sampleKeyframes(node[key], local);
|
|
1862
|
+
}
|
|
1863
|
+
return out || node;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/** Does this node carry any keyframed param? */
|
|
1867
|
+
function hasKeyframes(node) {
|
|
1868
|
+
for (const key in node) if (isKeyframed(node.op, key, node[key])) return true;
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1336
1872
|
// Extension surface — mirrors the CSS-level operation registry:
|
|
1337
1873
|
// registerRasterOp("pixelate", { stage, decl, code, uniforms }).
|
|
1874
|
+
//
|
|
1875
|
+
// This used to be two lines that assigned and returned. Every way of
|
|
1876
|
+
// getting it wrong therefore succeeded, and surfaced later as a shader
|
|
1877
|
+
// that failed to compile with no indication of which op wrote the bad
|
|
1878
|
+
// line — or, worse, as an op that compiled and did nothing, because a
|
|
1879
|
+
// misspelt `stage` matched no stage and its code was never emitted.
|
|
1880
|
+
// That is the silently-ignored-key class again, one level up: not a bad
|
|
1881
|
+
// option on a node, but a bad op in the registry.
|
|
1882
|
+
//
|
|
1883
|
+
// Registration is a one-time cost paid at startup, so it validates
|
|
1884
|
+
// eagerly and throws with the vocabulary listed. Everything here is
|
|
1885
|
+
// checkable without a GL context, which is why it runs at registration
|
|
1886
|
+
// rather than at first compile.
|
|
1887
|
+
function validateRasterOp(name, def) {
|
|
1888
|
+
const where = `registerRasterOp("${name}")`;
|
|
1889
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
1890
|
+
throw new TypeError("[nodality] registerRasterOp needs a non-empty name");
|
|
1891
|
+
}
|
|
1892
|
+
if (!def || typeof def !== "object") {
|
|
1893
|
+
throw new TypeError(`[nodality] ${where}: definition must be an object`);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// stage — the one field whose typo is invisible at runtime.
|
|
1897
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
1898
|
+
if (def.stage == null || !stages.length) {
|
|
1899
|
+
throw new TypeError(`[nodality] ${where}: missing "stage". ` +
|
|
1900
|
+
`Valid stages: ${RASTER_STAGES.join(", ")}.`);
|
|
1901
|
+
}
|
|
1902
|
+
for (const s of stages) {
|
|
1903
|
+
if (typeof s !== "string" || !RASTER_STAGES.includes(s)) {
|
|
1904
|
+
throw new TypeError(`[nodality] ${where}: ` +
|
|
1905
|
+
didYouMean(s, RASTER_STAGES, "stage"));
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// The two functions that produce GLSL, and the optional one that
|
|
1910
|
+
// feeds it. `uniforms` is optional because an op may be entirely
|
|
1911
|
+
// compile-time (see `copy`, which unrolls its loop in code()).
|
|
1912
|
+
for (const k of ["decl", "code"]) {
|
|
1913
|
+
if (typeof def[k] !== "function") {
|
|
1914
|
+
throw new TypeError(`[nodality] ${where}: "${k}" must be a function ` +
|
|
1915
|
+
`(got ${def[k] === undefined ? "nothing" : typeof def[k]}).`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
if (def.uniforms != null && typeof def.uniforms !== "function") {
|
|
1919
|
+
throw new TypeError(`[nodality] ${where}: "uniforms" must be a function if present.`);
|
|
1920
|
+
}
|
|
1921
|
+
// Phase I2. `map` is the CPU twin of the op's coordinate arithmetic;
|
|
1922
|
+
// `movesCoords: false` says a coordinate-stage op leaves the sampling
|
|
1923
|
+
// position alone (aberration only spreads the per-channel taps). Both
|
|
1924
|
+
// let a hit-test skip the GPU readback, so a wrong shape here would
|
|
1925
|
+
// silently route every query the slow way.
|
|
1926
|
+
if (def.map != null && typeof def.map !== "function") {
|
|
1927
|
+
throw new TypeError(`[nodality] ${where}: "map" must be a function if present.`);
|
|
1928
|
+
}
|
|
1929
|
+
if (def.movesCoords != null && typeof def.movesCoords !== "boolean") {
|
|
1930
|
+
throw new TypeError(`[nodality] ${where}: "movesCoords" must be a boolean if present.`);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
// Rebuild hints. A name here that is not a real param is not fatal,
|
|
1934
|
+
// but the shape being wrong means isStructuralChange() would throw
|
|
1935
|
+
// mid-drag, so the array-of-strings part is enforced.
|
|
1936
|
+
for (const k of ["structural", "structuralOnToggle"]) {
|
|
1937
|
+
if (def[k] == null) continue;
|
|
1938
|
+
if (!Array.isArray(def[k]) || def[k].some((s) => typeof s !== "string")) {
|
|
1939
|
+
throw new TypeError(`[nodality] ${where}: "${k}" must be an array of param names.`);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
if (def.defaultDriver != null && !DRIVER_NAMES.includes(def.defaultDriver)) {
|
|
1944
|
+
throw new TypeError(`[nodality] ${where}: ` +
|
|
1945
|
+
didYouMean(def.defaultDriver, DRIVER_NAMES, "driver"));
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// `doc` is optional — a third-party op stays registerable without it,
|
|
1949
|
+
// and the inspector falls back to introspection. But a doc that IS
|
|
1950
|
+
// supplied has to be the shape every reader assumes, or the panel
|
|
1951
|
+
// throws while rendering someone else's op.
|
|
1952
|
+
if (def.doc != null) {
|
|
1953
|
+
if (typeof def.doc !== "object") {
|
|
1954
|
+
throw new TypeError(`[nodality] ${where}: "doc" must be an object.`);
|
|
1955
|
+
}
|
|
1956
|
+
if (typeof def.doc.summary !== "string" || !def.doc.summary.trim()) {
|
|
1957
|
+
throw new TypeError(`[nodality] ${where}: "doc.summary" must be a non-empty string.`);
|
|
1958
|
+
}
|
|
1959
|
+
const params = def.doc.params;
|
|
1960
|
+
if (params != null) {
|
|
1961
|
+
if (typeof params !== "object" || Array.isArray(params)) {
|
|
1962
|
+
throw new TypeError(`[nodality] ${where}: "doc.params" must be an object ` +
|
|
1963
|
+
`keyed by param name.`);
|
|
1964
|
+
}
|
|
1965
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1966
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
1967
|
+
throw new TypeError(`[nodality] ${where}: doc.params.${k} must be an ` +
|
|
1968
|
+
`object like { default, unit }.`);
|
|
1969
|
+
}
|
|
1970
|
+
if (v.unit != null && !RASTER_UNITS.includes(v.unit)) {
|
|
1971
|
+
throw new TypeError(`[nodality] ${where}: doc.params.${k}: ` +
|
|
1972
|
+
didYouMean(v.unit, RASTER_UNITS, "unit"));
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return def;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1338
1980
|
function registerRasterOp(name, def) {
|
|
1981
|
+
validateRasterOp(name, def);
|
|
1982
|
+
// Replacing a built-in is legitimate — overriding `halftone` with your
|
|
1983
|
+
// own is a reason this surface exists — but doing it by accident,
|
|
1984
|
+
// because two libraries picked the same word, is not. Warn rather than
|
|
1985
|
+
// throw: the caller may well mean it, and there is no way to tell.
|
|
1986
|
+
if (Object.prototype.hasOwnProperty.call(REGISTRY, name) &&
|
|
1987
|
+
typeof console !== "undefined" && console.warn) {
|
|
1988
|
+
console.warn(`[nodality] registerRasterOp("${name}") replaces an existing op.`);
|
|
1989
|
+
}
|
|
1339
1990
|
REGISTRY[name] = def;
|
|
1340
1991
|
if (!RASTER_OP_NAMES.includes(name)) RASTER_OP_NAMES.push(name);
|
|
1992
|
+
return def;
|
|
1341
1993
|
}
|
|
1342
1994
|
|
|
1343
1995
|
function isHTMLInCanvasAvailable() {
|
|
@@ -1359,6 +2011,63 @@ const STAGE_VARS = {
|
|
|
1359
2011
|
color: ["col", "edgeCol", "edgeCov", "ovCol", "ovA"],
|
|
1360
2012
|
};
|
|
1361
2013
|
|
|
2014
|
+
// What `stage:` may say. Derived from STAGE_VARS rather than written out
|
|
2015
|
+
// again, so adding a stage cannot leave the validator rejecting it.
|
|
2016
|
+
// `field` is the exception that has no stage vars of its own: a field op
|
|
2017
|
+
// writes a scalar for later ops to read, it does not modify the frame.
|
|
2018
|
+
//
|
|
2019
|
+
// Read by validateRasterOp, which is defined above this line but only
|
|
2020
|
+
// ever RUNS from registerRasterOp — i.e. after this module has finished
|
|
2021
|
+
// evaluating. Nothing registers an op during our own module eval.
|
|
2022
|
+
const RASTER_STAGES = ["field", ...Object.keys(STAGE_VARS)];
|
|
2023
|
+
|
|
2024
|
+
// Params the PIPELINE reads off every node, whatever the op. An op does
|
|
2025
|
+
// not declare these and must not document them — they would be fifteen
|
|
2026
|
+
// identical paragraphs that drift apart on the first edit. Defined once
|
|
2027
|
+
// here; the inspector merges them in, and API.md documents them once.
|
|
2028
|
+
const FRAMEWORK_DOC = {
|
|
2029
|
+
op: { default: null, unit: "name", summary: "which op this node is" },
|
|
2030
|
+
target: {
|
|
2031
|
+
default: null, unit: "name",
|
|
2032
|
+
summary: "element ids this node applies to, e.g. [\"#hero\"]",
|
|
2033
|
+
},
|
|
2034
|
+
by: {
|
|
2035
|
+
default: "static", unit: "name", structural: true,
|
|
2036
|
+
summary: `what steers the effect: ${DRIVER_NAMES.join(", ")}. ` +
|
|
2037
|
+
"Compiled in, so changing it rebuilds.",
|
|
2038
|
+
},
|
|
2039
|
+
masked: {
|
|
2040
|
+
default: false, unit: "name",
|
|
2041
|
+
summary: "read a field written upstream — true for the default " +
|
|
2042
|
+
"\"mask\", or a name. Works for ANY op; the op needs no knowledge of it.",
|
|
2043
|
+
},
|
|
2044
|
+
live: {
|
|
2045
|
+
default: true, unit: "bool",
|
|
2046
|
+
summary: "false forces the snapshot backend for this element " +
|
|
2047
|
+
"instead of HTML-in-Canvas capture",
|
|
2048
|
+
},
|
|
2049
|
+
// Phase T3. Both reshape the progress this node's keyframes are
|
|
2050
|
+
// sampled against; neither is read by any op.
|
|
2051
|
+
window: {
|
|
2052
|
+
default: null, unit: "range",
|
|
2053
|
+
summary: "[from, to] slice of transition progress this node's " +
|
|
2054
|
+
"keyframes span — how ops in one chain stagger",
|
|
2055
|
+
},
|
|
2056
|
+
ease: {
|
|
2057
|
+
default: "linear", unit: "name",
|
|
2058
|
+
summary: `easing for this node's local progress: ${EASING_NAMES.join(", ")}`,
|
|
2059
|
+
},
|
|
2060
|
+
interactive: {
|
|
2061
|
+
default: true, unit: "bool",
|
|
2062
|
+
summary: "false opts this pipeline out of pointer retargeting",
|
|
2063
|
+
},
|
|
2064
|
+
hoverAttr: {
|
|
2065
|
+
default: false, unit: "bool",
|
|
2066
|
+
summary: "mirror hover onto [data-nodality-hover] at the DRAWN " +
|
|
2067
|
+
"position; costs the zero-DOM-mutation property, so opt-in",
|
|
2068
|
+
},
|
|
2069
|
+
};
|
|
2070
|
+
|
|
1362
2071
|
// A node opts into a field with `masked: true` (the default field) or
|
|
1363
2072
|
// `masked: "name"`. Field producers declare `as:` to name what they
|
|
1364
2073
|
// write. This is the whole of the node-to-node data flow: one op writes
|
|
@@ -1484,7 +2193,16 @@ function emitMerge(rec, buckets) {
|
|
|
1484
2193
|
}
|
|
1485
2194
|
}
|
|
1486
2195
|
|
|
1487
|
-
|
|
2196
|
+
/**
|
|
2197
|
+
* @param {boolean} [probe] emit the coordinate-readback variant (phase I1)
|
|
2198
|
+
* instead of the visible one. Identical up to and including the
|
|
2199
|
+
* displace stage; then writes the packed source coordinate rather
|
|
2200
|
+
* than a colour.
|
|
2201
|
+
*/
|
|
2202
|
+
function buildFragmentShader(recs, flat, probe, transition) {
|
|
2203
|
+
// "opaque" (default) keeps the union solid; "dissolve" is a true
|
|
2204
|
+
// cross-dissolve. See nodBlend.
|
|
2205
|
+
const fade = (transition && transition.fade) || "opaque";
|
|
1488
2206
|
let decls = "";
|
|
1489
2207
|
|
|
1490
2208
|
// Every field any node writes or reads, declared up front at 1.0 so
|
|
@@ -1520,19 +2238,155 @@ function buildFragmentShader(recs, flat) {
|
|
|
1520
2238
|
// Per-channel resampling costs two extra fetches, so it is only
|
|
1521
2239
|
// emitted when a displace-stage op actually asks for dispersion.
|
|
1522
2240
|
const hasChroma = nodes.some((n) => (REGISTRY[n.op] || {}).chroma);
|
|
1523
|
-
|
|
2241
|
+
// In transition mode the per-channel taps must see BOTH captures —
|
|
2242
|
+
// sampling u_tex alone would drop the old element out of the red and
|
|
2243
|
+
// blue channels for the whole morph.
|
|
2244
|
+
const chromaFetch = !hasChroma ? "" : (transition ? `
|
|
2245
|
+
vec2 cdev = vec2(chroma.x, chroma.y);
|
|
2246
|
+
col.r = nodSampleAt(sampleP + cdev).r;
|
|
2247
|
+
col.b = nodSampleAt(sampleP - cdev).b;` : `
|
|
1524
2248
|
vec2 cpx = vec2(chroma.x, -chroma.y) / u_res;
|
|
1525
2249
|
col.r = texture2D(u_tex, clamp(uv + cpx, 0.001, 0.999)).r;
|
|
1526
|
-
col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;`
|
|
2250
|
+
col.b = texture2D(u_tex, clamp(uv - cpx, 0.001, 0.999)).b;`);
|
|
2251
|
+
// Phase I1. The PROBE variant is the same shader with a different last
|
|
2252
|
+
// line: instead of compositing a colour it writes the source
|
|
2253
|
+
// coordinate the fragment sampled from. Everything between `main()`
|
|
2254
|
+
// and the tail is byte-identical to the visible pass, which is the
|
|
2255
|
+
// whole point — a hit-test that recomputed the coordinate its own way
|
|
2256
|
+
// could disagree with what is actually drawn, and that disagreement
|
|
2257
|
+
// would be invisible.
|
|
2258
|
+
//
|
|
2259
|
+
// In probe mode the fragment coordinate is SUPPLIED rather than
|
|
2260
|
+
// derived: `u_probe` names the pixel being asked about, so a 1x1
|
|
2261
|
+
// framebuffer with a plain viewport can stand in for any pixel of the
|
|
2262
|
+
// full-size render. No full-resolution attachment is allocated, and
|
|
2263
|
+
// no negative-origin viewport is needed — an earlier attempt offset
|
|
2264
|
+
// the viewport instead and rasterised nothing, which readback
|
|
2265
|
+
// reported as a confident (0, 0).
|
|
2266
|
+
const probeDecl = probe ? "uniform vec2 u_probe;\n" : "";
|
|
2267
|
+
const fragCoord = probe ? "u_probe + vec2(0.5)" : "gl_FragCoord.xy";
|
|
2268
|
+
// Two bytes per axis: hi in one channel, lo in the next, so a 4000px
|
|
2269
|
+
// element resolves to about 0.06px. Decoded in sourceAt().
|
|
2270
|
+
const probeTail = `
|
|
2271
|
+
vec2 q = clamp(sampleP / u_res, 0.0, 1.0) * 255.0;
|
|
2272
|
+
vec2 hi = floor(q);
|
|
2273
|
+
vec2 lo = floor(fract(q) * 255.0);
|
|
2274
|
+
gl_FragColor = vec4(hi.x, lo.x, hi.y, lo.y) / 255.0;
|
|
2275
|
+
}`;
|
|
2276
|
+
|
|
1527
2277
|
return `
|
|
1528
2278
|
precision highp float;
|
|
1529
2279
|
uniform sampler2D u_tex;
|
|
1530
2280
|
uniform vec2 u_res;
|
|
1531
2281
|
uniform vec2 u_mouse;
|
|
1532
2282
|
uniform float u_time;
|
|
1533
|
-
|
|
2283
|
+
uniform float u_t; // phase T1: transition progress, 0..1
|
|
2284
|
+
${transition ? `
|
|
2285
|
+
// ── phase T2: transition mode ──────────────────────────────────────
|
|
2286
|
+
// Two captures instead of one. u_tex is the NEW element, u_old the
|
|
2287
|
+
// frozen OLD one, and u_box is the content box they are both drawn
|
|
2288
|
+
// into — lerped from the old rect to the new rect by u_t. So geometry
|
|
2289
|
+
// (the box) and pixels (the effect chain) stay separate problems, which
|
|
2290
|
+
// is the split View Transitions also makes and the one that keeps this
|
|
2291
|
+
// maintainable.
|
|
2292
|
+
uniform sampler2D u_old;
|
|
2293
|
+
${transition && transition.newImage ? "uniform sampler2D u_newimg;" : ""}
|
|
2294
|
+
// Phase T2, gap 2. TWO boxes, not one. With a single shared box the
|
|
2295
|
+
// only expressible motion is "both sides march together", so the classic
|
|
2296
|
+
// "old slides out while new slides in" was impossible. Each capture now
|
|
2297
|
+
// interpolates in its own rect, which keeps per-side motion in the
|
|
2298
|
+
// GEOMETRY tier (P-3) instead of needing per-side op scoping.
|
|
2299
|
+
uniform vec4 u_boxOld; // x, y, w, h — device px, top-down
|
|
2300
|
+
uniform vec4 u_boxNew;
|
|
2301
|
+
|
|
2302
|
+
// sRGB <-> linear. A naive crossfade in gamma space dips visibly in
|
|
2303
|
+
// brightness at mid-fade on photographic content; this is the classic
|
|
2304
|
+
// artifact and it is why the mix below is not just mix().
|
|
2305
|
+
vec3 nodToLinear(vec3 c) { return pow(c, vec3(2.2)); }
|
|
2306
|
+
vec3 nodToSRGB(vec3 c) { return pow(c, vec3(1.0 / 2.2)); }
|
|
2307
|
+
|
|
2308
|
+
// Premultiplied, so anti-aliased edges over transparency do not pick up
|
|
2309
|
+
// a dark halo as the two sides cross.
|
|
2310
|
+
vec4 nodBlend(vec4 a, vec4 b, float t, float d) {
|
|
2311
|
+
vec3 pa = nodToLinear(a.rgb) * a.a;
|
|
2312
|
+
vec3 pb = nodToLinear(b.rgb) * b.a;
|
|
2313
|
+
${fade === "morph" ? `
|
|
2314
|
+
// MORPH — a per-pixel CHOICE, not a mix. d is a stable hash of the
|
|
2315
|
+
// output pixel, so each pixel flips from old to new at its own point
|
|
2316
|
+
// in t. At any instant almost every pixel is showing exactly ONE
|
|
2317
|
+
// side, and the two contents are never both legible at once.
|
|
2318
|
+
//
|
|
2319
|
+
// A uniform crossfade cannot do this: at t=0.5 it is by definition
|
|
2320
|
+
// 50% of each, which reads as a double exposure the moment the two
|
|
2321
|
+
// states differ much — a wide nav bar and a small card, say.
|
|
2322
|
+
float k = smoothstep(d - 0.12, d + 0.12, t);
|
|
2323
|
+
vec3 pm = pa * (1.0 - k) + pb * k;
|
|
2324
|
+
float al = a.a * (1.0 - k) + b.a * k;` : fade === "dissolve" ? `
|
|
2325
|
+
// DISSOLVE — a true cross-dissolve: alpha lerps between the two.
|
|
2326
|
+
// Correct, and the right choice when one side really should fade to
|
|
2327
|
+
// nothing. Wrong for a shape morph, because any region covered by
|
|
2328
|
+
// only ONE side is translucent for the whole transition and the
|
|
2329
|
+
// morph reads as "everything is disappearing".
|
|
2330
|
+
vec3 pm = mix(pa, pb, t);
|
|
2331
|
+
float al = mix(a.a, b.a, t);` : `
|
|
2332
|
+
// OPAQUE (default) — the colour crosses over without the shape
|
|
2333
|
+
// thinning out. The coverage-weighted lerp already gives that for
|
|
2334
|
+
// free, and it is worth seeing why:
|
|
2335
|
+
//
|
|
2336
|
+
// both sides cover a.a = b.a = 1 -> w = 1 at every t. Solid
|
|
2337
|
+
// throughout. No mid-fade.
|
|
2338
|
+
// only the old b.a = 0 -> w = 1-t. Releases.
|
|
2339
|
+
// only the new a.a = 0 -> w = t. Arrives.
|
|
2340
|
+
//
|
|
2341
|
+
// So one expression covers the union case AND each side's release,
|
|
2342
|
+
// linearly, with no curve to pop against.
|
|
2343
|
+
vec3 pm = pa * (1.0 - t) + pb * t;
|
|
2344
|
+
float al = a.a * (1.0 - t) + b.a * t;` }
|
|
2345
|
+
return vec4(nodToSRGB(pm / max(al, 1e-4)), al);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// EVERY fetch in transition mode goes through here. A displace-stage op
|
|
2349
|
+
// that sampled u_tex directly — aberration does exactly that for its
|
|
2350
|
+
// per-channel taps — would show fringes from the new capture only, with
|
|
2351
|
+
// the old one silently absent from those channels.
|
|
2352
|
+
// Takes a position in DEVICE PX (sampleP space), not uv, because the two
|
|
2353
|
+
// captures no longer share a uv. Each is mapped through its own box and
|
|
2354
|
+
// masked to it, then blended.
|
|
2355
|
+
vec2 nodBoxUV(vec4 box, vec2 p) {
|
|
2356
|
+
return vec2((p.x - box.x) / max(box.z, 1.0),
|
|
2357
|
+
1.0 - (p.y - box.y) / max(box.w, 1.0));
|
|
2358
|
+
}
|
|
2359
|
+
float nodInBox(vec2 uv) {
|
|
2360
|
+
return step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0);
|
|
2361
|
+
}
|
|
2362
|
+
vec4 nodSampleAt(vec2 p) {
|
|
2363
|
+
vec2 uo = nodBoxUV(u_boxOld, p);
|
|
2364
|
+
vec2 un = nodBoxUV(u_boxNew, p);
|
|
2365
|
+
vec4 o = texture2D(u_old, clamp(uo, 0.001, 0.999));
|
|
2366
|
+
// With newImage the new side is a capture of the ELEMENT, so both
|
|
2367
|
+
// sides can travel between their own rects and converge into one
|
|
2368
|
+
// shape. Without it the new side is the HOST capture, which can only
|
|
2369
|
+
// be mapped 1:1 — the destination then sits at its final place from
|
|
2370
|
+
// t=0 while the old shrinks toward it, and the result reads as two
|
|
2371
|
+
// things on screen at once rather than one thing morphing.
|
|
2372
|
+
vec4 n = texture2D(${transition && transition.newImage ? "u_newimg" : "u_tex"},
|
|
2373
|
+
clamp(un, 0.001, 0.999));
|
|
2374
|
+
o.a *= nodInBox(uo);
|
|
2375
|
+
n.a *= nodInBox(un);
|
|
2376
|
+
// The threshold each pixel flips at. Mostly a COHERENT ramp down the
|
|
2377
|
+
// box with a little grain on top — pure white noise chooses correctly
|
|
2378
|
+
// (never two legible images at once) but reads as television static
|
|
2379
|
+
// rather than as one thing becoming another. The ramp makes it a
|
|
2380
|
+
// wipe; the grain keeps the edge from looking like a ruler.
|
|
2381
|
+
float grain = fract(sin(dot(floor(p / 3.0), vec2(12.9898, 78.233))) * 43758.5453);
|
|
2382
|
+
float ramp = clamp(un.y, 0.0, 1.0);
|
|
2383
|
+
float d = ramp * 0.82 + grain * 0.18;
|
|
2384
|
+
return nodBlend(o, n, u_t, d);
|
|
2385
|
+
}` : ""}
|
|
2386
|
+
${probeDecl}${decls}
|
|
1534
2387
|
void main() {
|
|
1535
|
-
vec2
|
|
2388
|
+
vec2 fc = ${fragCoord};
|
|
2389
|
+
vec2 frag = vec2(fc.x, u_res.y - fc.y);
|
|
1536
2390
|
${fieldDecls}
|
|
1537
2391
|
${field}
|
|
1538
2392
|
vec2 warped = frag;
|
|
@@ -1542,9 +2396,13 @@ ${warp}
|
|
|
1542
2396
|
${cell}
|
|
1543
2397
|
vec2 sampleP = warped;
|
|
1544
2398
|
vec2 chroma = vec2(0.0);
|
|
1545
|
-
${displace}
|
|
2399
|
+
${displace}${probe ? probeTail : `
|
|
2400
|
+
${transition ? `
|
|
2401
|
+
// uv stays defined, as the NEW box's, for ops that reference it.
|
|
2402
|
+
vec2 uv = nodBoxUV(u_boxNew, sampleP);
|
|
2403
|
+
vec4 tex = nodSampleAt(sampleP);` : `
|
|
1546
2404
|
vec2 uv = vec2(sampleP.x / u_res.x, 1.0 - sampleP.y / u_res.y);
|
|
1547
|
-
vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999))
|
|
2405
|
+
vec4 tex = texture2D(u_tex, clamp(uv, 0.001, 0.999));`}
|
|
1548
2406
|
vec3 col = tex.rgb;${chromaFetch}
|
|
1549
2407
|
vec3 edgeCol = vec3(1.0);
|
|
1550
2408
|
float edgeCov = 0.0;
|
|
@@ -1559,7 +2417,7 @@ ${defaultSeam}
|
|
|
1559
2417
|
float outA = ovA + baseA * (1.0 - ovA);
|
|
1560
2418
|
vec3 outRGB = (ovCol * ovA + baseRGB * baseA * (1.0 - ovA)) / max(outA, 1e-4);
|
|
1561
2419
|
gl_FragColor = vec4(outRGB, outA);
|
|
1562
|
-
}`;
|
|
2420
|
+
}`}`;
|
|
1563
2421
|
}
|
|
1564
2422
|
|
|
1565
2423
|
// ── Snapshot backend: DOM subtree -> SVG foreignObject -> texture ────
|
|
@@ -1574,19 +2432,97 @@ ${defaultSeam}
|
|
|
1574
2432
|
// computes, on a clone, so the snapshot matches the live layout.
|
|
1575
2433
|
const VIEWPORT_UNIT = /\d(?:vw|vh|vmin|vmax|dvw|dvh|svw|svh|lvw|lvh)\b/i;
|
|
1576
2434
|
|
|
1577
|
-
|
|
2435
|
+
// Inheritable text properties. A foreignObject rendered from a data: URI
|
|
2436
|
+
// carries NO page stylesheet, and a clone carries only inline styles — so
|
|
2437
|
+
// anything a rule or an ancestor supplied is gone. Nodality's own
|
|
2438
|
+
// components style inline and survive; hand-written markup styled by CSS
|
|
2439
|
+
// captures as default black serif on transparent, which is what made the
|
|
2440
|
+
// demo panels render dark text on a dark panel.
|
|
2441
|
+
//
|
|
2442
|
+
// Frozen onto the clone only where an element's computed value DIFFERS
|
|
2443
|
+
// from its parent's, so inheritance still does the work and the
|
|
2444
|
+
// serialized string does not balloon on a large subtree.
|
|
2445
|
+
const INHERITED = [
|
|
2446
|
+
"color", "font-family", "font-size", "font-weight", "font-style",
|
|
2447
|
+
"line-height", "letter-spacing", "text-align", "text-transform",
|
|
2448
|
+
"text-decoration-color", "white-space", "word-break",
|
|
2449
|
+
];
|
|
2450
|
+
|
|
2451
|
+
// Painted, NON-inherited properties — see part 3 of freezeStyles. Layout
|
|
2452
|
+
// properties are deliberately absent: freezing width or padding would
|
|
2453
|
+
// relayout the clone and move the glyphs off the real text.
|
|
2454
|
+
const PAINTED = [
|
|
2455
|
+
"background-color", "background-image", "background-size",
|
|
2456
|
+
"background-position", "background-repeat", "background-clip",
|
|
2457
|
+
"border-radius", "box-shadow", "opacity", "outline-color",
|
|
2458
|
+
];
|
|
2459
|
+
|
|
2460
|
+
// Initial values, skipped so a page of plain elements does not gain ten
|
|
2461
|
+
// no-op declarations per node.
|
|
2462
|
+
const PAINT_INITIAL = new Set([
|
|
2463
|
+
"none", "rgba(0, 0, 0, 0)", "transparent", "0px", "auto", "1",
|
|
2464
|
+
"repeat", "0% 0%", "border-box", "0% 0% / auto repeat scroll padding-box border-box",
|
|
2465
|
+
]);
|
|
2466
|
+
|
|
2467
|
+
function freezeStyles(original, clone) {
|
|
1578
2468
|
const origs = [original, ...original.querySelectorAll("*")];
|
|
1579
2469
|
const clones = [clone, ...clone.querySelectorAll("*")];
|
|
1580
2470
|
for (let i = 0; i < origs.length && i < clones.length; i++) {
|
|
1581
2471
|
const inline = clones[i].style;
|
|
1582
|
-
if (!inline
|
|
1583
|
-
|
|
1584
|
-
//
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2472
|
+
if (!inline) continue;
|
|
2473
|
+
|
|
2474
|
+
// 1. Viewport units resolve against the SVG's own size inside a
|
|
2475
|
+
// foreignObject, not the browser viewport, so `calc(1.6rem +
|
|
2476
|
+
// 5vw)` renders at a different size than it does on the page.
|
|
2477
|
+
// The glyphs then sit somewhere the DOM text does not, which
|
|
2478
|
+
// is what makes a selection highlight look offset.
|
|
2479
|
+
if (inline.length > 0) {
|
|
2480
|
+
let computed = null;
|
|
2481
|
+
// Iterate a snapshot of the names: writing to style mutates the list.
|
|
2482
|
+
for (const prop of Array.from(inline)) {
|
|
2483
|
+
if (!VIEWPORT_UNIT.test(inline.getPropertyValue(prop))) continue;
|
|
2484
|
+
computed = computed || window.getComputedStyle(origs[i]);
|
|
2485
|
+
const px = computed.getPropertyValue(prop);
|
|
2486
|
+
if (px) inline.setProperty(prop, px, inline.getPropertyPriority(prop));
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// 2. Inherited text styling, which the clone would otherwise lose.
|
|
2491
|
+
const cs = window.getComputedStyle(origs[i]);
|
|
2492
|
+
const parent = origs[i].parentElement;
|
|
2493
|
+
const ps = i === 0 || !parent ? null : window.getComputedStyle(parent);
|
|
2494
|
+
for (const prop of INHERITED) {
|
|
2495
|
+
const v = cs.getPropertyValue(prop);
|
|
2496
|
+
if (!v) continue;
|
|
2497
|
+
// The root always states its value — there is no ancestor
|
|
2498
|
+
// inside the foreignObject to inherit from.
|
|
2499
|
+
if (ps && ps.getPropertyValue(prop) === v) continue;
|
|
2500
|
+
if (inline.getPropertyValue(prop)) continue; // caller was explicit
|
|
2501
|
+
inline.setProperty(prop, v);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// 3. Painted, NON-inherited properties. Inheritance cannot rescue
|
|
2505
|
+
// these: `.card { background: #e8eef5 }` is simply absent from
|
|
2506
|
+
// the clone, so the element captures fully transparent.
|
|
2507
|
+
//
|
|
2508
|
+
// The symptom is easy to misread. In a transition the two
|
|
2509
|
+
// captures crossfade against each other, and with backgrounds
|
|
2510
|
+
// missing both sides are transparent everywhere except their
|
|
2511
|
+
// glyphs — so the morph looks like it fades away toward the
|
|
2512
|
+
// middle and "disappears", when every pixel is exactly where
|
|
2513
|
+
// it belongs and merely has nothing opaque behind it.
|
|
2514
|
+
//
|
|
2515
|
+
// Restricted to properties that do not affect LAYOUT. Freezing
|
|
2516
|
+
// width, padding or border-width would relayout the clone and
|
|
2517
|
+
// move the glyphs away from where the real text sits — the
|
|
2518
|
+
// very bug this function was written to prevent.
|
|
2519
|
+
for (const prop of PAINTED) {
|
|
2520
|
+
if (inline.getPropertyValue(prop)) continue; // caller was explicit
|
|
2521
|
+
const v = cs.getPropertyValue(prop);
|
|
2522
|
+
// Skip initial values, or every node gains a handful of
|
|
2523
|
+
// no-op declarations and the serialized string balloons.
|
|
2524
|
+
if (!v || PAINT_INITIAL.has(v)) continue;
|
|
2525
|
+
inline.setProperty(prop, v);
|
|
1590
2526
|
}
|
|
1591
2527
|
}
|
|
1592
2528
|
return clone;
|
|
@@ -1697,7 +2633,7 @@ function snapshotToImage(el, w, h, dpr) {
|
|
|
1697
2633
|
|
|
1698
2634
|
return new Promise((resolve, reject) => {
|
|
1699
2635
|
const serialized = new XMLSerializer()
|
|
1700
|
-
.serializeToString(
|
|
2636
|
+
.serializeToString(freezeStyles(el, el.cloneNode(true)));
|
|
1701
2637
|
const svg =
|
|
1702
2638
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${w * dpr}" height="${h * dpr}" viewBox="0 0 ${w} ${h}">` +
|
|
1703
2639
|
`<foreignObject width="100%" height="100%">` +
|
|
@@ -1826,17 +2762,101 @@ function wrapReplacedHost(el) {
|
|
|
1826
2762
|
|
|
1827
2763
|
// ── Pipeline runner ──────────────────────────────────────────────────
|
|
1828
2764
|
|
|
1829
|
-
|
|
2765
|
+
// Fired on `document` whenever a pipeline param changes through setParam,
|
|
2766
|
+
// so any UI showing that data — the inspector, a code preview, an editor —
|
|
2767
|
+
// stays in step without polling or knowing about the others.
|
|
2768
|
+
// detail: { pipeline, el, index, key, value, prev, how }
|
|
2769
|
+
// `how` is "uniform" or "rebuild", the same value setParam returns.
|
|
2770
|
+
const RASTER_PARAM_EVENT = "nodality:raster-param";
|
|
2771
|
+
|
|
2772
|
+
// ── Live pipelines (phase H3) ────────────────────────────────────────
|
|
2773
|
+
// Every attached pipeline, so a dev tool can find what is running without
|
|
2774
|
+
// the page having to hand it over. A Set, not a WeakSet: enumerating is
|
|
2775
|
+
// the entire point, and destroy() removes deterministically.
|
|
2776
|
+
const ACTIVE = new Set();
|
|
2777
|
+
|
|
2778
|
+
/** Every raster pipeline currently attached, in attach order. */
|
|
2779
|
+
function activeRasterPipelines() {
|
|
2780
|
+
return [...ACTIVE];
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
// Params that are compiled into the shader rather than uploaded as a
|
|
2784
|
+
// uniform, per op. Measured from which of code()/uniforms() reads them —
|
|
2785
|
+
// see the table in HOUDINI-DECISIONS. `masked`, `op` and `live` are
|
|
2786
|
+
// structural for every op: they change the emitted code or the backend.
|
|
2787
|
+
const ALWAYS_STRUCTURAL = ["op", "masked", "live"];
|
|
2788
|
+
|
|
2789
|
+
/**
|
|
2790
|
+
* Does changing `key` on an op of this type require a new shader?
|
|
2791
|
+
*
|
|
2792
|
+
* Unknown keys rebuild. That is deliberately the pessimistic default: a
|
|
2793
|
+
* missed structural param renders the wrong thing with no error, while an
|
|
2794
|
+
* unnecessary rebuild only costs a frame.
|
|
2795
|
+
*/
|
|
2796
|
+
function isStructuralChange(op, key, next, prev) {
|
|
2797
|
+
if (ALWAYS_STRUCTURAL.includes(key)) return true;
|
|
2798
|
+
const def = REGISTRY[op];
|
|
2799
|
+
if (!def) return true;
|
|
2800
|
+
if ((def.structural || []).includes(key)) return true;
|
|
2801
|
+
// A param that only branches the shader when it crosses zero —
|
|
2802
|
+
// hexalize's `lift` picks the cheap path at 0 and the seven-neighbour
|
|
2803
|
+
// probe otherwise, so 0 -> 0.3 needs a rebuild but 0.3 -> 0.5 does not.
|
|
2804
|
+
if ((def.structuralOnToggle || []).includes(key)) return !next !== !prev;
|
|
2805
|
+
// Known uniform? Then it is live.
|
|
2806
|
+
if (def.uniforms) {
|
|
2807
|
+
try { if (key in def.uniforms(Object.assign({ op }, { [key]: next }), 1)) return false; }
|
|
2808
|
+
catch (e) { /* fall through to the pessimistic default */ }
|
|
2809
|
+
}
|
|
2810
|
+
return true;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/**
|
|
2814
|
+
* @param {HTMLElement} el
|
|
2815
|
+
* @param {Array} rasterNodes
|
|
2816
|
+
* @param {object} [opts]
|
|
2817
|
+
* @param {object} [opts.transition] phase T2 — run in transition mode.
|
|
2818
|
+
* `{ oldImage, oldRect, newRect }` where oldImage is an already
|
|
2819
|
+
* captured Image/canvas of the OLD element (it has to be captured
|
|
2820
|
+
* before the DOM swap, because by the time the morph runs that subtree
|
|
2821
|
+
* is gone), and the rects are CSS px relative to `el`.
|
|
2822
|
+
*/
|
|
2823
|
+
// One stylesheet for the whole library: while a host's ink is hidden, a
|
|
2824
|
+
// selection inside it paints its glyphs in the colour they had before the
|
|
2825
|
+
// canvas took over. Unselected text stays transparent (the canvas is
|
|
2826
|
+
// drawing it), so what the user sees is exactly the selected run appearing
|
|
2827
|
+
// in place — rather than a blank rectangle over an invisible one.
|
|
2828
|
+
let selectionStyleAdded = false;
|
|
2829
|
+
function ensureSelectionStyle() {
|
|
2830
|
+
if (selectionStyleAdded || typeof document === "undefined") return;
|
|
2831
|
+
selectionStyleAdded = true;
|
|
2832
|
+
const s = document.createElement("style");
|
|
2833
|
+
s.setAttribute("data-nodality", "selection");
|
|
2834
|
+
s.textContent =
|
|
2835
|
+
"[data-nodality-ink]::selection,[data-nodality-ink] *::selection{" +
|
|
2836
|
+
"color:var(--nod-ink);-webkit-text-fill-color:var(--nod-ink)}" +
|
|
2837
|
+
"[data-nodality-ink]::-moz-selection,[data-nodality-ink] *::-moz-selection{" +
|
|
2838
|
+
"color:var(--nod-ink);-webkit-text-fill-color:var(--nod-ink)}";
|
|
2839
|
+
(document.head || document.documentElement).appendChild(s);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
function applyRasterPipeline(el, rasterNodes, opts) {
|
|
2843
|
+
// Kept before flattening: rebuild() needs the tree, not the flat list.
|
|
2844
|
+
const sourceNodes = rasterNodes;
|
|
2845
|
+
const transition = (opts && opts.transition) || null;
|
|
1830
2846
|
// Hard guards — every early-out is silent by design so that jsdom
|
|
1831
2847
|
// prerender, old browsers and reduced-motion users get the plain
|
|
1832
2848
|
// page untouched.
|
|
1833
2849
|
if (!el || typeof document === "undefined" || typeof window === "undefined") return null;
|
|
1834
|
-
|
|
2850
|
+
// An empty chain is nothing to do — EXCEPT in transition mode, where
|
|
2851
|
+
// the crossfade between the two captures IS the effect and ops are
|
|
2852
|
+
// only decoration on top of it.
|
|
2853
|
+
if ((!rasterNodes || rasterNodes.length === 0) && !transition) return null;
|
|
2854
|
+
if (!rasterNodes) rasterNodes = [];
|
|
1835
2855
|
// Flatten any switch nodes down to the chain this device actually
|
|
1836
2856
|
// gets, before anything reads the list.
|
|
1837
2857
|
if (rasterNodes.some((n) => n && n.op === "switch")) {
|
|
1838
2858
|
rasterNodes = resolveSwitches(rasterNodes, 0);
|
|
1839
|
-
if (rasterNodes.length === 0) return null;
|
|
2859
|
+
if (rasterNodes.length === 0 && !transition) return null;
|
|
1840
2860
|
}
|
|
1841
2861
|
// Flatten merge sub-chains into the linear list that owns the
|
|
1842
2862
|
// uniform slots. `tree` keeps the branch structure for the shader
|
|
@@ -1888,7 +2908,14 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1888
2908
|
const rect = measure();
|
|
1889
2909
|
if (rect.width < 2 || rect.height < 2) return null;
|
|
1890
2910
|
|
|
1891
|
-
|
|
2911
|
+
// Render resolution. The cap exists because every op runs per output
|
|
2912
|
+
// pixel, so cost is quadratic in it — but capping at 2 also means a 3x
|
|
2913
|
+
// phone or a scaled 4K desktop renders the effect at LOWER density than
|
|
2914
|
+
// the text beside it, and the canvas reads as soft next to real DOM.
|
|
2915
|
+
// Default to the display's own density up to 3, and let a caller that
|
|
2916
|
+
// knows its budget say otherwise.
|
|
2917
|
+
const dpr = Math.max(1, Math.min(
|
|
2918
|
+
opts && opts.resolution ? opts.resolution : (window.devicePixelRatio || 1), 3));
|
|
1892
2919
|
const isOverlay = rasterNodes.some((n) => (REGISTRY[n.op] || {}).overlay);
|
|
1893
2920
|
// Stacking: a pipeline may mix in-place ops (hexalize/offset/duotone/
|
|
1894
2921
|
// edges) with an overlay op (blobs). The single shader composites them
|
|
@@ -1896,7 +2923,14 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1896
2923
|
// chain leaves the host visible (the lens refracts untouched content).
|
|
1897
2924
|
// The moment an in-place op joins, the canvas fully carries the look,
|
|
1898
2925
|
// so the host must be hidden or the original content ghosts through.
|
|
1899
|
-
|
|
2926
|
+
// `.every()` on an EMPTY array is vacuously true, which used to be
|
|
2927
|
+
// unreachable — a chain with no ops returned null long before here.
|
|
2928
|
+
// Transition mode made it reachable, and an empty transition chain
|
|
2929
|
+
// was classified a pure overlay: the canvas composited over untouched
|
|
2930
|
+
// DOM instead of replacing it, so the same chain rendered differently
|
|
2931
|
+
// with and without a zero-strength op in it.
|
|
2932
|
+
const pureOverlay = rasterNodes.length > 0 && !transition &&
|
|
2933
|
+
rasterNodes.every((n) => (REGISTRY[n.op] || {}).overlay);
|
|
1900
2934
|
// Live-first (HTML-in-Canvas). The live backend captures content that
|
|
1901
2935
|
// lives INSIDE the canvas (texElementImage2D over the restructured
|
|
1902
2936
|
// subtree) — the same model canvasUI's bubble uses: content in the
|
|
@@ -1907,7 +2941,29 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1907
2941
|
// subtree to sample; that case stays snapshot). A combined chain
|
|
1908
2942
|
// (blobs + hexalize/offset/…) hides the host and therefore runs live,
|
|
1909
2943
|
// lens included. Opt out per pipeline with `live: false` on any node.
|
|
1910
|
-
|
|
2944
|
+
// A TRANSITION never wants the live backend, and the reason is not a
|
|
2945
|
+
// preference — it is that live costs interactivity and buys nothing
|
|
2946
|
+
// here.
|
|
2947
|
+
//
|
|
2948
|
+
// Buys nothing: a morph samples u_old and u_newimg, two frozen
|
|
2949
|
+
// captures. The live upload of the host is never read (see
|
|
2950
|
+
// nodSampleAt), so restructuring the DOM into the canvas produces a
|
|
2951
|
+
// texture nobody looks at.
|
|
2952
|
+
//
|
|
2953
|
+
// Costs interactivity: the live path MOVES the host's children into
|
|
2954
|
+
// the canvas, where they become canvas fallback content — present in
|
|
2955
|
+
// the accessibility tree but not hit-testable the way ordinary DOM
|
|
2956
|
+
// is. Every control inside the morphed element then stops responding
|
|
2957
|
+
// to the pointer, which is what "the back button is not clickable"
|
|
2958
|
+
// is. It only reproduces on a browser with the HTML-in-Canvas API,
|
|
2959
|
+
// so a snapshot-only run says everything is fine.
|
|
2960
|
+
//
|
|
2961
|
+
// `transition.live: true` overrides this deliberately, for comparing
|
|
2962
|
+
// the two backends side by side. Expect the morph to LOOK the same and
|
|
2963
|
+
// stop responding to the pointer — that is the trade being shown.
|
|
2964
|
+
const forceLive = !!(transition && transition.live === true);
|
|
2965
|
+
const wantLive = !pureOverlay && (!transition || forceLive) &&
|
|
2966
|
+
!rasterNodes.some((n) => n.live === false);
|
|
1911
2967
|
const apiAvailable =
|
|
1912
2968
|
(typeof WebGL2RenderingContext !== "undefined" &&
|
|
1913
2969
|
"texElementImage2D" in WebGL2RenderingContext.prototype) ||
|
|
@@ -1948,6 +3004,9 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1948
3004
|
canvas.setAttribute("data-nodality-raster", live ? "live" : "snapshot");
|
|
1949
3005
|
console.info("[nodality] raster backend:", live ? "html-in-canvas (live)" : "snapshot");
|
|
1950
3006
|
|
|
3007
|
+
// In live mode the canvas fills the host's CONTENT box, not its border
|
|
3008
|
+
// box (it is a child of the host). Filled in below when live attaches.
|
|
3009
|
+
const liveBox = { width: rect.width, height: rect.height };
|
|
1951
3010
|
let sourceEl = el; // element uploaded as the texture in live mode
|
|
1952
3011
|
if (live) {
|
|
1953
3012
|
// HTML-in-Canvas: the subtree becomes canvas children (wrapped in
|
|
@@ -1976,9 +3035,42 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1976
3035
|
// mode the children never leave the host.
|
|
1977
3036
|
const hostCS = getComputedStyle(el);
|
|
1978
3037
|
const hostIsFlex = hostCS.display === "flex" || hostCS.display === "inline-flex";
|
|
3038
|
+
// GRID too, and for a sharper reason than flex.
|
|
3039
|
+
//
|
|
3040
|
+
// Dropping a flex host's layout misplaced its children; dropping a
|
|
3041
|
+
// GRID host's layout changes the box SIZE. Columns collapse, the
|
|
3042
|
+
// children stack vertically, and they overflow the fixed-height
|
|
3043
|
+
// wrapper — at which point measure()'s scrollHeight fallback (the
|
|
3044
|
+
// host's own rect is collapsed in live mode) starts counting the
|
|
3045
|
+
// overflowing canvas, which is a child of the thing being measured.
|
|
3046
|
+
// Canvas grows -> host grows -> ResizeObserver -> repeat. A
|
|
3047
|
+
// 98px-tall two-column panel reached 4014px before this line.
|
|
3048
|
+
const hostIsGrid = hostCS.display === "grid" || hostCS.display === "inline-grid";
|
|
3049
|
+
|
|
3050
|
+
// The canvas is a CHILD of the host, so it lives in the host's
|
|
3051
|
+
// CONTENT box — but `rect` is the border box. Sizing the canvas to
|
|
3052
|
+
// `rect` therefore overflows by the padding on both axes and makes
|
|
3053
|
+
// the host padding taller than it started, every time. Measure the
|
|
3054
|
+
// content box once, here, and use it for both the canvas and the
|
|
3055
|
+
// subtree; the host keeps its own padding and nothing is applied
|
|
3056
|
+
// twice.
|
|
3057
|
+
const px = (v) => parseFloat(v) || 0;
|
|
3058
|
+
const padX = px(hostCS.paddingLeft) + px(hostCS.paddingRight);
|
|
3059
|
+
const padY = px(hostCS.paddingTop) + px(hostCS.paddingBottom);
|
|
3060
|
+
liveBox.width = Math.max(2, rect.width - padX);
|
|
3061
|
+
liveBox.height = Math.max(2, rect.height - padY);
|
|
3062
|
+
|
|
1979
3063
|
wrap.style.cssText =
|
|
1980
|
-
`display:${hostIsFlex ?
|
|
1981
|
-
`width:${
|
|
3064
|
+
`display:${hostIsFlex || hostIsGrid ? hostCS.display : "block"};` +
|
|
3065
|
+
`width:${liveBox.width}px;height:${liveBox.height}px;`;
|
|
3066
|
+
if (hostIsGrid) {
|
|
3067
|
+
wrap.style.gridTemplateColumns = hostCS.gridTemplateColumns;
|
|
3068
|
+
wrap.style.gridTemplateRows = hostCS.gridTemplateRows;
|
|
3069
|
+
wrap.style.gridTemplateAreas = hostCS.gridTemplateAreas;
|
|
3070
|
+
wrap.style.gap = hostCS.gap;
|
|
3071
|
+
wrap.style.alignItems = hostCS.alignItems;
|
|
3072
|
+
wrap.style.justifyItems = hostCS.justifyItems;
|
|
3073
|
+
}
|
|
1982
3074
|
if (hostIsFlex) {
|
|
1983
3075
|
wrap.style.flexDirection = hostCS.flexDirection;
|
|
1984
3076
|
wrap.style.alignItems = hostCS.alignItems;
|
|
@@ -1991,7 +3083,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
1991
3083
|
// The host collapses once its children move into the canvas, so
|
|
1992
3084
|
// the canvas itself carries the box in normal flow (not overlay).
|
|
1993
3085
|
canvas.style.cssText =
|
|
1994
|
-
`display:block;width:${
|
|
3086
|
+
`display:block;width:${liveBox.width}px;height:${liveBox.height}px;`;
|
|
1995
3087
|
} else {
|
|
1996
3088
|
// Snapshot mode: purely visual overlay.
|
|
1997
3089
|
canvas.style.cssText =
|
|
@@ -2026,7 +3118,8 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2026
3118
|
};
|
|
2027
3119
|
const prog = gl.createProgram();
|
|
2028
3120
|
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS));
|
|
2029
|
-
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER,
|
|
3121
|
+
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER,
|
|
3122
|
+
buildFragmentShader(rasterTree, rasterNodes, false, transition)));
|
|
2030
3123
|
// Pin "a" to location 0 so a field-simulation program (which shares
|
|
2031
3124
|
// this vertex buffer and attribute array) can be swapped in without
|
|
2032
3125
|
// respecifying the pointer.
|
|
@@ -2046,15 +3139,46 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2046
3139
|
else if (kind === "3fv") gl.uniform3fv(loc, value);
|
|
2047
3140
|
};
|
|
2048
3141
|
|
|
2049
|
-
// Static uniforms from the node data.
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
3142
|
+
// Static uniforms from the node data. Uploaded once per PROGRAM
|
|
3143
|
+
// rather than once per pipeline: phase I1's probe is a second program
|
|
3144
|
+
// running the same chain, and without these its op uniforms would all
|
|
3145
|
+
// read 0 — a `size` of 0 divides to infinity and the whole coordinate
|
|
3146
|
+
// computation becomes NaN, which readback reports as a confident 0.
|
|
3147
|
+
// Phase T1. Transition progress. Static uniforms are resolved AT this
|
|
3148
|
+
// value, so a keyframed param is correct from the first frame rather
|
|
3149
|
+
// than jumping once something scrubs.
|
|
3150
|
+
let progress = 0;
|
|
3151
|
+
const keyframed = rasterNodes
|
|
3152
|
+
.map((node, i) => ({ node, i }))
|
|
3153
|
+
.filter(({ node }) => hasKeyframes(node));
|
|
3154
|
+
|
|
3155
|
+
const applyStaticUniforms = (Uq, only) => {
|
|
3156
|
+
rasterNodes.forEach((node, i) => {
|
|
3157
|
+
if (only && !only.has(i)) return;
|
|
3158
|
+
const def = REGISTRY[node.op];
|
|
3159
|
+
if (!def || !def.uniforms) return;
|
|
3160
|
+
const us = def.uniforms(resolveNode(node, progress), dpr);
|
|
3161
|
+
for (const key in us) {
|
|
3162
|
+
setUniform(Uq(`u${i}_${key}`), us[key][0], us[key][1]);
|
|
3163
|
+
}
|
|
3164
|
+
});
|
|
3165
|
+
};
|
|
3166
|
+
applyStaticUniforms(U);
|
|
3167
|
+
|
|
3168
|
+
// Re-upload only the keyframed nodes. Cheap enough to run per frame
|
|
3169
|
+
// while a transition is scrubbing, and a no-op for every chain that
|
|
3170
|
+
// has no keyframes — which is all of them until someone opts in.
|
|
3171
|
+
const kfIndices = new Set(keyframed.map((k) => k.i));
|
|
3172
|
+
const uploadKeyframes = () => {
|
|
3173
|
+
if (!keyframed.length) return;
|
|
3174
|
+
gl.useProgram(prog);
|
|
3175
|
+
applyStaticUniforms(U, kfIndices);
|
|
3176
|
+
if (probe && !probe.broken) {
|
|
3177
|
+
gl.useProgram(probe.prog);
|
|
3178
|
+
applyStaticUniforms(probe.U, kfIndices);
|
|
3179
|
+
gl.useProgram(prog);
|
|
2056
3180
|
}
|
|
2057
|
-
}
|
|
3181
|
+
};
|
|
2058
3182
|
|
|
2059
3183
|
// Ops with a CPU simulation (e.g. blobs) get per-frame state and
|
|
2060
3184
|
// feed dynamic uniforms each draw (see the render loop below).
|
|
@@ -2262,6 +3386,52 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2262
3386
|
const finishUpload = () => { if (needMips) gl.generateMipmap(gl.TEXTURE_2D); };
|
|
2263
3387
|
|
|
2264
3388
|
let textureReady = false;
|
|
3389
|
+
|
|
3390
|
+
// Phase T2. The frozen capture of the element being transitioned
|
|
3391
|
+
// FROM. Uploaded once — it is a still by definition, which is also
|
|
3392
|
+
// why transitions need no live-capture support and therefore work in
|
|
3393
|
+
// every browser that has WebGL, not just those in the origin trial.
|
|
3394
|
+
let oldTex = null;
|
|
3395
|
+
let newTex = null;
|
|
3396
|
+
// NOTE: the new element's ink is NOT hidden here. An earlier attempt
|
|
3397
|
+
// did it on the next animation frame and raced the capture — the
|
|
3398
|
+
// texture was taken from an already-transparent subtree, so the new
|
|
3399
|
+
// side of every morph was blank. The existing post-capture path
|
|
3400
|
+
// (`if (!pureOverlay) hideHostInk()`) runs at the right moment, and
|
|
3401
|
+
// transition mode is never pureOverlay; setProgress governs it from
|
|
3402
|
+
// then on.
|
|
3403
|
+
if (transition && transition.oldImage) {
|
|
3404
|
+
oldTex = gl.createTexture();
|
|
3405
|
+
gl.bindTexture(gl.TEXTURE_2D, oldTex);
|
|
3406
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
3407
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
3408
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3409
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3410
|
+
try {
|
|
3411
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
|
|
3412
|
+
gl.UNSIGNED_BYTE, transition.oldImage);
|
|
3413
|
+
} catch (e) {
|
|
3414
|
+
console.warn("[nodality] transition: old capture upload failed:", e);
|
|
3415
|
+
gl.deleteTexture(oldTex);
|
|
3416
|
+
oldTex = null;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
if (transition && transition.newImage) {
|
|
3420
|
+
newTex = gl.createTexture();
|
|
3421
|
+
gl.bindTexture(gl.TEXTURE_2D, newTex);
|
|
3422
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
3423
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
3424
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3425
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3426
|
+
try {
|
|
3427
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA,
|
|
3428
|
+
gl.UNSIGNED_BYTE, transition.newImage);
|
|
3429
|
+
} catch (e) {
|
|
3430
|
+
console.warn("[nodality] transition: new capture upload failed:", e);
|
|
3431
|
+
gl.deleteTexture(newTex);
|
|
3432
|
+
newTex = null;
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
2265
3435
|
let destroyed = false;
|
|
2266
3436
|
let mode = live ? "live" : "snapshot";
|
|
2267
3437
|
|
|
@@ -2305,12 +3475,31 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2305
3475
|
|
|
2306
3476
|
const hideHostInk = () => {
|
|
2307
3477
|
if (inkSaved) return;
|
|
3478
|
+
ensureSelectionStyle();
|
|
2308
3479
|
inkSaved = [];
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
3480
|
+
el.setAttribute("data-nodality-ink", "");
|
|
3481
|
+
const nodes = [el, ...el.querySelectorAll("*")]
|
|
3482
|
+
.filter((n) => n !== canvas && n.tagName !== "CANVAS");
|
|
3483
|
+
// Selection has to stay LEGIBLE while the ink is transparent. The
|
|
3484
|
+
// host is deliberately still selectable (see above), but with
|
|
3485
|
+
// color:transparent a drag paints the selection rectangle over
|
|
3486
|
+
// invisible glyphs — a blank block that hides the effect instead
|
|
3487
|
+
// of highlighting anything. Stash each node's real colour so the
|
|
3488
|
+
// ::selection rule can repaint just the selected run.
|
|
3489
|
+
//
|
|
3490
|
+
// Read EVERY colour before writing ANY, in two passes. Colour is
|
|
3491
|
+
// inherited, so transparentising a parent inside a single loop
|
|
3492
|
+
// makes each later child read back the already-hidden value —
|
|
3493
|
+
// which stores transparent as the "real" colour and restores
|
|
3494
|
+
// nothing.
|
|
3495
|
+
const realInk = nodes.map((n) => getComputedStyle(n).color);
|
|
3496
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
3497
|
+
const node = nodes[i];
|
|
3498
|
+
const saved = { op: node.style.getPropertyValue("opacity"),
|
|
3499
|
+
ink: node.style.getPropertyValue("--nod-ink") };
|
|
2312
3500
|
for (const p in INK) saved[p] = node.style.getPropertyValue(p);
|
|
2313
3501
|
inkSaved.push([node, saved]);
|
|
3502
|
+
node.style.setProperty("--nod-ink", realInk[i]);
|
|
2314
3503
|
for (const p in INK) node.style.setProperty(p, INK[p], "important");
|
|
2315
3504
|
// Replaced content has no "ink" property to neutralise.
|
|
2316
3505
|
if (node !== el && node.matches && node.matches(REPLACED)) {
|
|
@@ -2325,6 +3514,7 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2325
3514
|
|
|
2326
3515
|
const showHostInk = () => {
|
|
2327
3516
|
el.style.visibility = "";
|
|
3517
|
+
el.removeAttribute("data-nodality-ink");
|
|
2328
3518
|
if (!inkSaved) return;
|
|
2329
3519
|
for (const [node, saved] of inkSaved) {
|
|
2330
3520
|
for (const p in INK) {
|
|
@@ -2333,12 +3523,76 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2333
3523
|
}
|
|
2334
3524
|
if (saved.op) node.style.setProperty("opacity", saved.op);
|
|
2335
3525
|
else node.style.removeProperty("opacity");
|
|
3526
|
+
if (saved.ink) node.style.setProperty("--nod-ink", saved.ink);
|
|
3527
|
+
else node.style.removeProperty("--nod-ink");
|
|
2336
3528
|
}
|
|
2337
3529
|
inkSaved = null;
|
|
2338
3530
|
el.style.isolation = "";
|
|
2339
3531
|
canvas.style.zIndex = "";
|
|
2340
3532
|
};
|
|
2341
3533
|
|
|
3534
|
+
// Phase T2. WHO presents: the canvas while a transition is running,
|
|
3535
|
+
// the real DOM once it completes. They are alternatives, never both.
|
|
3536
|
+
//
|
|
3537
|
+
// This has to be one function called from everywhere, because the
|
|
3538
|
+
// snapshot path hides ink on EVERY capture completion — and a capture
|
|
3539
|
+
// can land long after the transition has finished (a resize, a late
|
|
3540
|
+
// image load, a refresh()). When that happened after t reached 1 the
|
|
3541
|
+
// ink was hidden again while the canvas was already down, and the
|
|
3542
|
+
// element vanished. It only reproduced on a real browser: headless
|
|
3543
|
+
// finishes its single capture before anything can scrub.
|
|
3544
|
+
//
|
|
3545
|
+
// Returns true when it took ownership, so callers know to stand down.
|
|
3546
|
+
// Re-asserted EVERY FRAME rather than only on the events that change
|
|
3547
|
+
// it, because at least three other subsystems write the same state
|
|
3548
|
+
// for their own reasons and the last writer wins:
|
|
3549
|
+
//
|
|
3550
|
+
// - snapshotCapture() calls showHostInk() before serialising, so
|
|
3551
|
+
// the capture sees real ink rather than the transparent version;
|
|
3552
|
+
// - its completion hides the ink again;
|
|
3553
|
+
// - the live -> snapshot fallback rewrites canvas.style.cssText
|
|
3554
|
+
// wholesale, discarding `visibility: hidden` along with it.
|
|
3555
|
+
//
|
|
3556
|
+
// Any of those can land after a transition completes and leave the
|
|
3557
|
+
// element hidden behind an already-hidden canvas, which is exactly
|
|
3558
|
+
// the "it disappears at t=1" report. Ordering fixes kept missing a
|
|
3559
|
+
// path; a cheap idempotent re-assert cannot. Two property reads per
|
|
3560
|
+
// frame, and only in transition mode.
|
|
3561
|
+
const syncTransitionView = () => {
|
|
3562
|
+
if (!transition) return false;
|
|
3563
|
+
const done = progress >= 1;
|
|
3564
|
+
// Standing the canvas down at t=1 assumes it OVERLAYS the content,
|
|
3565
|
+
// so hiding it reveals the real element underneath. That is true
|
|
3566
|
+
// of the snapshot backend, but the live backend MOVES the
|
|
3567
|
+
// element's children into the canvas (canvas.appendChild(wrap)) —
|
|
3568
|
+
// there the canvas is where the content lives, and hiding it hides
|
|
3569
|
+
// the very thing being handed over to.
|
|
3570
|
+
//
|
|
3571
|
+
// The test is `sourceEl !== el`, not `mode === "live"`. A live
|
|
3572
|
+
// pipeline only restructures when it actually needs the live
|
|
3573
|
+
// upload; a transition draws from the two frozen captures instead,
|
|
3574
|
+
// so it stays an overlay even on a live-capable browser. Keying
|
|
3575
|
+
// off the mode suppressed ink suppression on every such browser.
|
|
3576
|
+
const hostsContent = sourceEl !== el;
|
|
3577
|
+
// A morph owns the screen strictly BETWEEN its endpoints. At t=1
|
|
3578
|
+
// the new element takes over; opting in with `standDownAtStart`
|
|
3579
|
+
// makes t=0 symmetric, so the OLD element presents there — real,
|
|
3580
|
+
// clickable, selectable — instead of a picture of itself. Opt-in
|
|
3581
|
+
// because the library cannot supply the old element: it belongs to
|
|
3582
|
+
// the caller, who must put it back. Without it, t=0 keeps showing
|
|
3583
|
+
// the old capture, which is the right default for a morph that is
|
|
3584
|
+
// about to run.
|
|
3585
|
+
const atStart = transition.standDownAtStart && progress <= 0;
|
|
3586
|
+
const wantVis = (done || atStart) && !hostsContent ? "hidden" : "visible";
|
|
3587
|
+
if (canvas.style.visibility !== wantVis) canvas.style.visibility = wantVis;
|
|
3588
|
+
if (done) {
|
|
3589
|
+
if (inkSaved) showHostInk();
|
|
3590
|
+
} else if (!inkSaved) {
|
|
3591
|
+
hideHostInk();
|
|
3592
|
+
}
|
|
3593
|
+
return true;
|
|
3594
|
+
};
|
|
3595
|
+
|
|
2342
3596
|
const snapshotCapture = () => {
|
|
2343
3597
|
// Overlay + image content: use the image directly as the texture.
|
|
2344
3598
|
if (isOverlay) {
|
|
@@ -2374,8 +3628,12 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2374
3628
|
// A pure-overlay chain leaves the host alone; the lens
|
|
2375
3629
|
// floats over untouched content. Otherwise the canvas
|
|
2376
3630
|
// carries the look and the host's own ink is suppressed.
|
|
2377
|
-
|
|
2378
|
-
|
|
3631
|
+
// In transition mode progress decides this, not the
|
|
3632
|
+
// capture — see syncTransitionView.
|
|
3633
|
+
if (!syncTransitionView()) {
|
|
3634
|
+
if (!pureOverlay) hideHostInk();
|
|
3635
|
+
canvas.style.visibility = "visible";
|
|
3636
|
+
}
|
|
2379
3637
|
})
|
|
2380
3638
|
.catch((e) => {
|
|
2381
3639
|
showHostInk();
|
|
@@ -2519,9 +3777,420 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2519
3777
|
|
|
2520
3778
|
// Render loop — paused while off-screen.
|
|
2521
3779
|
let raf = 0;
|
|
3780
|
+
// The last frame's uniform values, so the phase-I1 probe pass can
|
|
3781
|
+
// reproduce that exact frame on the probe program. Null until the
|
|
3782
|
+
// first draw — sourceAt() reports "not ready" rather than guessing.
|
|
3783
|
+
let lastSnap = null;
|
|
3784
|
+
|
|
3785
|
+
/** Write a frame snapshot to whichever program `Uq` resolves against. */
|
|
3786
|
+
const applyUniforms = (Uq, s) => {
|
|
3787
|
+
gl.uniform2f(Uq("u_res"), s.res[0], s.res[1]);
|
|
3788
|
+
gl.uniform2f(Uq("u_mouse"), s.mouse[0], s.mouse[1]);
|
|
3789
|
+
gl.uniform1f(Uq("u_time"), s.time);
|
|
3790
|
+
gl.uniform1f(Uq("u_t"), s.t);
|
|
3791
|
+
for (const d of s.drivers) {
|
|
3792
|
+
gl.uniform2f(Uq(`u${d.i}_dpos`), d.x, d.y);
|
|
3793
|
+
gl.uniform1f(Uq(`u${d.i}_damt`), d.amt);
|
|
3794
|
+
}
|
|
3795
|
+
for (const d of s.dyn) {
|
|
3796
|
+
for (const u of d.ups) setUniform(Uq(`u${d.i}_${u.name}`), u.kind, u.value);
|
|
3797
|
+
}
|
|
3798
|
+
// Content on unit 0, then each solver's named result textures
|
|
3799
|
+
// (velocity, dye, ...) on units above it.
|
|
3800
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
3801
|
+
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
3802
|
+
gl.uniform1i(Uq("u_tex"), 0);
|
|
3803
|
+
let unit = 1;
|
|
3804
|
+
if (transition && oldTex) {
|
|
3805
|
+
// Phase T2. The frozen old capture, and the box both sides
|
|
3806
|
+
// are drawn into — lerped by progress, in device px.
|
|
3807
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
3808
|
+
gl.bindTexture(gl.TEXTURE_2D, oldTex);
|
|
3809
|
+
gl.uniform1i(Uq("u_old"), unit);
|
|
3810
|
+
unit++;
|
|
3811
|
+
// Each side interpolates in its own rect. `oldTo` is where
|
|
3812
|
+
// the outgoing element travels to (default: the new rect, so
|
|
3813
|
+
// they converge), `newFrom` where the incoming one starts
|
|
3814
|
+
// from (default: the old rect). Supplying both is how you get
|
|
3815
|
+
// "old exits left while new enters from the right".
|
|
3816
|
+
const t = s.t;
|
|
3817
|
+
const lerpBox = (from, to) => [
|
|
3818
|
+
(from.x + (to.x - from.x) * t) * dpr,
|
|
3819
|
+
(from.y + (to.y - from.y) * t) * dpr,
|
|
3820
|
+
(from.w + (to.w - from.w) * t) * dpr,
|
|
3821
|
+
(from.h + (to.h - from.h) * t) * dpr,
|
|
3822
|
+
];
|
|
3823
|
+
const oldBox = lerpBox(transition.oldRect,
|
|
3824
|
+
transition.oldTo || transition.newRect);
|
|
3825
|
+
const newBox = lerpBox(transition.newFrom || transition.oldRect,
|
|
3826
|
+
transition.newRect);
|
|
3827
|
+
gl.uniform4f(Uq("u_boxOld"), oldBox[0], oldBox[1], oldBox[2], oldBox[3]);
|
|
3828
|
+
gl.uniform4f(Uq("u_boxNew"), newBox[0], newBox[1], newBox[2], newBox[3]);
|
|
3829
|
+
if (newTex) {
|
|
3830
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
3831
|
+
gl.bindTexture(gl.TEXTURE_2D, newTex);
|
|
3832
|
+
gl.uniform1i(Uq("u_newimg"), unit);
|
|
3833
|
+
unit++;
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
for (const so of solverOps) {
|
|
3837
|
+
const smps = so.def.solver.samplers;
|
|
3838
|
+
for (const key in smps) {
|
|
3839
|
+
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
3840
|
+
gl.bindTexture(gl.TEXTURE_2D, so.targets[smps[key]].a.tex);
|
|
3841
|
+
gl.uniform1i(Uq(`u${so.i}_${key}`), unit);
|
|
3842
|
+
unit++;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
gl.activeTexture(gl.TEXTURE0); // texture uploads assume unit 0
|
|
3846
|
+
};
|
|
3847
|
+
|
|
3848
|
+
// ── Phase I1: coordinate readback ────────────────────────────────
|
|
3849
|
+
//
|
|
3850
|
+
// The overlay canvas is pointer-events:none and the DOM underneath
|
|
3851
|
+
// stays where layout put it, so once a warp displaces content, a link
|
|
3852
|
+
// is clickable where it is NOT drawn. Fixing that needs one fact:
|
|
3853
|
+
// given a screen point, which source pixel is shown there?
|
|
3854
|
+
//
|
|
3855
|
+
// The shader already computes it — output pixel -> sampleP -> fetch.
|
|
3856
|
+
// So rather than reimplement each op's arithmetic on the CPU (which
|
|
3857
|
+
// could silently disagree with what is drawn, and is impossible for
|
|
3858
|
+
// `stir`, a fluid sim with no closed form), run the SAME chain and
|
|
3859
|
+
// read the answer back.
|
|
3860
|
+
//
|
|
3861
|
+
// Cost is one draw of a single fragment plus a 1-pixel readPixels.
|
|
3862
|
+
// Built on first use, so a page that never hit-tests pays nothing.
|
|
3863
|
+
let probe = null;
|
|
3864
|
+
const buildProbe = () => {
|
|
3865
|
+
if (probe) return probe;
|
|
3866
|
+
const p = gl.createProgram();
|
|
3867
|
+
gl.attachShader(p, compile(gl.VERTEX_SHADER, VS));
|
|
3868
|
+
gl.attachShader(p, compile(gl.FRAGMENT_SHADER,
|
|
3869
|
+
buildFragmentShader(rasterTree, rasterNodes, true, transition)));
|
|
3870
|
+
gl.bindAttribLocation(p, 0, "a");
|
|
3871
|
+
gl.linkProgram(p);
|
|
3872
|
+
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
3873
|
+
console.warn("[nodality] probe program failed:", gl.getProgramInfoLog(p));
|
|
3874
|
+
gl.deleteProgram(p);
|
|
3875
|
+
probe = { broken: true };
|
|
3876
|
+
return probe;
|
|
3877
|
+
}
|
|
3878
|
+
// One pixel is the entire render target: the viewport is offset so
|
|
3879
|
+
// that the queried pixel is the only one rasterised.
|
|
3880
|
+
const t = gl.createTexture();
|
|
3881
|
+
gl.bindTexture(gl.TEXTURE_2D, t);
|
|
3882
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
3883
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
3884
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
3885
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
3886
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
3887
|
+
const fbo = gl.createFramebuffer();
|
|
3888
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
|
3889
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
|
|
3890
|
+
const st = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
|
|
3891
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
3892
|
+
if (st !== gl.FRAMEBUFFER_COMPLETE) {
|
|
3893
|
+
console.warn("[nodality] probe framebuffer incomplete:", st);
|
|
3894
|
+
gl.deleteProgram(p); gl.deleteFramebuffer(fbo); gl.deleteTexture(t);
|
|
3895
|
+
probe = { broken: true };
|
|
3896
|
+
return probe;
|
|
3897
|
+
}
|
|
3898
|
+
const cache = {};
|
|
3899
|
+
const Up = (n) => (n in cache ? cache[n] : (cache[n] = gl.getUniformLocation(p, n)));
|
|
3900
|
+
// The chain's static op uniforms, on this program too.
|
|
3901
|
+
gl.useProgram(p);
|
|
3902
|
+
applyStaticUniforms(Up);
|
|
3903
|
+
gl.useProgram(prog);
|
|
3904
|
+
probe = { prog: p, fbo, tex: t, px: new Uint8Array(4), U: Up };
|
|
3905
|
+
return probe;
|
|
3906
|
+
};
|
|
3907
|
+
|
|
3908
|
+
/**
|
|
3909
|
+
* Which source pixel is drawn at this point on screen?
|
|
3910
|
+
*
|
|
3911
|
+
* @param {number} clientX viewport coordinate, as on a PointerEvent
|
|
3912
|
+
* @param {number} clientY
|
|
3913
|
+
* @returns {{x: number, y: number} | null} the point in CLIENT
|
|
3914
|
+
* coordinates that the content visible at (clientX, clientY) came
|
|
3915
|
+
* from — feed it straight to document.elementFromPoint(). Null if
|
|
3916
|
+
* nothing has been drawn yet, the point is outside the canvas, or
|
|
3917
|
+
* the probe could not be built.
|
|
3918
|
+
*
|
|
3919
|
+
* Caveat, documented rather than hidden: `copy` draws several stamps,
|
|
3920
|
+
* so one screen point genuinely maps to several sources. This returns
|
|
3921
|
+
* the primary sample. Phase I4 classifies that case.
|
|
3922
|
+
*/
|
|
3923
|
+
// ── Phase I2: the CPU path ───────────────────────────────────────
|
|
3924
|
+
//
|
|
3925
|
+
// A GPU readback is a pipeline sync, which is fine per click and too
|
|
3926
|
+
// expensive per pointermove. An op may declare `map` — the same
|
|
3927
|
+
// coordinate arithmetic its GLSL does, in JS — and a chain whose
|
|
3928
|
+
// coordinate-moving ops all declare one is answered without touching
|
|
3929
|
+
// the GPU.
|
|
3930
|
+
//
|
|
3931
|
+
// The duplication is safe only because I1 exists to check it: the
|
|
3932
|
+
// property test asserts twin and readback agree across the parameter
|
|
3933
|
+
// space. A twin that drifts from its shader is caught, not trusted.
|
|
3934
|
+
//
|
|
3935
|
+
// Bails to null — meaning "ask the GPU" — for anything it cannot do
|
|
3936
|
+
// faithfully:
|
|
3937
|
+
// - an op in a coordinate stage with no `map` and no explicit
|
|
3938
|
+
// `movesCoords: false` (flow, stir)
|
|
3939
|
+
// - a `map` that declines (hexalize with lift)
|
|
3940
|
+
// - any MASKED op, because its effect is lerped by a field this
|
|
3941
|
+
// path does not evaluate
|
|
3942
|
+
const COORD_STAGES = ["warp", "cell", "displace"];
|
|
3943
|
+
const cpuSourceAt = (px, py) => {
|
|
3944
|
+
if (!lastSnap) return null;
|
|
3945
|
+
const byIndex = {};
|
|
3946
|
+
for (const d of lastSnap.drivers) byIndex[d.i] = d;
|
|
3947
|
+
let pt = [px, py];
|
|
3948
|
+
for (const stage of COORD_STAGES) {
|
|
3949
|
+
for (let i = 0; i < rasterNodes.length; i++) {
|
|
3950
|
+
const node = rasterNodes[i];
|
|
3951
|
+
const def = REGISTRY[node.op];
|
|
3952
|
+
if (!def) continue;
|
|
3953
|
+
const stages = Array.isArray(def.stage) ? def.stage : [def.stage];
|
|
3954
|
+
if (!stages.includes(stage)) continue;
|
|
3955
|
+
if (maskedField(node)) return null;
|
|
3956
|
+
if (typeof def.map !== "function") {
|
|
3957
|
+
if (def.movesCoords === false) continue;
|
|
3958
|
+
return null;
|
|
3959
|
+
}
|
|
3960
|
+
const d = byIndex[i];
|
|
3961
|
+
const out = def.map(pt, node, {
|
|
3962
|
+
res: lastSnap.res,
|
|
3963
|
+
dpr,
|
|
3964
|
+
dpos: d ? [d.x, d.y] : [lastSnap.res[0] / 2, lastSnap.res[1] / 2],
|
|
3965
|
+
damt: d ? d.amt : 1,
|
|
3966
|
+
time: lastSnap.time,
|
|
3967
|
+
mouse: lastSnap.mouse,
|
|
3968
|
+
});
|
|
3969
|
+
if (!out) return null;
|
|
3970
|
+
pt = out;
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
return pt;
|
|
3974
|
+
};
|
|
3975
|
+
|
|
3976
|
+
/**
|
|
3977
|
+
* @param {object} [opts]
|
|
3978
|
+
* @param {boolean} [opts.gpu] force the readback path, skipping any
|
|
3979
|
+
* declared CPU twin. This is the oracle the twins are checked
|
|
3980
|
+
* against — a `map` that drifted from its shader would
|
|
3981
|
+
* otherwise be undetectable, since both paths would be asked
|
|
3982
|
+
* the same question and only one of them consulted.
|
|
3983
|
+
*/
|
|
3984
|
+
const sourceAt = (clientX, clientY, opts) => {
|
|
3985
|
+
if (destroyed || !lastSnap || !textureReady) return null;
|
|
3986
|
+
|
|
3987
|
+
const rect = canvas.getBoundingClientRect();
|
|
3988
|
+
if (rect.width && rect.height && !(opts && opts.gpu)) {
|
|
3989
|
+
// Try the CPU path first — same answer, no pipeline sync.
|
|
3990
|
+
const sxc = canvas.width / rect.width, syc = canvas.height / rect.height;
|
|
3991
|
+
const dx = (clientX - rect.left) * sxc, dy = (clientY - rect.top) * syc;
|
|
3992
|
+
if (dx >= 0 && dy >= 0 && dx < canvas.width && dy < canvas.height) {
|
|
3993
|
+
// The SAME pixel-centre convention the readback uses: floor
|
|
3994
|
+
// to a pixel, then take its centre. Without this the two
|
|
3995
|
+
// paths ask about points up to half a pixel apart, which is
|
|
3996
|
+
// invisible for most ops and enormous for `offset` near its
|
|
3997
|
+
// focus, where dir = (warped - dpos)/d is a singularity.
|
|
3998
|
+
const fx = Math.floor(dx);
|
|
3999
|
+
const fy = Math.floor(canvas.height - dy);
|
|
4000
|
+
const cpu = cpuSourceAt(fx + 0.5, canvas.height - fy - 0.5);
|
|
4001
|
+
if (cpu) {
|
|
4002
|
+
return {
|
|
4003
|
+
x: rect.left + Math.min(Math.max(cpu[0], 0), canvas.width) / sxc,
|
|
4004
|
+
y: rect.top + Math.min(Math.max(cpu[1], 0), canvas.height) / syc,
|
|
4005
|
+
};
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
const pr = buildProbe();
|
|
4011
|
+
if (pr.broken) return null;
|
|
4012
|
+
|
|
4013
|
+
const r = canvas.getBoundingClientRect();
|
|
4014
|
+
if (!r.width || !r.height) return null;
|
|
4015
|
+
// Client -> canvas device pixels. The canvas may be scaled by CSS,
|
|
4016
|
+
// so go through the rect rather than assuming dpr.
|
|
4017
|
+
const sx = canvas.width / r.width, sy = canvas.height / r.height;
|
|
4018
|
+
const px = (clientX - r.left) * sx;
|
|
4019
|
+
const py = (clientY - r.top) * sy;
|
|
4020
|
+
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) return null;
|
|
4021
|
+
|
|
4022
|
+
// gl_FragCoord is bottom-up; `py` is top-down.
|
|
4023
|
+
const fx = Math.floor(px);
|
|
4024
|
+
const fy = Math.floor(canvas.height - py);
|
|
4025
|
+
|
|
4026
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, pr.fbo);
|
|
4027
|
+
gl.useProgram(pr.prog);
|
|
4028
|
+
// The whole target is one pixel; the shader reads the coordinate
|
|
4029
|
+
// it is standing in for from u_probe rather than gl_FragCoord.
|
|
4030
|
+
gl.viewport(0, 0, 1, 1);
|
|
4031
|
+
gl.uniform2f(pr.U("u_probe"), fx, fy);
|
|
4032
|
+
applyUniforms(pr.U, lastSnap);
|
|
4033
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
4034
|
+
gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pr.px);
|
|
4035
|
+
|
|
4036
|
+
// Restore what draw() expects to find.
|
|
4037
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
4038
|
+
gl.useProgram(prog);
|
|
4039
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
4040
|
+
|
|
4041
|
+
// Two bytes per axis, as packed in the probe tail.
|
|
4042
|
+
const b = pr.px;
|
|
4043
|
+
const qx = (b[0] + b[1] / 255) / 255;
|
|
4044
|
+
const qy = (b[2] + b[3] / 255) / 255;
|
|
4045
|
+
// Device px (top-down) -> client coordinates.
|
|
4046
|
+
return {
|
|
4047
|
+
x: r.left + (qx * canvas.width) / sx,
|
|
4048
|
+
y: r.top + (qy * canvas.height) / sy,
|
|
4049
|
+
};
|
|
4050
|
+
};
|
|
4051
|
+
|
|
4052
|
+
// ── Phase I3: event retargeting ──────────────────────────────────
|
|
4053
|
+
//
|
|
4054
|
+
// With I1 able to say where a pixel came from, the fix is mechanical:
|
|
4055
|
+
// catch pointer events on the host in the CAPTURE phase (before they
|
|
4056
|
+
// reach the element the browser picked), ask where the content under
|
|
4057
|
+
// the cursor actually lives, and re-dispatch there.
|
|
4058
|
+
//
|
|
4059
|
+
// Honest boundary, stated because it cannot be fixed from here: CSS
|
|
4060
|
+
// `:hover` and `:active` are computed by the browser from the real
|
|
4061
|
+
// pointer position over the real box. A synthetic MouseEvent does not
|
|
4062
|
+
// move them. So JS handlers retarget correctly and CSS pseudo-classes
|
|
4063
|
+
// still follow the undisplaced layout. Retargeting those would mean
|
|
4064
|
+
// moving the DOM, which is the one thing this library promises not to
|
|
4065
|
+
// do. Focus and keyboard were never wrong — they never went through
|
|
4066
|
+
// coordinates.
|
|
4067
|
+
const RETARGET = "__nodalityRetargeted";
|
|
4068
|
+
// Opt out per pipeline, like `live: false`. An effect used as pure
|
|
4069
|
+
// decoration should not pay for a GPU readback per click.
|
|
4070
|
+
const interactive = !rasterNodes.some((n) => n.interactive === false);
|
|
4071
|
+
|
|
4072
|
+
// Phase I3c. CSS :hover is computed by the browser from the real
|
|
4073
|
+
// pointer over the real box, and a synthetic MouseEvent does not move
|
|
4074
|
+
// it — so on a displaced element the wrong thing lights up. It CAN be
|
|
4075
|
+
// mirrored: track the hovered ancestor chain at the SOURCE position
|
|
4076
|
+
// and mark it with an attribute the page can style.
|
|
4077
|
+
//
|
|
4078
|
+
// { op: "flow", hoverAttr: true } → [data-nodality-hover]
|
|
4079
|
+
//
|
|
4080
|
+
// OPT-IN, and this is the reason: writing that attribute is a DOM
|
|
4081
|
+
// mutation, and this library's headline property is that an effect
|
|
4082
|
+
// never touches the host subtree — asserted with a MutationObserver
|
|
4083
|
+
// in the e2e suite. Turning it on trades that property for hover
|
|
4084
|
+
// fidelity, so it must be the page's decision, not a default. Page
|
|
4085
|
+
// authors also have to write `[data-nodality-hover]` rather than
|
|
4086
|
+
// `:hover`, which is a real cost.
|
|
4087
|
+
const HOVER_ATTR = "data-nodality-hover";
|
|
4088
|
+
const hoverMirror = rasterNodes.some((n) => n.hoverAttr === true);
|
|
4089
|
+
let hoverChain = [];
|
|
4090
|
+
const setHoverChain = (target) => {
|
|
4091
|
+
const next = [];
|
|
4092
|
+
for (let n = target; n && n !== el.parentNode; n = n.parentElement) next.push(n);
|
|
4093
|
+
const keep = new Set(next);
|
|
4094
|
+
for (const n of hoverChain) if (!keep.has(n)) n.removeAttribute(HOVER_ATTR);
|
|
4095
|
+
for (const n of next) if (!n.hasAttribute(HOVER_ATTR)) n.setAttribute(HOVER_ATTR, "");
|
|
4096
|
+
hoverChain = next;
|
|
4097
|
+
};
|
|
4098
|
+
|
|
4099
|
+
/** Build an event of the same kind, at corrected coordinates. */
|
|
4100
|
+
const cloneAt = (e, x, y) => {
|
|
4101
|
+
const Ctor = (typeof PointerEvent !== "undefined" && e instanceof PointerEvent)
|
|
4102
|
+
? PointerEvent
|
|
4103
|
+
: MouseEvent;
|
|
4104
|
+
const ev = new Ctor(e.type, {
|
|
4105
|
+
bubbles: true, cancelable: e.cancelable, composed: true,
|
|
4106
|
+
view: e.view || (typeof window !== "undefined" ? window : null),
|
|
4107
|
+
detail: e.detail,
|
|
4108
|
+
clientX: x, clientY: y,
|
|
4109
|
+
// Keep screen coords consistent with the shift we applied.
|
|
4110
|
+
screenX: e.screenX + (x - e.clientX),
|
|
4111
|
+
screenY: e.screenY + (y - e.clientY),
|
|
4112
|
+
ctrlKey: e.ctrlKey, altKey: e.altKey,
|
|
4113
|
+
shiftKey: e.shiftKey, metaKey: e.metaKey,
|
|
4114
|
+
button: e.button, buttons: e.buttons,
|
|
4115
|
+
relatedTarget: e.relatedTarget,
|
|
4116
|
+
// Ignored by MouseEvent, meaningful for PointerEvent.
|
|
4117
|
+
pointerId: e.pointerId, pointerType: e.pointerType,
|
|
4118
|
+
isPrimary: e.isPrimary, pressure: e.pressure,
|
|
4119
|
+
});
|
|
4120
|
+
ev[RETARGET] = true;
|
|
4121
|
+
return ev;
|
|
4122
|
+
};
|
|
4123
|
+
|
|
4124
|
+
// A GPU readback per pointermove would be one sync per frame. Moves
|
|
4125
|
+
// are throttled to one probe per animation frame; clicks are rare and
|
|
4126
|
+
// always probed. Phase I2 removes the sync for ops that can declare
|
|
4127
|
+
// their coordinate math on the CPU.
|
|
4128
|
+
let lastMoveProbe = 0;
|
|
4129
|
+
|
|
4130
|
+
const onRetarget = (e) => {
|
|
4131
|
+
if (!interactive || destroyed || e[RETARGET]) return;
|
|
4132
|
+
if (e.type === "pointermove" || e.type === "mousemove") {
|
|
4133
|
+
const t = (typeof performance !== "undefined") ? performance.now() : 0;
|
|
4134
|
+
if (t - lastMoveProbe < 12) return;
|
|
4135
|
+
lastMoveProbe = t;
|
|
4136
|
+
}
|
|
4137
|
+
const s = sourceAt(e.clientX, e.clientY);
|
|
4138
|
+
if (!s) return;
|
|
4139
|
+
// Nothing moved here: let the browser's own hit-testing stand.
|
|
4140
|
+
if (Math.abs(s.x - e.clientX) < 0.5 && Math.abs(s.y - e.clientY) < 0.5) return;
|
|
4141
|
+
|
|
4142
|
+
// Returns null when the source lands outside the VIEWPORT — an
|
|
4143
|
+
// element scrolled half off-screen whose displacement pushes the
|
|
4144
|
+
// source past the edge. Declining is the right answer there: the
|
|
4145
|
+
// native hit stands, rather than dispatching somewhere wrong.
|
|
4146
|
+
const target = typeof document.elementFromPoint === "function"
|
|
4147
|
+
? document.elementFromPoint(s.x, s.y)
|
|
4148
|
+
: null;
|
|
4149
|
+
// Only ever redirect INTO our own subtree. A displacement that
|
|
4150
|
+
// resolves outside the host is a bug or an edge case, and
|
|
4151
|
+
// dispatching into unrelated page content would be worse than
|
|
4152
|
+
// doing nothing.
|
|
4153
|
+
if (!target || target === e.target || !el.contains(target)) return;
|
|
4154
|
+
|
|
4155
|
+
// Hover, if the page opted in. Done for moves as well as clicks,
|
|
4156
|
+
// since that is when hover state changes.
|
|
4157
|
+
if (hoverMirror) setHoverChain(target);
|
|
4158
|
+
|
|
4159
|
+
// Phase I3b. Focus follows the pointer to the element that was
|
|
4160
|
+
// actually clicked. Without this, clicking a displaced link moves
|
|
4161
|
+
// focus to whatever sits under the cursor in the undisplaced
|
|
4162
|
+
// layout — so the picture, the click and the focus ring disagree.
|
|
4163
|
+
// Only on activating events: a pointermove must not steal focus.
|
|
4164
|
+
if (e.type === "pointerdown" || e.type === "mousedown" || e.type === "click") {
|
|
4165
|
+
const focusable = target.closest &&
|
|
4166
|
+
target.closest("a, button, input, select, textarea, [tabindex]");
|
|
4167
|
+
if (focusable && typeof focusable.focus === "function") {
|
|
4168
|
+
focusable.focus({ preventScroll: true });
|
|
4169
|
+
}
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
// Stop the original in the capture phase so the element the
|
|
4173
|
+
// browser picked never sees it, then deliver the corrected one.
|
|
4174
|
+
e.stopPropagation();
|
|
4175
|
+
if (e.cancelable) e.preventDefault();
|
|
4176
|
+
target.dispatchEvent(cloneAt(e, s.x, s.y));
|
|
4177
|
+
};
|
|
4178
|
+
|
|
4179
|
+
const RETARGET_EVENTS = ["pointerdown", "pointerup", "pointermove", "click",
|
|
4180
|
+
"dblclick", "contextmenu", "mousedown", "mouseup"];
|
|
4181
|
+
// Leaving the element must clear the mirrored hover, or it sticks.
|
|
4182
|
+
const onRetargetLeave = () => { if (hoverMirror) setHoverChain(null); };
|
|
4183
|
+
if (interactive) {
|
|
4184
|
+
for (const t of RETARGET_EVENTS) el.addEventListener(t, onRetarget, true);
|
|
4185
|
+
el.addEventListener("pointerleave", onRetargetLeave, true);
|
|
4186
|
+
}
|
|
4187
|
+
|
|
2522
4188
|
let visible = true;
|
|
2523
4189
|
const draw = () => {
|
|
2524
4190
|
if (destroyed) return;
|
|
4191
|
+
// Self-healing: whatever else touched the ink or the canvas since
|
|
4192
|
+
// the last frame, progress is the authority.
|
|
4193
|
+
syncTransitionView();
|
|
2525
4194
|
if (visible && textureReady) {
|
|
2526
4195
|
const now = (typeof performance !== "undefined") ? performance.now() : lastFrame + 16;
|
|
2527
4196
|
const dt = Math.max(0, (now - lastFrame) / 1000);
|
|
@@ -2540,10 +4209,20 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2540
4209
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
2541
4210
|
gl.clearColor(0, 0, 0, 0);
|
|
2542
4211
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
//
|
|
4212
|
+
// Everything the frame needs, computed ONCE, then written to
|
|
4213
|
+
// a program. Split this way for phase I1: the probe pass has
|
|
4214
|
+
// to reproduce this frame on a different program, and both
|
|
4215
|
+
// the hover easing above and `tick()` below MUTATE — running
|
|
4216
|
+
// them again for a hit-test would advance every animation by
|
|
4217
|
+
// an extra step per pointer event.
|
|
4218
|
+
const snap = {
|
|
4219
|
+
res: [canvas.width, canvas.height],
|
|
4220
|
+
mouse: [mouse[0], mouse[1]],
|
|
4221
|
+
time: now / 1000,
|
|
4222
|
+
t: progress,
|
|
4223
|
+
drivers: [],
|
|
4224
|
+
dyn: [],
|
|
4225
|
+
};
|
|
2547
4226
|
if (driverNodes.length > 0) {
|
|
2548
4227
|
hover += (hoverTarget - hover) * (1 - Math.exp(-dt * 8));
|
|
2549
4228
|
const dctx = {
|
|
@@ -2553,34 +4232,17 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2553
4232
|
};
|
|
2554
4233
|
for (const d of driverNodes) {
|
|
2555
4234
|
const v = d.fn(dctx);
|
|
2556
|
-
|
|
2557
|
-
gl.uniform1f(U(`u${d.i}_damt`), v.amt);
|
|
4235
|
+
snap.drivers.push({ i: d.i, x: v.x, y: v.y, amt: v.amt });
|
|
2558
4236
|
}
|
|
2559
4237
|
}
|
|
2560
|
-
// Advance and upload per-frame CPU simulations.
|
|
2561
4238
|
if (dynamicOps.length > 0) {
|
|
2562
4239
|
const ctx = { w: canvas.width, h: canvas.height, mouseX: mouse[0], mouseY: mouse[1], dt: dt, t: now / 1000 };
|
|
2563
4240
|
for (const d of dynamicOps) {
|
|
2564
|
-
|
|
2565
|
-
for (const u of ups) setUniform(U(`u${d.i}_${u.name}`), u.kind, u.value);
|
|
2566
|
-
}
|
|
2567
|
-
}
|
|
2568
|
-
// Content on unit 0, then each solver's named result
|
|
2569
|
-
// textures (velocity, dye, ...) on units above it.
|
|
2570
|
-
gl.activeTexture(gl.TEXTURE0);
|
|
2571
|
-
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
2572
|
-
gl.uniform1i(U("u_tex"), 0);
|
|
2573
|
-
let unit = 1;
|
|
2574
|
-
for (const s of solverOps) {
|
|
2575
|
-
const smps = s.def.solver.samplers;
|
|
2576
|
-
for (const key in smps) {
|
|
2577
|
-
gl.activeTexture(gl.TEXTURE0 + unit);
|
|
2578
|
-
gl.bindTexture(gl.TEXTURE_2D, s.targets[smps[key]].a.tex);
|
|
2579
|
-
gl.uniform1i(U(`u${s.i}_${key}`), unit);
|
|
2580
|
-
unit++;
|
|
4241
|
+
snap.dyn.push({ i: d.i, ups: d.def.tick(d.state, ctx) });
|
|
2581
4242
|
}
|
|
2582
4243
|
}
|
|
2583
|
-
|
|
4244
|
+
lastSnap = snap;
|
|
4245
|
+
applyUniforms(U, snap);
|
|
2584
4246
|
lastFrame = now;
|
|
2585
4247
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
2586
4248
|
}
|
|
@@ -2650,6 +4312,22 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2650
4312
|
|
|
2651
4313
|
if (typeof ResizeObserver !== "undefined") {
|
|
2652
4314
|
ro = new ResizeObserver(() => {
|
|
4315
|
+
// SNAPSHOT ONLY. In live mode the host's children have moved
|
|
4316
|
+
// inside the canvas, so the canvas is the host's only in-flow
|
|
4317
|
+
// child and the host's height is padding + canvas + padding.
|
|
4318
|
+
// Measuring the host to size the canvas is therefore circular:
|
|
4319
|
+
// every observation adds the padding back, resizes the canvas,
|
|
4320
|
+
// and re-triggers this observer. A 98px two-column panel walked
|
|
4321
|
+
// to ~4000px on load, growing by its own padding each pass.
|
|
4322
|
+
//
|
|
4323
|
+
// The live path is handled by onWinResize, which measures the
|
|
4324
|
+
// SUBTREE inside the canvas rather than the host — the one
|
|
4325
|
+
// measurement that is not downstream of the value being set.
|
|
4326
|
+
//
|
|
4327
|
+
// Guarded on the current mode rather than by not observing at
|
|
4328
|
+
// all, so a mid-session fallbackToSnapshot re-enables it with
|
|
4329
|
+
// no re-observation.
|
|
4330
|
+
if (mode === "live") return;
|
|
2653
4331
|
syncCanvasBox();
|
|
2654
4332
|
clearTimeout(resizeTimer);
|
|
2655
4333
|
resizeTimer = setTimeout(remeasureAndCapture, 150);
|
|
@@ -2709,10 +4387,122 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2709
4387
|
}
|
|
2710
4388
|
}
|
|
2711
4389
|
|
|
2712
|
-
|
|
4390
|
+
// A param change is DATA changing, and more than one thing may be
|
|
4391
|
+
// showing that data: the inspector panel, a code preview, a future node
|
|
4392
|
+
// editor. Whoever writes a value cannot know who else is displaying it,
|
|
4393
|
+
// so setParam announces rather than expecting callers to coordinate.
|
|
4394
|
+
//
|
|
4395
|
+
// A DOM CustomEvent rather than a subscriber list: consumers come and
|
|
4396
|
+
// go with the page, listeners unregister themselves, and nothing has to
|
|
4397
|
+
// hold a reference to a pipeline that may be rebuilt out from under it.
|
|
4398
|
+
const announce = (index, key, value, prev, how) => {
|
|
4399
|
+
if (typeof CustomEvent !== "function" || typeof document === "undefined") return;
|
|
4400
|
+
document.dispatchEvent(new CustomEvent(RASTER_PARAM_EVENT, {
|
|
4401
|
+
detail: { pipeline: handle, el, index, key, value, prev, how },
|
|
4402
|
+
}));
|
|
4403
|
+
};
|
|
4404
|
+
|
|
4405
|
+
// ── Live introspection (phase H3) ─────────────────────────────────
|
|
4406
|
+
// A param is LIVE if the op reads it in uniforms() — changing it is a
|
|
4407
|
+
// uniform upload. It is STRUCTURAL if the op reads it in code(),
|
|
4408
|
+
// because that value is compiled into the GLSL and the only way to
|
|
4409
|
+
// change it is to build a new shader. See `structural` on each
|
|
4410
|
+
// registry entry; anything unrecognised rebuilds, which is the safe
|
|
4411
|
+
// direction to be wrong in.
|
|
4412
|
+
const handle = {
|
|
2713
4413
|
canvas,
|
|
4414
|
+
// The FLATTENED node list — the one that owns the uniform slots,
|
|
4415
|
+
// so index i here is the `u<i>_` prefix in the shader and what an
|
|
4416
|
+
// inspector must show. Nodes nested in a `merge` appear inline.
|
|
4417
|
+
nodes: rasterNodes,
|
|
4418
|
+
get backend() { return mode; },
|
|
4419
|
+
|
|
4420
|
+
// Phase I1. Where the content visible at a screen point came
|
|
4421
|
+
// from, in client coordinates — the input to hit-testing through
|
|
4422
|
+
// a displacement. Defined below, next to the probe program.
|
|
4423
|
+
sourceAt: (clientX, clientY, opts) => sourceAt(clientX, clientY, opts),
|
|
4424
|
+
|
|
4425
|
+
// Phase T1. Transition progress. `t` is an INPUT, not a timer —
|
|
4426
|
+
// a timeline is one driver of it, scroll scrub and a test are
|
|
4427
|
+
// others. Everything downstream is a pure function of it, which
|
|
4428
|
+
// is what makes transitions deterministic to test and
|
|
4429
|
+
// interruptible for free.
|
|
4430
|
+
get progress() { return progress; },
|
|
4431
|
+
setProgress(t) {
|
|
4432
|
+
const next = Math.min(1, Math.max(0, Number(t) || 0));
|
|
4433
|
+
if (next === progress) return progress;
|
|
4434
|
+
progress = next;
|
|
4435
|
+
uploadKeyframes();
|
|
4436
|
+
// Phase T2, gap 1. The real NEW element is still in the tree
|
|
4437
|
+
// under the canvas, so without this it is visible at t=0 —
|
|
4438
|
+
// the morph would show the destination behind its own start
|
|
4439
|
+
// frame. Suppress its ink for the duration and release at
|
|
4440
|
+
// completion, which is also the moment the DOM becomes the
|
|
4441
|
+
// truth again: selection, hover and video resume on the real
|
|
4442
|
+
// element rather than on a picture of it.
|
|
4443
|
+
//
|
|
4444
|
+
// Driven off `progress` rather than a lifecycle callback, so
|
|
4445
|
+
// it stays a pure function of t (P-1) and a scrub backwards
|
|
4446
|
+
// out of 1 re-hides correctly.
|
|
4447
|
+
syncTransitionView();
|
|
4448
|
+
return progress;
|
|
4449
|
+
},
|
|
4450
|
+
|
|
4451
|
+
/**
|
|
4452
|
+
* Change one param of one node, live where possible.
|
|
4453
|
+
* Returns "uniform" | "rebuild" | false (unknown node).
|
|
4454
|
+
*/
|
|
4455
|
+
setParam(i, key, value) {
|
|
4456
|
+
const node = rasterNodes[i];
|
|
4457
|
+
if (!node || destroyed) return false;
|
|
4458
|
+
const prev = node[key];
|
|
4459
|
+
node[key] = value;
|
|
4460
|
+
|
|
4461
|
+
if (isStructuralChange(node.op, key, value, prev)) {
|
|
4462
|
+
handle.rebuild();
|
|
4463
|
+
announce(i, key, value, prev, "rebuild");
|
|
4464
|
+
return "rebuild";
|
|
4465
|
+
}
|
|
4466
|
+
const def = REGISTRY[node.op];
|
|
4467
|
+
if (def && def.uniforms) {
|
|
4468
|
+
const us = def.uniforms(node, dpr);
|
|
4469
|
+
gl.useProgram(prog);
|
|
4470
|
+
for (const k in us) setUniform(U(`u${i}_${k}`), us[k][0], us[k][1]);
|
|
4471
|
+
// The phase-I1 probe is a SECOND program running the same
|
|
4472
|
+
// chain, so it needs the new value too. Without this it
|
|
4473
|
+
// keeps answering with the parameters it was built with —
|
|
4474
|
+
// and since the inspector drives setParam on every drag,
|
|
4475
|
+
// hit-testing would silently drift away from the picture
|
|
4476
|
+
// the moment anyone tuned anything.
|
|
4477
|
+
if (probe && !probe.broken) {
|
|
4478
|
+
gl.useProgram(probe.prog);
|
|
4479
|
+
for (const k in us) setUniform(probe.U(`u${i}_${k}`), us[k][0], us[k][1]);
|
|
4480
|
+
gl.useProgram(prog);
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
announce(i, key, value, prev, "uniform");
|
|
4484
|
+
return "uniform";
|
|
4485
|
+
},
|
|
4486
|
+
|
|
4487
|
+
/**
|
|
4488
|
+
* Tear down and re-apply from the (mutated) node tree. Needed for
|
|
4489
|
+
* anything baked into the shader. `sourceNodes` is the ORIGINAL
|
|
4490
|
+
* tree, merge sub-chains and all — flattening loses that nesting,
|
|
4491
|
+
* and rebuilding from the flat list would silently promote a
|
|
4492
|
+
* merge branch to a top-level op.
|
|
4493
|
+
*/
|
|
4494
|
+
rebuild() {
|
|
4495
|
+
if (destroyed) return null;
|
|
4496
|
+
const host = el;
|
|
4497
|
+
handle.destroy();
|
|
4498
|
+
return applyRasterPipeline(host, sourceNodes);
|
|
4499
|
+
},
|
|
4500
|
+
};
|
|
4501
|
+
ACTIVE.add(handle);
|
|
4502
|
+
return Object.assign(handle, {
|
|
2714
4503
|
refresh: () => (mode === "live" ? onPaint() : snapshotCapture()),
|
|
2715
4504
|
destroy() {
|
|
4505
|
+
ACTIVE.delete(handle);
|
|
2716
4506
|
destroyed = true;
|
|
2717
4507
|
cancelAnimationFrame(raf);
|
|
2718
4508
|
clearTimeout(resizeTimer);
|
|
@@ -2722,6 +4512,11 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2722
4512
|
if (typeof window !== "undefined") window.removeEventListener("resize", onWinResize);
|
|
2723
4513
|
el.removeEventListener("mousemove", onMove);
|
|
2724
4514
|
el.removeEventListener("touchmove", onMove);
|
|
4515
|
+
// Phase I3 listeners are registered in the capture phase, so
|
|
4516
|
+
// they must be removed with the same flag or they leak.
|
|
4517
|
+
for (const t of RETARGET_EVENTS) el.removeEventListener(t, onRetarget, true);
|
|
4518
|
+
el.removeEventListener("pointerleave", onRetargetLeave, true);
|
|
4519
|
+
setHoverChain(null); // never leave the attribute behind
|
|
2725
4520
|
el.removeEventListener("mouseenter", onEnter);
|
|
2726
4521
|
el.removeEventListener("mouseleave", onLeave);
|
|
2727
4522
|
canvas.removeEventListener("paint", onPaint);
|
|
@@ -2730,17 +4525,44 @@ function applyRasterPipeline(el, rasterNodes) {
|
|
|
2730
4525
|
for (const n in s.progs) gl.deleteProgram(s.progs[n].prog);
|
|
2731
4526
|
}
|
|
2732
4527
|
solverOps.length = 0;
|
|
4528
|
+
// The probe pass owns a program, a 1x1 texture and an FBO —
|
|
4529
|
+
// only allocated if something hit-tested.
|
|
4530
|
+
if (probe && !probe.broken) {
|
|
4531
|
+
gl.deleteProgram(probe.prog);
|
|
4532
|
+
gl.deleteFramebuffer(probe.fbo);
|
|
4533
|
+
gl.deleteTexture(probe.tex);
|
|
4534
|
+
}
|
|
4535
|
+
probe = null;
|
|
4536
|
+
if (oldTex) { gl.deleteTexture(oldTex); oldTex = null; }
|
|
4537
|
+
if (newTex) { gl.deleteTexture(newTex); newTex = null; }
|
|
2733
4538
|
while (sourceEl !== el && sourceEl.firstChild) el.appendChild(sourceEl.firstChild);
|
|
2734
4539
|
showHostInk();
|
|
2735
4540
|
el.style.visibility = "";
|
|
2736
4541
|
canvas.remove();
|
|
2737
4542
|
},
|
|
2738
|
-
};
|
|
4543
|
+
});
|
|
2739
4544
|
}
|
|
2740
4545
|
|
|
2741
4546
|
export {
|
|
2742
4547
|
applyRasterPipeline, registerRasterOp, RASTER_OP_NAMES,
|
|
2743
4548
|
isHTMLInCanvasAvailable, DRIVER_NAMES,
|
|
4549
|
+
// Phase H3. The registry is already effectively public — registerRasterOp
|
|
4550
|
+
// mutates it — and a dev tool needs to read an op's stage and params to
|
|
4551
|
+
// show them. `isStructuralChange` is exported so the inspector can label
|
|
4552
|
+
// a control "rebuilds" before the user drags it, rather than after.
|
|
4553
|
+
activeRasterPipelines, isStructuralChange, REGISTRY, RASTER_PARAM_EVENT,
|
|
4554
|
+
// Phase H4. The op contract, as data: the vocabularies a third-party
|
|
4555
|
+
// op is checked against, and the shared params every op inherits from
|
|
4556
|
+
// the pipeline rather than declaring itself.
|
|
4557
|
+
RASTER_STAGES, RASTER_UNITS, FRAMEWORK_DOC, validateRasterOp,
|
|
4558
|
+
// Phase T1. Keyframed params, resolved against the units an op
|
|
4559
|
+
// already declares.
|
|
4560
|
+
isKeyframed, sampleKeyframes, resolveNode,
|
|
4561
|
+
// Phase T3. Choreography: per-node windows and easing over progress.
|
|
4562
|
+
EASINGS, EASING_NAMES, localProgress,
|
|
4563
|
+
// Phase I4. How each op behaves under hit-testing, derived from its
|
|
4564
|
+
// own declarations.
|
|
4565
|
+
interactionClass,
|
|
2744
4566
|
// Exported so which branch a `switch` takes can be asserted directly
|
|
2745
4567
|
// rather than inferred from pixels.
|
|
2746
4568
|
resolveSwitches,
|