vireglass 3.1.0 → 3.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.
- package/README.md +8 -3
- package/dist/dom.cjs +43 -18
- package/dist/dom.cjs.map +1 -1
- package/dist/dom.d.cts +8 -2
- package/dist/dom.d.ts +8 -2
- package/dist/dom.js +44 -20
- package/dist/dom.js.map +1 -1
- package/package.json +1 -1
- package/src/dom/displacement.ts +9 -2
- package/src/dom/index.ts +51 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vireglass",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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",
|
package/src/dom/displacement.ts
CHANGED
|
@@ -78,6 +78,14 @@ export type DisplacementState = {
|
|
|
78
78
|
morphs?: readonly MorphShape[];
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The `feDisplacementMap` scale for these optics. The map's pixels are a fraction of it and do not
|
|
83
|
+
* depend on it, so a change of strength alone, such as appear, is this attribute and not a bake.
|
|
84
|
+
*/
|
|
85
|
+
export function displacementScale(optics: VireGlassOptics, geometry: VireGlassGeometry): number {
|
|
86
|
+
return Math.min(optics.refraction * thicknessDp(geometry, optics) * SCALE_GAIN, MAX_SCALE);
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
/**
|
|
82
90
|
* The raw pixel buffer, with no canvas involved — the part actually worth unit-testing.
|
|
83
91
|
* `buildDisplacementMap` below is a thin DOM wrapper around this.
|
|
@@ -97,8 +105,7 @@ export function renderDisplacementPixels(
|
|
|
97
105
|
const density = mapDensity(geometry, bevel, dpr);
|
|
98
106
|
const width = Math.max(1, Math.round(geometry.width * density));
|
|
99
107
|
const height = Math.max(1, Math.round(geometry.height * density));
|
|
100
|
-
const
|
|
101
|
-
const scale = Math.min(rawScale, MAX_SCALE);
|
|
108
|
+
const scale = displacementScale(optics, geometry);
|
|
102
109
|
const smoothing = state.smoothing ?? 0;
|
|
103
110
|
// One list, whichever way the caller expressed it.
|
|
104
111
|
const shapes: (MorphShape | undefined)[] = [state.morph, state.morph2, ...(state.morphs ?? [])];
|
package/src/dom/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// See `docs/superpowers/specs/2026-09-19-vireglass-over-live-dom.md` for the technique and the
|
|
13
13
|
// three findings that make it work (objectBoundingBox, prefiltering the source, the 8-bit step).
|
|
14
14
|
import { ambientFrom, INK_DARK, INK_LIGHT, shouldInkBeLight, type BackdropSample } from '../adaptation';
|
|
15
|
-
import { buildDisplacementMap, type DisplacementMap } from './displacement';
|
|
15
|
+
import { buildDisplacementMap, displacementScale, type DisplacementMap } from './displacement';
|
|
16
16
|
import { buildSpectralMap, hasSpectralEdge, type SpectralMap } from './spectral-map';
|
|
17
17
|
import { cached } from './map-cache';
|
|
18
18
|
import { roundedRectGeometry, type VireGlassGeometry } from '../geometry';
|
|
@@ -132,6 +132,11 @@ function getFilterHost(host: Document | ShadowRoot): SVGSVGElement {
|
|
|
132
132
|
return svg;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** Every apply writes the presence, and most applies do not change it. */
|
|
136
|
+
function writeAttr(node: Element, name: string, value: string): void {
|
|
137
|
+
if (node.getAttribute(name) !== value) node.setAttribute(name, value);
|
|
138
|
+
}
|
|
139
|
+
|
|
135
140
|
/**
|
|
136
141
|
* Finding #1 (do not re-litigate): `objectBoundingBox` with an explicit unit box. `userSpaceOnUse`
|
|
137
142
|
* measures from something other than the element's own box and the displacement smears across
|
|
@@ -254,6 +259,7 @@ function withSpectral(filter: SVGFilterElement, spectral: SpectralMap | null): S
|
|
|
254
259
|
product.setAttribute('in2', 'hue');
|
|
255
260
|
product.setAttribute('operator', 'arithmetic');
|
|
256
261
|
product.setAttribute('k1', String(spectral.headroom));
|
|
262
|
+
product.setAttribute('data-vireglass-headroom', String(spectral.headroom));
|
|
257
263
|
product.setAttribute('k2', '0');
|
|
258
264
|
product.setAttribute('k3', '0');
|
|
259
265
|
product.setAttribute('k4', '0');
|
|
@@ -299,7 +305,7 @@ function decoded(url: string): Promise<void> {
|
|
|
299
305
|
return typeof img.decode === 'function' ? img.decode().catch(() => undefined) : Promise.resolve();
|
|
300
306
|
}
|
|
301
307
|
|
|
302
|
-
type InterimMap = { map: DisplacementMap; ready: boolean };
|
|
308
|
+
type InterimMap = { map: DisplacementMap; ready: boolean; geometry: VireGlassGeometry };
|
|
303
309
|
|
|
304
310
|
/** Interim with no call for this long ends itself: a morph that never reported landing must not
|
|
305
311
|
* leave the glass on a stretched map for good. */
|
|
@@ -436,7 +442,8 @@ export type GlassHandle = {
|
|
|
436
442
|
* probing the backdrop, stops rebuilding its maps and keeps its filter: the existing map is
|
|
437
443
|
* stretched over the live box. A change of `setMorph` shapes still updates the map, at reduced
|
|
438
444
|
* resolution and only once the new image has decoded. Turning it off does one full-resolution
|
|
439
|
-
* rebuild and one probe.
|
|
445
|
+
* rebuild and one probe. With no call for 2 s it turns itself off; calling it on again restarts
|
|
446
|
+
* the 2 s, which is how a host keeps a gesture held still interim. Turning it off twice does nothing.
|
|
440
447
|
*/
|
|
441
448
|
setInterim(on: boolean): void;
|
|
442
449
|
/**
|
|
@@ -463,6 +470,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
463
470
|
let filterEl: SVGFilterElement | null = null;
|
|
464
471
|
let destroyed = false;
|
|
465
472
|
let mapKey = '';
|
|
473
|
+
// The box the shown map was baked for: its strength belongs to that map, also while interim stretches it.
|
|
474
|
+
let shownGeometry: VireGlassGeometry | null = null;
|
|
466
475
|
let interim = false;
|
|
467
476
|
let settling = false;
|
|
468
477
|
let lastSample: BackdropSample | null = null;
|
|
@@ -550,14 +559,34 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
550
559
|
apply();
|
|
551
560
|
}
|
|
552
561
|
|
|
553
|
-
function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null): void {
|
|
562
|
+
function retarget(f: SVGFilterElement, map: DisplacementMap, hue: SpectralMap | null, geometry: VireGlassGeometry): void {
|
|
554
563
|
const images = f.querySelectorAll('feImage');
|
|
555
564
|
images[0]?.setAttribute('href', map.url);
|
|
556
565
|
if (hue) images[1]?.setAttribute('href', hue.url);
|
|
566
|
+
shownGeometry = geometry;
|
|
567
|
+
present(f);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* The maps are baked at full presence and appear is these attributes. The spectral hue is
|
|
572
|
+
* `1 + appear * (hue - 1)`, so mixing the full one in is the same picture.
|
|
573
|
+
*/
|
|
574
|
+
function present(f: SVGFilterElement): void {
|
|
575
|
+
if (!shownGeometry) return;
|
|
576
|
+
const o = optics();
|
|
577
|
+
const scale = displacementScale(o, shownGeometry);
|
|
557
578
|
// EVERY pass, with its own share of the scale: see paintInteraction.
|
|
558
579
|
for (const pass of f.querySelectorAll('feDisplacementMap')) {
|
|
559
580
|
const ratio = Number(pass.getAttribute('data-vireglass-ratio') ?? 1);
|
|
560
|
-
pass
|
|
581
|
+
writeAttr(pass, 'scale', String(scale * (Number.isFinite(ratio) ? ratio : 1)));
|
|
582
|
+
}
|
|
583
|
+
const frost = f.querySelector('feGaussianBlur');
|
|
584
|
+
if (frost) writeAttr(frost, 'stdDeviation', String(Math.max(o.blur, 0)));
|
|
585
|
+
const hue = f.querySelector('feComposite[in2="hue"]');
|
|
586
|
+
if (hue) {
|
|
587
|
+
const headroom = Number(hue.getAttribute('data-vireglass-headroom') ?? 1);
|
|
588
|
+
writeAttr(hue, 'k1', String(headroom * appear));
|
|
589
|
+
writeAttr(hue, 'k2', String(1 - appear));
|
|
561
590
|
}
|
|
562
591
|
}
|
|
563
592
|
|
|
@@ -565,18 +594,18 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
565
594
|
if (!svgSupported || !filterEl || destroyed) return;
|
|
566
595
|
const geometry = opts.geometry ?? measureGeometry(el);
|
|
567
596
|
if (isEmpty(geometry)) return;
|
|
568
|
-
const
|
|
597
|
+
const full = settledOptics();
|
|
569
598
|
const key = interimKey(
|
|
570
599
|
geometry.width,
|
|
571
600
|
geometry.height,
|
|
572
601
|
geometry.cornerRadius,
|
|
573
602
|
JSON.stringify([morph?.smoothing, morph?.shape, morph?.shape2, morph?.shapes]),
|
|
574
|
-
|
|
603
|
+
`${full.refraction}:${full.refractionScale}:${full.bevelDp}`,
|
|
575
604
|
);
|
|
576
605
|
let rec = interimMaps.get(key);
|
|
577
606
|
if (!rec) {
|
|
578
607
|
bump('reducedBakes');
|
|
579
|
-
rec = { map: buildDisplacementMap(
|
|
608
|
+
rec = { map: buildDisplacementMap(full, geometry, Math.min(1, devicePixelRatio()), morphState()), ready: false, geometry };
|
|
580
609
|
interimMaps.set(key, rec);
|
|
581
610
|
}
|
|
582
611
|
const mine = (seq += 1);
|
|
@@ -585,7 +614,7 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
585
614
|
const show = () => {
|
|
586
615
|
if (destroyed || filterEl !== forFilter || mine <= shownSeq) return;
|
|
587
616
|
shownSeq = mine;
|
|
588
|
-
retarget(forFilter, record.map, null);
|
|
617
|
+
retarget(forFilter, record.map, null, record.geometry);
|
|
589
618
|
};
|
|
590
619
|
if (record.ready) {
|
|
591
620
|
show();
|
|
@@ -612,7 +641,8 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
612
641
|
// The map depends only on geometry, optics and density — never on the backdrop. Rebuilding
|
|
613
642
|
// it on every scroll would re-run a per-pixel loop and a PNG encode for a picture that did
|
|
614
643
|
// not change; the refraction itself updates in the compositor with no JS at all.
|
|
615
|
-
const
|
|
644
|
+
const full = settledOptics();
|
|
645
|
+
const key = `m${morphKey()}|${geometry.width}x${geometry.height}r${geometry.cornerRadius}@${devicePixelRatio()}:${full.refraction}:${full.refractionScale}:${full.bevelDp}:${full.blur}`;
|
|
616
646
|
if (interim && filterEl) {
|
|
617
647
|
// The filter keeps its map; feImage stretches it over the live box.
|
|
618
648
|
} else if (isEmpty(geometry)) {
|
|
@@ -621,15 +651,15 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
621
651
|
// Keyed by exactly what the maps are functions of, which is exactly the cache key above.
|
|
622
652
|
const map = cached(`d|${key}`, () => {
|
|
623
653
|
bump('fullBakes');
|
|
624
|
-
return buildDisplacementMap(
|
|
654
|
+
return buildDisplacementMap(full, geometry, devicePixelRatio(), morphState());
|
|
625
655
|
});
|
|
626
|
-
const hue = hasSpectralEdge(
|
|
656
|
+
const hue = hasSpectralEdge(full)
|
|
627
657
|
? cached(`h|${key}`, () => {
|
|
628
658
|
bump('fullBakes');
|
|
629
|
-
return buildSpectralMap(
|
|
659
|
+
return buildSpectralMap(full, geometry, devicePixelRatio());
|
|
630
660
|
})
|
|
631
661
|
: null;
|
|
632
|
-
const signature = filterSignature(
|
|
662
|
+
const signature = filterSignature(full.blur, full.ior, full.iorSpread, !!hue);
|
|
633
663
|
if (settling && filterEl?.getAttribute('data-vireglass-signature') === signature) {
|
|
634
664
|
settling = false;
|
|
635
665
|
const mine = (seq += 1);
|
|
@@ -638,21 +668,23 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
638
668
|
void Promise.all([decoded(map.url), hue ? decoded(hue.url) : undefined]).then(() => {
|
|
639
669
|
if (destroyed || filterEl !== forFilter || mapKey !== forKey || mine <= shownSeq) return;
|
|
640
670
|
shownSeq = mine;
|
|
641
|
-
retarget(forFilter, map, hue);
|
|
671
|
+
retarget(forFilter, map, hue, geometry);
|
|
642
672
|
});
|
|
643
673
|
} else {
|
|
644
674
|
settling = false;
|
|
645
675
|
invalidate();
|
|
646
676
|
const defs = getFilterHost(host).querySelector('defs') as SVGDefsElement;
|
|
647
|
-
const next = buildFilterElement(id, map.url,
|
|
677
|
+
const next = buildFilterElement(id, map.url, full, map.scale, hue);
|
|
648
678
|
next.setAttribute('data-vireglass-signature', signature);
|
|
649
679
|
if (filterEl?.parentNode) defs.replaceChild(next, filterEl);
|
|
650
680
|
else defs.appendChild(next);
|
|
651
681
|
bump('filterSwaps');
|
|
652
682
|
filterEl = next;
|
|
683
|
+
shownGeometry = geometry;
|
|
653
684
|
}
|
|
654
685
|
mapKey = key;
|
|
655
686
|
}
|
|
687
|
+
if (filterEl) present(filterEl);
|
|
656
688
|
el.style.setProperty('backdrop-filter', `url(#${id})`);
|
|
657
689
|
el.style.setProperty('-webkit-backdrop-filter', `url(#${id})`);
|
|
658
690
|
} else {
|
|
@@ -921,7 +953,6 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
921
953
|
if (next === appear) return;
|
|
922
954
|
appear = next;
|
|
923
955
|
apply();
|
|
924
|
-
if (interim) requestInterimMap();
|
|
925
956
|
},
|
|
926
957
|
setAccent(next) {
|
|
927
958
|
accent = next ?? undefined;
|
|
@@ -953,13 +984,14 @@ export function attachGlass(el: HTMLElement, opts: AttachGlassOptions = {}): Gla
|
|
|
953
984
|
apply();
|
|
954
985
|
},
|
|
955
986
|
setInterim(on) {
|
|
956
|
-
if (destroyed
|
|
987
|
+
if (destroyed) return;
|
|
957
988
|
if (on) {
|
|
989
|
+
// A gesture held still sends no geometry, so a repeat is the host saying it is still moving.
|
|
958
990
|
interim = true;
|
|
959
991
|
keepInterim();
|
|
960
992
|
return;
|
|
961
993
|
}
|
|
962
|
-
leaveInterim();
|
|
994
|
+
if (interim) leaveInterim();
|
|
963
995
|
},
|
|
964
996
|
setSample(next) {
|
|
965
997
|
supplied = next ?? undefined;
|