Implements the complete zone and portal system for room transition detection and occupancy tracking: - Zone definitions using AABB volumes stored in SQLite - Portal definitions as doorway planes with plane points and dimensions - Portal editor in 3D dashboard using TransformControls - Zone editor in 3D dashboard for interactive zone placement - Crossing detection algorithm running at 10Hz in TrackManager - Two-phase crossing detection (tentative + committed) with velocity threshold - Occupancy counter with reconciliation pass for persisted values - WebSocket broadcasts for zone_occupancy and zone_transition events - REST API endpoints for zones and portals CRUD operations - Comprehensive tests for crossing detection, occupancy, and reconciliation Dashboard changes: - Add portal.js for 3D portal editor with TransformControls - Add zone-editor.js for 3D zone editor with TransformControls - Update index.html with portal/zone editor panels - Update app.js with zone_change, portal_change, zone_occupancy, zone_transition handlers - Update viz3d.js with zone/portal mesh rendering Backend changes: - Wire zone manager in main.go - Add WebSocket broadcasters in dashboard hub - Add comprehensive tests in zones/manager_test.go Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
518 lines
20 KiB
JavaScript
518 lines
20 KiB
JavaScript
/**
|
||
* Spaxel Zone Editor – Interactive zone placement with TransformControls
|
||
*
|
||
* Provides: TransformControls for dragging/resizing zones in 3D, zone
|
||
* creation/editing workflow, and REST API integration for persisting
|
||
* zone definitions.
|
||
*/
|
||
|
||
const ZoneEditor = (function () {
|
||
'use strict';
|
||
|
||
// ── module state ──────────────────────────────────────────────────────────
|
||
var _scene, _camera, _renderer, _orbitControls;
|
||
var _transformControls = null;
|
||
var _selectedZoneID = null;
|
||
var _editMode = false; // true when editing an existing zone
|
||
var _newZoneMesh = null; // temporary mesh for new zone
|
||
var _zones = []; // [{id, name, x, y, z, w, d, h, color, zoneType}]
|
||
var _mouseDown = { x: 0, y: 0 };
|
||
var _isDragging = false;
|
||
|
||
var DEFAULT_WIDTH = 4.0; // default room width (m)
|
||
var DEFAULT_DEPTH = 3.0; // default room depth (m)
|
||
var DEFAULT_HEIGHT = 2.5; // default room height (m)
|
||
var DEFAULT_COLOR = '#3b82f6'; // blue color for zones
|
||
|
||
// ── Zone mesh creation ────────────────────────────────────────────────────
|
||
|
||
function createZoneMesh(width, depth, height, position, color) {
|
||
var geometry = new THREE.BoxGeometry(width, height, depth);
|
||
var material = new THREE.MeshBasicMaterial({
|
||
color: parseInt(color.replace('#', '0x'), 16),
|
||
transparent: true,
|
||
opacity: 0.1,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false
|
||
});
|
||
|
||
var mesh = new THREE.Mesh(geometry, material);
|
||
mesh.position.set(position.x, position.y, position.z);
|
||
|
||
// Add edge helper for better visibility
|
||
var edges = new THREE.EdgesGeometry(geometry);
|
||
var line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
|
||
color: parseInt(color.replace('#', '0x'), 16),
|
||
transparent: true,
|
||
opacity: 0.3
|
||
}));
|
||
mesh.add(line);
|
||
|
||
return mesh;
|
||
}
|
||
|
||
// ── TransformControls setup ─────────────────────────────────────────────────
|
||
|
||
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 zone position on drag end
|
||
if (!event.value && _selectedZoneID) {
|
||
saveZonePosition();
|
||
}
|
||
});
|
||
|
||
_transformControls.addEventListener('objectChange', function () {
|
||
if (!_selectedZoneID) return;
|
||
|
||
var obj = _transformControls.object;
|
||
|
||
// Clamp to reasonable bounds (above floor, below ceiling)
|
||
obj.position.y = Math.max(0.1, Math.min(10.0, obj.position.y));
|
||
|
||
// Update zone panel fields
|
||
updateZonePanelFromMesh(obj);
|
||
});
|
||
}
|
||
|
||
// ── Zone selection ───────────────────────────────────────────────────────
|
||
|
||
function selectZone(zoneID) {
|
||
if (_selectedZoneID === zoneID) return;
|
||
|
||
deselectZone();
|
||
|
||
var zone = _zones.find(function (z) { return z.id === zoneID; });
|
||
if (!zone) return;
|
||
|
||
_selectedZoneID = zoneID;
|
||
_editMode = true;
|
||
|
||
// Create or update mesh for this zone
|
||
var mesh = getZoneMesh(zoneID);
|
||
if (!mesh) {
|
||
mesh = createZoneMeshFromData(zone);
|
||
mesh.userData.zoneID = zoneID;
|
||
_scene.add(mesh);
|
||
}
|
||
|
||
_transformControls.attach(mesh);
|
||
|
||
// Show zone editor panel
|
||
showZoneEditorPanel(zone);
|
||
|
||
// Highlight in zone list
|
||
document.querySelectorAll('.zone-item').forEach(function (el) {
|
||
el.classList.toggle('selected', el.dataset.zoneId === zoneID);
|
||
});
|
||
}
|
||
|
||
function deselectZone() {
|
||
if (_transformControls) _transformControls.detach();
|
||
_selectedZoneID = null;
|
||
_editMode = false;
|
||
|
||
// Remove new zone mesh if exists
|
||
if (_newZoneMesh) {
|
||
_scene.remove(_newZoneMesh);
|
||
_newZoneMesh.geometry.dispose();
|
||
_newZoneMesh.material.dispose();
|
||
_newZoneMesh = null;
|
||
}
|
||
|
||
document.querySelectorAll('.zone-item').forEach(function (el) {
|
||
el.classList.remove('selected');
|
||
});
|
||
|
||
var panel = document.getElementById('zone-editor-panel');
|
||
if (panel) panel.style.display = 'none';
|
||
}
|
||
|
||
// ── Zone creation workflow ───────────────────────────────────────────────
|
||
|
||
function startNewZone() {
|
||
deselectZone();
|
||
|
||
// Position zone at camera focal point, 2m away
|
||
var direction = new THREE.Vector3();
|
||
_camera.getWorldDirection(direction);
|
||
var position = new THREE.Vector3().copy(_camera.position).add(direction.multiplyScalar(2));
|
||
position.y = Math.max(0.5, Math.min(2.0, position.y)); // Default height
|
||
|
||
_newZoneMesh = createZoneMesh(DEFAULT_WIDTH, DEFAULT_DEPTH, DEFAULT_HEIGHT, position, DEFAULT_COLOR);
|
||
_newZoneMesh.userData.isNewZone = true;
|
||
_scene.add(_newZoneMesh);
|
||
|
||
_transformControls.attach(_newZoneMesh);
|
||
|
||
// Show zone editor panel for new zone
|
||
showNewZonePanel();
|
||
|
||
console.log('[ZoneEditor] Creating new zone at camera focus');
|
||
}
|
||
|
||
function saveNewZone() {
|
||
if (!_newZoneMesh) return;
|
||
|
||
var name = document.getElementById('zone-name').value || 'New Zone';
|
||
var x = parseFloat(document.getElementById('zone-x').value) || 0;
|
||
var y = parseFloat(document.getElementById('zone-y').value) || 0;
|
||
var z = parseFloat(document.getElementById('zone-z').value) || 0;
|
||
var w = parseFloat(document.getElementById('zone-w').value) || DEFAULT_WIDTH;
|
||
var d = parseFloat(document.getElementById('zone-d').value) || DEFAULT_DEPTH;
|
||
var h = parseFloat(document.getElementById('zone-h').value) || DEFAULT_HEIGHT;
|
||
var color = document.getElementById('zone-color').value || DEFAULT_COLOR;
|
||
var zoneType = document.getElementById('zone-type').value || 'general';
|
||
|
||
// Calculate zone data from mesh position
|
||
var mesh = _newZoneMesh;
|
||
var zoneData = {
|
||
id: undefined, // will be auto-generated
|
||
name: name,
|
||
x: mesh.position.x - w / 2,
|
||
y: mesh.position.y - h / 2,
|
||
z: mesh.position.z - d / 2,
|
||
w: w,
|
||
d: d,
|
||
h: h,
|
||
color: color,
|
||
zone_type: zoneType,
|
||
enabled: true
|
||
};
|
||
|
||
// Create zone via REST API
|
||
fetch('/api/zones', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(zoneData)
|
||
}).then(function (resp) {
|
||
if (resp.ok) {
|
||
return resp.json();
|
||
}
|
||
throw new Error('Failed to create zone');
|
||
}).then(function (created) {
|
||
console.log('[ZoneEditor] Zone created:', created.id);
|
||
deselectZone();
|
||
// The dashboard will receive a zone_change WebSocket message
|
||
// and Viz3D will create the permanent mesh
|
||
}).catch(function (e) {
|
||
console.error('[ZoneEditor] Create zone failed:', e);
|
||
alert('Failed to create zone: ' + e.message);
|
||
});
|
||
}
|
||
|
||
// ── Zone update workflow ─────────────────────────────────────────────────
|
||
|
||
function saveZonePosition() {
|
||
if (!_selectedZoneID || !_editMode) return;
|
||
|
||
var zone = _zones.find(function (z) { return z.id === _selectedZoneID; });
|
||
if (!zone) return;
|
||
|
||
var mesh = _transformControls.object;
|
||
if (!mesh) return;
|
||
|
||
// Calculate updated zone data from mesh
|
||
var updatedData = calculateZoneDataFromMesh(mesh, zone.name, zone.color, zone.zone_type);
|
||
|
||
// Update zone via REST API
|
||
fetch('/api/zones/' + encodeURIComponent(_selectedZoneID), {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updatedData)
|
||
}).then(function (resp) {
|
||
if (resp.ok) {
|
||
console.log('[ZoneEditor] Zone updated:', _selectedZoneID);
|
||
} else {
|
||
throw new Error('Failed to update zone');
|
||
}
|
||
}).catch(function (e) {
|
||
console.error('[ZoneEditor] Update zone failed:', e);
|
||
});
|
||
}
|
||
|
||
function deleteSelectedZone() {
|
||
if (!_selectedZoneID || !_editMode) return;
|
||
|
||
if (!confirm('Delete this zone?')) return;
|
||
|
||
fetch('/api/zones/' + encodeURIComponent(_selectedZoneID), {
|
||
method: 'DELETE'
|
||
}).then(function (resp) {
|
||
if (resp.ok) {
|
||
console.log('[ZoneEditor] Zone deleted:', _selectedZoneID);
|
||
deselectZone();
|
||
} else {
|
||
throw new Error('Failed to delete zone');
|
||
}
|
||
}).catch(function (e) {
|
||
console.error('[ZoneEditor] Delete zone failed:', e);
|
||
alert('Failed to delete zone: ' + e.message);
|
||
});
|
||
}
|
||
|
||
// ── Zone data calculation ────────────────────────────────────────────────
|
||
|
||
function calculateZoneDataFromMesh(mesh, name, color, zoneType) {
|
||
// Get zone position from mesh
|
||
var position = mesh.position;
|
||
|
||
// Get zone dimensions (stored in userData if created by us)
|
||
var w, h, d;
|
||
if (mesh.userData.zoneWidth) {
|
||
w = mesh.userData.zoneWidth;
|
||
h = mesh.userData.zoneHeight;
|
||
d = mesh.userData.zoneDepth;
|
||
} else {
|
||
// Extract from geometry scale or use defaults
|
||
w = DEFAULT_WIDTH;
|
||
h = DEFAULT_HEIGHT;
|
||
d = DEFAULT_DEPTH;
|
||
}
|
||
|
||
return {
|
||
id: _editMode ? _selectedZoneID : undefined,
|
||
name: name,
|
||
x: position.x - w / 2,
|
||
y: position.y - h / 2,
|
||
z: position.z - d / 2,
|
||
w: w,
|
||
d: d,
|
||
h: h,
|
||
color: color,
|
||
zone_type: zoneType || 'general',
|
||
enabled: true
|
||
};
|
||
}
|
||
|
||
function createZoneMeshFromData(zone) {
|
||
var width = zone.w || DEFAULT_WIDTH;
|
||
var height = zone.h || DEFAULT_HEIGHT;
|
||
var depth = zone.d || DEFAULT_DEPTH;
|
||
var color = zone.color || DEFAULT_COLOR;
|
||
|
||
// Calculate center position
|
||
var position = new THREE.Vector3(
|
||
(zone.x || 0) + width / 2,
|
||
(zone.y || 0) + height / 2,
|
||
(zone.z || 0) + depth / 2
|
||
);
|
||
|
||
var mesh = createZoneMesh(width, depth, height, position, color);
|
||
mesh.userData.zoneID = zone.id;
|
||
mesh.userData.zoneWidth = width;
|
||
mesh.userData.zoneHeight = height;
|
||
mesh.userData.zoneDepth = depth;
|
||
|
||
return mesh;
|
||
}
|
||
|
||
function getZoneMesh(zoneID) {
|
||
for (var i = 0; i < _scene.children.length; i++) {
|
||
var obj = _scene.children[i];
|
||
if (obj.userData && obj.userData.zoneID === zoneID) {
|
||
return obj;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Zone editor panel ────────────────────────────────────────────────────
|
||
|
||
function showNewZonePanel() {
|
||
var panel = document.getElementById('zone-editor-panel');
|
||
if (!panel) return;
|
||
|
||
panel.style.display = 'block';
|
||
|
||
// Set default values
|
||
document.getElementById('zone-name').value = 'New Zone';
|
||
document.getElementById('zone-w').value = DEFAULT_WIDTH;
|
||
document.getElementById('zone-d').value = DEFAULT_DEPTH;
|
||
document.getElementById('zone-h').value = DEFAULT_HEIGHT;
|
||
document.getElementById('zone-color').value = DEFAULT_COLOR;
|
||
document.getElementById('zone-type').value = 'general';
|
||
|
||
// Show save button, hide update/delete buttons
|
||
document.getElementById('zone-save-btn').style.display = 'inline-block';
|
||
document.getElementById('zone-update-btn').style.display = 'none';
|
||
document.getElementById('zone-delete-btn').style.display = 'none';
|
||
|
||
// Set panel title
|
||
panel.querySelector('h3').textContent = 'New Zone';
|
||
}
|
||
|
||
function showZoneEditorPanel(zone) {
|
||
var panel = document.getElementById('zone-editor-panel');
|
||
if (!panel) return;
|
||
|
||
panel.style.display = 'block';
|
||
|
||
// Populate fields with zone data
|
||
document.getElementById('zone-name').value = zone.name || '';
|
||
document.getElementById('zone-x').value = (zone.x || 0).toFixed(2);
|
||
document.getElementById('zone-y').value = (zone.y || 0).toFixed(2);
|
||
document.getElementById('zone-z').value = (zone.z || 0).toFixed(2);
|
||
document.getElementById('zone-w').value = (zone.w || DEFAULT_WIDTH).toFixed(2);
|
||
document.getElementById('zone-d').value = (zone.d || DEFAULT_DEPTH).toFixed(2);
|
||
document.getElementById('zone-h').value = (zone.h || DEFAULT_HEIGHT).toFixed(2);
|
||
document.getElementById('zone-color').value = zone.color || DEFAULT_COLOR;
|
||
document.getElementById('zone-type').value = zone.zone_type || 'general';
|
||
|
||
// Show update/delete buttons, hide save button
|
||
document.getElementById('zone-save-btn').style.display = 'none';
|
||
document.getElementById('zone-update-btn').style.display = 'inline-block';
|
||
document.getElementById('zone-delete-btn').style.display = 'inline-block';
|
||
|
||
// Set panel title
|
||
panel.querySelector('h3').textContent = 'Edit Zone';
|
||
}
|
||
|
||
function updateZonePanelFromMesh(mesh) {
|
||
// Update position display
|
||
var posDisplay = document.getElementById('zone-position-display');
|
||
if (posDisplay) {
|
||
posDisplay.textContent = 'X: ' + mesh.position.x.toFixed(2) +
|
||
' Y: ' + mesh.position.y.toFixed(2) +
|
||
' Z: ' + mesh.position.z.toFixed(2);
|
||
}
|
||
|
||
// Update dimension fields from mesh userData
|
||
if (mesh.userData.zoneWidth) {
|
||
document.getElementById('zone-w').value = mesh.userData.zoneWidth.toFixed(2);
|
||
document.getElementById('zone-d').value = mesh.userData.zoneDepth.toFixed(2);
|
||
document.getElementById('zone-h').value = mesh.userData.zoneHeight.toFixed(2);
|
||
}
|
||
}
|
||
|
||
// ── 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) > 5) return; // Not a click
|
||
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);
|
||
|
||
// Check for zone mesh intersections
|
||
var zoneMeshes = [];
|
||
for (var i = 0; i < _zones.length; i++) {
|
||
var mesh = getZoneMesh(_zones[i].id);
|
||
if (mesh) zoneMeshes.push(mesh);
|
||
}
|
||
|
||
var intersects = raycaster.intersectObjects(zoneMeshes);
|
||
if (intersects.length > 0) {
|
||
var mesh = intersects[0].object;
|
||
if (mesh.userData && mesh.userData.zoneID) {
|
||
selectZone(mesh.userData.zoneID);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Deselect if clicked elsewhere
|
||
deselectZone();
|
||
}
|
||
|
||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
|
||
|
||
function onKeyDown(event) {
|
||
if (event.target.tagName === 'INPUT') return;
|
||
|
||
if (event.key === 'Escape') {
|
||
deselectZone();
|
||
}
|
||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||
if (_selectedZoneID && _editMode) {
|
||
deleteSelectedZone();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Data from WebSocket/Viz3D ───────────────────────────────────────────────
|
||
|
||
function handleZoneUpdate(zones) {
|
||
_zones = zones.map(function (z) {
|
||
return {
|
||
id: z.id,
|
||
name: z.name,
|
||
x: z.x, y: z.y, z: z.z,
|
||
w: z.w, d: z.d, h: z.h,
|
||
color: z.color || '#3b82f6',
|
||
zoneType: z.zone_type || 'general',
|
||
enabled: z.enabled
|
||
};
|
||
});
|
||
}
|
||
|
||
// ── 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('[ZoneEditor] initialized');
|
||
}
|
||
|
||
// ── Tick (called from animation loop) ───────────────────────────────────────
|
||
|
||
function update() {
|
||
// Deselect if selected zone was deleted
|
||
if (_selectedZoneID && _editMode) {
|
||
var zone = _zones.find(function (z) { return z.id === _selectedZoneID; });
|
||
if (!zone) {
|
||
deselectZone();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Public API ────────────────────────────────────────────────────────────
|
||
|
||
return {
|
||
init: init,
|
||
update: update,
|
||
handleZoneUpdate: handleZoneUpdate,
|
||
startNewZone: startNewZone,
|
||
saveNewZone: saveNewZone,
|
||
deleteSelectedZone: deleteSelectedZone,
|
||
selectZone: selectZone,
|
||
deselectZone: deselectZone,
|
||
togglePanel: function () {
|
||
var panel = document.getElementById('zone-editor-panel');
|
||
if (!panel) return;
|
||
var visible = panel.style.display !== 'none';
|
||
panel.style.display = visible ? 'none' : 'block';
|
||
var btn = document.getElementById('add-zone-btn');
|
||
if (btn) btn.classList.toggle('active', !visible);
|
||
}
|
||
};
|
||
})();
|