spaxel/dashboard/js/zone-editor.js
jedarden 72b9256ff4 feat: implement room transition portals and zone occupancy
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>
2026-04-10 08:10:12 -04:00

518 lines
20 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 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);
}
};
})();