@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 +1 -1
- package/src/board3dSurface.test.ts +17 -0
- package/src/components/HkBoard3D.test.tsx +377 -1
- package/src/components/HkBoard3D.tsx +158 -3
- package/src/components/board3dHoverBox.test.ts +200 -0
- package/src/components/board3dHoverBox.ts +210 -0
- package/src/index.ts +9 -1
- package/src/utils/scene3d.test.ts +69 -0
- package/src/utils/scene3d.ts +67 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|
package/src/utils/scene3d.ts
CHANGED
|
@@ -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;
|