@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
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
673
|
-
|
|
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
|
-
|
|
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
|
});
|