@alkemdotdev/alkemist-components 1.0.0-beta.1

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/src/model.ts ADDED
@@ -0,0 +1,509 @@
1
+ import type { Object3D, Material, Texture, Mesh, SkinnedMesh } from 'three';
2
+
3
+ type ModelRuntime = {
4
+ dispose: () => void;
5
+ setVisible: (value: boolean) => void;
6
+ };
7
+
8
+ /** Release shared mesh resources once, including GLTFLoader-owned ImageBitmaps. */
9
+ function disposeObjects(roots: Object3D[]) {
10
+ const geometries = new Set<Mesh['geometry']>();
11
+ const materials = new Set<Material>();
12
+ const textures = new Set<Texture>();
13
+ const bitmaps = new Set<ImageBitmap>();
14
+ const skeletons = new Set<SkinnedMesh['skeleton']>();
15
+ for (const root of roots) {
16
+ root.traverse((object) => {
17
+ const mesh = object as Mesh;
18
+ if (mesh.geometry) geometries.add(mesh.geometry);
19
+ if (mesh.material) {
20
+ for (const material of Array.isArray(mesh.material)
21
+ ? mesh.material
22
+ : [mesh.material]) {
23
+ materials.add(material);
24
+ for (const value of Object.values(material)) {
25
+ if (value && typeof value === 'object' && 'isTexture' in value)
26
+ textures.add(value as Texture);
27
+ }
28
+ }
29
+ }
30
+ const skinned = object as SkinnedMesh;
31
+ if (skinned.isSkinnedMesh) skeletons.add(skinned.skeleton);
32
+ });
33
+ }
34
+ for (const texture of textures) {
35
+ const images = Array.isArray(texture.image)
36
+ ? texture.image
37
+ : [texture.image];
38
+ for (const image of images) {
39
+ if (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap)
40
+ bitmaps.add(image);
41
+ }
42
+ texture.dispose();
43
+ }
44
+ for (const bitmap of bitmaps) bitmap.close();
45
+ for (const material of materials) material.dispose();
46
+ for (const geometry of geometries) geometry.dispose();
47
+ for (const skeleton of skeletons) skeleton.dispose();
48
+ }
49
+
50
+ async function createModel(
51
+ host: HTMLElement,
52
+ signal: AbortSignal,
53
+ reportError: (message: string) => void,
54
+ ): Promise<ModelRuntime> {
55
+ // Importing the custom element is cheap; GPU code arrives only near the viewport.
56
+ const [THREE, { GLTFLoader }, { OrbitControls }] = await Promise.all([
57
+ import('three'),
58
+ import('three/addons/loaders/GLTFLoader.js'),
59
+ import('three/addons/controls/OrbitControls.js'),
60
+ ]);
61
+ signal.throwIfAborted();
62
+ const canvas = host.querySelector<HTMLCanvasElement>('canvas')!;
63
+ const viewport = host.querySelector<HTMLElement>('.alk-model-viewport')!;
64
+ const context = canvas.getContext('webgl2', {
65
+ antialias: true,
66
+ alpha: false,
67
+ });
68
+ if (!context)
69
+ throw new Error(
70
+ '3D is unavailable in this browser. Use the static view or download the model.',
71
+ );
72
+
73
+ const renderer = new THREE.WebGLRenderer({
74
+ canvas,
75
+ context,
76
+ antialias: true,
77
+ });
78
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
79
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
80
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
81
+ const scene = new THREE.Scene();
82
+ const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
83
+ const controls = new OrbitControls(camera, canvas);
84
+ controls.enableDamping = false;
85
+ controls.enablePan = false;
86
+ controls.autoRotateSpeed = 1;
87
+ const events = new AbortController();
88
+ const roots: Object3D[] = [scene];
89
+ let frame = 0;
90
+ let previousTime = 0;
91
+ let visible = false;
92
+ let disposed = false;
93
+ let ready = false;
94
+ let radius = 1;
95
+ let resizeObserver: ResizeObserver | undefined;
96
+
97
+ const requestRender = () => {
98
+ if (disposed || !ready || !visible || document.hidden || frame) return;
99
+ frame = requestAnimationFrame(render);
100
+ };
101
+ const render = (time: number) => {
102
+ frame = 0;
103
+ if (disposed || !visible || document.hidden) return;
104
+ if (controls.autoRotate)
105
+ controls.update(
106
+ previousTime ? Math.min((time - previousTime) / 1000, 0.05) : 0,
107
+ );
108
+ previousTime = time;
109
+ renderer.render(scene, camera);
110
+ if (controls.autoRotate) requestRender();
111
+ };
112
+ const pause = () => {
113
+ cancelAnimationFrame(frame);
114
+ frame = 0;
115
+ previousTime = 0;
116
+ };
117
+ const dispose = () => {
118
+ if (disposed) return;
119
+ disposed = true;
120
+ pause();
121
+ events.abort();
122
+ resizeObserver?.disconnect();
123
+ controls.dispose();
124
+ disposeObjects(roots);
125
+ renderer.dispose();
126
+ renderer.forceContextLoss();
127
+ // A bfcache restoration or reconnect needs a fresh context, not the deliberately lost one.
128
+ if (canvas.parentNode === viewport)
129
+ canvas.replaceWith(canvas.cloneNode(false));
130
+ };
131
+ signal.addEventListener('abort', dispose, { once: true });
132
+ const fitDistance = () => {
133
+ const vertical = THREE.MathUtils.degToRad(camera.fov / 2);
134
+ const horizontal = Math.atan(Math.tan(vertical) * camera.aspect);
135
+ return (radius / Math.sin(Math.min(vertical, horizontal))) * 1.12;
136
+ };
137
+ const preset = (view: string) => {
138
+ const direction =
139
+ view === 'top'
140
+ ? new THREE.Vector3(0, 1, 0.001)
141
+ : view === 'front'
142
+ ? new THREE.Vector3(0, 0.04, 1)
143
+ : new THREE.Vector3(3, 2.1, 3);
144
+ camera.position.copy(direction.normalize().multiplyScalar(fitDistance()));
145
+ controls.target.set(0, 0, 0);
146
+ controls.update();
147
+ requestRender();
148
+ };
149
+ const updateTheme = () => {
150
+ const style = getComputedStyle(viewport);
151
+ renderer.setClearColor(new THREE.Color(style.backgroundColor));
152
+ // Only our explicitly tagged fixture materials adopt UI inks; other assets retain their colors.
153
+ scene.traverse((object) => {
154
+ const mesh = object as Mesh;
155
+ if (!mesh.material) return;
156
+ for (const material of Array.isArray(mesh.material)
157
+ ? mesh.material
158
+ : [mesh.material]) {
159
+ if (material.userData.alkForeground && 'color' in material) {
160
+ (material.color as InstanceType<typeof THREE.Color>).set(style.color);
161
+ }
162
+ const ink = material.userData.alkInk;
163
+ if (
164
+ typeof ink === 'string' &&
165
+ /^[a-z]+$/.test(ink) &&
166
+ 'color' in material
167
+ ) {
168
+ const color = style.getPropertyValue(`--alk-ink-${ink}`).trim();
169
+ if (color)
170
+ (material.color as InstanceType<typeof THREE.Color>).set(color);
171
+ }
172
+ }
173
+ });
174
+ requestRender();
175
+ };
176
+
177
+ try {
178
+ const url = new URL(host.dataset.src!, document.baseURI);
179
+ const response = await fetch(url, { signal });
180
+ if (!response.ok)
181
+ throw new Error(
182
+ `The model could not be loaded (HTTP ${response.status}). Download the original to inspect it.`,
183
+ );
184
+ const buffer = await response.arrayBuffer();
185
+ signal.throwIfAborted();
186
+ const gltf = await new GLTFLoader().parseAsync(
187
+ buffer,
188
+ new URL('.', url).href,
189
+ );
190
+ // Parsing textures may finish after a removal; clean these late arrivals as well.
191
+ if (signal.aborted || disposed) {
192
+ disposeObjects(gltf.scenes);
193
+ signal.throwIfAborted();
194
+ throw new Error('Model view was closed.');
195
+ }
196
+ roots.push(...gltf.scenes);
197
+ const box = new THREE.Box3().setFromObject(gltf.scene);
198
+ if (box.isEmpty())
199
+ throw new Error(
200
+ 'This file has no visible model geometry. Download the original to inspect it.',
201
+ );
202
+ const center = box.getCenter(new THREE.Vector3());
203
+ const size = box.getSize(new THREE.Vector3());
204
+ radius = box.getBoundingSphere(new THREE.Sphere()).radius;
205
+ if (!Number.isFinite(radius) || radius <= 0)
206
+ throw new Error('The model has invalid or empty bounds.');
207
+ gltf.scene.position.sub(center);
208
+ scene.add(gltf.scene);
209
+ camera.near = radius / 1000;
210
+ camera.far = radius * 100;
211
+ controls.minDistance = radius * 1.15;
212
+ controls.maxDistance = radius * 18;
213
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x6c6a75, 2.6));
214
+ const key = new THREE.DirectionalLight(0xffffff, 3.2);
215
+ key.position.set(radius * 3, radius * 4, radius * 5);
216
+ scene.add(key);
217
+ const rim = new THREE.DirectionalLight(0xffffff, 1.4);
218
+ rim.position.set(-radius * 4, radius, -radius * 2);
219
+ scene.add(rim);
220
+ const grid = new THREE.GridHelper(radius * 5, 20, 0x858585, 0x858585);
221
+ grid.position.y = -size.y / 2 - radius * 0.1;
222
+ for (const material of Array.isArray(grid.material)
223
+ ? grid.material
224
+ : [grid.material]) {
225
+ material.transparent = true;
226
+ material.opacity = 0.24;
227
+ material.depthWrite = false;
228
+ material.userData.alkForeground = true;
229
+ }
230
+ scene.add(grid);
231
+
232
+ let triangles = 0;
233
+ gltf.scene.traverse((object) => {
234
+ const mesh = object as Mesh;
235
+ if (mesh.isMesh)
236
+ triangles +=
237
+ (mesh.geometry.index?.count ??
238
+ mesh.geometry.getAttribute('position')?.count ??
239
+ 0) / 3;
240
+ });
241
+ const status = host.querySelector<HTMLElement>('.alk-model-status')!;
242
+ status.textContent = `${Math.round(triangles).toLocaleString()} triangles · real glTF geometry`;
243
+ for (const control of host.querySelectorAll<
244
+ HTMLButtonElement | HTMLInputElement
245
+ >('button, input'))
246
+ control.disabled = false;
247
+ const spin = host.querySelector<HTMLInputElement>('[data-spin]')!;
248
+ const wireframe = host.querySelector<HTMLInputElement>('[data-wireframe]')!;
249
+ spin.checked = false;
250
+ wireframe.checked = false;
251
+ const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
252
+ const setSpin = () => {
253
+ controls.autoRotate = spin.checked;
254
+ previousTime = 0;
255
+ requestRender();
256
+ };
257
+ spin.addEventListener('change', setSpin, { signal: events.signal });
258
+ reducedMotion.addEventListener(
259
+ 'change',
260
+ () => {
261
+ if (reducedMotion.matches) {
262
+ spin.checked = false;
263
+ setSpin();
264
+ }
265
+ },
266
+ { signal: events.signal },
267
+ );
268
+ wireframe.addEventListener(
269
+ 'change',
270
+ () => {
271
+ gltf.scene.traverse((object) => {
272
+ const mesh = object as Mesh;
273
+ if (!mesh.material) return;
274
+ for (const material of Array.isArray(mesh.material)
275
+ ? mesh.material
276
+ : [mesh.material]) {
277
+ if ('wireframe' in material) {
278
+ material.wireframe = wireframe.checked;
279
+ material.needsUpdate = true;
280
+ }
281
+ }
282
+ });
283
+ requestRender();
284
+ },
285
+ { signal: events.signal },
286
+ );
287
+ for (const button of host.querySelectorAll<HTMLButtonElement>(
288
+ '[data-view]',
289
+ ))
290
+ button.addEventListener('click', () => preset(button.dataset.view!), {
291
+ signal: events.signal,
292
+ });
293
+ canvas.addEventListener(
294
+ 'keydown',
295
+ (event) => {
296
+ const offset = camera.position.clone().sub(controls.target);
297
+ const spherical = new THREE.Spherical().setFromVector3(offset);
298
+ switch (event.key) {
299
+ case 'ArrowLeft':
300
+ spherical.theta -= 0.14;
301
+ break;
302
+ case 'ArrowRight':
303
+ spherical.theta += 0.14;
304
+ break;
305
+ case 'ArrowUp':
306
+ spherical.phi -= 0.14;
307
+ break;
308
+ case 'ArrowDown':
309
+ spherical.phi += 0.14;
310
+ break;
311
+ case '+':
312
+ case '=':
313
+ spherical.radius *= 0.9;
314
+ break;
315
+ case '-':
316
+ case '_':
317
+ spherical.radius *= 1.1;
318
+ break;
319
+ case 'Home':
320
+ event.preventDefault();
321
+ preset('perspective');
322
+ return;
323
+ default:
324
+ return;
325
+ }
326
+ event.preventDefault();
327
+ spherical.makeSafe();
328
+ spherical.radius = THREE.MathUtils.clamp(
329
+ spherical.radius,
330
+ controls.minDistance,
331
+ controls.maxDistance,
332
+ );
333
+ camera.position
334
+ .copy(controls.target)
335
+ .add(new THREE.Vector3().setFromSpherical(spherical));
336
+ controls.update();
337
+ },
338
+ { signal: events.signal },
339
+ );
340
+ canvas.addEventListener(
341
+ 'webglcontextlost',
342
+ (event) => {
343
+ event.preventDefault();
344
+ reportError(
345
+ 'The 3D graphics context was lost. Reload to retry, or download the model.',
346
+ );
347
+ },
348
+ { signal: events.signal },
349
+ );
350
+ controls.addEventListener('change', requestRender);
351
+ window.addEventListener('alk:theme-change', updateTheme, {
352
+ signal: events.signal,
353
+ });
354
+ matchMedia('(prefers-color-scheme: dark)').addEventListener(
355
+ 'change',
356
+ updateTheme,
357
+ { signal: events.signal },
358
+ );
359
+ document.addEventListener(
360
+ 'visibilitychange',
361
+ () => {
362
+ if (document.hidden) pause();
363
+ else requestRender();
364
+ },
365
+ { signal: events.signal },
366
+ );
367
+ const resize = () => {
368
+ const width = viewport.clientWidth;
369
+ const height = viewport.clientHeight;
370
+ if (!width || !height) return;
371
+ const previousAspect = camera.aspect;
372
+ camera.aspect = width / height;
373
+ camera.updateProjectionMatrix();
374
+ renderer.setSize(width, height, false);
375
+ if (previousAspect !== camera.aspect) preset('perspective');
376
+ requestRender();
377
+ };
378
+ ready = true;
379
+ resizeObserver = new ResizeObserver(resize);
380
+ resizeObserver.observe(viewport);
381
+ resize();
382
+ preset('perspective');
383
+ updateTheme();
384
+ return {
385
+ dispose,
386
+ setVisible: (value) => {
387
+ visible = value;
388
+ if (value) requestRender();
389
+ else pause();
390
+ },
391
+ };
392
+ } catch (error) {
393
+ dispose();
394
+ throw error;
395
+ }
396
+ }
397
+
398
+ class AlkModelElement extends HTMLElement {
399
+ private runtime?: ModelRuntime;
400
+ private load?: AbortController;
401
+ private lifetime?: AbortController;
402
+ private observer?: IntersectionObserver;
403
+ private visible = false;
404
+
405
+ connectedCallback() {
406
+ if (this.lifetime) return;
407
+ this.lifetime = new AbortController();
408
+ window.addEventListener('pagehide', () => this.stop(), {
409
+ signal: this.lifetime.signal,
410
+ });
411
+ window.addEventListener('pageshow', () => this.observe(), {
412
+ signal: this.lifetime.signal,
413
+ });
414
+ this.observe();
415
+ }
416
+
417
+ disconnectedCallback() {
418
+ this.lifetime?.abort();
419
+ this.lifetime = undefined;
420
+ this.stop();
421
+ }
422
+
423
+ private observe() {
424
+ if (this.observer || !this.isConnected) return;
425
+ this.observer = new IntersectionObserver(
426
+ (entries) => {
427
+ this.visible = entries[0]?.isIntersecting ?? false;
428
+ this.runtime?.setVisible(this.visible);
429
+ if (this.visible && !this.load && this.dataset.state !== 'error')
430
+ void this.start();
431
+ },
432
+ { rootMargin: '0px', threshold: 0.01 },
433
+ );
434
+ this.observer.observe(this);
435
+ }
436
+
437
+ private async start() {
438
+ const load = new AbortController();
439
+ this.load = load;
440
+ this.dataset.state = 'loading';
441
+ this.querySelector<HTMLElement>('.alk-model-viewport')!.setAttribute(
442
+ 'aria-busy',
443
+ 'true',
444
+ );
445
+ this.querySelector<HTMLElement>('.alk-model-status')!.textContent =
446
+ 'Loading interactive geometry…';
447
+ try {
448
+ const runtime = await createModel(this, load.signal, (message) =>
449
+ this.fail(message),
450
+ );
451
+ if (load.signal.aborted || !this.isConnected) {
452
+ runtime.dispose();
453
+ return;
454
+ }
455
+ this.runtime = runtime;
456
+ this.dataset.state = 'ready';
457
+ this.querySelector<HTMLElement>('.alk-model-viewport')!.setAttribute(
458
+ 'aria-busy',
459
+ 'false',
460
+ );
461
+ runtime.setVisible(this.visible);
462
+ } catch (error) {
463
+ if (load.signal.aborted) return;
464
+ this.fail(
465
+ error instanceof Error
466
+ ? error.message
467
+ : 'The interactive model could not be loaded. The original file is available below.',
468
+ );
469
+ }
470
+ }
471
+
472
+ private fail(message: string) {
473
+ this.runtime?.dispose();
474
+ this.runtime = undefined;
475
+ this.dataset.state = 'error';
476
+ this.querySelector<HTMLElement>('.alk-model-viewport')!.setAttribute(
477
+ 'aria-busy',
478
+ 'false',
479
+ );
480
+ this.querySelector<HTMLElement>('.alk-model-status')!.textContent = message;
481
+ for (const control of this.querySelectorAll<
482
+ HTMLButtonElement | HTMLInputElement
483
+ >('button, input'))
484
+ control.disabled = true;
485
+ }
486
+
487
+ private stop() {
488
+ this.observer?.disconnect();
489
+ this.observer = undefined;
490
+ this.load?.abort();
491
+ this.load = undefined;
492
+ this.runtime?.dispose();
493
+ this.runtime = undefined;
494
+ this.dataset.state = 'idle';
495
+ this.querySelector<HTMLElement>('.alk-model-viewport')!.setAttribute(
496
+ 'aria-busy',
497
+ 'false',
498
+ );
499
+ this.querySelector<HTMLElement>('.alk-model-status')!.textContent =
500
+ 'Interactive view loads when visible.';
501
+ for (const control of this.querySelectorAll<
502
+ HTMLButtonElement | HTMLInputElement
503
+ >('button, input'))
504
+ control.disabled = true;
505
+ }
506
+ }
507
+
508
+ if (!customElements.get('alk-model'))
509
+ customElements.define('alk-model', AlkModelElement);
package/src/shader.css ADDED
@@ -0,0 +1,86 @@
1
+ alk-shader.alk-shader {
2
+ display: block;
3
+ margin: 1.5rem 0;
4
+ min-width: 0;
5
+ }
6
+ .alk-shader-viewport {
7
+ position: relative;
8
+ height: clamp(280px, 42vw, 520px);
9
+ overflow: hidden;
10
+ background: var(--alk-paper, #fff);
11
+ }
12
+ .alk-shader-viewport canvas,
13
+ .alk-shader-poster {
14
+ display: block;
15
+ width: 100%;
16
+ height: 100%;
17
+ }
18
+ .alk-shader-viewport canvas {
19
+ visibility: hidden;
20
+ position: absolute;
21
+ inset: 0;
22
+ }
23
+ .alk-shader[data-state='ready'] canvas {
24
+ visibility: visible;
25
+ }
26
+ .alk-shader[data-state='ready'] .alk-shader-poster {
27
+ visibility: hidden;
28
+ }
29
+ .alk-shader-coordinate {
30
+ position: absolute;
31
+ left: var(--alk-figure-gutter);
32
+ bottom: 1rem;
33
+ margin: 0;
34
+ background: var(--alk-paper, #fff);
35
+ color: var(--alk-muted, #5e5e5e);
36
+ font: 0.7rem/1.6 var(--alk-mono, ui-monospace, monospace);
37
+ padding: 0.3rem 0.5rem;
38
+ pointer-events: none;
39
+ }
40
+ .alk-shader-controls.alk-figure-controls {
41
+ display: grid;
42
+ grid-template-columns: 1fr 1fr auto;
43
+ gap: 1.25rem;
44
+ }
45
+ .alk-shader-controls label {
46
+ display: block;
47
+ min-width: 0;
48
+ }
49
+ .alk-shader-controls label > span {
50
+ display: flex;
51
+ gap: 0.6rem;
52
+ justify-content: space-between;
53
+ margin-bottom: 0.4rem;
54
+ }
55
+ .alk-shader-controls output {
56
+ color: var(--alk-muted, #5e5e5e);
57
+ }
58
+ .alk-shader-controls input {
59
+ display: block;
60
+ width: 100%;
61
+ accent-color: var(--alk-ink-violet, #a464e3);
62
+ }
63
+ @media (max-width: 600px) {
64
+ .alk-shader-controls.alk-figure-controls {
65
+ grid-template-columns: 1fr 1fr;
66
+ gap: 1rem;
67
+ }
68
+ .alk-shader-controls button {
69
+ grid-column: 1 / -1;
70
+ }
71
+ .alk-shader-coordinate {
72
+ font-size: 0.6rem;
73
+ }
74
+ }
75
+ @media print {
76
+ .alk-shader canvas,
77
+ .alk-shader-coordinate {
78
+ display: none;
79
+ }
80
+ .alk-shader .alk-shader-poster {
81
+ visibility: visible !important;
82
+ }
83
+ .alk-shader-viewport {
84
+ height: 300px;
85
+ }
86
+ }