@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.
@@ -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("draws the real data on the cylindrical band and animates each layer", () => {
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
- const band = hologram.group.getObjectByName("fleet-hologram-hud-band");
58
- expect(band).toBeInstanceOf(THREE.Mesh);
59
- expect((band as THREE.Mesh).geometry).toBeInstanceOf(THREE.CylinderGeometry);
60
- expect(fillText).toHaveBeenCalled();
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
- // Decorative rings and the text band never widen the body's pointer
66
- // target. Only the central polyhedron keeps the default raycaster.
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
- band?.raycast(new THREE.Raycaster(), hits);
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("uses three rings for a hub and two for a standard node", () => {
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 planet = makeFleetHologram(INPUT, new THREE.Color(), false);
86
- expect(hub.rings).toHaveLength(3);
87
- expect(planet.rings).toHaveLength(2);
88
- expect(hub.mesh.geometry).toBeInstanceOf(THREE.IcosahedronGeometry);
89
- expect(planet.mesh.geometry).toBeInstanceOf(THREE.DodecahedronGeometry);
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("gives every face its own detached inset ring, with no shared vertices", () => {
109
+ it("builds the ball from circles only — no solid sphere, no wireframe cage", () => {
93
110
  mockCanvas();
94
- for (const [role, expectedFaces] of [["star", 20], ["planet", 12]] as const) {
95
- const hologram = makeFleetHologram(
96
- { ...INPUT, role },
97
- new THREE.Color(),
98
- false,
99
- );
100
- const rings = hologram.mesh.getObjectsByProperty(
101
- "name",
102
- "fleet-hologram-face-ring",
103
- );
104
- expect(rings, `${role} face rings`).toHaveLength(expectedFaces);
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.mesh.getObjectsByProperty("name", "fleet-hologram-polyhedron-edges"),
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
- const vertexKeys = new Set<string>();
111
- let vertices = 0;
112
- for (const ring of rings) {
113
- expect(ring).toBeInstanceOf(THREE.LineLoop);
114
- const positions = (ring as THREE.LineLoop).geometry.getAttribute("position");
115
- expect(positions.count).toBe(32);
116
- for (let i = 0; i < positions.count; i += 1) {
117
- vertexKeys.add(
118
- `${positions.getX(i).toFixed(5)}:${positions.getY(i).toFixed(5)}:${positions.getZ(i).toFixed(5)}`,
119
- );
120
- vertices += 1;
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
- // Every ring has its own circumference; no ring shares an edge or
124
- // vertex with a neighbour, so the old connected cage cannot reappear.
125
- expect(vertexKeys.size).toBe(vertices);
126
-
127
- // Each ring must stay well INSIDE its own face: map it back to the
128
- // recovered face polygon, then pin both the 62% radius ratio and the
129
- // remaining margin. This makes an overflow regression impossible to
130
- // hide behind the count/name assertions above.
131
- const faces = extractHologramFaces(hologram.mesh.geometry);
132
- expect(faces).toHaveLength(expectedFaces);
133
- const usedFaces = new Set<number>();
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
- expect(usedFaces.size).toBe(expectedFaces);
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
  });