@johnmorrisdotca/kazoku-ui 0.7.2 → 0.9.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/CHANGELOG.md +30 -0
- package/README.md +33 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/renderer/cache.d.ts +38 -0
- package/dist/renderer/cache.js +104 -0
- package/dist/renderer/index.d.ts +5 -0
- package/dist/renderer/index.js +7 -0
- package/dist/renderer/pixels.d.ts +16 -0
- package/dist/renderer/pixels.js +25 -0
- package/dist/renderer/renderer.d.ts +118 -0
- package/dist/renderer/renderer.js +472 -0
- package/dist/renderer/scheduler.d.ts +42 -0
- package/dist/renderer/scheduler.js +91 -0
- package/dist/renderer/stills.d.ts +27 -0
- package/dist/renderer/stills.js +50 -0
- package/dist/testing/index.d.ts +34 -0
- package/dist/testing/index.js +64 -0
- package/dist/view-controls/address.d.ts +4 -0
- package/dist/view-controls/address.js +9 -1
- package/dist/view-controls/element.d.ts +1 -0
- package/dist/view-controls/element.js +101 -12
- package/dist/view-controls/index.d.ts +1 -0
- package/dist/view-controls/index.js +1 -0
- package/dist/view-controls/levels.d.ts +12 -0
- package/dist/view-controls/levels.js +9 -0
- package/dist/view-controls/styles.d.ts +1 -1
- package/dist/view-controls/styles.js +13 -5
- package/dist/view-controls/words.d.ts +7 -0
- package/dist/view-controls/words.js +14 -0
- package/package.json +26 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-11-02
|
|
4
|
+
|
|
5
|
+
The sixth and seventh parts: the shared 3D renderer, at `@johnmorrisdotca/kazoku-ui/renderer`, and the freeze checks for a 3D package's e2e, at `@johnmorrisdotca/kazoku-ui/testing`.
|
|
6
|
+
|
|
7
|
+
- **One WebGL context per page.** `createSharedRenderer({ load: () => import("three") })` makes one renderer and moves its canvas to the focused view's host (`view.focus()`); every other view shows a still. Thirty views and fifty changes of focus make one context and one canvas. three.js is an optional peer (`peerDependencies`, `peerDependenciesMeta`) and is never imported or bundled by this package: the page passes the dynamic import, so three loads only when the first view goes live (a demo page checks it).
|
|
8
|
+
- **Shared assets with reference counts** (`createAssetCache`, and `assets.acquire(key, make, dispose)` inside an item's `build`): made once per key, freed after a short linger when the last view lets go; `disposeDeep` frees a loaded model.
|
|
9
|
+
- **Stills** (`view.showStill()`): rendered once from the same renderer, one at a time, into a PNG blob in an `<img>`; items with the same key share one; a least-recently-used list with a byte budget (32 MB decoded) frees the rest, and an evicted still is made again when needed.
|
|
10
|
+
- **Drawing on demand** (`createScheduler`): one frame loop for every view, nothing drawn or requested at rest, exactly one frame requested at a time (a change event inside a draw adds none), a per-frame budget (2 draws, 10 ms) with the focused view first, and no frames while the view is out of sight or the tab hidden. After `releaseMs` (30 s) out of sight the context is let go and made again when the view is seen.
|
|
11
|
+
- **Pixel budget in one place** (`pixelRatioFor`): 6 million pixels, 4096 a side, ratio 2; a 5K screen at ratio 2 is drawn inside it, not at 24 MP.
|
|
12
|
+
- **Context loss** (`webglcontextlost` and `webglcontextrestored`) is handled in one place: drawing stops and the still shows, then the view draws again; `onContext` is told.
|
|
13
|
+
- **`/testing`**: `COUNT_SCRIPT` (an init script that counts WebGL contexts, GPU objects made and deleted, draws and animation frames), `readCounts`, `gpuObjects`, `canvasSize`, `withinPixelBudget`. It names no test framework.
|
|
14
|
+
- Demo `renderer.html` (thirty views on one renderer, Fill the window) and `renderer-lazy.html`; `e2e/renderer.demo.mjs` asserts contexts = 1 over fifty focus changes, 0 draws and 0 frame requests at rest, flat GPU objects, cache and heap over fifty more, the canvas within budget on a 5120x2880 screen at ratio 2, a lost and restored context, out of sight, the stills' byte budget, and three.js fetched only on demand. Docs: `docs/renderer.md`. Size: 8.9 KB gzipped for the renderer and 1.6 KB for the kit.
|
|
15
|
+
|
|
16
|
+
## [0.8.0] - 2026-11-02
|
|
17
|
+
|
|
18
|
+
The view controls show the zoom, control the auto-turn speed, and come in two levels so the bar stays simple.
|
|
19
|
+
|
|
20
|
+
- **Zoom readout.** A button between zoom out and zoom in shows the zoom (`1×`, `2.4×`); tapping it goes back to Fit. A screen reader is told "Zoom level 2.4×" once the zoom has settled (a polite status, not on load, not for every step of a pinch).
|
|
21
|
+
- **Auto-turn speed.** Slow, Medium and Fast: half, once and twice the `speed` attribute (30 degrees a second). It never stops or restarts the turn. Kept in the address as `?speed=slow` or `?speed=fast` (key renameable with `address-keys="speed:pace"`); Medium leaves the address, and a page's own `speed` value is left alone. `parseAddress` and `formatAddress` take and give an optional `pace`.
|
|
22
|
+
- **`controls="basic|advanced"`.** Basic is the default and shows the angle slider, Auto-turn, zoom out and in with the readout, Fit and a small More button. Advanced (More, or the attribute) adds tilt, speed, Front, 2 times, the pan pad and Move or Turn. The reader's choice is remembered in `localStorage` (`kazoku-view-controls`, or the key `storage` names) and beats the attribute written in the markup; the host carries `data-level`.
|
|
23
|
+
- **Changed default.** The default bar is simpler than 0.7: tilt, Front, 2 times, the pan pad and Move or Turn are behind More. A page that wants them from the start sets `controls="advanced"`.
|
|
24
|
+
- New pure exports from `view-controls`: `parseLevel`, `parsePace`, `zoomText`, `PACES`, `PACE_FACTOR`, `LEVEL_STORAGE_KEY`. New labels `zoomLevel`, `zoomReset`, `more`, `speed`, `slow`, `medium`, `fast` in English and Japanese (the Japanese is to be read by a native speaker).
|
|
25
|
+
|
|
26
|
+
## [0.7.3] - 2026-11-02
|
|
27
|
+
|
|
28
|
+
One agent-instruction template for the family's package repos. Tooling and docs only; the package is unchanged.
|
|
29
|
+
|
|
30
|
+
- `templates/AGENTS.md`: the family rules above a marker (read spx-stats `docs/package-work.md` first, the board and ticket prefix with `--area`, ports, commits, checks, the release path, hand-off project, reporting) and a Facts table plus a "This repo" section below it for what is true only in one repo. Public: no private host, path or key.
|
|
31
|
+
- `docs/agents-template.md`: how to adopt it, and which repos would. `test/template.test.ts` holds the marker, the table and the privacy rule.
|
|
32
|
+
|
|
3
33
|
## [0.7.2] - 2026-11-02
|
|
4
34
|
|
|
5
35
|
One release workflow for all the family's packages.
|
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
<picture>
|
|
4
4
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/hero-dark.webp">
|
|
5
|
-
<img alt="The demo: in its header the English and 日本語 choice and the Light, Dark and Auto switch; below, a white card with a blue circle and a black triangle, turned a little and tipped, in a frame with a dotted floor. Under it the view controls: Angle
|
|
5
|
+
<img alt="The demo: in its header the English and 日本語 choice and the Light, Dark and Auto switch; below, a white card with a blue circle and a black triangle, turned a little and tipped, in a frame with a dotted floor. Under it the view controls: an Angle slider, an Auto-turn button, zoom out, the zoom readout, zoom in and Fit, and a small More button." src="https://raw.githubusercontent.com/johnmorrisdotca/kazoku-ui/main/docs/images/hero-light.webp" width="720">
|
|
6
6
|
</picture>
|
|
7
7
|
|
|
8
8
|
The family's shared interface parts, as their own versioned package: one part per subpath, so a page loads only what it uses. No runtime dependencies, MIT.
|
|
9
9
|
The first part is the **view controls** for any 3D or zoomable view. They never draw the scene: they tell your page what the reader chose, and your canvas, WebGL,
|
|
10
10
|
SVG or CSS 3D applies it.
|
|
11
11
|
|
|
12
|
-
[Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
|
|
12
|
+
[Demo](https://johnmorrisdotca.github.io/kazoku-ui/) · [Strict-CSP demo](https://johnmorrisdotca.github.io/kazoku-ui/csp.html) · [Theme switch](docs/theme-switch.md) · [Language picker](docs/language-picker.md) · [Address state](docs/address-state.md) · [Save image](docs/save-image.md) · [Shared 3D renderer](docs/renderer.md) · [Content-Security-Policy](docs/csp.md) · [Labels, English and 日本語](docs/labels.md) · [Changelog](CHANGELOG.md)
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
15
|
npm install @johnmorrisdotca/kazoku-ui
|
|
@@ -31,13 +31,16 @@ Parts (each its own subpath; later ones: picture-tile pickers, Help, GitHub and
|
|
|
31
31
|
| `@johnmorrisdotca/kazoku-ui/save-image` | The formats, the file name, the size and the functions that render and download a canvas, an SVG or an element as PNG or SVG. Imports on a server. |
|
|
32
32
|
| `@johnmorrisdotca/kazoku-ui/save-image/element` | The `SaveImageElement` class (needs a browser). |
|
|
33
33
|
| `@johnmorrisdotca/kazoku-ui/save-image/define` | Registers `<kazoku-save-image>` (once, and only if the tag is free). |
|
|
34
|
+
| `@johnmorrisdotca/kazoku-ui/renderer` | The shared 3D renderer: one WebGL context for the focused view, shared assets, stills, drawing on demand, a pixel budget. three.js is an optional peer, loaded by your `import("three")`. Imports on a server. |
|
|
35
|
+
| `@johnmorrisdotca/kazoku-ui/testing` | The freeze checks for a 3D package's e2e: count contexts, GPU objects, draws and frames; the canvas size against the budget. No test framework named. |
|
|
34
36
|
| `@johnmorrisdotca/kazoku-ui/address-state` | Choices kept in the address: read, write and subscribe to URL parameters with `replaceState`. No element; imports on a server. |
|
|
35
37
|
|
|
36
38
|
## View controls
|
|
37
39
|
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
40
|
+
- **Two levels, so the bar stays simple.** The default, `controls="basic"`, shows the angle slider, Auto-turn, zoom out and in with a **zoom readout** (`2.4×`; tap it to go back to Fit), Fit, and a small More button. More (or `controls="advanced"`) adds the tilt slider, the auto-turn **speed** (Slow, Medium, Fast), Front, 2 times, and, once zoomed in, the four-arrow pan pad and the Move or Turn choice for what a drag does. The reader's choice with More is remembered in `localStorage` and beats the attribute.
|
|
41
|
+
- The zoom readout is the view's `zoom` (1× is Fit). A screen reader is told "Zoom level 2.4×" once the zoom has settled for a moment, not for every step of a pinch.
|
|
42
|
+
- Auto-turn with play and pause; its speed is Slow (half the `speed` attribute), Medium (the `speed` attribute, 30 degrees a second) or Fast (twice it), kept in the address as `?speed=slow` or `?speed=fast`.
|
|
43
|
+
- Zoom is about the pointer (and Ctrl or Command with the wheel, or a trackpad's pinch).
|
|
41
44
|
- Touch: one finger turns at 1x and moves the view when zoomed in; two fingers pinch.
|
|
42
45
|
- Keys on the view: arrows turn and tip it, `+` and `-` zoom, `0` or Home is Front and Fit.
|
|
43
46
|
- The choices are kept in the address, with keys you can rename.
|
|
@@ -178,10 +181,12 @@ An `init` event is sent once, a moment after the element connects, with the star
|
|
|
178
181
|
| `tilt-step` | Round the tilt of a drag to a multiple of this many degrees (`1` gives whole degrees). 0 by default: not rounded. |
|
|
179
182
|
| `autoplay` | Turn by itself from the start, unless the reader prefers reduced motion. |
|
|
180
183
|
| `autoplay-for` | Seconds of turning after which the `autoplay` turn stops by itself (as Auto-turn pressed). Left out, it never stops. Time spent paused off screen or in a hidden tab is not counted, and a reader's own Auto-turn press has no limit. |
|
|
181
|
-
| `speed` | Degrees a second (30). |
|
|
184
|
+
| `speed` | Degrees a second at Medium (30); Slow is half and Fast twice. |
|
|
185
|
+
| `controls` | `basic` (the default) or `advanced`: which controls are shown (see above). Written in the page's markup it is where the reader starts; what the reader chose with More is remembered under `kazoku-view-controls` in `localStorage` (or the key `storage` names) and wins. Setting the attribute later changes the level at once. The host carries `data-level` with the level now. |
|
|
186
|
+
| `storage` | The `localStorage` key the level is remembered under (`kazoku-view-controls`). |
|
|
182
187
|
| `flat` | The view cannot turn: no angle, tilt, Auto-turn or Front, and a drag moves it. |
|
|
183
188
|
| `tilt-max`, `zoom-min`, `zoom-max` | The limits (30, 1, 5). |
|
|
184
|
-
| `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn`). |
|
|
189
|
+
| `address` | Keep the choices in the page's address (`?yaw=40&tilt=10&zoom=2&drag=turn&speed=fast`). |
|
|
185
190
|
| `address-keys` | Other keys: `yaw:a,zoom:z`. |
|
|
186
191
|
| `theme` | `light` or `dark`; otherwise the page's colour scheme. |
|
|
187
192
|
| `overlay` | Sit over the bottom of a positioned parent. |
|
|
@@ -191,7 +196,7 @@ Colours are custom properties: `--kazoku-ink`, `--kazoku-surface`, `--kazoku-lin
|
|
|
191
196
|
|
|
192
197
|
### The address
|
|
193
198
|
|
|
194
|
-
Only what a reader chose is kept: angle, tilt, zoom and the
|
|
199
|
+
Only what a reader chose is kept: angle, tilt, zoom, the drag choice and the auto-turn speed (`speed=slow` or `speed=fast`; Medium leaves the address, and another value of `speed`, a page's own, is left alone). The pan is in pixels of one screen and the turning is of a moment, so neither is; while the view turns by itself the angle is left out. A value at its start (Fit, or the `zoom` the page opens at) leaves the address, and other parameters are kept. The address is written at most four times a second (Safari stops a page that changes its address more than 100 times in 30 seconds). Without the element: `parseAddress(search, keys?)` and `formatAddress(search, choices, keys?, leaveYaw?, startZoom?)`.
|
|
195
200
|
|
|
196
201
|
### Why the tag is only defined when it is free
|
|
197
202
|
|
|
@@ -244,6 +249,20 @@ See [docs/address-state.md](docs/address-state.md); the demo's `address.html` sh
|
|
|
244
249
|
|
|
245
250
|
What a picture of an element cannot carry (web fonts, `::before`, cross-origin images), the element-free `renderImage` and `saveImage`, and the one `img-src data:` a strict policy needs are in [docs/save-image.md](docs/save-image.md); the demo's `save-image.html` saves all three kinds.
|
|
246
251
|
|
|
252
|
+
## Shared 3D renderer
|
|
253
|
+
|
|
254
|
+
A page with many 3D views gets one WebGL context, handed to the focused view; the others are stills made once from the same renderer, kept in a budgeted list. Shared geometries,
|
|
255
|
+
materials, textures and models are counted and freed when the last view lets go. Nothing is drawn while the page rests or the view is out of sight, one animation frame is
|
|
256
|
+
requested at a time, and a canvas never passes 6 million pixels or 4096 a side. Context loss is handled in one place. **three.js is a peer dependency and is never bundled**:
|
|
257
|
+
your page passes `load: () => import("three")`, so it loads only when the first view goes live. How a package uses it, the API and the freeze checks: [docs/renderer.md](docs/renderer.md). Demo: `renderer.html`.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { createSharedRenderer } from "@johnmorrisdotca/kazoku-ui/renderer";
|
|
261
|
+
const renderer = createSharedRenderer({ load: () => import("three") });
|
|
262
|
+
const view = renderer.view(host, item); // item: { key, build({ three, assets, width, height }) => { scene, camera } }
|
|
263
|
+
host.addEventListener("focus", () => view.focus());
|
|
264
|
+
```
|
|
265
|
+
|
|
247
266
|
## One release workflow for the family's packages
|
|
248
267
|
|
|
249
268
|
`.github/workflows/reusable-release.yml` is the release written once: from main, the tag must equal `package.json`'s version, the repo's check script runs, a version already on npm is skipped, the `v<version>` tag is made if missing, and `npm publish --provenance` runs by trusted publishing (no token). A package repo's `release.yml` calls it:
|
|
@@ -256,17 +275,21 @@ jobs:
|
|
|
256
275
|
|
|
257
276
|
npm accepts a called workflow: the trusted-publisher entry on npmjs.com names the **calling** file (`release.yml`), and `id-token: write` goes on both. To move a repo over, keep its file name `release.yml`, replace the body with the call, and pin to a released tag. The inputs, the sources and the steps are in [docs/release-workflow.md](docs/release-workflow.md).
|
|
258
277
|
|
|
278
|
+
## One AGENTS.md for the family's package repos
|
|
279
|
+
|
|
280
|
+
`templates/AGENTS.md` is the agent-instruction file every package repo can start from: the family rules (read spx-stats `docs/package-work.md` first, the board and ticket prefix, ports, checks, release, hand-off) above a marker, and the repo's own facts below it. See [docs/agents-template.md](docs/agents-template.md).
|
|
281
|
+
|
|
259
282
|
## Content-Security-Policy
|
|
260
283
|
|
|
261
284
|
The styles are adopted as a constructable stylesheet (`adoptedStyleSheets`), so a page with `style-src 'self'` and no `'unsafe-inline'` needs no hash and no allowance. A browser without it gets a `<style>` carrying the element's `nonce`. What a page needs is in [docs/csp.md](docs/csp.md), and the demo's `csp.html` runs under a strict policy.
|
|
262
285
|
|
|
263
286
|
## Size
|
|
264
287
|
|
|
265
|
-
Gzipped, unbundled. View controls: the element and its definition about
|
|
288
|
+
Gzipped, unbundled. View controls: the element and its definition about 6.9 KB, the controller 4.4 KB, the maths 1.4 KB, the address 1.4 KB, the levels 0.4 KB, the labels 0.9 KB and the styles 1.7 KB. Theme switch: the element and its definition 3.3 KB, the choices 1.0 KB, the labels 0.3 KB and the styles 0.3 KB. Language picker: the element and its definition 2.8 KB, the codes and names 1.7 KB, the labels 0.3 KB. Save image: the element and its definition 3.0 KB, the rendering 3.6 KB, the formats and file names 1.0 KB, the labels 0.4 KB. Address state: 2.1 KB. Shared 3D renderer: 8.9 KB (the renderer 4.5, the scheduler 1.2, the assets 1.4, the stills 0.6, the pixel budget 0.8, the index 0.4), the test kit 1.6 KB; three.js is the page's own and is not counted. Shared by parts: the style adoption 0.8 KB, the base styles 1.0 KB, the page's colours 0.5 KB and the target and the store 0.6 KB. `pnpm test:package` prints them. Nothing is loaded from the network.
|
|
266
289
|
|
|
267
290
|
## Checks
|
|
268
291
|
|
|
269
|
-
`pnpm check` (lint, types,
|
|
292
|
+
`pnpm check` (lint, types, 99 unit tests of the maths, the address, the controller and the package, the build and the packed package), and `pnpm test:demo` (the demo in Chromium and WebKit at a phone's width and a desk's: the controls in the first screen, a toggle during Auto-turn leaves the angle advancing, a button or link inside the view still clicks while a drag turns it, the opening zoom and fit scale, zoom about the pointer, zoom about an anchor (`?anchor=pin`), Arrow Up either way (`?key-tilt=wheel`), `?stop-turn`, `?tilt-step=1`, pan when zoomed, touch, the address, the labels, reduced motion, the controls styled under a strict `style-src` with no violation, `demo/csp.html`, the theme switch and the language picker in each browser and size, the address state, back and forward and no history added, on `demo/address.html`, and the save buttons: each file read back from disk, on `demo/save-image.html`, and the shared 3D renderer on `demo/renderer.html`: one WebGL context over fifty focus changes, nothing drawn or requested at rest, the GPU objects and heap flat, the canvas within budget at 5K, context loss, out of sight, the stills' byte budget, and three.js fetched only when a view goes live).
|
|
270
293
|
|
|
271
294
|
## Not yet
|
|
272
295
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** The subpaths of the parts, in the order they were added. */
|
|
2
|
-
export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image"];
|
|
2
|
+
export declare const PARTS: readonly ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "renderer", "testing"];
|
|
3
3
|
export type Part = (typeof PARTS)[number];
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Kazoku UI 家族: the family's shared interface parts, each its own subpath so a page loads only what it uses.
|
|
2
2
|
/** The subpaths of the parts, in the order they were added. */
|
|
3
|
-
export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image"];
|
|
3
|
+
export const PARTS = ["view-controls", "theme-switch", "language-picker", "address-state", "save-image", "renderer", "testing"];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface Timers {
|
|
2
|
+
set(callback: () => void, ms: number): unknown;
|
|
3
|
+
clear(handle: unknown): void;
|
|
4
|
+
}
|
|
5
|
+
export interface AssetCacheOptions {
|
|
6
|
+
/** Milliseconds an asset with no users is kept before it is disposed (1000). 0 disposes at once. */
|
|
7
|
+
lingerMs?: number;
|
|
8
|
+
timers?: Timers;
|
|
9
|
+
}
|
|
10
|
+
export interface AssetCacheStats {
|
|
11
|
+
/** Assets held, with or without users. */
|
|
12
|
+
entries: number;
|
|
13
|
+
/** Users of assets, in all. */
|
|
14
|
+
refs: number;
|
|
15
|
+
/** Asks answered from the cache. */
|
|
16
|
+
hits: number;
|
|
17
|
+
/** Asks that made the asset. */
|
|
18
|
+
misses: number;
|
|
19
|
+
/** Assets disposed. */
|
|
20
|
+
disposed: number;
|
|
21
|
+
}
|
|
22
|
+
/** What one view takes: it asks for assets through its scope and lets all of them go with one `release()`. */
|
|
23
|
+
export interface AssetScope {
|
|
24
|
+
acquire<T>(key: string, make: () => T, dispose?: (value: T) => void): T;
|
|
25
|
+
release(): void;
|
|
26
|
+
}
|
|
27
|
+
export interface AssetCache {
|
|
28
|
+
/** The asset for `key`, made by `make` the first time. Every `acquire` needs a `release` of the same key (a scope does that for you). `dispose` is kept from the first ask. */
|
|
29
|
+
acquire<T>(key: string, make: () => T, dispose?: (value: T) => void): T;
|
|
30
|
+
release(key: string): void;
|
|
31
|
+
scope(): AssetScope;
|
|
32
|
+
/** Dispose every asset now, used or not (the page is done with the renderer). */
|
|
33
|
+
clear(): void;
|
|
34
|
+
stats(): AssetCacheStats;
|
|
35
|
+
}
|
|
36
|
+
export declare function createAssetCache(options?: AssetCacheOptions): AssetCache;
|
|
37
|
+
/** Free what a loaded model holds (its geometries, materials and the textures of those), for a `dispose` that frees a whole scene. Duck-typed, so it needs no three.js import. */
|
|
38
|
+
export declare function disposeDeep(root: unknown): void;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shared assets with reference counts: a geometry, a material, a texture or a loaded model is made once for everyone who asks for its key, and disposed when
|
|
2
|
+
// the last one lets go (after a short linger, so a view that lets go just before the next takes the same thing does not rebuild it). Nothing here knows about
|
|
3
|
+
// three.js; the `dispose` you pass says how to free a thing. A model that loads asynchronously is cached as its promise.
|
|
4
|
+
const defaultTimers = { set: (callback, ms) => setTimeout(callback, ms), clear: (handle) => clearTimeout(handle) };
|
|
5
|
+
export function createAssetCache(options = {}) {
|
|
6
|
+
const lingerMs = options.lingerMs ?? 1000;
|
|
7
|
+
const timers = options.timers ?? defaultTimers;
|
|
8
|
+
const entries = new Map();
|
|
9
|
+
const counts = { hits: 0, misses: 0, disposed: 0 };
|
|
10
|
+
const drop = (key) => {
|
|
11
|
+
const entry = entries.get(key);
|
|
12
|
+
if (entry === undefined)
|
|
13
|
+
return;
|
|
14
|
+
entries.delete(key);
|
|
15
|
+
if (entry.timer !== undefined)
|
|
16
|
+
timers.clear(entry.timer);
|
|
17
|
+
counts.disposed += 1;
|
|
18
|
+
entry.dispose?.(entry.value);
|
|
19
|
+
};
|
|
20
|
+
const cache = {
|
|
21
|
+
acquire(key, make, dispose) {
|
|
22
|
+
const held = entries.get(key);
|
|
23
|
+
if (held !== undefined) {
|
|
24
|
+
counts.hits += 1;
|
|
25
|
+
if (held.timer !== undefined) {
|
|
26
|
+
timers.clear(held.timer);
|
|
27
|
+
held.timer = undefined;
|
|
28
|
+
}
|
|
29
|
+
held.refs += 1;
|
|
30
|
+
return held.value;
|
|
31
|
+
}
|
|
32
|
+
counts.misses += 1;
|
|
33
|
+
const value = make();
|
|
34
|
+
entries.set(key, { value, refs: 1, dispose: dispose, timer: undefined });
|
|
35
|
+
return value;
|
|
36
|
+
},
|
|
37
|
+
release(key) {
|
|
38
|
+
const entry = entries.get(key);
|
|
39
|
+
if (entry === undefined || entry.refs === 0)
|
|
40
|
+
return;
|
|
41
|
+
entry.refs -= 1;
|
|
42
|
+
if (entry.refs > 0)
|
|
43
|
+
return;
|
|
44
|
+
if (lingerMs > 0)
|
|
45
|
+
entry.timer = timers.set(() => {
|
|
46
|
+
if (entry.refs === 0)
|
|
47
|
+
drop(key);
|
|
48
|
+
}, lingerMs);
|
|
49
|
+
else
|
|
50
|
+
drop(key);
|
|
51
|
+
},
|
|
52
|
+
scope() {
|
|
53
|
+
const taken = [];
|
|
54
|
+
return {
|
|
55
|
+
acquire(key, make, dispose) {
|
|
56
|
+
taken.push(key);
|
|
57
|
+
return cache.acquire(key, make, dispose);
|
|
58
|
+
},
|
|
59
|
+
release() {
|
|
60
|
+
for (const key of taken.splice(0))
|
|
61
|
+
cache.release(key);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
clear() {
|
|
66
|
+
for (const key of [...entries.keys()])
|
|
67
|
+
drop(key);
|
|
68
|
+
},
|
|
69
|
+
stats() {
|
|
70
|
+
let refs = 0;
|
|
71
|
+
for (const entry of entries.values())
|
|
72
|
+
refs += entry.refs;
|
|
73
|
+
return { entries: entries.size, refs, ...counts };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return cache;
|
|
77
|
+
}
|
|
78
|
+
/** Free what a loaded model holds (its geometries, materials and the textures of those), for a `dispose` that frees a whole scene. Duck-typed, so it needs no three.js import. */
|
|
79
|
+
export function disposeDeep(root) {
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const free = (thing) => {
|
|
82
|
+
if (typeof thing !== "object" || thing === null || seen.has(thing))
|
|
83
|
+
return;
|
|
84
|
+
seen.add(thing);
|
|
85
|
+
const object = thing;
|
|
86
|
+
for (const value of Object.values(object)) {
|
|
87
|
+
// A texture sits in a material's own property (map, normalMap, ...); an object's own children are walked by `traverse` below.
|
|
88
|
+
if (typeof value === "object" && value !== null && value.isTexture === true)
|
|
89
|
+
free(value);
|
|
90
|
+
}
|
|
91
|
+
object.dispose?.();
|
|
92
|
+
};
|
|
93
|
+
const visit = (node) => {
|
|
94
|
+
const object = node;
|
|
95
|
+
free(object.geometry);
|
|
96
|
+
for (const material of Array.isArray(object.material) ? object.material : [object.material])
|
|
97
|
+
free(material);
|
|
98
|
+
};
|
|
99
|
+
const walk = root;
|
|
100
|
+
if (typeof walk?.traverse === "function")
|
|
101
|
+
walk.traverse(visit);
|
|
102
|
+
else
|
|
103
|
+
visit(root);
|
|
104
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createAssetCache, disposeDeep, type AssetCache, type AssetCacheOptions, type AssetCacheStats, type AssetScope, type Timers } from "./cache.ts";
|
|
2
|
+
export { MAX_PIXELS, MAX_RATIO, MAX_SIDE, pixelRatioFor, type PixelLimits } from "./pixels.ts";
|
|
3
|
+
export { createSharedRenderer, type BuildContext, type GLRenderer, type Item3D, type RendererStats, type Scene3D, type SharedRenderer, type SharedRendererOptions, type ThreeLike, type View, type ViewOptions } from "./renderer.ts";
|
|
4
|
+
export { browserClock, createScheduler, type FrameClock, type Job, type Priority, type Scheduler, type SchedulerOptions, type SchedulerStats } from "./scheduler.ts";
|
|
5
|
+
export { createStillCache, STILL_BYTES, type Still, type StillCache, type StillCacheOptions } from "./stills.ts";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The shared 3D renderer: one WebGL context per page for the focused view, shared assets with reference counts, stills in a budgeted list, drawing on demand,
|
|
2
|
+
// a pixel budget and context-loss handling. three.js is not imported here: the page passes `load: () => import("three")`. Nothing touches the DOM until a view is made, so it imports on a server.
|
|
3
|
+
export { createAssetCache, disposeDeep } from "./cache.js";
|
|
4
|
+
export { MAX_PIXELS, MAX_RATIO, MAX_SIDE, pixelRatioFor } from "./pixels.js";
|
|
5
|
+
export { createSharedRenderer } from "./renderer.js";
|
|
6
|
+
export { browserClock, createScheduler } from "./scheduler.js";
|
|
7
|
+
export { createStillCache, STILL_BYTES } from "./stills.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Most pixels a canvas may hold. */
|
|
2
|
+
export declare const MAX_PIXELS = 6000000;
|
|
3
|
+
/** Most pixels along one side (a common GPU texture limit). */
|
|
4
|
+
export declare const MAX_SIDE = 4096;
|
|
5
|
+
/** Most device pixels to a CSS pixel; a ratio of 3 or 4 adds detail nobody sees on a moving 3D view. */
|
|
6
|
+
export declare const MAX_RATIO = 2;
|
|
7
|
+
export interface PixelLimits {
|
|
8
|
+
maxPixels?: number;
|
|
9
|
+
maxSide?: number;
|
|
10
|
+
maxRatio?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The pixel ratio to draw a `width` by `height` CSS pixel canvas at: the device's, held to `maxRatio`, then lowered until the canvas fits `maxPixels` and
|
|
14
|
+
* `maxSide`. Rounded down, so `floor(width * ratio)` never passes a limit. A view bigger than the limits on its own is drawn below 1 and scaled up by CSS.
|
|
15
|
+
*/
|
|
16
|
+
export declare function pixelRatioFor(width: number, height: number, deviceRatio: number, limits?: PixelLimits): number;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The pixel budget of a canvas, in one place: a canvas of a big screen at a high pixel ratio is tens of millions of pixels (a 5K screen at ratio 2 asked for
|
|
2
|
+
// 24 MP in one package), and a GPU that is asked for that stalls the page. Pure, so a server or a test can use it.
|
|
3
|
+
/** Most pixels a canvas may hold. */
|
|
4
|
+
export const MAX_PIXELS = 6_000_000;
|
|
5
|
+
/** Most pixels along one side (a common GPU texture limit). */
|
|
6
|
+
export const MAX_SIDE = 4096;
|
|
7
|
+
/** Most device pixels to a CSS pixel; a ratio of 3 or 4 adds detail nobody sees on a moving 3D view. */
|
|
8
|
+
export const MAX_RATIO = 2;
|
|
9
|
+
/**
|
|
10
|
+
* The pixel ratio to draw a `width` by `height` CSS pixel canvas at: the device's, held to `maxRatio`, then lowered until the canvas fits `maxPixels` and
|
|
11
|
+
* `maxSide`. Rounded down, so `floor(width * ratio)` never passes a limit. A view bigger than the limits on its own is drawn below 1 and scaled up by CSS.
|
|
12
|
+
*/
|
|
13
|
+
export function pixelRatioFor(width, height, deviceRatio, limits = {}) {
|
|
14
|
+
const { maxPixels = MAX_PIXELS, maxSide = MAX_SIDE, maxRatio = MAX_RATIO } = limits;
|
|
15
|
+
if (!(width > 0) || !(height > 0))
|
|
16
|
+
return 1;
|
|
17
|
+
let ratio = Math.min(Number.isFinite(deviceRatio) && deviceRatio > 0 ? deviceRatio : 1, maxRatio);
|
|
18
|
+
ratio = Math.min(ratio, maxSide / Math.max(width, height), Math.sqrt(maxPixels / (width * height)));
|
|
19
|
+
const floored = Math.floor(ratio * 1000) / 1000;
|
|
20
|
+
// Floating point can leave the product a pixel over a limit; step down until it is not.
|
|
21
|
+
let r = Math.max(floored, 0.001);
|
|
22
|
+
while (r > 0.001 && (Math.floor(width * r) * Math.floor(height * r) > maxPixels || Math.max(Math.floor(width * r), Math.floor(height * r)) > maxSide))
|
|
23
|
+
r = Math.floor((r - 0.001) * 1000) / 1000;
|
|
24
|
+
return r;
|
|
25
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type AssetCache, type AssetScope } from "./cache.ts";
|
|
2
|
+
import { type FrameClock, type Scheduler } from "./scheduler.ts";
|
|
3
|
+
import { type Still, type StillCache } from "./stills.ts";
|
|
4
|
+
/** What of three's `WebGLRenderer` the renderer uses. */
|
|
5
|
+
export interface GLRenderer {
|
|
6
|
+
readonly domElement: HTMLCanvasElement;
|
|
7
|
+
setPixelRatio(ratio: number): void;
|
|
8
|
+
setSize(width: number, height: number, updateStyle?: boolean): void;
|
|
9
|
+
render(scene: unknown, camera: unknown): void;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
forceContextLoss(): void;
|
|
12
|
+
}
|
|
13
|
+
/** What of the `three` module the renderer uses; the whole module is handed to `build`, so an adapter can use all of it. */
|
|
14
|
+
export interface ThreeLike {
|
|
15
|
+
WebGLRenderer: new (...args: never[]) => GLRenderer;
|
|
16
|
+
}
|
|
17
|
+
/** A scene ready to draw, made by an item's `build`. Everything but `scene` and `camera` is optional. */
|
|
18
|
+
export interface Scene3D {
|
|
19
|
+
scene: unknown;
|
|
20
|
+
camera: unknown;
|
|
21
|
+
/** Called before each draw while the view is live. Return true while something is moving; the view is then drawn again next frame, else it rests. */
|
|
22
|
+
update?(dt: number, now: number): boolean | void;
|
|
23
|
+
/** The view's size in CSS pixels changed (set the camera's aspect). */
|
|
24
|
+
resize?(width: number, height: number): void;
|
|
25
|
+
/** Free what only this scene owns. Shared assets are freed by the cache. */
|
|
26
|
+
dispose?(): void;
|
|
27
|
+
}
|
|
28
|
+
export interface BuildContext<T extends ThreeLike = ThreeLike> {
|
|
29
|
+
three: T;
|
|
30
|
+
/** Take every geometry, material, texture and model through here: they are shared by key and released when the view lets go. */
|
|
31
|
+
assets: AssetScope;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}
|
|
35
|
+
/** The package's adapter for one kind of thing: the scene for it and its framing. */
|
|
36
|
+
export interface Item3D<T extends ThreeLike = ThreeLike> {
|
|
37
|
+
/** What the item looks like, for its still and for the cache: items with the same key share one picture. */
|
|
38
|
+
key: string;
|
|
39
|
+
build(context: BuildContext<T>): Scene3D | Promise<Scene3D>;
|
|
40
|
+
}
|
|
41
|
+
export interface SharedRendererOptions<T extends ThreeLike = ThreeLike> {
|
|
42
|
+
/** `() => import("three")`: where three is loaded from, once, when a view first goes live or a still is first made. */
|
|
43
|
+
load: () => Promise<T>;
|
|
44
|
+
maxPixels?: number;
|
|
45
|
+
maxSide?: number;
|
|
46
|
+
maxRatio?: number;
|
|
47
|
+
/** Longest side of a still, in pixels (320). */
|
|
48
|
+
stillSize?: number;
|
|
49
|
+
/** Decoded bytes the stills may hold (32 MB). */
|
|
50
|
+
stillBytes?: number;
|
|
51
|
+
/** Milliseconds an unused shared asset is kept (1000). */
|
|
52
|
+
lingerMs?: number;
|
|
53
|
+
/** Milliseconds out of sight, in a hidden tab, after which the context is let go and the views show stills (30000). It comes back when the view does. */
|
|
54
|
+
releaseMs?: number;
|
|
55
|
+
antialias?: boolean;
|
|
56
|
+
/** Called when the context is lost or restored, or WebGL is not available. */
|
|
57
|
+
onContext?: (state: "lost" | "restored" | "unavailable") => void;
|
|
58
|
+
clock?: FrameClock;
|
|
59
|
+
document?: Document;
|
|
60
|
+
/** Your own cache or scheduler, for a test or to share them. */
|
|
61
|
+
assets?: AssetCache;
|
|
62
|
+
scheduler?: Scheduler;
|
|
63
|
+
}
|
|
64
|
+
export interface ViewOptions {
|
|
65
|
+
/** The `<img>` that shows the still. Left out, one is made inside the host. `null` shows no still. */
|
|
66
|
+
still?: HTMLImageElement | null;
|
|
67
|
+
}
|
|
68
|
+
export interface View {
|
|
69
|
+
readonly host: HTMLElement;
|
|
70
|
+
readonly item: Item3D;
|
|
71
|
+
/** True while this view has the renderer's canvas and a scene. */
|
|
72
|
+
readonly live: boolean;
|
|
73
|
+
/** The scene while live. */
|
|
74
|
+
readonly scene: Scene3D | undefined;
|
|
75
|
+
/** Take the renderer. The view that had it goes back to its still. Resolves true when this view is live, false if another took it meanwhile or WebGL is not available. */
|
|
76
|
+
focus(): Promise<boolean>;
|
|
77
|
+
/** Give the renderer back (the view shows its still). */
|
|
78
|
+
blur(): void;
|
|
79
|
+
/** Draw this view at the next frame. Call it when the camera or the scene changed; nothing is drawn without it. */
|
|
80
|
+
invalidate(): void;
|
|
81
|
+
/** Make the view's still if there is none (one at a time, from the same renderer) and show it. Call it when the view comes into sight. */
|
|
82
|
+
showStill(): Promise<Still | undefined>;
|
|
83
|
+
dispose(): void;
|
|
84
|
+
}
|
|
85
|
+
export interface RendererStats {
|
|
86
|
+
/** WebGL contexts made over the page's life: 1, unless the page let it go while hidden. */
|
|
87
|
+
contexts: number;
|
|
88
|
+
/** Draws of live views. */
|
|
89
|
+
draws: number;
|
|
90
|
+
/** Stills made. */
|
|
91
|
+
snapshots: number;
|
|
92
|
+
live: boolean;
|
|
93
|
+
lost: boolean;
|
|
94
|
+
/** The canvas's size in device pixels now. */
|
|
95
|
+
canvas: {
|
|
96
|
+
width: number;
|
|
97
|
+
height: number;
|
|
98
|
+
pixels: number;
|
|
99
|
+
};
|
|
100
|
+
frames: Scheduler["stats"];
|
|
101
|
+
assets: ReturnType<AssetCache["stats"]>;
|
|
102
|
+
stills: {
|
|
103
|
+
count: number;
|
|
104
|
+
bytes: number;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export interface SharedRenderer<T extends ThreeLike = ThreeLike> {
|
|
108
|
+
view(host: HTMLElement, item: Item3D<T>, options?: ViewOptions): View;
|
|
109
|
+
/** The view that is live, if any. */
|
|
110
|
+
readonly focused: View | undefined;
|
|
111
|
+
readonly stats: RendererStats;
|
|
112
|
+
readonly assets: AssetCache;
|
|
113
|
+
readonly stills: StillCache;
|
|
114
|
+
readonly scheduler: Scheduler;
|
|
115
|
+
/** Let go of everything: views, the context, the assets and the stills. */
|
|
116
|
+
dispose(): void;
|
|
117
|
+
}
|
|
118
|
+
export declare function createSharedRenderer<T extends ThreeLike = ThreeLike>(options: SharedRendererOptions<T>): SharedRenderer<T>;
|