@celestia-island/hikari 0.55.96 → 0.55.98

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.
Files changed (32) hide show
  1. package/package.json +5 -1
  2. package/src/board3dSurface.test.ts +74 -0
  3. package/src/components/HkBoard3D.scss +63 -0
  4. package/src/components/HkBoard3D.test.tsx +481 -0
  5. package/src/components/HkBoard3D.tsx +620 -0
  6. package/src/components/HkErrorLanding.detailsfold.contract.test.ts +63 -0
  7. package/src/components/HkErrorLanding.scss +23 -10
  8. package/src/components/HkErrorLanding.test.tsx +2 -1
  9. package/src/components/HkErrorLanding.tsx +13 -10
  10. package/src/components/HkJsonTree.contract.test.ts +28 -0
  11. package/src/components/HkJsonTree.scss +18 -0
  12. package/src/components/HkJsonTree.test.tsx +19 -0
  13. package/src/components/HkJsonTree.tsx +6 -1
  14. package/src/components/HkMinimap3D.scss +13 -0
  15. package/src/components/HkMinimap3D.test.tsx +361 -0
  16. package/src/components/HkMinimap3D.tsx +501 -0
  17. package/src/components/HkSidebar.scss +24 -2
  18. package/src/components/HkSidebar.styles.test.ts +103 -0
  19. package/src/i18n/locales/ar/components.json +3 -1
  20. package/src/i18n/locales/de/components.json +3 -1
  21. package/src/i18n/locales/en/components.json +3 -1
  22. package/src/i18n/locales/es/components.json +3 -1
  23. package/src/i18n/locales/fr/components.json +3 -1
  24. package/src/i18n/locales/ja/components.json +3 -1
  25. package/src/i18n/locales/ko/components.json +3 -1
  26. package/src/i18n/locales/pt/components.json +3 -1
  27. package/src/i18n/locales/ru/components.json +3 -1
  28. package/src/i18n/locales/zh-Hans/components.json +3 -1
  29. package/src/i18n/locales/zh-Hant/components.json +3 -1
  30. package/src/index.ts +24 -0
  31. package/src/utils/scene3d.test.ts +163 -0
  32. package/src/utils/scene3d.ts +175 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.96",
3
+ "version": "0.55.98",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -51,6 +51,8 @@
51
51
  "prepublishOnly": "vue-tsc --noEmit"
52
52
  },
53
53
  "peerDependencies": {
54
+ "@types/three": "^*",
55
+ "three": "^0.185",
54
56
  "vue": "^*"
55
57
  },
56
58
  "dependencies": {
@@ -61,6 +63,8 @@
61
63
  "highlight.js": "^*"
62
64
  },
63
65
  "devDependencies": {
66
+ "@types/three": "^*",
67
+ "three": "^0.185",
64
68
  "@vitejs/plugin-vue": "^*",
65
69
  "@vitejs/plugin-vue-jsx": "^*",
66
70
  "@vue/server-renderer": "^*",
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import * as barrel from "./index";
4
+
5
+ /**
6
+ * The public surface of the 3D board, pinned.
7
+ *
8
+ * hikari ships SOURCE (package main = src/index.ts), so a consumer's
9
+ * `vue-tsc` compiles this barrel and everything it re-exports. Two
10
+ * halves matter and only one of them is testable at runtime:
11
+ *
12
+ * - runtime names (components, constants, utils) — asserted below;
13
+ * - TYPE-only names (the engine handle, the object definition, the vec
14
+ * type) — a runtime assertion can never see them, so they are
15
+ * imported and used in this file instead: `vue-tsc --noEmit` covers
16
+ * every test file, which makes dropping a type export a gate failure
17
+ * rather than a silent break for consumers.
18
+ */
19
+
20
+ type Engine = barrel.Board3DEngine;
21
+ type Def = barrel.Board3DObjectDef;
22
+ type Vec = barrel.Vec3;
23
+
24
+ // Type-only probes: these lines exist so the checker resolves the three
25
+ // exported types through the barrel itself.
26
+ const engineProbe: Engine | null = null;
27
+ const defProbe: Def | null = null;
28
+ const vecProbe: Vec = [0, 0, 0];
29
+ void engineProbe;
30
+ void defProbe;
31
+ void vecProbe;
32
+
33
+ describe("hikari barrel — 3D board surface", () => {
34
+ it("exports both 3D components", () => {
35
+ expect(barrel.HkBoard3D).toBeTruthy();
36
+ expect(barrel.HkMinimap3D).toBeTruthy();
37
+ });
38
+
39
+ it("exports the helper-layer constant consumers need for own decoration", () => {
40
+ expect(barrel.BOARD3D_HELPERS_LAYER).toBe(1);
41
+ });
42
+
43
+ it("exports the scene3d utilities (palette, orbit, fit, zoom)", () => {
44
+ const fns = [
45
+ "hashIdToUnit",
46
+ "lerpPalette",
47
+ "paletteColorForId",
48
+ "orbitDelta",
49
+ "sphericalPosition",
50
+ "fitDistance",
51
+ "viewPlaneHalfExtents",
52
+ "planeCorners",
53
+ "clampMinimapZoom",
54
+ ] as const;
55
+ for (const name of fns) {
56
+ expect(typeof (barrel as Record<string, unknown>)[name], name).toBe("function");
57
+ }
58
+ });
59
+
60
+ it("exports the minimap governance constants, drag sensitivity included", () => {
61
+ // A consumer writing its own drag control needs the same sensitivity
62
+ // the built-in one uses — it must not stay module-private.
63
+ for (const name of [
64
+ "MINIMAP_MIN_POLAR",
65
+ "MINIMAP_MAX_POLAR",
66
+ "MINIMAP_DRAG_RADIANS_PER_PX",
67
+ "MINIMAP_ZOOM_STEP_PERCENT",
68
+ "MINIMAP_MIN_ZOOM_PERCENT",
69
+ "MINIMAP_MAX_ZOOM_PERCENT",
70
+ ] as const) {
71
+ expect(typeof (barrel as Record<string, unknown>)[name], name).toBe("number");
72
+ }
73
+ });
74
+ });
@@ -0,0 +1,63 @@
1
+ // HkBoard3D — the shared 3D scene surface.
2
+ // The renderer owns the pixels; this file only styles the layers (canvas,
3
+ // CSS2D labels, overlay slot, fallback note, minimap dock).
4
+
5
+ .hk-board3d {
6
+ position: relative;
7
+ width: 100%;
8
+ height: 100%;
9
+ min-height: 0;
10
+ overflow: hidden;
11
+ background: rgb(var(--color-surface));
12
+ // Gestures own the surface: no native scroll/selection interference
13
+ // (HkBoard precedent).
14
+ touch-action: none;
15
+ user-select: none;
16
+ }
17
+
18
+ .hk-board3d-canvas {
19
+ position: absolute;
20
+ inset: 0;
21
+ width: 100%;
22
+ height: 100%;
23
+ display: block;
24
+ cursor: grab;
25
+
26
+ &:active {
27
+ cursor: grabbing;
28
+ }
29
+ }
30
+
31
+ // CSS2D label chips: the overlay root is pointer-transparent, chips opt
32
+ // back in so they stay clickable without stealing orbit drags.
33
+ .hk-board3d-label {
34
+ pointer-events: auto;
35
+ white-space: nowrap;
36
+ }
37
+
38
+ // The page's HTML chrome (stat chips, toggles, selection cards) floats
39
+ // above the canvas; children opt into pointer events themselves.
40
+ .hk-board3d-overlay {
41
+ position: absolute;
42
+ inset: 0;
43
+ pointer-events: none;
44
+ z-index: 40;
45
+ }
46
+
47
+ .hk-board3d-fallback {
48
+ position: absolute;
49
+ inset: 0;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ padding: var(--space-16);
54
+ color: rgb(var(--color-muted));
55
+ font-size: var(--text-sm);
56
+ text-align: center;
57
+ }
58
+
59
+ // The 3D minimap docks exactly like the 2D one (HkMinimap.scss supplies
60
+ // .hk-minimap's absolute bottom-right card chrome).
61
+ .hk-board3d .hk-minimap3d {
62
+ z-index: 50;
63
+ }
@@ -0,0 +1,481 @@
1
+ /* happy-dom has no WebGL, so the renderer/controls/label-renderer are fakes
2
+ * over the REAL three core (scene graph, raycaster, cameras all genuine):
3
+ * assertions pin what the board does with the scene — registration, picking,
4
+ * labels, camera flights, theme sync, teardown. */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
8
+ import * as THREE from "three";
9
+
10
+ const H = vi.hoisted(() => {
11
+ class FakeWebGLRenderer {
12
+ static instances: FakeWebGLRenderer[] = [];
13
+ static failNext = false;
14
+ domElement: HTMLCanvasElement;
15
+ renders: Array<{ scene: unknown; camera: unknown }> = [];
16
+ sizes: Array<[number, number]> = [];
17
+ clearColor: unknown = null;
18
+ toneMapping: unknown = null;
19
+ shadowMap = { enabled: false };
20
+ disposed = false;
21
+ constructor(opts: { canvas: HTMLCanvasElement }) {
22
+ if (FakeWebGLRenderer.failNext) {
23
+ FakeWebGLRenderer.failNext = false;
24
+ throw new Error("no webgl (test)");
25
+ }
26
+ this.domElement = opts.canvas;
27
+ FakeWebGLRenderer.instances.push(this);
28
+ }
29
+ setPixelRatio(): void {}
30
+ setSize(w: number, h: number): void { this.sizes.push([w, h]); }
31
+ setClearColor(c: unknown): void { this.clearColor = c; }
32
+ render(scene: unknown, camera: unknown): void {
33
+ // A real renderer maintains world matrices every pass; picking and
34
+ // projection both read them.
35
+ (scene as THREE.Scene).updateMatrixWorld(true);
36
+ (camera as THREE.Camera).updateMatrixWorld();
37
+ this.renders.push({ scene, camera });
38
+ }
39
+ dispose(): void { this.disposed = true; }
40
+ }
41
+ return { FakeWebGLRenderer };
42
+ });
43
+
44
+ vi.mock("three", async (importOriginal) => {
45
+ const actual = await importOriginal<typeof import("three")>();
46
+ return { ...actual, WebGLRenderer: H.FakeWebGLRenderer };
47
+ });
48
+
49
+ vi.mock("three/examples/jsm/controls/OrbitControls.js", async () => {
50
+ const actual = await vi.importActual<typeof import("three")>("three");
51
+ class FakeOrbitControls {
52
+ target = new actual.Vector3();
53
+ enabled = true;
54
+ enableDamping = false;
55
+ dampingFactor = 0;
56
+ minDistance = 0;
57
+ maxDistance = Infinity;
58
+ enablePan = true;
59
+ update(): void {}
60
+ dispose(): void {}
61
+ }
62
+ return { OrbitControls: FakeOrbitControls };
63
+ });
64
+
65
+ vi.mock("three/examples/jsm/renderers/CSS2DRenderer.js", async (importOriginal) => {
66
+ const actual = await importOriginal<
67
+ typeof import("three/examples/jsm/renderers/CSS2DRenderer.js")
68
+ >();
69
+ class FakeCSS2DRenderer {
70
+ domElement: HTMLElement = document.createElement("div");
71
+ setSize(): void {}
72
+ render(): void {}
73
+ }
74
+ return { ...actual, CSS2DRenderer: FakeCSS2DRenderer };
75
+ });
76
+
77
+ import HkBoard3D, { type Board3DEngine } from "./HkBoard3D";
78
+
79
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
80
+
81
+ function mountBoard(props: Record<string, unknown> = {}, on: Record<string, (arg: never) => void> = {}) {
82
+ const container = document.createElement("div");
83
+ document.body.appendChild(container);
84
+ const app = createApp(
85
+ defineComponent({
86
+ setup() {
87
+ return () =>
88
+ h(HkBoard3D, {
89
+ ...props,
90
+ onReady: on.ready as never,
91
+ onObjectClick: on.objectClick as never,
92
+ onObjectHover: on.objectHover as never,
93
+ onError: on.error as never,
94
+ });
95
+ },
96
+ }),
97
+ );
98
+ app.mount(container);
99
+ mounts.push({ app, container });
100
+ return { app, container };
101
+ }
102
+
103
+ /** Wait long enough for the RAF loop to tick at least twice. */
104
+ async function tickFrames(): Promise<void> {
105
+ await new Promise((r) => setTimeout(r, 50));
106
+ }
107
+
108
+ beforeEach(() => {
109
+ H.FakeWebGLRenderer.instances = [];
110
+ H.FakeWebGLRenderer.failNext = false;
111
+ });
112
+
113
+ afterEach(() => {
114
+ for (const m of mounts.splice(0)) {
115
+ m.app.unmount();
116
+ m.container.remove();
117
+ }
118
+ });
119
+
120
+ describe("HkBoard3D", () => {
121
+ it("mounts, builds the renderer over the real scene, and emits ready", async () => {
122
+ let engine: Board3DEngine | null = null;
123
+ const { container } = mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
124
+ await nextTick();
125
+ expect(engine).not.toBeNull();
126
+ expect(engine!.scene).toBeInstanceOf(THREE.Scene);
127
+ expect(engine!.camera).toBeInstanceOf(THREE.PerspectiveCamera);
128
+ expect(H.FakeWebGLRenderer.instances.length).toBeGreaterThanOrEqual(1);
129
+ // The board's renderer is the first (the minimap spins up its own).
130
+ expect(H.FakeWebGLRenderer.instances[0].sizes.length).toBeGreaterThan(0);
131
+ expect(container.querySelector("canvas.hk-board3d-canvas")).not.toBeNull();
132
+ // The 3D minimap docks inside by default.
133
+ expect(container.querySelector(".hk-minimap3d")).not.toBeNull();
134
+ await tickFrames();
135
+ expect(H.FakeWebGLRenderer.instances[0].renders.length).toBeGreaterThan(0);
136
+ });
137
+
138
+ it("hides the minimap when minimap=false", async () => {
139
+ mountBoard({ minimap: false }, {});
140
+ await nextTick();
141
+ const { container } = mounts[0];
142
+ expect(container.querySelector(".hk-minimap3d")).toBeNull();
143
+ });
144
+
145
+ it("setObject registers content into the scene, attaches the CSS2D label", async () => {
146
+ let engine: Board3DEngine | null = null;
147
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
148
+ await nextTick();
149
+
150
+ const body = new THREE.Mesh(
151
+ new THREE.SphereGeometry(1, 8, 6),
152
+ new THREE.MeshBasicMaterial(),
153
+ );
154
+ const label = document.createElement("div");
155
+ label.textContent = "host-node-2";
156
+ engine!.setObject("node-1", { object: body, label, labelOffset: [0, 2, 0] });
157
+
158
+ expect(engine!.scene.children).toContain(body);
159
+ expect(body.userData.board3dId).toBe("node-1");
160
+ expect(label.classList.contains("hk-board3d-label")).toBe(true);
161
+ // Label rides the object as a CSS2D child at the requested offset.
162
+ const tag = body.children.find((c) => (c as unknown as { element?: HTMLElement }).element === label);
163
+ expect(tag).toBeDefined();
164
+ expect(tag!.position.y).toBe(2);
165
+
166
+ engine!.setObject("node-1", null);
167
+ expect(engine!.scene.children).not.toContain(body);
168
+ });
169
+
170
+ it("raycasts a click to the registered object id", async () => {
171
+ let engine: Board3DEngine | null = null;
172
+ const clicks: string[] = [];
173
+ mountBoard(
174
+ {},
175
+ {
176
+ ready: ((e: Board3DEngine) => { engine = e; }) as never,
177
+ objectClick: ((id: string) => clicks.push(id)) as never,
178
+ },
179
+ );
180
+ await nextTick();
181
+ const { container } = mounts[0];
182
+ const canvas = container.querySelector("canvas")!;
183
+
184
+ // happy-dom rects are all zero: give the canvas a real viewport box.
185
+ canvas.getBoundingClientRect = () =>
186
+ ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 }) as DOMRect;
187
+
188
+ const body = new THREE.Mesh(
189
+ new THREE.SphereGeometry(2, 16, 12),
190
+ new THREE.MeshBasicMaterial(),
191
+ );
192
+ engine!.setObject("star-1", { object: body });
193
+
194
+ // Let a frame pass so the render pass has maintained world matrices
195
+ // (the real renderer does this every frame; picking reads them).
196
+ await tickFrames();
197
+
198
+ // Centre of the canvas = the camera's look-at → straight through the sphere.
199
+ canvas.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 400, clientY: 300 }));
200
+ canvas.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 400, clientY: 300 }));
201
+ expect(clicks).toEqual(["star-1"]);
202
+
203
+ // A drag (beyond the slop) is an orbit, not a click.
204
+ canvas.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 400, clientY: 300 }));
205
+ canvas.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 430, clientY: 310 }));
206
+ expect(clicks).toEqual(["star-1"]);
207
+
208
+ // Clicking empty space selects nothing.
209
+ canvas.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 10, clientY: 10 }));
210
+ canvas.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 10, clientY: 10 }));
211
+ expect(clicks).toEqual(["star-1"]);
212
+ });
213
+
214
+ it("frameAll recentres the orbit target onto the content", async () => {
215
+ let engine: Board3DEngine | null = null;
216
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
217
+ await nextTick();
218
+
219
+ const body = new THREE.Mesh(
220
+ new THREE.SphereGeometry(1, 8, 6),
221
+ new THREE.MeshBasicMaterial(),
222
+ );
223
+ body.position.set(50, 0, -30);
224
+ engine!.setObject("far-away", { object: body });
225
+
226
+ engine!.frameAll(1.3, 0);
227
+ const target = engine!.controls.target;
228
+ expect(target.x).toBeCloseTo(50, 3);
229
+ expect(target.z).toBeCloseTo(-30, 3);
230
+ // And the camera pulled back far enough to see it.
231
+ expect(engine!.camera.position.distanceTo(target)).toBeGreaterThan(1);
232
+ });
233
+
234
+ it("frameAll honours its padding argument", async () => {
235
+ let engine: Board3DEngine | null = null;
236
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
237
+ await nextTick();
238
+
239
+ const body = new THREE.Mesh(
240
+ new THREE.SphereGeometry(2, 8, 6),
241
+ new THREE.MeshBasicMaterial(),
242
+ );
243
+ engine!.setObject("body", { object: body });
244
+
245
+ engine!.frameAll(1.3, 0);
246
+ const tight = engine!.camera.position.distanceTo(engine!.controls.target);
247
+ engine!.frameAll(3, 0);
248
+ const loose = engine!.camera.position.distanceTo(engine!.controls.target);
249
+ // More padding must push the camera strictly further out.
250
+ expect(loose).toBeGreaterThan(tight);
251
+ expect(loose / tight).toBeCloseTo(3 / 1.3, 4);
252
+ });
253
+
254
+ it("never picks, hovers or frames objects registered as pickable:false", async () => {
255
+ let engine: Board3DEngine | null = null;
256
+ const clicks: string[] = [];
257
+ const { container } = mountBoard(
258
+ {},
259
+ {
260
+ ready: ((e: Board3DEngine) => { engine = e; }) as never,
261
+ objectClick: ((id: string) => clicks.push(id)) as never,
262
+ },
263
+ );
264
+ await nextTick();
265
+
266
+ const canvas = container.querySelector("canvas")!;
267
+ vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({
268
+ left: 0, top: 0, width: 800, height: 600,
269
+ right: 800, bottom: 600, x: 0, y: 0, toJSON: () => ({}),
270
+ } as DOMRect);
271
+
272
+ // A mesh sitting exactly under the click ray, but registered as
273
+ // decoration: helpers must never become clickable.
274
+ const helper = new THREE.Mesh(
275
+ new THREE.SphereGeometry(4, 12, 8),
276
+ new THREE.MeshBasicMaterial(),
277
+ );
278
+ engine!.setObject("hidden", { object: helper, pickable: false });
279
+ const pickable = new THREE.Mesh(
280
+ new THREE.SphereGeometry(4, 12, 8),
281
+ new THREE.MeshBasicMaterial(),
282
+ );
283
+ pickable.position.set(200, 0, 0); // off the centre ray
284
+ engine!.setObject("visible", { object: pickable });
285
+
286
+ await tickFrames();
287
+ canvas.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 400, clientY: 300 }));
288
+ canvas.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 400, clientY: 300 }));
289
+ expect(clicks).toEqual([]);
290
+ });
291
+
292
+ it("keeps one label chip per id across re-registration and clears it on null", async () => {
293
+ let engine: Board3DEngine | null = null;
294
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
295
+ await nextTick();
296
+
297
+ const body = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), new THREE.MeshBasicMaterial());
298
+ const chip = () => {
299
+ const el = document.createElement("div");
300
+ el.textContent = "chip";
301
+ return el;
302
+ };
303
+ engine!.setObject("node", { object: body, label: chip() });
304
+ const labelChildren = () =>
305
+ body.children.filter((c) => (c as unknown as { isCSS2DObject?: boolean }).isCSS2DObject).length;
306
+ expect(labelChildren()).toBe(1);
307
+
308
+ // Re-registering the same id must REPLACE its chip, not stack a second.
309
+ engine!.setObject("node", { object: body, label: chip() });
310
+ expect(labelChildren()).toBe(1);
311
+
312
+ // Clearing the id detaches the chip from its anchor.
313
+ engine!.setObject("node", null);
314
+ expect(labelChildren()).toBe(0);
315
+ });
316
+
317
+ it("stops calling a tick once its disposer runs", async () => {
318
+ let engine: Board3DEngine | null = null;
319
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
320
+ await nextTick();
321
+
322
+ let calls = 0;
323
+ const stop = engine!.addTick(() => { calls += 1; });
324
+ await tickFrames();
325
+ expect(calls).toBeGreaterThan(0);
326
+
327
+ stop();
328
+ const frozen = calls;
329
+ await tickFrames();
330
+ expect(calls).toBe(frozen);
331
+ });
332
+
333
+ it("an instant flight cancels an in-flight animated one", async () => {
334
+ let engine: Board3DEngine | null = null;
335
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
336
+ await nextTick();
337
+
338
+ const body = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), new THREE.MeshBasicMaterial());
339
+ body.position.set(120, 0, 0);
340
+ engine!.setObject("far", { object: body });
341
+
342
+ // A long animated flight, then the default instant frameAll (the
343
+ // page's "fit everything" button): the fit must survive the next
344
+ // frames instead of being overridden by the stale tween.
345
+ engine!.flyTo([200, 200, 200], [120, 0, 0], 5000);
346
+ engine!.frameAll(1.3, 0);
347
+ const fitted = engine!.camera.position.clone();
348
+ await tickFrames();
349
+ expect(engine!.camera.position.distanceTo(fitted)).toBeLessThan(1e-6);
350
+ });
351
+
352
+ it("keeps an object in the scene while another id still registers it", async () => {
353
+ let engine: Board3DEngine | null = null;
354
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
355
+ await nextTick();
356
+
357
+ const shared = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), new THREE.MeshBasicMaterial());
358
+ engine!.setObject("alias-a", { object: shared });
359
+ engine!.setObject("alias-b", { object: shared });
360
+
361
+ // Clearing ONE id must not unparent an object the other id owns.
362
+ engine!.setObject("alias-a", null);
363
+ expect(engine!.objects().has("alias-b")).toBe(true);
364
+ expect(shared.parent).toBe(engine!.scene);
365
+
366
+ engine!.setObject("alias-b", null);
367
+ expect(shared.parent).toBeNull();
368
+ });
369
+
370
+ it("ignores non-finite camera poses instead of bricking the camera", async () => {
371
+ let engine: Board3DEngine | null = null;
372
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
373
+ await nextTick();
374
+
375
+ const before = engine!.camera.position.clone();
376
+ engine!.flyTo([Number.NaN, 0, 0] as never, [0, 0, 0], 0);
377
+ expect(engine!.camera.position.equals(before)).toBe(true);
378
+ const projected = engine!.projectToScreen([0, 0, 0]);
379
+ expect(Number.isFinite(projected.x)).toBe(true);
380
+ });
381
+
382
+ it("hands out a registry snapshot a consumer cannot corrupt", async () => {
383
+ let engine: Board3DEngine | null = null;
384
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
385
+ await nextTick();
386
+
387
+ const body = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), new THREE.MeshBasicMaterial());
388
+ engine!.setObject("node", { object: body });
389
+
390
+ // A consumer casting the view to a mutable Map must not be able to
391
+ // desynchronise the board from its own scene.
392
+ (engine!.objects() as unknown as Map<string, unknown>).clear();
393
+ expect(engine!.objects().has("node")).toBe(true);
394
+ engine!.setObject("node", null);
395
+ expect(body.parent).toBeNull();
396
+ });
397
+
398
+ it("falls back to a usable fov instead of bricking the projection", async () => {
399
+ let engine: Board3DEngine | null = null;
400
+ mountBoard({ fov: 0 }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
401
+ await nextTick();
402
+ const body = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 6), new THREE.MeshBasicMaterial());
403
+ engine!.setObject("centre", { object: body });
404
+ await tickFrames();
405
+
406
+ const projected = engine!.projectToScreen([0, 0, 0]);
407
+ expect(Number.isFinite(projected.x)).toBe(true);
408
+ expect(Number.isFinite(projected.y)).toBe(true);
409
+ expect(engine!.camera.projectionMatrix.elements[5]).toBeLessThan(Infinity);
410
+ });
411
+
412
+ it("normalises a reversed orbit-distance window", async () => {
413
+ let engine: Board3DEngine | null = null;
414
+ mountBoard(
415
+ { minDistance: 600, maxDistance: 2 },
416
+ { ready: ((e: Board3DEngine) => { engine = e; }) as never },
417
+ );
418
+ await nextTick();
419
+
420
+ // The window itself must come out ordered — OrbitControls clamps the
421
+ // radius to `max(min, min(max, r))`, so a reversed window pins the
422
+ // eye at 600 and defeats every fit (the mocked controls in this
423
+ // suite do not clamp, hence the direct assertion).
424
+ const controls = engine!.controls as unknown as { minDistance: number; maxDistance: number };
425
+ expect(controls.minDistance).toBe(2);
426
+ expect(controls.maxDistance).toBe(600);
427
+
428
+ const body = new THREE.Mesh(new THREE.SphereGeometry(2, 8, 6), new THREE.MeshBasicMaterial());
429
+ engine!.setObject("body", { object: body });
430
+ engine!.frameAll(1.3, 0);
431
+ await tickFrames();
432
+ expect(engine!.camera.position.distanceTo(engine!.controls.target)).toBeLessThan(100);
433
+ });
434
+
435
+ it("projectToScreen lands the origin at canvas centre for the default pose", async () => {
436
+ let engine: Board3DEngine | null = null;
437
+ const { container } = mountBoard(
438
+ {},
439
+ { ready: ((e: Board3DEngine) => { engine = e; }) as never },
440
+ );
441
+ await nextTick();
442
+ // Container client size is 0 in happy-dom; the projection maths is
443
+ // still exercised end-to-end (NDC → CSS px formula).
444
+ void container;
445
+ const p = engine!.projectToScreen([0, 0, 0]);
446
+ expect(p.visible).toBe(true);
447
+ });
448
+
449
+ it("syncs the scene background from the theme css vars", async () => {
450
+ let engine: Board3DEngine | null = null;
451
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
452
+ await nextTick();
453
+ const bg = engine!.scene.background as THREE.Color;
454
+ expect(bg).toBeInstanceOf(THREE.Color);
455
+ });
456
+
457
+ it("renders the fallback instead of throwing when WebGL is unavailable", async () => {
458
+ H.FakeWebGLRenderer.failNext = true;
459
+ const errors: string[] = [];
460
+ const { container } = mountBoard(
461
+ {},
462
+ { error: ((m: string) => errors.push(m)) as never },
463
+ );
464
+ await nextTick();
465
+ expect(errors).toEqual(["no webgl (test)"]);
466
+ expect(container.querySelector(".hk-board3d-fallback")).not.toBeNull();
467
+ expect(container.querySelector(".hk-minimap3d")).toBeNull();
468
+ });
469
+
470
+ it("disposes the renderer and empties the registries on unmount", async () => {
471
+ let engine: Board3DEngine | null = null;
472
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
473
+ await nextTick();
474
+ const renderer = H.FakeWebGLRenderer.instances[0];
475
+ const m = mounts.splice(0)[0];
476
+ m.app.unmount();
477
+ m.container.remove();
478
+ expect(renderer.disposed).toBe(true);
479
+ expect(engine!.objects().size).toBe(0);
480
+ });
481
+ });