@celestia-island/hikari 0.55.97 → 0.55.99

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.
@@ -0,0 +1,620 @@
1
+ /**
2
+ * HkBoard3D — the shared 3D scene surface.
3
+ *
4
+ * The 3D counterpart of HkBoard: ONE world-camera canvas that every
5
+ * scene-like page builds on (fleet star systems, holographic device
6
+ * twins, demo facility corridors). The board owns the renderer, the
7
+ * camera, the frame loop, picking, CSS2D labels, theme sync and the
8
+ * stereoscopic minimap; the page owns the content:
9
+ *
10
+ * - CAMERA: perspective + OrbitControls (damped orbit / wheel zoom /
11
+ * right-or-two-finger pan), distance clamps, `frameAll` fit and
12
+ * animated `flyTo`;
13
+ * - CONTENT: `engine.setObject(id, def)` registers an Object3D as
14
+ * addressable content — it joins framing, minimap markers and (when
15
+ * `pickable`) raycast picking; `label` attaches a CSS2D chip that
16
+ * follows the object through orbits;
17
+ * - PICKING: click (press-release inside a 4 px slop) and hover both
18
+ * raycast the pickable set and surface the REGISTERED id (children
19
+ * walk up to the registered root via userData);
20
+ * - LABELS: a CSS2DRenderer overlay — HTML chips that track world
21
+ * anchors, pointer-events opt-in per chip so drags still orbit;
22
+ * - MINIMAP: HkMinimap3D pinned bottom-right — a second, stereoscopic
23
+ * renderer onto the SAME scene with the main camera glyph, its view
24
+ * plane and content markers layered in;
25
+ * - THEME: scene background follows --color-background, re-read on
26
+ * data-mode / data-theme / style mutations; `engine.onTheme` lets the
27
+ * page re-tone its materials on the same signal;
28
+ * - FALLBACK: no WebGL → the `fallback` slot (or a localized note) and
29
+ * an `error` event; nothing throws out of mount.
30
+ *
31
+ * The engine handle arrives through the `ready` event (and is mirrored
32
+ * on the exposed ref) — EXACTLY once per mount, and only on success:
33
+ * when WebGL is unavailable the board renders the `fallback` slot and
34
+ * emits `error` instead, so a consumer registers its content from the
35
+ * ready handler and treats `error` as the inert path.
36
+ *
37
+ * OWNERSHIP: `setObject` hands the Object3D to the board, which adds it
38
+ * to the engine scene — an object that already had a parent is detached
39
+ * from it (three's `Object3D.add` semantics) and that parent is NOT
40
+ * restored when the id is cleared. The board also assumes one id per
41
+ * Object3D: registering the same object under a second id is allowed,
42
+ * but picking always resolves the LAST id, so alias registrations are
43
+ * best avoided. The board never disposes consumer objects — whoever
44
+ * created them disposes them.
45
+ *
46
+ * Everything the engine hands out stays live —
47
+ * scene, camera, controls — so pages with deeper needs (the holographic
48
+ * twin's lighting/world/assets stack) build directly on them.
49
+ */
50
+
51
+ import {
52
+ defineComponent,
53
+ onBeforeUnmount,
54
+ onMounted,
55
+ ref,
56
+ shallowRef,
57
+ watch,
58
+ type PropType,
59
+ } from "vue";
60
+
61
+ import * as THREE from "three";
62
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
63
+ import { CSS2DObject, CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js";
64
+
65
+ import { useI18n } from "../i18n/context";
66
+ import { BOARD3D_HELPERS_LAYER, fitDistance } from "../utils/scene3d";
67
+ import HkMinimap3D from "./HkMinimap3D";
68
+ import "./HkBoard3D.scss";
69
+
70
+ export { BOARD3D_HELPERS_LAYER };
71
+
72
+ export interface Board3DObjectDef {
73
+ /** The scene-graph root for this content item. The board adds/removes
74
+ * it from the scene; the page keeps animating it in place. */
75
+ object: THREE.Object3D;
76
+ /** Join the raycast picking set (default true). */
77
+ pickable?: boolean;
78
+ /** Count toward frameAll / minimap auto-fit bounds (default true). */
79
+ content?: boolean;
80
+ /** HTML chip rendered through the CSS2D overlay, attached as a child of
81
+ * `object` so it follows every animation for free. */
82
+ label?: HTMLElement;
83
+ /** Local offset of the label anchor (default [0, 0, 0]). */
84
+ labelOffset?: [number, number, number];
85
+ /** Minimap marker shape (default "dot"); "none" hides it there. */
86
+ marker?: "dot" | "cube" | "none";
87
+ /** Minimap marker colour (any CSS colour; default theme primary). */
88
+ markerColor?: string;
89
+ }
90
+
91
+ export interface Board3DEngine {
92
+ readonly scene: THREE.Scene;
93
+ readonly camera: THREE.PerspectiveCamera;
94
+ readonly controls: OrbitControls;
95
+ readonly renderer: THREE.WebGLRenderer;
96
+ /** Pre-render hook (page animations); returns the disposer. */
97
+ addTick(fn: (dt: number, elapsed: number) => void): () => void;
98
+ /** Post-render hook (the minimap lives here); returns the disposer. */
99
+ addPostRender(fn: () => void): () => void;
100
+ setObject(id: string, def: Board3DObjectDef | null): void;
101
+ /** Snapshot of the registered content (the minimap's marker set).
102
+ * Mutating it does not touch the board — register through
103
+ * `setObject` instead. */
104
+ objects(): ReadonlyMap<string, Board3DObjectDef>;
105
+ /** Animated camera flight; a user grab cancels it. */
106
+ flyTo(
107
+ position: [number, number, number],
108
+ target: [number, number, number],
109
+ durationMs?: number,
110
+ ): void;
111
+ /** Fit the camera onto every visible content object; a no-op
112
+ * while nothing frameable is registered. */
113
+ frameAll(padding?: number, durationMs?: number): void;
114
+ /** World → canvas CSS pixels; `visible` = inside the frustum. */
115
+ projectToScreen(p: [number, number, number]): { x: number; y: number; visible: boolean };
116
+ /** Re-tone hook fired on theme mutations (and once immediately). */
117
+ onTheme(cb: (bg: string, primary: string) => void): void;
118
+ }
119
+
120
+ const CLICK_SLOP_PX = 4;
121
+
122
+ /** A pose component is usable only if all three numbers are finite. */
123
+ function isFiniteVec3(v: readonly number[] | undefined): v is readonly [number, number, number] {
124
+ return (
125
+ Array.isArray(v) &&
126
+ v.length >= 3 &&
127
+ Number.isFinite(v[0]) &&
128
+ Number.isFinite(v[1]) &&
129
+ Number.isFinite(v[2])
130
+ );
131
+ }
132
+
133
+ function readCssColor(varName: string, fallback: string): string {
134
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
135
+ return raw || fallback;
136
+ }
137
+
138
+ function cssRgbToColor(raw: string): THREE.Color {
139
+ const parts = raw.split(/[\s,]+/).map(Number).filter((n) => Number.isFinite(n));
140
+ return new THREE.Color(
141
+ (parts[0] ?? 10) / 255,
142
+ (parts[1] ?? 15) / 255,
143
+ (parts[2] ?? 25) / 255,
144
+ );
145
+ }
146
+
147
+ export default defineComponent({
148
+ name: "HkBoard3D",
149
+ props: {
150
+ /** Wire orbit / zoom / pan gestures (picking always works). */
151
+ interactive: { type: Boolean, default: true },
152
+ /** Pin the stereoscopic minimap bottom-right. */
153
+ minimap: { type: Boolean, default: true },
154
+ /** Vertical field of view in degrees. STATIC after mount (the
155
+ * camera is built in init; remount to change it). */
156
+ fov: { type: Number, default: 45 },
157
+ /** Starting eye / orbit target. STATIC after mount (a non-finite
158
+ * or short array falls back to the defaults). */
159
+ initialPosition: {
160
+ type: Array as unknown as PropType<[number, number, number]>,
161
+ default: () => [22, 16, 26],
162
+ },
163
+ initialTarget: {
164
+ type: Array as unknown as PropType<[number, number, number]>,
165
+ default: () => [0, 0, 0],
166
+ },
167
+ /** Orbit distance window. STATIC after mount (applied to the
168
+ * controls once; a reversed window is normalised). */
169
+ minDistance: { type: Number, default: 2 },
170
+ maxDistance: { type: Number, default: 600 },
171
+ /** Wire two-finger / right-drag panning. STATIC after mount. */
172
+ enablePan: { type: Boolean, default: true },
173
+ /** Freeze the frame loop (content keeps its last pose). An animated
174
+ * flight issued while paused lands in a single jump on resume: the
175
+ * tween is evaluated against wall-clock time. */
176
+ paused: { type: Boolean, default: false },
177
+ },
178
+ emits: {
179
+ ready: (_engine: Board3DEngine) => true,
180
+ objectClick: (_id: string) => true,
181
+ objectHover: (_id: string | null) => true,
182
+ error: (_message: string) => true,
183
+ },
184
+ setup(props, { emit, expose, slots }) {
185
+ const { t } = useI18n();
186
+ const containerRef = ref<HTMLElement | null>(null);
187
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
188
+ const failed = ref(false);
189
+ const engineRef = shallowRef<Board3DEngine | null>(null);
190
+
191
+ let renderer: THREE.WebGLRenderer | null = null;
192
+ let labelRenderer: CSS2DRenderer | null = null;
193
+ let scene: THREE.Scene | null = null;
194
+ let camera: THREE.PerspectiveCamera | null = null;
195
+ let controls: OrbitControls | null = null;
196
+ let resizeObs: ResizeObserver | null = null;
197
+ let themeObs: MutationObserver | null = null;
198
+ let rafId = 0;
199
+ let disposed = false;
200
+
201
+ const tickHooks = new Set<(dt: number, elapsed: number) => void>();
202
+ const postHooks = new Set<() => void>();
203
+ const themeHooks = new Set<(bg: string, primary: string) => void>();
204
+ const registry = new Map<string, Board3DObjectDef>();
205
+ /** The CSS2D chip attached for a registered id — tracked so a
206
+ * re-register or a clear detaches it instead of stacking chips. */
207
+ const labelTags = new Map<string, CSS2DObject>();
208
+ const clock = new THREE.Timer();
209
+
210
+ // ── picking state ─────────────────────────────────────────────────
211
+ const raycaster = new THREE.Raycaster();
212
+ const ndc = new THREE.Vector2();
213
+ let hoverDirty = false;
214
+ let lastHover: string | null = null;
215
+ let pressAt: { pointerId: number; x: number; y: number } | null = null;
216
+
217
+ // ── camera tween ──────────────────────────────────────────────────
218
+ let tween: {
219
+ fromPos: THREE.Vector3;
220
+ toPos: THREE.Vector3;
221
+ fromTarget: THREE.Vector3;
222
+ toTarget: THREE.Vector3;
223
+ start: number;
224
+ duration: number;
225
+ } | null = null;
226
+
227
+ function applyTheme(): void {
228
+ const bgRaw = readCssColor("--color-background", "10 15 25");
229
+ const primaryRaw = readCssColor("--color-primary", "21 101 192");
230
+ const bg = cssRgbToColor(bgRaw);
231
+ if (scene) scene.background = bg;
232
+ if (renderer) renderer.setClearColor(bg, 1);
233
+ for (const cb of themeHooks) cb(bgRaw, primaryRaw);
234
+ }
235
+
236
+ function refreshSize(): void {
237
+ const el = containerRef.value;
238
+ if (!el || !camera || !renderer) return;
239
+ const w = Math.max(el.clientWidth, 1);
240
+ const h = Math.max(el.clientHeight, 1);
241
+ camera.aspect = w / h;
242
+ camera.updateProjectionMatrix();
243
+ renderer.setSize(w, h);
244
+ labelRenderer?.setSize(w, h);
245
+ }
246
+
247
+ function pickAt(clientX: number, clientY: number): string | null {
248
+ const el = canvasRef.value;
249
+ if (!el || !camera || !scene) return null;
250
+ // getBoundingClientRect is DRAWN pixels — correct even under a
251
+ // scaled root (the 2D board's pxScale equivalent for raycasts).
252
+ const rect = el.getBoundingClientRect();
253
+ if (rect.width <= 0 || rect.height <= 0) return null;
254
+ ndc.set(
255
+ ((clientX - rect.left) / rect.width) * 2 - 1,
256
+ -((clientY - rect.top) / rect.height) * 2 + 1,
257
+ );
258
+ raycaster.setFromCamera(ndc, camera);
259
+ const pickables: THREE.Object3D[] = [];
260
+ for (const [id, def] of registry) {
261
+ if (def.pickable === false) continue;
262
+ if (!def.object.userData.board3dId) def.object.userData.board3dId = id;
263
+ pickables.push(def.object);
264
+ }
265
+ const hits = raycaster.intersectObjects(pickables, true);
266
+ for (const hit of hits) {
267
+ let node: THREE.Object3D | null = hit.object;
268
+ while (node) {
269
+ const id = node.userData?.board3dId as string | undefined;
270
+ // `id !== undefined`, not truthiness: "" is a legal id.
271
+ if (id !== undefined && registry.has(id)) return id;
272
+ node = node.parent;
273
+ }
274
+ }
275
+ return null;
276
+ }
277
+
278
+ const lastPointer = { x: 0, y: 0 };
279
+
280
+ function onPointerMove(e: PointerEvent): void {
281
+ hoverDirty = true;
282
+ lastPointer.x = e.clientX;
283
+ lastPointer.y = e.clientY;
284
+ }
285
+
286
+ function onPointerDown(e: PointerEvent): void {
287
+ pressAt = { pointerId: e.pointerId, x: e.clientX, y: e.clientY };
288
+ // Capture every press: up/cancel are only guaranteed while capture
289
+ // holds, and without it a pointer released off-canvas would leave a
290
+ // stale press to synthesize a phantom click (HkBoard's convention).
291
+ canvasRef.value?.setPointerCapture?.(e.pointerId);
292
+ tween = null; // a grab cancels any in-flight camera animation
293
+ }
294
+
295
+ function onPointerUp(e: PointerEvent): void {
296
+ const p = pressAt;
297
+ pressAt = null;
298
+ if (!p) return;
299
+ // Only the pressing pointer's release clicks; the slop stays the
300
+ // Manhattan metric HkBoard uses (|dx| + |dy|).
301
+ if (p.pointerId !== e.pointerId) return;
302
+ if (Math.abs(e.clientX - p.x) + Math.abs(e.clientY - p.y) > CLICK_SLOP_PX) return;
303
+ const id = pickAt(e.clientX, e.clientY);
304
+ if (id) emit("objectClick", id);
305
+ }
306
+
307
+ function onPointerCancel(): void {
308
+ // An aborted gesture must never synthesize a click: prune only.
309
+ pressAt = null;
310
+ }
311
+
312
+ /** Capture-loss backstop: up/cancel are only guaranteed while pointer
313
+ * capture holds, so a pointer released without an up is pruned here
314
+ * (fires on the capture target and does not bubble → capture phase;
315
+ * a no-op after a normal up). */
316
+ function onLostPointerCapture(): void {
317
+ pressAt = null;
318
+ }
319
+
320
+ function easeInOutCubic(x: number): number {
321
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
322
+ }
323
+
324
+ function frame(): void {
325
+ if (disposed) return;
326
+ rafId = requestAnimationFrame(frame);
327
+ if (props.paused || !renderer || !scene || !camera) return;
328
+ clock.update();
329
+ const dt = Math.min(clock.getDelta(), 0.1);
330
+ const elapsed = clock.getElapsed();
331
+
332
+ if (tween) {
333
+ const t = Math.min(1, (performance.now() - tween.start) / tween.duration);
334
+ const k = easeInOutCubic(t);
335
+ camera.position.lerpVectors(tween.fromPos, tween.toPos, k);
336
+ controls?.target.lerpVectors(tween.fromTarget, tween.toTarget, k);
337
+ if (t >= 1) tween = null;
338
+ }
339
+
340
+ for (const fn of tickHooks) fn(dt, elapsed);
341
+ controls?.update();
342
+
343
+ if (hoverDirty) {
344
+ hoverDirty = false;
345
+ const id = pickAt(lastPointer.x, lastPointer.y);
346
+ if (id !== lastHover) {
347
+ lastHover = id;
348
+ if (canvasRef.value) {
349
+ canvasRef.value.style.cursor = id ? "pointer" : "";
350
+ }
351
+ emit("objectHover", id);
352
+ }
353
+ }
354
+
355
+ renderer.render(scene, camera);
356
+ labelRenderer?.render(scene, camera);
357
+ for (const fn of postHooks) fn();
358
+ }
359
+
360
+ function init(): void {
361
+ const canvas = canvasRef.value;
362
+ const container = containerRef.value;
363
+ if (!canvas || !container) return;
364
+
365
+ scene = new THREE.Scene();
366
+ // A degenerate fov (≤ 0 or ≥ 180) silently bricks the projection —
367
+ // projectionMatrix[5] becomes Infinity/negative, projection and
368
+ // picking return NaN, and nothing throws. Fall back to the default.
369
+ const fov = Number.isFinite(props.fov) && props.fov > 0 && props.fov < 180 ? props.fov : 45;
370
+ camera = new THREE.PerspectiveCamera(fov, 1, 0.1, 4000);
371
+ // Guard the initial pose too: a non-finite component (an untyped
372
+ // caller passing a short/garbage array) would poison every
373
+ // projection for the board's whole lifetime.
374
+ const initPos = isFiniteVec3(props.initialPosition) ? props.initialPosition : [22, 16, 26];
375
+ const initTarget = isFiniteVec3(props.initialTarget) ? props.initialTarget : [0, 0, 0];
376
+ camera.position.set(initPos[0], initPos[1], initPos[2]);
377
+ camera.lookAt(initTarget[0], initTarget[1], initTarget[2]);
378
+
379
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
380
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
381
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
382
+ renderer.toneMappingExposure = 1.15;
383
+ renderer.shadowMap.enabled = true;
384
+
385
+ labelRenderer = new CSS2DRenderer();
386
+ labelRenderer.domElement.style.position = "absolute";
387
+ labelRenderer.domElement.style.inset = "0";
388
+ labelRenderer.domElement.style.pointerEvents = "none";
389
+ container.appendChild(labelRenderer.domElement);
390
+
391
+ controls = new OrbitControls(camera, canvas);
392
+ controls.enableDamping = true;
393
+ controls.dampingFactor = 0.08;
394
+ // A reversed window (min > max) is not rejected by OrbitControls —
395
+ // it clamps the radius to `max(min, min(max, r))`, pinning the eye
396
+ // at min forever and defeating every frameAll. Normalise instead.
397
+ controls.minDistance = Math.min(props.minDistance, props.maxDistance);
398
+ controls.maxDistance = Math.max(props.minDistance, props.maxDistance);
399
+ controls.enablePan = props.enablePan;
400
+ controls.target.set(initTarget[0], initTarget[1], initTarget[2]);
401
+ controls.enabled = props.interactive;
402
+ controls.update();
403
+
404
+ applyTheme();
405
+ themeObs = new MutationObserver(applyTheme);
406
+ themeObs.observe(document.documentElement, {
407
+ attributes: true,
408
+ attributeFilter: ["data-mode", "data-theme", "style", "class"],
409
+ });
410
+
411
+ refreshSize();
412
+ resizeObs = new ResizeObserver(refreshSize);
413
+ resizeObs.observe(container);
414
+
415
+ canvas.addEventListener("pointermove", onPointerMove);
416
+ canvas.addEventListener("pointerdown", onPointerDown);
417
+ canvas.addEventListener("pointerup", onPointerUp);
418
+ canvas.addEventListener("pointercancel", onPointerCancel);
419
+ canvas.addEventListener("lostpointercapture", onLostPointerCapture, true);
420
+
421
+ const engine: Board3DEngine = {
422
+ scene,
423
+ camera,
424
+ controls,
425
+ renderer,
426
+ addTick(fn) {
427
+ tickHooks.add(fn);
428
+ return () => tickHooks.delete(fn);
429
+ },
430
+ addPostRender(fn) {
431
+ postHooks.add(fn);
432
+ return () => postHooks.delete(fn);
433
+ },
434
+ setObject(id, def) {
435
+ const prev = registry.get(id);
436
+ if (prev) {
437
+ // One Object3D can be registered under several ids; only
438
+ // detach it from the scene when no other id still owns it.
439
+ const stillOwned = [...registry.entries()].some(
440
+ ([other, otherDef]) => other !== id && otherDef.object === prev.object,
441
+ );
442
+ if (!stillOwned) scene?.remove(prev.object);
443
+ registry.delete(id);
444
+ }
445
+ // The label tag is owned per id: re-registering (or clearing) an
446
+ // id must not stack a second chip on the same anchor.
447
+ const prevTag = labelTags.get(id);
448
+ if (prevTag) {
449
+ prevTag.removeFromParent();
450
+ labelTags.delete(id);
451
+ }
452
+ if (!def) return;
453
+ def.object.userData.board3dId = id;
454
+ if (def.label) {
455
+ def.label.classList.add("hk-board3d-label");
456
+ const tag = new CSS2DObject(def.label);
457
+ const off = def.labelOffset ?? [0, 0, 0];
458
+ tag.position.set(off[0], off[1], off[2]);
459
+ def.object.add(tag);
460
+ labelTags.set(id, tag);
461
+ }
462
+ registry.set(id, def);
463
+ scene?.add(def.object);
464
+ },
465
+ // A snapshot: a consumer that casts it to Map and clears it must
466
+ // not be able to desynchronise the board from its scene.
467
+ objects: () => new Map(registry),
468
+ flyTo(position, target, durationMs = 600) {
469
+ if (!camera || !controls) return;
470
+ // A non-finite pose would brick the camera silently (every later
471
+ // projection returns NaN) — ignore the call instead.
472
+ if (!isFiniteVec3(position) || !isFiniteVec3(target)) return;
473
+ if (durationMs <= 0) {
474
+ // An instant flight must ALSO cancel any in-flight tween —
475
+ // otherwise the next frame re-applies the old animation and
476
+ // undoes this pose (frameAll's default duration is 0).
477
+ tween = null;
478
+ camera.position.set(...position);
479
+ controls.target.set(...target);
480
+ controls.update();
481
+ return;
482
+ }
483
+ tween = {
484
+ fromPos: camera.position.clone(),
485
+ toPos: new THREE.Vector3(...position),
486
+ fromTarget: controls.target.clone(),
487
+ toTarget: new THREE.Vector3(...target),
488
+ start: performance.now(),
489
+ duration: durationMs,
490
+ };
491
+ },
492
+ frameAll(padding = 1.3, durationMs = 0) {
493
+ if (!camera || !controls) return;
494
+ const box = new THREE.Box3();
495
+ let any = false;
496
+ for (const def of registry.values()) {
497
+ if (def.content === false) continue;
498
+ // Skip what the MAIN camera cannot see (the minimap's helpers
499
+ // live on their own layer): framing must never chase them.
500
+ if (!def.object.layers.test(camera.layers)) continue;
501
+ box.expandByObject(def.object);
502
+ any = true;
503
+ }
504
+ // Nothing frameable registered yet (an async page calling
505
+ // frameAll before its content arrives): stay put rather than
506
+ // framing whatever happens to be in the scene.
507
+ if (!any || box.isEmpty()) return;
508
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
509
+ const dist = fitDistance(sphere.radius, camera.fov, camera.aspect) * padding;
510
+ // Keep the current viewing direction — a fit reframes, it
511
+ // never whips the user around to a canonical side.
512
+ const dir = camera.position.clone().sub(controls.target);
513
+ if (dir.lengthSq() < 1e-8) dir.set(0.6, 0.45, 1);
514
+ dir.normalize().multiplyScalar(dist);
515
+ const pos = sphere.center.clone().add(dir);
516
+ engine.flyTo(
517
+ [pos.x, pos.y, pos.z],
518
+ [sphere.center.x, sphere.center.y, sphere.center.z],
519
+ durationMs,
520
+ );
521
+ },
522
+ projectToScreen(p) {
523
+ const el = containerRef.value;
524
+ if (!el || !camera) return { x: 0, y: 0, visible: false };
525
+ // Correct even before the first frame: the renderer normally
526
+ // owns matrix maintenance, a direct query must not depend on it.
527
+ camera.updateMatrixWorld();
528
+ const v = new THREE.Vector3(p[0], p[1], p[2]).project(camera);
529
+ return {
530
+ x: ((v.x + 1) / 2) * el.clientWidth,
531
+ y: ((1 - v.y) / 2) * el.clientHeight,
532
+ visible: v.z < 1 && v.x >= -1 && v.x <= 1 && v.y >= -1 && v.y <= 1,
533
+ };
534
+ },
535
+ onTheme(cb) {
536
+ themeHooks.add(cb);
537
+ const bgRaw = readCssColor("--color-background", "10 15 25");
538
+ const primaryRaw = readCssColor("--color-primary", "21 101 192");
539
+ cb(bgRaw, primaryRaw);
540
+ },
541
+ };
542
+
543
+ engineRef.value = engine;
544
+ rafId = requestAnimationFrame(frame);
545
+ emit("ready", engine);
546
+ }
547
+
548
+ watch(
549
+ () => props.interactive,
550
+ (v) => {
551
+ if (controls) controls.enabled = v;
552
+ },
553
+ );
554
+
555
+ onMounted(() => {
556
+ try {
557
+ init();
558
+ } catch (err) {
559
+ // No WebGL (old device, headless test DOM): the page keeps its
560
+ // HTML fallback instead of dying mid-mount.
561
+ failed.value = true;
562
+ emit("error", err instanceof Error ? err.message : String(err));
563
+ }
564
+ });
565
+
566
+ onBeforeUnmount(() => {
567
+ disposed = true;
568
+ cancelAnimationFrame(rafId);
569
+ resizeObs?.disconnect();
570
+ themeObs?.disconnect();
571
+ const canvas = canvasRef.value;
572
+ if (canvas) {
573
+ canvas.removeEventListener("pointermove", onPointerMove);
574
+ canvas.removeEventListener("pointerdown", onPointerDown);
575
+ canvas.removeEventListener("pointerup", onPointerUp);
576
+ canvas.removeEventListener("pointercancel", onPointerCancel);
577
+ canvas.removeEventListener("lostpointercapture", onLostPointerCapture, true);
578
+ }
579
+ controls?.dispose();
580
+ if (labelRenderer?.domElement.parentNode) {
581
+ labelRenderer.domElement.parentNode.removeChild(labelRenderer.domElement);
582
+ }
583
+ for (const tag of labelTags.values()) tag.removeFromParent();
584
+ labelTags.clear();
585
+ renderer?.dispose();
586
+ // Free the GL context eagerly: a SPA that remounts the board can
587
+ // otherwise brush the browser's live-context cap.
588
+ renderer?.forceContextLoss?.();
589
+ registry.clear();
590
+ tickHooks.clear();
591
+ postHooks.clear();
592
+ themeHooks.clear();
593
+ engineRef.value = null;
594
+ });
595
+
596
+ expose({
597
+ /** Null until the `ready` event — prefer the event. */
598
+ engine: engineRef,
599
+ });
600
+
601
+ return () => (
602
+ <div class="hk-board3d" ref={containerRef}>
603
+ <canvas ref={canvasRef} class="hk-board3d-canvas" />
604
+ {failed.value ? (
605
+ slots.fallback ? (
606
+ slots.fallback()
607
+ ) : (
608
+ <div class="hk-board3d-fallback">
609
+ {t("hikari::board3d.unavailable", "3D is unavailable on this device")}
610
+ </div>
611
+ )
612
+ ) : null}
613
+ {props.minimap && engineRef.value && !failed.value ? (
614
+ <HkMinimap3D engine={engineRef.value} />
615
+ ) : null}
616
+ {slots.default ? <div class="hk-board3d-overlay">{slots.default()}</div> : null}
617
+ </div>
618
+ );
619
+ },
620
+ });
@@ -208,13 +208,18 @@
208
208
  user-select: none;
209
209
  }
210
210
 
211
- // Fixed-height details pane: the raw payload keeps a stable ~20vh
212
- // footprint. Folding every JSON node still leaves the pane standing at
213
- // the same height, and a long stack trace can never stretch the card —
214
- // the family overlay scrollbar (attachOverlayScrollbars) carries the
215
- // overflow inside the frame. The pane div is the track's positioning
211
+ // Standing-frame details pane: the raw payload keeps a STABLE height —
212
+ // the larger of a 9rem floor and ~20vh — so the frame pre-elevates a
213
+ // little but NEVER breathes with the content. The 2026-10-01
214
+ // content-hugging frame shipped and was reverted the same day after
215
+ // mobile review: folding the tree shrank the whole error card with it
216
+ // and the jumping height read as broken. Folding only changes what is
217
+ // INSIDE the frame; a long stack trace scrolls inside it instead of
218
+ // stretching the card. The 9rem term keeps short viewports (20vh < 9rem)
219
+ // at nine rem of scroll area. The pane div is the track's positioning
216
220
  // context and wraps EXACTLY the scrolling body (useOverlayScrollbar
217
- // host contract).
221
+ // host contract); the family overlay scrollbar's observers keep the
222
+ // thumb geometry fresh on fold/expand.
218
223
  .hk-error-landing__details-pane {
219
224
  position: relative;
220
225
  margin-top: var(--space-6, 0.375rem);
@@ -226,6 +231,8 @@
226
231
  }
227
232
 
228
233
  .hk-error-landing__details-body {
234
+ // The frame's height is fixed, so the body simply fills it; a fold or
235
+ // an expand re-renders INSIDE the frame and never moves the card.
229
236
  height: 100%;
230
237
  overflow: auto;
231
238
  border-radius: inherit;