spaxel/dashboard/js/placement.js
jedarden 80d070d0d3 feat(placement): add space key to place virtual nodes at camera target
- Press space bar to place a new virtual node at camera target position
- Node is placed at intersection of camera ray with ground plane
- Position is clamped to room bounds
- Height defaults to 80% of room height or 1.5m minimum
- Virtual nodes are rendered as ghost wireframe nodes (via viz3d.js)
- Links to/from virtual nodes are dashed (via viz3d.js)

This completes the 'space + virtual node placement' feature for the
simulator, reusing the 3D editor with ghost wireframe nodes and dashed links.
2026-05-05 21:40:48 -04:00

576 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Spaxel Placement Interactive node placement with GDOP coverage overlay
*
* Provides: TransformControls for dragging nodes in 3D, real-time GDOP
* (Geometric Dilution of Precision) coverage overlay on the ground plane,
* space dimension editor, virtual node support for planning, and REST API
* integration for persisting positions.
*/
const Placement = (function () {
'use strict';
// ── module state ──────────────────────────────────────────────────────────
var _scene, _camera, _renderer, _orbitControls;
var _transformControls = null;
var _selectedMAC = null;
var _gdopEnabled = false;
var _gdopMesh = null;
var _gdopTexture = null;
var _gdopDirty = false;
var _roomConfig = null;
var _nodeMACs = []; // [{mac, virtual}]
var _mouseDown = { x: 0, y: 0 };
var _isDragging = false;
var GDOP_RES = 128;
var CLICK_THRESHOLD = 5;
// ── GDOP colour mapping ──────────────────────────────────────────────────
//
// Multi-stop gradient: green → yellow-green → yellow → orange → red
function gdopToColor(hdop) {
if (!isFinite(hdop) || hdop > 20) return [180, 30, 30, 150];
var t = Math.min(hdop / 8.0, 1.0);
var stops = [
{ t: 0.00, r: 30, g: 200, b: 80 },
{ t: 0.25, r: 140, g: 220, b: 50 },
{ t: 0.50, r: 240, g: 210, b: 40 },
{ t: 0.75, r: 240, g: 130, b: 30 },
{ t: 1.00, r: 210, g: 40, b: 30 },
];
var i = 0;
for (; i < stops.length - 2; i++) {
if (t <= stops[i + 1].t) break;
}
var s0 = stops[i], s1 = stops[i + 1];
var f = (t - s0.t) / (s1.t - s0.t);
return [
Math.round(s0.r + (s1.r - s0.r) * f),
Math.round(s0.g + (s1.g - s0.g) * f),
Math.round(s0.b + (s1.b - s0.b) * f),
150
];
}
// ── GDOP computation ─────────────────────────────────────────────────────
//
// Computes 2-D horizontal DOP (HDOP) for a point (px, pz) on the ground
// plane, given anchor nodes at (x, y, z). The observation model assumes
// range-based localisation; the 2×2 normal matrix G = HᵀH is inverted to
// obtain HDOP = √ trace(G⁻¹).
function computeHDOP(px, pz, nodes) {
if (nodes.length < 2) return Infinity;
var g00 = 0, g01 = 0, g11 = 0;
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var dx = px - n.x;
var dz = pz - n.z;
var dy = n.y;
var d2 = dx * dx + dy * dy + dz * dz;
if (d2 < 1e-6) continue;
var d = Math.sqrt(d2);
var ux = dx / d;
var uz = dz / d;
g00 += ux * ux;
g01 += ux * uz;
g11 += uz * uz;
}
var det = g00 * g11 - g01 * g01;
if (det < 1e-8) return Infinity;
return Math.sqrt(Math.max(0, (g00 + g11) / det));
}
// ── Read live mesh positions for GDOP ────────────────────────────────────
function getNodePositions() {
var positions = [];
for (var i = 0; i < _nodeMACs.length; i++) {
var entry = _nodeMACs[i];
var mesh = Viz3D.getNodeMesh(entry.mac);
if (mesh) {
positions.push({
x: mesh.position.x,
y: mesh.position.y,
z: mesh.position.z
});
}
}
return positions;
}
// ── GDOP overlay ─────────────────────────────────────────────────────────
// Cache for backend GDOP data
var _gdopCache = null;
var _gdopPending = false;
function updateGDOPOverlay() {
var nodes = getNodePositions();
if (!_gdopEnabled || !_roomConfig || nodes.length < 2) {
if (_gdopMesh) _gdopMesh.visible = false;
return;
}
// Fetch GDOP data from backend API (uses angular diversity algorithm)
if (!_gdopPending) {
_gdopPending = true;
fetch('/api/simulator/gdop/heatmap')
.then(function (resp) { return resp.json(); })
.then(function (data) {
_gdopCache = data;
renderGDOPFromCache();
_gdopPending = false;
})
.catch(function (e) {
console.error('[Placement] GDOP fetch failed:', e);
_gdopPending = false;
});
} else if (_gdopCache) {
renderGDOPFromCache();
}
}
function renderGDOPFromCache() {
if (!_gdopCache || !_roomConfig) return;
var data = _gdopCache;
var w = _roomConfig.width;
var d = _roomConfig.depth;
var ox = _roomConfig.origin_x || 0;
var oz = _roomConfig.origin_z || 0;
// Get grid dimensions from backend response
var gridWidth = data.grid_dimensions ? data.grid_dimensions[0] : GDOP_RES;
var gridDepth = data.grid_dimensions ? data.grid_dimensions[1] : GDOP_RES;
var gdopValues = data.gdop_map || [];
var colors = data.colors || [];
// Create or reuse DataTexture
if (!_gdopTexture) {
var texData = new Uint8Array(gridWidth * gridDepth * 4);
_gdopTexture = new THREE.DataTexture(texData, gridWidth, gridDepth, THREE.RGBAFormat);
_gdopTexture.magFilter = THREE.LinearFilter;
_gdopTexture.minFilter = THREE.LinearFilter;
}
var texData = _gdopTexture.image.data;
// Use backend-provided colors if available, otherwise compute from GDOP values
if (colors.length > 0) {
// Backend provides RGB colors
for (var i = 0; i < colors.length && i < gridWidth * gridDepth; i++) {
var idx = i * 4;
texData[idx] = colors[i][0];
texData[idx + 1] = colors[i][1];
texData[idx + 2] = colors[i][2];
texData[idx + 3] = 150; // Alpha
}
} else if (gdopValues.length > 0) {
// Fallback: compute colors from GDOP values
for (var i = 0; i < gdopValues.length && i < gridWidth * gridDepth; i++) {
var gdop = gdopValues[i];
var c = gdopToColor(gdop);
var idx = i * 4;
texData[idx] = c[0];
texData[idx + 1] = c[1];
texData[idx + 2] = c[2];
texData[idx + 3] = c[3];
}
}
_gdopTexture.needsUpdate = true;
if (!_gdopMesh) {
var geo = new THREE.PlaneGeometry(w, d);
var mat = new THREE.MeshBasicMaterial({
map: _gdopTexture,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide
});
_gdopMesh = new THREE.Mesh(geo, mat);
_gdopMesh.rotation.x = -Math.PI / 2;
_gdopMesh.position.set(ox + w / 2, 0.006, oz + d / 2);
_scene.add(_gdopMesh);
} else {
_gdopMesh.geometry.dispose();
_gdopMesh.geometry = new THREE.PlaneGeometry(w, d);
_gdopMesh.position.set(ox + w / 2, 0.006, oz + d / 2);
}
_gdopMesh.visible = true;
// Update coverage score display if available
updateCoverageScore(data.coverage_score || data.coverage_percent);
}
function updateCoverageScore(score) {
var scoreEl = document.getElementById('gdop-coverage-score');
if (scoreEl) {
var pct = typeof score === 'number' ? score : 0;
scoreEl.textContent = 'Coverage: ' + pct.toFixed(1) + '%';
// Color-code the score
scoreEl.style.color = pct >= 70 ? '#22c65e' : pct >= 50 ? '#ffc107' : '#dc3545';
}
}
function rebuildGDOPIfDirty() {
if (_gdopDirty && _gdopEnabled) {
updateGDOPOverlay();
_gdopDirty = false;
}
}
// ── TransformControls ────────────────────────────────────────────────────
function initTransformControls() {
_transformControls = new THREE.TransformControls(_camera, _renderer.domElement);
_transformControls.setMode('translate');
_transformControls.setSize(0.75);
_scene.add(_transformControls.getHelper());
_transformControls.addEventListener('dragging-changed', function (event) {
_orbitControls.enabled = !event.value;
_isDragging = event.value;
// Save position on drag end
if (!event.value && _selectedMAC) {
var mesh = Viz3D.getNodeMesh(_selectedMAC);
if (mesh) {
saveNodePosition(_selectedMAC, mesh.position.x, mesh.position.y, mesh.position.z);
}
}
});
_transformControls.addEventListener('objectChange', function () {
if (!_selectedMAC || !_roomConfig) return;
var obj = _transformControls.object;
var ox = _roomConfig.origin_x || 0;
var oz = _roomConfig.origin_z || 0;
// Clamp to room bounds
obj.position.x = Math.max(ox, Math.min(ox + _roomConfig.width, obj.position.x));
obj.position.y = Math.max(0.05, Math.min(_roomConfig.height, obj.position.y));
obj.position.z = Math.max(oz, Math.min(oz + _roomConfig.depth, obj.position.z));
_gdopDirty = true;
_gdopCache = null; // Clear cache to force refresh with new node positions
// Update link lines to follow dragged node
if (Viz3D.rebuildLinkLines) Viz3D.rebuildLinkLines();
});
}
// ── Node selection ───────────────────────────────────────────────────────
function selectNode(mac) {
if (_selectedMAC === mac) return;
deselectNode();
var mesh = Viz3D.getNodeMesh(mac);
if (!mesh) return;
_selectedMAC = mac;
_transformControls.attach(mesh);
// Highlight in node panel
document.querySelectorAll('.node-item').forEach(function (el) {
el.classList.toggle('selected', el.dataset.mac === mac);
});
// Show delete button for virtual nodes
var isVirtual = _nodeMACs.some(function (n) { return n.mac === mac && n.virtual; });
var delBtn = document.getElementById('delete-node-btn');
if (delBtn) delBtn.style.display = isVirtual ? 'inline-block' : 'none';
}
function deselectNode() {
if (_transformControls) _transformControls.detach();
_selectedMAC = null;
document.querySelectorAll('.node-item').forEach(function (el) {
el.classList.remove('selected');
});
var delBtn = document.getElementById('delete-node-btn');
if (delBtn) delBtn.style.display = 'none';
}
// ── Pointer handling (click-to-select) ───────────────────────────────────
function onPointerDown(event) {
_mouseDown.x = event.clientX;
_mouseDown.y = event.clientY;
}
function onPointerUp(event) {
var dx = event.clientX - _mouseDown.x;
var dy = event.clientY - _mouseDown.y;
if (Math.sqrt(dx * dx + dy * dy) > CLICK_THRESHOLD) return;
if (_isDragging) return;
if (event.target !== _renderer.domElement) return;
var rect = _renderer.domElement.getBoundingClientRect();
var mouse = new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
);
var raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, _camera);
var meshes = [];
for (var i = 0; i < _nodeMACs.length; i++) {
var m = Viz3D.getNodeMesh(_nodeMACs[i].mac);
if (m) meshes.push(m);
}
var intersects = raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
for (var j = 0; j < _nodeMACs.length; j++) {
var mesh = Viz3D.getNodeMesh(_nodeMACs[j].mac);
if (mesh === intersects[0].object) {
selectNode(_nodeMACs[j].mac);
return;
}
}
}
deselectNode();
}
// ── Keyboard shortcuts ───────────────────────────────────────────────────
function onKeyDown(event) {
if (event.target.tagName === 'INPUT') return;
if (event.key === 'Escape') {
deselectNode();
var panel = document.getElementById('room-editor-panel');
if (panel) panel.style.display = 'none';
}
if (event.key === 'g' || event.key === 'G') {
toggleGDOP();
}
// Space bar: place virtual node at camera target position
if (event.key === ' ' || event.code === 'Space') {
event.preventDefault();
placeVirtualNodeAtCameraTarget();
}
}
// ── Place virtual node at camera target ─────────────────────────────────────
// Places a new virtual node at the intersection of the camera ray with the ground plane
function placeVirtualNodeAtCameraTarget() {
if (!_roomConfig) {
console.warn('[Placement] No room config, cannot place node');
return;
}
// Calculate intersection with ground plane (y = 0)
var raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(0, 0), _camera);
// Create a ground plane at y=0 for intersection
var groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
var intersection = new THREE.Vector3();
raycaster.ray.intersectPlane(groundPlane, intersection);
if (!intersection) {
console.warn('[Placement] No intersection with ground plane');
return;
}
// Clamp to room bounds
var ox = _roomConfig.origin_x || 0;
var oz = _roomConfig.origin_z || 0;
var x = Math.max(ox, Math.min(ox + _roomConfig.width, intersection.x));
var z = Math.max(oz, Math.min(oz + _roomConfig.depth, intersection.z));
// Set height to a reasonable default (80% of room height or 1.5m minimum)
var y = Math.max(1.5, _roomConfig.height * 0.8);
// Generate a name for the virtual node
var nodeName = 'Virtual Node ' + (_nodeMACs.length + 1);
// Add the virtual node
addVirtualNode(nodeName, x, y, z);
console.log('[Placement] Placed virtual node at:', { x: x, y: y, z: z });
}
// ── REST API calls ───────────────────────────────────────────────────────
function saveNodePosition(mac, x, y, z) {
fetch('/api/nodes/' + encodeURIComponent(mac) + '/position', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: x, y: y, z: z })
}).catch(function (e) {
console.error('[Placement] save position failed:', e);
});
}
function addVirtualNode(name, x, y, z) {
var mac = 'AA:BB:CC:' +
Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase() + ':' +
Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase() + ':' +
Math.floor(Math.random() * 256).toString(16).padStart(2, '0').toUpperCase();
fetch('/api/nodes/virtual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mac: mac, name: name || 'Virtual', x: x, y: y, z: z })
}).then(function (resp) {
if (resp.ok) console.log('[Placement] virtual node added:', mac);
}).catch(function (e) {
console.error('[Placement] add virtual node failed:', e);
});
}
function deleteNodeFromServer(mac) {
fetch('/api/nodes/' + encodeURIComponent(mac), {
method: 'DELETE'
}).then(function () {
console.log('[Placement] node deleted:', mac);
}).catch(function (e) {
console.error('[Placement] delete node failed:', e);
});
}
function saveRoomConfig(width, depth, height) {
fetch('/api/room', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ width: width, depth: depth, height: height, origin_x: 0, origin_z: 0 })
}).catch(function (e) {
console.error('[Placement] save room failed:', e);
});
}
// ── Room editor ──────────────────────────────────────────────────────────
function toggleRoomEditor() {
var panel = document.getElementById('room-editor-panel');
if (!panel) return;
var visible = panel.style.display !== 'none';
panel.style.display = visible ? 'none' : 'block';
var btn = document.getElementById('room-editor-btn');
if (btn) btn.classList.toggle('active', !visible);
}
function applyRoomFromEditor() {
var w = parseFloat(document.getElementById('room-width').value) || 6;
var d = parseFloat(document.getElementById('room-depth').value) || 5;
var h = parseFloat(document.getElementById('room-height').value) || 2.5;
saveRoomConfig(w, d, h);
}
// ── Init ─────────────────────────────────────────────────────────────────
function init(scene, camera, renderer, controls) {
_scene = scene;
_camera = camera;
_renderer = renderer;
_orbitControls = controls;
initTransformControls();
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointerup', onPointerUp);
window.addEventListener('keydown', onKeyDown);
console.log('[Placement] initialized');
}
// ── Tick (called from animation loop) ────────────────────────────────────
function update() {
// Deselect if selected node was removed
if (_selectedMAC && !Viz3D.getNodeMesh(_selectedMAC)) {
deselectNode();
}
rebuildGDOPIfDirty();
}
// ── Data from WebSocket registry_state ───────────────────────────────────
function handleRegistryState(msg) {
if (msg.room) {
_roomConfig = msg.room;
var wEl = document.getElementById('room-width');
var dEl = document.getElementById('room-depth');
var hEl = document.getElementById('room-height');
if (wEl) wEl.value = msg.room.width;
if (dEl) dEl.value = msg.room.depth;
if (hEl) hEl.value = msg.room.height;
}
if (msg.nodes) {
_nodeMACs = msg.nodes.map(function (n) {
return { mac: n.mac, virtual: !!n.virtual };
});
}
_gdopDirty = true;
}
// ── GDOP toggle ──────────────────────────────────────────────────────────
function toggleGDOP() {
_gdopEnabled = !_gdopEnabled;
var btn = document.getElementById('gdop-toggle-btn');
if (btn) btn.classList.toggle('active', _gdopEnabled);
var legend = document.getElementById('gdop-legend');
if (legend) legend.classList.toggle('visible', _gdopEnabled);
if (_gdopEnabled) {
updateGDOPOverlay();
} else if (_gdopMesh) {
_gdopMesh.visible = false;
}
}
// ── Public API ────────────────────────────────────────────────────────────
return {
init: init,
update: update,
handleRegistryState: handleRegistryState,
selectNode: selectNode,
deselectNode: deselectNode,
toggleGDOP: toggleGDOP,
toggleRoomEditor: toggleRoomEditor,
applyRoomFromEditor: applyRoomFromEditor,
addVirtualNode: function () {
var cx = _roomConfig ? (_roomConfig.origin_x || 0) + _roomConfig.width / 2 : 3;
var cz = _roomConfig ? (_roomConfig.origin_z || 0) + _roomConfig.depth / 2 : 2.5;
var h = _roomConfig ? _roomConfig.height * 0.8 : 2.0;
addVirtualNode('Virtual ' + _nodeMACs.length, cx, h, cz);
},
deleteSelectedNode: function () {
if (!_selectedMAC) return;
var isVirtual = _nodeMACs.some(function (n) { return n.mac === _selectedMAC && n.virtual; });
if (!isVirtual) return;
var mac = _selectedMAC;
deselectNode();
deleteNodeFromServer(mac);
}
};
})();