@celestia-island/hikari 0.55.255 → 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 +1 -1
- package/src/components/HkTimeline.bondcell.contract.test.ts +25 -12
- package/src/components/HkTimeline.scss +36 -25
- package/src/scene3d/holographicFleetBody.test.ts +75 -0
- package/src/scene3d/holographicFleetBody.ts +127 -16
- package/src/scene3d/holographicFleetScene.test.ts +11 -3
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Source contract for the bond CELLS of the horizontal stepper (2026-10-31
|
|
3
|
-
* user rule).
|
|
3
|
+
* user rule; 60 % share since the same evening's second look).
|
|
4
4
|
*
|
|
5
5
|
* The row is read as one cell per step plus one cell per bond: the steps
|
|
6
6
|
* keep their content width, the leftover splits into EQUAL bond cells, and
|
|
7
|
-
* each bond paints
|
|
7
|
+
* each bond paints 60 % of its own cell CENTERED, so both ends keep a 20 %
|
|
8
8
|
* air gap. Before that rule the bond filled whatever the step had left after
|
|
9
9
|
* its label, minus two fixed 4px margins — measured on a real engine at the
|
|
10
10
|
* provider wizard's 590px row that put the bond 12px off its own label but
|
|
@@ -95,7 +95,7 @@ describe("horizontal bond cell contract", () => {
|
|
|
95
95
|
// while the bond→node air is margin alone — the 12/4 asymmetry that
|
|
96
96
|
// made the bond look glued to the next circle.
|
|
97
97
|
expect(cell).toContain(`margin-inline: calc(-1 * ${GAP}) 0;`);
|
|
98
|
-
// The box must not paint itself: the visible
|
|
98
|
+
// The box must not paint itself: the visible 60 % is the ::before.
|
|
99
99
|
expect(cell).toContain("background: none;");
|
|
100
100
|
// The ::before resolves percentage insets against this box.
|
|
101
101
|
expect(cell).toContain("position: relative;");
|
|
@@ -106,7 +106,7 @@ describe("horizontal bond cell contract", () => {
|
|
|
106
106
|
expect(blockFor(".hk-timeline-step")).toContain(`gap: ${GAP};`);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
it("paints
|
|
109
|
+
it("paints 60 % of the cell centred with a 20 % inset on each end", () => {
|
|
110
110
|
// The parser above only yields top-level rules; the paint lives in the
|
|
111
111
|
// nested block, so lift it out of the cell rule's body.
|
|
112
112
|
const paint = /&::before\s*\{([^}]*)\}/.exec(blockFor(BOND_CELL))?.[1] ?? "";
|
|
@@ -114,18 +114,31 @@ describe("horizontal bond cell contract", () => {
|
|
|
114
114
|
expect(paint).toContain('content: "";');
|
|
115
115
|
expect(paint).toContain("position: absolute;");
|
|
116
116
|
expect(paint).toContain("inset-block: 0;");
|
|
117
|
-
expect(paint).toContain("inset-inline:
|
|
117
|
+
expect(paint).toContain("inset-inline: 20%;");
|
|
118
118
|
expect(paint).toContain("background: var(--_bond-color);");
|
|
119
|
-
// Percentage PADDING would resolve against the step, not the bond
|
|
120
|
-
|
|
119
|
+
// Percentage PADDING would resolve against the step, not the bond —
|
|
120
|
+
// ban the MECHANISM on both boxes, not one pinned value (a stray
|
|
121
|
+
// `padding-inline: 10%` beside the correct inset would widen the paint
|
|
122
|
+
// through the padding box and slip past a value-pinned regex).
|
|
123
|
+
expect(blockFor(BOND_CELL)).not.toMatch(/padding-inline:/);
|
|
124
|
+
expect(paint).not.toMatch(/padding-inline:/);
|
|
121
125
|
});
|
|
122
126
|
|
|
123
|
-
it("
|
|
127
|
+
it("reserves the two-margin-era collapse threshold through its floor", () => {
|
|
124
128
|
const cell = blockFor(BOND_CELL);
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
+
// The floor (half a rem plus two gaps, minus the cancelled gap) reserves
|
|
130
|
+
// the same sixteen outer pixels per bond the old two 4px margins
|
|
131
|
+
// carried, so collapse="auto" measures the row's max-content width at
|
|
132
|
+
// the two-margin-era threshold — never earlier, never later.
|
|
133
|
+
expect(cell).toContain(`min-width: calc(0.5rem + 2 * ${GAP});`);
|
|
134
|
+
// A definite flex-basis reserves NOTHING: engines size a flex item's
|
|
135
|
+
// intrinsic contribution at max(min-width, content) and ignore the
|
|
136
|
+
// basis (measured: a 100px basis on an empty item contributed its 8px
|
|
137
|
+
// floor). The basis must not creep back in place of the floor — nor as
|
|
138
|
+
// the shorthand `flex:`, whose basis component would be equally silent
|
|
139
|
+
// while the literal reads like a reservation.
|
|
140
|
+
expect(cell).not.toContain("flex-basis");
|
|
141
|
+
expect(cell).not.toMatch(/\bflex\s*:/);
|
|
129
142
|
});
|
|
130
143
|
|
|
131
144
|
it("publishes one bond colour token for every status", () => {
|
|
@@ -312,11 +312,13 @@
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
/* ── Bond cells (2026-10-31 user rule)
|
|
315
|
+
/* ── Bond cells (2026-10-31 user rule; 60 % share the same evening) ────
|
|
316
316
|
The horizontal row reads as one cell per step plus one cell per bond: the
|
|
317
317
|
steps keep their content width, the leftover splits into EQUAL bond cells,
|
|
318
|
-
and each bond paints
|
|
319
|
-
air gap.
|
|
318
|
+
and each bond paints 60 % of its cell CENTERED, so both ends keep a 20 %
|
|
319
|
+
air gap. (The first cut painted 80 %/10 % — centred, but the completed
|
|
320
|
+
bond read as long as ever, so the share was trimmed the same evening.)
|
|
321
|
+
Three mechanics make that exact:
|
|
320
322
|
|
|
321
323
|
1. The bond's box must span the WHOLE cell. Its cell begins where its own
|
|
322
324
|
label ends, but the step's flex gap already sits in front of it (that
|
|
@@ -325,39 +327,48 @@
|
|
|
325
327
|
SAME `--hk-timeline-step-gap` token the step's `gap` uses. Before this
|
|
326
328
|
the gap counted twice and the bond ran 12px off its label but only 4px
|
|
327
329
|
off the next node: the completed bond read as glued to the next circle.
|
|
328
|
-
2. The painted
|
|
330
|
+
2. The painted share cannot be `padding-inline` — a percentage padding
|
|
329
331
|
resolves against the CONTAINING block (the step), not the bond's own
|
|
330
|
-
box, which would inset it by a slice of the step instead of
|
|
331
|
-
cell. So the colour
|
|
332
|
+
box, which would inset it by a slice of the step instead of 20 % of the
|
|
333
|
+
cell. So the colour lives on a ::before pinned `inset-inline: 20%`,
|
|
332
334
|
which does resolve against the bond. The status rules below therefore
|
|
333
335
|
publish `--_bond-color`; the vertical rail keeps painting its own box
|
|
334
336
|
(it is a rail hanging under the node, not a cell-wide bond).
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
337
|
+
3. The row-width RESERVATION is a min-width floor, sized to the two-margin
|
|
338
|
+
era at the stock 8px gap: half a rem plus two gaps, minus the cancelled
|
|
339
|
+
gap, reserves the same sixteen outer pixels per bond the old fixed
|
|
340
|
+
4px+4px margins carried, so `collapse="auto"` (HkTimeline.tsx measures
|
|
341
|
+
the row's max-content width) switches to the window at exactly the
|
|
342
|
+
threshold the two-margin era had — never earlier, never later. (A host
|
|
343
|
+
that retunes `--hk-timeline-step-gap` shifts the threshold by the
|
|
344
|
+
difference: the old margins were fixed 16px outer while this floor
|
|
345
|
+
reserves gap+8. And a definite `flex-basis` reserves NOTHING here:
|
|
346
|
+
engines size a flex item's intrinsic contribution at
|
|
347
|
+
max(min-width, content) and ignore the basis — measured, a 100px basis
|
|
348
|
+
on an empty item contributed only its 8px floor.) The floor only binds
|
|
349
|
+
when a row is squeezed below its natural width, which `collapse="auto"`
|
|
350
|
+
never lets happen for windowable rows (the window takes over; a ≤3-step
|
|
351
|
+
row stays full-mode at any width, but the floor then behaves exactly as
|
|
352
|
+
both prior eras' floors did); through `collapse="never"` — or the
|
|
353
|
+
full-mode frame a label change renders before re-measuring — the pinned
|
|
354
|
+
24px box paints 14.4px inset 4.8px, and that paint slides UNDER the
|
|
355
|
+
next step's 24px circle for as long as the overflow stays under 24px
|
|
356
|
+
(host ≳ 380px for the five-step wizard row). Below that a small stub
|
|
357
|
+
shows beside the circle (onset ~381px, ~5px by 360px) — marginally
|
|
358
|
+
tighter than the two-margin geometry's own stub in this manually-forced
|
|
359
|
+
mode (onset ~384px, ~6px by 360px), and bounded below by the width
|
|
360
|
+
where a label reaches the next circle (~400px for this row). */
|
|
350
361
|
.hk-timeline[data-mode="full"][data-orientation="horizontal"] .hk-timeline-step [data-el="connector"] {
|
|
351
362
|
position: relative;
|
|
352
363
|
margin-inline: calc(-1 * var(--hk-timeline-step-gap, var(--space-8, 8px))) 0;
|
|
353
|
-
min-width: calc(0.5rem + var(--hk-timeline-step-gap, var(--space-8, 8px)));
|
|
364
|
+
min-width: calc(0.5rem + 2 * var(--hk-timeline-step-gap, var(--space-8, 8px)));
|
|
354
365
|
background: none;
|
|
355
366
|
|
|
356
367
|
&::before {
|
|
357
368
|
content: "";
|
|
358
369
|
position: absolute;
|
|
359
370
|
inset-block: 0;
|
|
360
|
-
inset-inline:
|
|
371
|
+
inset-inline: 20%;
|
|
361
372
|
background: var(--_bond-color);
|
|
362
373
|
}
|
|
363
374
|
}
|
|
@@ -426,7 +437,7 @@
|
|
|
426
437
|
}
|
|
427
438
|
|
|
428
439
|
[data-el="connector"] {
|
|
429
|
-
// The bonds paint
|
|
440
|
+
// The bonds paint 60 % of their cell through a ::before (see the
|
|
430
441
|
// full-mode cell rule); the token is the single source for both that
|
|
431
442
|
// paint and the vertical rail's own box.
|
|
432
443
|
--_bond-color: var(--hi-color-primary);
|
|
@@ -516,7 +527,7 @@
|
|
|
516
527
|
|
|
517
528
|
// Status crossfade: the data-status driven colors (indicator fill/border,
|
|
518
529
|
// label color, connector fill) transition instead of flipping. The
|
|
519
|
-
// horizontal bond's paint lives on its ::before (the
|
|
530
|
+
// horizontal bond's paint lives on its ::before (the 60 % cell rule), so
|
|
520
531
|
// that box rides the crossfade too — without it a status flip snaps there
|
|
521
532
|
// while the vertical rail keeps easing.
|
|
522
533
|
.hk-timeline-step [data-el="indicator"],
|
|
@@ -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
|
});
|