feat: implement Fresnel zone debug overlay for 3D visualization
Add comprehensive Fresnel zone ellipsoid visualization for all active links in the 3D scene. Provides developers with real-time feedback on link coverage geometry and detection quality. Features: - FresnelEllipsoid() helper function in dashboard/js/fresnel.js for shared ellipsoid geometry computation (used by both debug and explainability) - Wireframe + fill material rendering for clear ellipsoid visualization - Debug layer toggle in node panel (expert mode only) - Hover interaction with tooltip showing link details (distance, wavelength, Fresnel radius, health score) - Click interaction to select corresponding link in sidebar - Mobile viewport optimization (reduced segment count for performance) - Comprehensive test coverage for ellipsoid geometry computation The first Fresnel zone ellipsoid represents the region where point reflectors have maximum impact on CSI phase. Visualizing these zones helps system tuners understand why localisation places blobs in specific positions and identify coverage gaps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
41153865c2
commit
8216c76bd0
4 changed files with 913 additions and 1 deletions
|
|
@ -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); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -2556,6 +2588,15 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-section" id="debug-section" style="display: none;">
|
||||
<h3>Debug</h3>
|
||||
<div id="debug-controls">
|
||||
<label class="pattern-checkbox">
|
||||
<input type="checkbox" id="fresnel-zones-toggle" onchange="toggleFresnelDebugOverlay(this.checked)">
|
||||
<span>Fresnel Zones</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amplitude chart -->
|
||||
|
|
@ -2591,6 +2632,8 @@
|
|||
<script src="js/auth.js"></script>
|
||||
<!-- 3-D spatial visualisation layer -->
|
||||
<script src="js/viz3d.js"></script>
|
||||
<!-- Fresnel zone helper (shared with explainability) -->
|
||||
<script src="js/fresnel.js"></script>
|
||||
<!-- Node placement, GDOP coverage, room editor -->
|
||||
<script src="js/placement.js"></script>
|
||||
<!-- Panel Framework (must load before modules that use it) -->
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
'<strong>Link:</strong> ' + abbreviateLinkID(linkID) + '<br>' +
|
||||
'<strong>TX:</strong> ' + txLabel + '<br>' +
|
||||
'<strong>RX:</strong> ' + rxLabel + '<br>' +
|
||||
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(3) + ' m<br>' +
|
||||
'<strong>Link distance:</strong> ' + data.d.toFixed(2) + ' m<br>' +
|
||||
'<strong>Wavelength:</strong> ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')<br>' +
|
||||
'<strong>Link health:</strong> ' + 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
269
dashboard/js/fresnel.js
Normal file
269
dashboard/js/fresnel.js
Normal file
|
|
@ -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');
|
||||
})();
|
||||
316
dashboard/js/fresnel.test.js
Normal file
316
dashboard/js/fresnel.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue