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