@celestia-island/hikari 0.55.261 → 0.55.262
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/components/HkMenu.rail.test.tsx +72 -3
- package/src/components/HkMenu.scss +7 -7
- package/src/components/HkSidebar.scss +14 -10
- package/src/components/HkSidebar.styles.test.ts +55 -10
- package/src/components/HkSidebar.tsx +6 -5
- package/src/scene3d/holographicFleetBody.test.ts +214 -90
- package/src/scene3d/holographicFleetBody.ts +327 -231
- package/src/scene3d/holographicFleetScene.test.ts +156 -106
- package/src/scene3d/holographicFleetScene.ts +47 -64
package/package.json
CHANGED
|
@@ -200,6 +200,11 @@ describe("HkMenu sidebar icon rail", () => {
|
|
|
200
200
|
|
|
201
201
|
const children = c.querySelector(".hk-menu-sidebar-rail-children");
|
|
202
202
|
expect(children, "the open group lists its children in the rail").toBeTruthy();
|
|
203
|
+
// The rail wrapper wears the SHARED children class too: the expanded
|
|
204
|
+
// sidebar's 2px margin step reaches the rail through it — that class
|
|
205
|
+
// union IS the vertical-invariance carrier (2026-10-31). A rail-only
|
|
206
|
+
// wrapper would re-introduce the per-density step.
|
|
207
|
+
expect(children!.classList.contains("hk-menu-sidebar-children")).toBe(true);
|
|
203
208
|
const childRows = railRows(children!);
|
|
204
209
|
expect(childRows.map((row) => row.getAttribute("aria-label"))).toEqual(["Users", "Audit log"]);
|
|
205
210
|
for (const row of childRows) {
|
|
@@ -238,6 +243,15 @@ describe("HkMenu sidebar icon rail", () => {
|
|
|
238
243
|
expect(c.querySelector(".hk-menu-sidebar-group[data-open]"), "the group is still open").toBeTruthy();
|
|
239
244
|
expect(groupRows(c)[0]!.getAttribute("aria-expanded")).toBe("true");
|
|
240
245
|
expect(c.textContent).toContain("Users");
|
|
246
|
+
// TWO-SIDED class-union pin: the EXPANDED wrapper must wear the
|
|
247
|
+
// shared class too. Dropping it there removes the 2px step from the
|
|
248
|
+
// expanded sidebar — rows below an open group would move on every
|
|
249
|
+
// flip, the exact drift this contract kills (a rail-only pin let
|
|
250
|
+
// that through).
|
|
251
|
+
expect(
|
|
252
|
+
c.querySelector(".hk-menu-sidebar-group[data-open] > .hk-menu-sidebar-children"),
|
|
253
|
+
"the expanded children wrapper keeps the shared step class",
|
|
254
|
+
).toBeTruthy();
|
|
241
255
|
|
|
242
256
|
// Reverse direction: collapse it from the expanded sidebar, flip
|
|
243
257
|
// back into the rail — it must stay collapsed (no resurrection).
|
|
@@ -508,10 +522,65 @@ describe("HkMenu rail styles (compiled sheet contracts)", () => {
|
|
|
508
522
|
expect(row).toContain("justify-content: center");
|
|
509
523
|
});
|
|
510
524
|
|
|
511
|
-
it("keeps open group children
|
|
525
|
+
it("keeps open group children at the EXPANDED vertical step (no rail re-step)", () => {
|
|
526
|
+
// Vertical rhythm is density-invariant (2026-10-31 user direction:
|
|
527
|
+
// the collapse toggle must not move rows). The rail children wrapper
|
|
528
|
+
// therefore carries NO margin rule of its own — the expanded
|
|
529
|
+
// sidebar's 2px step from the shared .hk-menu-sidebar-children rule
|
|
530
|
+
// applies in the rail too, and the wrapper stays width-only (its
|
|
531
|
+
// selector survives only inside the comma-grouped width rule, which
|
|
532
|
+
// the exact-line ruleBody skips — the null here asserts no
|
|
533
|
+
// STANDALONE rail-children rule came back).
|
|
512
534
|
const children = ruleBody(".hk-menu-sidebar[data-collapsed] .hk-menu-sidebar-rail-children");
|
|
513
|
-
expect(children, "
|
|
514
|
-
|
|
535
|
+
expect(children, "a standalone rail-children rule re-stepped the margin").toBeNull();
|
|
536
|
+
const shared = ruleBody(".hk-menu-sidebar-children");
|
|
537
|
+
expect(shared, "the shared children rule exists").not.toBeNull();
|
|
538
|
+
expect(shared).toContain("margin-top: var(--space-2, 2px)");
|
|
539
|
+
// A rule whose selector is EXACTLY a rail wrapper class — top-level,
|
|
540
|
+
// outside any density dress — would restyle the rail children while
|
|
541
|
+
// evading every dress-keyed scan below (the compiled chunk then
|
|
542
|
+
// carries no carrier text). The wrappers' geometry must come from
|
|
543
|
+
// the shared rules alone.
|
|
544
|
+
expect(css.match(/^\s*\.hk-menu-sidebar-rail-(?:children|group)\s*\{/gm)).toBeNull();
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("the rail dress authors no vertical rhythm in any spelling", () => {
|
|
548
|
+
// The stronger form of the contract above, so a re-step cannot hide
|
|
549
|
+
// inside a comma-grouped selector the per-selector ruleBody skips
|
|
550
|
+
// AND cannot return through alternate spellings or sister metrics:
|
|
551
|
+
// every rule of the density dress is scanned and banned for ANY
|
|
552
|
+
// margin/padding spelling that reaches a block edge — the shorthand
|
|
553
|
+
// (it sets the block side too: the historical `margin-top: 0`
|
|
554
|
+
// regression spelled `margin: 0` evaded a longhand-only scan) and
|
|
555
|
+
// every vertical longhand, top AND bottom — plus the block-FLOW
|
|
556
|
+
// metrics that move rows just the same (gap/row-gap/height/
|
|
557
|
+
// min-height/max-height: a `gap:` inside the comma-grouped width
|
|
558
|
+
// rule once passed a margin/padding-only scan). Horizontal-only
|
|
559
|
+
// tracks (margin-inline*, padding-inline*) stay legal; absolute
|
|
560
|
+
// INSETS (the caret corner's inset-block-end) are flow-neutral
|
|
561
|
+
// decoration and stay legal. A rail band that truly needs a
|
|
562
|
+
// different block metric must move the BASE rule for BOTH densities.
|
|
563
|
+
// Keyed to BOTH carrier spellings so a future [data-rail]-keyed
|
|
564
|
+
// carrier in this sheet cannot slip the scan.
|
|
565
|
+
const dressRules = css
|
|
566
|
+
.split("}")
|
|
567
|
+
.filter(
|
|
568
|
+
(rule) =>
|
|
569
|
+
rule.includes(".hk-menu-sidebar[data-collapsed]") ||
|
|
570
|
+
rule.includes(".hk-menu-sidebar[data-rail]"),
|
|
571
|
+
);
|
|
572
|
+
expect(dressRules.length, "the rail dress rules exist").toBeGreaterThan(3);
|
|
573
|
+
for (const rule of dressRules) {
|
|
574
|
+
expect(rule, `vertical margin in the rail dress: ${rule}`).not.toMatch(
|
|
575
|
+
/\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
|
|
576
|
+
);
|
|
577
|
+
expect(rule, `vertical padding in the rail dress: ${rule}`).not.toMatch(
|
|
578
|
+
/\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
|
|
579
|
+
);
|
|
580
|
+
expect(rule, `block-flow metric in the rail dress: ${rule}`).not.toMatch(
|
|
581
|
+
/\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
515
584
|
});
|
|
516
585
|
|
|
517
586
|
it("mirrors the rail caret in RTL with the scaleX convention, never a rotate", () => {
|
|
@@ -193,18 +193,18 @@
|
|
|
193
193
|
font-weight: 600;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/* Rail groups keep the
|
|
197
|
-
*
|
|
198
|
-
*
|
|
196
|
+
/* Rail groups keep the ONE-COLUMN rhythm at every depth: width-only,
|
|
197
|
+
* never an indent. The children wrapper deliberately carries NO
|
|
198
|
+
* margin override of its own — the expanded sidebar's 2px step (the
|
|
199
|
+
* shared .hk-menu-sidebar-children rule above) applies in the rail
|
|
200
|
+
* too, because vertical rhythm is density-invariant (2026-10-31 user
|
|
201
|
+
* direction): a rail-specific step would move every row below an open
|
|
202
|
+
* group on each collapse/expand flip. */
|
|
199
203
|
.hk-menu-sidebar-rail-group,
|
|
200
204
|
.hk-menu-sidebar-rail-children {
|
|
201
205
|
width: 100%;
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
.hk-menu-sidebar-rail-children {
|
|
205
|
-
margin-top: 0;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
208
|
/* Disclosure ink, at the same secondary weight as the expanded
|
|
209
209
|
* sidebar's .hk-menu-more. The placeholder form lives in the glyph
|
|
210
210
|
* cell and centers like every other row icon. */
|
|
@@ -86,23 +86,27 @@
|
|
|
86
86
|
* kit (HkSidebarRailSwap) — the generation's [data-rail] flips with the
|
|
87
87
|
* swap key, so the leaving generation keeps its dress for the dissolve
|
|
88
88
|
* while the entering one takes the new one. A root-level attr cannot do
|
|
89
|
-
* that: it would restyle the frozen leaving copy mid-dissolve.
|
|
89
|
+
* that: it would restyle the frozen leaving copy mid-dissolve.
|
|
90
|
+
*
|
|
91
|
+
* VERTICAL RHYTHM IS DENSITY-INVARIANT (2026-10-31 user direction: the
|
|
92
|
+
* collapse toggle must not move rows). The dress authors INLINE geometry
|
|
93
|
+
* only — the header/footer rules below narrow the horizontal padding for
|
|
94
|
+
* the rail column and recentre the header, while every BLOCK metric (the
|
|
95
|
+
* header's 16px band, the body's 20px leading air + scroll tail, the
|
|
96
|
+
* footer's 16px band) stays with the base rules alone. Shrinking the
|
|
97
|
+
* collapsed paddings used to lift the whole nav by up to two paddings'
|
|
98
|
+
* worth on every flip; a band that needs a different vertical dress in
|
|
99
|
+
* rail mode would have to move the base values for BOTH densities, never
|
|
100
|
+
* add a per-density block padding. */
|
|
90
101
|
.hk-sidebar[data-collapsed],
|
|
91
102
|
.hk-sidebar-rail-generation[data-rail] {
|
|
92
103
|
.hk-sidebar-header {
|
|
93
104
|
justify-content: center;
|
|
94
|
-
padding: var(--space-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
.hk-sidebar-body {
|
|
98
|
-
/* Same end-cap as the expanded body — collapsed rows are icon-only
|
|
99
|
-
* but the tail's job (announcing the end of the scroll range) is
|
|
100
|
-
* identical, so only the leading air shrinks here. */
|
|
101
|
-
padding-block: var(--space-12) var(--hk-sidebar-scroll-tail);
|
|
105
|
+
padding: var(--space-16) var(--space-8);
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
.hk-sidebar-footer {
|
|
105
|
-
padding: var(--space-
|
|
109
|
+
padding: var(--space-16) var(--space-8);
|
|
106
110
|
}
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -74,16 +74,61 @@ describe("HkSidebar scroll end-cap", () => {
|
|
|
74
74
|
expect(body).not.toMatch(/padding-block:[^;]*--space-4/);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
it("keeps
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
expect(
|
|
86
|
-
|
|
77
|
+
it("the density dress keeps every vertical metric at the expanded value", () => {
|
|
78
|
+
// Vertical rhythm is density-invariant (2026-10-31 user direction:
|
|
79
|
+
// the collapse toggle must not move rows). The body therefore has no
|
|
80
|
+
// density rule AT ALL — the leading air AND the scroll tail ride the
|
|
81
|
+
// single base rule in both densities — and the header/footer dress
|
|
82
|
+
// rules keep the expanded BLOCK padding, narrowing only the inline
|
|
83
|
+
// track for the rail column.
|
|
84
|
+
expect(css).not.toContain(".hk-sidebar[data-collapsed] .hk-sidebar-body");
|
|
85
|
+
expect(css).not.toContain(".hk-sidebar-rail-generation[data-rail] .hk-sidebar-body");
|
|
86
|
+
// The two carrier literals above are the compiled spellings; a brand
|
|
87
|
+
// NEW carrier (a third density hook) is a new dress and must re-run
|
|
88
|
+
// this contract — not mechanically foreseeable here.
|
|
89
|
+
// ruleBody needs the arm its brace follows; the dress compiles to one
|
|
90
|
+
// comma-grouped rule per band, so the generation arm is the one the
|
|
91
|
+
// helper can read (the legacy arm shares its body).
|
|
92
|
+
const header = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-header");
|
|
93
|
+
expect(header, "the collapsed header rule exists (grouped density carriers)").not.toBeNull();
|
|
94
|
+
expect(header).toContain("padding: var(--space-16) var(--space-8)");
|
|
95
|
+
const footer = ruleBody(css, ".hk-sidebar-rail-generation[data-rail] .hk-sidebar-footer");
|
|
96
|
+
expect(footer, "the collapsed footer rule exists (grouped density carriers)").not.toBeNull();
|
|
97
|
+
expect(footer).toContain("padding: var(--space-16) var(--space-8)");
|
|
98
|
+
// The stronger, spelling-proof form (whole-dress scan): EVERY
|
|
99
|
+
// padding declaration in ANY dress rule must be EXACTLY the expanded
|
|
100
|
+
// two-track shorthand — exact, not a prefix: a three-track spelling
|
|
101
|
+
// (`padding: var(--space-16) var(--space-8) var(--space-12)`) drifts
|
|
102
|
+
// the block END past a block-start-only check, and a longhand
|
|
103
|
+
// (`padding-block-start:`) hides from a shorthand-only one; both
|
|
104
|
+
// green-holed earlier rounds. Physical `bottom` longhands are banned
|
|
105
|
+
// beside the logical ones. Horizontal-only tracks (padding-inline*)
|
|
106
|
+
// cannot match the value pin and are rejected the same way; a band
|
|
107
|
+
// that truly needs different block metrics must move the BASE rule
|
|
108
|
+
// for BOTH densities. Margins are banned outright — the bands
|
|
109
|
+
// separate by their paddings alone — and so are the block-FLOW
|
|
110
|
+
// metrics that move rows just the same (gap/row-gap/height/
|
|
111
|
+
// min-height/max-height).
|
|
112
|
+
const dressRules = css
|
|
113
|
+
.split("}")
|
|
114
|
+
.filter((rule) => rule.includes("[data-collapsed]") || rule.includes("[data-rail]"));
|
|
115
|
+
expect(dressRules.length, "the density dress rules exist").toBeGreaterThan(1);
|
|
116
|
+
for (const rule of dressRules) {
|
|
117
|
+
for (const decl of rule.matchAll(
|
|
118
|
+
/\bpadding(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:[^;}]*/g,
|
|
119
|
+
)) {
|
|
120
|
+
expect(
|
|
121
|
+
decl[0].trim(),
|
|
122
|
+
`a dress padding is not the expanded two-track shorthand: ${decl[0]}`,
|
|
123
|
+
).toBe("padding: var(--space-16) var(--space-8)");
|
|
124
|
+
}
|
|
125
|
+
expect(rule, `a margin crept into the dress: ${rule}`).not.toMatch(
|
|
126
|
+
/\bmargin(?:-(?:top|bottom|block(?:-(?:start|end))?))?\s*:/,
|
|
127
|
+
);
|
|
128
|
+
expect(rule, `block-flow metric in the dress: ${rule}`).not.toMatch(
|
|
129
|
+
/\b(?:gap|row-gap|height|min-height|max-height)\s*:/,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
87
132
|
});
|
|
88
133
|
|
|
89
134
|
it("reads the tail fallback from the scss source, not a drifted literal", () => {
|
|
@@ -155,11 +155,12 @@ export default defineComponent({
|
|
|
155
155
|
// dissolve), which is where the position is read from — an event-only
|
|
156
156
|
// save would miss programmatic scrolls (no `scroll` event fires for
|
|
157
157
|
// them), so the read happens on the ref hand-off itself and the
|
|
158
|
-
// onScroll below merely keeps the value fresh between flips.
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// any overshoot
|
|
158
|
+
// onScroll below merely keeps the value fresh between flips. For
|
|
159
|
+
// library-owned rows the raw restore is EXACT since the vertical
|
|
160
|
+
// rhythm became density-invariant (2026-10-31: both dresses render
|
|
161
|
+
// the same band paddings and row heights, so y offsets match); the
|
|
162
|
+
// browser clamps any overshoot a host's density-dependent content
|
|
163
|
+
// could still produce.
|
|
163
164
|
const savedScrollTop = ref(0);
|
|
164
165
|
const onBodyScroll = (e: Event): void => {
|
|
165
166
|
savedScrollTop.value = (e.currentTarget as HTMLElement).scrollTop;
|
|
@@ -3,10 +3,9 @@ import * as THREE from "three";
|
|
|
3
3
|
|
|
4
4
|
import { fakeAs } from "../test-utils/fakes";
|
|
5
5
|
import {
|
|
6
|
-
extractHologramFaces,
|
|
7
|
-
faceInradius,
|
|
8
6
|
hologramHudText,
|
|
9
7
|
makeFleetHologram,
|
|
8
|
+
makeSatelliteHologram,
|
|
10
9
|
poseFleetHologram,
|
|
11
10
|
type FleetHologramInput,
|
|
12
11
|
} from "./holographicFleetBody";
|
|
@@ -49,116 +48,241 @@ describe("hologramHudText", () => {
|
|
|
49
48
|
});
|
|
50
49
|
|
|
51
50
|
describe("makeFleetHologram", () => {
|
|
52
|
-
it("
|
|
51
|
+
it("pins the real-data ticker to the centre of the front dial", () => {
|
|
53
52
|
const fillText = mockCanvas();
|
|
54
53
|
const hologram = makeFleetHologram(INPUT, new THREE.Color(0x7fb8dd), false);
|
|
55
54
|
|
|
56
55
|
expect(hologram.hudTexture).not.toBeNull();
|
|
57
|
-
|
|
58
|
-
expect(
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
expect(hologram.hudPanel).not.toBeNull();
|
|
57
|
+
expect(hologram.hudPanel!.geometry).toBeInstanceOf(THREE.PlaneGeometry);
|
|
58
|
+
// The ticker is a child of the camera-facing dial, AT its centre: the
|
|
59
|
+
// dial owns the plane, so facing the dial faces the text (user
|
|
60
|
+
// direction 2026-10-31 — no wrapping ribbon).
|
|
61
|
+
expect(hologram.frontDial.children).toContain(hologram.hudPanel);
|
|
62
|
+
expect(hologram.hudPanel!.position.x).toBe(0);
|
|
63
|
+
expect(hologram.hudPanel!.position.y).toBe(0);
|
|
64
|
+
// Not vacuous: a blank ticker must fail here, not just render wrong
|
|
65
|
+
// text when it renders anything at all.
|
|
66
|
+
expect(fillText.mock.calls.length).toBeGreaterThan(0);
|
|
61
67
|
for (const call of fillText.mock.calls) {
|
|
62
68
|
expect(call[0]).toBe("node-1 engine stack · Online · 3 endpoints");
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
//
|
|
66
|
-
//
|
|
71
|
+
// The plane's aspect matches the texture's (1024×96): a mismatched
|
|
72
|
+
// aspect compresses the glyphs horizontally (R2 S3-1 regression).
|
|
73
|
+
const plane = hologram.hudPanel!.geometry as THREE.PlaneGeometry;
|
|
74
|
+
const params = plane.parameters as { width: number; height: number };
|
|
75
|
+
expect(params.width / params.height).toBeCloseTo(1024 / 96, 5);
|
|
76
|
+
|
|
77
|
+
// The text plane never widens the body's pointer target.
|
|
67
78
|
const hits: THREE.Intersection[] = [];
|
|
68
|
-
|
|
79
|
+
hologram.hudPanel!.raycast(new THREE.Raycaster(), hits);
|
|
69
80
|
expect(hits).toHaveLength(0);
|
|
70
|
-
|
|
71
|
-
const initialRingY = hologram.rings.map((ring) => ring.pivot.rotation.y);
|
|
72
|
-
poseFleetHologram(hologram, 5, 20);
|
|
73
|
-
expect(hologram.mesh.rotation.y).toBeCloseTo(Math.PI / 2, 6);
|
|
74
|
-
hologram.rings.forEach((ring, index) => {
|
|
75
|
-
expect(ring.pivot.rotation.y).not.toBe(initialRingY[index]);
|
|
76
|
-
});
|
|
77
|
-
expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.09, 6);
|
|
78
|
-
poseFleetHologram(hologram, 10, 20);
|
|
79
|
-
expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.18, 6);
|
|
80
81
|
});
|
|
81
82
|
|
|
82
|
-
it("
|
|
83
|
+
it("gives main cores denser dial content than node cores", () => {
|
|
83
84
|
mockCanvas();
|
|
84
85
|
const hub = makeFleetHologram({ ...INPUT, role: "star" }, new THREE.Color(), false);
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
86
|
+
const node = makeFleetHologram(INPUT, new THREE.Color(), false);
|
|
87
|
+
|
|
88
|
+
const dialCounts = (hologram: ReturnType<typeof makeFleetHologram>) => ({
|
|
89
|
+
rings: hologram.frontDial.getObjectsByProperty("name", "fleet-hologram-face-dial-ring").length,
|
|
90
|
+
arcs: hologram.frontDial.getObjectsByProperty("name", "fleet-hologram-face-dial-arc").length,
|
|
91
|
+
core: hologram.frontDial.getObjectsByProperty("name", "fleet-hologram-face-dial-core").length,
|
|
92
|
+
ticks: (() => {
|
|
93
|
+
const ticks = hologram.frontDial.getObjectByName("fleet-hologram-face-dial-ticks");
|
|
94
|
+
if (!ticks) return 0;
|
|
95
|
+
return (ticks as THREE.LineSegments).geometry.getAttribute("position").count / 2;
|
|
96
|
+
})(),
|
|
97
|
+
spindle: hologram.shell.getObjectsByProperty(
|
|
98
|
+
"name",
|
|
99
|
+
"fleet-hologram-ring-spindle-circle",
|
|
100
|
+
).length,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// User direction 2026-10-31: a main core's ball is bigger AND carries
|
|
104
|
+
// more content; a node core is smaller and sparser — never identical.
|
|
105
|
+
expect(dialCounts(hub)).toEqual({ rings: 3, ticks: 48, arcs: 2, core: 1, spindle: 9 });
|
|
106
|
+
expect(dialCounts(node)).toEqual({ rings: 2, ticks: 28, arcs: 1, core: 1, spindle: 6 });
|
|
90
107
|
});
|
|
91
108
|
|
|
92
|
-
it("
|
|
109
|
+
it("builds the ball from circles only — no solid sphere, no wireframe cage", () => {
|
|
93
110
|
mockCanvas();
|
|
94
|
-
for (const
|
|
95
|
-
const hologram = makeFleetHologram(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
for (const role of ["star", "planet"] as const) {
|
|
112
|
+
const hologram = makeFleetHologram({ ...INPUT, role }, new THREE.Color(), false);
|
|
113
|
+
|
|
114
|
+
// No SphereGeometry anywhere: the pick proxy is an invisible
|
|
115
|
+
// icosahedron, everything visible is a circle, a plane or a disc.
|
|
116
|
+
const spheres: THREE.Mesh[] = [];
|
|
117
|
+
hologram.group.traverse((obj) => {
|
|
118
|
+
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) {
|
|
119
|
+
spheres.push(obj);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
expect(spheres, `${role} solid spheres`).toHaveLength(0);
|
|
123
|
+
|
|
124
|
+
// The shell is six overlapping support dials whose rims merge into
|
|
125
|
+
// one rounded envelope — the fused structures must not leave a
|
|
126
|
+
// readable cube behind (no shared wireframe, no box edges).
|
|
127
|
+
const shellDials = hologram.shell.getObjectsByProperty("name", "fleet-hologram-face-dial");
|
|
128
|
+
expect(shellDials, `${role} shell dial count`).toHaveLength(6);
|
|
129
|
+
// The six dials sit on the principal axes at ±0.4r — the geometry
|
|
130
|
+
// of the merge, not just the count (collapse-to-origin stays RED).
|
|
131
|
+
const offsets = shellDials.map((dial) => dial.position.clone());
|
|
132
|
+
const expectedAxes: Array<[number, number, number]> = [
|
|
133
|
+
[0, 0, 0.4], [0, 0, -0.4], [0.4, 0, 0], [-0.4, 0, 0],
|
|
134
|
+
[0, 0.4, 0], [0, -0.4, 0],
|
|
135
|
+
];
|
|
136
|
+
for (const axis of expectedAxes) {
|
|
137
|
+
const match = offsets.find((p) =>
|
|
138
|
+
Math.abs(p.x - axis[0]) < 1e-9
|
|
139
|
+
&& Math.abs(p.y - axis[1]) < 1e-9
|
|
140
|
+
&& Math.abs(p.z - axis[2]) < 1e-9);
|
|
141
|
+
expect(match, `${role} dial at ${axis.join(",")}`).toBeTruthy();
|
|
142
|
+
}
|
|
143
|
+
// Each support dial carries ONE outline ring only — content belongs
|
|
144
|
+
// to the front dial, so the sphere cannot re-clutter.
|
|
145
|
+
for (const dial of shellDials) {
|
|
146
|
+
expect(dial.getObjectsByProperty("name", "fleet-hologram-face-dial-ring"))
|
|
147
|
+
.toHaveLength(1);
|
|
148
|
+
expect(dial.getObjectsByProperty("name", "fleet-hologram-face-dial-ticks"))
|
|
149
|
+
.toHaveLength(0);
|
|
150
|
+
}
|
|
105
151
|
expect(
|
|
106
|
-
hologram.
|
|
107
|
-
`${role} shared wireframe`,
|
|
152
|
+
hologram.group.getObjectsByProperty("name", "fleet-hologram-polyhedron-edges"),
|
|
153
|
+
`${role} shared wireframe cage`,
|
|
108
154
|
).toHaveLength(0);
|
|
109
155
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
// The spindle is FAT AT THE EQUATOR and tapers to the poles: the
|
|
157
|
+
// widest ring sits at z≈0 and the radius shrinks monotonically
|
|
158
|
+
// toward both ends (a flattened spindle stays RED).
|
|
159
|
+
const spindleRings = hologram.shell.getObjectsByProperty(
|
|
160
|
+
"name",
|
|
161
|
+
"fleet-hologram-ring-spindle-circle",
|
|
162
|
+
) as THREE.LineLoop[];
|
|
163
|
+
expect(spindleRings.length).toBe(role === "star" ? 9 : 6);
|
|
164
|
+
const radiusOf = (ring: THREE.LineLoop) => {
|
|
165
|
+
const pos = ring.geometry.getAttribute("position");
|
|
166
|
+
return Math.hypot(pos.getX(0), pos.getY(0));
|
|
167
|
+
};
|
|
168
|
+
const widestRing = spindleRings.reduce(
|
|
169
|
+
(best, ring) => (radiusOf(ring) > radiusOf(best) ? ring : best),
|
|
170
|
+
spindleRings[0]!,
|
|
171
|
+
);
|
|
172
|
+
// Fat at the equator: ordered by |z|, radii never grow (an even
|
|
173
|
+
// count staggers a mirrored pair around z=0, hence non-strict)…
|
|
174
|
+
const byEquator = [...spindleRings].sort(
|
|
175
|
+
(a, b) => Math.abs(a.position.z) - Math.abs(b.position.z),
|
|
176
|
+
);
|
|
177
|
+
for (let i = 1; i < byEquator.length; i += 1) {
|
|
178
|
+
expect(
|
|
179
|
+
radiusOf(byEquator[i]!),
|
|
180
|
+
`${role} ring ${i} grows toward its pole`,
|
|
181
|
+
).toBeLessThanOrEqual(radiusOf(byEquator[i - 1]!));
|
|
122
182
|
}
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
expect(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
for (const ring of rings) {
|
|
135
|
-
expect(ring.raycast).not.toBe(THREE.LineLoop.prototype.raycast);
|
|
136
|
-
const positions = (ring as THREE.LineLoop).geometry.getAttribute("position");
|
|
137
|
-
const ringCenter = new THREE.Vector3();
|
|
138
|
-
const points: THREE.Vector3[] = [];
|
|
139
|
-
for (let i = 0; i < positions.count; i += 1) {
|
|
140
|
-
const point = new THREE.Vector3(
|
|
141
|
-
positions.getX(i),
|
|
142
|
-
positions.getY(i),
|
|
143
|
-
positions.getZ(i),
|
|
144
|
-
);
|
|
145
|
-
points.push(point);
|
|
146
|
-
ringCenter.add(point);
|
|
147
|
-
}
|
|
148
|
-
ringCenter.multiplyScalar(1 / positions.count);
|
|
149
|
-
const faceIndex = faces.findIndex((face, index) =>
|
|
150
|
-
!usedFaces.has(index) && ringCenter.distanceTo(face.center) < 0.03);
|
|
151
|
-
expect(faceIndex, `${role} ring maps to one face`).toBeGreaterThanOrEqual(0);
|
|
152
|
-
usedFaces.add(faceIndex);
|
|
153
|
-
const face = faces[faceIndex]!;
|
|
154
|
-
const inradius = faceInradius(face);
|
|
155
|
-
const ringRadius = Math.max(...points.map((point) => point.distanceTo(ringCenter)));
|
|
156
|
-
expect(ringRadius / inradius).toBeCloseTo(0.62, 5);
|
|
157
|
-
for (const point of points) {
|
|
158
|
-
expect(point.distanceTo(face.center)).toBeLessThan(inradius);
|
|
159
|
-
}
|
|
183
|
+
// …and the poles are STRICTLY thinner than the equator band, so a
|
|
184
|
+
// flattened (cylindrical) spindle cannot pass.
|
|
185
|
+
expect(radiusOf(byEquator[byEquator.length - 1]!)).toBeLessThan(
|
|
186
|
+
radiusOf(widestRing),
|
|
187
|
+
);
|
|
188
|
+
// Symmetric stack: every ring has a z-mirror.
|
|
189
|
+
for (const ring of byEquator) {
|
|
190
|
+
const mirror = byEquator.find((other) =>
|
|
191
|
+
Math.abs(other.position.z + ring.position.z) < 1e-9
|
|
192
|
+
&& Math.abs(radiusOf(other) - radiusOf(ring)) < 1e-9);
|
|
193
|
+
expect(mirror, `${role} ring at z=${ring.position.z} mirrors`).toBeTruthy();
|
|
160
194
|
}
|
|
161
|
-
|
|
195
|
+
|
|
196
|
+
// The pick proxy keeps the old polyhedron's reach (82% of the body
|
|
197
|
+
// radius): most of the visible silhouette stays clickable.
|
|
198
|
+
hologram.mesh.geometry.computeBoundingSphere();
|
|
199
|
+
const proxyRadius = (hologram.mesh.geometry.boundingSphere as THREE.Sphere).radius;
|
|
200
|
+
expect(proxyRadius / INPUT.radius).toBeCloseTo(0.82, 5);
|
|
201
|
+
|
|
202
|
+
// Exactly ONE detailed dial exists and it faces the camera group.
|
|
203
|
+
const focus = hologram.frontDial.getObjectsByProperty(
|
|
204
|
+
"name",
|
|
205
|
+
"fleet-hologram-face-dial",
|
|
206
|
+
);
|
|
207
|
+
expect(focus).toHaveLength(1);
|
|
208
|
+
|
|
209
|
+
// Only the invisible centre proxy answers raycasts: every other
|
|
210
|
+
// line and plane has an instance-level raycast override.
|
|
211
|
+
const pickables: THREE.Object3D[] = [];
|
|
212
|
+
hologram.group.traverse((obj) => {
|
|
213
|
+
if (Object.prototype.hasOwnProperty.call(obj, "raycast")) return;
|
|
214
|
+
if (obj instanceof THREE.Mesh || obj instanceof THREE.Line) pickables.push(obj);
|
|
215
|
+
});
|
|
216
|
+
expect(pickables, `${role} pickable objects`).toHaveLength(1);
|
|
217
|
+
expect(pickables[0]).toBe(hologram.mesh);
|
|
218
|
+
const material = hologram.mesh.material as THREE.MeshBasicMaterial;
|
|
219
|
+
expect(material.opacity).toBe(0);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("hides the circular focus mark until the scene selects the body", () => {
|
|
224
|
+
mockCanvas();
|
|
225
|
+
const hologram = makeFleetHologram(INPUT, new THREE.Color(), false);
|
|
226
|
+
expect(hologram.selectionRing.visible).toBe(false);
|
|
227
|
+
// Not vacuous: the mark is exactly two concentric circular outlines
|
|
228
|
+
// just outside the body — an empty or shrunken ring stays RED.
|
|
229
|
+
const lines = hologram.selectionRing.children;
|
|
230
|
+
expect(lines).toHaveLength(2);
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
expect(line).toBeInstanceOf(THREE.LineLoop);
|
|
233
|
+
expect(line).not.toBeInstanceOf(THREE.LineSegments);
|
|
234
|
+
const pos = (line as THREE.LineLoop).geometry.getAttribute("position");
|
|
235
|
+
const radius = Math.hypot(pos.getX(0), pos.getY(0));
|
|
236
|
+
expect(radius).toBeGreaterThan(INPUT.radius);
|
|
237
|
+
expect(radius).toBeLessThan(INPUT.radius * 1.2);
|
|
162
238
|
}
|
|
163
239
|
});
|
|
240
|
+
|
|
241
|
+
it("animates each layer independently and faces the camera", () => {
|
|
242
|
+
mockCanvas();
|
|
243
|
+
const hologram = makeFleetHologram(INPUT, new THREE.Color(), false);
|
|
244
|
+
|
|
245
|
+
const camera = new THREE.PerspectiveCamera();
|
|
246
|
+
camera.position.set(3, -2, 5);
|
|
247
|
+
camera.lookAt(0, 0, 0);
|
|
248
|
+
camera.updateMatrixWorld();
|
|
249
|
+
|
|
250
|
+
poseFleetHologram(hologram, 5, 20, camera.quaternion);
|
|
251
|
+
// The shell turns as a ball of circles…
|
|
252
|
+
expect(hologram.shell.rotation.y).toBeCloseTo(Math.PI / 2, 6);
|
|
253
|
+
// …the front dial and the focus ring face the camera (component-wise:
|
|
254
|
+
// Quaternion instances never satisfy reference equality)…
|
|
255
|
+
expect(hologram.frontDial.quaternion.angleTo(camera.quaternion)).toBeCloseTo(0, 6);
|
|
256
|
+
expect(hologram.selectionRing.quaternion.angleTo(camera.quaternion)).toBeCloseTo(0, 6);
|
|
257
|
+
// …the dial's ticks sweep…
|
|
258
|
+
expect(hologram.frontSpinner.rotation.z).toBeCloseTo(0.55, 6);
|
|
259
|
+
// …and the real-data ticker scrolls across the dial's centre.
|
|
260
|
+
expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.09, 6);
|
|
261
|
+
poseFleetHologram(hologram, 10, 20, camera.quaternion);
|
|
262
|
+
expect(hologram.hudTexture?.offset.x).toBeCloseTo(0.18, 6);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("makeSatelliteHologram", () => {
|
|
267
|
+
it("is a miniature crossed-dial shell with no text and no solid sphere", () => {
|
|
268
|
+
const satellite = makeSatelliteHologram(0.2, new THREE.Color(0x9fb8dd), false);
|
|
269
|
+
|
|
270
|
+
// Endpoint tier: three crossed support dials plus a three-ring
|
|
271
|
+
// spindle — the gateway's language, minimal content, no ticker.
|
|
272
|
+
expect(satellite.getObjectsByProperty("name", "fleet-hologram-face-dial"))
|
|
273
|
+
.toHaveLength(3);
|
|
274
|
+
expect(satellite.getObjectsByProperty("name", "fleet-hologram-ring-spindle-circle"))
|
|
275
|
+
.toHaveLength(3);
|
|
276
|
+
expect(satellite.getObjectsByProperty("name", "fleet-hologram-hud-panel"))
|
|
277
|
+
.toHaveLength(0);
|
|
278
|
+
let meshes = 0;
|
|
279
|
+
satellite.traverse((obj) => {
|
|
280
|
+
if (obj instanceof THREE.Mesh) meshes += 1;
|
|
281
|
+
});
|
|
282
|
+
expect(meshes).toBe(0);
|
|
283
|
+
|
|
284
|
+
// Nothing in a satellite may answer raycasts.
|
|
285
|
+
const hits = new THREE.Raycaster().intersectObject(satellite, true);
|
|
286
|
+
expect(hits).toHaveLength(0);
|
|
287
|
+
});
|
|
164
288
|
});
|