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.
- package/README.md +2 -1
- package/dist/{chunk-JF3FK254.js → chunk-2XJJO7SC.js} +4 -4
- package/dist/{chunk-JF3FK254.js.map → chunk-2XJJO7SC.js.map} +1 -1
- package/dist/{chunk-3PGYF2NK.js → chunk-CTTZNZFV.js} +3 -3
- package/dist/{chunk-3PGYF2NK.js.map → chunk-CTTZNZFV.js.map} +1 -1
- package/dist/{chunk-IQOBZGCF.js → chunk-H5QTBF67.js} +30 -32
- package/dist/chunk-H5QTBF67.js.map +1 -0
- package/dist/{chunk-TTOKBARR.js → chunk-MMPK6DCK.js} +10 -3
- package/dist/chunk-MMPK6DCK.js.map +1 -0
- package/dist/{chunk-P2ZOODXD.js → chunk-SLRPHDV7.js} +3 -3
- package/dist/{chunk-P2ZOODXD.js.map → chunk-SLRPHDV7.js.map} +1 -1
- package/dist/dom.cjs +79 -52
- package/dist/dom.cjs.map +1 -1
- package/dist/dom.d.cts +27 -4
- package/dist/dom.d.ts +27 -4
- package/dist/dom.js +48 -26
- package/dist/dom.js.map +1 -1
- package/dist/{group-model-8u4GKrUu.d.cts → group-model-DwtixGhG.d.cts} +15 -6
- package/dist/{group-model-vYSmEVia.d.ts → group-model-z4N27qsC.d.ts} +15 -6
- package/dist/index.cjs +28 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -5
- package/dist/law.cjs +8 -1
- package/dist/law.cjs.map +1 -1
- package/dist/law.d.cts +7 -0
- package/dist/law.d.ts +7 -0
- package/dist/law.js +1 -1
- package/dist/reference.js +2 -2
- package/dist/web/index.cjs +1 -2
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.js +4 -4
- package/docs/law.md +2 -2
- package/package.json +11 -9
- package/src/dom/body.ts +24 -9
- package/src/dom/displacement.ts +7 -2
- package/src/dom/index.ts +52 -11
- package/src/dom/probe.ts +27 -3
- package/src/law.ts +7 -0
- package/src/sdf.ts +44 -36
- package/dist/chunk-IQOBZGCF.js.map +0 -1
- 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-
|
|
2
|
-
import { LENS_SHADER, toLensProps, toSurfaceUniforms, toDeviceSurfaceUniforms, DYNAMIC_UNIFORMS, ICON_UNIFORMS, OVERLAY_UNIFORMS } from '../chunk-
|
|
3
|
-
import { lensPadDp, surfacePadDp, shadowOpacity } from '../chunk-
|
|
4
|
-
import '../chunk-
|
|
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 —
|
|
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 **
|
|
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.
|
|
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.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
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(
|
|
106
|
+
const needed = separation / Math.max(Math.abs(pole - mean), 1e-4);
|
|
94
107
|
return {
|
|
95
|
-
density: Math.max(body.density, Math.min(needed,
|
|
96
|
-
|
|
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
|
}
|
package/src/dom/displacement.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
112
|
+
styleParent(host).appendChild(style);
|
|
91
113
|
}
|
|
92
114
|
|
|
93
|
-
function getFilterHost(): SVGSVGElement {
|
|
94
|
-
const existing =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
231
|
-
*
|
|
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
|
-
|
|
240
|
-
c?: MorphShape,
|
|
245
|
+
...shapes: (MorphShape | undefined)[]
|
|
241
246
|
): number {
|
|
242
|
-
|
|
243
|
-
if (smoothing <= 0
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
return
|
|
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
|
-
/**
|
|
252
|
-
*
|
|
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
|
-
|
|
261
|
-
c?: MorphShape,
|
|
268
|
+
...shapes: (MorphShape | undefined)[]
|
|
262
269
|
): [number, number] {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
//
|
|
284
|
-
//
|
|
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;
|