@celestia-island/hikari 0.55.256 → 0.55.257

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.256",
3
+ "version": "0.55.257",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -3,6 +3,8 @@ import * as THREE from "three";
3
3
 
4
4
  import { fakeAs } from "../test-utils/fakes";
5
5
  import {
6
+ extractHologramFaces,
7
+ faceInradius,
6
8
  hologramHudText,
7
9
  makeFleetHologram,
8
10
  poseFleetHologram,
@@ -86,4 +88,77 @@ describe("makeFleetHologram", () => {
86
88
  expect(hub.mesh.geometry).toBeInstanceOf(THREE.IcosahedronGeometry);
87
89
  expect(planet.mesh.geometry).toBeInstanceOf(THREE.DodecahedronGeometry);
88
90
  });
91
+
92
+ it("gives every face its own detached inset ring, with no shared vertices", () => {
93
+ 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);
105
+ expect(
106
+ hologram.mesh.getObjectsByProperty("name", "fleet-hologram-polyhedron-edges"),
107
+ `${role} shared wireframe`,
108
+ ).toHaveLength(0);
109
+
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
+ }
122
+ }
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
+ }
160
+ }
161
+ expect(usedFaces.size).toBe(expectedFaces);
162
+ }
163
+ });
89
164
  });
@@ -3,10 +3,11 @@
3
3
  * data-bound cylindrical HUD band.
4
4
  *
5
5
  * The visual language is deliberately restrained: a coarse translucent
6
- * polyhedron, two rings for a standard node (three for a hub), one bright
7
- * arc per ring, and a slow ticker made ONLY from real caller-supplied
8
- * fields. Decorative lines and the band never join raycast picking; the
9
- * centre mesh remains the node's pointer and selection target.
6
+ * polyhedron whose FACES each carry an independent in-plane ring (no shared
7
+ * wireframe edges), two orbit rings for a standard node (three for a hub),
8
+ * one bright arc per orbit ring, and a slow ticker made ONLY from real
9
+ * caller-supplied fields. Decorative lines and the band never join raycast
10
+ * picking; the centre mesh remains the node's pointer and selection target.
10
11
  */
11
12
 
12
13
  import * as THREE from "three";
@@ -76,6 +77,125 @@ function unpickable<T extends THREE.Object3D>(object: T): T {
76
77
  return object;
77
78
  }
78
79
 
80
+ /** One flat polygon face recovered from the polyhedron's triangles.
81
+ * Exported for the geometry contract test; not part of the public barrel. */
82
+ export interface HologramFace {
83
+ center: THREE.Vector3;
84
+ normal: THREE.Vector3;
85
+ vertices: THREE.Vector3[];
86
+ }
87
+
88
+ /** Recover the polyhedron's true faces (a dodecahedron face is three
89
+ * coplanar triangles; a pentagon must become ONE ring, not three). Group by
90
+ * normal direction with a dot threshold: Three.js' projected pentagon
91
+ * fragments are nearly coplanar, while adjacent dodecahedron faces differ
92
+ * by tens of degrees. */
93
+ export function extractHologramFaces(geometry: THREE.BufferGeometry): HologramFace[] {
94
+ const position = geometry.getAttribute("position");
95
+ const groups: Array<{ normal: THREE.Vector3; vertices: Map<string, THREE.Vector3> }> = [];
96
+ for (let i = 0; i < position.count; i += 3) {
97
+ const a = new THREE.Vector3(position.getX(i), position.getY(i), position.getZ(i));
98
+ const b = new THREE.Vector3(position.getX(i + 1), position.getY(i + 1), position.getZ(i + 1));
99
+ const c = new THREE.Vector3(position.getX(i + 2), position.getY(i + 2), position.getZ(i + 2));
100
+ const normal = b.clone().sub(a).cross(c.clone().sub(a)).normalize();
101
+ const triangleCenter = a.clone().add(b).add(c).multiplyScalar(1 / 3);
102
+ if (normal.dot(triangleCenter) < 0) normal.negate();
103
+ let group = groups.find((candidate) => candidate.normal.dot(normal) > 0.999);
104
+ if (!group) {
105
+ group = { normal, vertices: new Map() };
106
+ groups.push(group);
107
+ }
108
+ for (const vertex of [a, b, c]) {
109
+ const vertexKey = [vertex.x, vertex.y, vertex.z]
110
+ .map((value) => value.toFixed(5))
111
+ .join(":");
112
+ group.vertices.set(vertexKey, vertex);
113
+ }
114
+ }
115
+
116
+ return groups.map((group) => {
117
+ const vertices = [...group.vertices.values()];
118
+ const center = vertices
119
+ .reduce((sum, vertex) => sum.add(vertex), new THREE.Vector3())
120
+ .multiplyScalar(1 / vertices.length);
121
+ return {
122
+ center,
123
+ normal: group.normal.clone().dot(center) < 0
124
+ ? group.normal.clone().negate()
125
+ : group.normal.clone(),
126
+ vertices,
127
+ };
128
+ });
129
+ }
130
+
131
+ /** Inradius of the face polygon — the largest inset circle that still
132
+ * leaves a visible gap between neighbouring face rings. Exported for the
133
+ * geometry contract test; not part of the public barrel. */
134
+ export function faceInradius(face: HologramFace): number {
135
+ const u = face.vertices[0]!.clone().sub(face.center).normalize();
136
+ const v = face.normal.clone().cross(u).normalize();
137
+ const ordered = [...face.vertices].sort((a, b) => {
138
+ const angle = (point: THREE.Vector3): number => Math.atan2(
139
+ point.clone().sub(face.center).dot(v),
140
+ point.clone().sub(face.center).dot(u),
141
+ );
142
+ return angle(a) - angle(b);
143
+ });
144
+ let inradius = Number.POSITIVE_INFINITY;
145
+ for (let i = 0; i < ordered.length; i += 1) {
146
+ const a = ordered[i]!;
147
+ const b = ordered[(i + 1) % ordered.length]!;
148
+ const edge = b.clone().sub(a);
149
+ const area2 = edge.clone().cross(face.center.clone().sub(a)).length();
150
+ if (edge.lengthSq() > 1e-10) inradius = Math.min(inradius, area2 / edge.length());
151
+ }
152
+ return Number.isFinite(inradius) ? inradius : 0;
153
+ }
154
+
155
+ /** Each face gets its own detached ring. No shared edges, no connected
156
+ * wireframe cage — adjacent rings visibly stop short of every face border. */
157
+ function makeFaceRings(
158
+ geometry: THREE.BufferGeometry,
159
+ bodyRadius: number,
160
+ color: THREE.Color,
161
+ offline: boolean,
162
+ ): THREE.LineLoop[] {
163
+ const lift = bodyRadius * 0.01;
164
+ const segments = 32;
165
+ const rings: THREE.LineLoop[] = [];
166
+ for (const face of extractHologramFaces(geometry)) {
167
+ const radius = Math.max(0.012, faceInradius(face) * 0.62);
168
+ const u = face.vertices[0]!.clone().sub(face.center).normalize();
169
+ const v = face.normal.clone().cross(u).normalize();
170
+ const points: number[] = [];
171
+ for (let i = 0; i < segments; i += 1) {
172
+ const angle = (i / segments) * Math.PI * 2;
173
+ const point = face.center
174
+ .clone()
175
+ .addScaledVector(face.normal, lift)
176
+ .addScaledVector(u, Math.cos(angle) * radius)
177
+ .addScaledVector(v, Math.sin(angle) * radius);
178
+ points.push(point.x, point.y, point.z);
179
+ }
180
+ const ring = unpickable(new THREE.LineLoop(
181
+ new THREE.BufferGeometry().setAttribute(
182
+ "position",
183
+ new THREE.Float32BufferAttribute(points, 3),
184
+ ),
185
+ new THREE.LineBasicMaterial({
186
+ color,
187
+ transparent: true,
188
+ opacity: offline ? 0.16 : 0.62,
189
+ depthWrite: false,
190
+ blending: THREE.AdditiveBlending,
191
+ }),
192
+ ));
193
+ ring.name = "fleet-hologram-face-ring";
194
+ rings.push(ring);
195
+ }
196
+ return rings;
197
+ }
198
+
79
199
  function makeRing(
80
200
  radius: number,
81
201
  index: number,
@@ -212,18 +332,9 @@ export function makeFleetHologram(
212
332
  mesh.name = "fleet-hologram-polyhedron";
213
333
  mesh.rotation.set(0.2, 0, 0.1);
214
334
 
215
- const edges = unpickable(new THREE.LineSegments(
216
- new THREE.EdgesGeometry(geometry, 1),
217
- new THREE.LineBasicMaterial({
218
- color: edgeColor,
219
- transparent: true,
220
- opacity: offline ? 0.26 : 0.78,
221
- depthWrite: false,
222
- blending: THREE.AdditiveBlending,
223
- }),
224
- ));
225
- edges.name = "fleet-hologram-polyhedron-edges";
226
- mesh.add(edges);
335
+ for (const ring of makeFaceRings(geometry, input.radius, edgeColor, offline)) {
336
+ mesh.add(ring);
337
+ }
227
338
  group.add(mesh);
228
339
 
229
340
  const rings = HOLOGRAM_RING_SCALES[input.role].map((scale, index) => {
@@ -669,10 +669,18 @@ describe("buildFleetScene body dressing", () => {
669
669
  expect(ring.getObjectByName("fleet-hologram-ring-base")).toBeDefined();
670
670
  expect(ring.getObjectByName("fleet-hologram-ring-arc")).toBeDefined();
671
671
  }
672
- const edges = bodyMesh(engine, id).getObjectByName(
673
- "fleet-hologram-polyhedron-edges",
672
+ const faceRings = root.getObjectsByProperty("name", "fleet-hologram-face-ring");
673
+ expect(faceRings, `${id} detached face-ring count`).toHaveLength(
674
+ id === "hub-a" ? 20 : 12,
674
675
  );
675
- expect(edges, `${id} polyhedron edges`).toBeInstanceOf(THREE.LineSegments);
676
+ for (const faceRing of faceRings) {
677
+ expect(faceRing).toBeInstanceOf(THREE.LineLoop);
678
+ expect(faceRing).not.toBeInstanceOf(THREE.LineSegments);
679
+ }
680
+ expect(
681
+ root.getObjectsByProperty("name", "fleet-hologram-polyhedron-edges"),
682
+ "the shared wireframe cage is gone",
683
+ ).toHaveLength(0);
676
684
  }
677
685
  handle.dispose();
678
686
  });