@celestia-island/hikari 0.55.103 → 0.55.105
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
|
@@ -109,6 +109,20 @@ describe("hikari barrel — 3D board surface", () => {
|
|
|
109
109
|
expect(has).toBe(true);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
it("pins the engine's selection surface", () => {
|
|
113
|
+
// `setSelection` is how a page keeps the 8-corner frame on its
|
|
114
|
+
// selected object (the fleet sky's cube cursor). Type-level probe:
|
|
115
|
+
// vue-tsc covers this file, so dropping the method breaks the gate
|
|
116
|
+
// rather than the consumer's build.
|
|
117
|
+
const engine = null as Engine | null;
|
|
118
|
+
void engine;
|
|
119
|
+
type HasSelection = Engine extends { setSelection(id: string | null): void }
|
|
120
|
+
? true
|
|
121
|
+
: false;
|
|
122
|
+
const has: HasSelection = true;
|
|
123
|
+
expect(has).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
112
126
|
it("exports the minimap governance constants, drag sensitivity included", () => {
|
|
113
127
|
// A consumer writing its own drag control needs the same sensitivity
|
|
114
128
|
// the built-in one uses — it must not stay module-private.
|
|
@@ -45,6 +45,13 @@
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
.hk-board3d-fallback {
|
|
48
|
+
// Self-carried contrast chip: in transparent-backdrop mode the root
|
|
49
|
+
// paints nothing, so the note cannot lean on the old surface fill.
|
|
50
|
+
background: rgb(var(--color-surface) / 0.9);
|
|
51
|
+
border-radius: var(--radius-md, 8px);
|
|
52
|
+
padding: 6px 12px;
|
|
53
|
+
margin: auto;
|
|
54
|
+
width: fit-content;
|
|
48
55
|
position: absolute;
|
|
49
56
|
inset: 0;
|
|
50
57
|
display: flex;
|
|
@@ -15,20 +15,28 @@ const H = vi.hoisted(() => {
|
|
|
15
15
|
renders: Array<{ scene: unknown; camera: unknown }> = [];
|
|
16
16
|
sizes: Array<[number, number]> = [];
|
|
17
17
|
clearColor: unknown = null;
|
|
18
|
+
clearAlpha: number | null = null;
|
|
19
|
+
/** The drawing buffer's alpha channel — only present when the board
|
|
20
|
+
* asks for a transparent backdrop (`alpha: true`). */
|
|
21
|
+
alpha: boolean;
|
|
18
22
|
toneMapping: unknown = null;
|
|
19
23
|
shadowMap = { enabled: false };
|
|
20
24
|
disposed = false;
|
|
21
|
-
constructor(opts: { canvas: HTMLCanvasElement }) {
|
|
25
|
+
constructor(opts: { canvas: HTMLCanvasElement; alpha?: boolean }) {
|
|
22
26
|
if (FakeWebGLRenderer.failNext) {
|
|
23
27
|
FakeWebGLRenderer.failNext = false;
|
|
24
28
|
throw new Error("no webgl (test)");
|
|
25
29
|
}
|
|
26
30
|
this.domElement = opts.canvas;
|
|
31
|
+
this.alpha = opts.alpha === true;
|
|
27
32
|
FakeWebGLRenderer.instances.push(this);
|
|
28
33
|
}
|
|
29
34
|
setPixelRatio(): void {}
|
|
30
35
|
setSize(w: number, h: number): void { this.sizes.push([w, h]); }
|
|
31
|
-
setClearColor(c: unknown): void {
|
|
36
|
+
setClearColor(c: unknown, alpha = 1): void {
|
|
37
|
+
this.clearColor = c;
|
|
38
|
+
this.clearAlpha = alpha;
|
|
39
|
+
}
|
|
32
40
|
render(scene: unknown, camera: unknown): void {
|
|
33
41
|
// A real renderer maintains world matrices every pass; picking and
|
|
34
42
|
// projection both read them.
|
|
@@ -105,6 +113,25 @@ async function tickFrames(): Promise<void> {
|
|
|
105
113
|
await new Promise((r) => setTimeout(r, 50));
|
|
106
114
|
}
|
|
107
115
|
|
|
116
|
+
/** Both gizmo frames — hover, then selection — are the only scene
|
|
117
|
+
* children on the main-camera-only layer, and init() creates them in
|
|
118
|
+
* that order, so the index is a stable discriminator. */
|
|
119
|
+
function framesOf(engine: Board3DEngine): THREE.LineSegments[] {
|
|
120
|
+
const found = engine.scene.children.filter((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER));
|
|
121
|
+
expect(found, "both gizmo frames in the scene").toHaveLength(2);
|
|
122
|
+
return found as THREE.LineSegments[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The hover frame (created first in init). */
|
|
126
|
+
function frameOf(engine: Board3DEngine): THREE.LineSegments {
|
|
127
|
+
return framesOf(engine)[0]!;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The selection frame (created second in init). */
|
|
131
|
+
function selectionOf(engine: Board3DEngine): THREE.LineSegments {
|
|
132
|
+
return framesOf(engine)[1]!;
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
beforeEach(() => {
|
|
109
136
|
H.FakeWebGLRenderer.instances = [];
|
|
110
137
|
H.FakeWebGLRenderer.failNext = false;
|
|
@@ -689,13 +716,6 @@ describe("HkBoard3D", () => {
|
|
|
689
716
|
});
|
|
690
717
|
|
|
691
718
|
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
719
|
function canvasOf(): HTMLCanvasElement {
|
|
700
720
|
const canvas = mounts[0].container.querySelector("canvas")!;
|
|
701
721
|
canvas.getBoundingClientRect = () =>
|
|
@@ -1063,3 +1083,318 @@ describe("HkBoard3D hover frame", () => {
|
|
|
1063
1083
|
expect(frameOf(engine).visible).toBe(false);
|
|
1064
1084
|
});
|
|
1065
1085
|
});
|
|
1086
|
+
|
|
1087
|
+
describe("HkBoard3D selection frame", () => {
|
|
1088
|
+
/** A board with one body registered and the id selected — NO pointer
|
|
1089
|
+
* involvement anywhere: the selection frame must not need one. */
|
|
1090
|
+
async function selectedBoard(props: Record<string, unknown> = {}) {
|
|
1091
|
+
const hovers: Array<string | null> = [];
|
|
1092
|
+
let engine: Board3DEngine | null = null;
|
|
1093
|
+
mountBoard({ minimap: false, ...props }, {
|
|
1094
|
+
ready: ((e: Board3DEngine) => { engine = e; }) as never,
|
|
1095
|
+
objectHover: ((id: string | null) => hovers.push(id)) as never,
|
|
1096
|
+
});
|
|
1097
|
+
await nextTick();
|
|
1098
|
+
const body = new THREE.Mesh(
|
|
1099
|
+
new THREE.SphereGeometry(2, 16, 12),
|
|
1100
|
+
new THREE.MeshBasicMaterial(),
|
|
1101
|
+
);
|
|
1102
|
+
body.name = "body";
|
|
1103
|
+
engine!.setObject("star-1", { object: body });
|
|
1104
|
+
engine!.setSelection("star-1");
|
|
1105
|
+
await tickFrames();
|
|
1106
|
+
return { engine: engine!, body, hovers };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
it("pins the persistent corner frame without any pointer", async () => {
|
|
1110
|
+
const { engine, body } = await selectedBoard();
|
|
1111
|
+
const selection = selectionOf(engine);
|
|
1112
|
+
expect(selection.visible).toBe(true);
|
|
1113
|
+
// Same measurement contract as the hover frame: the body's own
|
|
1114
|
+
// extents plus the padding, composed through its world matrix.
|
|
1115
|
+
expect(selection.matrix.elements[0]).toBeCloseTo(4 * 1.08, 4);
|
|
1116
|
+
expect(selection.matrix.elements[13]).toBeCloseTo(0, 5);
|
|
1117
|
+
// The pointer frame has nothing to say here — nothing is hovered.
|
|
1118
|
+
expect(frameOf(engine).visible).toBe(false);
|
|
1119
|
+
// The body itself is untouched, and the gizmo never joins the
|
|
1120
|
+
// registry (framing and picking never see it).
|
|
1121
|
+
expect(body.scale.x).toBe(1);
|
|
1122
|
+
expect([...engine.objects().keys()]).toEqual(["star-1"]);
|
|
1123
|
+
|
|
1124
|
+
// A selected object re-registered as UN-pickable stays marked: the
|
|
1125
|
+
// frame marks what the page selected, and pointer reachability is a
|
|
1126
|
+
// hover concern (decision pinned — R1).
|
|
1127
|
+
engine.setObject("star-1", { object: body, pickable: false });
|
|
1128
|
+
await tickFrames();
|
|
1129
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it("re-selecting the same id does not re-measure", async () => {
|
|
1133
|
+
const { engine, body } = await selectedBoard();
|
|
1134
|
+
// Pages re-select on data refreshes; each redundant call must be a
|
|
1135
|
+
// no-op, not a forced subtree walk (the hover path guards the same
|
|
1136
|
+
// way at the event level — R2 P2). The measurement walk is the
|
|
1137
|
+
// (updateParents=true, updateChildren=true) call; the per-frame
|
|
1138
|
+
// refresh uses (true, false) and stays.
|
|
1139
|
+
const walk = vi.spyOn(body, "updateWorldMatrix");
|
|
1140
|
+
for (let i = 0; i < 5; i += 1) engine.setSelection("star-1");
|
|
1141
|
+
await tickFrames();
|
|
1142
|
+
const measured = walk.mock.calls.filter(
|
|
1143
|
+
([parents, children]) => parents === true && children === true,
|
|
1144
|
+
).length;
|
|
1145
|
+
expect(measured).toBe(0);
|
|
1146
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it("only ever ends through setSelection(null)", async () => {
|
|
1150
|
+
const { engine } = await selectedBoard();
|
|
1151
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1152
|
+
|
|
1153
|
+
// Frames pass — the selection is sticky, not a hover that decays.
|
|
1154
|
+
await tickFrames();
|
|
1155
|
+
await tickFrames();
|
|
1156
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1157
|
+
|
|
1158
|
+
engine.setSelection(null);
|
|
1159
|
+
await tickFrames();
|
|
1160
|
+
expect(selectionOf(engine).visible).toBe(false);
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it("survives a rebuild: the removed id hides, re-registration re-measures", async () => {
|
|
1164
|
+
const { engine } = await selectedBoard();
|
|
1165
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1166
|
+
|
|
1167
|
+
// A roster rebuild strips every body first — the frame hides while
|
|
1168
|
+
// the id is gone but the SELECTION survives the interim.
|
|
1169
|
+
engine.setObject("star-1", null);
|
|
1170
|
+
await tickFrames();
|
|
1171
|
+
expect(selectionOf(engine).visible).toBe(false);
|
|
1172
|
+
|
|
1173
|
+
// …and the re-registered body (a fresh object at a new spot) gets the
|
|
1174
|
+
// frame measured against IT, not against the dead predecessor.
|
|
1175
|
+
const next = new THREE.Mesh(
|
|
1176
|
+
new THREE.SphereGeometry(1, 12, 8),
|
|
1177
|
+
new THREE.MeshBasicMaterial(),
|
|
1178
|
+
);
|
|
1179
|
+
next.position.set(30, 0, 0);
|
|
1180
|
+
engine.setObject("star-1", { object: next });
|
|
1181
|
+
await tickFrames();
|
|
1182
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1183
|
+
expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(30, 4);
|
|
1184
|
+
expect(selectionOf(engine).matrix.elements[0]).toBeCloseTo(2 * 1.08, 4);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it("re-measures when a rebuild hands the selected id a NEW object directly", async () => {
|
|
1188
|
+
const { engine } = await selectedBoard();
|
|
1189
|
+
expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(0, 5);
|
|
1190
|
+
|
|
1191
|
+
// The ONE-step rebuild (strip + re-register fused into a single
|
|
1192
|
+
// setObject): the latch must release here too, or the frame keeps
|
|
1193
|
+
// riding the dead predecessor's matrix — the two-step test above
|
|
1194
|
+
// cannot see this, the hide path resets the latch for it (R1 P1).
|
|
1195
|
+
const next = new THREE.Mesh(
|
|
1196
|
+
new THREE.SphereGeometry(1, 12, 8),
|
|
1197
|
+
new THREE.MeshBasicMaterial(),
|
|
1198
|
+
);
|
|
1199
|
+
next.position.set(30, 0, 0);
|
|
1200
|
+
engine.setObject("star-1", { object: next });
|
|
1201
|
+
await tickFrames();
|
|
1202
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1203
|
+
expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(30, 4);
|
|
1204
|
+
expect(selectionOf(engine).matrix.elements[0]).toBeCloseTo(2 * 1.08, 4);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
it("suppresses the hover frame on the selected body only", async () => {
|
|
1208
|
+
const { engine, hovers } = await selectedBoard();
|
|
1209
|
+
const canvas = mounts[0]!.container.querySelector("canvas")!;
|
|
1210
|
+
canvas.getBoundingClientRect = () =>
|
|
1211
|
+
({ left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 }) as DOMRect;
|
|
1212
|
+
canvas.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 400, clientY: 300 }));
|
|
1213
|
+
await tickFrames();
|
|
1214
|
+
// Hovering the selected body draws ONE bracket, not two.
|
|
1215
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1216
|
+
expect(frameOf(engine).visible).toBe(false);
|
|
1217
|
+
// Suppression is frame-only: the page still HEARS the hover (its
|
|
1218
|
+
// hover card must keep working on the selected body) and the cursor
|
|
1219
|
+
// still promises the click (R1 P2).
|
|
1220
|
+
expect(hovers.at(-1)).toBe("star-1");
|
|
1221
|
+
expect(canvas.style.cursor).toBe("pointer");
|
|
1222
|
+
|
|
1223
|
+
// Moving the selection elsewhere un-suppresses the hover: the pointer
|
|
1224
|
+
// is still over star-1, which is no longer the selected body.
|
|
1225
|
+
const other = new THREE.Mesh(
|
|
1226
|
+
new THREE.SphereGeometry(1, 12, 8),
|
|
1227
|
+
new THREE.MeshBasicMaterial(),
|
|
1228
|
+
);
|
|
1229
|
+
other.position.set(500, 500, 500);
|
|
1230
|
+
engine.setObject("star-2", { object: other });
|
|
1231
|
+
engine.setSelection("star-2");
|
|
1232
|
+
await tickFrames();
|
|
1233
|
+
expect(frameOf(engine).visible).toBe(true);
|
|
1234
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1235
|
+
expect(selectionOf(engine).matrix.elements[12]).toBeCloseTo(500, 4);
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it("picks the selection up when the content arrives after the select", async () => {
|
|
1239
|
+
let engine: Board3DEngine | null = null;
|
|
1240
|
+
mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
|
|
1241
|
+
await nextTick();
|
|
1242
|
+
|
|
1243
|
+
// Content streams in: the registered group's only mesh is still
|
|
1244
|
+
// hidden when the page selects it.
|
|
1245
|
+
const group = new THREE.Group();
|
|
1246
|
+
const mesh = new THREE.Mesh(
|
|
1247
|
+
new THREE.SphereGeometry(1, 12, 8),
|
|
1248
|
+
new THREE.MeshBasicMaterial(),
|
|
1249
|
+
);
|
|
1250
|
+
mesh.visible = false;
|
|
1251
|
+
group.add(mesh);
|
|
1252
|
+
engine!.setObject("stream", { object: group });
|
|
1253
|
+
engine!.setSelection("stream");
|
|
1254
|
+
await tickFrames();
|
|
1255
|
+
expect(selectionOf(engine!).visible).toBe(false);
|
|
1256
|
+
|
|
1257
|
+
mesh.visible = true;
|
|
1258
|
+
await tickFrames();
|
|
1259
|
+
expect(selectionOf(engine!).visible).toBe(true);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it("re-measures an unmeasurable selection on a cadence, not every frame", async () => {
|
|
1263
|
+
let engine: Board3DEngine | null = null;
|
|
1264
|
+
// No minimap: its marker sync calls updateWorldMatrix on every
|
|
1265
|
+
// registered object each frame, which would drown the measurement
|
|
1266
|
+
// cadence this test is about.
|
|
1267
|
+
mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
|
|
1268
|
+
await nextTick();
|
|
1269
|
+
|
|
1270
|
+
const group = new THREE.Group();
|
|
1271
|
+
const decor = new THREE.Sprite(new THREE.SpriteMaterial());
|
|
1272
|
+
group.add(decor);
|
|
1273
|
+
engine!.setObject("empty", { object: group });
|
|
1274
|
+
engine!.setSelection("empty");
|
|
1275
|
+
await tickFrames();
|
|
1276
|
+
expect(selectionOf(engine!).visible).toBe(false);
|
|
1277
|
+
|
|
1278
|
+
let frames = 0;
|
|
1279
|
+
engine!.addTick(() => {
|
|
1280
|
+
frames += 1;
|
|
1281
|
+
});
|
|
1282
|
+
// The measurement is the walk `frameLocalBounds` makes on the anchor.
|
|
1283
|
+
const walk = vi.spyOn(group, "updateWorldMatrix");
|
|
1284
|
+
await tickFrames();
|
|
1285
|
+
expect(frames).toBeGreaterThan(10);
|
|
1286
|
+
expect(walk.mock.calls.length).toBeGreaterThan(0);
|
|
1287
|
+
expect(walk.mock.calls.length).toBeLessThan(frames / 3);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it("hides while the body is invisible, and comes back with it", async () => {
|
|
1291
|
+
const { engine, body } = await selectedBoard();
|
|
1292
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1293
|
+
|
|
1294
|
+
// three renders by the CHAIN: the frame must not paint the air.
|
|
1295
|
+
body.visible = false;
|
|
1296
|
+
await tickFrames();
|
|
1297
|
+
expect(selectionOf(engine).visible).toBe(false);
|
|
1298
|
+
|
|
1299
|
+
body.visible = true;
|
|
1300
|
+
await tickFrames();
|
|
1301
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it("tints the selection from the primary channel, apart from the hover", async () => {
|
|
1305
|
+
const previous = ["--color-background", "--color-text", "--color-primary"].map((name) => [
|
|
1306
|
+
name,
|
|
1307
|
+
document.documentElement.style.getPropertyValue(name),
|
|
1308
|
+
] as const);
|
|
1309
|
+
try {
|
|
1310
|
+
// Deliberately NOT the fallback literals: a variable read that fell
|
|
1311
|
+
// through to its default must fail these assertions.
|
|
1312
|
+
document.documentElement.style.setProperty("--color-background", "10 15 25");
|
|
1313
|
+
document.documentElement.style.setProperty("--color-text", "250 10 10");
|
|
1314
|
+
document.documentElement.style.setProperty("--color-primary", "10 10 250");
|
|
1315
|
+
const { engine } = await selectedBoard();
|
|
1316
|
+
const hoverColor = (frameOf(engine).material as THREE.LineBasicMaterial).color;
|
|
1317
|
+
const selectionColor = (selectionOf(engine).material as THREE.LineBasicMaterial).color;
|
|
1318
|
+
expect(selectionColor.r * 255).toBeCloseTo(10, 0);
|
|
1319
|
+
expect(selectionColor.b * 255).toBeCloseTo(250, 0);
|
|
1320
|
+
// The hover frame keeps its own channel (text) — the two stay
|
|
1321
|
+
// readable apart at a glance.
|
|
1322
|
+
expect(hoverColor.r * 255).toBeCloseTo(250, 0);
|
|
1323
|
+
expect(hoverColor.b * 255).toBeCloseTo(10, 0);
|
|
1324
|
+
} finally {
|
|
1325
|
+
for (const [name, value] of previous) {
|
|
1326
|
+
document.documentElement.style.removeProperty(name);
|
|
1327
|
+
if (value) document.documentElement.style.setProperty(name, value);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
it("disposes the selection frame on unmount", async () => {
|
|
1333
|
+
const { engine } = await selectedBoard();
|
|
1334
|
+
expect(selectionOf(engine).visible).toBe(true);
|
|
1335
|
+
const selection = selectionOf(engine);
|
|
1336
|
+
const geoSpy = vi.spyOn(selection.geometry, "dispose");
|
|
1337
|
+
const matSpy = vi.spyOn(selection.material as THREE.Material, "dispose");
|
|
1338
|
+
|
|
1339
|
+
const m = mounts.splice(0)[0];
|
|
1340
|
+
m.app.unmount();
|
|
1341
|
+
m.container.remove();
|
|
1342
|
+
|
|
1343
|
+
expect(geoSpy).toHaveBeenCalled();
|
|
1344
|
+
expect(matSpy).toHaveBeenCalled();
|
|
1345
|
+
expect(engine.scene.children.some((c) => c.layers.isEnabled(BOARD3D_MAIN_LAYER))).toBe(false);
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
describe("HkBoard3D transparent background", () => {
|
|
1350
|
+
function rendererOf(): InstanceType<typeof H.FakeWebGLRenderer> {
|
|
1351
|
+
// The board's renderer is the first instance (the minimap spins up
|
|
1352
|
+
// its own, and only when mounted).
|
|
1353
|
+
return H.FakeWebGLRenderer.instances[0]!;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
it("paints the theme background by default", async () => {
|
|
1357
|
+
let engine: Board3DEngine | null = null;
|
|
1358
|
+
mountBoard({ minimap: false }, { ready: ((e: Board3DEngine) => { engine = e; }) as never });
|
|
1359
|
+
await nextTick();
|
|
1360
|
+
expect(engine!.scene.background).toBeInstanceOf(THREE.Color);
|
|
1361
|
+
expect(rendererOf().alpha).toBe(false);
|
|
1362
|
+
expect(rendererOf().clearAlpha).toBe(1);
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
it("clears to transparent with the transparentBackground prop", async () => {
|
|
1366
|
+
let engine: Board3DEngine | null = null;
|
|
1367
|
+
mountBoard(
|
|
1368
|
+
{ minimap: false, transparentBackground: true },
|
|
1369
|
+
{ ready: ((e: Board3DEngine) => { engine = e; }) as never },
|
|
1370
|
+
);
|
|
1371
|
+
await nextTick();
|
|
1372
|
+
// No scene backdrop: the page's own backdrop shows through. The
|
|
1373
|
+
// drawing buffer must actually HAVE an alpha channel, and the clear
|
|
1374
|
+
// must be fully transparent — an opaque clear would read as black.
|
|
1375
|
+
expect(engine!.scene.background).toBeNull();
|
|
1376
|
+
expect(rendererOf().alpha).toBe(true);
|
|
1377
|
+
expect(rendererOf().clearAlpha).toBe(0);
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
it("drops the container's own surface paint in transparent mode", async () => {
|
|
1381
|
+
const { container } = mountBoard(
|
|
1382
|
+
{ minimap: false, transparentBackground: true },
|
|
1383
|
+
{},
|
|
1384
|
+
);
|
|
1385
|
+
await nextTick();
|
|
1386
|
+
const root = container.querySelector(".hk-board3d") as HTMLElement;
|
|
1387
|
+
// The stylesheet paints --color-surface on .hk-board3d; an inline
|
|
1388
|
+
// transparent style must win, or a page-colored rectangle sits
|
|
1389
|
+
// behind the alpha-0 canvas (user report 2026-10-01: the scene
|
|
1390
|
+
// background differs from the page background).
|
|
1391
|
+
expect(root.style.background).toContain("transparent");
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it("keeps the stylesheet surface paint when the backdrop is opaque", async () => {
|
|
1395
|
+
const { container } = mountBoard({ minimap: false }, {});
|
|
1396
|
+
await nextTick();
|
|
1397
|
+
const root = container.querySelector(".hk-board3d") as HTMLElement;
|
|
1398
|
+
expect(root.style.background).toBe("");
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
* walk up to the registered root via userData);
|
|
20
20
|
* - HOVER FRAME: the hovered object is wrapped in an 8-corner selection
|
|
21
21
|
* frame (default ON, see the `hoverBox` prop) — the pointer marks what
|
|
22
|
-
* it is aiming at instead of resizing it;
|
|
22
|
+
* it is aiming at instead of resizing it; hovering the SELECTED body
|
|
23
|
+
* shows nothing extra (the selection frame is already there);
|
|
24
|
+
* - SELECTION FRAME: `engine.setSelection(id)` pins the same 8-corner
|
|
25
|
+
* frame on a registered object for as long as the selection lasts —
|
|
26
|
+
* the persistent counterpart of the hover frame, tinted with the
|
|
27
|
+
* theme's primary instead of the text channel (user direction
|
|
28
|
+
* 2026-10-01: the fleet sky marks its selection with the cube cursor,
|
|
29
|
+
* never with a ring). The frame survives the object being removed and
|
|
30
|
+
* re-registered under the same id (a roster rebuild re-measures), and
|
|
31
|
+
* only `setSelection(null)` clears it;
|
|
23
32
|
* - LABELS: a CSS2DRenderer overlay — HTML chips that track world
|
|
24
33
|
* anchors, pointer-events opt-in per chip so drags still orbit;
|
|
25
34
|
* - MINIMAP: HkMinimap3D pinned bottom-right — a second, stereoscopic
|
|
@@ -27,7 +36,9 @@
|
|
|
27
36
|
* plane and content markers layered in;
|
|
28
37
|
* - THEME: scene background follows --color-background, re-read on
|
|
29
38
|
* data-mode / data-theme / style mutations; `engine.onTheme` lets the
|
|
30
|
-
* page re-tone its materials on the same signal
|
|
39
|
+
* page re-tone its materials on the same signal. With the
|
|
40
|
+
* `transparentBackground` prop the canvas clears to transparent
|
|
41
|
+
* instead, so the page's own backdrop shows through the sky;
|
|
31
42
|
* - FALLBACK: no WebGL → the `fallback` slot (or a localized note) and
|
|
32
43
|
* an `error` event; nothing throws out of mount.
|
|
33
44
|
*
|
|
@@ -119,6 +130,16 @@ export interface Board3DEngine {
|
|
|
119
130
|
/** Post-render hook (the minimap lives here); returns the disposer. */
|
|
120
131
|
addPostRender(fn: () => void): () => void;
|
|
121
132
|
setObject(id: string, def: Board3DObjectDef | null): void;
|
|
133
|
+
/** Pin the persistent 8-corner selection frame on the registered
|
|
134
|
+
* object `id` (`null` clears it). The frame is independent of the
|
|
135
|
+
* pointer: it rides the object through orbits and rebuilds, hides
|
|
136
|
+
* while the object is absent or unmeasurable, and re-measures when
|
|
137
|
+
* the id is re-registered. It marks what the page SELECTED — a
|
|
138
|
+
* `pickable: false` object is marked all the same (pointer
|
|
139
|
+
* reachability is a hover concern, not a selection one). Selecting
|
|
140
|
+
* an object also suppresses the HOVER frame on that same object —
|
|
141
|
+
* the selection frame is already marking it. */
|
|
142
|
+
setSelection(id: string | null): void;
|
|
122
143
|
/** Snapshot of the registered content (the minimap's marker set).
|
|
123
144
|
* Mutating it does not touch the board — register through
|
|
124
145
|
* `setObject` instead. */
|
|
@@ -291,6 +312,17 @@ function hoverFrameColor(background: THREE.Color): THREE.Color {
|
|
|
291
312
|
);
|
|
292
313
|
}
|
|
293
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The selection frame's tint: the theme's PRIMARY channel on both modes.
|
|
317
|
+
* It has to read apart from the hover frame (text/muted, i.e. near-white
|
|
318
|
+
* over a night sky) at a glance — the selection is a statement about the
|
|
319
|
+
* page's data, and primary is the one channel already reserved for
|
|
320
|
+
* exactly that.
|
|
321
|
+
*/
|
|
322
|
+
function selectionFrameColor(): THREE.Color {
|
|
323
|
+
return cssRgbToColor(readCssColor("--color-primary", "21 101 192"));
|
|
324
|
+
}
|
|
325
|
+
|
|
294
326
|
export default defineComponent({
|
|
295
327
|
name: "HkBoard3D",
|
|
296
328
|
props: {
|
|
@@ -329,13 +361,20 @@ export default defineComponent({
|
|
|
329
361
|
/** Wrap the hovered object in the 8-corner selection frame. Defaults
|
|
330
362
|
* ON: anything the pointer can select gets one, and `frameObject:
|
|
331
363
|
* null` opts a single object out. Turn it off for boards whose
|
|
332
|
-
* content is not "selected" at all.
|
|
364
|
+
* content is not "selected" at all. Hovering the object pinned by
|
|
365
|
+
* `engine.setSelection` draws no second frame — the selection
|
|
366
|
+
* frame is already there. */
|
|
333
367
|
hoverBox: { type: Boolean, default: true },
|
|
368
|
+
/** Clear the canvas to transparent instead of painting the theme
|
|
369
|
+
* background, so the page's own backdrop shows through the sky.
|
|
370
|
+
* STATIC after mount (the drawing buffer's alpha channel exists or
|
|
371
|
+
* it does not — remount to change it). */
|
|
372
|
+
transparentBackground: { type: Boolean, default: false },
|
|
334
373
|
/** Freeze the frame loop (content keeps its last pose). An animated
|
|
335
374
|
* flight issued while paused lands in a single jump on resume: the
|
|
336
|
-
* tween is evaluated against wall-clock time.
|
|
337
|
-
* it:
|
|
338
|
-
* (nothing re-renders while paused). */
|
|
375
|
+
* tween is evaluated against wall-clock time. Both pointer/selection
|
|
376
|
+
* frames are frozen with it: each keeps whatever it had painted
|
|
377
|
+
* until the loop resumes (nothing re-renders while paused). */
|
|
339
378
|
paused: { type: Boolean, default: false },
|
|
340
379
|
},
|
|
341
380
|
emits: {
|
|
@@ -357,6 +396,7 @@ export default defineComponent({
|
|
|
357
396
|
let camera: THREE.PerspectiveCamera | null = null;
|
|
358
397
|
let controls: OrbitControls | null = null;
|
|
359
398
|
let hoverFrame: HoverBoxHandle | null = null;
|
|
399
|
+
let selectionFrame: HoverBoxHandle | null = null;
|
|
360
400
|
let resizeObs: ResizeObserver | null = null;
|
|
361
401
|
let themeObs: MutationObserver | null = null;
|
|
362
402
|
let rafId = 0;
|
|
@@ -384,6 +424,8 @@ export default defineComponent({
|
|
|
384
424
|
const ndc = new THREE.Vector2();
|
|
385
425
|
let hoverDirty = false;
|
|
386
426
|
let lastHover: string | null = null;
|
|
427
|
+
/** The id pinned by `engine.setSelection` (null = no selection). */
|
|
428
|
+
let selectionId: string | null = null;
|
|
387
429
|
let pressAt: { pointerId: number; x: number; y: number } | null = null;
|
|
388
430
|
|
|
389
431
|
// ── camera tween ──────────────────────────────────────────────────
|
|
@@ -400,9 +442,14 @@ export default defineComponent({
|
|
|
400
442
|
const bgRaw = readCssColor("--color-background", "10 15 25");
|
|
401
443
|
const primaryRaw = readCssColor("--color-primary", "21 101 192");
|
|
402
444
|
const bg = cssRgbToColor(bgRaw);
|
|
403
|
-
|
|
404
|
-
|
|
445
|
+
// Transparent mode keeps the TINT for the clear colour but drops
|
|
446
|
+
// the scene backdrop and the clear ALPHA to 0, so the page's own
|
|
447
|
+
// backdrop shows through (the renderer must have been mounted with
|
|
448
|
+
// `alpha: true` — the prop is static for exactly this reason).
|
|
449
|
+
if (scene) scene.background = props.transparentBackground ? null : bg;
|
|
450
|
+
if (renderer) renderer.setClearColor(bg, props.transparentBackground ? 0 : 1);
|
|
405
451
|
hoverFrame?.setColor(hoverFrameColor(bg));
|
|
452
|
+
selectionFrame?.setColor(selectionFrameColor());
|
|
406
453
|
for (const cb of themeHooks) cb(bgRaw, primaryRaw);
|
|
407
454
|
}
|
|
408
455
|
|
|
@@ -453,6 +500,10 @@ export default defineComponent({
|
|
|
453
500
|
* measurement is the expensive half, so it happens on hover change
|
|
454
501
|
* only — the per-frame half is one matrix composition. */
|
|
455
502
|
let frameBound = false;
|
|
503
|
+
/** Same latch/cooldown pair for the SELECTION frame (it re-measures
|
|
504
|
+
* when the selected id is re-registered, i.e. after a rebuild). */
|
|
505
|
+
let selectionBound = false;
|
|
506
|
+
let selectionCooldown = 0;
|
|
456
507
|
/** Frames still to skip before the next measurement attempt while the
|
|
457
508
|
* hovered target has nothing measurable to wrap (content still
|
|
458
509
|
* streaming in). A permanently unmeasurable target — a group of
|
|
@@ -537,10 +588,16 @@ export default defineComponent({
|
|
|
537
588
|
* content still streaming in under a resting pointer — also stays
|
|
538
589
|
* un-latched, which is what lets the frame appear when the content
|
|
539
590
|
* finally lands.
|
|
591
|
+
*
|
|
592
|
+
* The SELECTED object is likewise unmarked: its selection frame is
|
|
593
|
+
* already on, and two nested brackets around one body read as noise,
|
|
594
|
+
* not information.
|
|
540
595
|
*/
|
|
541
596
|
function syncHoverFrame(): void {
|
|
542
597
|
if (!hoverFrame) return;
|
|
543
|
-
const def = props.hoverBox && lastHover !== null
|
|
598
|
+
const def = props.hoverBox && lastHover !== null && lastHover !== selectionId
|
|
599
|
+
? registry.get(lastHover)
|
|
600
|
+
: undefined;
|
|
544
601
|
const target = def ? (def.frameObject === null ? null : (def.frameObject ?? def.object)) : null;
|
|
545
602
|
if (!def || def.pickable === false || !target || !isVisibleInHierarchy(target)) {
|
|
546
603
|
hoverFrame.hide();
|
|
@@ -560,6 +617,36 @@ export default defineComponent({
|
|
|
560
617
|
hoverFrame.refresh();
|
|
561
618
|
}
|
|
562
619
|
|
|
620
|
+
/**
|
|
621
|
+
* The selection frame: the same measure-once / ride-the-world-matrix
|
|
622
|
+
* contract as the hover frame, driven by `engine.setSelection`
|
|
623
|
+
* instead of the pointer. It is deliberately STICKY — a removed id
|
|
624
|
+
* (a roster rebuild strips and re-registers every body) hides the
|
|
625
|
+
* frame for the interim and re-measures on re-registration, but the
|
|
626
|
+
* selection itself only ever ends through `setSelection(null)`.
|
|
627
|
+
*/
|
|
628
|
+
function syncSelectionFrame(): void {
|
|
629
|
+
if (!selectionFrame) return;
|
|
630
|
+
const def = selectionId !== null ? registry.get(selectionId) : undefined;
|
|
631
|
+
const target = def ? (def.frameObject === null ? null : (def.frameObject ?? def.object)) : null;
|
|
632
|
+
if (!def || !target || !isVisibleInHierarchy(target)) {
|
|
633
|
+
selectionFrame.hide();
|
|
634
|
+
selectionBound = false;
|
|
635
|
+
selectionCooldown = 0;
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (!selectionBound) {
|
|
639
|
+
if (selectionCooldown > 0) {
|
|
640
|
+
selectionCooldown -= 1;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
selectionBound = selectionFrame.attach(def.object, target);
|
|
644
|
+
selectionCooldown = selectionBound ? 0 : HOVER_BOX_RETRY_FRAMES;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
selectionFrame.refresh();
|
|
648
|
+
}
|
|
649
|
+
|
|
563
650
|
function frame(): void {
|
|
564
651
|
if (disposed) return;
|
|
565
652
|
rafId = requestAnimationFrame(frame);
|
|
@@ -595,6 +682,7 @@ export default defineComponent({
|
|
|
595
682
|
// After the tick hooks have posed the content, so the frame lands on
|
|
596
683
|
// this frame's pose rather than the previous one.
|
|
597
684
|
syncHoverFrame();
|
|
685
|
+
syncSelectionFrame();
|
|
598
686
|
|
|
599
687
|
renderer.render(scene, camera);
|
|
600
688
|
labelRenderer?.render(scene, camera);
|
|
@@ -607,12 +695,16 @@ export default defineComponent({
|
|
|
607
695
|
if (!canvas || !container) return;
|
|
608
696
|
|
|
609
697
|
scene = new THREE.Scene();
|
|
610
|
-
//
|
|
611
|
-
// the scene root (so
|
|
612
|
-
//
|
|
698
|
+
// Both frames are pointer/selection affordances, not scenery: they
|
|
699
|
+
// hang off the scene root (so they never join framing, markers or
|
|
700
|
+
// picking) and ride the main-camera-only layer, keeping the minimap
|
|
701
|
+
// render clean.
|
|
613
702
|
hoverFrame = createHoverBox();
|
|
614
703
|
hoverFrame.object.layers.set(BOARD3D_MAIN_LAYER);
|
|
615
704
|
scene.add(hoverFrame.object);
|
|
705
|
+
selectionFrame = createHoverBox();
|
|
706
|
+
selectionFrame.object.layers.set(BOARD3D_MAIN_LAYER);
|
|
707
|
+
scene.add(selectionFrame.object);
|
|
616
708
|
// A degenerate fov (≤ 0 or ≥ 180) silently bricks the projection —
|
|
617
709
|
// projectionMatrix[5] becomes Infinity/negative, projection and
|
|
618
710
|
// picking return NaN, and nothing throws. Fall back to the default.
|
|
@@ -627,7 +719,15 @@ export default defineComponent({
|
|
|
627
719
|
camera.position.set(initPos[0], initPos[1], initPos[2]);
|
|
628
720
|
camera.lookAt(initTarget[0], initTarget[1], initTarget[2]);
|
|
629
721
|
|
|
630
|
-
renderer = new THREE.WebGLRenderer({
|
|
722
|
+
renderer = new THREE.WebGLRenderer({
|
|
723
|
+
canvas,
|
|
724
|
+
antialias: true,
|
|
725
|
+
// Transparent backdrops need an alpha channel in the drawing
|
|
726
|
+
// buffer — an `alpha: false` context would paint the cleared
|
|
727
|
+
// pixels opaque black over the page. This is why the prop is
|
|
728
|
+
// documented STATIC after mount.
|
|
729
|
+
alpha: props.transparentBackground,
|
|
730
|
+
});
|
|
631
731
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
632
732
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
633
733
|
renderer.toneMappingExposure = 1.15;
|
|
@@ -706,7 +806,13 @@ export default defineComponent({
|
|
|
706
806
|
// their content), and a frame still measured against the old one
|
|
707
807
|
// would hang where that object used to be — it is out of the
|
|
708
808
|
// scene now and never moves again. Measure again on the next
|
|
709
|
-
// frame instead.
|
|
809
|
+
// frame instead. The selection frame follows the same rule for
|
|
810
|
+
// its own id (the fleet sky strips and re-registers every body
|
|
811
|
+
// on a roster refresh; the selection must survive that).
|
|
812
|
+
if (id === selectionId) {
|
|
813
|
+
selectionBound = false;
|
|
814
|
+
selectionCooldown = 0;
|
|
815
|
+
}
|
|
710
816
|
if (id === lastHover) {
|
|
711
817
|
frameBound = false;
|
|
712
818
|
measureCooldown = 0;
|
|
@@ -753,6 +859,24 @@ export default defineComponent({
|
|
|
753
859
|
// A snapshot: a consumer that casts it to Map and clears it must
|
|
754
860
|
// not be able to desynchronise the board from its scene.
|
|
755
861
|
objects: () => new Map(registry),
|
|
862
|
+
setSelection(id) {
|
|
863
|
+
// Re-selecting the SAME id is a no-op: releasing the latch would
|
|
864
|
+
// force a full re-measure next frame for a frame that is already
|
|
865
|
+
// correct — a page that re-selects on every data refresh would
|
|
866
|
+
// otherwise pay the subtree walk each time (the hover path makes
|
|
867
|
+
// the same guard at the event level).
|
|
868
|
+
if (selectionId === id) return;
|
|
869
|
+
// The frame hides itself while the id is absent from the
|
|
870
|
+
// registry (syncSelectionFrame runs every frame), so an unknown
|
|
871
|
+
// or not-yet-registered id is accepted as-is — a consumer may
|
|
872
|
+
// legitimately select before the content lands. A CHANGED id
|
|
873
|
+
// must also release the measurement latch: without this, the
|
|
874
|
+
// frame keeps riding the PREVIOUS anchor's world matrix and
|
|
875
|
+
// paints a bracket around the body the user just deselected.
|
|
876
|
+
selectionId = id;
|
|
877
|
+
selectionBound = false;
|
|
878
|
+
selectionCooldown = 0;
|
|
879
|
+
},
|
|
756
880
|
flyTo(position, target, durationMs = 600) {
|
|
757
881
|
if (!camera || !controls) return;
|
|
758
882
|
// A non-finite pose would brick the camera silently (every later
|
|
@@ -970,7 +1094,13 @@ export default defineComponent({
|
|
|
970
1094
|
hoverFrame.dispose();
|
|
971
1095
|
hoverFrame = null;
|
|
972
1096
|
}
|
|
1097
|
+
if (selectionFrame) {
|
|
1098
|
+
scene?.remove(selectionFrame.object);
|
|
1099
|
+
selectionFrame.dispose();
|
|
1100
|
+
selectionFrame = null;
|
|
1101
|
+
}
|
|
973
1102
|
frameBound = false;
|
|
1103
|
+
selectionBound = false;
|
|
974
1104
|
controls?.dispose();
|
|
975
1105
|
if (labelRenderer?.domElement.parentNode) {
|
|
976
1106
|
labelRenderer.domElement.parentNode.removeChild(labelRenderer.domElement);
|
|
@@ -1004,7 +1134,16 @@ export default defineComponent({
|
|
|
1004
1134
|
});
|
|
1005
1135
|
|
|
1006
1136
|
return () => (
|
|
1007
|
-
<div
|
|
1137
|
+
<div
|
|
1138
|
+
class="hk-board3d"
|
|
1139
|
+
// The transparent-backdrop mode clears the CANVAS to alpha 0, but
|
|
1140
|
+
// the container's own surface paint would still sit behind it —
|
|
1141
|
+
// a page-colored rectangle where the page's backdrop should show.
|
|
1142
|
+
// An inline style wins over the stylesheet without a specificity
|
|
1143
|
+
// war with consumer classes.
|
|
1144
|
+
style={props.transparentBackground ? { background: "transparent" } : undefined}
|
|
1145
|
+
ref={containerRef}
|
|
1146
|
+
>
|
|
1008
1147
|
<canvas ref={canvasRef} class="hk-board3d-canvas" />
|
|
1009
1148
|
{failed.value ? (
|
|
1010
1149
|
slots.fallback ? (
|