From 9f4efbed7ec41b72ff9a6e57553f369c11446dcc Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:13:46 -0400 Subject: [PATCH] feat: add compass rose on floor that glows when facing north Adds a Three.js compass rose group at y=-2 in the scene with: - 8 directional spokes (N/S/E/W cardinals + NE/SE/SW/NW intercardinals) - Outer decorative ring and center hub - North arrow with animated MeshBasicMaterial + additive-blended glow halo - Per-frame camera direction check: north arrow pulses when camera faces -Z Fixes #120 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/app.js b/app.js index 4902f2f..b1cc541 100644 --- a/app.js +++ b/app.js @@ -116,6 +116,125 @@ function buildConstellationLines() { const constellationLines = buildConstellationLines(); scene.add(constellationLines); +// === COMPASS ROSE === +// North = -Z world direction (matches camera's initial forward direction). +// The north arrow glows when the camera is facing north. + +/** + * Builds a compass rose group centered at the origin, lying in the XZ plane. + * @returns {THREE.Group} + */ +function buildCompassRose() { + const group = new THREE.Group(); + group.position.set(0, -2, 0); + + // Outer decorative ring + const ringGeo = new THREE.RingGeometry(2.85, 3.0, 64); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x1a3355, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.4, + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + group.add(ring); + + // Center hub + const hubGeo = new THREE.CircleGeometry(0.12, 16); + const hubMat = new THREE.MeshBasicMaterial({ + color: 0x3366aa, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.7, + }); + const hub = new THREE.Mesh(hubGeo, hubMat); + hub.rotation.x = -Math.PI / 2; + group.add(hub); + + // Non-north spokes: angle=0 → -Z (north), clockwise from above + const spokeMat = new THREE.LineBasicMaterial({ + color: 0x3355aa, + transparent: true, + opacity: 0.55, + }); + const nonNorthDirs = [ + { angle: Math.PI / 4, cardinal: false }, // NE + { angle: Math.PI / 2, cardinal: true }, // E + { angle: 3 * Math.PI / 4, cardinal: false }, // SE + { angle: Math.PI, cardinal: true }, // S + { angle: 5 * Math.PI / 4, cardinal: false }, // SW + { angle: 3 * Math.PI / 2, cardinal: true }, // W + { angle: 7 * Math.PI / 4, cardinal: false }, // NW + ]; + for (const { angle, cardinal } of nonNorthDirs) { + const len = cardinal ? 2.5 : 1.8; + const pts = [ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(Math.sin(angle) * len, 0, -Math.cos(angle) * len), + ]; + group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), spokeMat)); + } + + // North spoke — animated material + const northMat = new THREE.LineBasicMaterial({ + color: 0x55aaff, + transparent: true, + opacity: 0.9, + }); + const NORTH_LEN = 2.5; + group.add(new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, -NORTH_LEN), + ]), + northMat + )); + + // North arrowhead (flat triangle in XZ plane) + const arrowVerts = new Float32Array([ + 0, 0, -NORTH_LEN, + -0.18, 0, -(NORTH_LEN - 0.5), + 0.18, 0, -(NORTH_LEN - 0.5), + ]); + const arrowGeo = new THREE.BufferGeometry(); + arrowGeo.setAttribute('position', new THREE.BufferAttribute(arrowVerts, 3)); + arrowGeo.setIndex([0, 1, 2]); + const northArrowMat = new THREE.MeshBasicMaterial({ + color: 0x55aaff, + side: THREE.DoubleSide, + transparent: true, + opacity: 0.9, + }); + group.add(new THREE.Mesh(arrowGeo, northArrowMat)); + + // North glow halo (additive point sprite at arrow tip) + const glowGeo = new THREE.BufferGeometry(); + glowGeo.setAttribute('position', new THREE.BufferAttribute( + new Float32Array([0, 0, -NORTH_LEN]), 3 + )); + const glowMat = new THREE.PointsMaterial({ + color: 0x88ccff, + size: 0.3, + transparent: true, + opacity: 0.7, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + group.add(new THREE.Points(glowGeo, glowMat)); + + group.userData.northMat = northMat; + group.userData.northArrowMat = northArrowMat; + group.userData.glowMat = glowMat; + return group; +} + +const compassRose = buildCompassRose(); +scene.add(compassRose); + +// Reusable vector for per-frame camera direction (avoids GC pressure) +const _compassCamDir = new THREE.Vector3(); + // === MOUSE-DRIVEN ROTATION === let mouseX = 0; let mouseY = 0; @@ -250,6 +369,25 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // Compass rose — north glow based on camera horizontal facing direction + camera.getWorldDirection(_compassCamDir); + _compassCamDir.y = 0; + const _horizLen = _compassCamDir.length(); + if (_horizLen > 0.001) _compassCamDir.divideScalar(_horizLen); + const northDot = Math.max(0, -_compassCamDir.z); // 1 = facing -Z (north) + const facingNorth = northDot > 0.7; + const northGlow = facingNorth + ? 0.65 + northDot * 0.35 + Math.sin(elapsed * 3.5) * 0.12 + : 0.15 + northDot * 0.5; + compassRose.userData.northMat.opacity = Math.min(1.0, northGlow); + compassRose.userData.northArrowMat.opacity = Math.min(1.0, northGlow); + compassRose.userData.glowMat.size = facingNorth + ? 0.5 + Math.sin(elapsed * 4) * 0.2 + : 0.15 + northDot * 0.15; + compassRose.userData.glowMat.opacity = facingNorth + ? 0.55 + Math.sin(elapsed * 4) * 0.3 + : 0.1 + northDot * 0.2; + if (photoMode) { orbitControls.update(); composer.render(); -- 2.43.0