@celestia-island/hikari 0.55.261 → 0.55.262

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.
@@ -1,42 +1,45 @@
1
1
  /**
2
- * holographicFleetBody — one node's polyhedron, holographic rings and
3
- * data-bound cylindrical HUD band.
2
+ * holographicFleetBody — one node's hologram: a spherical shell built from
3
+ * six overlapping face dials plus a fat-centre ring spindle, with ONE
4
+ * detailed camera-facing dial at the front carrying the real-data ticker.
4
5
  *
5
- * The visual language is deliberately restrained: a coarse translucent
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.
6
+ * User direction (2026-10-31): the body must read as a ball of circles
7
+ * from every side — no polyhedron, no solid sphere, no shared wireframe,
8
+ * and no visible cube edges. Main cores (role "star") are larger with
9
+ * denser dial content; node cores (role "planet") are sparser; satellites
10
+ * get a miniature crossed-dial shell with no text. Every decorative line
11
+ * stays out of raycast picking; the invisible centre proxy remains the
12
+ * node's pointer target.
11
13
  */
12
14
 
13
15
  import * as THREE from "three";
14
16
 
15
- import {
16
- HOLOGRAM_RING_SCALES,
17
- type FleetBodySpec,
18
- } from "./holographicFleetSpec";
17
+ import type { FleetBodySpec } from "./holographicFleetSpec";
19
18
 
20
19
  export type FleetHologramInput = Pick<
21
20
  FleetBodySpec,
22
21
  "id" | "label" | "status" | "detail" | "role" | "radius"
23
22
  >;
24
23
 
25
- export interface FleetHologramRing {
26
- pivot: THREE.Group;
27
- baseY: number;
28
- speed: number;
29
- }
30
-
31
24
  export interface FleetHologram {
32
25
  group: THREE.Group;
26
+ /** Invisible centre proxy; the node's pointer target (never rendered). */
33
27
  mesh: THREE.Mesh;
34
- rings: FleetHologramRing[];
28
+ /** Six faint overlapping face dials: the spherical shell. */
29
+ shell: THREE.Group;
30
+ /** The detailed camera-facing dial that carries the real-data ticker. */
31
+ frontDial: THREE.Group;
32
+ /** Sweeping layer inside the front dial (ticks and arcs rotate here). */
33
+ frontSpinner: THREE.Group;
34
+ /** Circular focus mark, shown while the body is selected. */
35
+ selectionRing: THREE.Group;
36
+ /** The real-data ticker plane at the front dial's centre. */
37
+ hudPanel: THREE.Mesh | null;
35
38
  /** Null only when the host cannot create a 2D canvas (SSR/test DOM). */
36
39
  hudTexture: THREE.CanvasTexture | null;
37
40
  }
38
41
 
39
- /** The single source of truth for the outer-surface ticker's content. */
42
+ /** The single source of truth for the dial ticker's content. */
40
43
  export function hologramHudText(input: FleetHologramInput): string {
41
44
  return [input.label, input.status, input.detail]
42
45
  .map((part) => part.trim())
@@ -44,11 +47,17 @@ export function hologramHudText(input: FleetHologramInput): string {
44
47
  .join(" · ") || input.id;
45
48
  }
46
49
 
47
- function circleGeometry(radius: number, segments: number): THREE.BufferGeometry {
50
+ /** Decorative geometry must not become a broad, invisible pointer target. */
51
+ function unpickable<T extends THREE.Object3D>(object: T): T {
52
+ object.raycast = () => undefined;
53
+ return object;
54
+ }
55
+
56
+ function xyCircleGeometry(radius: number, segments: number): THREE.BufferGeometry {
48
57
  const points: number[] = [];
49
58
  for (let i = 0; i < segments; i += 1) {
50
59
  const angle = (i / segments) * Math.PI * 2;
51
- points.push(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
60
+ points.push(Math.cos(angle) * radius, Math.sin(angle) * radius, 0);
52
61
  }
53
62
  return new THREE.BufferGeometry().setAttribute(
54
63
  "position",
@@ -56,14 +65,16 @@ function circleGeometry(radius: number, segments: number): THREE.BufferGeometry
56
65
  );
57
66
  }
58
67
 
59
- function arcGeometry(radius: number): THREE.BufferGeometry {
68
+ function xyArcGeometry(
69
+ radius: number,
70
+ start: number,
71
+ span: number,
72
+ segments = 48,
73
+ ): THREE.BufferGeometry {
60
74
  const points: number[] = [];
61
- const segments = 32;
62
- const start = -0.22;
63
- const span = Math.PI * 0.48;
64
75
  for (let i = 0; i <= segments; i += 1) {
65
76
  const angle = start + (i / segments) * span;
66
- points.push(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);
77
+ points.push(Math.cos(angle) * radius, Math.sin(angle) * radius, 0);
67
78
  }
68
79
  return new THREE.BufferGeometry().setAttribute(
69
80
  "position",
@@ -71,199 +82,210 @@ function arcGeometry(radius: number): THREE.BufferGeometry {
71
82
  );
72
83
  }
73
84
 
74
- /** Decorative geometry must not become a broad, invisible pointer target. */
75
- function unpickable<T extends THREE.Object3D>(object: T): T {
76
- object.raycast = () => undefined;
77
- return object;
85
+ function lineMaterial(color: THREE.Color, opacity: number): THREE.LineBasicMaterial {
86
+ return new THREE.LineBasicMaterial({
87
+ color,
88
+ transparent: true,
89
+ opacity,
90
+ depthWrite: false,
91
+ blending: THREE.AdditiveBlending,
92
+ });
78
93
  }
79
94
 
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[];
95
+ /** How much content a dial tier draws (user direction: main cores carry
96
+ * MORE content than node cores — rings, ticks, arcs — never identical). */
97
+ interface DialTier {
98
+ rings: ReadonlyArray<{ scale: number; opacity: number }>;
99
+ tickCount: number;
100
+ arcs: ReadonlyArray<{ scale: number; start: number; span: number; opacity: number }>;
101
+ core: boolean;
86
102
  }
87
103
 
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
- }
104
+ const FOCUS_STAR: DialTier = {
105
+ rings: [
106
+ { scale: 1, opacity: 0.72 },
107
+ { scale: 0.64, opacity: 0.3 },
108
+ { scale: 0.38, opacity: 0.22 },
109
+ ],
110
+ tickCount: 48,
111
+ arcs: [
112
+ { scale: 1, start: -0.25, span: Math.PI * 0.62, opacity: 0.94 },
113
+ { scale: 0.64, start: Math.PI * 0.85, span: Math.PI * 0.2, opacity: 0.62 },
114
+ ],
115
+ core: true,
116
+ };
115
117
 
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
- }
118
+ const FOCUS_PLANET: DialTier = {
119
+ rings: [
120
+ { scale: 1, opacity: 0.66 },
121
+ { scale: 0.52, opacity: 0.26 },
122
+ ],
123
+ tickCount: 28,
124
+ arcs: [
125
+ { scale: 1, start: -0.25, span: Math.PI * 0.5, opacity: 0.9 },
126
+ ],
127
+ core: true,
128
+ };
130
129
 
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;
130
+ /** The five non-facing shell faces: an outline and a faint arc only, so
131
+ * the sphere stays legible without competing with the front dial. */
132
+ const SUPPORT: DialTier = {
133
+ rings: [{ scale: 1, opacity: 0.28 }],
134
+ tickCount: 0,
135
+ arcs: [{ scale: 1, start: -0.25, span: Math.PI * 0.62, opacity: 0.34 }],
136
+ core: false,
137
+ };
138
+
139
+ function focusTierFor(role: "star" | "planet"): DialTier {
140
+ return role === "star" ? FOCUS_STAR : FOCUS_PLANET;
153
141
  }
154
142
 
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,
143
+ /** One circular dial in the XY plane, drawn to a content tier. */
144
+ function makeFaceDial(
145
+ radius: number,
160
146
  color: THREE.Color,
161
147
  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);
148
+ tier: DialTier,
149
+ ): THREE.Group {
150
+ const dial = new THREE.Group();
151
+ dial.name = "fleet-hologram-face-dial";
152
+
153
+ for (const ring of tier.rings) {
154
+ const line = unpickable(new THREE.LineLoop(
155
+ xyCircleGeometry(radius * ring.scale, 160),
156
+ lineMaterial(color, offline ? ring.opacity * 0.24 : ring.opacity),
157
+ ));
158
+ line.name = "fleet-hologram-face-dial-ring";
159
+ dial.add(line);
160
+ }
161
+
162
+ if (tier.tickCount > 0) {
163
+ const ticks: number[] = [];
164
+ for (let i = 0; i < tier.tickCount; i += 1) {
165
+ const angle = (i / tier.tickCount) * Math.PI * 2;
166
+ const major = i % 4 === 0;
167
+ const inner = radius - radius * (major ? 0.13 : 0.06);
168
+ const outer = radius + radius * (major ? 0.05 : 0.025);
169
+ ticks.push(Math.cos(angle) * inner, Math.sin(angle) * inner, 0);
170
+ ticks.push(Math.cos(angle) * outer, Math.sin(angle) * outer, 0);
179
171
  }
180
- const ring = unpickable(new THREE.LineLoop(
172
+ const tickMarks = unpickable(new THREE.LineSegments(
181
173
  new THREE.BufferGeometry().setAttribute(
182
174
  "position",
183
- new THREE.Float32BufferAttribute(points, 3),
175
+ new THREE.Float32BufferAttribute(ticks, 3),
184
176
  ),
185
- new THREE.LineBasicMaterial({
177
+ lineMaterial(color, offline ? 0.12 : 0.58),
178
+ ));
179
+ tickMarks.name = "fleet-hologram-face-dial-ticks";
180
+ dial.add(tickMarks);
181
+ }
182
+
183
+ for (const arc of tier.arcs) {
184
+ const line = unpickable(new THREE.Line(
185
+ xyArcGeometry(radius * arc.scale, arc.start, arc.span),
186
+ lineMaterial(color, offline ? arc.opacity * 0.22 : arc.opacity),
187
+ ));
188
+ line.name = "fleet-hologram-face-dial-arc";
189
+ dial.add(line);
190
+ }
191
+
192
+ if (tier.core) {
193
+ const core = unpickable(new THREE.Mesh(
194
+ new THREE.CircleGeometry(radius * 0.16, 32),
195
+ new THREE.MeshBasicMaterial({
186
196
  color,
187
197
  transparent: true,
188
- opacity: offline ? 0.16 : 0.62,
198
+ opacity: offline ? 0.08 : 0.26,
199
+ side: THREE.DoubleSide,
189
200
  depthWrite: false,
190
201
  blending: THREE.AdditiveBlending,
191
202
  }),
192
203
  ));
193
- ring.name = "fleet-hologram-face-ring";
194
- rings.push(ring);
204
+ core.name = "fleet-hologram-face-dial-core";
205
+ dial.add(core);
195
206
  }
196
- return rings;
207
+ return dial;
197
208
  }
198
209
 
199
- function makeRing(
200
- radius: number,
201
- index: number,
210
+ /** Six overlapping support dials on the cube's face axes. Their rims
211
+ * merge into one rounded envelope — the box itself must stay invisible
212
+ * (user direction 2026-10-31: fusing the two structures is not allowed
213
+ * to leave a readable cube behind). */
214
+ function makeDiscShell(
215
+ bodyRadius: number,
202
216
  color: THREE.Color,
203
217
  offline: boolean,
204
- ): FleetHologramRing {
205
- const pivot = new THREE.Group();
206
- pivot.name = "fleet-hologram-ring";
207
- const tilts = [0.48, -0.62, 0.86];
208
- const rolls = [0.18, 0.72, -0.36];
209
- pivot.rotation.x = tilts[index] ?? 0.4;
210
- pivot.rotation.z = rolls[index] ?? 0;
211
-
212
- const base = unpickable(new THREE.LineLoop(
213
- circleGeometry(radius, 128),
214
- new THREE.LineBasicMaterial({
215
- color,
216
- transparent: true,
217
- opacity: offline ? 0.1 : 0.3,
218
- depthWrite: false,
219
- blending: THREE.AdditiveBlending,
220
- }),
221
- ));
222
- base.name = "fleet-hologram-ring-base";
223
- pivot.add(base);
224
-
225
- // One bright arc makes the ring's slow rotation visible without filling
226
- // the scene with dashed lines, random glyphs or particle noise.
227
- const arc = unpickable(new THREE.Line(
228
- arcGeometry(radius),
229
- new THREE.LineBasicMaterial({
230
- color,
231
- transparent: true,
232
- opacity: offline ? 0.2 : 0.86,
233
- depthWrite: false,
234
- blending: THREE.AdditiveBlending,
235
- }),
236
- ));
237
- arc.name = "fleet-hologram-ring-arc";
238
- pivot.add(arc);
218
+ ): THREE.Group {
219
+ const shell = new THREE.Group();
220
+ shell.name = "fleet-hologram-disc-shell";
221
+ const faceOffset = bodyRadius * 0.4;
222
+ const dialRadius = bodyRadius * 0.8;
223
+ const faces: Array<{ position: [number, number, number]; rotation: [number, number, number] }> = [
224
+ { position: [0, 0, faceOffset], rotation: [0, 0, 0] },
225
+ { position: [0, 0, -faceOffset], rotation: [0, Math.PI, 0] },
226
+ { position: [faceOffset, 0, 0], rotation: [0, Math.PI / 2, 0] },
227
+ { position: [-faceOffset, 0, 0], rotation: [0, -Math.PI / 2, 0] },
228
+ { position: [0, faceOffset, 0], rotation: [-Math.PI / 2, 0, 0] },
229
+ { position: [0, -faceOffset, 0], rotation: [Math.PI / 2, 0, 0] },
230
+ ];
231
+ for (const face of faces) {
232
+ const dial = makeFaceDial(dialRadius, color, offline, SUPPORT);
233
+ dial.position.set(...face.position);
234
+ dial.rotation.set(...face.rotation);
235
+ shell.add(dial);
236
+ }
237
+ return shell;
238
+ }
239
239
 
240
- const baseY = index * 0.74;
241
- pivot.rotation.y = baseY;
242
- return {
243
- pivot,
244
- baseY,
245
- speed: (index % 2 === 0 ? 1 : -1) * (0.07 + index * 0.025),
246
- };
240
+ /** A stack of concentric circles along the Z axis: fat at the equator,
241
+ * tapering to thin rings at the poles (user direction 2026-10-31 — this
242
+ * is the second of the two fused structures; it rounds the shell into a
243
+ * ball and keeps every side view a set of nested circles). */
244
+ function makeRingSpindle(
245
+ bodyRadius: number,
246
+ color: THREE.Color,
247
+ offline: boolean,
248
+ count: number,
249
+ ): THREE.Group {
250
+ const spindle = new THREE.Group();
251
+ spindle.name = "fleet-hologram-ring-spindle";
252
+ for (let i = 0; i < count; i += 1) {
253
+ const t = count === 1 ? 0 : -0.97 + (1.94 * i) / (count - 1);
254
+ const radius = bodyRadius * Math.sqrt(Math.max(0, 1 - t * t)) * 0.98;
255
+ const loop = unpickable(new THREE.LineLoop(
256
+ xyCircleGeometry(radius, 160),
257
+ lineMaterial(color, offline ? 0.04 : 0.08 + (1 - Math.abs(t)) * 0.12),
258
+ ));
259
+ loop.name = "fleet-hologram-ring-spindle-circle";
260
+ loop.position.z = bodyRadius * t;
261
+ spindle.add(loop);
262
+ }
263
+ return spindle;
247
264
  }
248
265
 
249
266
  function makeHudTexture(input: FleetHologramInput): THREE.CanvasTexture | null {
250
267
  const canvas = document.createElement("canvas");
251
268
  canvas.width = 1024;
252
- canvas.height = 64;
269
+ canvas.height = 96;
253
270
  const ctx = canvas.getContext("2d");
254
271
  if (!ctx) return null;
255
272
 
256
273
  const line = hologramHudText(input);
257
274
  ctx.clearRect(0, 0, canvas.width, canvas.height);
258
- ctx.font = "600 28px ui-monospace, SFMono-Regular, Consolas, monospace";
275
+ // 64px glyphs on the 96px strip: the plane's aspect is locked to the
276
+ // canvas's below, so on-disc text height is fontPx/canvasW × planeW —
277
+ // 64/1024 of the band, readable at fleet zoom.
278
+ ctx.font = "600 64px ui-monospace, SFMono-Regular, Consolas, monospace";
259
279
  ctx.textBaseline = "middle";
260
280
  ctx.fillStyle = "#ffffff";
261
281
 
262
- // Repeat a real sentence around the cylinder. Long host labels are
263
- // condensed by canvas rather than replaced with a fabricated identifier.
264
- const maxItemWidth = 460;
282
+ // A clipped horizontal ticker for the dial's centre (user direction
283
+ // 2026-10-31: the text belongs to the disc's middle, not a wrapping
284
+ // ribbon). Long host labels are condensed by canvas rather than
285
+ // replaced with fabricated data.
286
+ const maxItemWidth = 610;
265
287
  const measured = ctx.measureText(line).width;
266
- const itemWidth = Math.min(maxItemWidth, Math.max(220, measured + 48));
288
+ const itemWidth = Math.min(maxItemWidth, Math.max(360, measured + 64));
267
289
  for (let x = 24; x < canvas.width; x += itemWidth) {
268
290
  ctx.fillText(line, x, canvas.height / 2, maxItemWidth - 48);
269
291
  }
@@ -275,30 +297,52 @@ function makeHudTexture(input: FleetHologramInput): THREE.CanvasTexture | null {
275
297
  return texture;
276
298
  }
277
299
 
278
- function makeHudBand(
279
- input: FleetHologramInput,
300
+ /** The real-data ticker plane, pinned to the front dial's centre. The
301
+ * plane's aspect is locked to the texture's (1024:96) — a mismatched
302
+ * aspect would compress the glyphs horizontally. */
303
+ function makeTickerPlane(
304
+ bodyRadius: number,
280
305
  color: THREE.Color,
281
306
  offline: boolean,
282
- ): { mesh: THREE.Mesh; texture: THREE.CanvasTexture } | null {
283
- const texture = makeHudTexture(input);
284
- if (!texture) return null;
285
- const radius = input.radius * 1.05;
286
- const height = Math.max(0.18, input.radius * 0.23);
287
- const material = new THREE.MeshBasicMaterial({
288
- map: texture,
289
- color,
290
- transparent: true,
291
- opacity: offline ? 0.2 : 0.68,
292
- side: THREE.DoubleSide,
293
- depthWrite: false,
294
- blending: THREE.AdditiveBlending,
295
- });
307
+ texture: THREE.CanvasTexture,
308
+ ): THREE.Mesh {
309
+ const width = bodyRadius * 1.14;
310
+ const height = width * (96 / 1024);
296
311
  const mesh = unpickable(new THREE.Mesh(
297
- new THREE.CylinderGeometry(radius, radius, height, 96, 1, true),
298
- material,
312
+ new THREE.PlaneGeometry(width, height),
313
+ new THREE.MeshBasicMaterial({
314
+ map: texture,
315
+ color,
316
+ transparent: true,
317
+ opacity: offline ? 0.18 : 0.86,
318
+ side: THREE.DoubleSide,
319
+ depthWrite: false,
320
+ blending: THREE.AdditiveBlending,
321
+ }),
299
322
  ));
300
- mesh.name = "fleet-hologram-hud-band";
301
- return { mesh, texture };
323
+ mesh.name = "fleet-hologram-hud-panel";
324
+ return mesh;
325
+ }
326
+
327
+ /** The circular focus mark that replaces the board's cube cursor while a
328
+ * body is selected (straight corners would fight the ring silhouette). */
329
+ function makeSelectionRing(
330
+ bodyRadius: number,
331
+ color: THREE.Color,
332
+ offline: boolean,
333
+ ): THREE.Group {
334
+ const ring = new THREE.Group();
335
+ ring.name = "fleet-hologram-selection-ring";
336
+ ring.visible = false;
337
+ for (const [scale, opacity] of [[1.02, 0.8], [1.08, 0.24]] as const) {
338
+ const line = unpickable(new THREE.LineLoop(
339
+ xyCircleGeometry(bodyRadius * scale, 160),
340
+ lineMaterial(color, offline ? opacity * 0.2 : opacity),
341
+ ));
342
+ line.name = "fleet-hologram-selection-ring-line";
343
+ ring.add(line);
344
+ }
345
+ return ring;
302
346
  }
303
347
 
304
348
  export function makeFleetHologram(
@@ -308,58 +352,110 @@ export function makeFleetHologram(
308
352
  ): FleetHologram {
309
353
  const group = new THREE.Group();
310
354
  group.name = "fleet-hologram";
311
- const color = baseColor.clone();
312
- const edgeColor = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.18);
313
- if (offline) {
314
- color.multiplyScalar(0.3);
315
- edgeColor.multiplyScalar(0.3);
316
- }
355
+ const color = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.18);
356
+ if (offline) color.multiplyScalar(0.3);
317
357
 
318
- const geometry = input.role === "star"
319
- ? new THREE.IcosahedronGeometry(input.radius * 0.82, 0)
320
- : new THREE.DodecahedronGeometry(input.radius * 0.82, 0);
358
+ // The visible object is the disc shell plus its spindle; this invisible
359
+ // proxy is only the pointer target the board measures for picking. It
360
+ // reaches 82% of the body radius — the old polyhedron's pick reach —
361
+ // so most of the visible silhouette still answers a click.
321
362
  const mesh = new THREE.Mesh(
322
- geometry,
363
+ new THREE.IcosahedronGeometry(input.radius * 0.82, 1),
323
364
  new THREE.MeshBasicMaterial({
324
365
  color,
325
366
  transparent: true,
326
- opacity: offline ? 0.025 : 0.06,
327
- side: THREE.DoubleSide,
367
+ opacity: 0,
328
368
  depthWrite: false,
329
- blending: THREE.AdditiveBlending,
330
369
  }),
331
370
  );
332
- mesh.name = "fleet-hologram-polyhedron";
333
- mesh.rotation.set(0.2, 0, 0.1);
371
+ mesh.name = "fleet-hologram-core";
372
+
373
+ const shell = makeDiscShell(input.radius, color, offline);
374
+ shell.add(makeRingSpindle(
375
+ input.radius,
376
+ color,
377
+ offline,
378
+ input.role === "star" ? 9 : 6,
379
+ ));
380
+ group.add(shell);
381
+
382
+ const frontDial = new THREE.Group();
383
+ frontDial.name = "fleet-hologram-front-dial";
384
+ const frontSpinner = new THREE.Group();
385
+ frontSpinner.name = "fleet-hologram-front-spinner";
386
+ frontSpinner.add(makeFaceDial(input.radius * 0.8, color, offline, focusTierFor(input.role)));
387
+ frontDial.add(frontSpinner);
334
388
 
335
- for (const ring of makeFaceRings(geometry, input.radius, edgeColor, offline)) {
336
- mesh.add(ring);
389
+ const hudTexture = makeHudTexture(input);
390
+ let hudPanel: THREE.Mesh | null = null;
391
+ if (hudTexture) {
392
+ hudPanel = makeTickerPlane(input.radius, color, offline, hudTexture);
393
+ hudPanel.position.z = input.radius * 0.02;
394
+ frontDial.add(hudPanel);
337
395
  }
396
+ group.add(frontDial);
397
+
398
+ const selectionRing = makeSelectionRing(input.radius, color, offline);
399
+ group.add(selectionRing);
338
400
  group.add(mesh);
339
401
 
340
- const rings = HOLOGRAM_RING_SCALES[input.role].map((scale, index) => {
341
- const ring = makeRing(input.radius * scale, index, edgeColor, offline);
342
- group.add(ring.pivot);
343
- return ring;
344
- });
402
+ group.userData.fleetOwnedTextures = hudTexture ? [hudTexture] : [];
345
403
 
346
- const hud = makeHudBand(input, edgeColor, offline);
347
- if (hud) group.add(hud.mesh);
348
- group.userData.fleetOwnedTextures = hud ? [hud.texture] : [];
404
+ return {
405
+ group,
406
+ mesh,
407
+ shell,
408
+ frontDial,
409
+ frontSpinner,
410
+ selectionRing,
411
+ hudPanel,
412
+ hudTexture,
413
+ };
414
+ }
349
415
 
350
- return { group, mesh, rings, hudTexture: hud?.texture ?? null };
416
+ /** A satellite's miniature hologram: three crossed support dials and a
417
+ * three-ring spindle — the same visual language as its gateway, at
418
+ * endpoint scale and with no text (user direction 2026-10-31: EVERY
419
+ * solid sphere in the scene is replaced, hierarchy carried by size and
420
+ * content density). */
421
+ export function makeSatelliteHologram(
422
+ radius: number,
423
+ baseColor: THREE.Color,
424
+ offline: boolean,
425
+ ): THREE.Group {
426
+ const group = new THREE.Group();
427
+ group.name = "fleet-hologram-satellite";
428
+ const color = baseColor.clone().lerp(new THREE.Color(0xffffff), 0.18);
429
+ if (offline) color.multiplyScalar(0.3);
430
+ const orientations: Array<[number, number, number]> = [
431
+ [0, 0, 0],
432
+ [0, Math.PI / 3, Math.PI / 4],
433
+ [Math.PI / 3, 0, -Math.PI / 5],
434
+ ];
435
+ for (const [rx, ry, rz] of orientations) {
436
+ const dial = makeFaceDial(radius, color, offline, SUPPORT);
437
+ dial.rotation.set(rx, ry, rz);
438
+ group.add(dial);
439
+ }
440
+ group.add(makeRingSpindle(radius, color, offline, 3));
441
+ return group;
351
442
  }
352
443
 
353
- /** Independent motion: the polyhedron spins, ring arcs precess, and the
354
- * real-data banner scrolls around the outer surface by UV offset. */
444
+ /** Independent motion: the shell turns slowly, the front dial and the
445
+ * selection ring face the camera, the dial's ticks sweep, and the
446
+ * real-data ticker scrolls across the disc's centre. */
355
447
  export function poseFleetHologram(
356
448
  hologram: FleetHologram,
357
449
  elapsed: number,
358
450
  spinPeriodSec: number,
451
+ cameraQuaternion?: THREE.Quaternion,
359
452
  ): void {
360
- hologram.mesh.rotation.y = (elapsed / spinPeriodSec) * Math.PI * 2;
361
- for (const ring of hologram.rings) {
362
- ring.pivot.rotation.y = ring.baseY + elapsed * ring.speed;
453
+ hologram.shell.rotation.y = (elapsed / spinPeriodSec) * Math.PI * 2;
454
+ hologram.shell.rotation.x = Math.sin(elapsed * 0.08) * 0.06;
455
+ hologram.frontSpinner.rotation.z = elapsed * 0.11;
456
+ if (cameraQuaternion) {
457
+ hologram.frontDial.quaternion.copy(cameraQuaternion);
458
+ hologram.selectionRing.quaternion.copy(cameraQuaternion);
363
459
  }
364
460
  if (hologram.hudTexture) {
365
461
  hologram.hudTexture.offset.x = (elapsed * 0.018) % 1;