@celestia-island/hikari 0.55.261 → 0.55.263
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/HkBoard3D.controls.test.tsx +99 -0
- package/src/components/HkBoard3D.tsx +44 -4
- 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/components/HkSolarLocationSetting.tsx +3 -3
- package/src/components/HkTimeZoneGlobe.board.test.tsx +263 -0
- package/src/components/HkTimeZoneGlobe.scss +42 -13
- package/src/components/HkTimeZoneGlobe.test.tsx +33 -0
- package/src/components/HkTimeZoneGlobe.tsx +66 -34
- 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/src/theme/solarGlobeMap.ts +1 -1
- package/src/theme/solarTimeZones.ts +6 -6
|
@@ -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
|
});
|