@project-skymap/library 0.7.4 → 0.8.0

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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE5 from 'three';
1
+ import * as THREE6 from 'three';
2
2
  import { forwardRef, useRef, useImperativeHandle, useEffect } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
@@ -129,14 +129,14 @@ var init_constellations = __esm({
129
129
  });
130
130
  function lookAt(point, target, up) {
131
131
  const zAxis = target.clone().normalize();
132
- let xAxis = new THREE5.Vector3().crossVectors(up, zAxis);
132
+ let xAxis = new THREE6.Vector3().crossVectors(up, zAxis);
133
133
  if (xAxis.lengthSq() < 1e-4) {
134
- xAxis = new THREE5.Vector3().crossVectors(new THREE5.Vector3(1, 0, 0), zAxis);
134
+ xAxis = new THREE6.Vector3().crossVectors(new THREE6.Vector3(1, 0, 0), zAxis);
135
135
  }
136
136
  xAxis.normalize();
137
- const yAxis = new THREE5.Vector3().crossVectors(zAxis, xAxis).normalize();
138
- const m = new THREE5.Matrix4().makeBasis(xAxis, yAxis, zAxis);
139
- const v = new THREE5.Vector3(point.x, point.y, point.z);
137
+ const yAxis = new THREE6.Vector3().crossVectors(zAxis, xAxis).normalize();
138
+ const m = new THREE6.Matrix4().makeBasis(xAxis, yAxis, zAxis);
139
+ const v = new THREE6.Vector3(point.x, point.y, point.z);
140
140
  v.applyMatrix4(m);
141
141
  v.add(target);
142
142
  return { x: v.x, y: v.y, z: v.z };
@@ -217,7 +217,7 @@ function computeLayoutPositions(model, layout) {
217
217
  const radiusAtY = Math.sqrt(1 - y * y);
218
218
  const x = Math.cos(midAngle) * radiusAtY;
219
219
  const z = Math.sin(midAngle) * radiusAtY;
220
- const labelPos = new THREE5.Vector3(x, y, z).multiplyScalar(radius);
220
+ const labelPos = new THREE6.Vector3(x, y, z).multiplyScalar(radius);
221
221
  uDivision.meta.x = labelPos.x;
222
222
  uDivision.meta.y = labelPos.y;
223
223
  uDivision.meta.z = labelPos.z;
@@ -233,7 +233,7 @@ function computeLayoutPositions(model, layout) {
233
233
  const theta = startAngle + t * angleSpan;
234
234
  const x = Math.cos(theta) * radiusAtY;
235
235
  const z = Math.sin(theta) * radiusAtY;
236
- const bookPos = new THREE5.Vector3(x, y, z).multiplyScalar(radius);
236
+ const bookPos = new THREE6.Vector3(x, y, z).multiplyScalar(radius);
237
237
  const labelPos = bookPos.clone();
238
238
  labelPos.y += radius * 0.025;
239
239
  labelPos.setLength(radius);
@@ -244,7 +244,7 @@ function computeLayoutPositions(model, layout) {
244
244
  if (chapters.length > 0) {
245
245
  const territoryRadius = radius * 2 / Math.sqrt(books.length * 2) * 0.7;
246
246
  const localPoints = getConstellationLayout(bookKey, chapters.length, territoryRadius);
247
- const up = new THREE5.Vector3(0, 1, 0);
247
+ const up = new THREE6.Vector3(0, 1, 0);
248
248
  chapters.forEach((chap, idx) => {
249
249
  const uChap = updatedNodeMap.get(chap.id);
250
250
  const lp = localPoints[idx];
@@ -263,10 +263,10 @@ function computeLayoutPositions(model, layout) {
263
263
  testaments.forEach((t) => {
264
264
  const children = childrenMap.get(t.id) ?? [];
265
265
  if (children.length === 0) return;
266
- const centroid = new THREE5.Vector3();
266
+ const centroid = new THREE6.Vector3();
267
267
  children.forEach((c) => {
268
268
  const u = updatedNodeMap.get(c.id);
269
- centroid.add(new THREE5.Vector3(u.meta.x, u.meta.y, u.meta.z));
269
+ centroid.add(new THREE6.Vector3(u.meta.x, u.meta.y, u.meta.z));
270
270
  });
271
271
  centroid.divideScalar(children.length);
272
272
  if (centroid.length() > 0.1) {
@@ -387,7 +387,7 @@ float getMaskAlpha() {
387
387
  });
388
388
  function createSmartMaterial(params) {
389
389
  const uniforms = { ...globalUniforms, ...params.uniforms };
390
- return new THREE5.ShaderMaterial({
390
+ return new THREE6.ShaderMaterial({
391
391
  uniforms,
392
392
  vertexShader: `
393
393
  ${BLEND_CHUNK}
@@ -401,8 +401,8 @@ function createSmartMaterial(params) {
401
401
  transparent: params.transparent || false,
402
402
  depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
403
403
  depthTest: params.depthTest !== void 0 ? params.depthTest : true,
404
- side: params.side || THREE5.FrontSide,
405
- blending: params.blending || THREE5.NormalBlending
404
+ side: params.side || THREE6.FrontSide,
405
+ blending: params.blending || THREE6.NormalBlending
406
406
  });
407
407
  }
408
408
  var globalUniforms;
@@ -420,16 +420,102 @@ var init_materials = __esm({
420
420
  uAtmGlow: { value: 1 },
421
421
  uAtmDark: { value: 0.6 },
422
422
  uAtmExtinction: { value: 4 },
423
- uAtmTwinkle: { value: 0.8 },
424
- uColorHorizon: { value: new THREE5.Color(3825292) },
425
- uColorZenith: { value: new THREE5.Color(132104) }
423
+ uAtmTwinkle: { value: 0 },
424
+ uColorHorizon: { value: new THREE6.Color(3825292) },
425
+ uColorZenith: { value: new THREE6.Color(132104) }
426
426
  };
427
427
  }
428
428
  });
429
+
430
+ // src/engine/fader.ts
431
+ var Fader;
432
+ var init_fader = __esm({
433
+ "src/engine/fader.ts"() {
434
+ Fader = class {
435
+ target = false;
436
+ value = 0;
437
+ duration;
438
+ constructor(duration = 0.3) {
439
+ this.duration = duration;
440
+ }
441
+ update(dt) {
442
+ const goal = this.target ? 1 : 0;
443
+ if (this.value === goal) return;
444
+ const speed = 1 / this.duration;
445
+ const step = speed * dt;
446
+ const diff = goal - this.value;
447
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
448
+ }
449
+ /** Smoothstep-eased value for perceptually smooth transitions */
450
+ get eased() {
451
+ const v = this.value;
452
+ return v * v * (3 - 2 * v);
453
+ }
454
+ };
455
+ }
456
+ });
457
+ function buildSphereQuad(center, rightDir, upDir, halfWidth, halfHeight, domeRadius, subdivisions = 8) {
458
+ const vertsPerSide = subdivisions + 1;
459
+ const vertCount = vertsPerSide * vertsPerSide;
460
+ const positions = new Float32Array(vertCount * 3);
461
+ const uvs = new Float32Array(vertCount * 2);
462
+ const centerNorm = center.clone().normalize();
463
+ const temp = new THREE6.Vector3();
464
+ const tangent = new THREE6.Vector3();
465
+ const q = new THREE6.Quaternion();
466
+ const halfAngleX = Math.atan2(halfWidth, domeRadius);
467
+ const halfAngleY = Math.atan2(halfHeight, domeRadius);
468
+ for (let iy = 0; iy < vertsPerSide; iy++) {
469
+ for (let ix = 0; ix < vertsPerSide; ix++) {
470
+ const idx = iy * vertsPerSide + ix;
471
+ const u = ix / subdivisions;
472
+ const v = iy / subdivisions;
473
+ const angX = (u - 0.5) * 2 * halfAngleX;
474
+ const angY = (v - 0.5) * 2 * halfAngleY;
475
+ tangent.copy(rightDir).multiplyScalar(angX).addScaledVector(upDir, angY);
476
+ const angle = tangent.length();
477
+ if (angle > 1e-5) {
478
+ tangent.normalize();
479
+ q.setFromAxisAngle(tangent, angle);
480
+ temp.copy(centerNorm).applyQuaternion(q).multiplyScalar(domeRadius);
481
+ } else {
482
+ temp.copy(center);
483
+ }
484
+ positions[idx * 3 + 0] = temp.x;
485
+ positions[idx * 3 + 1] = temp.y;
486
+ positions[idx * 3 + 2] = temp.z;
487
+ uvs[idx * 2 + 0] = u;
488
+ uvs[idx * 2 + 1] = 1 - v;
489
+ }
490
+ }
491
+ const indexCount = subdivisions * subdivisions * 6;
492
+ const indices = new Uint16Array(indexCount);
493
+ let ii = 0;
494
+ for (let iy = 0; iy < subdivisions; iy++) {
495
+ for (let ix = 0; ix < subdivisions; ix++) {
496
+ const a = iy * vertsPerSide + ix;
497
+ const b = a + 1;
498
+ const c = a + vertsPerSide;
499
+ const d = c + 1;
500
+ indices[ii++] = a;
501
+ indices[ii++] = c;
502
+ indices[ii++] = b;
503
+ indices[ii++] = b;
504
+ indices[ii++] = c;
505
+ indices[ii++] = d;
506
+ }
507
+ }
508
+ const geometry = new THREE6.BufferGeometry();
509
+ geometry.setAttribute("position", new THREE6.BufferAttribute(positions, 3));
510
+ geometry.setAttribute("uv", new THREE6.BufferAttribute(uvs, 2));
511
+ geometry.setIndex(new THREE6.BufferAttribute(indices, 1));
512
+ return geometry;
513
+ }
429
514
  var ConstellationArtworkLayer;
430
515
  var init_ConstellationArtworkLayer = __esm({
431
516
  "src/engine/ConstellationArtworkLayer.ts"() {
432
517
  init_materials();
518
+ init_fader();
433
519
  ConstellationArtworkLayer = class {
434
520
  root;
435
521
  items = [];
@@ -437,27 +523,22 @@ var init_ConstellationArtworkLayer = __esm({
437
523
  hoveredId = null;
438
524
  focusedId = null;
439
525
  constructor(root) {
440
- this.textureLoader = new THREE5.TextureLoader();
526
+ this.textureLoader = new THREE6.TextureLoader();
441
527
  this.textureLoader.crossOrigin = "anonymous";
442
- this.root = new THREE5.Group();
528
+ this.root = new THREE6.Group();
443
529
  this.root.renderOrder = -1;
444
530
  root.add(this.root);
445
531
  }
446
532
  getItems() {
447
533
  return this.items;
448
534
  }
449
- setPosition(id, pos) {
450
- const item = this.items.find((i) => i.config.id === id);
451
- if (item) {
452
- item.mesh.position.copy(pos);
453
- }
454
- }
455
- load(config, getPosition) {
535
+ load(config, getPosition, getOrientationPosition) {
536
+ const getAnchorPos = getOrientationPosition ?? getPosition;
456
537
  this.clear();
457
538
  console.log(`[Constellation] Loading ${config.constellations.length} constellations from ${config.atlasBasePath}`);
458
539
  const basePath = config.atlasBasePath.replace(/\/$/, "");
459
540
  config.constellations.forEach((c) => {
460
- let center = new THREE5.Vector3();
541
+ let center = new THREE6.Vector3();
461
542
  let valid = false;
462
543
  let radius = 2e3;
463
544
  const arrPos = getPosition(c.id);
@@ -475,7 +556,7 @@ var init_ConstellationArtworkLayer = __esm({
475
556
  }
476
557
  }
477
558
  } else if (c.center) {
478
- center.set(c.center[0], c.center[1], c.center[2]);
559
+ center.set(c.center[0], c.center[1], 0);
479
560
  valid = true;
480
561
  } else if (c.anchors.length > 0) {
481
562
  const points = [];
@@ -495,100 +576,65 @@ var init_ConstellationArtworkLayer = __esm({
495
576
  }
496
577
  }
497
578
  if (!valid) return;
498
- const normal = center.clone().normalize().negate();
499
- const upVec = center.clone().normalize();
500
- let right = new THREE5.Vector3(1, 0, 0);
579
+ const centerNorm = center.clone().normalize();
580
+ let rightDir = new THREE6.Vector3();
581
+ let upDir = new THREE6.Vector3();
501
582
  if (c.anchors.length >= 2) {
502
- const p0 = getPosition(c.anchors[0]);
503
- const p1 = getPosition(c.anchors[1]);
504
- if (p0 && p1) {
505
- const diff = new THREE5.Vector3().subVectors(p1, p0);
506
- right.copy(diff).sub(upVec.clone().multiplyScalar(diff.dot(upVec))).normalize();
583
+ const p0 = getAnchorPos(c.anchors[0]);
584
+ const p1 = getAnchorPos(c.anchors[1]);
585
+ if (p0 && p1 && p0.distanceTo(p1) > 1e-3) {
586
+ const diff = new THREE6.Vector3().subVectors(p1, p0);
587
+ rightDir.copy(diff).sub(centerNorm.clone().multiplyScalar(diff.dot(centerNorm))).normalize();
588
+ upDir.crossVectors(centerNorm, rightDir).normalize();
589
+ rightDir.crossVectors(upDir, centerNorm).normalize();
590
+ } else {
591
+ this._defaultTangentFrame(centerNorm, rightDir, upDir);
507
592
  }
508
593
  } else {
509
- if (Math.abs(upVec.y) > 0.9) right.set(1, 0, 0).cross(upVec).normalize();
510
- else right.set(0, 1, 0).cross(upVec).normalize();
594
+ this._defaultTangentFrame(centerNorm, rightDir, upDir);
595
+ }
596
+ if (c.rotationDeg !== 0) {
597
+ const q = new THREE6.Quaternion().setFromAxisAngle(centerNorm, THREE6.MathUtils.degToRad(c.rotationDeg));
598
+ rightDir.applyQuaternion(q);
599
+ upDir.applyQuaternion(q);
511
600
  }
512
- const top = new THREE5.Vector3().crossVectors(upVec, right).normalize();
513
- right.crossVectors(top, upVec).normalize();
514
- new THREE5.Matrix4().makeBasis(right, top, normal);
515
- const geometry = new THREE5.PlaneGeometry(1, 1);
516
601
  let size = c.radius;
517
602
  if (size <= 1) size *= radius;
518
603
  size *= 2;
604
+ const sqrtAspect = Math.sqrt(c.aspectRatio ?? 1);
605
+ const halfWidth = size / 2 * sqrtAspect;
606
+ const halfHeight = size / 2 / sqrtAspect;
607
+ const geometry = buildSphereQuad(center, rightDir, upDir, halfWidth, halfHeight, radius, 8);
519
608
  const texPath = `${basePath}/${c.image}`;
520
- let blending = THREE5.NormalBlending;
521
- if (c.blend === "additive") blending = THREE5.AdditiveBlending;
609
+ const blending = c.blend === "additive" ? THREE6.AdditiveBlending : THREE6.NormalBlending;
522
610
  const material = createSmartMaterial({
523
611
  uniforms: {
524
612
  uMap: { value: this.textureLoader.load(texPath) },
525
- // Placeholder, updated below
526
- uOpacity: { value: c.opacity },
527
- uSize: { value: size },
528
- uImgRotation: { value: THREE5.MathUtils.degToRad(c.rotationDeg) },
529
- uImgAspect: { value: c.aspectRatio ?? 1 }
530
- // uScale, uAspect (screen) are injected by createSmartMaterial/globalUniforms
613
+ uOpacity: { value: c.opacity }
531
614
  },
532
615
  vertexShaderBody: `
533
- #ifdef GL_ES
534
- precision highp float;
535
- #endif
536
-
537
- uniform float uSize;
538
- uniform float uImgRotation;
539
- uniform float uImgAspect;
540
-
541
616
  varying vec2 vUv;
542
-
617
+ varying float vClipFade;
543
618
  void main() {
544
619
  vUv = uv;
545
-
546
- // 1. Project Center Point (Proven Method)
547
- vec4 mvCenter = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
548
- vec4 clipCenter = smartProject(mvCenter);
549
-
550
- // 2. Project "Up" Point (World Zenith)
551
- // Transform World Up (0,1,0) to View Space
552
- vec3 viewUpDir = mat3(viewMatrix) * vec3(0.0, 1.0, 0.0);
553
- // Offset center by a significant amount (1000.0) to ensure screen delta
554
- vec4 mvUp = mvCenter + vec4(viewUpDir * 1000.0, 0.0);
555
- vec4 clipUp = smartProject(mvUp);
556
-
557
- // 3. Calculate Horizon Angle
558
- vec2 screenCenter = clipCenter.xy / clipCenter.w;
559
- vec2 screenUp = clipUp.xy / clipUp.w;
560
- vec2 screenDelta = screenUp - screenCenter;
561
-
562
- float horizonAngle = 0.0;
563
- if (length(screenDelta) > 0.001) {
564
- vec2 screenDir = normalize(screenDelta);
565
- horizonAngle = atan(screenDir.y, screenDir.x) - 1.5708; // -90 deg
620
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
621
+
622
+ // Compute clip-boundary fade BEFORE smartProject
623
+ // (Stellarium culls entire constellations near the boundary;
624
+ // we fade per-vertex for smoother transitions)
625
+ vec3 viewDir = normalize(mvPosition.xyz);
626
+ float clipZ;
627
+ if (uProjectionType == 0) {
628
+ clipZ = -0.1;
629
+ } else if (uProjectionType == 1) {
630
+ clipZ = 0.1;
631
+ } else {
632
+ clipZ = mix(-0.1, 0.1, uBlend);
566
633
  }
567
-
568
- // 4. Combine with User Rotation
569
- float finalAngle = uImgRotation + horizonAngle;
570
-
571
- // 5. Billboard Offset
572
- vec2 offset = position.xy;
573
-
574
- float cr = cos(finalAngle);
575
- float sr = sin(finalAngle);
576
- vec2 rotated = vec2(
577
- offset.x * cr - offset.y * sr,
578
- offset.x * sr + offset.y * cr
579
- );
580
-
581
- rotated.x *= uImgAspect;
582
-
583
- float dist = length(mvCenter.xyz);
584
- float scale = (uSize / dist) * uScale;
585
-
586
- rotated *= scale;
587
- rotated.x /= uAspect;
588
-
589
- gl_Position = clipCenter;
590
- gl_Position.xy += rotated * clipCenter.w;
591
-
634
+ // Smooth fade over 0.3 radians before the clip threshold
635
+ vClipFade = smoothstep(clipZ, clipZ - 0.3, viewDir.z);
636
+
637
+ gl_Position = smartProject(mvPosition);
592
638
  vScreenPos = gl_Position.xy / gl_Position.w;
593
639
  }
594
640
  `,
@@ -599,30 +645,50 @@ var init_ConstellationArtworkLayer = __esm({
599
645
  uniform sampler2D uMap;
600
646
  uniform float uOpacity;
601
647
  varying vec2 vUv;
648
+ varying float vClipFade;
602
649
  void main() {
603
650
  float mask = getMaskAlpha();
604
651
  if (mask < 0.01) discard;
605
652
  vec4 tex = texture2D(uMap, vUv);
606
- gl_FragColor = vec4(tex.rgb, tex.a * uOpacity * mask);
653
+
654
+ // Apply a slight blue tinge to the artwork
655
+ vec3 color = tex.rgb * vec3(0.8, 0.9, 1.0);
656
+
657
+ // vClipFade smoothly hides vertices near the projection
658
+ // clip boundary, preventing mesh distortion from escape positions
659
+ gl_FragColor = vec4(color, tex.a * uOpacity * mask * vClipFade);
607
660
  }
608
661
  `,
609
662
  transparent: true,
610
663
  depthWrite: false,
611
664
  depthTest: true,
612
665
  blending,
613
- side: THREE5.DoubleSide
666
+ side: THREE6.DoubleSide
614
667
  });
615
668
  material.uniforms.uMap.value = this.textureLoader.load(
616
669
  texPath,
617
670
  (tex) => {
618
- tex.minFilter = THREE5.LinearFilter;
619
- tex.magFilter = THREE5.LinearFilter;
671
+ tex.minFilter = THREE6.LinearFilter;
672
+ tex.magFilter = THREE6.LinearFilter;
620
673
  tex.generateMipmaps = false;
621
674
  tex.needsUpdate = true;
622
675
  if (c.aspectRatio === void 0 && tex.image.width && tex.image.height) {
623
676
  const natAspect = tex.image.width / tex.image.height;
624
- material.uniforms.uImgAspect.value = natAspect;
677
+ const sqrtNatAspect = Math.sqrt(natAspect);
678
+ const newHalfWidth = size / 2 * sqrtNatAspect;
679
+ const newHalfHeight = size / 2 / sqrtNatAspect;
680
+ const newGeometry = buildSphereQuad(center, rightDir, upDir, newHalfWidth, newHalfHeight, radius, 8);
681
+ const item2 = this.items.find((i) => i.config.id === c.id);
682
+ if (item2) {
683
+ item2.mesh.geometry.dispose();
684
+ item2.mesh.geometry = newGeometry;
685
+ item2.halfWidth = newHalfWidth;
686
+ item2.halfHeight = newHalfHeight;
687
+ item2.imageLoadedFader.target = true;
688
+ }
625
689
  }
690
+ const item = this.items.find((i) => i.config.id === c.id);
691
+ if (item) item.imageLoadedFader.target = true;
626
692
  console.log(`[Constellation] Loaded: ${c.id} (${tex.image.width}x${tex.image.height})`);
627
693
  },
628
694
  (progress) => {
@@ -635,23 +701,55 @@ var init_ConstellationArtworkLayer = __esm({
635
701
  material.polygonOffset = true;
636
702
  material.polygonOffsetFactor = -c.zBias;
637
703
  }
638
- const mesh = new THREE5.Mesh(geometry, material);
704
+ const mesh = new THREE6.Mesh(geometry, material);
639
705
  mesh.frustumCulled = false;
640
706
  mesh.userData = { id: c.id, type: "constellation" };
641
- mesh.position.copy(center);
642
707
  this.root.add(mesh);
643
- this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
708
+ this.items.push({
709
+ config: c,
710
+ mesh,
711
+ material,
712
+ baseOpacity: c.opacity,
713
+ center: center.clone(),
714
+ rightDir: rightDir.clone(),
715
+ upDir: upDir.clone(),
716
+ halfWidth,
717
+ halfHeight,
718
+ domeRadius: radius,
719
+ visibleFader: new Fader(0.35),
720
+ imageLoadedFader: new Fader(0.5)
721
+ });
644
722
  });
645
723
  }
724
+ _defaultTangentFrame(centerNorm, rightDir, upDir) {
725
+ const worldUp = new THREE6.Vector3(0, 1, 0);
726
+ if (Math.abs(centerNorm.dot(worldUp)) > 0.99) {
727
+ rightDir.crossVectors(new THREE6.Vector3(1, 0, 0), centerNorm).normalize();
728
+ } else {
729
+ rightDir.crossVectors(worldUp, centerNorm).normalize();
730
+ }
731
+ upDir.crossVectors(centerNorm, rightDir).normalize();
732
+ rightDir.crossVectors(upDir, centerNorm).normalize();
733
+ }
646
734
  _globalOpacity = 1;
647
735
  setGlobalOpacity(v) {
648
736
  this._globalOpacity = v;
649
737
  }
650
- update(fov, showArt) {
738
+ /**
739
+ * Update visibility and opacity.
740
+ * Accepts an optional camera for Stellarium-style visibility culling:
741
+ * constellations whose center is near or past the projection clip
742
+ * boundary are hidden to prevent mesh distortion from escape positions.
743
+ */
744
+ update(fov, showArt, camera, dt = 0.016) {
651
745
  this.root.visible = showArt;
652
746
  if (!showArt) {
653
747
  return;
654
748
  }
749
+ let cameraForward = null;
750
+ if (camera) {
751
+ cameraForward = new THREE6.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
752
+ }
655
753
  for (const item of this.items) {
656
754
  const { fade } = item.config;
657
755
  let opacity = fade.maxOpacity;
@@ -661,10 +759,30 @@ var init_ConstellationArtworkLayer = __esm({
661
759
  opacity = fade.minOpacity;
662
760
  } else {
663
761
  const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
664
- opacity = THREE5.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
762
+ opacity = THREE6.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
763
+ }
764
+ const halfAngleX = Math.atan2(item.halfWidth, item.domeRadius);
765
+ const halfAngleY = Math.atan2(item.halfHeight, item.domeRadius);
766
+ const diameterDeg = Math.max(halfAngleX, halfAngleY) * 2 * THREE6.MathUtils.RAD2DEG;
767
+ const zoomFade = THREE6.MathUtils.smoothstep(fov, diameterDeg / 5, diameterDeg / 2);
768
+ opacity *= zoomFade;
769
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity * item.baseOpacity;
770
+ if (cameraForward) {
771
+ const centerDir = item.center.clone().normalize();
772
+ const dot = cameraForward.dot(centerDir);
773
+ const angle = Math.acos(THREE6.MathUtils.clamp(dot, -1, 1));
774
+ const angularRadius = Math.max(halfAngleX, halfAngleY);
775
+ const margin = THREE6.MathUtils.degToRad(8);
776
+ item.visibleFader.target = angle <= Math.PI * 0.5 + angularRadius + margin;
777
+ } else {
778
+ item.visibleFader.target = true;
665
779
  }
666
- opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
780
+ item.visibleFader.update(dt);
781
+ opacity *= item.visibleFader.eased;
782
+ item.imageLoadedFader.update(dt);
783
+ opacity *= item.imageLoadedFader.eased;
667
784
  item.material.uniforms.uOpacity.value = opacity;
785
+ item.mesh.visible = opacity > 1e-3;
668
786
  }
669
787
  }
670
788
  setHovered(id) {
@@ -761,6 +879,7 @@ var init_projections = __esm({
761
879
  blendEnd;
762
880
  /** Current blend factor, updated via setFov() */
763
881
  blend = 0;
882
+ blendOverride = null;
764
883
  constructor(blendStart = 40, blendEnd = 100) {
765
884
  this.blendStart = blendStart;
766
885
  this.blendEnd = blendEnd;
@@ -779,14 +898,22 @@ var init_projections = __esm({
779
898
  this.blend = t * t * (3 - 2 * t);
780
899
  }
781
900
  getBlend() {
782
- return this.blend;
901
+ return this.blendOverride ?? this.blend;
902
+ }
903
+ setBlendOverride(value) {
904
+ if (value === null || value === void 0 || Number.isNaN(value)) {
905
+ this.blendOverride = null;
906
+ return;
907
+ }
908
+ this.blendOverride = Math.max(0, Math.min(1, value));
783
909
  }
784
910
  forward(dir) {
785
- if (this.blend > 0.5 && dir.z > 0.4) return null;
786
- if (this.blend < 0.1 && dir.z > -0.1) return null;
911
+ const b = this.getBlend();
912
+ if (b > 0.5 && dir.z > 0.4) return null;
913
+ if (b < 0.1 && dir.z > -0.1) return null;
787
914
  const kLinear = 1 / Math.max(0.01, -dir.z);
788
915
  const kStereo = 2 / (1 - dir.z);
789
- const k = kLinear * (1 - this.blend) + kStereo * this.blend;
916
+ const k = kLinear * (1 - b) + kStereo * b;
790
917
  return { x: k * dir.x, y: k * dir.y, z: dir.z };
791
918
  }
792
919
  inverse(uvX, uvY, fovRad) {
@@ -795,7 +922,8 @@ var init_projections = __esm({
795
922
  const thetaLin = Math.atan(r * halfHeightLin);
796
923
  const halfHeightStereo = 2 * Math.tan(fovRad / 4);
797
924
  const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
798
- const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
925
+ const b = this.getBlend();
926
+ const theta = thetaLin * (1 - b) + thetaStereo * b;
799
927
  const phi = Math.atan2(uvY, uvX);
800
928
  const sinT = Math.sin(theta);
801
929
  return {
@@ -807,11 +935,13 @@ var init_projections = __esm({
807
935
  getScale(fovRad) {
808
936
  const scaleLinear = 1 / Math.tan(fovRad / 2);
809
937
  const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
810
- return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
938
+ const b = this.getBlend();
939
+ return scaleLinear * (1 - b) + scaleStereo * b;
811
940
  }
812
941
  isClipped(dirZ) {
813
- if (this.blend > 0.5) return dirZ > 0.4;
814
- if (this.blend < 0.1) return dirZ > -0.1;
942
+ const b = this.getBlend();
943
+ if (b > 0.5) return dirZ > 0.4;
944
+ if (b < 0.1) return dirZ > -0.1;
815
945
  return false;
816
946
  }
817
947
  };
@@ -821,30 +951,366 @@ var init_projections = __esm({
821
951
  };
822
952
  }
823
953
  });
824
-
825
- // src/engine/fader.ts
826
- var Fader;
827
- var init_fader = __esm({
828
- "src/engine/fader.ts"() {
829
- Fader = class {
830
- target = false;
831
- value = 0;
832
- duration;
833
- constructor(duration = 0.3) {
834
- this.duration = duration;
954
+ function levelToClass(level) {
955
+ if (level === 1) return "division";
956
+ if (level === 2) return "book";
957
+ if (level === 2.5) return "group";
958
+ return "chapter";
959
+ }
960
+ function isClassEnabled(classKey, toggles) {
961
+ if (classKey === "division") return toggles.showDivisionLabels;
962
+ if (classKey === "book") return toggles.showBookLabels;
963
+ if (classKey === "group") return toggles.showGroupLabels;
964
+ return toggles.showChapterLabels;
965
+ }
966
+ function lerp(a, b, t) {
967
+ return a + (b - a) * t;
968
+ }
969
+ function resolveLabelBehavior(config) {
970
+ const classes = { ...DEFAULT_LABEL_BEHAVIOR.classes };
971
+ const mergeClass = (key, source) => {
972
+ const base = DEFAULT_LABEL_BEHAVIOR.classes[key];
973
+ return {
974
+ minFov: source?.minFov ?? base.minFov,
975
+ maxFov: source?.maxFov ?? base.maxFov,
976
+ priority: source?.priority ?? base.priority,
977
+ mode: source?.mode ?? base.mode,
978
+ maxOverlapPx: source?.maxOverlapPx ?? base.maxOverlapPx,
979
+ radialFadeStart: source?.radialFadeStart ?? base.radialFadeStart,
980
+ radialFadeEnd: source?.radialFadeEnd ?? base.radialFadeEnd,
981
+ fadeDuration: source?.fadeDuration ?? base.fadeDuration
982
+ };
983
+ };
984
+ classes.division = mergeClass("division", config?.classes?.division);
985
+ classes.book = mergeClass("book", config?.classes?.book);
986
+ classes.group = mergeClass("group", config?.classes?.group);
987
+ classes.chapter = mergeClass("chapter", config?.classes?.chapter);
988
+ return {
989
+ hideBackFacing: config?.hideBackFacing ?? DEFAULT_LABEL_BEHAVIOR.hideBackFacing,
990
+ overlapPaddingPx: config?.overlapPaddingPx ?? DEFAULT_LABEL_BEHAVIOR.overlapPaddingPx,
991
+ reappearDelayMs: config?.reappearDelayMs ?? DEFAULT_LABEL_BEHAVIOR.reappearDelayMs,
992
+ classes
993
+ };
994
+ }
995
+ function boundsOverlapDepth(a, b) {
996
+ const ix0 = Math.max(a.x, b.x);
997
+ const iy0 = Math.max(a.y, b.y);
998
+ const ix1 = Math.min(a.x + a.w, b.x + b.w);
999
+ const iy1 = Math.min(a.y + a.h, b.y + b.h);
1000
+ if (ix1 <= ix0 || iy1 <= iy0) return 0;
1001
+ return Math.min(ix1 - ix0, iy1 - iy0);
1002
+ }
1003
+ function boundsDistPoint(rect, px, py) {
1004
+ const cx = rect.x + rect.w * 0.5;
1005
+ const cy = rect.y + rect.h * 0.5;
1006
+ const dx = Math.max(Math.abs(px - cx) - rect.w * 0.5, 0);
1007
+ const dy = Math.max(Math.abs(py - cy) - rect.h * 0.5, 0);
1008
+ return Math.sqrt(dx * dx + dy * dy);
1009
+ }
1010
+ function getLabelUniforms(obj) {
1011
+ const material = obj.material;
1012
+ if (!(material instanceof THREE6.ShaderMaterial) || !material.uniforms) return null;
1013
+ const uniforms = material.uniforms;
1014
+ const uSize = uniforms.uSize;
1015
+ const uAlpha = uniforms.uAlpha;
1016
+ const uAngle = uniforms.uAngle;
1017
+ if (!uSize || !(uSize.value instanceof THREE6.Vector2) || !uAlpha || typeof uAlpha.value !== "number") {
1018
+ return null;
1019
+ }
1020
+ return {
1021
+ uSize: { value: uSize.value },
1022
+ uAlpha: { value: uAlpha.value },
1023
+ uAngle: uAngle && typeof uAngle.value === "number" ? { value: uAngle.value } : void 0
1024
+ };
1025
+ }
1026
+ function applyUniformAlpha(obj, alpha, angle) {
1027
+ const material = obj.material;
1028
+ if (!(material instanceof THREE6.ShaderMaterial) || !material.uniforms) return;
1029
+ const uniforms = material.uniforms;
1030
+ if (uniforms.uAlpha && typeof uniforms.uAlpha.value === "number") {
1031
+ uniforms.uAlpha.value = alpha;
1032
+ }
1033
+ }
1034
+ var DEFAULT_LABEL_BEHAVIOR, LabelManager;
1035
+ var init_LabelManager = __esm({
1036
+ "src/engine/LabelManager.ts"() {
1037
+ init_fader();
1038
+ DEFAULT_LABEL_BEHAVIOR = {
1039
+ hideBackFacing: true,
1040
+ overlapPaddingPx: 2,
1041
+ reappearDelayMs: 60,
1042
+ classes: {
1043
+ division: {
1044
+ minFov: 55,
1045
+ maxFov: 180,
1046
+ priority: 70,
1047
+ mode: "floating",
1048
+ maxOverlapPx: 10,
1049
+ radialFadeStart: 1,
1050
+ radialFadeEnd: 1.2,
1051
+ fadeDuration: 0.28
1052
+ },
1053
+ book: {
1054
+ minFov: 0,
1055
+ maxFov: 22,
1056
+ priority: 60,
1057
+ mode: "pinned",
1058
+ maxOverlapPx: 999,
1059
+ radialFadeStart: 1,
1060
+ radialFadeEnd: 1.2,
1061
+ fadeDuration: 0.22
1062
+ },
1063
+ group: {
1064
+ minFov: 0,
1065
+ maxFov: 22,
1066
+ priority: 42,
1067
+ mode: "pinned",
1068
+ maxOverlapPx: 999,
1069
+ radialFadeStart: 1,
1070
+ radialFadeEnd: 1.2,
1071
+ fadeDuration: 0.22
1072
+ },
1073
+ chapter: {
1074
+ minFov: 0,
1075
+ maxFov: 22,
1076
+ priority: 30,
1077
+ mode: "pinned",
1078
+ maxOverlapPx: 999,
1079
+ radialFadeStart: 0.55,
1080
+ radialFadeEnd: 0.95,
1081
+ fadeDuration: 0.16
1082
+ }
835
1083
  }
836
- update(dt) {
837
- const goal = this.target ? 1 : 0;
838
- if (this.value === goal) return;
839
- const speed = 1 / this.duration;
840
- const step = speed * dt;
841
- const diff = goal - this.value;
842
- this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
1084
+ };
1085
+ LabelManager = class {
1086
+ records = /* @__PURE__ */ new Map();
1087
+ setLabels(labels) {
1088
+ const activeIds = /* @__PURE__ */ new Set();
1089
+ for (const label of labels) {
1090
+ activeIds.add(label.node.id);
1091
+ const existing = this.records.get(label.node.id);
1092
+ if (existing) {
1093
+ existing.label = label;
1094
+ existing.classKey = levelToClass(label.node.level);
1095
+ continue;
1096
+ }
1097
+ this.records.set(label.node.id, {
1098
+ id: label.node.id,
1099
+ label,
1100
+ fader: new Fader(0.2),
1101
+ classKey: levelToClass(label.node.level),
1102
+ lastRejectedAtMs: 0,
1103
+ lastAccepted: false,
1104
+ targetAlpha: 0
1105
+ });
1106
+ }
1107
+ for (const [id] of this.records) {
1108
+ if (!activeIds.has(id)) {
1109
+ this.records.delete(id);
1110
+ }
1111
+ }
843
1112
  }
844
- /** Smoothstep-eased value for perceptually smooth transitions */
845
- get eased() {
846
- const v = this.value;
847
- return v * v * (3 - 2 * v);
1113
+ clear() {
1114
+ this.records.clear();
1115
+ }
1116
+ update(ctx) {
1117
+ const behavior = resolveLabelBehavior(ctx.config);
1118
+ const candidates = [];
1119
+ const cameraForward = new THREE6.Vector3(0, 0, -1).applyQuaternion(ctx.camera.quaternion);
1120
+ for (const record of this.records.values()) {
1121
+ const classBehavior = behavior.classes[record.classKey];
1122
+ record.fader.duration = classBehavior.fadeDuration;
1123
+ const isEnabled = isClassEnabled(record.classKey, ctx.toggles);
1124
+ const isSpecial = record.id === ctx.selectedId || record.id === ctx.hoverId || record.id === ctx.focusedId;
1125
+ let targetAlpha = 0;
1126
+ let angleTarget;
1127
+ if (isEnabled) {
1128
+ const maxFov = classBehavior.maxFov + (record.label.maxFovBias ?? 0);
1129
+ const inFovRange = ctx.fov >= classBehavior.minFov && ctx.fov <= maxFov;
1130
+ if (inFovRange || isSpecial) {
1131
+ const pWorld = record.label.obj.position;
1132
+ const pProj = ctx.project(pWorld);
1133
+ const frontVisible = pProj.z <= 0.2;
1134
+ let backFacing = false;
1135
+ if (behavior.hideBackFacing) {
1136
+ const worldDir = pWorld.clone().normalize();
1137
+ backFacing = worldDir.dot(cameraForward) < -0.2;
1138
+ }
1139
+ if (frontVisible && !backFacing) {
1140
+ const ndcX = pProj.x * ctx.globalScale / ctx.aspect;
1141
+ const ndcY = pProj.y * ctx.globalScale;
1142
+ const sX = (ndcX * 0.5 + 0.5) * ctx.screenW;
1143
+ const sY = (-ndcY * 0.5 + 0.5) * ctx.screenH;
1144
+ const uniforms = getLabelUniforms(record.label.obj);
1145
+ if (uniforms) {
1146
+ const pixelH = uniforms.uSize.value.y * ctx.screenH * 0.8;
1147
+ const pixelW = uniforms.uSize.value.x * ctx.screenH * 0.8;
1148
+ targetAlpha = 1;
1149
+ if (targetAlpha > 0 && record.classKey === "chapter" && !isSpecial) {
1150
+ const dist = Math.sqrt(ndcX * ndcX + ndcY * ndcY);
1151
+ const fovWeight = THREE6.MathUtils.smoothstep(ctx.fov, 10, 40);
1152
+ const focusOuter = THREE6.MathUtils.lerp(0.82, 0.62, fovWeight);
1153
+ const focusInner = THREE6.MathUtils.lerp(0.24, 0.16, fovWeight);
1154
+ const centerFocus = 1 - THREE6.MathUtils.smoothstep(dist, focusInner, focusOuter);
1155
+ const chapterVisibility = THREE6.MathUtils.lerp(1, centerFocus, fovWeight);
1156
+ targetAlpha *= chapterVisibility;
1157
+ if (dist > focusOuter && ctx.fov > 12) {
1158
+ targetAlpha = 0;
1159
+ }
1160
+ if (dist < 0.18 && ctx.fov < 58) {
1161
+ targetAlpha = Math.max(targetAlpha, 0.55);
1162
+ }
1163
+ }
1164
+ if (targetAlpha > 0 && (record.classKey === "book" || record.classKey === "group") && !isSpecial) {
1165
+ const dist = Math.sqrt(ndcX * ndcX + ndcY * ndcY);
1166
+ const fovWeight = THREE6.MathUtils.smoothstep(ctx.fov, 15, 58);
1167
+ const focusOuter = THREE6.MathUtils.lerp(0.95, 0.7, fovWeight);
1168
+ const focusInner = THREE6.MathUtils.lerp(0.35, 0.22, fovWeight);
1169
+ const centerFocus = 1 - THREE6.MathUtils.smoothstep(dist, focusInner, focusOuter);
1170
+ const bookVisibility = THREE6.MathUtils.lerp(1, centerFocus, fovWeight);
1171
+ targetAlpha *= bookVisibility;
1172
+ if (dist > focusOuter && ctx.fov > 20) {
1173
+ targetAlpha = 0;
1174
+ }
1175
+ }
1176
+ if (targetAlpha > 0 && ctx.shouldFilter) {
1177
+ const node = record.label.node;
1178
+ if (node.level === 3) {
1179
+ targetAlpha = 0;
1180
+ } else if (node.level === 2 || node.level === 2.5) {
1181
+ if (ctx.isNodeFiltered(node)) targetAlpha = 0;
1182
+ }
1183
+ }
1184
+ if (targetAlpha > 0 && record.classKey === "chapter" && record.label.chapterStarWorldPos) {
1185
+ const starProj = ctx.project(record.label.chapterStarWorldPos);
1186
+ if (starProj.z <= 0.2) {
1187
+ const starNdcX = starProj.x * ctx.globalScale / ctx.aspect;
1188
+ const starNdcY = starProj.y * ctx.globalScale;
1189
+ const starSX = (starNdcX * 0.5 + 0.5) * ctx.screenW;
1190
+ const starSY = (-starNdcY * 0.5 + 0.5) * ctx.screenH;
1191
+ const rect = {
1192
+ x: sX - pixelW / 2,
1193
+ y: sY - pixelH / 2,
1194
+ w: pixelW,
1195
+ h: pixelH,
1196
+ priority: classBehavior.priority
1197
+ };
1198
+ const glowRadiusPx = record.label.chapterGlowRadiusPx ?? 18;
1199
+ const clearancePx = Math.max(1, glowRadiusPx * 0.02);
1200
+ const distToLabel = boundsDistPoint(rect, starSX, starSY);
1201
+ if (glowRadiusPx >= 70) {
1202
+ const threshold = glowRadiusPx + clearancePx;
1203
+ const visibility = THREE6.MathUtils.smoothstep(distToLabel, threshold - 4, threshold + 2);
1204
+ const lowFovRelief = 1 - THREE6.MathUtils.smoothstep(ctx.fov, 8, 18);
1205
+ const boostedVisibility = isSpecial ? Math.max(visibility, 0.92) : Math.max(visibility, lowFovRelief * 0.85);
1206
+ targetAlpha *= boostedVisibility;
1207
+ }
1208
+ }
1209
+ }
1210
+ if (record.classKey === "division" && uniforms.uAngle) {
1211
+ angleTarget = 0;
1212
+ if (ctx.projectionId !== "perspective") {
1213
+ const dx = sX - ctx.screenW / 2;
1214
+ const dy = sY - ctx.screenH / 2;
1215
+ angleTarget = Math.atan2(-dy, -dx) - Math.PI / 2;
1216
+ }
1217
+ }
1218
+ if (targetAlpha > 0) {
1219
+ const priorityBoost = isSpecial ? record.id === ctx.selectedId ? 400 : 300 : 0;
1220
+ candidates.push({
1221
+ record,
1222
+ behavior: classBehavior,
1223
+ uniforms,
1224
+ sX,
1225
+ sY,
1226
+ w: pixelW,
1227
+ h: pixelH,
1228
+ ndcX,
1229
+ ndcY,
1230
+ priority: classBehavior.priority + priorityBoost,
1231
+ isPinned: isSpecial || classBehavior.mode === "pinned",
1232
+ isSpecial,
1233
+ centerDist: Math.sqrt((sX - ctx.screenW * 0.5) ** 2 + (sY - ctx.screenH * 0.5) ** 2)
1234
+ });
1235
+ }
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ if (typeof angleTarget === "number") {
1241
+ const material = record.label.obj.material;
1242
+ if (material instanceof THREE6.ShaderMaterial && material.uniforms.uAngle && typeof material.uniforms.uAngle.value === "number") {
1243
+ const current = material.uniforms.uAngle.value;
1244
+ material.uniforms.uAngle.value = lerp(current, angleTarget, 0.1);
1245
+ }
1246
+ }
1247
+ record.targetAlpha = targetAlpha;
1248
+ }
1249
+ candidates.sort((a, b) => {
1250
+ if (a.priority !== b.priority) return b.priority - a.priority;
1251
+ if (a.record.lastAccepted !== b.record.lastAccepted) return a.record.lastAccepted ? -1 : 1;
1252
+ return a.centerDist - b.centerDist;
1253
+ });
1254
+ let guaranteedCenterChapterId = null;
1255
+ if (ctx.toggles.showChapterLabels && ctx.fov <= behavior.classes.chapter.maxFov + 15) {
1256
+ const centerChapter = candidates.filter((c) => c.record.classKey === "chapter" && c.record.targetAlpha > 0).sort((a, b) => a.centerDist - b.centerDist)[0];
1257
+ if (centerChapter) {
1258
+ guaranteedCenterChapterId = centerChapter.record.id;
1259
+ centerChapter.record.targetAlpha = Math.max(centerChapter.record.targetAlpha, 0.85);
1260
+ }
1261
+ }
1262
+ const occupied = [];
1263
+ const accepted = /* @__PURE__ */ new Set();
1264
+ for (const c of candidates) {
1265
+ if (c.record.targetAlpha <= 0) continue;
1266
+ const rect = {
1267
+ x: c.sX - c.w / 2 - behavior.overlapPaddingPx,
1268
+ y: c.sY - c.h / 2 - behavior.overlapPaddingPx,
1269
+ w: c.w + behavior.overlapPaddingPx * 2,
1270
+ h: c.h + behavior.overlapPaddingPx * 2,
1271
+ priority: c.priority
1272
+ };
1273
+ let rejected = false;
1274
+ const isGuaranteedCenterChapter = c.record.id === guaranteedCenterChapterId;
1275
+ if (!c.isPinned && !isGuaranteedCenterChapter) {
1276
+ if (!c.record.lastAccepted && !c.isSpecial && c.record.lastRejectedAtMs > 0) {
1277
+ const sinceReject = ctx.nowMs - c.record.lastRejectedAtMs;
1278
+ if (sinceReject < behavior.reappearDelayMs) {
1279
+ rejected = true;
1280
+ }
1281
+ }
1282
+ if (!rejected) {
1283
+ for (const other of occupied) {
1284
+ if (other.priority < c.priority) continue;
1285
+ const overlapDepth = boundsOverlapDepth(rect, other);
1286
+ if (overlapDepth > c.behavior.maxOverlapPx) {
1287
+ rejected = true;
1288
+ break;
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+ if (rejected) {
1294
+ c.record.lastAccepted = false;
1295
+ c.record.lastRejectedAtMs = ctx.nowMs;
1296
+ continue;
1297
+ }
1298
+ occupied.push(rect);
1299
+ accepted.add(c.record.id);
1300
+ c.record.lastAccepted = true;
1301
+ }
1302
+ for (const record of this.records.values()) {
1303
+ const acceptedThisFrame = accepted.has(record.id) && record.targetAlpha > 0;
1304
+ record.fader.target = acceptedThisFrame;
1305
+ record.fader.update(ctx.dt);
1306
+ const baseAlpha = acceptedThisFrame ? record.targetAlpha : record.targetAlpha > 0 ? 1 : 0;
1307
+ const alpha = record.fader.eased * baseAlpha;
1308
+ applyUniformAlpha(record.label.obj, alpha);
1309
+ record.label.obj.visible = alpha > 0.01;
1310
+ if (!acceptedThisFrame) {
1311
+ record.lastAccepted = false;
1312
+ }
1313
+ }
848
1314
  }
849
1315
  };
850
1316
  }
@@ -871,6 +1337,7 @@ function createEngine({
871
1337
  }) {
872
1338
  let hoveredBookId = null;
873
1339
  let focusedBookId = null;
1340
+ let focusedNodeId = null;
874
1341
  let orderRevealEnabled = true;
875
1342
  let activeBookIndex = -1;
876
1343
  let orderRevealStrength = 0;
@@ -889,26 +1356,15 @@ function createEngine({
889
1356
  const bookIdToIndex = /* @__PURE__ */ new Map();
890
1357
  const testamentToIndex = /* @__PURE__ */ new Map();
891
1358
  const divisionToIndex = /* @__PURE__ */ new Map();
892
- const renderer = new THREE5.WebGLRenderer({ antialias: true, alpha: false });
1359
+ const renderer = new THREE6.WebGLRenderer({ antialias: true, alpha: false });
893
1360
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
894
1361
  renderer.setSize(container.clientWidth, container.clientHeight);
895
1362
  container.appendChild(renderer.domElement);
896
- const scene = new THREE5.Scene();
897
- scene.background = new THREE5.Color(0);
898
- const camera = new THREE5.PerspectiveCamera(60, 1, 0.1, 1e4);
1363
+ const scene = new THREE6.Scene();
1364
+ scene.background = null;
1365
+ const camera = new THREE6.PerspectiveCamera(60, 1, 0.1, 1e4);
899
1366
  camera.position.set(0, 0, 0);
900
1367
  camera.up.set(0, 1, 0);
901
- function setHoveredBook(id) {
902
- if (id === hoveredBookId) return;
903
- const now = performance.now();
904
- if (hoveredBookId) {
905
- hoverCooldowns.set(hoveredBookId, now);
906
- }
907
- if (id) {
908
- hoverCooldowns.get(id) || 0;
909
- }
910
- hoveredBookId = id;
911
- }
912
1368
  let running = false;
913
1369
  let raf = 0;
914
1370
  const state = {
@@ -946,12 +1402,15 @@ function createEngine({
946
1402
  longPressTimer: null,
947
1403
  longPressTriggered: false
948
1404
  };
949
- const mouseNDC = new THREE5.Vector2();
1405
+ const mouseNDC = new THREE6.Vector2();
950
1406
  let isMouseInWindow = false;
951
1407
  let isTouchDevice = false;
952
1408
  let edgeHoverStart = 0;
953
1409
  let handlers = { onSelect, onHover, onArrangementChange, onFovChange, onLongPress };
954
1410
  let currentConfig;
1411
+ function getSceneDebug() {
1412
+ return currentConfig?.debug?.sceneMechanics;
1413
+ }
955
1414
  const constellationLayer = new ConstellationArtworkLayer(scene);
956
1415
  function mix(a, b, t) {
957
1416
  return a * (1 - t) + b * t;
@@ -960,6 +1419,7 @@ function createEngine({
960
1419
  function syncProjectionState() {
961
1420
  if (currentProjection instanceof BlendedProjection) {
962
1421
  currentProjection.setFov(state.fov);
1422
+ currentProjection.setBlendOverride(getSceneDebug()?.projectionBlendOverride ?? null);
963
1423
  globalUniforms.uBlend.value = currentProjection.getBlend();
964
1424
  }
965
1425
  globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
@@ -970,10 +1430,10 @@ function createEngine({
970
1430
  let scale = currentProjection.getScale(fovRad);
971
1431
  const aspect = camera.aspect;
972
1432
  if (currentConfig?.fitProjection) {
973
- if (aspect > 1) {
1433
+ if (aspect >= 1) {
974
1434
  scale /= aspect;
975
1435
  } else {
976
- scale *= aspect;
1436
+ scale *= aspect * aspect;
977
1437
  }
978
1438
  }
979
1439
  globalUniforms.uScale.value = scale;
@@ -987,7 +1447,7 @@ function createEngine({
987
1447
  const uvX = mouseNDC.x * aspectRatio;
988
1448
  const uvY = mouseNDC.y;
989
1449
  const v = currentProjection.inverse(uvX, uvY, fovRad);
990
- return new THREE5.Vector3(v.x, v.y, v.z).normalize();
1450
+ return new THREE6.Vector3(v.x, v.y, v.z).normalize();
991
1451
  }
992
1452
  function getMouseWorldVector(pixelX, pixelY, width, height) {
993
1453
  const aspect = width / height;
@@ -996,7 +1456,7 @@ function createEngine({
996
1456
  syncProjectionState();
997
1457
  const fovRad = state.fov * Math.PI / 180;
998
1458
  const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
999
- const vView = new THREE5.Vector3(v.x, v.y, v.z).normalize();
1459
+ const vView = new THREE6.Vector3(v.x, v.y, v.z).normalize();
1000
1460
  return vView.applyQuaternion(camera.quaternion);
1001
1461
  }
1002
1462
  function smartProjectJS(worldPos) {
@@ -1006,80 +1466,438 @@ function createEngine({
1006
1466
  if (!result) return { x: 0, y: 0, z: dir.z };
1007
1467
  return result;
1008
1468
  }
1009
- const groundGroup = new THREE5.Group();
1469
+ const groundGroup = new THREE6.Group();
1010
1470
  scene.add(groundGroup);
1471
+ const MAX_HORIZON_POINTS = 64;
1472
+ let groundMaterial = null;
1473
+ let horizonLine = null;
1474
+ let activeHorizonProfile = {
1475
+ mode: 0,
1476
+ pointCount: 0,
1477
+ azDeg: [],
1478
+ altDeg: [],
1479
+ rotateRad: 0,
1480
+ baseAltDeg: 3
1481
+ };
1482
+ let lastHorizonDiagTs = 0;
1483
+ function toColor(input, fallbackHex) {
1484
+ if (!input) return new THREE6.Color(fallbackHex);
1485
+ try {
1486
+ return new THREE6.Color(input);
1487
+ } catch {
1488
+ return new THREE6.Color(fallbackHex);
1489
+ }
1490
+ }
1491
+ function applyGroundTheme(cfg) {
1492
+ if (!groundMaterial) return;
1493
+ const theme = getSceneDebug()?.disableHorizonTheme ? void 0 : cfg?.horizonTheme;
1494
+ const uniforms = groundMaterial.uniforms;
1495
+ const atmo = theme?.atmosphere;
1496
+ const mode = theme?.source === "polygonal" && (theme.profile?.points?.length ?? 0) >= 2 ? 1 : 0;
1497
+ const groundColor = toColor(theme?.groundColor, 65794);
1498
+ const fogColor = toColor(theme?.horizonLineColor, 663098);
1499
+ const fogIntensity = THREE6.MathUtils.clamp(atmo?.fogIntensity ?? 0.6, 0, 1.5);
1500
+ const fogVisible = atmo?.fogVisible === false ? 0 : 1;
1501
+ const minBrightness = THREE6.MathUtils.clamp(atmo?.minimalBrightness ?? 0, 0, 1);
1502
+ const rotateRad = (theme?.profile?.angleRotateZDeg ?? 0) * Math.PI / 180;
1503
+ const azSamples = new Array(MAX_HORIZON_POINTS).fill(0);
1504
+ const altSamples = new Array(MAX_HORIZON_POINTS).fill(0);
1505
+ let pointCount = 0;
1506
+ let sortedPoints = [];
1507
+ if (mode === 1 && theme?.profile?.points) {
1508
+ sortedPoints = [...theme.profile.points].map((p) => ({
1509
+ azDeg: (p.azDeg % 360 + 360) % 360,
1510
+ altDeg: THREE6.MathUtils.clamp(p.altDeg, -30, 35)
1511
+ })).sort((a, b) => a.azDeg - b.azDeg);
1512
+ pointCount = Math.min(sortedPoints.length, MAX_HORIZON_POINTS);
1513
+ for (let i = 0; i < pointCount; i++) {
1514
+ azSamples[i] = sortedPoints[i].azDeg;
1515
+ altSamples[i] = sortedPoints[i].altDeg;
1516
+ }
1517
+ }
1518
+ const baseAltDeg = pointCount > 0 ? altSamples.slice(0, pointCount).reduce((sum, v) => sum + v, 0) / pointCount : 3;
1519
+ activeHorizonProfile = {
1520
+ mode,
1521
+ pointCount,
1522
+ azDeg: azSamples.slice(0, pointCount),
1523
+ altDeg: altSamples.slice(0, pointCount),
1524
+ rotateRad,
1525
+ baseAltDeg
1526
+ };
1527
+ uniforms.color.value = groundColor;
1528
+ uniforms.fogColor.value = fogColor;
1529
+ uniforms.uFogIntensity.value = fogIntensity;
1530
+ uniforms.uFogVisible.value = fogVisible;
1531
+ uniforms.uMinBrightness.value = minBrightness;
1532
+ uniforms.uHorizonMode.value = mode;
1533
+ uniforms.uHorizonPointCount.value = pointCount;
1534
+ uniforms.uHorizonAzDeg.value = azSamples;
1535
+ uniforms.uHorizonAltDeg.value = altSamples;
1536
+ uniforms.uHorizonRotateRad.value = rotateRad;
1537
+ uniforms.uBaseAltDeg.value = baseAltDeg;
1538
+ groundMaterial.uniformsNeedUpdate = true;
1539
+ if (atmosphereMesh && atmosphereMesh.material instanceof THREE6.ShaderMaterial) {
1540
+ const atmUniforms = atmosphereMesh.material.uniforms;
1541
+ const topAltDeg = atmo?.fogBandTopAltDeg ?? 90;
1542
+ const bottomAltDeg = atmo?.fogBandBottomAltDeg ?? -90;
1543
+ atmUniforms.uThemeFogVisible.value = fogVisible;
1544
+ atmUniforms.uThemeFogIntensity.value = fogIntensity;
1545
+ atmUniforms.uThemeFogTopSin.value = Math.sin(THREE6.MathUtils.degToRad(topAltDeg));
1546
+ atmUniforms.uThemeFogBottomSin.value = Math.sin(THREE6.MathUtils.degToRad(bottomAltDeg));
1547
+ atmUniforms.uThemeMinBrightness.value = minBrightness;
1548
+ atmosphereMesh.material.uniformsNeedUpdate = true;
1549
+ }
1550
+ if (horizonLine) {
1551
+ groundGroup.remove(horizonLine);
1552
+ horizonLine.geometry.dispose();
1553
+ horizonLine.material.dispose();
1554
+ horizonLine = null;
1555
+ }
1556
+ const lineThickness = THREE6.MathUtils.clamp(theme?.horizonLineThickness ?? 0, 0, 8);
1557
+ const shouldDrawLine = mode === 1 && pointCount >= 2 && lineThickness > 0;
1558
+ if (!shouldDrawLine) return;
1559
+ const lineColor = toColor(theme?.horizonLineColor, 5601177);
1560
+ const lineRadius = 997;
1561
+ const pts = [];
1562
+ for (let i = 0; i < pointCount; i++) {
1563
+ const sample = sortedPoints[i];
1564
+ const angleDeg = sample.azDeg - (theme?.profile?.angleRotateZDeg ?? 0);
1565
+ const a = THREE6.MathUtils.degToRad(angleDeg);
1566
+ const alt = THREE6.MathUtils.degToRad(sample.altDeg);
1567
+ const rc = Math.cos(alt);
1568
+ pts.push(new THREE6.Vector3(
1569
+ lineRadius * rc * Math.cos(a),
1570
+ lineRadius * Math.sin(alt),
1571
+ lineRadius * rc * Math.sin(a)
1572
+ ));
1573
+ }
1574
+ if (pts.length > 0) pts.push(pts[0].clone());
1575
+ const geo = new THREE6.BufferGeometry().setFromPoints(pts);
1576
+ const mat = createSmartMaterial({
1577
+ uniforms: {
1578
+ color: { value: lineColor },
1579
+ alpha: { value: 0.95 }
1580
+ },
1581
+ vertexShaderBody: `
1582
+ uniform vec3 color;
1583
+ varying vec3 vColor;
1584
+ void main() {
1585
+ vColor = color;
1586
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1587
+ gl_Position = smartProject(mvPosition);
1588
+ vScreenPos = gl_Position.xy / gl_Position.w;
1589
+ }
1590
+ `,
1591
+ fragmentShader: `
1592
+ uniform float alpha;
1593
+ varying vec3 vColor;
1594
+ void main() {
1595
+ float alphaMask = getMaskAlpha();
1596
+ if (alphaMask < 0.01) discard;
1597
+ gl_FragColor = vec4(vColor, alpha * alphaMask);
1598
+ }
1599
+ `,
1600
+ transparent: true,
1601
+ depthWrite: false,
1602
+ depthTest: true
1603
+ });
1604
+ const line = new THREE6.Line(geo, mat);
1605
+ line.material.linewidth = lineThickness;
1606
+ line.frustumCulled = false;
1607
+ line.renderOrder = 3;
1608
+ horizonLine = line;
1609
+ groundGroup.add(line);
1610
+ }
1611
+ function sampleActiveHorizonAltDeg(azDeg) {
1612
+ const profile = activeHorizonProfile;
1613
+ if (profile.mode !== 1 || profile.pointCount < 2) return profile.baseAltDeg;
1614
+ const query = ((azDeg + THREE6.MathUtils.radToDeg(profile.rotateRad)) % 360 + 360) % 360;
1615
+ const n = profile.pointCount;
1616
+ const firstAz = profile.azDeg[0];
1617
+ const firstAlt = profile.altDeg[0];
1618
+ for (let i = 1; i < n; i++) {
1619
+ const prevAz2 = profile.azDeg[i - 1];
1620
+ const prevAlt2 = profile.altDeg[i - 1];
1621
+ const curAz = profile.azDeg[i];
1622
+ const curAlt = profile.altDeg[i];
1623
+ if (query >= prevAz2 && query <= curAz) {
1624
+ const t2 = (query - prevAz2) / Math.max(1e-4, curAz - prevAz2);
1625
+ return mix(prevAlt2, curAlt, t2);
1626
+ }
1627
+ }
1628
+ const prevAz = profile.azDeg[n - 1];
1629
+ const prevAlt = profile.altDeg[n - 1];
1630
+ const wrappedQuery = query < firstAz ? query + 360 : query;
1631
+ const t = (wrappedQuery - prevAz) / Math.max(1e-4, firstAz + 360 - prevAz);
1632
+ return mix(prevAlt, firstAlt, t);
1633
+ }
1634
+ function runHorizonDiagnostics(nowMs) {
1635
+ if (nowMs - lastHorizonDiagTs < 1200) return;
1636
+ lastHorizonDiagTs = nowMs;
1637
+ const points = [];
1638
+ const r = 997;
1639
+ const scale = globalUniforms.uScale.value;
1640
+ const aspect = Math.max(1e-4, globalUniforms.uAspect.value);
1641
+ for (let az = 0; az < 360; az += 2) {
1642
+ const altDeg = sampleActiveHorizonAltDeg(az);
1643
+ const azRad = THREE6.MathUtils.degToRad(az);
1644
+ const altRad = THREE6.MathUtils.degToRad(altDeg);
1645
+ const rc = Math.cos(altRad);
1646
+ const worldPos = new THREE6.Vector3(
1647
+ r * rc * Math.cos(azRad),
1648
+ r * Math.sin(altRad),
1649
+ r * rc * Math.sin(azRad)
1650
+ );
1651
+ const p = smartProjectJS(worldPos);
1652
+ if (currentProjection.isClipped(p.z)) continue;
1653
+ const x = p.x * scale / aspect;
1654
+ const y = p.y * scale;
1655
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
1656
+ if (Math.abs(x) > 1) continue;
1657
+ points.push({ x, y });
1658
+ }
1659
+ if (points.length < 16) {
1660
+ console.debug(`[HorizonDiag] insufficient visible horizon samples at fov=${state.fov.toFixed(1)}`);
1661
+ return;
1662
+ }
1663
+ const binCount = 12;
1664
+ const maxY = new Array(binCount).fill(-Infinity);
1665
+ for (const p of points) {
1666
+ const ax = Math.min(0.999, Math.abs(p.x));
1667
+ const idx = Math.floor(ax * binCount);
1668
+ maxY[idx] = Math.max(maxY[idx], p.y);
1669
+ }
1670
+ const compact = maxY.map((v) => Number.isFinite(v) ? Number(v.toFixed(3)) : null);
1671
+ let dropCount = 0;
1672
+ for (let i = 1; i < binCount; i++) {
1673
+ const prev = maxY[i - 1];
1674
+ const cur = maxY[i];
1675
+ if (!Number.isFinite(prev) || !Number.isFinite(cur)) continue;
1676
+ if (cur < prev - 0.02) dropCount++;
1677
+ }
1678
+ const flatten = groundMaterial?.uniforms?.uZenithFlatten?.value;
1679
+ const blend = currentProjection instanceof BlendedProjection ? currentProjection.getBlend() : -1;
1680
+ console.debug(
1681
+ `[HorizonDiag] fov=${state.fov.toFixed(1)} latDeg=${THREE6.MathUtils.radToDeg(state.lat).toFixed(1)} mode=${activeHorizonProfile.mode} blend=${blend.toFixed(3)} flatten=${Number(flatten ?? 0).toFixed(3)} drops=${dropCount} bins=${JSON.stringify(compact)}`
1682
+ );
1683
+ }
1011
1684
  function createGround() {
1012
1685
  groundGroup.clear();
1013
1686
  const radius = 995;
1014
- const geometry = new THREE5.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
1687
+ const geometry = new THREE6.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
1015
1688
  const material = createSmartMaterial({
1016
1689
  uniforms: {
1017
- color: { value: new THREE5.Color(65794) },
1018
- fogColor: { value: new THREE5.Color(663098) }
1690
+ color: { value: new THREE6.Color(65794) },
1691
+ fogColor: { value: new THREE6.Color(663098) },
1692
+ uFogIntensity: { value: 0.6 },
1693
+ uFogVisible: { value: 1 },
1694
+ uMinBrightness: { value: 0 },
1695
+ uHorizonMode: { value: 0 },
1696
+ uHorizonPointCount: { value: 0 },
1697
+ uHorizonAzDeg: { value: new Array(MAX_HORIZON_POINTS).fill(0) },
1698
+ uHorizonAltDeg: { value: new Array(MAX_HORIZON_POINTS).fill(0) },
1699
+ uHorizonRotateRad: { value: 0 },
1700
+ uHorizonRadius: { value: radius },
1701
+ uBaseAltDeg: { value: 3 },
1702
+ uZenithFlatten: { value: 0 }
1019
1703
  },
1020
1704
  vertexShaderBody: `
1021
1705
  varying vec3 vPos;
1022
1706
  varying vec3 vWorldPos;
1707
+ varying float vViewDirZ;
1023
1708
  void main() {
1024
1709
  vPos = position;
1025
1710
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1026
1711
  gl_Position = smartProject(mvPosition);
1027
1712
  vScreenPos = gl_Position.xy / gl_Position.w;
1028
1713
  vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
1714
+ vViewDirZ = normalize(mvPosition.xyz).z;
1029
1715
  }
1030
1716
  `,
1031
1717
  fragmentShader: `
1032
1718
  uniform vec3 color;
1033
1719
  uniform vec3 fogColor;
1720
+ uniform float uFogIntensity;
1721
+ uniform float uFogVisible;
1722
+ uniform float uMinBrightness;
1723
+ uniform int uHorizonMode;
1724
+ uniform int uHorizonPointCount;
1725
+ uniform float uHorizonAzDeg[64];
1726
+ uniform float uHorizonAltDeg[64];
1727
+ uniform float uHorizonRotateRad;
1728
+ uniform float uHorizonRadius;
1729
+ uniform float uBaseAltDeg;
1730
+ uniform float uZenithFlatten;
1034
1731
  varying vec3 vPos;
1035
1732
  varying vec3 vWorldPos;
1733
+ varying float vViewDirZ;
1734
+
1735
+ float samplePolygonalAltDeg(float azDeg) {
1736
+ if (uHorizonPointCount < 2) return 0.0;
1737
+ float z = mod(azDeg, 360.0);
1738
+ if (z < 0.0) z += 360.0;
1739
+
1740
+ float prevAz = uHorizonAzDeg[0];
1741
+ float prevAlt = uHorizonAltDeg[0];
1742
+ for (int i = 1; i < 64; i++) {
1743
+ if (i >= uHorizonPointCount) break;
1744
+ float curAz = uHorizonAzDeg[i];
1745
+ float curAlt = uHorizonAltDeg[i];
1746
+ if (z >= prevAz && z <= curAz) {
1747
+ float t = (z - prevAz) / max(0.0001, curAz - prevAz);
1748
+ return mix(prevAlt, curAlt, t);
1749
+ }
1750
+ prevAz = curAz;
1751
+ prevAlt = curAlt;
1752
+ }
1753
+
1754
+ float firstAz = uHorizonAzDeg[0] + 360.0;
1755
+ float firstAlt = uHorizonAltDeg[0];
1756
+ float zw = z;
1757
+ if (zw < uHorizonAzDeg[0]) zw += 360.0;
1758
+ float t = (zw - prevAz) / max(0.0001, firstAz - prevAz);
1759
+ return mix(prevAlt, firstAlt, t);
1760
+ }
1036
1761
 
1037
1762
  void main() {
1038
1763
  float alphaMask = getMaskAlpha();
1039
1764
  if (alphaMask < 0.01) discard;
1765
+
1766
+ // Keep ground visibility aligned with the active projection clip.
1767
+ float clipZ = -0.1;
1768
+ if (uProjectionType == 1) {
1769
+ clipZ = 0.1;
1770
+ } else if (uProjectionType == 2) {
1771
+ clipZ = mix(-0.1, 0.1, clamp(uBlend, 0.0, 1.0));
1772
+ }
1773
+ if (vViewDirZ > clipZ) discard;
1040
1774
 
1041
- // Procedural Horizon (Mountains)
1042
1775
  float angle = atan(vPos.z, vPos.x);
1043
-
1044
- // FBM-like terrain with increased amplitude
1045
- float h = 0.0;
1046
- h += sin(angle * 6.0) * 35.0;
1047
- h += sin(angle * 13.0 + 1.0) * 18.0;
1048
- h += sin(angle * 29.0 + 2.0) * 8.0;
1049
- h += sin(angle * 63.0 + 4.0) * 3.0;
1050
- h += sin(angle * 97.0 + 5.0) * 1.5;
1776
+ float terrainHeight;
1051
1777
 
1052
- float terrainHeight = h + 12.0;
1778
+ if (uHorizonMode == 1 && uHorizonPointCount >= 2) {
1779
+ float azDeg = mod(degrees(angle) + 360.0 + degrees(uHorizonRotateRad), 360.0);
1780
+ float altDeg = samplePolygonalAltDeg(azDeg);
1781
+ terrainHeight = uHorizonRadius * sin(radians(altDeg));
1782
+ } else {
1783
+ // Procedural Horizon (Mountains)
1784
+ float h = 0.0;
1785
+ h += sin(angle * 6.0) * 35.0;
1786
+ h += sin(angle * 13.0 + 1.0) * 18.0;
1787
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1788
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1789
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1790
+ terrainHeight = h + 12.0;
1791
+ }
1792
+ float circularHeight = uHorizonRadius * sin(radians(uBaseAltDeg));
1793
+ terrainHeight = mix(terrainHeight, circularHeight, clamp(uZenithFlatten, 0.0, 1.0));
1053
1794
 
1054
1795
  if (vPos.y > terrainHeight) discard;
1055
1796
 
1056
1797
  // Atmospheric rim glow just below terrain peaks
1057
1798
  float rimDist = terrainHeight - vPos.y;
1058
- float rim = exp(-rimDist * 0.15) * 0.4;
1799
+ float rim = exp(-rimDist * 0.15) * 0.4 * uFogVisible;
1059
1800
  vec3 rimColor = fogColor * 1.5;
1060
1801
 
1061
1802
  // Atmospheric haze \u2014 stronger near horizon
1062
1803
  float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1063
- vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1804
+ vec3 finalCol = mix(color, fogColor, fogFactor * uFogIntensity * uFogVisible);
1064
1805
 
1065
1806
  // Add rim glow near terrain peaks
1066
1807
  finalCol += rimColor * rim;
1808
+ finalCol = max(finalCol, color * uMinBrightness);
1067
1809
 
1068
1810
  gl_FragColor = vec4(finalCol, 1.0);
1069
1811
  }
1070
1812
  `,
1071
- side: THREE5.BackSide,
1813
+ side: THREE6.BackSide,
1072
1814
  transparent: false,
1073
1815
  depthWrite: true,
1074
1816
  depthTest: true
1075
1817
  });
1076
- const ground = new THREE5.Mesh(geometry, material);
1818
+ groundMaterial = material;
1819
+ const ground = new THREE6.Mesh(geometry, material);
1077
1820
  groundGroup.add(ground);
1821
+ applyGroundTheme(currentConfig);
1078
1822
  }
1823
+ let skyBackgroundMesh = null;
1079
1824
  let atmosphereMesh = null;
1825
+ let moonMesh = null;
1826
+ let moonGlowMesh = null;
1827
+ let sunDiscMesh = null;
1828
+ let sunHaloMesh = null;
1829
+ let milkyWayMesh = null;
1830
+ let editHoverMesh = null;
1831
+ let editHoverTargetPos = null;
1832
+ let editDropFlash = 0;
1833
+ function createSkyBackground() {
1834
+ const geo = new THREE6.SphereGeometry(2400, 32, 32);
1835
+ const mat = createSmartMaterial({
1836
+ uniforms: {},
1837
+ vertexShaderBody: `
1838
+ varying vec3 vWorldNormal;
1839
+ void main() {
1840
+ vWorldNormal = normalize(position);
1841
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
1842
+ gl_Position = smartProject(mv);
1843
+ vScreenPos = gl_Position.xy / gl_Position.w;
1844
+ }
1845
+ `,
1846
+ fragmentShader: `
1847
+ varying vec3 vWorldNormal;
1848
+ void main() {
1849
+ float h = clamp(normalize(vWorldNormal).y, -1.0, 1.0);
1850
+
1851
+ // Scotopic-inspired 5-stop gradient.
1852
+ // Night sky: blue channel ~2.6x red, derived from CIE (x=0.25, y=0.25).
1853
+ vec3 cZenith = vec3(0.010, 0.022, 0.055);
1854
+ vec3 cUpper = vec3(0.015, 0.033, 0.080);
1855
+ vec3 cMid = vec3(0.022, 0.048, 0.108);
1856
+ vec3 cLower = vec3(0.035, 0.072, 0.148);
1857
+ vec3 cHorizon = vec3(0.052, 0.100, 0.190);
1858
+
1859
+ float t1 = smoothstep(0.0, 0.30, h);
1860
+ float t2 = smoothstep(0.3, 0.60, h);
1861
+ float t3 = smoothstep(0.6, 0.85, h);
1862
+ float t4 = smoothstep(0.85, 1.00, h);
1863
+
1864
+ vec3 col = cHorizon;
1865
+ col = mix(col, cLower, t1);
1866
+ col = mix(col, cMid, t2);
1867
+ col = mix(col, cUpper, t3);
1868
+ col = mix(col, cZenith, t4);
1869
+
1870
+ // Rayleigh limb brightening at horizon
1871
+ float limb = exp(-18.0 * abs(h)) * smoothstep(-0.05, 0.06, h);
1872
+ col += vec3(0.012, 0.024, 0.050) * limb;
1873
+
1874
+ // Below ground: fade to near-black
1875
+ float below = smoothstep(-0.04, -0.18, h);
1876
+ col = mix(col, vec3(0.002, 0.003, 0.006), below);
1877
+
1878
+ gl_FragColor = vec4(col, 1.0);
1879
+ }
1880
+ `,
1881
+ transparent: false,
1882
+ depthWrite: false,
1883
+ depthTest: false,
1884
+ side: THREE6.BackSide
1885
+ });
1886
+ skyBackgroundMesh = new THREE6.Mesh(geo, mat);
1887
+ skyBackgroundMesh.renderOrder = -2;
1888
+ skyBackgroundMesh.frustumCulled = false;
1889
+ scene.add(skyBackgroundMesh);
1890
+ }
1080
1891
  function createAtmosphere() {
1081
- const geometry = new THREE5.SphereGeometry(990, 64, 64);
1892
+ const geometry = new THREE6.SphereGeometry(990, 64, 64);
1082
1893
  const material = createSmartMaterial({
1894
+ uniforms: {
1895
+ uThemeFogVisible: { value: 1 },
1896
+ uThemeFogTopSin: { value: 0.95 },
1897
+ uThemeFogBottomSin: { value: -1 },
1898
+ uThemeFogIntensity: { value: 1 },
1899
+ uThemeMinBrightness: { value: 0 }
1900
+ },
1083
1901
  vertexShaderBody: `
1084
1902
  varying vec3 vWorldNormal;
1085
1903
  void main() {
@@ -1095,6 +1913,11 @@ function createEngine({
1095
1913
  uniform float uAtmDark;
1096
1914
  uniform vec3 uColorHorizon;
1097
1915
  uniform vec3 uColorZenith;
1916
+ uniform float uThemeFogVisible;
1917
+ uniform float uThemeFogTopSin;
1918
+ uniform float uThemeFogBottomSin;
1919
+ uniform float uThemeFogIntensity;
1920
+ uniform float uThemeMinBrightness;
1098
1921
 
1099
1922
  void main() {
1100
1923
  float alphaMask = getMaskAlpha();
@@ -1108,6 +1931,10 @@ function createEngine({
1108
1931
 
1109
1932
  // Non-linear mix for realistic sky falloff
1110
1933
  vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
1934
+ float hazeBand = smoothstep(uThemeFogBottomSin, uThemeFogTopSin, h);
1935
+ float hazeFadeEnd = max(uThemeFogTopSin + 0.001, min(1.0, uThemeFogTopSin + 0.25));
1936
+ hazeBand *= (1.0 - smoothstep(uThemeFogTopSin, hazeFadeEnd, h));
1937
+ float fogTheme = uThemeFogVisible * uThemeFogIntensity;
1111
1938
 
1112
1939
  // 2. Teal tint at mid-altitudes (subtle colour variation)
1113
1940
  float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
@@ -1115,26 +1942,393 @@ function createEngine({
1115
1942
 
1116
1943
  // 3. Primary horizon glow band (wider than before)
1117
1944
  float horizonBand = exp(-10.0 * abs(h - 0.02));
1118
- skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
1945
+ skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow * fogTheme * max(0.15, hazeBand);
1119
1946
 
1120
1947
  // 4. Warm secondary glow (light pollution / sodium scatter)
1121
1948
  float warmGlow = exp(-8.0 * abs(h));
1122
- skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1949
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow * fogTheme * max(0.15, hazeBand);
1950
+ skyColor = max(skyColor, uColorZenith * (0.2 * uThemeMinBrightness));
1123
1951
 
1124
1952
  gl_FragColor = vec4(skyColor, 1.0);
1125
1953
  }
1126
1954
  `,
1127
- side: THREE5.BackSide,
1955
+ side: THREE6.BackSide,
1128
1956
  depthWrite: false,
1129
1957
  depthTest: true
1130
1958
  });
1131
- const atm = new THREE5.Mesh(geometry, material);
1959
+ const atm = new THREE6.Mesh(geometry, material);
1132
1960
  atmosphereMesh = atm;
1133
1961
  groundGroup.add(atm);
1134
1962
  }
1135
- const backdropGroup = new THREE5.Group();
1963
+ function createMoon() {
1964
+ const moonDir = new THREE6.Vector3(-0.38, 0.62, -0.68).normalize();
1965
+ const moonWorldPos = moonDir.clone().multiplyScalar(2e3);
1966
+ const glowGeo = new THREE6.PlaneGeometry(1, 1);
1967
+ const glowMat = createSmartMaterial({
1968
+ uniforms: { uMoonSize: { value: 0.082 } },
1969
+ vertexShaderBody: `
1970
+ uniform float uMoonSize;
1971
+ varying vec2 vUv;
1972
+ void main() {
1973
+ vUv = uv;
1974
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1975
+ vec4 projected = smartProject(mvPos);
1976
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
1977
+ vec2 offset = position.xy * uMoonSize * uScale * 2.4;
1978
+ projected.xy += offset / vec2(uAspect, 1.0);
1979
+ vScreenPos = projected.xy / projected.w;
1980
+ gl_Position = projected;
1981
+ }
1982
+ `,
1983
+ fragmentShader: `
1984
+ varying vec2 vUv;
1985
+ void main() {
1986
+ float alphaMask = getMaskAlpha();
1987
+ if (alphaMask < 0.01) discard;
1988
+ vec2 p = vUv * 2.0 - 1.0;
1989
+ float d = length(p);
1990
+ if (d > 1.0) discard;
1991
+ float halo = exp(-5.0 * d * d) * 0.07;
1992
+ halo += exp(-2.5 * max(0.0, d - 0.42)) * 0.045;
1993
+ if (halo < 0.003) discard;
1994
+ gl_FragColor = vec4(vec3(0.78, 0.88, 1.0) * halo, halo * alphaMask);
1995
+ }
1996
+ `,
1997
+ transparent: true,
1998
+ depthWrite: false,
1999
+ depthTest: true,
2000
+ blending: THREE6.AdditiveBlending
2001
+ });
2002
+ moonGlowMesh = new THREE6.Mesh(glowGeo, glowMat);
2003
+ moonGlowMesh.position.copy(moonWorldPos);
2004
+ moonGlowMesh.frustumCulled = false;
2005
+ moonGlowMesh.renderOrder = 2;
2006
+ scene.add(moonGlowMesh);
2007
+ const discGeo = new THREE6.PlaneGeometry(1, 1);
2008
+ const discMat = createSmartMaterial({
2009
+ uniforms: { uMoonSize: { value: 0.082 } },
2010
+ vertexShaderBody: `
2011
+ uniform float uMoonSize;
2012
+ varying vec2 vUv;
2013
+ void main() {
2014
+ vUv = uv;
2015
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2016
+ vec4 projected = smartProject(mvPos);
2017
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2018
+ vec2 offset = position.xy * uMoonSize * uScale;
2019
+ projected.xy += offset / vec2(uAspect, 1.0);
2020
+ vScreenPos = projected.xy / projected.w;
2021
+ gl_Position = projected;
2022
+ }
2023
+ `,
2024
+ fragmentShader: `
2025
+ varying vec2 vUv;
2026
+ void main() {
2027
+ float alphaMask = getMaskAlpha();
2028
+ if (alphaMask < 0.01) discard;
2029
+ vec2 p = vUv * 2.0 - 1.0;
2030
+ float d = length(p);
2031
+ if (d > 1.0) discard;
2032
+
2033
+ float edge = smoothstep(1.0, 0.90, d);
2034
+
2035
+ // Phase: sunlight from upper-right (gibbous moon)
2036
+ vec2 sunDir2D = normalize(vec2(0.55, 0.45));
2037
+ float phaseRaw = dot(normalize(p + vec2(0.0001)), sunDir2D);
2038
+ float lit = smoothstep(-0.18, 0.32, phaseRaw);
2039
+
2040
+ // Limb darkening (classical sqrt law)
2041
+ float cosTheta = sqrt(max(0.001, 1.0 - d * d));
2042
+ float limb = cosTheta * 0.42 + 0.58;
2043
+
2044
+ // Procedural surface texture
2045
+ float angle = atan(p.y, p.x);
2046
+ float r = d;
2047
+ float detail = sin(angle * 5.0 + 2.1) * sin(r * 8.3) * 0.038
2048
+ + sin(angle * 11.0 - 1.3) * sin(r * 13.0) * 0.022
2049
+ + sin(angle * 2.0 + 0.8) * (1.0 - r) * 0.055
2050
+ + sin(angle * 17.0 + r * 6.5) * 0.014
2051
+ + sin(angle * 23.0 - r * 11.0) * 0.009;
2052
+
2053
+ // Mare (dark maria) patches
2054
+ float mare1 = 1.0 - smoothstep(0.0, 0.30, length(p - vec2(-0.20, 0.22)));
2055
+ float mare2 = 1.0 - smoothstep(0.0, 0.20, length(p - vec2( 0.10, 0.30)));
2056
+ float mare3 = 1.0 - smoothstep(0.0, 0.24, length(p - vec2( 0.17,-0.06)));
2057
+ float mare4 = 1.0 - smoothstep(0.0, 0.14, length(p - vec2(-0.30,-0.20)));
2058
+ float totalMare = clamp(mare1*0.50 + mare2*0.38 + mare3*0.32 + mare4*0.28, 0.0, 0.58);
2059
+
2060
+ vec3 highland = vec3(0.88, 0.85, 0.80);
2061
+ vec3 mareColor = vec3(0.40, 0.39, 0.37);
2062
+ vec3 moonBase = clamp(mix(highland, mareColor, totalMare) + detail, 0.0, 1.0);
2063
+
2064
+ vec3 litSurface = moonBase * limb;
2065
+ vec3 earthshine = vec3(0.038, 0.052, 0.078);
2066
+ vec3 finalColor = mix(earthshine, litSurface, lit);
2067
+
2068
+ gl_FragColor = vec4(finalColor * edge, edge * alphaMask);
2069
+ }
2070
+ `,
2071
+ transparent: true,
2072
+ depthWrite: true,
2073
+ depthTest: true,
2074
+ blending: THREE6.NormalBlending
2075
+ });
2076
+ moonMesh = new THREE6.Mesh(discGeo, discMat);
2077
+ moonMesh.position.copy(moonWorldPos);
2078
+ moonMesh.frustumCulled = false;
2079
+ moonMesh.renderOrder = 3;
2080
+ scene.add(moonMesh);
2081
+ }
2082
+ function createSun() {
2083
+ const sunDir = new THREE6.Vector3(-1, -0.08, 0).normalize();
2084
+ const sunWorldPos = sunDir.clone().multiplyScalar(2e3);
2085
+ const haloGeo = new THREE6.PlaneGeometry(1, 1);
2086
+ const haloMat = createSmartMaterial({
2087
+ uniforms: { uSunHaloSize: { value: 0.46 } },
2088
+ vertexShaderBody: `
2089
+ uniform float uSunHaloSize;
2090
+ varying vec2 vUv;
2091
+ void main() {
2092
+ vUv = uv;
2093
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2094
+ vec4 projected = smartProject(mvPos);
2095
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2096
+ vec2 offset = position.xy * uSunHaloSize * uScale;
2097
+ projected.xy += offset / vec2(uAspect, 1.0);
2098
+ vScreenPos = projected.xy / projected.w;
2099
+ gl_Position = projected;
2100
+ }
2101
+ `,
2102
+ fragmentShader: `
2103
+ varying vec2 vUv;
2104
+ void main() {
2105
+ float alphaMask = getMaskAlpha();
2106
+ if (alphaMask < 0.01) discard;
2107
+
2108
+ vec2 p = vUv * 2.0 - 1.0;
2109
+ float d = length(p);
2110
+ if (d > 1.0) discard;
2111
+
2112
+ // Asymmetric falloff: spread wider horizontally than vertically
2113
+ float asymDist = length(vec2(p.x * 0.55, p.y));
2114
+
2115
+ // Radial glow: warm near centre, fading outward
2116
+ float glow = exp(-2.8 * asymDist * asymDist) * 1.0;
2117
+ glow += exp(-1.0 * asymDist) * 0.35;
2118
+
2119
+ // Crepuscular rays: fan out from bottom, visible above sun centre
2120
+ float rayMask = smoothstep(-0.05, 0.35, p.y);
2121
+ float rayFade = max(0.0, 1.0 - d) * (1.0 - d);
2122
+ float rayAngle = atan(p.x, max(0.0001, p.y)); // angle from vertical
2123
+ float rays = pow(abs(sin(rayAngle * 7.0 + 0.30)), 9.0) * 0.10
2124
+ + pow(abs(sin(rayAngle * 13.0 - 1.10)), 14.0) * 0.07
2125
+ + pow(abs(sin(rayAngle * 19.0 + 2.30)), 11.0) * 0.05;
2126
+ rays *= rayMask * rayFade;
2127
+
2128
+ // Colour: white-yellow \u2192 orange \u2192 hot-pink \u2192 purple
2129
+ vec3 cYellow = vec3(1.00, 0.88, 0.52);
2130
+ vec3 cOrange = vec3(1.00, 0.42, 0.10);
2131
+ vec3 cPink = vec3(0.90, 0.22, 0.52);
2132
+ vec3 cPurple = vec3(0.38, 0.12, 0.48);
2133
+ vec3 col = mix(cYellow, cOrange, smoothstep(0.00, 0.40, asymDist));
2134
+ col = mix(col, cPink, smoothstep(0.35, 0.72, asymDist));
2135
+ col = mix(col, cPurple, smoothstep(0.65, 1.00, asymDist));
2136
+
2137
+ float total = (glow + rays) * alphaMask;
2138
+ if (total < 0.005) discard;
2139
+ gl_FragColor = vec4(col * total, total);
2140
+ }
2141
+ `,
2142
+ transparent: true,
2143
+ depthWrite: false,
2144
+ depthTest: true,
2145
+ blending: THREE6.AdditiveBlending
2146
+ });
2147
+ sunHaloMesh = new THREE6.Mesh(haloGeo, haloMat);
2148
+ sunHaloMesh.position.copy(sunWorldPos);
2149
+ sunHaloMesh.frustumCulled = false;
2150
+ sunHaloMesh.renderOrder = 1;
2151
+ scene.add(sunHaloMesh);
2152
+ const discGeo = new THREE6.PlaneGeometry(1, 1);
2153
+ const discMat = createSmartMaterial({
2154
+ uniforms: { uSunSize: { value: 0.09 } },
2155
+ vertexShaderBody: `
2156
+ uniform float uSunSize;
2157
+ varying vec2 vUv;
2158
+ void main() {
2159
+ vUv = uv;
2160
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2161
+ vec4 projected = smartProject(mvPos);
2162
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2163
+ vec2 offset = position.xy * uSunSize * uScale;
2164
+ projected.xy += offset / vec2(uAspect, 1.0);
2165
+ vScreenPos = projected.xy / projected.w;
2166
+ gl_Position = projected;
2167
+ }
2168
+ `,
2169
+ fragmentShader: `
2170
+ varying vec2 vUv;
2171
+ void main() {
2172
+ float alphaMask = getMaskAlpha();
2173
+ if (alphaMask < 0.01) discard;
2174
+
2175
+ vec2 p = vUv * 2.0 - 1.0;
2176
+ float d = length(p);
2177
+ if (d > 1.0) discard;
2178
+
2179
+ float edge = smoothstep(1.0, 0.86, d);
2180
+
2181
+ // Photosphere limb darkening: bright white core \u2192 orange limb
2182
+ float core = smoothstep(0.28, 0.00, d);
2183
+ float mid = smoothstep(0.68, 0.22, d) * (1.0 - core);
2184
+ float limb = (1.0 - smoothstep(0.70, 1.00, d)) * (1.0 - core - mid);
2185
+
2186
+ vec3 cCore = vec3(1.00, 0.97, 0.88); // hot white
2187
+ vec3 cMid = vec3(1.00, 0.80, 0.38); // yellow
2188
+ vec3 cLimb = vec3(1.00, 0.52, 0.08); // deep orange
2189
+
2190
+ vec3 col = cCore * (core + 0.12) + cMid * mid + cLimb * limb;
2191
+ col = clamp(col, 0.0, 1.5); // allow slight overbright
2192
+
2193
+ gl_FragColor = vec4(col * edge, edge * alphaMask);
2194
+ }
2195
+ `,
2196
+ transparent: true,
2197
+ depthWrite: true,
2198
+ depthTest: true,
2199
+ blending: THREE6.NormalBlending
2200
+ });
2201
+ sunDiscMesh = new THREE6.Mesh(discGeo, discMat);
2202
+ sunDiscMesh.position.copy(sunWorldPos);
2203
+ sunDiscMesh.frustumCulled = false;
2204
+ sunDiscMesh.renderOrder = 3;
2205
+ scene.add(sunDiscMesh);
2206
+ }
2207
+ function createMilkyWay() {
2208
+ if (milkyWayMesh) {
2209
+ scene.remove(milkyWayMesh);
2210
+ milkyWayMesh.geometry.dispose();
2211
+ milkyWayMesh.material.dispose();
2212
+ milkyWayMesh = null;
2213
+ }
2214
+ const geo = new THREE6.PlaneGeometry(1100, 380, 4, 4);
2215
+ const mat = createSmartMaterial({
2216
+ uniforms: {},
2217
+ vertexShaderBody: `
2218
+ varying vec2 vUv;
2219
+ void main() {
2220
+ vUv = uv;
2221
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
2222
+ gl_Position = smartProject(mv);
2223
+ vScreenPos = gl_Position.xy / gl_Position.w;
2224
+ }
2225
+ `,
2226
+ fragmentShader: `
2227
+ varying vec2 vUv;
2228
+
2229
+ // --- Noise helpers ---
2230
+ float hash(vec2 p) {
2231
+ p = fract(p * vec2(127.1, 311.7));
2232
+ p += dot(p, p + 19.19);
2233
+ return fract(p.x * p.y);
2234
+ }
2235
+ float vnoise(vec2 p) {
2236
+ vec2 i = floor(p); vec2 f = fract(p);
2237
+ f = f * f * (3.0 - 2.0 * f);
2238
+ return mix(
2239
+ mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
2240
+ mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0,1.0)), f.x), f.y
2241
+ );
2242
+ }
2243
+ float fbm(vec2 p) {
2244
+ float v = 0.0; float a = 0.5;
2245
+ mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
2246
+ for (int i = 0; i < 7; i++) { v += a * vnoise(p); p = m * p; a *= 0.5; }
2247
+ return v;
2248
+ }
2249
+
2250
+ void main() {
2251
+ float alphaMask = getMaskAlpha();
2252
+ if (alphaMask < 0.01) discard;
2253
+
2254
+ vec2 uv = vUv * 2.0 - 1.0; // -1..1 centred
2255
+
2256
+ // Galactic band: tight Gaussian falloff vertically
2257
+ float bandMask = exp(-uv.y * uv.y * 10.0);
2258
+
2259
+ // Warp UV for organic turbulence (two layers of distortion)
2260
+ vec2 q = vec2(fbm(uv * 1.5),
2261
+ fbm(uv * 1.5 + vec2(5.2, 1.3)));
2262
+ vec2 r = vec2(fbm(uv * 1.0 + 4.0 * q + vec2(1.7, 9.2)),
2263
+ fbm(uv * 1.0 + 4.0 * q + vec2(8.3, 2.8)));
2264
+
2265
+ float nebula = fbm(uv * 2.0 + 2.0 * r);
2266
+ float detail = fbm(uv * 5.0 + r * 3.0 + vec2(3.1, 2.7));
2267
+ float fine = fbm(uv * 10.0 + vec2(1.0, 5.0));
2268
+
2269
+ // Base density
2270
+ float density = smoothstep(0.30, 0.80, nebula) * bandMask;
2271
+ density += smoothstep(0.45, 0.85, detail) * bandMask * 0.35;
2272
+
2273
+ // Dust lanes \u2014 dark patches carved into the band
2274
+ float dust = fbm(uv * 3.5 + vec2(11.0, 7.0));
2275
+ density *= (1.0 - smoothstep(0.52, 0.62, dust) * 0.7 * bandMask);
2276
+
2277
+ // Galactic core boost toward horizontal centre
2278
+ float galCore = exp(-uv.x * uv.x * 1.2) * bandMask;
2279
+
2280
+ // --- Color palette ---
2281
+ vec3 deepBlue = vec3(0.10, 0.15, 0.45);
2282
+ vec3 midBlue = vec3(0.25, 0.30, 0.65);
2283
+ vec3 purple = vec3(0.40, 0.20, 0.60);
2284
+ vec3 coreWarm = vec3(0.85, 0.80, 0.65); // warm star-cluster glow
2285
+ vec3 pinkNeb = vec3(0.65, 0.28, 0.50); // emission nebula pink
2286
+
2287
+ float t1 = smoothstep(0.3, 0.7, nebula);
2288
+ float t2 = smoothstep(0.5, 0.8, detail);
2289
+ float t3 = smoothstep(0.55, 0.75, fine);
2290
+
2291
+ vec3 color = mix(deepBlue, midBlue, t1);
2292
+ color = mix(color, purple, t2 * 0.5);
2293
+ color = mix(color, pinkNeb, t3 * 0.25 * bandMask);
2294
+ color += coreWarm * galCore * 0.45 * density;
2295
+
2296
+ // Micro-star field \u2014 denser in the band
2297
+ float starThresh = mix(0.975, 0.940, bandMask);
2298
+ float starSeed = hash(floor(vUv * 500.0));
2299
+ float star = step(starThresh, starSeed);
2300
+ float starBright = hash(floor(vUv * 500.0) + 37.0);
2301
+ color += vec3(0.90, 0.95, 1.0) * star * (0.4 + 0.6 * starBright);
2302
+ density = max(density, star * bandMask * 0.5);
2303
+
2304
+ // Soft edge vignette
2305
+ float ex = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
2306
+ float ey = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.82, vUv.y);
2307
+
2308
+ float alpha = density * ex * ey * alphaMask * 0.80;
2309
+ if (alpha < 0.004) discard;
2310
+ gl_FragColor = vec4(color, alpha);
2311
+ }
2312
+ `,
2313
+ transparent: true,
2314
+ depthWrite: false,
2315
+ depthTest: true,
2316
+ side: THREE6.DoubleSide,
2317
+ blending: THREE6.AdditiveBlending
2318
+ });
2319
+ milkyWayMesh = new THREE6.Mesh(geo, mat);
2320
+ const mwDir = new THREE6.Vector3(-0.62, 0.6, -0.5).normalize();
2321
+ milkyWayMesh.position.copy(mwDir.clone().multiplyScalar(920));
2322
+ milkyWayMesh.lookAt(0, 0, 0);
2323
+ milkyWayMesh.rotateY(Math.PI);
2324
+ milkyWayMesh.frustumCulled = false;
2325
+ milkyWayMesh.renderOrder = 1;
2326
+ scene.add(milkyWayMesh);
2327
+ }
2328
+ const backdropGroup = new THREE6.Group();
1136
2329
  scene.add(backdropGroup);
1137
- function createBackdropStars(count = 31e3) {
2330
+ let backdropStarsMaterial = null;
2331
+ function createBackdropStars(count = 5e3) {
1138
2332
  backdropGroup.clear();
1139
2333
  while (backdropGroup.children.length > 0) {
1140
2334
  const c = backdropGroup.children[0];
@@ -1142,7 +2336,7 @@ function createEngine({
1142
2336
  if (c.geometry) c.geometry.dispose();
1143
2337
  if (c.material) c.material.dispose();
1144
2338
  }
1145
- const geometry = new THREE5.BufferGeometry();
2339
+ const geometry = new THREE6.BufferGeometry();
1146
2340
  const positions = [];
1147
2341
  const sizes = [];
1148
2342
  const colors = [];
@@ -1177,14 +2371,17 @@ function createEngine({
1177
2371
  }
1178
2372
  colors.push(cr, cg, cb);
1179
2373
  }
1180
- geometry.setAttribute("position", new THREE5.Float32BufferAttribute(positions, 3));
1181
- geometry.setAttribute("size", new THREE5.Float32BufferAttribute(sizes, 1));
1182
- geometry.setAttribute("color", new THREE5.Float32BufferAttribute(colors, 3));
2374
+ geometry.setAttribute("position", new THREE6.Float32BufferAttribute(positions, 3));
2375
+ geometry.setAttribute("size", new THREE6.Float32BufferAttribute(sizes, 1));
2376
+ geometry.setAttribute("color", new THREE6.Float32BufferAttribute(colors, 3));
1183
2377
  const material = createSmartMaterial({
1184
2378
  uniforms: {
1185
2379
  pixelRatio: { value: renderer.getPixelRatio() },
1186
2380
  uScale: globalUniforms.uScale,
1187
- uTime: globalUniforms.uTime
2381
+ uTime: globalUniforms.uTime,
2382
+ uBackdropGain: { value: 1 },
2383
+ uBackdropEnergy: { value: 2.2 },
2384
+ uBackdropSizeExp: { value: 0.9 }
1188
2385
  },
1189
2386
  vertexShaderBody: `
1190
2387
  attribute float size;
@@ -1195,6 +2392,9 @@ function createEngine({
1195
2392
  uniform float uAtmExtinction;
1196
2393
  uniform float uAtmTwinkle;
1197
2394
  uniform float uTime;
2395
+ uniform float uBackdropGain;
2396
+ uniform float uBackdropEnergy;
2397
+ uniform float uBackdropSizeExp;
1198
2398
 
1199
2399
  void main() {
1200
2400
  vec3 nPos = normalize(position);
@@ -1210,15 +2410,16 @@ function createEngine({
1210
2410
  float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1211
2411
  float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
1212
2412
 
1213
- vColor = color * 3.0 * extinction * horizonFade * scintillation;
2413
+ vColor = color * uBackdropEnergy * extinction * horizonFade * scintillation * uBackdropGain;
1214
2414
 
1215
2415
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1216
2416
  gl_Position = smartProject(mvPosition);
1217
2417
  vScreenPos = gl_Position.xy / gl_Position.w;
1218
2418
 
1219
- float zoomScale = pow(uScale, 0.5);
2419
+ float zoomScale = pow(max(uScale, 0.0001), uBackdropSizeExp);
1220
2420
  float perceptualSize = pow(size, 0.55);
1221
- gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
2421
+ float sizeGain = mix(0.78, 1.0, uBackdropGain);
2422
+ gl_PointSize = clamp(perceptualSize * zoomScale * sizeGain * 0.5 * pixelRatio * (800.0 / length(mvPosition.xyz)) * horizonFade, 0.5, 20.0);
1222
2423
  }
1223
2424
  `,
1224
2425
  fragmentShader: `
@@ -1242,27 +2443,84 @@ function createEngine({
1242
2443
  transparent: true,
1243
2444
  depthWrite: false,
1244
2445
  depthTest: true,
1245
- blending: THREE5.AdditiveBlending
2446
+ blending: THREE6.AdditiveBlending
1246
2447
  });
1247
- const points = new THREE5.Points(geometry, material);
2448
+ backdropStarsMaterial = material;
2449
+ const points = new THREE6.Points(geometry, material);
1248
2450
  points.frustumCulled = false;
1249
2451
  backdropGroup.add(points);
1250
2452
  }
2453
+ function createEditHoverRing() {
2454
+ const geo = new THREE6.PlaneGeometry(1, 1);
2455
+ const mat = createSmartMaterial({
2456
+ uniforms: {
2457
+ uRingSize: { value: 0.06 },
2458
+ uRingAlpha: { value: 0 },
2459
+ uRingColor: { value: new THREE6.Color(0.55, 0.88, 1) }
2460
+ },
2461
+ vertexShaderBody: `
2462
+ uniform float uRingSize;
2463
+ varying vec2 vUv;
2464
+ void main() {
2465
+ vUv = uv;
2466
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2467
+ vec4 proj = smartProject(mvPos);
2468
+ if (proj.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2469
+ vec2 offset = position.xy * uRingSize * uScale;
2470
+ proj.xy += offset / vec2(uAspect, 1.0);
2471
+ vScreenPos = proj.xy / proj.w;
2472
+ gl_Position = proj;
2473
+ }
2474
+ `,
2475
+ fragmentShader: `
2476
+ varying vec2 vUv;
2477
+ uniform float uRingAlpha;
2478
+ uniform vec3 uRingColor;
2479
+ void main() {
2480
+ float alphaMask = getMaskAlpha();
2481
+ if (alphaMask < 0.01) discard;
2482
+ vec2 p = vUv * 2.0 - 1.0;
2483
+ float d = length(p);
2484
+ float ring = smoothstep(0.52, 0.62, d) * (1.0 - smoothstep(0.80, 0.92, d));
2485
+ float glow = (1.0 - smoothstep(0.55, 0.98, d)) * 0.18;
2486
+ float a = (ring + glow) * uRingAlpha * alphaMask;
2487
+ if (a < 0.005) discard;
2488
+ gl_FragColor = vec4(uRingColor * (ring * 1.2 + glow), a);
2489
+ }
2490
+ `,
2491
+ transparent: true,
2492
+ depthWrite: false,
2493
+ depthTest: false,
2494
+ side: THREE6.DoubleSide,
2495
+ blending: THREE6.AdditiveBlending
2496
+ });
2497
+ editHoverMesh = new THREE6.Mesh(geo, mat);
2498
+ editHoverMesh.renderOrder = 500;
2499
+ editHoverMesh.frustumCulled = false;
2500
+ scene.add(editHoverMesh);
2501
+ }
2502
+ createSkyBackground();
1251
2503
  createGround();
1252
2504
  createAtmosphere();
2505
+ createMoon();
2506
+ createSun();
2507
+ createMilkyWay();
1253
2508
  createBackdropStars();
1254
- const raycaster = new THREE5.Raycaster();
2509
+ createEditHoverRing();
2510
+ const raycaster = new THREE6.Raycaster();
1255
2511
  raycaster.params.Points.threshold = 5;
1256
- new THREE5.Vector2();
1257
- const root = new THREE5.Group();
2512
+ new THREE6.Vector2();
2513
+ const root = new THREE6.Group();
1258
2514
  scene.add(root);
1259
2515
  const nodeById = /* @__PURE__ */ new Map();
1260
2516
  const starIndexToId = [];
2517
+ const starIdToIndex = /* @__PURE__ */ new Map();
1261
2518
  const dynamicLabels = [];
2519
+ const labelManager = new LabelManager();
1262
2520
  const hoverLabelMat = createSmartMaterial({
1263
2521
  uniforms: {
1264
2522
  uMap: { value: null },
1265
- uSize: { value: new THREE5.Vector2(1, 1) },
2523
+ uSize: { value: new THREE6.Vector2(1, 1) },
1266
2524
  uAlpha: { value: 0 },
1267
2525
  uAngle: { value: 0 }
1268
2526
  },
@@ -1300,7 +2558,7 @@ function createEngine({
1300
2558
  depthTest: false
1301
2559
  // Always on top of stars
1302
2560
  });
1303
- const hoverLabelMesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), hoverLabelMat);
2561
+ const hoverLabelMesh = new THREE6.Mesh(new THREE6.PlaneGeometry(1, 1), hoverLabelMat);
1304
2562
  hoverLabelMesh.visible = false;
1305
2563
  hoverLabelMesh.renderOrder = 999;
1306
2564
  hoverLabelMesh.frustumCulled = false;
@@ -1324,7 +2582,9 @@ function createEngine({
1324
2582
  }
1325
2583
  nodeById.clear();
1326
2584
  starIndexToId.length = 0;
2585
+ starIdToIndex.clear();
1327
2586
  dynamicLabels.length = 0;
2587
+ labelManager.clear();
1328
2588
  constellationLines = null;
1329
2589
  boundaryLines = null;
1330
2590
  starPoints = null;
@@ -1346,49 +2606,132 @@ function createEngine({
1346
2606
  ctx.textAlign = "center";
1347
2607
  ctx.textBaseline = "middle";
1348
2608
  ctx.fillText(text, w / 2, h / 2);
1349
- const tex = new THREE5.CanvasTexture(canvas);
1350
- tex.minFilter = THREE5.LinearFilter;
2609
+ const tex = new THREE6.CanvasTexture(canvas);
2610
+ tex.minFilter = THREE6.LinearFilter;
1351
2611
  return { tex, aspect: w / h };
1352
2612
  }
1353
2613
  function getPosition(n) {
1354
2614
  if (currentConfig?.arrangement) {
1355
2615
  const arr = currentConfig.arrangement[n.id];
1356
2616
  if (arr) {
1357
- if (arr.position[2] === 0) {
1358
- const x = arr.position[0];
1359
- const y = arr.position[1];
2617
+ const [px, py, pz] = arr.position;
2618
+ if (pz === 0) {
1360
2619
  const radius = currentConfig.layout?.radius ?? 2e3;
1361
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
1362
- const phi = Math.atan2(y, x);
1363
- const theta = r_norm * (Math.PI / 2);
1364
- return new THREE5.Vector3(
1365
- Math.sin(theta) * Math.cos(phi),
1366
- Math.cos(theta),
1367
- Math.sin(theta) * Math.sin(phi)
1368
- ).multiplyScalar(radius);
2620
+ const len3d = Math.sqrt(px * px + py * py);
2621
+ if (len3d < radius * 0.99) {
2622
+ const r_norm = Math.min(1, len3d / radius);
2623
+ const phi = Math.atan2(py, px);
2624
+ const theta = r_norm * (Math.PI / 2);
2625
+ return new THREE6.Vector3(
2626
+ Math.sin(theta) * Math.cos(phi),
2627
+ Math.cos(theta),
2628
+ Math.sin(theta) * Math.sin(phi)
2629
+ ).multiplyScalar(radius);
2630
+ }
1369
2631
  }
1370
- return new THREE5.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2632
+ return new THREE6.Vector3(px, py, pz);
1371
2633
  }
1372
2634
  }
1373
- return new THREE5.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
2635
+ return new THREE6.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
1374
2636
  }
1375
2637
  function getBoundaryPoint(angle, t, radius) {
1376
2638
  const y = 0.05 + t * (1 - 0.05);
1377
2639
  const rY = Math.sqrt(1 - y * y);
1378
2640
  const x = Math.cos(angle) * rY;
1379
2641
  const z = Math.sin(angle) * rY;
1380
- return new THREE5.Vector3(x, y, z).multiplyScalar(radius);
2642
+ return new THREE6.Vector3(x, y, z).multiplyScalar(radius);
2643
+ }
2644
+ function updateChapterLabelAnchors() {
2645
+ if (!starPoints) return;
2646
+ const attr = starPoints.geometry.attributes.position;
2647
+ if (!attr) return;
2648
+ const cameraUpWorld = new THREE6.Vector3(0, 1, 0).applyQuaternion(camera.quaternion).normalize();
2649
+ const cameraRightWorld = new THREE6.Vector3(1, 0, 0).applyQuaternion(camera.quaternion).normalize();
2650
+ for (const item of dynamicLabels) {
2651
+ if (item.node.level !== 3) continue;
2652
+ const idx = starIdToIndex.get(item.node.id);
2653
+ if (idx === void 0) continue;
2654
+ const starPos = new THREE6.Vector3(attr.getX(idx), attr.getY(idx), attr.getZ(idx));
2655
+ const normal = starPos.clone().normalize();
2656
+ const tangent = cameraUpWorld.clone().sub(normal.clone().multiplyScalar(cameraUpWorld.dot(normal)));
2657
+ if (tangent.lengthSq() < 1e-6) {
2658
+ tangent.copy(cameraRightWorld).sub(normal.clone().multiplyScalar(cameraRightWorld.dot(normal)));
2659
+ }
2660
+ if (tangent.lengthSq() < 1e-6) continue;
2661
+ tangent.normalize();
2662
+ const starNorm = item.chapterStarSizeNorm ?? 0.5;
2663
+ const baseSize = item.chapterStarBaseSize ?? 3.5;
2664
+ const altitude = normal.y;
2665
+ const horizonFade = THREE6.MathUtils.smoothstep(altitude, -0.1, 0.05);
2666
+ const mvPos = starPos.clone().applyMatrix4(camera.matrixWorldInverse);
2667
+ const dist = Math.max(1, mvPos.length());
2668
+ const perceptualSize = Math.pow(baseSize, 0.7);
2669
+ const sizeBoost = 1 + Math.pow(baseSize, 0.5) * 0.08;
2670
+ const pointSize = THREE6.MathUtils.clamp(
2671
+ perceptualSize * sizeBoost * 20 * globalUniforms.uScale.value * renderer.getPixelRatio() * (2e3 / dist) * horizonFade,
2672
+ 1,
2673
+ 600
2674
+ );
2675
+ item.chapterGlowRadiusPx = pointSize * 0.6;
2676
+ const viewportH = Math.max(1, renderer.domElement.clientHeight);
2677
+ const fovRad = state.fov * Math.PI / 180;
2678
+ const worldPerPixel = 2 * dist * Math.tan(fovRad * 0.5) / viewportH;
2679
+ let labelHalfDiagPx = 18;
2680
+ const mat = item.obj.material;
2681
+ if (mat instanceof THREE6.ShaderMaterial && mat.uniforms?.uSize?.value instanceof THREE6.Vector2) {
2682
+ const uAlpha = typeof mat.uniforms.uAlpha?.value === "number" ? mat.uniforms.uAlpha.value : 0;
2683
+ const revealT = THREE6.MathUtils.smoothstep(uAlpha, 0, 1);
2684
+ const revealScale = 0.82 + 0.28 * revealT;
2685
+ const fadeOutScale = 1 + (1 - revealT) * 0.06;
2686
+ const zoomTextBoost = THREE6.MathUtils.lerp(1.4, 0.55, THREE6.MathUtils.smoothstep(state.fov, 8, 46));
2687
+ const starTextBoost = THREE6.MathUtils.lerp(0.9, 1.35, starNorm);
2688
+ const scaleMul = zoomTextBoost * starTextBoost * revealScale * fadeOutScale;
2689
+ const uSize = mat.uniforms.uSize.value;
2690
+ const targetX = item.initialScale.x * scaleMul;
2691
+ const targetY = item.initialScale.y * scaleMul;
2692
+ uSize.x = THREE6.MathUtils.lerp(uSize.x, targetX, 0.2);
2693
+ uSize.y = THREE6.MathUtils.lerp(uSize.y, targetY, 0.2);
2694
+ const size = mat.uniforms.uSize.value;
2695
+ const pixelH = size.y * viewportH * 0.8;
2696
+ const pixelW = size.x * viewportH * 0.8;
2697
+ labelHalfDiagPx = Math.max(6, Math.max(pixelH, pixelW * 0.45) * 0.5);
2698
+ }
2699
+ const edgeMarginPx = THREE6.MathUtils.lerp(1, 3, starNorm);
2700
+ const requiredPx = item.chapterGlowRadiusPx + edgeMarginPx + labelHalfDiagPx;
2701
+ const zoomPush = 1 + (1 - THREE6.MathUtils.smoothstep(state.fov, 8, 30)) * 0.8;
2702
+ const starPush = THREE6.MathUtils.lerp(0.95, 1.2, starNorm);
2703
+ const offset = THREE6.MathUtils.clamp(requiredPx * worldPerPixel * zoomPush * starPush, 3, 76);
2704
+ item.obj.position.copy(starPos);
2705
+ item.obj.position.addScaledVector(tangent, offset);
2706
+ item.obj.position.addScaledVector(normal, 2.5);
2707
+ item.chapterStarWorldPos = starPos.clone();
2708
+ }
2709
+ for (const item of dynamicLabels) {
2710
+ const level = item.node.level;
2711
+ if (level !== 2 && level !== 2.5) continue;
2712
+ const mat = item.obj.material;
2713
+ if (!(mat instanceof THREE6.ShaderMaterial) || !(mat.uniforms?.uSize?.value instanceof THREE6.Vector2)) continue;
2714
+ const entryFov = 22;
2715
+ const zoomBoost = THREE6.MathUtils.lerp(1.3, 0.5, THREE6.MathUtils.smoothstep(state.fov, 8, entryFov));
2716
+ const uAlpha = typeof mat.uniforms.uAlpha?.value === "number" ? mat.uniforms.uAlpha.value : 0;
2717
+ const revealT = THREE6.MathUtils.smoothstep(uAlpha, 0, 1);
2718
+ const revealScale = 0.82 + 0.28 * revealT;
2719
+ const scaleMul = zoomBoost * revealScale;
2720
+ const uSize = mat.uniforms.uSize.value;
2721
+ uSize.x = THREE6.MathUtils.lerp(uSize.x, item.initialScale.x * scaleMul, 0.2);
2722
+ uSize.y = THREE6.MathUtils.lerp(uSize.y, item.initialScale.y * scaleMul, 0.2);
2723
+ }
1381
2724
  }
1382
2725
  function buildFromModel(model, cfg) {
1383
2726
  clearRoot();
1384
2727
  bookIdToIndex.clear();
1385
2728
  testamentToIndex.clear();
1386
2729
  divisionToIndex.clear();
1387
- scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5.Color(cfg.background) : new THREE5.Color(0);
2730
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE6.Color(cfg.background) : null;
1388
2731
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1389
2732
  const laidOut = computeLayoutPositions(model, layoutCfg);
1390
2733
  const divisionPositions = /* @__PURE__ */ new Map();
1391
- if (cfg.arrangement) {
2734
+ {
1392
2735
  const divMap = /* @__PURE__ */ new Map();
1393
2736
  for (const n of laidOut.nodes) {
1394
2737
  if (n.level === 2 && n.parent) {
@@ -1398,7 +2741,7 @@ function createEngine({
1398
2741
  }
1399
2742
  }
1400
2743
  for (const [divId, books] of divMap.entries()) {
1401
- const centroid = new THREE5.Vector3();
2744
+ const centroid = new THREE6.Vector3();
1402
2745
  let count = 0;
1403
2746
  for (const b of books) {
1404
2747
  const p = getPosition(b);
@@ -1419,20 +2762,25 @@ function createEngine({
1419
2762
  const starChapterIndices = [];
1420
2763
  const starTestamentIndices = [];
1421
2764
  const starDivisionIndices = [];
2765
+ const chapterLineCutById = /* @__PURE__ */ new Map();
2766
+ const chapterStarSizeById = /* @__PURE__ */ new Map();
2767
+ const chapterWeightNormById = /* @__PURE__ */ new Map();
2768
+ let minChapterStarSize = Infinity;
2769
+ let maxChapterStarSize = -Infinity;
1422
2770
  const SPECTRAL_COLORS = [
1423
- new THREE5.Color(14544639),
2771
+ new THREE6.Color(14544639),
1424
2772
  // O - Blueish White
1425
- new THREE5.Color(15660287),
2773
+ new THREE6.Color(15660287),
1426
2774
  // B - White
1427
- new THREE5.Color(16317695),
2775
+ new THREE6.Color(16317695),
1428
2776
  // A - White
1429
- new THREE5.Color(16777208),
2777
+ new THREE6.Color(16777208),
1430
2778
  // F - White
1431
- new THREE5.Color(16775406),
2779
+ new THREE6.Color(16775406),
1432
2780
  // G - Yellowish White
1433
- new THREE5.Color(16773085),
2781
+ new THREE6.Color(16773085),
1434
2782
  // K - Pale Orange
1435
- new THREE5.Color(16771788)
2783
+ new THREE6.Color(16771788)
1436
2784
  // M - Light Orange
1437
2785
  ];
1438
2786
  let minWeight = Infinity;
@@ -1452,15 +2800,38 @@ function createEngine({
1452
2800
  }
1453
2801
  for (const n of laidOut.nodes) {
1454
2802
  if (n.level === 3) {
1455
- const p = getPosition(n);
1456
- starPositions.push(p.x, p.y, p.z);
1457
- starIndexToId.push(n.id);
1458
2803
  let baseSize = 3.5;
2804
+ let weightNorm = 0;
1459
2805
  if (typeof n.weight === "number") {
1460
- const t = (n.weight - minWeight) / (maxWeight - minWeight);
1461
- baseSize = 0.1 + Math.pow(t, 0.5) * 11.9;
2806
+ weightNorm = (n.weight - minWeight) / (maxWeight - minWeight);
2807
+ const sizeExp = cfg.starSizeExponent ?? 4;
2808
+ const sizeScale = cfg.starSizeScale ?? 6;
2809
+ baseSize = Math.pow(weightNorm, sizeExp) * 22 * sizeScale;
1462
2810
  }
2811
+ chapterStarSizeById.set(n.id, baseSize);
2812
+ chapterWeightNormById.set(n.id, weightNorm);
2813
+ minChapterStarSize = Math.min(minChapterStarSize, baseSize);
2814
+ maxChapterStarSize = Math.max(maxChapterStarSize, baseSize);
2815
+ }
2816
+ }
2817
+ if (!Number.isFinite(minChapterStarSize)) {
2818
+ minChapterStarSize = 1;
2819
+ maxChapterStarSize = 2;
2820
+ } else if (minChapterStarSize === maxChapterStarSize) {
2821
+ maxChapterStarSize = minChapterStarSize + 1;
2822
+ }
2823
+ for (const n of laidOut.nodes) {
2824
+ if (n.level === 3) {
2825
+ const p = getPosition(n);
2826
+ starPositions.push(p.x, p.y, p.z);
2827
+ starIdToIndex.set(n.id, starIndexToId.length);
2828
+ starIndexToId.push(n.id);
2829
+ const baseSize = chapterStarSizeById.get(n.id) ?? 3.5;
1463
2830
  starSizes.push(baseSize);
2831
+ chapterLineCutById.set(
2832
+ n.id,
2833
+ THREE6.MathUtils.clamp(2.5 + baseSize * 0.45, 3, 40)
2834
+ );
1464
2835
  const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
1465
2836
  const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
1466
2837
  starColors.push(c.r, c.g, c.b);
@@ -1511,8 +2882,11 @@ function createEngine({
1511
2882
  let baseScale = 0.05;
1512
2883
  if (n.level === 1) baseScale = 0.08;
1513
2884
  else if (n.level === 2) baseScale = 0.04;
1514
- else if (n.level === 3) baseScale = 0.03;
1515
- const size = new THREE5.Vector2(baseScale * texRes.aspect, baseScale);
2885
+ else if (n.level === 3) {
2886
+ const wn2 = chapterWeightNormById.get(n.id) ?? 0;
2887
+ baseScale = THREE6.MathUtils.lerp(0.019, 0.039, wn2);
2888
+ }
2889
+ const size = new THREE6.Vector2(baseScale * texRes.aspect, baseScale);
1516
2890
  const mat = createSmartMaterial({
1517
2891
  uniforms: {
1518
2892
  uMap: { value: texRes.tex },
@@ -1551,39 +2925,59 @@ function createEngine({
1551
2925
  `,
1552
2926
  transparent: true,
1553
2927
  depthWrite: false,
1554
- depthTest: true
2928
+ depthTest: n.level === 3 ? false : true
1555
2929
  });
1556
- const mesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), mat);
2930
+ const mesh = new THREE6.Mesh(new THREE6.PlaneGeometry(1, 1), mat);
1557
2931
  let p = getPosition(n);
1558
2932
  if (n.level === 1) {
1559
- if (divisionPositions.has(n.id)) {
1560
- p.copy(divisionPositions.get(n.id));
2933
+ if (cfg.arrangement?.[n.id]) {
2934
+ const arr = cfg.arrangement[n.id];
2935
+ p.set(arr.position[0], arr.position[1], arr.position[2]);
2936
+ } else {
2937
+ if (divisionPositions.has(n.id)) {
2938
+ p.copy(divisionPositions.get(n.id));
2939
+ }
2940
+ const r = layoutCfg.radius * 0.95;
2941
+ const angle = Math.atan2(p.z, p.x);
2942
+ p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1561
2943
  }
1562
- const r = layoutCfg.radius * 0.95;
1563
- const angle = Math.atan2(p.z, p.x);
1564
- p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1565
2944
  } else if (n.level === 3) {
1566
- p.y += 30;
1567
- p.multiplyScalar(1.001);
2945
+ const starSize = chapterStarSizeById.get(n.id) ?? 3.5;
2946
+ const starNorm = THREE6.MathUtils.clamp(
2947
+ (starSize - minChapterStarSize) / (maxChapterStarSize - minChapterStarSize),
2948
+ 0,
2949
+ 1
2950
+ );
2951
+ const radialOffset = THREE6.MathUtils.lerp(16, 46, starNorm);
2952
+ p.addScaledVector(p.clone().normalize(), radialOffset);
1568
2953
  }
1569
2954
  mesh.position.set(p.x, p.y, p.z);
1570
2955
  mesh.scale.set(size.x, size.y, 1);
1571
2956
  mesh.frustumCulled = false;
1572
2957
  mesh.userData = { id: n.id };
1573
2958
  root.add(mesh);
1574
- dynamicLabels.push({ obj: mesh, node: n, initialScale: size.clone() });
2959
+ const wn = n.level === 3 ? chapterWeightNormById.get(n.id) ?? 0 : 0;
2960
+ const chapterMaxFovBias = n.level === 3 ? THREE6.MathUtils.lerp(-4, 8, wn) : 0;
2961
+ dynamicLabels.push({
2962
+ obj: mesh,
2963
+ node: n,
2964
+ initialScale: size.clone(),
2965
+ maxFovBias: chapterMaxFovBias,
2966
+ chapterStarSizeNorm: n.level === 3 ? wn : void 0,
2967
+ chapterStarBaseSize: n.level === 3 ? chapterStarSizeById.get(n.id) ?? 3.5 : void 0
2968
+ });
1575
2969
  }
1576
2970
  }
1577
2971
  }
1578
- const starGeo = new THREE5.BufferGeometry();
1579
- starGeo.setAttribute("position", new THREE5.Float32BufferAttribute(starPositions, 3));
1580
- starGeo.setAttribute("size", new THREE5.Float32BufferAttribute(starSizes, 1));
1581
- starGeo.setAttribute("color", new THREE5.Float32BufferAttribute(starColors, 3));
1582
- starGeo.setAttribute("phase", new THREE5.Float32BufferAttribute(starPhases, 1));
1583
- starGeo.setAttribute("bookIndex", new THREE5.Float32BufferAttribute(starBookIndices, 1));
1584
- starGeo.setAttribute("chapterIndex", new THREE5.Float32BufferAttribute(starChapterIndices, 1));
1585
- starGeo.setAttribute("testamentIndex", new THREE5.Float32BufferAttribute(starTestamentIndices, 1));
1586
- starGeo.setAttribute("divisionIndex", new THREE5.Float32BufferAttribute(starDivisionIndices, 1));
2972
+ const starGeo = new THREE6.BufferGeometry();
2973
+ starGeo.setAttribute("position", new THREE6.Float32BufferAttribute(starPositions, 3));
2974
+ starGeo.setAttribute("size", new THREE6.Float32BufferAttribute(starSizes, 1));
2975
+ starGeo.setAttribute("color", new THREE6.Float32BufferAttribute(starColors, 3));
2976
+ starGeo.setAttribute("phase", new THREE6.Float32BufferAttribute(starPhases, 1));
2977
+ starGeo.setAttribute("bookIndex", new THREE6.Float32BufferAttribute(starBookIndices, 1));
2978
+ starGeo.setAttribute("chapterIndex", new THREE6.Float32BufferAttribute(starChapterIndices, 1));
2979
+ starGeo.setAttribute("testamentIndex", new THREE6.Float32BufferAttribute(starTestamentIndices, 1));
2980
+ starGeo.setAttribute("divisionIndex", new THREE6.Float32BufferAttribute(starDivisionIndices, 1));
1587
2981
  const starMat = createSmartMaterial({
1588
2982
  uniforms: {
1589
2983
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1592,7 +2986,7 @@ function createEngine({
1592
2986
  uActiveBookIndex: { value: -1 },
1593
2987
  uOrderRevealStrength: { value: 0 },
1594
2988
  uGlobalDimFactor: { value: ORDER_REVEAL_CONFIG.globalDim },
1595
- uPulseParams: { value: new THREE5.Vector3(
2989
+ uPulseParams: { value: new THREE6.Vector3(
1596
2990
  ORDER_REVEAL_CONFIG.pulseDuration,
1597
2991
  ORDER_REVEAL_CONFIG.delayPerChapter,
1598
2992
  ORDER_REVEAL_CONFIG.pulseAmplitude
@@ -1613,6 +3007,7 @@ function createEngine({
1613
3007
  attribute float divisionIndex;
1614
3008
 
1615
3009
  varying vec3 vColor;
3010
+ varying float vSize;
1616
3011
  uniform float pixelRatio;
1617
3012
 
1618
3013
  uniform float uTime;
@@ -1685,41 +3080,159 @@ function createEngine({
1685
3080
  gl_Position = smartProject(mvPosition);
1686
3081
  vScreenPos = gl_Position.xy / gl_Position.w;
1687
3082
 
1688
- float sizeBoost = 1.0 + activePulse * 0.8;
1689
- float perceptualSize = pow(size, 0.55);
1690
- gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
3083
+ float sizeBoost = 1.0 + activePulse * 0.15;
3084
+ // pow(size, 0.7) is gentler compression than 0.55 \u2014 preserves more of
3085
+ // the aggressive JS curve so large stars stay visually dominant.
3086
+ float perceptualSize = pow(size, 0.7);
3087
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 20.0) * uScale * pixelRatio * (2000.0 / length(mvPosition.xyz)) * horizonFade, 1.0, 600.0);
3088
+ vSize = gl_PointSize;
1691
3089
  }
1692
3090
  `,
1693
3091
  fragmentShader: `
1694
- varying vec3 vColor;
1695
- void main() {
1696
- vec2 coord = gl_PointCoord - vec2(0.5);
1697
- float d = length(coord) * 2.0;
1698
- if (d > 1.0) discard;
1699
-
1700
- float alphaMask = getMaskAlpha();
1701
- if (alphaMask < 0.01) discard;
1702
-
1703
- // Stellarium-style dual-layer: sharp core + soft glow
1704
- float core = smoothstep(0.8, 0.4, d);
1705
- float glow = smoothstep(1.0, 0.0, d) * 0.08;
1706
- float k = core + glow;
3092
+ varying vec3 vColor;
3093
+ varying float vSize;
3094
+ void main() {
3095
+ vec2 coord = gl_PointCoord - vec2(0.5);
3096
+ float d = length(coord) * 2.0;
3097
+ if (d > 1.0) discard;
3098
+
3099
+ float alphaMask = getMaskAlpha();
3100
+ if (alphaMask < 0.01) discard;
3101
+
3102
+ // --- Multi-layer Gaussian star model ---
3103
+ // Tight white-hot core
3104
+ float core = exp(-d * d * 9.0);
3105
+ // Broader coloured inner halo
3106
+ float innerGlow = exp(-d * d * 3.0) * 0.45;
3107
+ // Wide faint bloom that fades smoothly to the disc edge
3108
+ float outerBloom = max(0.0, 1.0 - d * d) * 0.10;
3109
+
3110
+ float k = core + innerGlow + outerBloom;
3111
+
3112
+ // White-hot centre \u2192 spectral colour at the halo
3113
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.88);
3114
+
3115
+ // --- Size-dependent diffraction spikes ---
3116
+ // Only appear on larger (brighter) stars, matching real optics.
3117
+ float spikeFactor = smoothstep(10.0, 24.0, vSize);
3118
+ float spikeH = exp(-coord.y * coord.y * 180.0) * exp(-abs(coord.x) * 6.0);
3119
+ float spikeV = exp(-coord.x * coord.x * 180.0) * exp(-abs(coord.y) * 6.0);
3120
+ float spikes = (spikeH + spikeV) * 0.18 * spikeFactor;
1707
3121
 
1708
- // White-hot core blending into coloured halo
1709
- vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1710
- gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
3122
+ gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, 1.0);
1711
3123
  }
1712
3124
  `,
1713
3125
  transparent: true,
1714
3126
  depthWrite: false,
1715
3127
  depthTest: true,
1716
- blending: THREE5.AdditiveBlending
3128
+ blending: THREE6.AdditiveBlending
1717
3129
  });
1718
- starPoints = new THREE5.Points(starGeo, starMat);
3130
+ starPoints = new THREE6.Points(starGeo, starMat);
1719
3131
  starPoints.frustumCulled = false;
1720
3132
  root.add(starPoints);
1721
3133
  const linePoints = [];
3134
+ const lineWeights = [];
3135
+ const seenEdges = /* @__PURE__ */ new Set();
1722
3136
  const bookMap = /* @__PURE__ */ new Map();
3137
+ const parseBookKeyFromChapterId = (id) => {
3138
+ if (!id) return null;
3139
+ const parts = id.split(":");
3140
+ if (parts.length < 3 || parts[0] !== "C") return null;
3141
+ return parts[1] || null;
3142
+ };
3143
+ const weightScaleFromLabel = (weight) => {
3144
+ if (weight === "thin") return 0.65;
3145
+ if (weight === "bold") return 1.6;
3146
+ return 1;
3147
+ };
3148
+ const edgeKey = (aNodeId, bNodeId) => aNodeId < bNodeId ? `${aNodeId}|${bNodeId}` : `${bNodeId}|${aNodeId}`;
3149
+ const addTruncatedSegment = (aNodeId, bNodeId, weightScale) => {
3150
+ if (aNodeId === bNodeId) return;
3151
+ const k = edgeKey(aNodeId, bNodeId);
3152
+ if (seenEdges.has(k)) return;
3153
+ seenEdges.add(k);
3154
+ const aNode = nodeById.get(aNodeId);
3155
+ const bNode = nodeById.get(bNodeId);
3156
+ if (!aNode || !bNode) return;
3157
+ const p1 = getPosition(aNode);
3158
+ const p2 = getPosition(bNode);
3159
+ const dir = new THREE6.Vector3().subVectors(p2, p1);
3160
+ const len = dir.length();
3161
+ if (len < 1e-3) return;
3162
+ dir.divideScalar(len);
3163
+ let cutA = chapterLineCutById.get(aNodeId) ?? 4;
3164
+ let cutB = chapterLineCutById.get(bNodeId) ?? 4;
3165
+ const maxTotalCut = len * 0.8;
3166
+ const totalCut = cutA + cutB;
3167
+ if (totalCut > maxTotalCut && totalCut > 0) {
3168
+ const scale = maxTotalCut / totalCut;
3169
+ cutA *= scale;
3170
+ cutB *= scale;
3171
+ }
3172
+ const a = p1.clone().addScaledVector(dir, cutA);
3173
+ const b = p2.clone().addScaledVector(dir, -cutB);
3174
+ linePoints.push(a.x, a.y, a.z);
3175
+ linePoints.push(b.x, b.y, b.z);
3176
+ lineWeights.push(weightScale);
3177
+ };
3178
+ const customBooks = /* @__PURE__ */ new Set();
3179
+ const rawConstellations = cfg.constellations && Array.isArray(cfg.constellations.constellations) ? cfg.constellations.constellations : [];
3180
+ for (const c of rawConstellations) {
3181
+ const linePaths = Array.isArray(c?.linePaths) ? c.linePaths : [];
3182
+ const lineSegments = Array.isArray(c?.lineSegments) ? c.lineSegments : [];
3183
+ if (linePaths.length === 0 && lineSegments.length === 0) continue;
3184
+ const anchorBookKey = parseBookKeyFromChapterId(c?.anchors?.[0]);
3185
+ if (anchorBookKey) customBooks.add(anchorBookKey);
3186
+ for (const segDef of lineSegments) {
3187
+ let from;
3188
+ let to;
3189
+ let weightLabel;
3190
+ if (Array.isArray(segDef)) {
3191
+ const raw = segDef;
3192
+ if (typeof raw[0] === "string" && (raw[0] === "thin" || raw[0] === "bold" || raw[0] === "normal")) {
3193
+ weightLabel = raw[0];
3194
+ from = typeof raw[1] === "string" ? raw[1] : void 0;
3195
+ to = typeof raw[2] === "string" ? raw[2] : void 0;
3196
+ } else {
3197
+ from = typeof raw[0] === "string" ? raw[0] : void 0;
3198
+ to = typeof raw[1] === "string" ? raw[1] : void 0;
3199
+ }
3200
+ } else if (segDef) {
3201
+ from = typeof segDef.from === "string" ? segDef.from : void 0;
3202
+ to = typeof segDef.to === "string" ? segDef.to : void 0;
3203
+ weightLabel = typeof segDef.weight === "string" ? segDef.weight : void 0;
3204
+ }
3205
+ if (!from || !to) continue;
3206
+ const k1 = parseBookKeyFromChapterId(from);
3207
+ const k2 = parseBookKeyFromChapterId(to);
3208
+ if (k1) customBooks.add(k1);
3209
+ if (k2) customBooks.add(k2);
3210
+ addTruncatedSegment(from, to, weightScaleFromLabel(weightLabel));
3211
+ }
3212
+ for (const pathDef of linePaths) {
3213
+ let nodes = [];
3214
+ let weightLabel = void 0;
3215
+ if (Array.isArray(pathDef)) {
3216
+ const raw = pathDef;
3217
+ if (typeof raw[0] === "string" && (raw[0] === "thin" || raw[0] === "bold" || raw[0] === "normal")) {
3218
+ weightLabel = raw[0];
3219
+ nodes = raw.slice(1).filter((v) => typeof v === "string");
3220
+ } else {
3221
+ nodes = raw.filter((v) => typeof v === "string");
3222
+ }
3223
+ } else if (pathDef && Array.isArray(pathDef.nodes)) {
3224
+ nodes = pathDef.nodes.filter((v) => typeof v === "string");
3225
+ weightLabel = typeof pathDef.weight === "string" ? pathDef.weight : void 0;
3226
+ }
3227
+ if (nodes.length < 2) continue;
3228
+ const inferredBookKey = parseBookKeyFromChapterId(nodes[0]);
3229
+ if (inferredBookKey) customBooks.add(inferredBookKey);
3230
+ const w = weightScaleFromLabel(weightLabel);
3231
+ for (let i = 0; i < nodes.length - 1; i++) {
3232
+ addTruncatedSegment(nodes[i], nodes[i + 1], w);
3233
+ }
3234
+ }
3235
+ }
1723
3236
  for (const n of laidOut.nodes) {
1724
3237
  if (n.level === 3 && n.parent) {
1725
3238
  const list = bookMap.get(n.parent) ?? [];
@@ -1730,24 +3243,27 @@ function createEngine({
1730
3243
  for (const chapters of bookMap.values()) {
1731
3244
  chapters.sort((a, b) => (a.meta?.chapter || 0) - (b.meta?.chapter || 0));
1732
3245
  if (chapters.length < 2) continue;
3246
+ const bookKey = chapters[0]?.meta?.bookKey ?? null;
3247
+ if (bookKey && customBooks.has(bookKey)) continue;
1733
3248
  for (let i = 0; i < chapters.length - 1; i++) {
1734
3249
  const c1 = chapters[i];
1735
3250
  const c2 = chapters[i + 1];
1736
3251
  if (!c1 || !c2) continue;
1737
- const p1 = getPosition(c1);
1738
- const p2 = getPosition(c2);
1739
- linePoints.push(p1.x, p1.y, p1.z);
1740
- linePoints.push(p2.x, p2.y, p2.z);
3252
+ addTruncatedSegment(c1.id, c2.id, 1);
1741
3253
  }
1742
3254
  }
1743
3255
  if (linePoints.length > 0) {
1744
3256
  const quadPositions = [];
1745
3257
  const quadUvs = [];
3258
+ const quadLineWeight = [];
3259
+ const quadSegmentIndex = [];
1746
3260
  const quadIndices = [];
1747
3261
  const lineWidth = 8;
3262
+ const segmentCount = linePoints.length / 6;
1748
3263
  for (let i = 0; i < linePoints.length; i += 6) {
1749
3264
  const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1750
3265
  const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
3266
+ const segIndex = i / 6;
1751
3267
  const dx = bx - ax, dy = by - ay, dz = bz - az;
1752
3268
  const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1753
3269
  if (len < 1e-3) continue;
@@ -1772,23 +3288,36 @@ function createEngine({
1772
3288
  quadUvs.push(1, -1);
1773
3289
  quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1774
3290
  quadUvs.push(1, 1);
3291
+ const w = lineWeights[segIndex] ?? 1;
3292
+ quadLineWeight.push(w, w, w, w);
3293
+ quadSegmentIndex.push(segIndex, segIndex, segIndex, segIndex);
1775
3294
  quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1776
3295
  }
1777
- const lineGeo = new THREE5.BufferGeometry();
1778
- lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(quadPositions, 3));
1779
- lineGeo.setAttribute("lineUv", new THREE5.Float32BufferAttribute(quadUvs, 2));
3296
+ const lineGeo = new THREE6.BufferGeometry();
3297
+ lineGeo.setAttribute("position", new THREE6.Float32BufferAttribute(quadPositions, 3));
3298
+ lineGeo.setAttribute("lineUv", new THREE6.Float32BufferAttribute(quadUvs, 2));
3299
+ lineGeo.setAttribute("lineWeight", new THREE6.Float32BufferAttribute(quadLineWeight, 1));
3300
+ lineGeo.setAttribute("segmentIndex", new THREE6.Float32BufferAttribute(quadSegmentIndex, 1));
1780
3301
  lineGeo.setIndex(quadIndices);
1781
3302
  const lineMat = createSmartMaterial({
1782
3303
  uniforms: {
1783
- color: { value: new THREE5.Color(11193599) },
3304
+ color: { value: new THREE6.Color(11193599) },
1784
3305
  uLineWidth: { value: 1.5 },
1785
- uGlowIntensity: { value: 0.3 }
3306
+ uGlowIntensity: { value: 0.3 },
3307
+ uReveal: { value: 0 },
3308
+ uSegmentCount: { value: Math.max(1, segmentCount) }
1786
3309
  },
1787
3310
  vertexShaderBody: `
1788
3311
  attribute vec2 lineUv;
3312
+ attribute float lineWeight;
3313
+ attribute float segmentIndex;
1789
3314
  varying vec2 vLineUv;
3315
+ varying float vLineWeight;
3316
+ varying float vSegmentIndex;
1790
3317
  void main() {
1791
3318
  vLineUv = lineUv;
3319
+ vLineWeight = lineWeight;
3320
+ vSegmentIndex = segmentIndex;
1792
3321
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1793
3322
  gl_Position = smartProject(mvPosition);
1794
3323
  vScreenPos = gl_Position.xy / gl_Position.w;
@@ -1798,32 +3327,53 @@ function createEngine({
1798
3327
  uniform vec3 color;
1799
3328
  uniform float uLineWidth;
1800
3329
  uniform float uGlowIntensity;
3330
+ uniform float uReveal;
3331
+ uniform float uSegmentCount;
1801
3332
  varying vec2 vLineUv;
3333
+ varying float vLineWeight;
3334
+ varying float vSegmentIndex;
1802
3335
  void main() {
1803
3336
  float alphaMask = getMaskAlpha();
1804
3337
  if (alphaMask < 0.01) discard;
1805
3338
 
3339
+ // Progressive line draw tuned closer to Stellarium feel:
3340
+ // - eased global reveal
3341
+ // - sequential segment staggering with slight overlap
3342
+ // - smooth growth of each segment endpoint
3343
+ float reveal = smoothstep(0.0, 1.0, uReveal);
3344
+ float segCount = max(uSegmentCount, 1.0);
3345
+ float segStart = vSegmentIndex / segCount;
3346
+ float segSpan = (1.25 / segCount) + 0.04;
3347
+ float localReveal = clamp((reveal - segStart) / segSpan, 0.0, 1.0);
3348
+ localReveal = smoothstep(0.0, 1.0, localReveal);
3349
+
3350
+ // Keep fragment only when x is before the animated endpoint.
3351
+ float endpointMask = 1.0 - smoothstep(localReveal - 0.03, localReveal + 0.02, vLineUv.x);
3352
+ // Fade in segment brightness as it begins drawing.
3353
+ float drawMask = endpointMask * smoothstep(0.0, 0.08, localReveal);
3354
+ if (drawMask < 0.001) discard;
3355
+
1806
3356
  float dist = abs(vLineUv.y);
1807
3357
 
1808
3358
  // Anti-aliased core line
1809
- float hw = uLineWidth * 0.05;
3359
+ float hw = (uLineWidth * vLineWeight) * 0.05;
1810
3360
  float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1811
3361
 
1812
3362
  // Soft glow extending outward
1813
- float glow = (1.0 - dist) * uGlowIntensity;
3363
+ float glow = (1.0 - dist) * uGlowIntensity * vLineWeight;
1814
3364
 
1815
3365
  float alpha = max(glow, base);
1816
3366
  if (alpha < 0.005) discard;
1817
3367
 
1818
- gl_FragColor = vec4(color, alpha * alphaMask);
3368
+ gl_FragColor = vec4(color, alpha * alphaMask * drawMask);
1819
3369
  }
1820
3370
  `,
1821
3371
  transparent: true,
1822
3372
  depthWrite: false,
1823
- blending: THREE5.AdditiveBlending,
1824
- side: THREE5.DoubleSide
3373
+ blending: THREE6.AdditiveBlending,
3374
+ side: THREE6.DoubleSide
1825
3375
  });
1826
- constellationLines = new THREE5.Mesh(lineGeo, lineMat);
3376
+ constellationLines = new THREE6.Mesh(lineGeo, lineMat);
1827
3377
  constellationLines.frustumCulled = false;
1828
3378
  root.add(constellationLines);
1829
3379
  }
@@ -1836,7 +3386,7 @@ function createEngine({
1836
3386
  if (groupList) {
1837
3387
  groupList.forEach((g, idx) => {
1838
3388
  const groupId = `G:${bookId}:${idx}`;
1839
- let p = new THREE5.Vector3();
3389
+ let p = new THREE6.Vector3();
1840
3390
  if (cfg.arrangement && cfg.arrangement[groupId]) {
1841
3391
  const arr = cfg.arrangement[groupId];
1842
3392
  p.set(arr.position[0], arr.position[1], arr.position[2]);
@@ -1855,7 +3405,7 @@ function createEngine({
1855
3405
  const texRes = createTextTexture(labelText, "#4fa4fa80");
1856
3406
  if (texRes) {
1857
3407
  const baseScale = 0.036;
1858
- const size = new THREE5.Vector2(baseScale * texRes.aspect, baseScale);
3408
+ const size = new THREE6.Vector2(baseScale * texRes.aspect, baseScale);
1859
3409
  const mat = createSmartMaterial({
1860
3410
  uniforms: {
1861
3411
  uMap: { value: texRes.tex },
@@ -1896,7 +3446,7 @@ function createEngine({
1896
3446
  depthWrite: false,
1897
3447
  depthTest: true
1898
3448
  });
1899
- const mesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), mat);
3449
+ const mesh = new THREE6.Mesh(new THREE6.PlaneGeometry(1, 1), mat);
1900
3450
  mesh.position.copy(p);
1901
3451
  mesh.scale.set(size.x, size.y, 1);
1902
3452
  mesh.frustumCulled = false;
@@ -1918,14 +3468,14 @@ function createEngine({
1918
3468
  const boundaries = laidOut.meta?.divisionBoundaries ?? [];
1919
3469
  if (boundaries.length > 0) {
1920
3470
  const boundaryMat = createSmartMaterial({
1921
- uniforms: { color: { value: new THREE5.Color(5601177) } },
3471
+ uniforms: { color: { value: new THREE6.Color(5601177) } },
1922
3472
  vertexShaderBody: `uniform vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
1923
3473
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
1924
3474
  transparent: true,
1925
3475
  depthWrite: false,
1926
- blending: THREE5.AdditiveBlending
3476
+ blending: THREE6.AdditiveBlending
1927
3477
  });
1928
- const boundaryGeo = new THREE5.BufferGeometry();
3478
+ const boundaryGeo = new THREE6.BufferGeometry();
1929
3479
  const bPoints = [];
1930
3480
  boundaries.forEach((angle) => {
1931
3481
  const steps = 32;
@@ -1938,8 +3488,8 @@ function createEngine({
1938
3488
  bPoints.push(p2.x, p2.y, p2.z);
1939
3489
  }
1940
3490
  });
1941
- boundaryGeo.setAttribute("position", new THREE5.Float32BufferAttribute(bPoints, 3));
1942
- boundaryLines = new THREE5.LineSegments(boundaryGeo, boundaryMat);
3491
+ boundaryGeo.setAttribute("position", new THREE6.Float32BufferAttribute(bPoints, 3));
3492
+ boundaryLines = new THREE6.LineSegments(boundaryGeo, boundaryMat);
1943
3493
  boundaryLines.frustumCulled = false;
1944
3494
  root.add(boundaryLines);
1945
3495
  }
@@ -1958,7 +3508,7 @@ function createEngine({
1958
3508
  const r_norm = Math.sqrt(x * x + y * y);
1959
3509
  const phi = Math.atan2(y, x);
1960
3510
  const theta = r_norm * (Math.PI / 2);
1961
- return new THREE5.Vector3(
3511
+ return new THREE6.Vector3(
1962
3512
  Math.sin(theta) * Math.cos(phi),
1963
3513
  Math.cos(theta),
1964
3514
  Math.sin(theta) * Math.sin(phi)
@@ -1971,22 +3521,23 @@ function createEngine({
1971
3521
  }
1972
3522
  }
1973
3523
  if (polyPoints.length > 0) {
1974
- const polyGeo = new THREE5.BufferGeometry();
1975
- polyGeo.setAttribute("position", new THREE5.Float32BufferAttribute(polyPoints, 3));
3524
+ const polyGeo = new THREE6.BufferGeometry();
3525
+ polyGeo.setAttribute("position", new THREE6.Float32BufferAttribute(polyPoints, 3));
1976
3526
  const polyMat = createSmartMaterial({
1977
- uniforms: { color: { value: new THREE5.Color(3718648) } },
3527
+ uniforms: { color: { value: new THREE6.Color(3718648) } },
1978
3528
  // Cyan-ish
1979
3529
  vertexShaderBody: `uniform vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
1980
3530
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
1981
3531
  transparent: true,
1982
3532
  depthWrite: false,
1983
- blending: THREE5.AdditiveBlending
3533
+ blending: THREE6.AdditiveBlending
1984
3534
  });
1985
- const polyLines = new THREE5.LineSegments(polyGeo, polyMat);
3535
+ const polyLines = new THREE6.LineSegments(polyGeo, polyMat);
1986
3536
  polyLines.frustumCulled = false;
1987
3537
  root.add(polyLines);
1988
3538
  }
1989
3539
  }
3540
+ labelManager.setLabels(dynamicLabels);
1990
3541
  resize();
1991
3542
  }
1992
3543
  let lastData = void 0;
@@ -2007,6 +3558,10 @@ function createEngine({
2007
3558
  }
2008
3559
  function setConfig(cfg) {
2009
3560
  currentConfig = cfg;
3561
+ applyGroundTheme(cfg);
3562
+ const externalFocusId = cfg.focus?.nodeId;
3563
+ if (typeof externalFocusId === "string") focusedNodeId = externalFocusId;
3564
+ if (externalFocusId === null) focusedNodeId = null;
2010
3565
  if (cfg.projection) setProjection(cfg.projection);
2011
3566
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
2012
3567
  state.lon = cfg.camera.lon;
@@ -2047,6 +3602,25 @@ function createEngine({
2047
3602
  if (lastModel) buildFromModel(lastModel, cfg);
2048
3603
  }
2049
3604
  if (cfg.constellations) {
3605
+ const getLayoutPosition = (id) => {
3606
+ const n = nodeById.get(id);
3607
+ if (!n) return null;
3608
+ const x = n.meta?.x ?? 0;
3609
+ const y = n.meta?.y ?? 0;
3610
+ const z = n.meta?.z ?? 0;
3611
+ if (z === 0) {
3612
+ const radius = cfg.layout?.radius ?? 2e3;
3613
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3614
+ const phi = Math.atan2(y, x);
3615
+ const theta = r_norm * (Math.PI / 2);
3616
+ return new THREE6.Vector3(
3617
+ Math.sin(theta) * Math.cos(phi),
3618
+ Math.cos(theta),
3619
+ Math.sin(theta) * Math.sin(phi)
3620
+ ).multiplyScalar(radius);
3621
+ }
3622
+ return new THREE6.Vector3(x, y, z);
3623
+ };
2050
3624
  constellationLayer.load(cfg.constellations, (id) => {
2051
3625
  if (cfg.arrangement && cfg.arrangement[id]) {
2052
3626
  const arr = cfg.arrangement[id];
@@ -2057,17 +3631,16 @@ function createEngine({
2057
3631
  const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
2058
3632
  const phi = Math.atan2(y, x);
2059
3633
  const theta = r_norm * (Math.PI / 2);
2060
- return new THREE5.Vector3(
3634
+ return new THREE6.Vector3(
2061
3635
  Math.sin(theta) * Math.cos(phi),
2062
3636
  Math.cos(theta),
2063
3637
  Math.sin(theta) * Math.sin(phi)
2064
3638
  ).multiplyScalar(radius);
2065
3639
  }
2066
- return new THREE5.Vector3(arr.position[0], arr.position[1], arr.position[2]);
3640
+ return new THREE6.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2067
3641
  }
2068
- const n = nodeById.get(id);
2069
- return n ? getPosition(n) : null;
2070
- });
3642
+ return getLayoutPosition(id);
3643
+ }, getLayoutPosition);
2071
3644
  }
2072
3645
  }
2073
3646
  function setHandlers(next) {
@@ -2092,7 +3665,7 @@ function createEngine({
2092
3665
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
2093
3666
  }
2094
3667
  for (const item of constellationLayer.getItems()) {
2095
- arr[item.config.id] = { position: [item.mesh.position.x, item.mesh.position.y, item.mesh.position.z] };
3668
+ arr[item.config.id] = { position: [item.center.x, item.center.y, item.center.z] };
2096
3669
  }
2097
3670
  Object.assign(arr, state.tempArrangement);
2098
3671
  return arr;
@@ -2116,60 +3689,70 @@ function createEngine({
2116
3689
  const uAspect = camera.aspect;
2117
3690
  const w = rect.width;
2118
3691
  const h = rect.height;
2119
- let closestLabel = null;
2120
- const LABEL_THRESHOLD = isTouchDevice ? 48 : 40;
2121
- let minLabelDist = LABEL_THRESHOLD;
2122
- for (const item of dynamicLabels) {
2123
- if (!item.obj.visible) continue;
2124
- if (isNodeFiltered(item.node)) continue;
2125
- const pWorld = item.obj.position;
2126
- const pProj = smartProjectJS(pWorld);
2127
- if (currentProjection.isClipped(pProj.z)) continue;
2128
- const xNDC = pProj.x * uScale / uAspect;
2129
- const yNDC = pProj.y * uScale;
2130
- const sX = (xNDC * 0.5 + 0.5) * w;
2131
- const sY = (-yNDC * 0.5 + 0.5) * h;
2132
- const dx = mX - sX;
2133
- const dy = mY - sY;
2134
- const d = Math.sqrt(dx * dx + dy * dy);
2135
- if (d < minLabelDist) {
2136
- minLabelDist = d;
2137
- closestLabel = item;
3692
+ const isEditMode = currentConfig?.editable ?? false;
3693
+ function pickLabel(threshold) {
3694
+ let closest = null;
3695
+ let minDist = threshold;
3696
+ for (const item of dynamicLabels) {
3697
+ if (!item.obj.visible) continue;
3698
+ if (isNodeFiltered(item.node)) continue;
3699
+ const labelMat = item.obj.material;
3700
+ if ((labelMat?.uniforms?.uAlpha?.value ?? 0) < 0.1) continue;
3701
+ const pWorld = item.obj.position;
3702
+ const pProj = smartProjectJS(pWorld);
3703
+ if (currentProjection.isClipped(pProj.z)) continue;
3704
+ const xNDC = pProj.x * uScale / uAspect;
3705
+ const yNDC = pProj.y * uScale;
3706
+ const sX = (xNDC * 0.5 + 0.5) * w;
3707
+ const sY = (-yNDC * 0.5 + 0.5) * h;
3708
+ const d = Math.sqrt((mX - sX) ** 2 + (mY - sY) ** 2);
3709
+ if (d < minDist) {
3710
+ minDist = d;
3711
+ closest = item;
3712
+ }
2138
3713
  }
3714
+ return closest;
2139
3715
  }
3716
+ if (isEditMode) {
3717
+ if (starPoints) {
3718
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
3719
+ raycaster.ray.origin.set(0, 0, 0);
3720
+ raycaster.ray.direction.copy(worldDir);
3721
+ raycaster.params.Points.threshold = 65 * (state.fov / 60);
3722
+ const hits = raycaster.intersectObject(starPoints, false);
3723
+ const pointHit = hits[0];
3724
+ if (pointHit && pointHit.index !== void 0) {
3725
+ const id = starIndexToId[pointHit.index];
3726
+ if (id) {
3727
+ const node = nodeById.get(id);
3728
+ if (node && !isNodeFiltered(node)) {
3729
+ const attr = starPoints.geometry.attributes.position;
3730
+ const starPos = new THREE6.Vector3(attr.getX(pointHit.index), attr.getY(pointHit.index), attr.getZ(pointHit.index));
3731
+ return { type: "star", node, index: pointHit.index, point: starPos, object: void 0 };
3732
+ }
3733
+ }
3734
+ }
3735
+ }
3736
+ const editLabel = pickLabel(isTouchDevice ? 48 : 32);
3737
+ if (editLabel) {
3738
+ return { type: "label", node: editLabel.node, object: editLabel.obj, point: editLabel.obj.position.clone(), index: void 0 };
3739
+ }
3740
+ return void 0;
3741
+ }
3742
+ const closestLabel = pickLabel(isTouchDevice ? 48 : 40);
2140
3743
  if (closestLabel) {
2141
3744
  return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
2142
3745
  }
2143
3746
  let closestConst = null;
2144
3747
  let minConstDist = Infinity;
3748
+ const artWorldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
3749
+ raycaster.ray.origin.set(0, 0, 0);
3750
+ raycaster.ray.direction.copy(artWorldDir);
2145
3751
  for (const item of constellationLayer.getItems()) {
2146
3752
  if (!item.mesh.visible) continue;
2147
- const pWorld = item.mesh.position;
2148
- const pProj = smartProjectJS(pWorld);
2149
- if (currentProjection.isClipped(pProj.z)) continue;
2150
- const uniforms = item.material.uniforms;
2151
- if (!uniforms || !uniforms.uSize) continue;
2152
- const uSize = uniforms.uSize.value;
2153
- const uImgAspect = uniforms.uImgAspect.value;
2154
- const uImgRotation = uniforms.uImgRotation.value;
2155
- const dist = pWorld.length();
2156
- if (dist < 1e-3) continue;
2157
- const scale = uSize / dist * uScale;
2158
- const halfH_px = scale / 2 * (h / 2);
2159
- const halfW_px = halfH_px * uImgAspect;
2160
- const xNDC = pProj.x * uScale / uAspect;
2161
- const yNDC = pProj.y * uScale;
2162
- const sX = (xNDC * 0.5 + 0.5) * w;
2163
- const sY = (-yNDC * 0.5 + 0.5) * h;
2164
- const dx = mX - sX;
2165
- const dy = mY - sY;
2166
- const dy_cart = -dy;
2167
- const cr = Math.cos(-uImgRotation);
2168
- const sr = Math.sin(-uImgRotation);
2169
- const localX = dx * cr - dy_cart * sr;
2170
- const localY = dx * sr + dy_cart * cr;
2171
- if (Math.abs(localX) < halfW_px * 1.2 && Math.abs(localY) < halfH_px * 1.2) {
2172
- const d = Math.sqrt(dx * dx + dy * dy);
3753
+ const hits = raycaster.intersectObject(item.mesh, false);
3754
+ if (hits.length > 0) {
3755
+ const d = hits[0].distance;
2173
3756
  if (!closestConst || d < minConstDist) {
2174
3757
  minConstDist = d;
2175
3758
  closestConst = item;
@@ -2182,7 +3765,7 @@ function createEngine({
2182
3765
  label: closestConst.config.title,
2183
3766
  level: -1
2184
3767
  };
2185
- return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.mesh.position.clone(), index: void 0 };
3768
+ return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.center.clone(), index: void 0 };
2186
3769
  }
2187
3770
  if (starPoints) {
2188
3771
  const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
@@ -2213,11 +3796,20 @@ function createEngine({
2213
3796
  if (hit) {
2214
3797
  state.dragMode = "node";
2215
3798
  state.draggedNodeId = hit.node.id;
2216
- state.draggedDist = hit.point.length();
3799
+ if (hit.type === "star" && hit.index !== void 0 && starPoints) {
3800
+ const attr = starPoints.geometry.attributes.position;
3801
+ const starWorldPos = new THREE6.Vector3(attr.getX(hit.index), attr.getY(hit.index), attr.getZ(hit.index));
3802
+ state.draggedDist = starWorldPos.length();
3803
+ } else {
3804
+ state.draggedDist = hit.point.length();
3805
+ }
2217
3806
  document.body.style.cursor = "crosshair";
3807
+ state.velocityX = 0;
3808
+ state.velocityY = 0;
2218
3809
  if (hit.type === "star") {
2219
3810
  state.draggedStarIndex = hit.index ?? -1;
2220
3811
  state.draggedGroup = null;
3812
+ state.tempArrangement = {};
2221
3813
  } else if (hit.type === "label") {
2222
3814
  const bookId = hit.node.id;
2223
3815
  const children = [];
@@ -2228,7 +3820,7 @@ function createEngine({
2228
3820
  if (starId) {
2229
3821
  const starNode = nodeById.get(starId);
2230
3822
  if (starNode && starNode.parent === bookId) {
2231
- children.push({ index: i, initialPos: new THREE5.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
3823
+ children.push({ index: i, initialPos: new THREE6.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
2232
3824
  }
2233
3825
  }
2234
3826
  }
@@ -2236,7 +3828,7 @@ function createEngine({
2236
3828
  state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
2237
3829
  state.draggedStarIndex = -1;
2238
3830
  } else if (hit.type === "constellation") {
2239
- state.draggedGroup = null;
3831
+ state.draggedGroup = { labelInitialPos: hit.point.clone(), children: [] };
2240
3832
  state.draggedStarIndex = -1;
2241
3833
  }
2242
3834
  }
@@ -2265,6 +3857,9 @@ function createEngine({
2265
3857
  const attr = starPoints.geometry.attributes.position;
2266
3858
  attr.setXYZ(idx, newPos.x, newPos.y, newPos.z);
2267
3859
  attr.needsUpdate = true;
3860
+ editHoverTargetPos = newPos.clone();
3861
+ const starId = starIndexToId[idx];
3862
+ if (starId) state.tempArrangement[starId] = { position: [newPos.x, newPos.y, newPos.z] };
2268
3863
  } else if (state.draggedGroup && state.draggedNodeId) {
2269
3864
  const group = state.draggedGroup;
2270
3865
  const item = dynamicLabels.find((l) => l.node.id === state.draggedNodeId);
@@ -2274,16 +3869,19 @@ function createEngine({
2274
3869
  } else if (state.draggedNodeId) {
2275
3870
  const cItem = constellationLayer.getItems().find((c) => c.config.id === state.draggedNodeId);
2276
3871
  if (cItem) {
2277
- cItem.mesh.position.copy(newPos);
3872
+ const vS = group.labelInitialPos.clone().normalize();
3873
+ const vE = newPos.clone().normalize();
3874
+ cItem.mesh.quaternion.setFromUnitVectors(vS, vE);
3875
+ cItem.center.copy(newPos);
2278
3876
  state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
2279
3877
  }
2280
3878
  }
2281
3879
  const vStart = group.labelInitialPos.clone().normalize();
2282
3880
  const vEnd = newPos.clone().normalize();
2283
- const q = new THREE5.Quaternion().setFromUnitVectors(vStart, vEnd);
3881
+ const q = new THREE6.Quaternion().setFromUnitVectors(vStart, vEnd);
2284
3882
  if (starPoints && group.children.length > 0) {
2285
3883
  const attr = starPoints.geometry.attributes.position;
2286
- const tempVec = new THREE5.Vector3();
3884
+ const tempVec = new THREE6.Vector3();
2287
3885
  for (const child of group.children) {
2288
3886
  tempVec.copy(child.initialPos).applyQuaternion(q);
2289
3887
  attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
@@ -2319,7 +3917,7 @@ function createEngine({
2319
3917
  if (res) {
2320
3918
  hoverLabelMat.uniforms.uMap.value = res.tex;
2321
3919
  const baseScale = 0.03;
2322
- const size = new THREE5.Vector2(baseScale * res.aspect, baseScale);
3920
+ const size = new THREE6.Vector2(baseScale * res.aspect, baseScale);
2323
3921
  hoverLabelMat.uniforms.uSize.value = size;
2324
3922
  hoverLabelMesh.scale.set(size.x, size.y, 1);
2325
3923
  }
@@ -2327,10 +3925,19 @@ function createEngine({
2327
3925
  hoverLabelMesh.position.copy(hit.point);
2328
3926
  hoverLabelMat.uniforms.uAlpha.value = 1;
2329
3927
  hoverLabelMesh.visible = true;
3928
+ if (currentConfig?.editable && hit.type === "star" && hit.index !== void 0 && starPoints) {
3929
+ const attr = starPoints.geometry.attributes.position;
3930
+ editHoverTargetPos = new THREE6.Vector3(attr.getX(hit.index), attr.getY(hit.index), attr.getZ(hit.index));
3931
+ } else if (currentConfig?.editable && hit.type === "star") {
3932
+ editHoverTargetPos = hit.point.clone();
3933
+ }
2330
3934
  } else {
2331
3935
  currentHoverNodeId = null;
2332
3936
  hoverLabelMat.uniforms.uAlpha.value = 0;
2333
3937
  hoverLabelMesh.visible = false;
3938
+ if (currentConfig?.editable && state.dragMode !== "node") {
3939
+ editHoverTargetPos = null;
3940
+ }
2334
3941
  }
2335
3942
  if (hit?.node.id !== handlers._lastHoverId) {
2336
3943
  handlers._lastHoverId = hit?.node.id;
@@ -2347,6 +3954,7 @@ function createEngine({
2347
3954
  if (state.dragMode === "node") {
2348
3955
  const fullArr = getFullArrangement();
2349
3956
  handlers.onArrangementChange?.(fullArr);
3957
+ editDropFlash = 1;
2350
3958
  state.dragMode = "none";
2351
3959
  state.draggedNodeId = null;
2352
3960
  state.draggedStarIndex = -1;
@@ -2361,10 +3969,12 @@ function createEngine({
2361
3969
  if (hit) {
2362
3970
  handlers.onSelect?.(hit.node);
2363
3971
  constellationLayer.setFocused(hit.node.id);
3972
+ focusedNodeId = hit.node.id;
2364
3973
  if (hit.node.level === 2) setFocusedBook(hit.node.id);
2365
3974
  else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2366
3975
  } else {
2367
3976
  setFocusedBook(null);
3977
+ focusedNodeId = null;
2368
3978
  }
2369
3979
  }
2370
3980
  } else {
@@ -2372,10 +3982,12 @@ function createEngine({
2372
3982
  if (hit) {
2373
3983
  handlers.onSelect?.(hit.node);
2374
3984
  constellationLayer.setFocused(hit.node.id);
3985
+ focusedNodeId = hit.node.id;
2375
3986
  if (hit.node.level === 2) setFocusedBook(hit.node.id);
2376
3987
  else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2377
3988
  } else {
2378
3989
  setFocusedBook(null);
3990
+ focusedNodeId = null;
2379
3991
  }
2380
3992
  }
2381
3993
  }
@@ -2391,7 +4003,7 @@ function createEngine({
2391
4003
  handlers.onFovChange?.(state.fov);
2392
4004
  updateUniforms();
2393
4005
  const vAfter = getMouseViewVector(state.fov, aspect);
2394
- const quaternion = new THREE5.Quaternion().setFromUnitVectors(vAfter, vBefore);
4006
+ const quaternion = new THREE6.Quaternion().setFromUnitVectors(vAfter, vBefore);
2395
4007
  const dampStartFov = 40;
2396
4008
  const dampEndFov = 120;
2397
4009
  let spinAmount = 1;
@@ -2400,27 +4012,27 @@ function createEngine({
2400
4012
  spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2401
4013
  }
2402
4014
  if (spinAmount < 0.999) {
2403
- const identityQuat = new THREE5.Quaternion();
4015
+ const identityQuat = new THREE6.Quaternion();
2404
4016
  quaternion.slerp(identityQuat, 1 - spinAmount);
2405
4017
  }
2406
4018
  const y = Math.sin(state.lat);
2407
4019
  const r = Math.cos(state.lat);
2408
4020
  const x = r * Math.sin(state.lon);
2409
4021
  const z = -r * Math.cos(state.lon);
2410
- const currentLook = new THREE5.Vector3(x, y, z);
4022
+ const currentLook = new THREE6.Vector3(x, y, z);
2411
4023
  const camForward = currentLook.clone().normalize();
2412
4024
  const camUp = camera.up.clone();
2413
- const camRight = new THREE5.Vector3().crossVectors(camForward, camUp).normalize();
2414
- const camUpOrtho = new THREE5.Vector3().crossVectors(camRight, camForward).normalize();
2415
- const mat = new THREE5.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
2416
- const qOld = new THREE5.Quaternion().setFromRotationMatrix(mat);
4025
+ const camRight = new THREE6.Vector3().crossVectors(camForward, camUp).normalize();
4026
+ const camUpOrtho = new THREE6.Vector3().crossVectors(camRight, camForward).normalize();
4027
+ const mat = new THREE6.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
4028
+ const qOld = new THREE6.Quaternion().setFromRotationMatrix(mat);
2417
4029
  const qNew = qOld.clone().multiply(quaternion);
2418
- const newForward = new THREE5.Vector3(0, 0, -1).applyQuaternion(qNew);
4030
+ const newForward = new THREE6.Vector3(0, 0, -1).applyQuaternion(qNew);
2419
4031
  state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
2420
4032
  state.lon = Math.atan2(newForward.x, -newForward.z);
2421
- const newUp = new THREE5.Vector3(0, 1, 0).applyQuaternion(qNew);
4033
+ const newUp = new THREE6.Vector3(0, 1, 0).applyQuaternion(qNew);
2422
4034
  camera.up.copy(newUp);
2423
- if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
4035
+ if (!getSceneDebug()?.disableZenithBias && e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
2424
4036
  const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
2425
4037
  let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
2426
4038
  t = Math.max(0, Math.min(1, t));
@@ -2532,7 +4144,7 @@ function createEngine({
2532
4144
  state.fov = state.pinchStartFov / scale;
2533
4145
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2534
4146
  handlers.onFovChange?.(state.fov);
2535
- if (state.fov > prevFov && state.fov > ENGINE_CONFIG.zenithStartFov) {
4147
+ if (!getSceneDebug()?.disableZenithBias && state.fov > prevFov && state.fov > ENGINE_CONFIG.zenithStartFov) {
2536
4148
  const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
2537
4149
  let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
2538
4150
  t = Math.max(0, Math.min(1, t));
@@ -2768,14 +4380,24 @@ function createEngine({
2768
4380
  const r = Math.cos(state.lat);
2769
4381
  const x = r * Math.sin(state.lon);
2770
4382
  const z = -r * Math.cos(state.lon);
2771
- const target = new THREE5.Vector3(x, y, z);
2772
- const idealUp = new THREE5.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
4383
+ const target = new THREE6.Vector3(x, y, z);
4384
+ const idealUp = new THREE6.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
2773
4385
  camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
2774
4386
  camera.up.normalize();
2775
4387
  camera.lookAt(target);
2776
4388
  camera.updateMatrixWorld();
2777
4389
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
4390
+ if (groundMaterial?.uniforms?.uZenithFlatten) {
4391
+ const flatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6.MathUtils.smoothstep(
4392
+ state.lat,
4393
+ THREE6.MathUtils.degToRad(68),
4394
+ THREE6.MathUtils.degToRad(88)
4395
+ );
4396
+ groundMaterial.uniforms.uZenithFlatten.value = flatten;
4397
+ }
2778
4398
  updateUniforms();
4399
+ if (getSceneDebug()?.horizonDiagnostics) runHorizonDiagnostics(now);
4400
+ updateChapterLabelAnchors();
2779
4401
  const nowSec = now / 1e3;
2780
4402
  const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2781
4403
  lastTickTime = nowSec;
@@ -2783,21 +4405,60 @@ function createEngine({
2783
4405
  linesFader.update(dt);
2784
4406
  artFader.target = currentConfig?.showConstellationArt ?? false;
2785
4407
  artFader.update(dt);
2786
- constellationLayer.update(state.fov, artFader.eased > 0.01);
2787
- if (artFader.eased < 1) {
2788
- constellationLayer.setGlobalOpacity?.(artFader.eased);
2789
- }
4408
+ constellationLayer.update(state.fov, artFader.eased > 0.01, camera, dt);
4409
+ const baseArtOpacity = THREE6.MathUtils.clamp(currentConfig?.constellationBaseOpacity ?? 1, 0, 300);
4410
+ constellationLayer.setGlobalOpacity?.(artFader.eased * baseArtOpacity);
2790
4411
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
4412
+ if (backdropStarsMaterial?.uniforms) {
4413
+ const minGain = THREE6.MathUtils.clamp(currentConfig?.backdropWideFovGain ?? 0.42, 0, 1);
4414
+ const fovT = THREE6.MathUtils.smoothstep(state.fov, 24, 100);
4415
+ const gain = THREE6.MathUtils.lerp(1, minGain, fovT);
4416
+ backdropStarsMaterial.uniforms.uBackdropGain.value = gain;
4417
+ backdropStarsMaterial.uniforms.uBackdropEnergy.value = THREE6.MathUtils.clamp(currentConfig?.backdropEnergy ?? 2.2, 0.2, 5);
4418
+ backdropStarsMaterial.uniforms.uBackdropSizeExp.value = THREE6.MathUtils.clamp(currentConfig?.backdropSizeExponent ?? 0.9, 0.4, 1.4);
4419
+ }
4420
+ if (skyBackgroundMesh) skyBackgroundMesh.visible = currentConfig?.background !== "transparent";
2791
4421
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2792
- const DIVISION_THRESHOLD = 60;
2793
- const showDivisions = state.fov > DIVISION_THRESHOLD;
4422
+ if (moonMesh) moonMesh.visible = currentConfig?.showMoon ?? true;
4423
+ if (moonGlowMesh) moonGlowMesh.visible = currentConfig?.showMoon ?? true;
4424
+ const showSun = currentConfig?.showSunrise ?? true;
4425
+ if (sunDiscMesh) sunDiscMesh.visible = showSun;
4426
+ if (sunHaloMesh) sunHaloMesh.visible = showSun;
4427
+ if (milkyWayMesh) milkyWayMesh.visible = currentConfig?.showMilkyWay ?? true;
4428
+ if (editHoverMesh) {
4429
+ const ringMat = editHoverMesh.material;
4430
+ const isEditing = currentConfig?.editable ?? false;
4431
+ const isDraggingStar = state.dragMode === "node" && state.draggedStarIndex !== -1;
4432
+ const hasTarget = isEditing && editHoverTargetPos !== null;
4433
+ if (hasTarget) {
4434
+ editHoverMesh.position.copy(editHoverTargetPos);
4435
+ const pulseBoost = editDropFlash * 1.8;
4436
+ const targetAlpha = 0.8 + pulseBoost;
4437
+ ringMat.uniforms.uRingAlpha.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingAlpha.value, targetAlpha, 0.15);
4438
+ const tGold = isDraggingStar ? 1 : editDropFlash;
4439
+ const targetColor = new THREE6.Color(
4440
+ THREE6.MathUtils.lerp(0.55, 1, tGold),
4441
+ THREE6.MathUtils.lerp(0.88, 0.82, tGold),
4442
+ THREE6.MathUtils.lerp(1, 0.18, tGold)
4443
+ );
4444
+ ringMat.uniforms.uRingColor.value.lerp(targetColor, 0.18);
4445
+ const baseSize = isDraggingStar ? 0.075 : 0.06;
4446
+ const targetSize = baseSize * (1 + editDropFlash * 0.7);
4447
+ ringMat.uniforms.uRingSize.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingSize.value, targetSize, 0.18);
4448
+ editDropFlash = Math.max(0, editDropFlash - dt * 3);
4449
+ } else {
4450
+ ringMat.uniforms.uRingAlpha.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingAlpha.value, 0, 0.15);
4451
+ ringMat.uniforms.uRingSize.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingSize.value, 0.06, 0.2);
4452
+ }
4453
+ }
2794
4454
  if (constellationLines) {
2795
4455
  constellationLines.visible = linesFader.eased > 0.01;
2796
4456
  if (constellationLines.visible && constellationLines.material) {
2797
4457
  const mat = constellationLines.material;
2798
4458
  if (mat.uniforms?.color) {
2799
4459
  mat.uniforms.color.value.setHex(11193599);
2800
- mat.opacity = linesFader.eased;
4460
+ if (mat.uniforms.uReveal) mat.uniforms.uReveal.value = linesFader.eased;
4461
+ mat.opacity = 1;
2801
4462
  }
2802
4463
  }
2803
4464
  }
@@ -2808,116 +4469,35 @@ function createEngine({
2808
4469
  const screenW = rect.width;
2809
4470
  const screenH = rect.height;
2810
4471
  const aspect = screenW / screenH;
2811
- const labelsToCheck = [];
2812
- const occupied = [];
2813
- function isOverlapping(x2, y2, w, h) {
2814
- for (const r2 of occupied) {
2815
- if (x2 < r2.x + r2.w && x2 + w > r2.x && y2 < r2.y + r2.h && y2 + h > r2.y) return true;
2816
- }
2817
- return false;
2818
- }
2819
- const showBookLabels = currentConfig?.showBookLabels === true;
2820
- const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2821
- const showChapterLabels = currentConfig?.showChapterLabels === true;
2822
- const showGroupLabels = currentConfig?.showGroupLabels === true;
2823
- const showChapters = state.fov < 45;
2824
- for (const item of dynamicLabels) {
2825
- const uniforms = item.obj.material.uniforms;
2826
- const level = item.node.level;
2827
- let isEnabled = false;
2828
- if (level === 2 && showBookLabels) isEnabled = true;
2829
- else if (level === 1 && showDivisionLabels) isEnabled = true;
2830
- else if (level === 3 && showChapterLabels) isEnabled = true;
2831
- else if (level === 2.5 && showGroupLabels) isEnabled = true;
2832
- if (!isEnabled) {
2833
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2834
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2835
- continue;
2836
- }
2837
- const pWorld = item.obj.position;
2838
- const pProj = smartProjectJS(pWorld);
2839
- if (pProj.z > 0.2) {
2840
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2841
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2842
- continue;
2843
- }
2844
- if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2845
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2846
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2847
- continue;
2848
- }
2849
- const ndcX = pProj.x * globalUniforms.uScale.value / aspect;
2850
- const ndcY = pProj.y * globalUniforms.uScale.value;
2851
- const sX = (ndcX * 0.5 + 0.5) * screenW;
2852
- const sY = (-ndcY * 0.5 + 0.5) * screenH;
2853
- const size = uniforms.uSize.value;
2854
- const pixelH = size.y * screenH * 0.8;
2855
- const pixelW = size.x * screenH * 0.8;
2856
- labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level, ndcX, ndcY });
2857
- }
2858
- const hoverId = handlers._lastHoverId;
4472
+ const hoverId = handlers._lastHoverId ?? null;
2859
4473
  const selectedId = state.draggedNodeId;
2860
- labelsToCheck.sort((a, b) => {
2861
- const getScore = (l) => {
2862
- if (l.item.node.id === selectedId) return 10;
2863
- if (l.item.node.id === hoverId) return 9;
2864
- const level = l.level;
2865
- if (level === 2) return 5;
2866
- if (level === 1) return showDivisions ? 6 : 1;
2867
- return 0;
2868
- };
2869
- return getScore(b) - getScore(a);
4474
+ labelManager.update({
4475
+ nowMs: now,
4476
+ dt,
4477
+ fov: state.fov,
4478
+ camera,
4479
+ projectionId: currentProjection.id,
4480
+ screenW,
4481
+ screenH,
4482
+ globalScale: globalUniforms.uScale.value,
4483
+ aspect,
4484
+ hoverId,
4485
+ selectedId,
4486
+ focusedId: focusedNodeId,
4487
+ shouldFilter: !!currentFilter && filterStrength > 0.01,
4488
+ isNodeFiltered: (node) => {
4489
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
4490
+ return !!nodeToCheck && isNodeFiltered(nodeToCheck);
4491
+ },
4492
+ toggles: {
4493
+ showBookLabels: currentConfig?.showBookLabels === true,
4494
+ showDivisionLabels: currentConfig?.showDivisionLabels === true,
4495
+ showChapterLabels: currentConfig?.showChapterLabels === true,
4496
+ showGroupLabels: currentConfig?.showGroupLabels === true
4497
+ },
4498
+ config: currentConfig?.labelBehavior,
4499
+ project: smartProjectJS
2870
4500
  });
2871
- for (const l of labelsToCheck) {
2872
- let target2 = 0;
2873
- const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2874
- if (l.level === 1) {
2875
- let rot = 0;
2876
- const isWideAngle = currentProjection.id !== "perspective";
2877
- if (isWideAngle) {
2878
- const dx = l.sX - screenW / 2;
2879
- const dy = l.sY - screenH / 2;
2880
- rot = Math.atan2(-dy, -dx) - Math.PI / 2;
2881
- }
2882
- l.uniforms.uAngle.value = THREE5.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2883
- }
2884
- if (l.level === 2) {
2885
- {
2886
- target2 = 1;
2887
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2888
- }
2889
- } else if (l.level === 1) {
2890
- if (showDivisions || isSpecial) {
2891
- const pad = -5;
2892
- if (!isOverlapping(l.sX - l.w / 2 - pad, l.sY - l.h / 2 - pad, l.w + pad * 2, l.h + pad * 2)) {
2893
- target2 = 1;
2894
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2895
- }
2896
- }
2897
- } else if (l.level === 2.5 || l.level === 3) {
2898
- if (showChapters || isSpecial) {
2899
- target2 = 1;
2900
- if (!isSpecial) {
2901
- const dist = Math.sqrt(l.ndcX * l.ndcX + l.ndcY * l.ndcY);
2902
- const focusFade = 1 - THREE5.MathUtils.smoothstep(0.4, 0.7, dist);
2903
- target2 *= focusFade;
2904
- }
2905
- }
2906
- }
2907
- if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2908
- const node = l.item.node;
2909
- if (node.level === 3) {
2910
- target2 = 0;
2911
- } else if (node.level === 2 || node.level === 2.5) {
2912
- const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2913
- if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2914
- target2 = 0;
2915
- }
2916
- }
2917
- }
2918
- l.uniforms.uAlpha.value = THREE5.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2919
- l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2920
- }
2921
4501
  renderer.render(scene, camera);
2922
4502
  }
2923
4503
  function stop() {
@@ -2942,6 +4522,48 @@ function createEngine({
2942
4522
  function dispose() {
2943
4523
  stop();
2944
4524
  constellationLayer.dispose();
4525
+ if (moonMesh) {
4526
+ scene.remove(moonMesh);
4527
+ moonMesh.geometry.dispose();
4528
+ moonMesh.material.dispose();
4529
+ moonMesh = null;
4530
+ }
4531
+ if (moonGlowMesh) {
4532
+ scene.remove(moonGlowMesh);
4533
+ moonGlowMesh.geometry.dispose();
4534
+ moonGlowMesh.material.dispose();
4535
+ moonGlowMesh = null;
4536
+ }
4537
+ if (sunDiscMesh) {
4538
+ scene.remove(sunDiscMesh);
4539
+ sunDiscMesh.geometry.dispose();
4540
+ sunDiscMesh.material.dispose();
4541
+ sunDiscMesh = null;
4542
+ }
4543
+ if (sunHaloMesh) {
4544
+ scene.remove(sunHaloMesh);
4545
+ sunHaloMesh.geometry.dispose();
4546
+ sunHaloMesh.material.dispose();
4547
+ sunHaloMesh = null;
4548
+ }
4549
+ if (milkyWayMesh) {
4550
+ scene.remove(milkyWayMesh);
4551
+ milkyWayMesh.geometry.dispose();
4552
+ milkyWayMesh.material.dispose();
4553
+ milkyWayMesh = null;
4554
+ }
4555
+ if (skyBackgroundMesh) {
4556
+ scene.remove(skyBackgroundMesh);
4557
+ skyBackgroundMesh.geometry.dispose();
4558
+ skyBackgroundMesh.material.dispose();
4559
+ skyBackgroundMesh = null;
4560
+ }
4561
+ if (editHoverMesh) {
4562
+ scene.remove(editHoverMesh);
4563
+ editHoverMesh.geometry.dispose();
4564
+ editHoverMesh.material.dispose();
4565
+ editHoverMesh = null;
4566
+ }
2945
4567
  renderer.dispose();
2946
4568
  renderer.domElement.remove();
2947
4569
  }
@@ -2961,6 +4583,7 @@ function createEngine({
2961
4583
  function flyTo(nodeId, targetFov) {
2962
4584
  const node = nodeById.get(nodeId);
2963
4585
  if (!node) return;
4586
+ focusedNodeId = nodeId;
2964
4587
  const pos = getPosition(node).normalize();
2965
4588
  flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2966
4589
  flyToTargetLon = Math.atan2(pos.x, -pos.z);
@@ -2991,10 +4614,11 @@ var init_createEngine = __esm({
2991
4614
  init_ConstellationArtworkLayer();
2992
4615
  init_projections();
2993
4616
  init_fader();
4617
+ init_LabelManager();
2994
4618
  ENGINE_CONFIG = {
2995
4619
  minFov: 1,
2996
4620
  maxFov: 135,
2997
- defaultFov: 50,
4621
+ defaultFov: 35,
2998
4622
  dragSpeed: 125e-5,
2999
4623
  inertiaDamping: 0.92,
3000
4624
  blendStart: 35,
@@ -3021,7 +4645,7 @@ var init_createEngine = __esm({
3021
4645
  };
3022
4646
  ORDER_REVEAL_CONFIG = {
3023
4647
  globalDim: 0.85,
3024
- pulseAmplitude: 0.6,
4648
+ pulseAmplitude: 0.12,
3025
4649
  pulseDuration: 2,
3026
4650
  delayPerChapter: 0.1
3027
4651
  };
@@ -32211,7 +33835,7 @@ var RNG = class {
32211
33835
  const r = Math.sqrt(1 - y * y);
32212
33836
  const x = r * Math.cos(theta);
32213
33837
  const z = r * Math.sin(theta);
32214
- return new THREE5.Vector3(x, y, z);
33838
+ return new THREE6.Vector3(x, y, z);
32215
33839
  }
32216
33840
  };
32217
33841
  function simpleNoise3D(v, scale) {
@@ -32249,11 +33873,11 @@ function generateArrangement(bible, options = {}) {
32249
33873
  });
32250
33874
  });
32251
33875
  const bookCount = books.length;
32252
- const mwRad = THREE5.MathUtils.degToRad(opts.milkyWayAngle);
32253
- const mwNormal = new THREE5.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
33876
+ const mwRad = THREE6.MathUtils.degToRad(opts.milkyWayAngle);
33877
+ const mwNormal = new THREE6.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
32254
33878
  const anchors = [];
32255
33879
  for (let i = 0; i < bookCount; i++) {
32256
- let bestP = new THREE5.Vector3();
33880
+ let bestP = new THREE6.Vector3();
32257
33881
  let valid = false;
32258
33882
  let attempt = 0;
32259
33883
  while (!valid && attempt < 100) {
@@ -32279,7 +33903,7 @@ function generateArrangement(bible, options = {}) {
32279
33903
  arrangement[`B:${book.key}`] = { position: [anchorPos.x, anchorPos.y, anchorPos.z] };
32280
33904
  for (let c = 0; c < book.chapters; c++) {
32281
33905
  const localSpread = opts.clusterSpread * (0.8 + rng.next() * 0.4);
32282
- const offset = new THREE5.Vector3(
33906
+ const offset = new THREE6.Vector3(
32283
33907
  (rng.next() - 0.5) * 2,
32284
33908
  (rng.next() - 0.5) * 2,
32285
33909
  (rng.next() - 0.5) * 2
@@ -32300,7 +33924,7 @@ function generateArrangement(bible, options = {}) {
32300
33924
  const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
32301
33925
  const divId = `D:${book.testament}:${book.division}`;
32302
33926
  if (!divisions.has(divId)) {
32303
- divisions.set(divId, { sum: new THREE5.Vector3(), count: 0 });
33927
+ divisions.set(divId, { sum: new THREE6.Vector3(), count: 0 });
32304
33928
  }
32305
33929
  const entry = divisions.get(divId);
32306
33930
  entry.sum.add(anchorPos);