@celestia-island/hikari 0.55.102 → 0.55.103

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.102",
3
+ "version": "0.55.103",
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",
@@ -52,6 +52,23 @@ describe("hikari barrel — 3D board surface", () => {
52
52
  expect(barrel.BOARD3D_HELPERS_LAYER).toBe(1);
53
53
  });
54
54
 
55
+ it("exports the main-camera gizmo layer and the hover-frame geometry", () => {
56
+ // Consumers drawing their own main-camera-only affordance (and the
57
+ // tests pinning that the minimap never sees one) need the layer; the
58
+ // corner-bracket maths is shared so a bespoke frame matches the
59
+ // built-in one.
60
+ expect(barrel.BOARD3D_MAIN_LAYER).toBe(2);
61
+ expect(typeof barrel.hoverBoxSegments).toBe("function");
62
+ expect(barrel.hoverBoxSegments([1, 1, 1])).toHaveLength(48 * 3);
63
+ for (const name of [
64
+ "HOVER_BOX_ARM_RATIO",
65
+ "HOVER_BOX_PADDING",
66
+ "HOVER_BOX_MIN_EXTENT_RATIO",
67
+ ] as const) {
68
+ expect(typeof (barrel as Record<string, unknown>)[name], name).toBe("number");
69
+ }
70
+ });
71
+
55
72
  it("exports the scene3d utilities (palette, orbit, fit, zoom)", () => {
56
73
  const fns = [
57
74
  "hashIdToUnit",
@@ -74,7 +74,7 @@ vi.mock("three/examples/jsm/renderers/CSS2DRenderer.js", async (importOriginal)
74
74
  return { ...actual, CSS2DRenderer: FakeCSS2DRenderer };
75
75
  });
76
76
 
77
- import HkBoard3D, { type Board3DEngine } from "./HkBoard3D";
77
+ import HkBoard3D, { BOARD3D_MAIN_LAYER, type Board3DEngine } from "./HkBoard3D";
78
78
 
79
79
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
80
80
 
@@ -687,3 +687,379 @@ describe("HkBoard3D", () => {
687
687
  expect(engine!.objects().size).toBe(0);
688
688
  });
689
689
  });
690
+
691
+ describe("HkBoard3D hover frame", () => {
692
+ /** The frame is the only scene child on the main-camera-only layer. */
693
+ function frameOf(engine: Board3DEngine): THREE.LineSegments {
694
+ const found = engine.scene.children.find((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER));
695
+ expect(found, "hover frame in the scene").toBeDefined();
696
+ return found as THREE.LineSegments;
697
+ }
698
+
699
+ function canvasOf(): HTMLCanvasElement {
700
+ const canvas = mounts[0].container.querySelector("canvas")!;
701
+ canvas.getBoundingClientRect = () =>
702
+ ({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 }) as DOMRect;
703
+ return canvas;
704
+ }
705
+
706
+ /** The board root — the element the pointer must actually leave. */
707
+ function boardOf(): HTMLElement {
708
+ return mounts[0].container.querySelector(".hk-board3d") as HTMLElement;
709
+ }
710
+
711
+ function moveTo(canvas: HTMLCanvasElement, x: number, y: number): void {
712
+ canvas.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: x, clientY: y }));
713
+ }
714
+
715
+ interface HoverHandlers {
716
+ hover?: (id: string | null) => void;
717
+ click?: (id: string) => void;
718
+ }
719
+
720
+ async function hoveredBoard(props: Record<string, unknown> = {}, on: HoverHandlers = {}) {
721
+ let engine: Board3DEngine | null = null;
722
+ mountBoard(props, {
723
+ ready: ((e: Board3DEngine) => { engine = e; }) as never,
724
+ objectHover: ((id: string | null) => on.hover?.(id)) as never,
725
+ objectClick: ((id: string) => on.click?.(id)) as never,
726
+ });
727
+ await nextTick();
728
+ const canvas = canvasOf();
729
+ const body = new THREE.Mesh(
730
+ new THREE.SphereGeometry(2, 16, 12),
731
+ new THREE.MeshBasicMaterial(),
732
+ );
733
+ body.name = "body";
734
+ engine!.setObject("star-1", { object: body });
735
+ await tickFrames();
736
+ moveTo(canvas, 400, 300);
737
+ await tickFrames();
738
+ return { engine: engine!, canvas, board: boardOf(), body };
739
+ }
740
+
741
+ it("wraps the hovered object in the corner frame without resizing it", async () => {
742
+ const { engine, body } = await hoveredBoard();
743
+ const frame = frameOf(engine);
744
+ expect(frame.visible).toBe(true);
745
+ // A radius-2 sphere at the origin: a 4-unit box plus the padding,
746
+ // placed by the frame's own matrix — the object is untouched.
747
+ expect(frame.matrix.elements[0]).toBeCloseTo(4 * 1.08, 4);
748
+ expect(frame.matrix.elements[13]).toBeCloseTo(0, 5);
749
+ expect(body.scale.x).toBe(1);
750
+ // The gizmo never joins picking or framing.
751
+ expect(engine.objects().get("star-1")!.object).toBe(body);
752
+ expect(frame.material).toBeInstanceOf(THREE.LineBasicMaterial);
753
+ expect((frame.material as THREE.LineBasicMaterial).depthTest).toBe(false);
754
+ });
755
+
756
+ it("hides the frame once the pointer leaves the body and the board", async () => {
757
+ const hovers: Array<string | null> = [];
758
+ const { engine, canvas, board } = await hoveredBoard({}, { hover: (id) => hovers.push(id) });
759
+ const frame = frameOf(engine);
760
+ expect(frame.visible).toBe(true);
761
+
762
+ moveTo(canvas, 10, 10);
763
+ await tickFrames();
764
+ expect(frame.visible).toBe(false);
765
+
766
+ moveTo(canvas, 400, 300);
767
+ await tickFrames();
768
+ expect(frame.visible).toBe(true);
769
+ board.dispatchEvent(new PointerEvent("pointerleave", { bubbles: false }));
770
+ await tickFrames();
771
+ expect(frame.visible).toBe(false);
772
+ // The event a page closes its hover card on.
773
+ expect(hovers).toEqual(["star-1", null, "star-1", null]);
774
+ });
775
+
776
+ it("keeps the hover while the pointer is on the board's own chrome", async () => {
777
+ const hovers: Array<string | null> = [];
778
+ const { engine, canvas } = await hoveredBoard({}, { hover: (id) => hovers.push(id) });
779
+ const frame = frameOf(engine);
780
+
781
+ // The CSS2D label chips and the minimap card overlap the canvas as
782
+ // SIBLINGS: reaching for a chip fires pointerleave on the canvas while
783
+ // the user is still on the object. The hover must survive that.
784
+ canvas.dispatchEvent(new PointerEvent("pointerleave", { bubbles: false }));
785
+ await tickFrames();
786
+ expect(frame.visible).toBe(true);
787
+ expect(hovers).toEqual(["star-1"]);
788
+ });
789
+
790
+ it("measures the frame target, and honours a per-object opt-out", async () => {
791
+ let engine: Board3DEngine | null = null;
792
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
793
+ await nextTick();
794
+ const canvas = canvasOf();
795
+
796
+ // A body wearing a corona ten times its size AND a satellite parked
797
+ // 10 units out. The sprite is pruned as decoration either way, so the
798
+ // satellite is what makes this test discriminate: measuring the group
799
+ // instead of `frameObject` would wrap the moon system, not the body.
800
+ const group = new THREE.Group();
801
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshBasicMaterial());
802
+ group.add(mesh);
803
+ const corona = new THREE.Sprite(new THREE.SpriteMaterial());
804
+ corona.scale.setScalar(20);
805
+ group.add(corona);
806
+ const satellite = new THREE.Mesh(
807
+ new THREE.SphereGeometry(0.2, 8, 6),
808
+ new THREE.MeshBasicMaterial(),
809
+ );
810
+ satellite.position.set(10, 0, 0);
811
+ group.add(satellite);
812
+ engine!.setObject("hub", { object: group, frameObject: mesh });
813
+ await tickFrames();
814
+ moveTo(canvas, 400, 300);
815
+ await tickFrames();
816
+ expect(frameOf(engine!).matrix.elements[0]).toBeCloseTo(2 * 1.08, 4);
817
+ expect(frameOf(engine!).matrix.elements[12]).toBeCloseTo(0, 5);
818
+ // Sanity: the group as a whole would have measured far wider.
819
+ expect(frameOf(engine!).matrix.elements[0]).toBeLessThan(10);
820
+
821
+ // frameObject: null opts the object out entirely.
822
+ engine!.setObject("hub", { object: group, frameObject: null });
823
+ await tickFrames();
824
+ expect(frameOf(engine!).visible).toBe(false);
825
+ });
826
+
827
+ it("never shows a frame when hoverBox is off, and still clicks", async () => {
828
+ const clicks: string[] = [];
829
+ const { engine, canvas } = await hoveredBoard({ hoverBox: false }, { click: (id) => clicks.push(id) });
830
+ expect(frameOf(engine).visible).toBe(false);
831
+ moveTo(canvas, 400, 300);
832
+ await tickFrames();
833
+ expect(frameOf(engine).visible).toBe(false);
834
+ // The picking contract itself is untouched by the opt-out.
835
+ canvas.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 400, clientY: 300 }));
836
+ canvas.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 400, clientY: 300 }));
837
+ expect(clicks).toEqual(["star-1"]);
838
+ });
839
+
840
+ it("keeps only the main camera on the gizmo layer", async () => {
841
+ let engine: Board3DEngine | null = null;
842
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
843
+ await nextTick();
844
+ expect(engine!.camera.layers.isEnabled(BOARD3D_MAIN_LAYER)).toBe(true);
845
+ // The minimap camera enables the helper layer only — its render pass
846
+ // must not pick the pointer affordance up.
847
+ expect(frameOf(engine!).layers.mask).toBe(1 << BOARD3D_MAIN_LAYER);
848
+ });
849
+
850
+ it("re-measures the frame when a rebuild hands the hovered id a new object", async () => {
851
+ const { engine } = await hoveredBoard();
852
+ const frame = frameOf(engine);
853
+ expect(frame.matrix.elements[12]).toBeCloseTo(0, 5);
854
+
855
+ // Pages rebuild their content: the frame must follow the object that
856
+ // is actually registered now, not the one that left the scene.
857
+ const next = new THREE.Mesh(
858
+ new THREE.SphereGeometry(1, 12, 8),
859
+ new THREE.MeshBasicMaterial(),
860
+ );
861
+ next.position.set(30, 0, 0);
862
+ engine.setObject("star-1", { object: next });
863
+ await tickFrames();
864
+ expect(frame.visible).toBe(true);
865
+ expect(frame.matrix.elements[12]).toBeCloseTo(30, 4);
866
+ expect(frame.matrix.elements[0]).toBeCloseTo(2 * 1.08, 4);
867
+ });
868
+
869
+ it("follows the target in and out of visibility, and drops a stale opt-out", async () => {
870
+ const hovers: Array<string | null> = [];
871
+ const { engine, body, canvas } = await hoveredBoard({}, { hover: (id) => hovers.push(id) });
872
+ const frame = frameOf(engine);
873
+ expect(frame.visible).toBe(true);
874
+
875
+ // Hidden under a hidden ancestor (three renders by the CHAIN, not by
876
+ // the node's own flag): the bracket must stop painting the air.
877
+ body.visible = false;
878
+ await tickFrames();
879
+ expect(frame.visible).toBe(false);
880
+
881
+ body.visible = true;
882
+ await tickFrames();
883
+ expect(frame.visible).toBe(true);
884
+
885
+ // …and an id re-registered as unselectable loses its frame too.
886
+ engine.setObject("star-1", { object: body, pickable: false });
887
+ await tickFrames();
888
+ expect(frame.visible).toBe(false);
889
+ // The pointer can no longer select it: the page must hear that here,
890
+ // because no pointer movement is coming to say it.
891
+ expect(hovers.at(-1)).toBeNull();
892
+ expect(canvas.style.cursor).toBe("");
893
+ });
894
+
895
+ it("hides the frame when an ANCESTOR of the frame target is hidden", async () => {
896
+ const hovers: Array<string | null> = [];
897
+ let engine: Board3DEngine | null = null;
898
+ mountBoard({}, {
899
+ ready: ((e: Board3DEngine) => { engine = e; }) as never,
900
+ objectHover: ((id: string | null) => hovers.push(id)) as never,
901
+ });
902
+ await nextTick();
903
+ const canvas = canvasOf();
904
+
905
+ // The target stays `visible = true` while its PARENT is hidden: only
906
+ // the renderer's ancestor-chain rule catches that.
907
+ const group = new THREE.Group();
908
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8), new THREE.MeshBasicMaterial());
909
+ group.add(mesh);
910
+ engine!.setObject("wrap", { object: group, frameObject: mesh });
911
+ await tickFrames();
912
+ moveTo(canvas, 400, 300);
913
+ await tickFrames();
914
+ expect(frameOf(engine!).visible).toBe(true);
915
+
916
+ group.visible = false;
917
+ await tickFrames();
918
+ expect(mesh.visible).toBe(true);
919
+ expect(frameOf(engine!).visible).toBe(false);
920
+
921
+ group.visible = true;
922
+ await tickFrames();
923
+ expect(frameOf(engine!).visible).toBe(true);
924
+ });
925
+
926
+ it("picks the frame up when the content arrives after the hover", async () => {
927
+ let engine: Board3DEngine | null = null;
928
+ mountBoard({}, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
929
+ await nextTick();
930
+ const canvas = canvasOf();
931
+
932
+ // Content streams in: the registered group is empty (its only mesh is
933
+ // still hidden) when the pointer settles on it.
934
+ const group = new THREE.Group();
935
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 8), new THREE.MeshBasicMaterial());
936
+ mesh.visible = false;
937
+ group.add(mesh);
938
+ engine!.setObject("stream", { object: group });
939
+ await tickFrames();
940
+ moveTo(canvas, 400, 300);
941
+ await tickFrames();
942
+ expect(frameOf(engine!).visible).toBe(false);
943
+
944
+ mesh.visible = true;
945
+ await tickFrames();
946
+ expect(frameOf(engine!).visible).toBe(true);
947
+ });
948
+
949
+ it("re-measures an unmeasurable target on a cadence, not every frame", async () => {
950
+ let engine: Board3DEngine | null = null;
951
+ // No minimap: its marker sync calls updateWorldMatrix on every
952
+ // registered object each frame, which would drown the measurement
953
+ // cadence this test is about.
954
+ mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
955
+ await nextTick();
956
+ const canvas = canvasOf();
957
+
958
+ // Content that never becomes measurable (a group of decor only) must
959
+ // not turn every frame into a subtree walk — but the board must still
960
+ // look again, or streaming content would never be framed.
961
+ const group = new THREE.Group();
962
+ const decor = new THREE.Sprite(new THREE.SpriteMaterial());
963
+ group.add(decor);
964
+ engine!.setObject("empty", { object: group });
965
+ await tickFrames();
966
+ moveTo(canvas, 400, 300);
967
+ await tickFrames();
968
+ expect(frameOf(engine!).visible).toBe(false);
969
+
970
+ let frames = 0;
971
+ engine!.addTick(() => {
972
+ frames += 1;
973
+ });
974
+ // The measurement is the walk `frameLocalBounds` makes on the anchor.
975
+ const walk = vi.spyOn(group, "updateWorldMatrix");
976
+ await tickFrames();
977
+ expect(frames).toBeGreaterThan(10);
978
+ expect(walk.mock.calls.length).toBeGreaterThan(0);
979
+ expect(walk.mock.calls.length).toBeLessThan(frames / 3);
980
+ });
981
+
982
+ it("tints the frame from the palette: near-white by night, grey by day", async () => {
983
+ const previous = ["--color-background", "--color-text", "--color-muted"].map((name) => [
984
+ name,
985
+ document.documentElement.style.getPropertyValue(name),
986
+ ] as const);
987
+ try {
988
+ // Deliberately NOT the fallback literals: a variable read that fell
989
+ // through to its default must fail these assertions.
990
+ document.documentElement.style.setProperty("--color-background", "10 15 25");
991
+ document.documentElement.style.setProperty("--color-text", "250 10 10");
992
+ document.documentElement.style.setProperty("--color-muted", "10 250 10");
993
+ const { engine } = await hoveredBoard();
994
+ const color = (frameOf(engine).material as THREE.LineBasicMaterial).color;
995
+ // Night: the TEXT channel.
996
+ expect(color.r * 255).toBeCloseTo(250, 0);
997
+ expect(color.g * 255).toBeCloseTo(10, 0);
998
+
999
+ // Day: the MUTED channel — and a live theme mutation re-tints the
1000
+ // frame in place.
1001
+ document.documentElement.style.setProperty("--color-background", "245 245 240");
1002
+ await tickFrames();
1003
+ expect(color.g * 255).toBeCloseTo(250, 0);
1004
+ expect(color.r * 255).toBeCloseTo(10, 0);
1005
+ } finally {
1006
+ for (const [name, value] of previous) {
1007
+ document.documentElement.style.removeProperty(name);
1008
+ if (value) document.documentElement.style.setProperty(name, value);
1009
+ }
1010
+ }
1011
+ });
1012
+
1013
+ it("detaches and frees the frame, and its listeners, on unmount", async () => {
1014
+ const { engine, board } = await hoveredBoard();
1015
+ const frame = frameOf(engine);
1016
+ const geoSpy = vi.spyOn(frame.geometry, "dispose");
1017
+ const matSpy = vi.spyOn(frame.material as THREE.Material, "dispose");
1018
+ // A container listener left behind keeps a dead board reachable — and
1019
+ // a remount would stack a second one.
1020
+ const removeSpy = vi.spyOn(board, "removeEventListener");
1021
+
1022
+ const m = mounts.splice(0)[0];
1023
+ m.app.unmount();
1024
+ m.container.remove();
1025
+
1026
+ expect(geoSpy).toHaveBeenCalled();
1027
+ expect(matSpy).toHaveBeenCalled();
1028
+ expect(removeSpy).toHaveBeenCalledWith("pointerleave", expect.any(Function));
1029
+ expect(engine.scene.children.some((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER))).toBe(false);
1030
+ });
1031
+
1032
+ it("never resurrects the hover after the pointer has left the board", async () => {
1033
+ const hovers: Array<string | null> = [];
1034
+ const { engine, canvas, board } = await hoveredBoard({}, { hover: (id) => hovers.push(id) });
1035
+ const frame = frameOf(engine);
1036
+ expect(frame.visible).toBe(true);
1037
+
1038
+ // A move and a leave inside ONE frame: the pending pick must not be
1039
+ // replayed against the stale position on the next loop iteration.
1040
+ moveTo(canvas, 400, 300);
1041
+ board.dispatchEvent(new PointerEvent("pointerleave", { bubbles: false }));
1042
+ await tickFrames();
1043
+ expect(frame.visible).toBe(false);
1044
+ expect(hovers.at(-1)).toBeNull();
1045
+
1046
+ // …and it stays left, however many frames pass.
1047
+ await tickFrames();
1048
+ expect(frame.visible).toBe(false);
1049
+ expect(hovers.filter((id) => id === "star-1").length).toBe(1);
1050
+ });
1051
+
1052
+ it("tells the page when the hovered object leaves the registry", async () => {
1053
+ const hovers: Array<string | null> = [];
1054
+ const { engine, canvas } = await hoveredBoard({}, { hover: (id) => hovers.push(id) });
1055
+ expect(canvas.style.cursor).toBe("pointer");
1056
+
1057
+ // The object is gone: no pointer movement will ever clear the hover,
1058
+ // so the board has to say it here (and stop promising a click).
1059
+ engine.setObject("star-1", null);
1060
+ await tickFrames();
1061
+ expect(hovers.at(-1)).toBeNull();
1062
+ expect(canvas.style.cursor).toBe("");
1063
+ expect(frameOf(engine).visible).toBe(false);
1064
+ });
1065
+ });
@@ -17,6 +17,9 @@
17
17
  * - PICKING: click (press-release inside a 4 px slop) and hover both
18
18
  * raycast the pickable set and surface the REGISTERED id (children
19
19
  * walk up to the registered root via userData);
20
+ * - HOVER FRAME: the hovered object is wrapped in an 8-corner selection
21
+ * frame (default ON, see the `hoverBox` prop) — the pointer marks what
22
+ * it is aiming at instead of resizing it;
20
23
  * - LABELS: a CSS2DRenderer overlay — HTML chips that track world
21
24
  * anchors, pointer-events opt-in per chip so drags still orbit;
22
25
  * - MINIMAP: HkMinimap3D pinned bottom-right — a second, stereoscopic
@@ -71,11 +74,16 @@ import {
71
74
  type Board3DLightingDescriptor,
72
75
  type LightingRigOptions,
73
76
  } from "../scene3d/lighting";
74
- import { BOARD3D_HELPERS_LAYER, fitDistance } from "../utils/scene3d";
77
+ import { BOARD3D_HELPERS_LAYER, BOARD3D_MAIN_LAYER, fitDistance } from "../utils/scene3d";
78
+ import {
79
+ createHoverBox,
80
+ isVisibleInHierarchy,
81
+ type HoverBoxHandle,
82
+ } from "./board3dHoverBox";
75
83
  import HkMinimap3D from "./HkMinimap3D";
76
84
  import "./HkBoard3D.scss";
77
85
 
78
- export { BOARD3D_HELPERS_LAYER };
86
+ export { BOARD3D_HELPERS_LAYER, BOARD3D_MAIN_LAYER };
79
87
 
80
88
  export interface Board3DObjectDef {
81
89
  /** The scene-graph root for this content item. The board adds/removes
@@ -94,6 +102,11 @@ export interface Board3DObjectDef {
94
102
  marker?: "dot" | "cube" | "none";
95
103
  /** Minimap marker colour (any CSS colour; default theme primary). */
96
104
  markerColor?: string;
105
+ /** What the hover frame wraps, measured inside `object`'s space
106
+ * (default `object` itself). Point it at the body mesh when satellites,
107
+ * coronas or other decoration should stay outside the box, or pass
108
+ * `null` to opt this object out of the frame entirely. */
109
+ frameObject?: THREE.Object3D | null;
97
110
  }
98
111
 
99
112
  export interface Board3DEngine {
@@ -225,6 +238,13 @@ function studioEnvironment(renderer: THREE.WebGLRenderer): THREE.Texture {
225
238
  return tex;
226
239
  }
227
240
 
241
+ /** How many frames to wait before re-measuring a hovered target that had
242
+ * nothing measurable (≈ 100 ms at 60 fps): fast enough that content
243
+ * streaming in under a resting pointer gets its frame, slow enough that
244
+ * an unmeasurable target costs one subtree walk per interval instead of
245
+ * one per frame. */
246
+ const HOVER_BOX_RETRY_FRAMES = 6;
247
+
228
248
  /** A pose component is usable only if all three numbers are finite. */
229
249
  function isFiniteVec3(v: readonly number[] | undefined): v is readonly [number, number, number] {
230
250
  return (
@@ -250,6 +270,27 @@ function cssRgbToColor(raw: string): THREE.Color {
250
270
  );
251
271
  }
252
272
 
273
+ /** Perceived brightness of a palette triplet, 0 (black) … 1 (white). */
274
+ function colorLuminance(color: THREE.Color): number {
275
+ return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b;
276
+ }
277
+
278
+ /**
279
+ * The hover frame's tint, taken from the page's own palette: the TEXT
280
+ * channel in a dark room, the MUTED channel in a light one. That is the
281
+ * user's rule — a whitish bracket over a night sky, a grey one over a day
282
+ * sky — expressed with tokens rather than literals, so a consumer that
283
+ * re-skins the palette re-skins the frame with it.
284
+ */
285
+ function hoverFrameColor(background: THREE.Color): THREE.Color {
286
+ const dark = colorLuminance(background) < 0.5;
287
+ return cssRgbToColor(
288
+ dark
289
+ ? readCssColor("--color-text", "247 249 252")
290
+ : readCssColor("--color-muted", "80 80 80"),
291
+ );
292
+ }
293
+
253
294
  export default defineComponent({
254
295
  name: "HkBoard3D",
255
296
  props: {
@@ -285,9 +326,16 @@ export default defineComponent({
285
326
  /** Keep the camera's near/far planes clipped to the framed extent
286
327
  * (large scenes lose depth precision without it). */
287
328
  autoClipping: { type: Boolean, default: false },
329
+ /** Wrap the hovered object in the 8-corner selection frame. Defaults
330
+ * ON: anything the pointer can select gets one, and `frameObject:
331
+ * null` opts a single object out. Turn it off for boards whose
332
+ * content is not "selected" at all. */
333
+ hoverBox: { type: Boolean, default: true },
288
334
  /** Freeze the frame loop (content keeps its last pose). An animated
289
335
  * flight issued while paused lands in a single jump on resume: the
290
- * tween is evaluated against wall-clock time. */
336
+ * tween is evaluated against wall-clock time. Hover is frozen with
337
+ * it: the frame keeps whatever it had painted until the loop resumes
338
+ * (nothing re-renders while paused). */
291
339
  paused: { type: Boolean, default: false },
292
340
  },
293
341
  emits: {
@@ -308,6 +356,7 @@ export default defineComponent({
308
356
  let scene: THREE.Scene | null = null;
309
357
  let camera: THREE.PerspectiveCamera | null = null;
310
358
  let controls: OrbitControls | null = null;
359
+ let hoverFrame: HoverBoxHandle | null = null;
311
360
  let resizeObs: ResizeObserver | null = null;
312
361
  let themeObs: MutationObserver | null = null;
313
362
  let rafId = 0;
@@ -353,6 +402,7 @@ export default defineComponent({
353
402
  const bg = cssRgbToColor(bgRaw);
354
403
  if (scene) scene.background = bg;
355
404
  if (renderer) renderer.setClearColor(bg, 1);
405
+ hoverFrame?.setColor(hoverFrameColor(bg));
356
406
  for (const cb of themeHooks) cb(bgRaw, primaryRaw);
357
407
  }
358
408
 
@@ -399,6 +449,16 @@ export default defineComponent({
399
449
  }
400
450
 
401
451
  const lastPointer = { x: 0, y: 0 };
452
+ /** True while the frame has been measured for the current hover. The
453
+ * measurement is the expensive half, so it happens on hover change
454
+ * only — the per-frame half is one matrix composition. */
455
+ let frameBound = false;
456
+ /** Frames still to skip before the next measurement attempt while the
457
+ * hovered target has nothing measurable to wrap (content still
458
+ * streaming in). A permanently unmeasurable target — a group of
459
+ * sprites, content that stays hidden — must not turn the loop into a
460
+ * full subtree walk every frame. */
461
+ let measureCooldown = 0;
402
462
 
403
463
  function onPointerMove(e: PointerEvent): void {
404
464
  hoverDirty = true;
@@ -406,6 +466,26 @@ export default defineComponent({
406
466
  lastPointer.y = e.clientY;
407
467
  }
408
468
 
469
+ /** Leaving the BOARD drops the hover: nothing is under the pointer any
470
+ * more, and a frame left hanging around reads as a stuck selection.
471
+ *
472
+ * This listens on the CONTAINER, not the canvas, and that is the whole
473
+ * point: the CSS2D label chips and the minimap card sit ON TOP of the
474
+ * canvas as siblings, so a pointer moving from a body onto its own
475
+ * chip fires `pointerleave` on the canvas while the user is still
476
+ * "on" the object — a hover card opened from `objectHover` would slam
477
+ * shut as the user reached for it. */
478
+ function onPointerLeave(): void {
479
+ // Drop the PENDING pick as well: a move and a leave can land inside
480
+ // one frame, and the loop would otherwise re-pick the stale position
481
+ // and resurrect the hover after the pointer has left the board.
482
+ hoverDirty = false;
483
+ if (lastHover === null) return;
484
+ lastHover = null;
485
+ if (canvasRef.value) canvasRef.value.style.cursor = "";
486
+ emit("objectHover", null);
487
+ }
488
+
409
489
  function onPointerDown(e: PointerEvent): void {
410
490
  pressAt = { pointerId: e.pointerId, x: e.clientX, y: e.clientY };
411
491
  // Capture every press: up/cancel are only guaranteed while capture
@@ -444,6 +524,42 @@ export default defineComponent({
444
524
  return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
445
525
  }
446
526
 
527
+ /**
528
+ * Keep the hover frame on what the pointer is aiming at: measure once
529
+ * per hover, then ride the target's world matrix.
530
+ *
531
+ * The frame is hidden — and the latch released, so the next frame
532
+ * looks again — whenever there is nothing to mark: no hover, a cleared
533
+ * id, `hoverBox: false`, an object re-registered as `pickable: false`
534
+ * (the pointer can no longer select it), `frameObject: null`, or a
535
+ * target the renderer is not drawing (hidden itself or under a hidden
536
+ * ancestor). A target that measures to nothing measurable yet —
537
+ * content still streaming in under a resting pointer — also stays
538
+ * un-latched, which is what lets the frame appear when the content
539
+ * finally lands.
540
+ */
541
+ function syncHoverFrame(): void {
542
+ if (!hoverFrame) return;
543
+ const def = props.hoverBox && lastHover !== null ? registry.get(lastHover) : undefined;
544
+ const target = def ? (def.frameObject === null ? null : (def.frameObject ?? def.object)) : null;
545
+ if (!def || def.pickable === false || !target || !isVisibleInHierarchy(target)) {
546
+ hoverFrame.hide();
547
+ frameBound = false;
548
+ measureCooldown = 0;
549
+ return;
550
+ }
551
+ if (!frameBound) {
552
+ if (measureCooldown > 0) {
553
+ measureCooldown -= 1;
554
+ return;
555
+ }
556
+ frameBound = hoverFrame.attach(def.object, target);
557
+ measureCooldown = frameBound ? 0 : HOVER_BOX_RETRY_FRAMES;
558
+ return;
559
+ }
560
+ hoverFrame.refresh();
561
+ }
562
+
447
563
  function frame(): void {
448
564
  if (disposed) return;
449
565
  rafId = requestAnimationFrame(frame);
@@ -468,12 +584,17 @@ export default defineComponent({
468
584
  const id = pickAt(lastPointer.x, lastPointer.y);
469
585
  if (id !== lastHover) {
470
586
  lastHover = id;
587
+ frameBound = false;
588
+ measureCooldown = 0;
471
589
  if (canvasRef.value) {
472
590
  canvasRef.value.style.cursor = id ? "pointer" : "";
473
591
  }
474
592
  emit("objectHover", id);
475
593
  }
476
594
  }
595
+ // After the tick hooks have posed the content, so the frame lands on
596
+ // this frame's pose rather than the previous one.
597
+ syncHoverFrame();
477
598
 
478
599
  renderer.render(scene, camera);
479
600
  labelRenderer?.render(scene, camera);
@@ -486,11 +607,18 @@ export default defineComponent({
486
607
  if (!canvas || !container) return;
487
608
 
488
609
  scene = new THREE.Scene();
610
+ // The hover frame is a pointer affordance, not scenery: it hangs off
611
+ // the scene root (so it never joins framing, markers or picking) and
612
+ // rides the main-camera-only layer, keeping the minimap render clean.
613
+ hoverFrame = createHoverBox();
614
+ hoverFrame.object.layers.set(BOARD3D_MAIN_LAYER);
615
+ scene.add(hoverFrame.object);
489
616
  // A degenerate fov (≤ 0 or ≥ 180) silently bricks the projection —
490
617
  // projectionMatrix[5] becomes Infinity/negative, projection and
491
618
  // picking return NaN, and nothing throws. Fall back to the default.
492
619
  const fov = Number.isFinite(props.fov) && props.fov > 0 && props.fov < 180 ? props.fov : 45;
493
620
  camera = new THREE.PerspectiveCamera(fov, 1, 0.1, 4000);
621
+ camera.layers.enable(BOARD3D_MAIN_LAYER);
494
622
  // Guard the initial pose too: a non-finite component (an untyped
495
623
  // caller passing a short/garbage array) would poison every
496
624
  // projection for the board's whole lifetime.
@@ -547,6 +675,7 @@ export default defineComponent({
547
675
  canvas.addEventListener("pointerup", onPointerUp);
548
676
  canvas.addEventListener("pointercancel", onPointerCancel);
549
677
  canvas.addEventListener("lostpointercapture", onLostPointerCapture, true);
678
+ container.addEventListener("pointerleave", onPointerLeave);
550
679
 
551
680
  /** Depth planes from a subject distance (large scenes need it). */
552
681
  function applyClipping(dist: number): void {
@@ -573,6 +702,25 @@ export default defineComponent({
573
702
  },
574
703
  setObject(id, def) {
575
704
  const prev = registry.get(id);
705
+ // A re-register can hand over a DIFFERENT object (pages rebuild
706
+ // their content), and a frame still measured against the old one
707
+ // would hang where that object used to be — it is out of the
708
+ // scene now and never moves again. Measure again on the next
709
+ // frame instead.
710
+ if (id === lastHover) {
711
+ frameBound = false;
712
+ measureCooldown = 0;
713
+ // The hovered object either left the board or became
714
+ // unselectable. Nothing will move the pointer for us, so the
715
+ // page has to be told here — otherwise its hover card outlives
716
+ // the object and the cursor keeps promising a click that can
717
+ // no longer land.
718
+ if (!def || def.pickable === false) {
719
+ lastHover = null;
720
+ if (canvasRef.value) canvasRef.value.style.cursor = "";
721
+ emit("objectHover", null);
722
+ }
723
+ }
576
724
  if (prev) {
577
725
  // One Object3D can be registered under several ids; only
578
726
  // detach it from the scene when no other id still owns it.
@@ -816,6 +964,13 @@ export default defineComponent({
816
964
  canvas.removeEventListener("pointercancel", onPointerCancel);
817
965
  canvas.removeEventListener("lostpointercapture", onLostPointerCapture, true);
818
966
  }
967
+ containerRef.value?.removeEventListener("pointerleave", onPointerLeave);
968
+ if (hoverFrame) {
969
+ scene?.remove(hoverFrame.object);
970
+ hoverFrame.dispose();
971
+ hoverFrame = null;
972
+ }
973
+ frameBound = false;
819
974
  controls?.dispose();
820
975
  if (labelRenderer?.domElement.parentNode) {
821
976
  labelRenderer.domElement.parentNode.removeChild(labelRenderer.domElement);
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import * as THREE from "three";
3
+
4
+ import {
5
+ createHoverBox,
6
+ frameLocalBounds,
7
+ isVisibleInHierarchy,
8
+ normalizeHoverBounds,
9
+ } from "./board3dHoverBox";
10
+ import { HOVER_BOX_MIN_EXTENT_RATIO, HOVER_BOX_PADDING } from "../utils/scene3d";
11
+
12
+ /** A body mesh plus the decoration a scene normally hangs off it: a glow
13
+ * sprite far larger than the body and a CSS2D label anchor. */
14
+ function fixture() {
15
+ const anchor = new THREE.Group();
16
+ const body = new THREE.Mesh(
17
+ new THREE.BoxGeometry(2, 2, 2),
18
+ new THREE.MeshBasicMaterial(),
19
+ );
20
+ body.position.set(3, 0, 0);
21
+ anchor.add(body);
22
+
23
+ const corona = new THREE.Sprite(new THREE.SpriteMaterial());
24
+ corona.scale.setScalar(100);
25
+ anchor.add(corona);
26
+
27
+ const chip = new THREE.Mesh(new THREE.BoxGeometry(50, 50, 50), new THREE.MeshBasicMaterial());
28
+ (chip as unknown as { isCSS2DObject: boolean }).isCSS2DObject = true;
29
+ anchor.add(chip);
30
+
31
+ anchor.updateMatrixWorld(true);
32
+ return { anchor, body, corona, chip };
33
+ }
34
+
35
+ describe("frameLocalBounds", () => {
36
+ it("measures the shape only — sprites and CSS2D anchors are decoration", () => {
37
+ const { anchor } = fixture();
38
+ const box = frameLocalBounds(anchor, anchor);
39
+ expect(box.min.x).toBeCloseTo(2, 5);
40
+ expect(box.max.x).toBeCloseTo(4, 5);
41
+ expect(box.min.y).toBeCloseTo(-1, 5);
42
+ expect(box.max.z).toBeCloseTo(1, 5);
43
+ });
44
+
45
+ it("measures inside the anchor's space, not the world's", () => {
46
+ const { anchor } = fixture();
47
+ const before = frameLocalBounds(anchor, anchor).clone();
48
+ anchor.position.set(40, -12, 7);
49
+ anchor.updateMatrixWorld(true);
50
+ const after = frameLocalBounds(anchor, anchor);
51
+ for (let axis = 0; axis < 3; axis += 1) {
52
+ expect(after.min.getComponent(axis)).toBeCloseTo(before.min.getComponent(axis), 5);
53
+ expect(after.max.getComponent(axis)).toBeCloseTo(before.max.getComponent(axis), 5);
54
+ }
55
+ });
56
+
57
+ it("can bound a subtree the frame target points at", () => {
58
+ const { anchor, body } = fixture();
59
+ const box = frameLocalBounds(anchor, body);
60
+ // The body alone: centred on its own origin, no corona, no chip.
61
+ expect(box.getSize(new THREE.Vector3()).toArray()).toEqual([2, 2, 2]);
62
+ });
63
+
64
+ it("prunes invisible subtrees", () => {
65
+ const { anchor } = fixture();
66
+ const hidden = new THREE.Mesh(
67
+ new THREE.BoxGeometry(80, 80, 80),
68
+ new THREE.MeshBasicMaterial(),
69
+ );
70
+ anchor.add(hidden);
71
+ expect(frameLocalBounds(anchor, anchor).max.x).toBeCloseTo(40, 5);
72
+
73
+ hidden.visible = false;
74
+ expect(frameLocalBounds(anchor, anchor).max.x).toBeCloseTo(4, 5);
75
+ });
76
+
77
+ it("returns an empty box for a subtree with nothing measurable", () => {
78
+ const anchor = new THREE.Group();
79
+ anchor.add(new THREE.Object3D());
80
+ expect(frameLocalBounds(anchor, anchor).isEmpty()).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe("normalizeHoverBounds", () => {
85
+ it("pads every axis around the centre", () => {
86
+ const box = new THREE.Box3(new THREE.Vector3(-1, -2, -3), new THREE.Vector3(1, 2, 3));
87
+ expect(normalizeHoverBounds(box)).toBe(true);
88
+ expect(box.min.x).toBeCloseTo(-HOVER_BOX_PADDING, 5);
89
+ expect(box.max.y).toBeCloseTo(2 * HOVER_BOX_PADDING, 5);
90
+ expect(box.getCenter(new THREE.Vector3()).toArray()).toEqual([0, 0, 0]);
91
+ });
92
+
93
+ it("floors a degenerate axis so a flat card still reads as a box", () => {
94
+ const box = new THREE.Box3(
95
+ new THREE.Vector3(0, 0, 0),
96
+ new THREE.Vector3(10, 10, 0),
97
+ );
98
+ expect(normalizeHoverBounds(box)).toBe(true);
99
+ const size = box.getSize(new THREE.Vector3());
100
+ expect(size.z).toBeCloseTo(10 * HOVER_BOX_MIN_EXTENT_RATIO, 5);
101
+ // …and the axis it was floored on stays centred on the original plane.
102
+ expect(box.getCenter(new THREE.Vector3()).z).toBeCloseTo(0, 6);
103
+ });
104
+
105
+ it("rejects boxes there is nothing to wrap", () => {
106
+ expect(normalizeHoverBounds(new THREE.Box3())).toBe(false);
107
+ const point = new THREE.Box3(new THREE.Vector3(5, 5, 5), new THREE.Vector3(5, 5, 5));
108
+ expect(normalizeHoverBounds(point)).toBe(false);
109
+ // An infinite extent would compose a NaN matrix and un-render the
110
+ // frame silently: it counts as nothing to wrap.
111
+ const infinite = new THREE.Box3(
112
+ new THREE.Vector3(0, 0, 0),
113
+ new THREE.Vector3(Number.POSITIVE_INFINITY, 1, 1),
114
+ );
115
+ expect(normalizeHoverBounds(infinite)).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("isVisibleInHierarchy", () => {
120
+ it("follows the renderer's rule — a hidden ANCESTOR hides the child", () => {
121
+ const root = new THREE.Group();
122
+ const parent = new THREE.Group();
123
+ const leaf = new THREE.Object3D();
124
+ root.add(parent);
125
+ parent.add(leaf);
126
+ expect(isVisibleInHierarchy(leaf)).toBe(true);
127
+
128
+ parent.visible = false;
129
+ // The leaf still reads visible=true: three only skips the subtree.
130
+ expect(leaf.visible).toBe(true);
131
+ expect(isVisibleInHierarchy(leaf)).toBe(false);
132
+ expect(isVisibleInHierarchy(parent)).toBe(false);
133
+
134
+ parent.visible = true;
135
+ leaf.visible = false;
136
+ expect(isVisibleInHierarchy(leaf)).toBe(false);
137
+ expect(isVisibleInHierarchy(parent)).toBe(true);
138
+ });
139
+ });
140
+
141
+ describe("createHoverBox", () => {
142
+ it("wraps a target and follows its anchor's world matrix", () => {
143
+ const { anchor, body } = fixture();
144
+ const frame = createHoverBox();
145
+ expect(frame.object.visible).toBe(false);
146
+
147
+ frame.attach(anchor, body);
148
+ expect(frame.object.visible).toBe(true);
149
+ // The body is 2 units at x = 3 → padded half-extent on every axis.
150
+ const half = (2 * HOVER_BOX_PADDING) / 2;
151
+ expect(frame.object.matrix.elements[12]).toBeCloseTo(3, 5);
152
+ expect(frame.object.matrix.elements[0]).toBeCloseTo(half * 2, 5);
153
+
154
+ // Rigid motion of the anchor is tracked exactly, at O(1).
155
+ anchor.position.set(-8, 5, 1);
156
+ anchor.updateMatrixWorld(true);
157
+ frame.refresh();
158
+ expect(frame.object.matrix.elements[12]).toBeCloseTo(-5, 5);
159
+ expect(frame.object.matrix.elements[13]).toBeCloseTo(5, 5);
160
+ expect(frame.object.matrix.elements[14]).toBeCloseTo(1, 5);
161
+ // …and the frame never rewrites three's own transform bookkeeping.
162
+ expect(frame.object.matrixWorldNeedsUpdate).toBe(true);
163
+
164
+ frame.dispose();
165
+ });
166
+
167
+ it("stays hidden for a null target, an empty subtree, or a hide()", () => {
168
+ const { anchor } = fixture();
169
+ const frame = createHoverBox();
170
+ // The return value is the caller's "look again" signal: an empty
171
+ // measurement must not latch a hidden frame for good.
172
+ expect(frame.attach(anchor, null)).toBe(false);
173
+ expect(frame.object.visible).toBe(false);
174
+
175
+ expect(frame.attach(anchor, new THREE.Object3D())).toBe(false);
176
+ expect(frame.object.visible).toBe(false);
177
+
178
+ expect(frame.attach(anchor, anchor)).toBe(true);
179
+ expect(frame.object.visible).toBe(true);
180
+ // A hidden frame is not re-placed behind the consumer's back.
181
+ frame.hide();
182
+ const before = frame.object.matrix.clone();
183
+ frame.refresh();
184
+ expect(frame.object.matrix.equals(before)).toBe(true);
185
+ frame.dispose();
186
+ });
187
+
188
+ it("takes its colour from the caller and frees its own GPU resources", () => {
189
+ const frame = createHoverBox();
190
+ frame.setColor("#123456");
191
+ const material = frame.object.material as THREE.LineBasicMaterial;
192
+ expect(material.color.getHexString()).toBe("123456");
193
+
194
+ const geoSpy = vi.spyOn(frame.object.geometry, "dispose");
195
+ const matSpy = vi.spyOn(material, "dispose");
196
+ frame.dispose();
197
+ expect(geoSpy).toHaveBeenCalled();
198
+ expect(matSpy).toHaveBeenCalled();
199
+ });
200
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * board3dHoverBox — the 8-corner selection frame HkBoard3D draws around
3
+ * the object under the pointer.
4
+ *
5
+ * Why a frame instead of a highlight: hovering used to be the consumer's
6
+ * job, and the usual implementation is "scale the mesh up a little". That
7
+ * resizes the thing the user is aiming at (targets move under the
8
+ * pointer) and tells the user nothing about the object's extent. A
9
+ * corner bracket marks the object without touching it.
10
+ *
11
+ * The frame's colour is the page's: HkBoard3D hands in the palette's text
12
+ * channel in a dark room and its muted channel in a light one.
13
+ *
14
+ * COST MODEL — the box is measured ONCE per hover, in the registered
15
+ * object's own space, and each frame is placed by composing that local
16
+ * box with the object's world matrix. Rigid motion (orbits, a parent's
17
+ * spin, camera-independent flights) is tracked exactly, at O(1) per frame
18
+ * however heavy the object's subtree is. Animation INSIDE the frame
19
+ * target is deliberately not chased: a spinning sphere must not tumble
20
+ * its own selection frame.
21
+ *
22
+ * DECORATION IS NOT SHAPE — sprites (a star's corona) and CSS2D label
23
+ * anchors are skipped when measuring, and invisible subtrees are pruned:
24
+ * a glow that is six times the body must not blow the frame up with it.
25
+ */
26
+
27
+ import * as THREE from "three";
28
+
29
+ import {
30
+ HOVER_BOX_MIN_EXTENT_RATIO,
31
+ HOVER_BOX_PADDING,
32
+ hoverBoxSegments,
33
+ } from "../utils/scene3d";
34
+
35
+ /** Drawn in the transparent pass, over whatever the content left behind:
36
+ * the frame is a pointer affordance, not scenery, so all eight corners
37
+ * stay readable even where the body covers them (gizmo convention). */
38
+ const HOVER_BOX_RENDER_ORDER = 10;
39
+
40
+ export interface HoverBoxHandle {
41
+ /** The gizmo itself — add it to the scene, never to content. */
42
+ readonly object: THREE.LineSegments;
43
+ /** Wrap `target` measured inside `anchor`'s space; `null` hides the
44
+ * frame. Measuring happens here and only here. Returns false when
45
+ * there was nothing measurable to wrap (empty or hidden content), so
46
+ * the caller knows to look again instead of latching onto a hidden
47
+ * frame forever. */
48
+ attach(anchor: THREE.Object3D, target: THREE.Object3D | null): boolean;
49
+ /** Re-place the frame from the anchor's current world matrix (per
50
+ * frame). A no-op while the frame is hidden. */
51
+ refresh(): void;
52
+ /** Hide without forgetting the attachment. */
53
+ hide(): void;
54
+ setColor(color: THREE.ColorRepresentation): void;
55
+ dispose(): void;
56
+ }
57
+
58
+ /**
59
+ * `Object3D.visible` is per NODE: an object under a hidden parent is
60
+ * invisible to the renderer while reading `true` itself. The hover frame
61
+ * must follow the renderer's rule, or it keeps painting a bracket around
62
+ * something nobody can see.
63
+ */
64
+ export function isVisibleInHierarchy(obj: THREE.Object3D): boolean {
65
+ for (let node: THREE.Object3D | null = obj; node; node = node.parent) {
66
+ if (!node.visible) return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ /** Sprites and CSS2D/CSS3D anchors are decoration, never the object's
72
+ * shape. */
73
+ function isDecoration(obj: THREE.Object3D): boolean {
74
+ const probe = obj as unknown as {
75
+ isSprite?: boolean;
76
+ isCSS2DObject?: boolean;
77
+ isCSS3DObject?: boolean;
78
+ };
79
+ return (
80
+ probe.isSprite === true || probe.isCSS2DObject === true || probe.isCSS3DObject === true
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Bounding box of `target`'s subtree expressed in `anchor`'s local space.
86
+ * Prunes invisible subtrees and decoration; an empty box means "nothing
87
+ * measurable here".
88
+ */
89
+ export function frameLocalBounds(
90
+ anchor: THREE.Object3D,
91
+ target: THREE.Object3D,
92
+ out = new THREE.Box3(),
93
+ ): THREE.Box3 {
94
+ anchor.updateWorldMatrix(true, true);
95
+ const inverseAnchor = anchor.matrixWorld.clone().invert();
96
+ const probe = new THREE.Box3();
97
+ const intoLocal = new THREE.Matrix4();
98
+
99
+ out.makeEmpty();
100
+ const walk = (obj: THREE.Object3D): void => {
101
+ if (!obj.visible || isDecoration(obj)) return;
102
+ const mesh = obj as THREE.Mesh;
103
+ if (mesh.geometry) {
104
+ if (!mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
105
+ const bb = mesh.geometry.boundingBox;
106
+ if (bb) {
107
+ intoLocal.multiplyMatrices(inverseAnchor, obj.matrixWorld);
108
+ probe.copy(bb).applyMatrix4(intoLocal);
109
+ out.union(probe);
110
+ }
111
+ }
112
+ for (const child of obj.children) walk(child);
113
+ };
114
+ walk(target);
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Pad the box and floor every degenerate axis so the frame stays a box.
120
+ * Returns false when there is nothing to wrap (an empty or zero-size
121
+ * box), which callers turn into "stay hidden".
122
+ */
123
+ export function normalizeHoverBounds(
124
+ box: THREE.Box3,
125
+ padding = HOVER_BOX_PADDING,
126
+ minExtentRatio = HOVER_BOX_MIN_EXTENT_RATIO,
127
+ ): boolean {
128
+ if (box.isEmpty()) return false;
129
+ const size = box.getSize(new THREE.Vector3());
130
+ const longest = Math.max(size.x, size.y, size.z);
131
+ // A non-finite extent (NaN or ±Infinity from a degenerate geometry
132
+ // bounding box) would compose a NaN placement matrix and silently
133
+ // un-render the frame: nothing measurable, stay hidden.
134
+ if (!(longest > 0) || !Number.isFinite(longest)) return false;
135
+ const floor = minExtentRatio * longest;
136
+ const center = box.getCenter(new THREE.Vector3());
137
+ const half = new THREE.Vector3(
138
+ Math.max(size.x * padding, floor) / 2,
139
+ Math.max(size.y * padding, floor) / 2,
140
+ Math.max(size.z * padding, floor) / 2,
141
+ );
142
+ box.min.set(center.x - half.x, center.y - half.y, center.z - half.z);
143
+ box.max.set(center.x + half.x, center.y + half.y, center.z + half.z);
144
+ return true;
145
+ }
146
+
147
+ export function createHoverBox(): HoverBoxHandle {
148
+ const geometry = new THREE.BufferGeometry();
149
+ geometry.setAttribute(
150
+ "position",
151
+ new THREE.BufferAttribute(hoverBoxSegments([1, 1, 1]), 3),
152
+ );
153
+ const material = new THREE.LineBasicMaterial({
154
+ color: 0xffffff,
155
+ transparent: true,
156
+ opacity: 0.9,
157
+ depthTest: false,
158
+ depthWrite: false,
159
+ });
160
+ const object = new THREE.LineSegments(geometry, material);
161
+ // The placement matrix is composed from the anchor's world matrix, so
162
+ // three must not derive it from position/quaternion/scale.
163
+ object.matrixAutoUpdate = false;
164
+ object.renderOrder = HOVER_BOX_RENDER_ORDER;
165
+ object.frustumCulled = false;
166
+ object.visible = false;
167
+
168
+ const localBox = new THREE.Box3();
169
+ const localMatrix = new THREE.Matrix4();
170
+ const center = new THREE.Vector3();
171
+ const size = new THREE.Vector3();
172
+ const identity = new THREE.Quaternion();
173
+ let anchor: THREE.Object3D | null = null;
174
+
175
+ function refresh(): void {
176
+ if (!object.visible || !anchor) return;
177
+ anchor.updateWorldMatrix(true, false);
178
+ object.matrix.multiplyMatrices(anchor.matrixWorld, localMatrix);
179
+ object.matrixWorldNeedsUpdate = true;
180
+ }
181
+
182
+ return {
183
+ object,
184
+ attach(nextAnchor, target) {
185
+ anchor = nextAnchor;
186
+ localBox.makeEmpty();
187
+ object.visible = false;
188
+ if (!target) return false;
189
+ frameLocalBounds(nextAnchor, target, localBox);
190
+ if (!normalizeHoverBounds(localBox)) return false;
191
+ localBox.getCenter(center);
192
+ localBox.getSize(size);
193
+ localMatrix.compose(center, identity, size);
194
+ object.visible = true;
195
+ refresh();
196
+ return true;
197
+ },
198
+ refresh,
199
+ hide() {
200
+ object.visible = false;
201
+ },
202
+ setColor(color) {
203
+ material.color.set(color);
204
+ },
205
+ dispose() {
206
+ geometry.dispose();
207
+ material.dispose();
208
+ },
209
+ };
210
+ }
package/src/index.ts CHANGED
@@ -215,6 +215,10 @@ export {
215
215
  viewPlaneHalfExtents,
216
216
  planeCorners,
217
217
  clampMinimapZoom,
218
+ hoverBoxSegments,
219
+ HOVER_BOX_ARM_RATIO,
220
+ HOVER_BOX_PADDING,
221
+ HOVER_BOX_MIN_EXTENT_RATIO,
218
222
  MINIMAP_MIN_POLAR,
219
223
  MINIMAP_MAX_POLAR,
220
224
  MINIMAP_DRAG_RADIANS_PER_PX,
@@ -759,7 +763,11 @@ export { default as HkMediaVisualizer } from "./components/HkMediaVisualizer";
759
763
  export { default as HkImageViewer } from "./components/HkImageViewer";
760
764
  export { default as HkZoomToolbar } from "./components/HkZoomToolbar";
761
765
  export { default as HkMinimap } from "./components/HkMinimap";
762
- export { default as HkBoard3D, BOARD3D_HELPERS_LAYER } from "./components/HkBoard3D";
766
+ export {
767
+ default as HkBoard3D,
768
+ BOARD3D_HELPERS_LAYER,
769
+ BOARD3D_MAIN_LAYER,
770
+ } from "./components/HkBoard3D";
763
771
  export { default as HkMinimap3D } from "./components/HkMinimap3D";
764
772
  export { default as HkTrendChart } from "./components/HkTrendChart";
765
773
  export { default as HkErrorBoundary } from "./components/HkErrorBoundary";
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  clampMinimapZoom,
5
5
  fitDistance,
6
+ HOVER_BOX_ARM_RATIO,
7
+ hoverBoxSegments,
6
8
  hashIdToUnit,
7
9
  lerpPalette,
8
10
  MINIMAP_MAX_POLAR,
@@ -161,3 +163,70 @@ describe("clampMinimapZoom", () => {
161
163
  expect(clampMinimapZoom(Number.NaN)).toBe(100);
162
164
  });
163
165
  });
166
+
167
+ describe("hoverBoxSegments", () => {
168
+ it("emits eight corners with three inward arms each", () => {
169
+ const size: [number, number, number] = [2, 4, 6];
170
+ const positions = hoverBoxSegments(size);
171
+ // 8 corners × 3 arms × 2 vertices.
172
+ expect(positions).toHaveLength(8 * 3 * 2 * 3);
173
+
174
+ const cornerSet = new Set<string>();
175
+ const armLengths: number[] = [];
176
+ // One arm per axis, each a fraction of ITS OWN side: that is what lets
177
+ // the built-in frame scale a unit box by `size`.
178
+ const arms = size.map((side) => HOVER_BOX_ARM_RATIO * side);
179
+ for (let i = 0; i < positions.length; i += 6) {
180
+ const a: [number, number, number] = [positions[i], positions[i + 1], positions[i + 2]];
181
+ const b: [number, number, number] = [positions[i + 3], positions[i + 4], positions[i + 5]];
182
+ cornerSet.add(a.join(","));
183
+ // Every arm runs along exactly one axis…
184
+ const deltas = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
185
+ expect(deltas.filter((d) => d !== 0)).toHaveLength(1);
186
+ const axis = deltas.findIndex((d) => d !== 0);
187
+ const len = Math.abs(deltas[axis]);
188
+ expect(len).toBeCloseTo(arms[axis], 6);
189
+ armLengths.push(len);
190
+ // …and points INWARD: the arm may never leave the box.
191
+ for (let axis = 0; axis < 3; axis += 1) {
192
+ const limit = size[axis] / 2;
193
+ expect(Math.abs(a[axis])).toBeLessThanOrEqual(limit + 1e-9);
194
+ expect(Math.abs(b[axis])).toBeLessThanOrEqual(limit + 1e-9);
195
+ }
196
+ }
197
+ // Every corner of the box is present exactly once.
198
+ expect(cornerSet.size).toBe(8);
199
+ for (const x of [-1, 1]) {
200
+ for (const y of [-2, 2]) {
201
+ for (const z of [-3, 3]) {
202
+ expect(cornerSet.has([x, y, z].join(",")), `${x},${y},${z}`).toBe(true);
203
+ }
204
+ }
205
+ }
206
+ expect(armLengths).toHaveLength(24);
207
+ });
208
+
209
+ it("gives each axis its own arm, and survives junk sizes", () => {
210
+ const flat = hoverBoxSegments([10, 0, 10]);
211
+ // A zero side ⇒ zero-length arms on that axis rather than a throw.
212
+ expect(flat.every((v) => Number.isFinite(v))).toBe(true);
213
+ expect(flat.every((v) => !Number.isNaN(v))).toBe(true);
214
+
215
+ const cubic = hoverBoxSegments([4, 4, 4]);
216
+ const arm = HOVER_BOX_ARM_RATIO * 4;
217
+ const firstLen = Math.abs(cubic[3] - cubic[0]) + Math.abs(cubic[4] - cubic[1]) + Math.abs(cubic[5] - cubic[2]);
218
+ expect(firstLen).toBeCloseTo(arm, 6);
219
+
220
+ // Scale invariance: the unit box scaled by `size` is the box's own
221
+ // bracket — the contract the barrel documents for bespoke frames.
222
+ const scaled = hoverBoxSegments([1, 1, 1]);
223
+ const stretched = hoverBoxSegments([2, 4, 6]);
224
+ for (let i = 0; i < stretched.length; i += 3) {
225
+ const axis = i % 3;
226
+ expect(stretched[i + axis] / scaled[i + axis]).toBeCloseTo([2, 4, 6][axis], 6);
227
+ }
228
+
229
+ const junk = hoverBoxSegments([Number.NaN, -3, 0] as [number, number, number]);
230
+ expect(junk.every((v) => Number.isFinite(v))).toBe(true);
231
+ });
232
+ });
@@ -20,6 +20,11 @@ export type Vec3 = [number, number, number];
20
20
  * runtime import of the board — the two components stay acyclic.) */
21
21
  export const BOARD3D_HELPERS_LAYER = 1;
22
22
 
23
+ /** Layer the main-camera-only gizmos live on — the hover frame. The
24
+ * minimap camera never enables it, so a pointer affordance stays out of
25
+ * the map render while the page's own eye still sees it. */
26
+ export const BOARD3D_MAIN_LAYER = 2;
27
+
23
28
  /** FNV-1a 32-bit hash of a string folded into [0, 1). Stable across runs
24
29
  * and platforms — the same node id always lands on the same palette stop. */
25
30
  export function hashIdToUnit(id: string): number {
@@ -163,6 +168,68 @@ export function planeCorners(
163
168
  return [at(-1, 1), at(1, 1), at(1, -1), at(-1, -1)];
164
169
  }
165
170
 
171
+ // ── Hover frame (the 8-corner selection box) ────────────────────────────
172
+
173
+ /** Corner arm length, as a fraction of the side it runs along. */
174
+ export const HOVER_BOX_ARM_RATIO = 0.24;
175
+
176
+ /** Breathing room between the object's bounds and its frame. */
177
+ export const HOVER_BOX_PADDING = 1.08;
178
+
179
+ /** Floor on a degenerate axis — a flat card or a zero-thickness plane
180
+ * must still read as a box instead of collapsing onto one line. As a
181
+ * fraction of the box's LONGEST side. */
182
+ export const HOVER_BOX_MIN_EXTENT_RATIO = 0.12;
183
+
184
+ /**
185
+ * The line-segment positions of a hover frame for a box of `size`
186
+ * centred on the origin: EIGHT CORNERS, three arms each (8 × 3 × 2 = 48
187
+ * vertices). Corner brackets rather than a full wireframe — the frame
188
+ * reads as a box around the object without drawing a cage over it.
189
+ *
190
+ * Each arm runs along one axis and is HOVER_BOX_ARM_RATIO of THAT axis,
191
+ * which is what makes the geometry scale-invariant: the built-in frame
192
+ * builds a unit box and scales it by `size`, so a consumer building its
193
+ * own frame from this helper gets the same bracket.
194
+ *
195
+ * Degenerate sizes (non-finite, ≤ 0) are treated as 0; callers that care
196
+ * about a zero-size box collapse check it before building the frame.
197
+ */
198
+ export function hoverBoxSegments(size: Vec3): Float32Array {
199
+ const [sx, sy, sz] = [0, 1, 2].map((i) => {
200
+ const v = size[i];
201
+ return Number.isFinite(v) && v > 0 ? v : 0;
202
+ }) as Vec3;
203
+ const arms: Vec3 = [
204
+ HOVER_BOX_ARM_RATIO * sx,
205
+ HOVER_BOX_ARM_RATIO * sy,
206
+ HOVER_BOX_ARM_RATIO * sz,
207
+ ];
208
+ const out = new Float32Array(8 * 3 * 2 * 3);
209
+ let at = 0;
210
+ for (const ix of [-1, 1]) {
211
+ for (const iy of [-1, 1]) {
212
+ for (const iz of [-1, 1]) {
213
+ const corner: Vec3 = [(ix * sx) / 2, (iy * sy) / 2, (iz * sz) / 2];
214
+ for (let axis = 0; axis < 3; axis += 1) {
215
+ // One arm per axis, all pointing INWARD from the corner (the
216
+ // sign flip is what makes the bracket hug the box).
217
+ const tip: Vec3 = [corner[0], corner[1], corner[2]];
218
+ tip[axis] -= [ix, iy, iz][axis] * arms[axis];
219
+ out[at] = corner[0];
220
+ out[at + 1] = corner[1];
221
+ out[at + 2] = corner[2];
222
+ out[at + 3] = tip[0];
223
+ out[at + 4] = tip[1];
224
+ out[at + 5] = tip[2];
225
+ at += 6;
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
166
233
  /** Minimap zoom-ladder governance: the ± buttons step the view-radius
167
234
  * factor by a fixed 15% rung inside [50%, 250%] of the auto-fit radius. */
168
235
  export const MINIMAP_ZOOM_STEP_PERCENT = 15;