diff --git a/dashboard/index.html b/dashboard/index.html
index ee89250..9bea2eb 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -2428,6 +2428,38 @@
.tuning-btn.reset:hover {
background: rgba(255, 255, 255, 0.15);
}
+
+ /* Fresnel zone debug tooltip */
+ .fresnel-tooltip {
+ position: fixed;
+ display: none;
+ background: rgba(0, 0, 0, 0.9);
+ border: 1px solid rgba(79, 195, 247, 0.5);
+ border-radius: 6px;
+ padding: 10px 12px;
+ font-size: 12px;
+ color: #eee;
+ pointer-events: none;
+ z-index: 1000;
+ max-width: 280px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+ }
+ .fresnel-tooltip strong {
+ color: #4fc3f7;
+ }
+
+ /* Fresnel ellipsoid hover effect on link items */
+ .link-item.fresnel-hover {
+ background: rgba(79, 195, 247, 0.15);
+ border-left: 2px solid #4fc3f7;
+ }
+ .link-item.flash-highlight {
+ animation: flashLink 1s ease-out;
+ }
+ @keyframes flashLink {
+ 0%, 100% { background: transparent; }
+ 50% { background: rgba(79, 195, 247, 0.3); }
+ }
@@ -2556,6 +2588,15 @@
+
+
Debug
+
+
+
+
@@ -2591,6 +2632,8 @@
+
+
diff --git a/dashboard/js/app.js b/dashboard/js/app.js
index 2a8843c..81b7373 100644
--- a/dashboard/js/app.js
+++ b/dashboard/js/app.js
@@ -61,7 +61,13 @@
// Event dedup: set of recently processed event IDs to avoid double-processing
// from immediate broadcast + delta buffering
recentEventIDs: new Set(),
- recentEventIDsPruneAt: 0
+ recentEventIDsPruneAt: 0,
+ // Fresnel debug overlay state
+ fresnelDebugVisible: false,
+ fresnelEllipsoids: new Map(), // linkID -> { wireframe, fill, data }
+ fresnelRaycaster: new THREE.Raycaster(),
+ fresnelMouse: new THREE.Vector2(),
+ fresnelHoveredEllipsoid: null
};
// ============================================
@@ -1730,4 +1736,282 @@
if (btn) btn.classList.add('active');
}
};
+
+ // ============================================
+ // Fresnel Zone Debug Overlay
+ // ============================================
+
+ /**
+ * Toggle Fresnel zone debug overlay for all active links.
+ * @param {boolean} visible - Whether to show Fresnel zones
+ */
+ window.toggleFresnelDebugOverlay = function(visible) {
+ state.fresnelDebugVisible = visible;
+
+ if (visible) {
+ rebuildFresnelDebugEllipsoids();
+ } else {
+ clearFresnelDebugEllipsoids();
+ }
+ };
+
+ /**
+ * Rebuild Fresnel zone ellipsoids for all active links.
+ * Called when the overlay is toggled on or when links change.
+ */
+ function rebuildFresnelDebugEllipsoids() {
+ if (!state.fresnelDebugVisible) return;
+ if (!window.Fresnel) {
+ console.warn('[Fresnel Debug] Fresnel module not loaded');
+ return;
+ }
+
+ // Clear existing ellipsoids
+ clearFresnelDebugEllipsoids();
+
+ // Get node positions from Viz3D
+ var nodeMeshes = Viz3D.getNodeMesh ? Viz3D.getNodeMesh() : new Map();
+
+ // Create ellipsoids for each active link
+ state.links.forEach(function(link, linkID) {
+ var parts = linkID.split(':');
+ if (parts.length < 2) return;
+
+ var txMAC = link.nodeMAC || parts[0];
+ var rxMAC = link.peerMAC || parts[1];
+
+ var txMesh = nodeMeshes.get(txMAC);
+ var rxMesh = nodeMeshes.get(rxMAC);
+
+ if (!txMesh || !rxMesh) return;
+
+ var tx = txMesh.position;
+ var rx = rxMesh.position;
+
+ // Get channel from link health data (default to 6 for 2.4 GHz)
+ var healthData = state.worstLinkID === linkID ? { score: state.worstLinkScore } : null;
+ var channel = 6; // Default 2.4 GHz channel
+
+ // Determine color based on link health
+ var healthScore = healthData ? healthData.score : 0.5;
+ var color = getFresnelHealthColor(healthScore);
+
+ // Create Fresnel ellipsoid
+ var ellipsoid = window.Fresnel.addFresnelEllipsoid(tx, rx, channel, color);
+ if (ellipsoid) {
+ // Store link info in userData for interactions
+ ellipsoid.wireframe.userData.linkID = linkID;
+ ellipsoid.wireframe.userData.txMAC = txMAC;
+ ellipsoid.wireframe.userData.rxMAC = rxMAC;
+ ellipsoid.fill.userData.linkID = linkID;
+ ellipsoid.fill.userData.txMAC = txMAC;
+ ellipsoid.fill.userData.rxMAC = rxMAC;
+
+ state.fresnelEllipsoids.set(linkID, ellipsoid);
+ }
+ });
+
+ console.log('[Fresnel Debug] Created ' + state.fresnelEllipsoids.size + ' Fresnel ellipsoids');
+ }
+
+ /**
+ * Clear all Fresnel debug ellipsoids from the scene.
+ */
+ function clearFresnelDebugEllipsoids() {
+ state.fresnelEllipsoids.forEach(function(ellipsoid) {
+ if (window.Fresnel) {
+ window.Fresnel.removeFresnelEllipsoid(ellipsoid);
+ }
+ });
+ state.fresnelEllipsoids.clear();
+ hideFresnelTooltip();
+ }
+
+ /**
+ * Get color for Fresnel zone based on link health score.
+ * @param {number} score - Health score (0-1)
+ * @returns {number} Color hex value
+ */
+ function getFresnelHealthColor(score) {
+ if (score >= 0.7) return 0x66bb6a; // green
+ if (score >= 0.4) return 0xeab308; // yellow
+ return 0xef4444; // red
+ }
+
+ /**
+ * Handle mouse move events for Fresnel ellipsoid hover detection.
+ */
+ function onFresnelMouseMove(event) {
+ if (!state.fresnelDebugVisible) return;
+
+ // Calculate mouse position in normalized device coordinates
+ var rect = renderer.domElement.getBoundingClientRect();
+ state.fresnelMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
+ state.fresnelMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
+
+ // Raycast against all Fresnel ellipsoids
+ state.fresnelRaycaster.setFromCamera(state.fresnelMouse, camera);
+
+ var intersects = [];
+ state.fresnelEllipsoids.forEach(function(ellipsoid) {
+ var result = state.fresnelRaycaster.intersectObject(ellipsoid.fill, true);
+ if (result.length > 0) {
+ intersects.push(result[0]);
+ }
+ });
+
+ if (intersects.length > 0) {
+ var intersect = intersects[0];
+ var linkID = intersect.object.userData.linkID;
+
+ if (state.fresnelHoveredEllipsoid !== linkID) {
+ // New hover
+ state.fresnelHoveredEllipsoid = linkID;
+ highlightFresnelLink(linkID, true);
+ showFresnelTooltip(event, linkID, intersect.point);
+ } else {
+ // Update tooltip position
+ updateFresnelTooltipPosition(event);
+ }
+ } else {
+ if (state.fresnelHoveredEllipsoid !== null) {
+ // Hover ended
+ highlightFresnelLink(state.fresnelHoveredEllipsoid, false);
+ state.fresnelHoveredEllipsoid = null;
+ hideFresnelTooltip();
+ }
+ }
+ }
+
+ /**
+ * Handle click events on Fresnel ellipsoids.
+ */
+ function onFresnelClick(event) {
+ if (!state.fresnelDebugVisible || state.fresnelHoveredEllipsoid === null) return;
+
+ var linkID = state.fresnelHoveredEllipsoid;
+
+ // Select the corresponding link in the link panel
+ selectLink(linkID);
+
+ // Flash the link entry to highlight it
+ var linkItem = document.querySelector('.link-item[data-link-id="' + linkID + '"]');
+ if (linkItem) {
+ linkItem.classList.add('flash-highlight');
+ setTimeout(function() {
+ linkItem.classList.remove('flash-highlight');
+ }, 1000);
+ }
+ }
+
+ /**
+ * Highlight or unhighlight a link when its Fresnel ellipsoid is hovered.
+ * @param {string} linkID - Link ID
+ * @param {boolean} highlight - Whether to highlight
+ */
+ function highlightFresnelLink(linkID, highlight) {
+ var linkItem = document.querySelector('.link-item[data-link-id="' + linkID + '"]');
+ if (linkItem) {
+ if (highlight) {
+ linkItem.classList.add('fresnel-hover');
+ } else {
+ linkItem.classList.remove('fresnel-hover');
+ }
+ }
+
+ // Also highlight the link line in 3D if Viz3D supports it
+ if (window.Viz3D && window.Viz3D.highlightLink) {
+ window.Viz3D.highlightLink(linkID, highlight);
+ }
+ }
+
+ /**
+ * Show tooltip with Fresnel ellipsoid details.
+ * @param {MouseEvent} event - Mouse event
+ * @param {string} linkID - Link ID
+ * @param {THREE.Vector3} point - 3D point of intersection
+ */
+ function showFresnelTooltip(event, linkID, point) {
+ var tooltip = document.getElementById('fresnel-tooltip');
+ if (!tooltip) {
+ tooltip = document.createElement('div');
+ tooltip.id = 'fresnel-tooltip';
+ tooltip.className = 'fresnel-tooltip';
+ document.body.appendChild(tooltip);
+ }
+
+ var link = state.links.get(linkID);
+ var ellipsoid = state.fresnelEllipsoids.get(linkID);
+ if (!link || !ellipsoid) return;
+
+ var data = ellipsoid.data;
+ var healthScore = state.worstLinkID === linkID ? state.worstLinkScore : 0.5;
+
+ var txLabel = state.nodes.get(data.txMAC) ? state.nodes.get(data.txMAC).mac : data.txMAC;
+ var rxLabel = state.nodes.get(data.rxMAC) ? state.nodes.get(data.rxMAC).mac : data.rxMAC;
+
+ tooltip.innerHTML =
+ 'Link: ' + abbreviateLinkID(linkID) + '
' +
+ 'TX: ' + txLabel + '
' +
+ 'RX: ' + rxLabel + '
' +
+ 'Fresnel radius at midpoint: ' + data.b.toFixed(3) + ' m
' +
+ 'Link distance: ' + data.d.toFixed(2) + ' m
' +
+ 'Wavelength: ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')
' +
+ 'Link health: ' + Math.round(healthScore * 100) + '%';
+
+ tooltip.style.display = 'block';
+ updateFresnelTooltipPosition(event);
+ }
+
+ /**
+ * Update tooltip position based on mouse event.
+ * @param {MouseEvent} event - Mouse event
+ */
+ function updateFresnelTooltipPosition(event) {
+ var tooltip = document.getElementById('fresnel-tooltip');
+ if (!tooltip) return;
+
+ tooltip.style.left = (event.clientX + 15) + 'px';
+ tooltip.style.top = (event.clientY + 15) + 'px';
+ }
+
+ /**
+ * Hide the Fresnel tooltip.
+ */
+ function hideFresnelTooltip() {
+ var tooltip = document.getElementById('fresnel-tooltip');
+ if (tooltip) {
+ tooltip.style.display = 'none';
+ }
+ }
+
+ // Add Fresnel interaction event listeners after scene initialization
+ var originalInitScene = initScene;
+ initScene = function() {
+ originalInitScene();
+
+ // Initialize Fresnel module with scene
+ if (window.Fresnel && window.Fresnel.init) {
+ window.Fresnel.init(scene);
+ }
+
+ // Add event listeners for Fresnel interaction
+ renderer.domElement.addEventListener('mousemove', onFresnelMouseMove);
+ renderer.domElement.addEventListener('click', onFresnelClick);
+
+ // Show debug section if expert mode (always visible for now)
+ var debugSection = document.getElementById('debug-section');
+ if (debugSection) {
+ debugSection.style.display = 'block';
+ }
+ };
+
+ // Update Fresnel ellipsoids when links change
+ var originalUpdateLinkList = updateLinkList;
+ updateLinkList = function() {
+ originalUpdateLinkList();
+ if (state.fresnelDebugVisible) {
+ rebuildFresnelDebugEllipsoids();
+ }
+ };
})();
diff --git a/dashboard/js/fresnel.js b/dashboard/js/fresnel.js
new file mode 100644
index 0000000..ddb83ce
--- /dev/null
+++ b/dashboard/js/fresnel.js
@@ -0,0 +1,269 @@
+/**
+ * Spaxel Dashboard - Fresnel Zone Helper Module
+ *
+ * Shared Fresnel zone ellipsoid geometry computation for:
+ * - Debug overlay (all active links)
+ * - Explainability overlay (contributing links for a specific blob)
+ *
+ * Provides FresnelEllipsoid() function that returns Three.js meshes
+ * ready for scene insertion.
+ */
+
+(function() {
+ 'use strict';
+
+ // ============================================
+ // Configuration
+ // ============================================
+ const CONFIG = {
+ // WiFi wavelengths (c/f in meters)
+ wavelength_5ghz: 0.06, // 5 GHz
+ wavelength_2_4ghz: 0.125, // 2.4 GHz
+ // Geometry
+ sphereSegments: 32, // Sphere geometry segments (reduced on mobile)
+ sphereHeightSegments: 16,
+ wireframeOpacity: 0.6, // Line opacity for wireframe
+ fillOpacity: 0.08, // Fill opacity
+ mobileViewportWidth: 768, // Width threshold for mobile
+ mobileSegments: 16, // Reduced segments for mobile
+ mobileHeightSegments: 8
+ };
+
+ // ============================================
+ // Private State
+ // ============================================
+ let _scene = null;
+
+ /**
+ * Initialize the Fresnel module with the Three.js scene.
+ * @param {THREE.Scene} scene - The Three.js scene
+ */
+ function init(scene) {
+ _scene = scene;
+ }
+
+ /**
+ * Get WiFi wavelength based on channel number.
+ * @param {number} channel - WiFi channel (1-14 for 2.4 GHz, 36-165 for 5 GHz)
+ * @returns {number} Wavelength in meters
+ */
+ function getWavelengthForChannel(channel) {
+ if (!channel || channel < 1) return CONFIG.wavelength_2_4ghz;
+
+ // 2.4 GHz channels: 1-14
+ if (channel <= 14) {
+ return CONFIG.wavelength_2_4ghz;
+ }
+
+ // 5 GHz channels: 36 and above
+ return CONFIG.wavelength_5ghz;
+ }
+
+ /**
+ * Calculate Fresnel zone ellipsoid parameters for a link.
+ * Based on the first Fresnel zone geometry.
+ *
+ * For a link with TX at position P1 and RX at position P2:
+ * - Link distance d = |P1 - P2|
+ * - WiFi wavelength lambda: 5 GHz -> lambda = 0.06m, 2.4 GHz -> lambda = 0.125m
+ * - Semi-major axis: a = (d + lambda/2) / 2
+ * - Semi-minor axis: b = sqrt(a^2 - (d/2)^2)
+ * - Ellipsoid centre: midpoint(P1, P2)
+ * - Ellipsoid orientation: major axis along the P1->P2 unit vector
+ *
+ * @param {THREE.Vector3} tx - Transmitter position
+ * @param {THREE.Vector3} rx - Receiver position
+ * @param {number} channel - WiFi channel number (for wavelength)
+ * @returns {Object} Ellipsoid parameters: { center, semiAxes, rotation, lambda, d, a, b }
+ */
+ function calculateFresnelEllipsoid(tx, rx, channel) {
+ // Get wavelength based on channel
+ const lambda = getWavelengthForChannel(channel);
+
+ // Direct distance between TX and RX
+ const d = tx.distanceTo(rx);
+
+ // First Fresnel zone ellipsoid parameters
+ // Semi-major axis: a = (d + lambda/2) / 2
+ const a = (d + lambda / 2) / 2;
+
+ // Semi-minor axis: b = sqrt(a^2 - (d/2)^2)
+ // Using the property that for a prolate spheroid with foci at tx and rx:
+ // b^2 = a^2 - (d/2)^2
+ const b = Math.sqrt(Math.max(0, a * a - (d / 2) * (d / 2)));
+
+ // Center of ellipsoid (midpoint between TX and RX)
+ const center = new THREE.Vector3().addVectors(tx, rx).multiplyScalar(0.5);
+
+ // Rotation: align with TX-RX axis
+ // We want the Z-axis of the ellipsoid to point along the link direction
+ const direction = new THREE.Vector3().subVectors(rx, tx).normalize();
+ const up = new THREE.Vector3(0, 0, 1); // Z-axis is up in our ellipsoid geometry
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(up, direction);
+
+ return {
+ center: center,
+ semiAxes: new THREE.Vector3(b, b, a), // X, Y (minor), Z (major along link)
+ rotation: quaternion,
+ lambda: lambda,
+ d: d,
+ a: a,
+ b: b,
+ channel: channel
+ };
+ }
+
+ /**
+ * Create a Three.js Mesh for a Fresnel zone ellipsoid.
+ * Creates both wireframe and fill meshes for proper visualization.
+ *
+ * @param {THREE.Vector3} tx - Transmitter position
+ * @param {THREE.Vector3} rx - Receiver position
+ * @param {number} channel - WiFi channel number
+ * @param {number} color - Color hex value (e.g., 0x4FC3F7 for blue)
+ * @param {Object} options - Optional settings { wireframeOpacity, fillOpacity }
+ * @returns {Object} Object containing { wireframe, fill, data } meshes
+ */
+ function FresnelEllipsoid(tx, rx, channel, color, options) {
+ if (!_scene) {
+ console.warn('[Fresnel] Scene not initialized. Call Fresnel.init(scene) first.');
+ return null;
+ }
+
+ options = options || {};
+ const wireframeOpacity = options.wireframeOpacity !== undefined ? options.wireframeOpacity : CONFIG.wireframeOpacity;
+ const fillOpacity = options.fillOpacity !== undefined ? options.fillOpacity : CONFIG.fillOpacity;
+
+ // Calculate ellipsoid geometry
+ const ellipsoid = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // Determine segment count based on viewport (mobile optimization)
+ const isMobile = window.innerWidth < CONFIG.mobileViewportWidth;
+ const segments = isMobile ? CONFIG.mobileSegments : CONFIG.sphereSegments;
+ const heightSegments = isMobile ? CONFIG.mobileHeightSegments : CONFIG.sphereHeightSegments;
+
+ // Create sphere geometry (unit sphere that will be scaled)
+ const geometry = new THREE.SphereGeometry(1, segments, heightSegments);
+
+ // Apply non-uniform scaling to create ellipsoid
+ // We scale after creation to get the correct ellipsoid shape
+ geometry.scale(ellipsoid.semiAxes.x, ellipsoid.semiAxes.y, ellipsoid.semiAxes.z);
+
+ // Create wireframe using EdgesGeometry for crisp edges
+ const edgesGeometry = new THREE.EdgesGeometry(geometry);
+ const wireframeMaterial = new THREE.LineBasicMaterial({
+ color: color,
+ transparent: true,
+ opacity: wireframeOpacity,
+ depthTest: true,
+ depthWrite: false
+ });
+ const wireframe = new THREE.LineSegments(edgesGeometry, wireframeMaterial);
+
+ // Create fill mesh
+ const fillMaterial = new THREE.MeshBasicMaterial({
+ color: color,
+ transparent: true,
+ opacity: fillOpacity,
+ depthWrite: false,
+ side: THREE.DoubleSide
+ });
+ const fill = new THREE.Mesh(geometry, fillMaterial);
+
+ // Position at ellipsoid center
+ wireframe.position.copy(ellipsoid.center);
+ fill.position.copy(ellipsoid.center);
+
+ // Apply rotation to align with link axis
+ wireframe.quaternion.copy(ellipsoid.rotation);
+ fill.quaternion.copy(ellipsoid.rotation);
+
+ // Store metadata for raycasting and interactions
+ const data = {
+ tx: tx.clone(),
+ rx: rx.clone(),
+ channel: channel,
+ lambda: ellipsoid.lambda,
+ d: ellipsoid.d,
+ a: ellipsoid.a,
+ b: ellipsoid.b,
+ semiAxes: ellipsoid.semiAxes.clone(),
+ center: ellipsoid.center.clone(),
+ rotation: ellipsoid.rotation.clone()
+ };
+
+ wireframe.userData = { fresnelEllipsoid: data };
+ fill.userData = { fresnelEllipsoid: data };
+
+ return {
+ wireframe: wireframe,
+ fill: fill,
+ data: data
+ };
+ }
+
+ /**
+ * Add a Fresnel ellipsoid to the scene.
+ * Convenience function that creates and adds the mesh.
+ *
+ * @param {THREE.Vector3} tx - Transmitter position
+ * @param {THREE.Vector3} rx - Receiver position
+ * @param {number} channel - WiFi channel number
+ * @param {number} color - Color hex value
+ * @param {Object} options - Optional settings
+ * @returns {Object} Object containing { wireframe, fill, data }
+ */
+ function addFresnelEllipsoid(tx, rx, channel, color, options) {
+ const ellipsoid = FresnelEllipsoid(tx, rx, channel, color, options);
+ if (!ellipsoid) return null;
+
+ if (_scene) {
+ _scene.add(ellipsoid.wireframe);
+ _scene.add(ellipsoid.fill);
+ }
+
+ return ellipsoid;
+ }
+
+ /**
+ * Remove a Fresnel ellipsoid from the scene.
+ *
+ * @param {Object} ellipsoid - Object returned from addFresnelEllipsoid or FresnelEllipsoid
+ */
+ function removeFresnelEllipsoid(ellipsoid) {
+ if (!ellipsoid) return;
+
+ if (ellipsoid.wireframe && _scene) {
+ _scene.remove(ellipsoid.wireframe);
+ ellipsoid.wireframe.geometry.dispose();
+ if (ellipsoid.wireframe.material) {
+ ellipsoid.wireframe.material.dispose();
+ }
+ }
+
+ if (ellipsoid.fill && _scene) {
+ _scene.remove(ellipsoid.fill);
+ if (ellipsoid.fill.geometry) {
+ ellipsoid.fill.geometry.dispose();
+ }
+ if (ellipsoid.fill.material) {
+ ellipsoid.fill.material.dispose();
+ }
+ }
+ }
+
+ // ============================================
+ // Public API
+ // ============================================
+ window.Fresnel = {
+ init: init,
+ calculateFresnelEllipsoid: calculateFresnelEllipsoid,
+ FresnelEllipsoid: FresnelEllipsoid,
+ addFresnelEllipsoid: addFresnelEllipsoid,
+ removeFresnelEllipsoid: removeFresnelEllipsoid,
+ // Configuration access
+ CONFIG: CONFIG
+ };
+
+ console.log('[Fresnel] Module loaded');
+})();
diff --git a/dashboard/js/fresnel.test.js b/dashboard/js/fresnel.test.js
new file mode 100644
index 0000000..1998702
--- /dev/null
+++ b/dashboard/js/fresnel.test.js
@@ -0,0 +1,316 @@
+/**
+ * Spaxel Dashboard - Fresnel Zone Tests
+ *
+ * Tests for Fresnel ellipsoid geometry computation.
+ * These tests verify the correctness of the Fresnel zone calculations
+ * used for both the debug overlay and explainability overlay.
+ */
+
+describe('Fresnel Module', function() {
+ // Mock THREE.js before all tests
+ beforeAll(function() {
+ if (typeof THREE === 'undefined') {
+ global.THREE = {
+ Vector3: function(x, y, z) {
+ this.x = x || 0;
+ this.y = y || 0;
+ this.z = z || 0;
+ },
+ Quaternion: function() {
+ this._x = 0;
+ this._y = 0;
+ this._z = 0;
+ this._w = 1;
+ }
+ };
+
+ // Add prototype methods to Vector3
+ THREE.Vector3.prototype.distanceTo = function(v) {
+ return Math.sqrt(
+ Math.pow(this.x - v.x, 2) +
+ Math.pow(this.y - v.y, 2) +
+ Math.pow(this.z - v.z, 2)
+ );
+ };
+
+ THREE.Vector3.prototype.addVectors = function(v1, v2) {
+ this.x = v1.x + v2.x;
+ this.y = v1.y + v2.y;
+ this.z = v1.z + v2.z;
+ return this;
+ };
+
+ THREE.Vector3.prototype.subVectors = function(v1, v2) {
+ this.x = v1.x - v2.x;
+ this.y = v1.y - v2.y;
+ this.z = v1.z - v2.z;
+ return this;
+ };
+
+ THREE.Vector3.prototype.multiplyScalar = function(s) {
+ this.x *= s;
+ this.y *= s;
+ this.z *= s;
+ return this;
+ };
+
+ THREE.Vector3.prototype.clone = function() {
+ return new THREE.Vector3(this.x, this.y, this.z);
+ };
+
+ THREE.Quaternion.prototype.setFromUnitVectors = function(from, to) {
+ // Simplified quaternion for testing
+ this._w = 1;
+ return this;
+ };
+
+ THREE.Quaternion.prototype.clone = function() {
+ return new THREE.Quaternion();
+ };
+ }
+
+ // Load the Fresnel module
+ if (typeof window !== 'undefined') {
+ // In browser environment, the module should already be loaded
+ // For testing purposes, we'll use the functions directly
+ }
+ });
+
+ describe('calculateFresnelEllipsoid', function() {
+ var calculateFresnelEllipsoid;
+
+ beforeEach(function() {
+ if (window.Fresnel && window.Fresnel.calculateFresnelEllipsoid) {
+ calculateFresnelEllipsoid = window.Fresnel.calculateFresnelEllipsoid;
+ }
+ });
+
+ it('should calculate correct semi-major and semi-minor axes for a 4m link', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(4, 0, 0);
+ var channel = 6; // 2.4 GHz
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // Expected values:
+ // lambda = 0.125m (2.4 GHz)
+ // d = 4m
+ // a = (d + lambda/2) / 2 = (4 + 0.0625) / 2 = 2.03125
+ // b = sqrt(a^2 - (d/2)^2) = sqrt(2.03125^2 - 2^2) = sqrt(4.126 - 4) = sqrt(0.126) ≈ 0.355
+
+ expect(result.d).toBeCloseTo(4, 0.01);
+ expect(result.a).toBeCloseTo(2.031, 0.001);
+ expect(result.b).toBeCloseTo(0.355, 0.001);
+ });
+
+ it('should calculate correct semi-minor axis for very short link (edge case)', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(0.1, 0, 0);
+ var channel = 6;
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // For a very short link, b should be small but positive
+ expect(result.b).toBeGreaterThan(0);
+ expect(result.b).toBeLessThan(0.1);
+ });
+
+ it('should calculate correct axes for diagonal link (distance = 5m)', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(3, 4, 0); // 3-4-5 triangle, distance = 5
+ var channel = 6;
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // Expected:
+ // d = 5m
+ // lambda = 0.125m
+ // a = (5 + 0.0625) / 2 = 2.53125
+ // b = sqrt(2.53125^2 - 2.5^2) = sqrt(6.407 - 6.25) = sqrt(0.157) ≈ 0.396
+
+ expect(result.d).toBeCloseTo(5, 0.01);
+ expect(result.a).toBeCloseTo(2.531, 0.001);
+ expect(result.b).toBeCloseTo(0.396, 0.001);
+ });
+
+ it('should use correct wavelength for 5 GHz channel', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(4, 0, 0);
+ var channel = 36; // 5 GHz channel
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // For 5 GHz: lambda = 0.06m
+ // a = (4 + 0.03) / 2 = 2.015
+ // b = sqrt(2.015^2 - 2^2) = sqrt(4.060 - 4) = sqrt(0.060) ≈ 0.245
+
+ expect(result.lambda).toBeCloseTo(0.06, 0.001);
+ expect(result.a).toBeCloseTo(2.015, 0.001);
+ expect(result.b).toBeCloseTo(0.245, 0.001);
+ });
+
+ it('should use correct wavelength for 2.4 GHz channel', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(4, 0, 0);
+ var channel = 6; // 2.4 GHz channel
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // For 2.4 GHz: lambda = 0.125m
+ expect(result.lambda).toBeCloseTo(0.125, 0.001);
+ });
+
+ it('should calculate ellipsoid center at midpoint', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(4, 0, 0);
+ var channel = 6;
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ expect(result.center.x).toBeCloseTo(2, 0.001);
+ expect(result.center.y).toBeCloseTo(0, 0.001);
+ expect(result.center.z).toBeCloseTo(0, 0.001);
+ });
+
+ it('should calculate ellipsoid center for diagonal link', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(6, 8, 0);
+ var channel = 6;
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // Midpoint of (0,0,0) and (6,8,0) is (3,4,0)
+ expect(result.center.x).toBeCloseTo(3, 0.001);
+ expect(result.center.y).toBeCloseTo(4, 0.001);
+ expect(result.center.z).toBeCloseTo(0, 0.001);
+ });
+
+ it('should handle zero channel gracefully (default to 2.4 GHz)', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(4, 0, 0);
+ var channel = 0; // Invalid channel
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // Should default to 2.4 GHz wavelength
+ expect(result.lambda).toBeCloseTo(0.125, 0.001);
+ });
+
+ it('should handle very large link distance (100m)', function() {
+ if (!calculateFresnelEllipsoid) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(100, 0, 0);
+ var channel = 6;
+
+ var result = calculateFresnelEllipsoid(tx, rx, channel);
+
+ // For 100m link:
+ // a = (100 + 0.0625) / 2 = 50.03125
+ // b = sqrt(50.03125^2 - 50^2) = sqrt(2503.125 - 2500) = sqrt(3.125) ≈ 1.768
+
+ expect(result.d).toBeCloseTo(100, 0.01);
+ expect(result.a).toBeCloseTo(50.031, 0.001);
+ expect(result.b).toBeCloseTo(1.768, 0.001);
+ });
+ });
+
+ describe('getWavelengthForChannel', function() {
+ var getWavelengthForChannel;
+
+ beforeEach(function() {
+ if (window.Fresnel && window.Fresnel.calculateFresnelEllipsoid) {
+ // Test the wavelength indirectly through calculateFresnelEllipsoid
+ getWavelengthForChannel = function(channel) {
+ var tx = new THREE.Vector3(0, 0, 0);
+ var rx = new THREE.Vector3(1, 0, 0);
+ var result = window.Fresnel.calculateFresnelEllipsoid(tx, rx, channel);
+ return result.lambda;
+ };
+ }
+ });
+
+ it('should return 2.4 GHz wavelength for channel 1', function() {
+ if (!getWavelengthForChannel) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+ expect(getWavelengthForChannel(1)).toBeCloseTo(0.125, 0.001);
+ });
+
+ it('should return 2.4 GHz wavelength for channel 6', function() {
+ if (!getWavelengthForChannel) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+ expect(getWavelengthForChannel(6)).toBeCloseTo(0.125, 0.001);
+ });
+
+ it('should return 2.4 GHz wavelength for channel 14', function() {
+ if (!getWavelengthForChannel) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+ expect(getWavelengthForChannel(14)).toBeCloseTo(0.125, 0.001);
+ });
+
+ it('should return 5 GHz wavelength for channel 36', function() {
+ if (!getWavelengthForChannel) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+ expect(getWavelengthForChannel(36)).toBeCloseTo(0.06, 0.001);
+ });
+
+ it('should return 5 GHz wavelength for channel 149', function() {
+ if (!getWavelengthForChannel) {
+ test.skip('Fresnel module not loaded');
+ return;
+ }
+ expect(getWavelengthForChannel(149)).toBeCloseTo(0.06, 0.001);
+ });
+ });
+});