vireglass 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -1
  2. package/dist/{chunk-JF3FK254.js → chunk-2XJJO7SC.js} +4 -4
  3. package/dist/{chunk-JF3FK254.js.map → chunk-2XJJO7SC.js.map} +1 -1
  4. package/dist/{chunk-3PGYF2NK.js → chunk-CTTZNZFV.js} +3 -3
  5. package/dist/{chunk-3PGYF2NK.js.map → chunk-CTTZNZFV.js.map} +1 -1
  6. package/dist/{chunk-IQOBZGCF.js → chunk-H5QTBF67.js} +30 -32
  7. package/dist/chunk-H5QTBF67.js.map +1 -0
  8. package/dist/{chunk-TTOKBARR.js → chunk-MMPK6DCK.js} +10 -3
  9. package/dist/chunk-MMPK6DCK.js.map +1 -0
  10. package/dist/{chunk-P2ZOODXD.js → chunk-SLRPHDV7.js} +3 -3
  11. package/dist/{chunk-P2ZOODXD.js.map → chunk-SLRPHDV7.js.map} +1 -1
  12. package/dist/dom.cjs +79 -52
  13. package/dist/dom.cjs.map +1 -1
  14. package/dist/dom.d.cts +27 -4
  15. package/dist/dom.d.ts +27 -4
  16. package/dist/dom.js +48 -26
  17. package/dist/dom.js.map +1 -1
  18. package/dist/{group-model-8u4GKrUu.d.cts → group-model-DwtixGhG.d.cts} +15 -6
  19. package/dist/{group-model-vYSmEVia.d.ts → group-model-z4N27qsC.d.ts} +15 -6
  20. package/dist/index.cjs +28 -31
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +5 -5
  25. package/dist/law.cjs +8 -1
  26. package/dist/law.cjs.map +1 -1
  27. package/dist/law.d.cts +7 -0
  28. package/dist/law.d.ts +7 -0
  29. package/dist/law.js +1 -1
  30. package/dist/reference.js +2 -2
  31. package/dist/web/index.cjs +1 -2
  32. package/dist/web/index.cjs.map +1 -1
  33. package/dist/web/index.js +4 -4
  34. package/docs/law.md +2 -2
  35. package/package.json +11 -9
  36. package/src/dom/body.ts +24 -9
  37. package/src/dom/displacement.ts +7 -2
  38. package/src/dom/index.ts +52 -11
  39. package/src/dom/probe.ts +27 -3
  40. package/src/law.ts +7 -0
  41. package/src/sdf.ts +44 -36
  42. package/dist/chunk-IQOBZGCF.js.map +0 -1
  43. package/dist/chunk-TTOKBARR.js.map +0 -1
package/dist/web/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { toGLSL, SURFACE_SHADER } from '../chunk-JF3FK254.js';
2
- import { LENS_SHADER, toLensProps, toSurfaceUniforms, toDeviceSurfaceUniforms, DYNAMIC_UNIFORMS, ICON_UNIFORMS, OVERLAY_UNIFORMS } from '../chunk-IQOBZGCF.js';
3
- import { lensPadDp, surfacePadDp, shadowOpacity } from '../chunk-P2ZOODXD.js';
4
- import '../chunk-TTOKBARR.js';
1
+ import { toGLSL, SURFACE_SHADER } from '../chunk-2XJJO7SC.js';
2
+ import { LENS_SHADER, toLensProps, toSurfaceUniforms, toDeviceSurfaceUniforms, DYNAMIC_UNIFORMS, ICON_UNIFORMS, OVERLAY_UNIFORMS } from '../chunk-H5QTBF67.js';
3
+ import { lensPadDp, surfacePadDp, shadowOpacity } from '../chunk-SLRPHDV7.js';
4
+ import '../chunk-MMPK6DCK.js';
5
5
 
6
6
  // src/web/gl.ts
7
7
  var FULLSCREEN_TRIANGLE_VERTEX_SOURCE = `#version 300 es
package/docs/law.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # The law
2
2
 
3
- `src/law.ts` holds every calibrated number in the material — 120 of them, in 20 groups —
3
+ `src/law.ts` holds every calibrated number in the material — 121 of them, in 20 groups —
4
4
  and nothing else in the source is allowed to hold one. Renderers, shaders and helpers read from
5
5
  it; the shader templates interpolate it into their GLSL and AGSL, so the same constant reaches
6
6
  the web, Android and the DOM path without being copied.
@@ -42,7 +42,7 @@ It checks three things, and reports a fourth:
42
42
  - and it names every unmeasured value, every run — and fails if this page or the README states a
43
43
  different number, because a count written out in prose goes stale the moment a value is added.
44
44
 
45
- Today that last line reads **42 of 120**. That is the real state of the model, and it is meant to
45
+ Today that last line reads **43 of 121**. That is the real state of the model, and it is meant to
46
46
  be uncomfortable. It was 9 until the constants hiding inside `optics.ts`, `geometry.ts` and
47
47
  `touch-response.ts` were brought in — the number did not get worse, the instrument got honest.
48
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vireglass",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "A physically-derived liquid glass material for interfaces. One material model, three render targets: WebGL2, live DOM, and Android AGSL.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Danil Kotlaev",
@@ -94,7 +94,8 @@
94
94
  "measure:dom": "node scripts/measure-dom.mjs",
95
95
  "prepare": "tsup",
96
96
  "check:package": "node scripts/check-package.mjs",
97
- "check:readme": "node scripts/check-readme.mjs"
97
+ "check:readme": "node scripts/check-readme.mjs",
98
+ "check:agsl": "tsx scripts/check-agsl.mjs"
98
99
  },
99
100
  "peerDependencies": {
100
101
  "@shopify/react-native-skia": ">=2.0.0",
@@ -129,20 +130,21 @@
129
130
  }
130
131
  },
131
132
  "devDependencies": {
133
+ "@shopify/react-native-skia": "2.6.2",
132
134
  "@types/node": "^22.10.0",
133
135
  "@types/react": "^19.0.0",
136
+ "canvaskit-wasm": "^0.42.0",
134
137
  "esbuild": "^0.28.2",
135
- "playwright": "^1.62.1",
136
- "tsup": "^8.5.0",
137
- "tsx": "^4.23.13",
138
- "typescript": "^5.8.3",
139
- "vitest": "^4.1.11",
140
- "@shopify/react-native-skia": "2.6.2",
141
138
  "expo": "~57.0.18",
142
139
  "expo-blur": "~57.0.2",
143
140
  "expo-sensors": "~57.0.2",
141
+ "playwright": "^1.62.1",
144
142
  "react": "19.2.3",
145
143
  "react-native": "0.86.2",
146
- "react-native-reanimated": "4.5.1"
144
+ "react-native-reanimated": "4.5.1",
145
+ "tsup": "^8.5.0",
146
+ "tsx": "^4.23.13",
147
+ "typescript": "^5.8.3",
148
+ "vitest": "^4.1.11"
147
149
  }
148
150
  }
package/src/dom/body.ts CHANGED
@@ -78,21 +78,36 @@ export function resolveBody(optics: VireGlassOptics, sample: BodyBackdrop, ink:
78
78
 
79
79
  /**
80
80
  * PRESENCE (§3): over a uniform backdrop there is nothing to refract and the glass honestly
81
- * disappears — right for a piece of background, wrong for a control. The sign comes from the
82
- * BACKDROP, not from ink polarity: lighter over dark, darker over light. It is a minimum, not an
83
- * addition, so a body that is already dense enough is left alone.
81
+ * disappears — right for a piece of background, wrong for a control. It is a MINIMUM, not an
82
+ * addition, so a body that already stands apart is left alone.
83
+ *
84
+ * The direction is the body's own. The previous version decided "lighter over dark, darker over
85
+ * light" from the backdrop alone, and then, when the body disagreed, clamped `tintLuma` to the
86
+ * backdrop's mean — the one value that separates from nothing. Light ink over a dark backdrop puts
87
+ * the body at 0.07 against a backdrop of 0.12: already separating, downward, and the clamp pulled
88
+ * it back onto the backdrop. Measured on the default material at presence 0.08: exactly 0.0000 at
89
+ * backdrops of 0.12, 0.20 and 0.35.
90
+ *
91
+ * What the demand is DIVIDED by is deliberately the distance to the tint's pole rather than to the
92
+ * body's own tint. Dividing by the latter is arithmetically tighter and asks for far more density
93
+ * wherever the two are close — enough to drive it to the ceiling over a dark backdrop and stop the
94
+ * glass being a window at all, which check:optics says plainly. Presence is a floor on visibility,
95
+ * not a licence to go opaque.
84
96
  */
85
97
  export function withPresence(body: GlassBody, optics: VireGlassOptics, sample: BodyBackdrop): GlassBody {
86
98
  if (optics.presence <= 0) return body;
87
99
  const mean = clamp(sample.luma, 0, 1);
88
- const lighter = mean < 0.5;
89
- const target = lighter ? Math.min(mean + optics.presence, 1) : Math.max(mean - optics.presence, 0);
90
- const separation = Math.abs(target - mean);
100
+ // Whichever side the body is already on; with nothing to go on, the side with more room.
101
+ const above = Math.abs(body.tintLuma - mean) > 1e-4 ? body.tintLuma > mean : mean < 0.5;
102
+ const pole = above ? TINT_LIGHT : TINT_DARK;
103
+ const separation = Math.min(optics.presence, Math.abs(pole - mean));
91
104
  const reached = Math.abs(body.tintLuma - mean) * body.density;
92
105
  if (reached >= separation) return body;
93
- const needed = separation / Math.max(Math.abs((lighter ? TINT_LIGHT : TINT_DARK) - mean), 1e-4);
106
+ const needed = separation / Math.max(Math.abs(pole - mean), 1e-4);
94
107
  return {
95
- density: Math.max(body.density, Math.min(needed, MAX_DEMAND)),
96
- tintLuma: lighter ? Math.max(body.tintLuma, mean) : Math.min(body.tintLuma, mean),
108
+ density: Math.max(body.density, Math.min(needed, BODY.presenceDemand)),
109
+ // Left where the tint law put it, unless it landed exactly on the backdrop — which is the
110
+ // case that used to be created here rather than avoided.
111
+ tintLuma: Math.abs(body.tintLuma - mean) > 1e-4 ? body.tintLuma : pole,
97
112
  };
98
113
  }
@@ -73,6 +73,9 @@ export type DisplacementState = {
73
73
  smoothing?: number;
74
74
  morph?: MorphShape;
75
75
  morph2?: MorphShape;
76
+ /** Any number of shapes flowing into this one. `morph`/`morph2` are the first two, kept so a
77
+ * caller that only ever had two does not have to change. */
78
+ morphs?: readonly MorphShape[];
76
79
  };
77
80
 
78
81
  /**
@@ -97,6 +100,8 @@ export function renderDisplacementPixels(
97
100
  const rawScale = optics.refraction * thicknessDp(geometry, optics) * SCALE_GAIN;
98
101
  const scale = Math.min(rawScale, MAX_SCALE);
99
102
  const smoothing = state.smoothing ?? 0;
103
+ // One list, whichever way the caller expressed it.
104
+ const shapes: (MorphShape | undefined)[] = [state.morph, state.morph2, ...(state.morphs ?? [])];
100
105
 
101
106
  const data = new Uint8ClampedArray(width * height * 4);
102
107
  const halfW = geometry.width / 2;
@@ -109,7 +114,7 @@ export function renderDisplacementPixels(
109
114
  const i = (py * width + px) * 4;
110
115
  // Warp the FIELD first, then read the scene at the warped point — the shader's own order.
111
116
  const [wx, wy] = state.touch ? touchWarp(x, y, state.touch) : [x, y];
112
- const sd = sceneDistance(wx, wy, geometry.width, geometry.height, geometry.cornerRadius, smoothing, state.morph, state.morph2);
117
+ const sd = sceneDistance(wx, wy, geometry.width, geometry.height, geometry.cornerRadius, smoothing, ...shapes);
113
118
  const t = Math.min(Math.max((sd + bevel) / bevel, 0), 1);
114
119
  if (t <= 0 || scale <= 0) {
115
120
  data[i] = 128;
@@ -118,7 +123,7 @@ export function renderDisplacementPixels(
118
123
  data[i + 3] = 255;
119
124
  continue;
120
125
  }
121
- const [gx, gy] = sceneGradient(wx, wy, geometry.width, geometry.height, geometry.cornerRadius, smoothing, state.morph, state.morph2);
126
+ const [gx, gy] = sceneGradient(wx, wy, geometry.width, geometry.height, geometry.cornerRadius, smoothing, ...shapes);
122
127
  // The rim reaches half the scale: the encoding can only span ±scale/2, so the derived
123
128
  // displacement is expressed as a fraction of that half-range.
124
129
  const magnitude = bevelProfile(t) * (scale / 2);
package/src/dom/index.ts CHANGED
@@ -78,8 +78,30 @@ const STYLE_ATTR = 'data-vireglass-styles';
78
78
  * The mask pair is what makes it a ring rather than a fill: the same box painted twice, once
79
79
  * clipped to the content box, composited so only the padding band survives.
80
80
  */
81
- function ensureRimStyles(): void {
82
- if (document.querySelector(`style[${STYLE_ATTR}]`)) return;
81
+ /**
82
+ * Where the filter and the rim styles go.
83
+ *
84
+ * `backdrop-filter: url(#id)` resolves the reference in the element's OWN tree. From inside a
85
+ * shadow root, a filter parked on the page is not found — and the failure is silent, because the
86
+ * reference is valid CSS: the element gets no backdrop at all, not even the blur fallback. A
87
+ * widget in a shadow root (which is how you survive a page full of global `!important`) came out
88
+ * with no material whatsoever.
89
+ *
90
+ * So the host is the element's own root node, which is the shadow root when there is one and the
91
+ * document otherwise. Nothing to configure and nothing to get wrong.
92
+ */
93
+ function hostFor(el: Element): Document | ShadowRoot {
94
+ const root = el.getRootNode();
95
+ return root instanceof ShadowRoot ? root : el.ownerDocument ?? document;
96
+ }
97
+
98
+ /** A shadow root has no `<head>`; a style element goes straight into it. */
99
+ function styleParent(host: Document | ShadowRoot): Node {
100
+ return host instanceof ShadowRoot ? host : host.head;
101
+ }
102
+
103
+ function ensureRimStyles(host: Document | ShadowRoot): void {
104
+ if (host.querySelector(`style[${STYLE_ATTR}]`)) return;
83
105
  const style = document.createElement('style');
84
106
  style.setAttribute(STYLE_ATTR, '');
85
107
  style.textContent =
@@ -87,11 +109,11 @@ function ensureRimStyles(): void {
87
109
  // The touch glow (§5) lights the material from the point of contact. Below the content, above
88
110
  // the refraction — it is light inside the glass, not a film over the label.
89
111
  `[${GLASS_ATTR}]::before{content:'';position:absolute;inset:0;border-radius:inherit;pointer-events:none;background:var(--vireglass-glow,none);opacity:var(--vireglass-glow-opacity,0)}`;
90
- document.head.appendChild(style);
112
+ styleParent(host).appendChild(style);
91
113
  }
92
114
 
93
- function getFilterHost(): SVGSVGElement {
94
- const existing = document.querySelector<SVGSVGElement>(`svg[${FILTER_HOST_ATTR}]`);
115
+ function getFilterHost(host: Document | ShadowRoot): SVGSVGElement {
116
+ const existing = host.querySelector<SVGSVGElement>(`svg[${FILTER_HOST_ATTR}]`);
95
117
  if (existing) return existing;
96
118
  const svg = document.createElementNS(SVG_NS, 'svg');
97
119
  svg.setAttribute(FILTER_HOST_ATTR, '');
@@ -101,7 +123,8 @@ function getFilterHost(): SVGSVGElement {
101
123
  svg.style.position = 'absolute';
102
124
  svg.style.pointerEvents = 'none';
103
125
  svg.appendChild(document.createElementNS(SVG_NS, 'defs'));
104
- document.body.appendChild(svg);
126
+ // A shadow root takes the svg directly; a document takes it on the body.
127
+ (host instanceof ShadowRoot ? host : host.body).appendChild(svg);
105
128
  return svg;
106
129
  }
107
130
 
@@ -165,6 +188,9 @@ function buildFilterElement(
165
188
  pass.setAttribute('xChannelSelector', 'R');
166
189
  pass.setAttribute('yChannelSelector', 'G');
167
190
  pass.setAttribute('result', `${name}raw`);
191
+ // Каждый канал смещается на свою долю базового масштаба. Записываем её в сам элемент:
192
+ // при нажатии масштаб пересчитывается каждый кадр, и восстановить доли иначе неоткуда.
193
+ pass.setAttribute('data-vireglass-ratio', String(scale > 0 ? chScale / scale : 1));
168
194
  const only = document.createElementNS(SVG_NS, 'feColorMatrix');
169
195
  only.setAttribute('in', `${name}raw`);
170
196
  only.setAttribute('type', 'matrix');
@@ -344,6 +370,14 @@ export type GlassMorph = {
344
370
  smoothing: number;
345
371
  shape?: MorphShape;
346
372
  shape2?: MorphShape;
373
+ /**
374
+ * Any number of shapes flowing into this one. Several independent elements becoming one whole is
375
+ * the behaviour; how many of them there are is the host's business, and it used to be the
376
+ * material's — the count lived in the field NAMES, so a fourth element had nowhere to go.
377
+ *
378
+ * `shape` and `shape2` still work and come first.
379
+ */
380
+ shapes?: readonly MorphShape[];
347
381
  };
348
382
 
349
383
  export type GlassHandle = {
@@ -395,7 +429,9 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
395
429
 
396
430
  warnIfNested(el);
397
431
 
398
- ensureRimStyles();
432
+ // The element's own tree: a shadow root when it is in one, the document otherwise.
433
+ const host = hostFor(el);
434
+ ensureRimStyles(host);
399
435
  el.setAttribute(GLASS_ATTR, '');
400
436
  // `inset: 0` on the rim needs a positioned ancestor. Only taken over when the host left it at
401
437
  // the initial value, and put back on destroy.
@@ -419,7 +455,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
419
455
 
420
456
  function morphState() {
421
457
  if (!morph) return {};
422
- return { smoothing: morph.smoothing, morph: morph.shape, morph2: morph.shape2 };
458
+ return { smoothing: morph.smoothing, morph: morph.shape, morph2: morph.shape2, morphs: morph.shapes };
423
459
  }
424
460
 
425
461
  function morphKey(): string {
@@ -443,7 +479,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
443
479
  if (key !== mapKey || !filterEl) {
444
480
  // Keyed by exactly what the maps are functions of, which is exactly the cache key above.
445
481
  const map = cached(`d|${key}`, () => buildDisplacementMap(opticsNow, geometry, devicePixelRatio(), morphState()));
446
- const defs = getFilterHost().querySelector('defs') as SVGDefsElement;
482
+ const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
447
483
  const hue = hasSpectralEdge(opticsNow)
448
484
  ? cached(`h|${key}`, () => buildSpectralMap(opticsNow, geometry, devicePixelRatio()))
449
485
  : null;
@@ -543,9 +579,14 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
543
579
  });
544
580
  if (filterEl) {
545
581
  const feImage = filterEl.querySelector('feImage');
546
- const feDisplace = filterEl.querySelector('feDisplacementMap');
547
582
  feImage?.setAttribute('href', map.url);
548
- feDisplace?.setAttribute('scale', String(map.scale));
583
+ // ВСЕ проходы, а не первый. При дисперсии их три, с разными масштабами, и querySelector
584
+ // возвращал только красный: под пальцем он получал неразделённый масштаб, а зелёный с синим
585
+ // оставались на том, что был при подключении. Цветной край переставал следовать за пальцем.
586
+ for (const pass of filterEl.querySelectorAll('feDisplacementMap')) {
587
+ const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
588
+ pass.setAttribute('scale', String(map.scale * (Number.isFinite(ratio) ? ratio : 1)));
589
+ }
549
590
  }
550
591
  mapKey = '';
551
592
 
package/src/dom/probe.ts CHANGED
@@ -126,11 +126,35 @@ function sampleColorAt(el: HTMLElement, x: number, y: number): RgbColor | null {
126
126
  }
127
127
  const composited = compositeLayers(layers);
128
128
  if (composited) return composited;
129
- const topmost = behind[0];
130
- if (!topmost) return null;
131
- return parseColor(getComputedStyle(topmost).color);
129
+ return canvasColor(el);
132
130
  }
133
131
 
132
+ /**
133
+ * The page's own background — the floor everything else is composited over.
134
+ *
135
+ * Reached whenever nothing in the stack is opaque, which is not an edge case: CSS propagates the
136
+ * body's background to the CANVAS, so `getComputedStyle` reports `<html>` and often `<body>` as
137
+ * transparent on a perfectly ordinary white page. Any element past the end of the body's box —
138
+ * a widget pinned to a corner, say — sees a stack of nothing.
139
+ *
140
+ * This used to fall back to the topmost element's `color`, which is its TEXT colour: a white page
141
+ * with black text read as black, and the material dressed itself for a dark backdrop over a light
142
+ * one. The propagation rule says where to look instead — the root element's background, then the
143
+ * body's, then the user agent's white.
144
+ */
145
+ function canvasColor(el: HTMLElement): RgbColor | null {
146
+ const doc = el.ownerDocument;
147
+ for (const node of [doc.documentElement, doc.body]) {
148
+ if (!node) continue;
149
+ const bg = parseColor(getComputedStyle(node).backgroundColor);
150
+ if (bg && bg.a > 0.001) return bg.a >= 0.999 ? bg : compositeLayers([bg, WHITE]);
151
+ }
152
+ return WHITE;
153
+ }
154
+
155
+ /** What a browser paints the canvas when nobody says otherwise. */
156
+ const WHITE: RgbColor = { r: 255, g: 255, b: 255, a: 1 };
157
+
134
158
  export type ProbeOptions = {
135
159
  /** Grid density override — wider for a hero element, narrower for a tight control. */
136
160
  cols?: number;
package/src/law.ts CHANGED
@@ -60,6 +60,13 @@ export const BODY = {
60
60
  darkSideTo: 0.58,
61
61
  /** Ceiling on any single demand, so one requirement cannot make the glass opaque on its own. */
62
62
  maxDemand: 0.92,
63
+ /**
64
+ * And a tighter one for presence specifically. It is a FLOOR on visibility, not a licence to go
65
+ * opaque: wherever the body's tint sits close to the backdrop, the density that would deliver the
66
+ * separation is the ceiling, and glass at `maxDemand` has stopped being a window. Better to
67
+ * separate as far as transparency allows and no further. UNMEASURED.
68
+ */
69
+ presenceDemand: 0.5,
63
70
  } as const;
64
71
 
65
72
  /** Shadow (§4). The density law lives in `geometry.ts`; these are its measured ends. */
package/src/sdf.ts CHANGED
@@ -227,8 +227,14 @@ export type MorphShape = {
227
227
  cornerRadius: number;
228
228
  };
229
229
 
230
- /** Mirrors the scene distance: one shape, smoothly unioned with up to two neighbours when
231
- * `smoothing` is positive. §5's merging and splitting are this union opening and closing. */
230
+ /**
231
+ * Mirrors the scene distance: one shape, smoothly unioned with any number of neighbours when
232
+ * `smoothing` is positive. §5's merging and splitting are this union opening and closing.
233
+ *
234
+ * A fold, not a pair. The count used to be in the ARGUMENT NAMES — `b` and `c`, and a fourth
235
+ * shape would have been `d` — which is not a limit the material has. Several independent elements
236
+ * flowing into one whole is the behaviour; how many of them there happen to be is the host's.
237
+ */
232
238
  export function sceneDistance(
233
239
  x: number,
234
240
  y: number,
@@ -236,20 +242,22 @@ export function sceneDistance(
236
242
  h: number,
237
243
  r: number,
238
244
  smoothing = 0,
239
- b?: MorphShape,
240
- c?: MorphShape,
245
+ ...shapes: (MorphShape | undefined)[]
241
246
  ): number {
242
- const a = sdfRoundedRect(x, y, w, h, r);
243
- if (smoothing <= 0 || !b || b.width <= 0) return a;
244
- const db = sdfRoundedRect(x - b.offsetX, y - b.offsetY, b.width, b.height, b.cornerRadius);
245
- const ab = smin(a, db, smoothing);
246
- if (!c || c.width <= 0) return ab;
247
- const dc = sdfRoundedRect(x - c.offsetX, y - c.offsetY, c.width, c.height, c.cornerRadius);
248
- return smin(ab, dc, smoothing);
247
+ let d = sdfRoundedRect(x, y, w, h, r);
248
+ if (smoothing <= 0) return d;
249
+ for (const s of shapes) {
250
+ if (!s || s.width <= 0) continue;
251
+ d = smin(d, sdfRoundedRect(x - s.offsetX, y - s.offsetY, s.width, s.height, s.cornerRadius), smoothing);
252
+ }
253
+ return d;
249
254
  }
250
255
 
251
- /** Mirrors `vgSceneNormal`: normals blend with the same weight that blends the distances, which
252
- * the shader derives rather than approximates. */
256
+ /**
257
+ * Mirrors `vgSceneNormal`: normals blend with the same weight that blends the distances, which the
258
+ * shader derives rather than approximates. The same fold as `sceneDistance`, carrying the running
259
+ * distance alongside the running normal because each step's weight is measured against it.
260
+ */
253
261
  export function sceneGradient(
254
262
  x: number,
255
263
  y: number,
@@ -257,31 +265,31 @@ export function sceneGradient(
257
265
  h: number,
258
266
  r: number,
259
267
  smoothing = 0,
260
- b?: MorphShape,
261
- c?: MorphShape,
268
+ ...shapes: (MorphShape | undefined)[]
262
269
  ): [number, number] {
263
- const na = sdfRoundedRectGradient(x, y, w, h, r);
264
- if (smoothing <= 0 || !b || b.width <= 0) return na;
265
- const bx = x - b.offsetX;
266
- const by = y - b.offsetY;
267
- const da = sdfRoundedRect(x, y, w, h, r);
268
- const db = sdfRoundedRect(bx, by, b.width, b.height, b.cornerRadius);
269
- const hb = Math.min(Math.max(0.5 + (0.5 * (db - da)) / smoothing, 0), 1);
270
- const nb = sdfRoundedRectGradient(bx, by, b.width, b.height, b.cornerRadius);
271
- let gx = nb[0] + (na[0] - nb[0]) * hb;
272
- let gy = nb[1] + (na[1] - nb[1]) * hb;
273
- if (c && c.width > 0) {
274
- const cx = x - c.offsetX;
275
- const cy = y - c.offsetY;
276
- const dab = smin(da, db, smoothing);
277
- const dc = sdfRoundedRect(cx, cy, c.width, c.height, c.cornerRadius);
278
- const hc = Math.min(Math.max(0.5 + (0.5 * (dc - dab)) / smoothing, 0), 1);
279
- const nc = sdfRoundedRectGradient(cx, cy, c.width, c.height, c.cornerRadius);
280
- gx = nc[0] + (gx - nc[0]) * hc;
281
- gy = nc[1] + (gy - nc[1]) * hc;
270
+ let d = sdfRoundedRect(x, y, w, h, r);
271
+ let [gx, gy] = sdfRoundedRectGradient(x, y, w, h, r);
272
+ let blended = false;
273
+ if (smoothing > 0) {
274
+ for (const s of shapes) {
275
+ if (!s || s.width <= 0) continue;
276
+ const sx = x - s.offsetX;
277
+ const sy = y - s.offsetY;
278
+ const ds = sdfRoundedRect(sx, sy, s.width, s.height, s.cornerRadius);
279
+ const weight = Math.min(Math.max(0.5 + (0.5 * (ds - d)) / smoothing, 0), 1);
280
+ const [nx, ny] = sdfRoundedRectGradient(sx, sy, s.width, s.height, s.cornerRadius);
281
+ gx = nx + (gx - nx) * weight;
282
+ gy = ny + (gy - ny) * weight;
283
+ d = smin(d, ds, smoothing);
284
+ blended = true;
285
+ }
282
286
  }
283
- // The shader's `+ float2(${EPSILON}, ${EPSILON})` before normalising, and it is not decoration: where two
284
- // shapes meet head-on their normals cancel exactly and the blend lands on zero.
287
+ // With nothing blended in, the base gradient is already a unit vector and is returned untouched —
288
+ // the shader takes the same early exit (`if (k <= 0.0) return na;`), and nudging it here instead
289
+ // would put the two renderers a rounding step apart on every flat edge in the material.
290
+ if (!blended) return [gx, gy];
291
+ // The shader's `+ float2(${EPSILON}, ${EPSILON})` before normalising, and it is not decoration:
292
+ // where two shapes meet head-on their normals cancel exactly and the blend lands on zero.
285
293
  gx += EPSILON;
286
294
  gy += EPSILON;
287
295
  const len = Math.hypot(gx, gy) || 1;