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>
This commit is contained in:
jedarden 2026-04-10 08:09:58 -04:00
parent 23fd6e89b3
commit 72b9256ff4
10 changed files with 2270 additions and 5 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
b583990d434dd472d8bac8cfb60f3129ff93e56a
5803bb790a995dc1ab91e8185a8bb5b08eb3faf7

View file

@ -1490,6 +1490,126 @@
#room-editor-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
#room-editor-btn.active { background: rgba(79,195,247,0.2); color: #4fc3f7; border-color: #4fc3f7; }
/* Add Portal button in status bar */
#add-portal-btn {
background: rgba(255, 167, 38, 0.15);
border: 1px solid rgba(255, 167, 38, 0.4);
color: #ffa726;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#add-portal-btn:hover {
background: rgba(255, 167, 38, 0.25);
}
/* Portal editor button */
#portal-editor-btn {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: #888;
font-size: 12px;
padding: 3px 10px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
#portal-editor-btn:hover { background: rgba(255,255,255,0.12); color: #ccc; }
#portal-editor-btn.active { background: rgba(255,167,38,0.2); color: #ffa726; border-color: #ffa726; }
/* Portal editor panel */
#portal-editor-panel {
position: fixed;
top: 60px;
left: 580px;
width: 240px;
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 16px;
z-index: 100;
display: none;
backdrop-filter: blur(10px);
}
#portal-editor-panel h3 {
font-size: 14px;
color: #ffa726;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
#portal-editor-panel .room-field {
margin-bottom: 12px;
}
#portal-editor-panel .room-field label {
display: block;
font-size: 11px;
color: #888;
margin-bottom: 4px;
}
#portal-editor-panel .room-field input,
#portal-editor-panel .room-field select {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
box-sizing: border-box;
}
#portal-editor-panel .room-field input:focus,
#portal-editor-panel .room-field select:focus {
outline: none;
border-color: rgba(255, 167, 38, 0.5);
}
#portal-editor-panel .portal-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
#portal-editor-panel .portal-actions button {
flex: 1;
background: rgba(79, 195, 247, 0.15);
border: 1px solid rgba(79, 195, 247, 0.4);
color: #4fc3f7;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
transition: background 0.2s;
}
#portal-editor-panel .portal-actions button:hover {
background: rgba(79, 195, 247, 0.25);
}
#portal-editor-panel .portal-delete-btn {
background: rgba(239, 83, 80, 0.15);
border-color: rgba(239, 83, 80, 0.4);
color: #ef5350;
}
#portal-editor-panel .portal-delete-btn:hover {
background: rgba(239, 83, 80, 0.25);
}
#portal-editor-panel .portal-position-info {
font-size: 11px;
color: #888;
margin-bottom: 8px;
}
/* Simulator button */
#simulator-btn {
background: rgba(255,255,255,0.06);
@ -2868,7 +2988,11 @@
<button class="view-btn" id="view-follow" onclick="Viz3D.setViewPreset('follow')">Follow</button>
<button id="gdop-toggle-btn" onclick="Placement && Placement.toggleGDOP()">GDOP</button>
<button id="fresnel-toggle-btn" onclick="toggleFresnelZones()">Fresnel</button>
<button id="zones-toggle-btn" onclick="Viz3D && Viz3D.toggleZonesVisible()">Zones</button>
<button id="portals-toggle-btn" onclick="Viz3D && Viz3D.togglePortalsVisible()">Portals</button>
<button id="room-editor-btn" onclick="Placement && Placement.toggleRoomEditor()">Room</button>
<button id="add-portal-btn" onclick="PortalEditor && PortalEditor.startNewPortal()">+ Add Portal</button>
<button id="portal-editor-btn" onclick="PortalEditor && PortalEditor.togglePanel()">Portals</button>
<button id="floorplan-btn" onclick="FloorPlanSetup.togglePanel()">Floor plan</button>
<button id="simulator-btn" onclick="Simulate && Simulate.togglePanel()">Simulator</button>
</div>
@ -2961,6 +3085,10 @@
<script src="js/auth.js"></script>
<!-- 3-D spatial visualisation layer -->
<script src="js/viz3d.js"></script>
<!-- Portal Editor -->
<script src="js/portal.js"></script>
<!-- Zone Editor -->
<script src="js/zone-editor.js"></script>
<!-- Fresnel zone helper (shared with explainability) -->
<script src="js/fresnel.js"></script>
<!-- Node placement, GDOP coverage, room editor -->
@ -3049,6 +3177,94 @@
<button id="room-apply-btn" onclick="Placement && Placement.applyRoomFromEditor()">Apply</button>
</div>
<!-- Portal editor panel -->
<div id="portal-editor-panel" style="display: none;">
<h3>New Portal</h3>
<div class="room-field">
<label>Name</label>
<input type="text" id="portal-name" value="New Portal" placeholder="e.g., Kitchen Door">
</div>
<div class="room-field">
<label>Width <span class="unit">(m)</span></label>
<input type="number" id="portal-width" value="0.9" min="0.3" max="5" step="0.05">
</div>
<div class="room-field">
<label>Height <span class="unit">(m)</span></label>
<input type="number" id="portal-height" value="2.1" min="1.5" max="5" step="0.1">
</div>
<div class="room-field">
<label>Zone A</label>
<select id="portal-zone-a">
<option value="">-- Select Zone --</option>
</select>
</div>
<div class="room-field">
<label>Zone B</label>
<select id="portal-zone-b">
<option value="">-- Select Zone --</option>
</select>
</div>
<div class="portal-position-info" id="portal-position-display" style="font-size: 11px; color: #888; margin-bottom: 8px;">
Drag portal to position
</div>
<div class="portal-actions">
<button id="portal-save-btn" onclick="PortalEditor && PortalEditor.saveNewPortal()">Save Portal</button>
<button id="portal-update-btn" onclick="PortalEditor && PortalEditor.savePortalPosition()" style="display: none;">Update</button>
<button id="portal-delete-btn" class="portal-delete-btn" onclick="PortalEditor && PortalEditor.deleteSelectedPortal()" style="display: none;">Delete</button>
<button onclick="PortalEditor && PortalEditor.deselectPortal()">Cancel</button>
</div>
</div>
<!-- Zone editor panel -->
<div id="zone-editor-panel" style="display: none;">
<h3>New Zone</h3>
<div class="room-field">
<label>Name</label>
<input type="text" id="zone-name" placeholder="Kitchen">
</div>
<div class="room-field">
<label>Position (meters)</label>
<div style="display: flex; gap: 8px;">
<input type="number" id="zone-x" placeholder="X" step="0.1" style="flex:1">
<input type="number" id="zone-y" placeholder="Y" step="0.1" style="flex:1">
<input type="number" id="zone-z" placeholder="Z" step="0.1" style="flex:1">
</div>
</div>
<div class="room-field">
<label>Size (meters)</label>
<div style="display: flex; gap: 8px;">
<input type="number" id="zone-w" placeholder="Width" step="0.1" min="0.1" style="flex:1">
<input type="number" id="zone-d" placeholder="Depth" step="0.1" min="0.1" style="flex:1">
<input type="number" id="zone-h" placeholder="Height" step="0.1" min="0.1" value="2.5" style="flex:1">
</div>
</div>
<div class="room-field">
<label>Color</label>
<input type="color" id="zone-color" value="#3b82f6">
</div>
<div class="room-field">
<label>Zone Type</label>
<select id="zone-type">
<option value="general">General</option>
<option value="bedroom">Bedroom</option>
<option value="kitchen">Kitchen</option>
<option value="living">Living Room</option>
<option value="office">Office</option>
<option value="entry">Entry</option>
<option value="bathroom">Bathroom</option>
</select>
</div>
<div class="zone-position-info" style="font-size: 11px; color: #888; margin-bottom: 8px;">
Drag zone to position in 3D view
</div>
<div class="portal-actions">
<button id="zone-save-btn" onclick="ZoneEditor && ZoneEditor.saveNewZone()">Save Zone</button>
<button id="zone-update-btn" onclick="ZoneEditor.saveZonePosition()" style="display: none;">Update</button>
<button id="zone-delete-btn" class="portal-delete-btn" onclick="ZoneEditor.deleteSelectedZone()" style="display: none;">Delete</button>
<button onclick="ZoneEditor.deselectZone()">Cancel</button>
</div>
</div>
<!-- Pre-Deployment Simulator Panel -->
<div id="simulator-panel" class="simulator-panel" style="display:none;">
<div class="simulator-header">

View file

@ -164,6 +164,16 @@
Placement.init(scene, camera, renderer, controls);
}
// Initialise zone editor (TransformControls for zones)
if (window.ZoneEditor) {
ZoneEditor.init(scene, camera, renderer, controls);
}
// Initialise portal editor (TransformControls for portals)
if (window.PortalEditor) {
PortalEditor.init(scene, camera, renderer, controls);
}
console.log('[Spaxel] Scene initialized');
}
@ -811,6 +821,34 @@
}
break;
case 'zone_change':
// Zone created, updated, or deleted
if (window.Viz3D && Viz3D.handleZoneChange) {
Viz3D.handleZoneChange(msg);
}
break;
case 'portal_change':
// Portal created, updated, or deleted
if (window.Viz3D && Viz3D.handlePortalChange) {
Viz3D.handlePortalChange(msg);
}
break;
case 'zone_occupancy':
// Zone occupancy counts update
if (window.Viz3D && Viz3D.handleZoneOccupancy) {
Viz3D.handleZoneOccupancy(msg);
}
break;
case 'zone_transition':
// Portal crossing event
if (window.Viz3D && Viz3D.handleZoneTransition) {
Viz3D.handleZoneTransition(msg);
}
break;
default:
// Log unhandled types for future debugging
console.log('[Spaxel] Unknown message type:', msg.type, msg);
@ -893,6 +931,13 @@
}
}
// Portals
if (msg.portals) {
if (window.Viz3D && window.Viz3D.handlePortalUpdate) {
Viz3D.handlePortalUpdate(msg.portals);
}
}
updateNodeList();
updateLinkList();
Viz3D.applyLinks(msg.links || []);
@ -972,6 +1017,13 @@
}
}
// Portals
if (msg.portals) {
if (window.Viz3D && window.Viz3D.handlePortalUpdate) {
Viz3D.handlePortalUpdate(msg.portals);
}
}
// Events buffered since last tick (presence transitions, zone entries/exits, portal crossings)
if (msg.events && Array.isArray(msg.events)) {
msg.events.forEach(function (evt) {

533
dashboard/js/portal.js Normal file
View file

@ -0,0 +1,533 @@
/**
* Spaxel Portal Editor Interactive portal placement with TransformControls
*
* Provides: TransformControls for dragging/rotating portals in 3D, portal
* creation/editing workflow, zone assignment, and REST API integration for
* persisting portal definitions.
*/
const PortalEditor = (function () {
'use strict';
// ── module state ──────────────────────────────────────────────────────────
var _scene, _camera, _renderer, _orbitControls;
var _transformControls = null;
var _selectedPortalID = null;
var _editMode = false; // true when editing an existing portal
var _newPortalMesh = null; // temporary mesh for new portal
var _portals = []; // [{id, name, zoneA, zoneB, p1, p2, p3, width, height}]
var _zones = []; // [{id, name, minX, minY, minZ, maxX, maxY, maxZ}]
var _mouseDown = { x: 0, y: 0 };
var _isDragging = false;
var DEFAULT_WIDTH = 0.9; // standard door width (m)
var DEFAULT_HEIGHT = 2.1; // standard door height (m)
var PORTAL_COLOR = 0xffa726; // orange color for portal planes
// ── Portal mesh creation ───────────────────────────────────────────────────
function createPortalMesh(width, height, position, quaternion) {
var geometry = new THREE.PlaneGeometry(width, height);
var material = new THREE.MeshBasicMaterial({
color: PORTAL_COLOR,
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide,
depthWrite: false
});
var mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(position);
mesh.quaternion.copy(quaternion);
// Add edge helper for better visibility
var edges = new THREE.EdgesGeometry(geometry);
var line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffcc80 }));
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 portal position on drag end
if (!event.value && _selectedPortalID) {
savePortalPosition();
}
});
_transformControls.addEventListener('objectChange', function () {
if (!_selectedPortalID) return;
var obj = _transformControls.object;
// Clamp to reasonable bounds (above floor, below ceiling)
obj.position.y = Math.max(0.1, Math.min(4.0, obj.position.y));
// Update portal panel fields
updatePortalPanelFromMesh(obj);
});
}
// ── Portal selection ───────────────────────────────────────────────────────
function selectPortal(portalID) {
if (_selectedPortalID === portalID) return;
deselectPortal();
var portal = _portals.find(function (p) { return p.id === portalID; });
if (!portal) return;
_selectedPortalID = portalID;
_editMode = true;
// Create or update mesh for this portal
var mesh = getPortalMesh(portalID);
if (!mesh) {
mesh = createPortalMeshFromData(portal);
mesh.userData.portalID = portalID;
_scene.add(mesh);
}
_transformControls.attach(mesh);
// Show portal editor panel
showPortalEditorPanel(portal);
// Highlight in portal list
document.querySelectorAll('.portal-item').forEach(function (el) {
el.classList.toggle('selected', el.dataset.portalId === portalID);
});
}
function deselectPortal() {
if (_transformControls) _transformControls.detach();
_selectedPortalID = null;
_editMode = false;
// Remove new portal mesh if exists
if (_newPortalMesh) {
_scene.remove(_newPortalMesh);
_newPortalMesh.geometry.dispose();
_newPortalMesh.material.dispose();
_newPortalMesh = null;
}
document.querySelectorAll('.portal-item').forEach(function (el) {
el.classList.remove('selected');
});
var panel = document.getElementById('portal-editor-panel');
if (panel) panel.style.display = 'none';
}
// ── Portal creation workflow ───────────────────────────────────────────────
function startNewPortal() {
deselectPortal();
// Position portal 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(1.0, Math.min(2.5, position.y)); // Default height
// Create default portal facing camera
var quaternion = new THREE.Quaternion();
quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), direction.normalize());
_newPortalMesh = createPortalMesh(DEFAULT_WIDTH, DEFAULT_HEIGHT, position, quaternion);
_newPortalMesh.userData.isNewPortal = true;
_scene.add(_newPortalMesh);
_transformControls.attach(_newPortalMesh);
// Show portal editor panel for new portal
showNewPortalPanel();
console.log('[PortalEditor] Creating new portal at camera focus');
}
function saveNewPortal() {
if (!_newPortalMesh) return;
var name = document.getElementById('portal-name').value || 'New Portal';
var zoneA = document.getElementById('portal-zone-a').value;
var zoneB = document.getElementById('portal-zone-b').value;
var width = parseFloat(document.getElementById('portal-width').value) || DEFAULT_WIDTH;
var height = parseFloat(document.getElementById('portal-height').value) || DEFAULT_HEIGHT;
// Calculate portal plane from mesh transform
var portalData = calculatePortalDataFromMesh(_newPortalMesh, name, zoneA, zoneB, width, height);
// Create portal via REST API
fetch('/api/portals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(portalData)
}).then(function (resp) {
if (resp.ok) {
return resp.json();
}
throw new Error('Failed to create portal');
}).then(function (created) {
console.log('[PortalEditor] Portal created:', created.id);
deselectPortal();
// The dashboard will receive a portal_change WebSocket message
// and Viz3D will create the permanent mesh
}).catch(function (e) {
console.error('[PortalEditor] Create portal failed:', e);
alert('Failed to create portal: ' + e.message);
});
}
// ── Portal update workflow ─────────────────────────────────────────────────
function savePortalPosition() {
if (!_selectedPortalID || !_editMode) return;
var portal = _portals.find(function (p) { return p.id === _selectedPortalID; });
if (!portal) return;
var mesh = _transformControls.object;
if (!mesh) return;
// Calculate updated portal data from mesh
var updatedData = calculatePortalDataFromMesh(mesh, portal.name, portal.zoneA, portal.zoneB,
parseFloat(document.getElementById('portal-width').value) || portal.width,
parseFloat(document.getElementById('portal-height').value) || portal.height);
// Update portal via REST API
fetch('/api/portals/' + encodeURIComponent(_selectedPortalID), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedData)
}).then(function (resp) {
if (resp.ok) {
console.log('[PortalEditor] Portal updated:', _selectedPortalID);
} else {
throw new Error('Failed to update portal');
}
}).catch(function (e) {
console.error('[PortalEditor] Update portal failed:', e);
});
}
function deleteSelectedPortal() {
if (!_selectedPortalID || !_editMode) return;
if (!confirm('Delete this portal?')) return;
fetch('/api/portals/' + encodeURIComponent(_selectedPortalID), {
method: 'DELETE'
}).then(function (resp) {
if (resp.ok) {
console.log('[PortalEditor] Portal deleted:', _selectedPortalID);
deselectPortal();
} else {
throw new Error('Failed to delete portal');
}
}).catch(function (e) {
console.error('[PortalEditor] Delete portal failed:', e);
alert('Failed to delete portal: ' + e.message);
});
}
// ── Portal data calculation ────────────────────────────────────────────────
function calculatePortalDataFromMesh(mesh, name, zoneA, zoneB, width, height) {
// Get portal position and orientation from mesh
var position = mesh.position;
var normal = new THREE.Vector3(0, 0, 1);
normal.applyQuaternion(mesh.quaternion);
// Calculate three points defining the portal plane
// P1: center of bottom edge
var p1 = new THREE.Vector3(0, -height / 2, 0);
// P2: center of top edge
var p2 = new THREE.Vector3(0, height / 2, 0);
// P3: one corner of bottom edge (defines width direction)
var p3 = new THREE.Vector3(-width / 2, -height / 2, 0);
p1.applyQuaternion(mesh.quaternion).add(mesh.position);
p2.applyQuaternion(mesh.quaternion).add(mesh.position);
p3.applyQuaternion(mesh.quaternion).add(mesh.position);
return {
id: _editMode ? _selectedPortalID : undefined,
name: name,
zone_a: zoneA || '',
zone_b: zoneB || '',
p1_x: p1.x, p1_y: p1.y, p1_z: p1.z,
p2_x: p2.x, p2_y: p2.y, p2_z: p2.z,
p3_x: p3.x, p3_y: p3.y, p3_z: p3.z,
width: width,
height: height,
enabled: true
};
}
function createPortalMeshFromData(portal) {
// Calculate position and quaternion from three points
var p1 = new THREE.Vector3(portal.p1_x || 0, portal.p1_y || 0, portal.p1_z || 0);
var p2 = new THREE.Vector3(portal.p2_x || 0, portal.p2_y || 0, portal.p2_z || 0);
var p3 = new THREE.Vector3(portal.p3_x || 0, portal.p3_y || 0, portal.p3_z || 0);
// Calculate center (midpoint of p1 and p2)
var position = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
// Calculate normal from the plane defined by p1, p2, p3
var v1 = new THREE.Vector3().subVectors(p2, p1);
var v2 = new THREE.Vector3().subVectors(p3, p1);
var normal = new THREE.Vector3().crossVectors(v1, v2).normalize();
// Calculate quaternion from normal (default +Z facing)
var quaternion = new THREE.Quaternion();
var up = new THREE.Vector3(0, 0, 1);
quaternion.setFromUnitVectors(up, normal);
var width = portal.width || DEFAULT_WIDTH;
var height = portal.height || DEFAULT_HEIGHT;
return createPortalMesh(width, height, position, quaternion);
}
function getPortalMesh(portalID) {
for (var i = 0; i < _scene.children.length; i++) {
var obj = _scene.children[i];
if (obj.userData && obj.userData.portalID === portalID) {
return obj;
}
}
return null;
}
// ── Portal editor panel ────────────────────────────────────────────────────
function showNewPortalPanel() {
var panel = document.getElementById('portal-editor-panel');
if (!panel) return;
panel.style.display = 'block';
// Set default values
document.getElementById('portal-name').value = 'New Portal';
document.getElementById('portal-width').value = DEFAULT_WIDTH;
document.getElementById('portal-height').value = DEFAULT_HEIGHT;
// Populate zone dropdowns
populateZoneDropdowns();
// Show save button, hide update button
document.getElementById('portal-save-btn').style.display = 'inline-block';
document.getElementById('portal-update-btn').style.display = 'none';
document.getElementById('portal-delete-btn').style.display = 'none';
// Set panel title
document.querySelector('#portal-editor-panel h3').textContent = 'New Portal';
}
function showPortalEditorPanel(portal) {
var panel = document.getElementById('portal-editor-panel');
if (!panel) return;
panel.style.display = 'block';
// Populate fields with portal data
document.getElementById('portal-name').value = portal.name || '';
document.getElementById('portal-width').value = portal.width || DEFAULT_WIDTH;
document.getElementById('portal-height').value = portal.height || DEFAULT_HEIGHT;
// Populate zone dropdowns
populateZoneDropdowns();
document.getElementById('portal-zone-a').value = portal.zoneA || '';
document.getElementById('portal-zone-b').value = portal.zoneB || '';
// Show update/delete buttons, hide save button
document.getElementById('portal-save-btn').style.display = 'none';
document.getElementById('portal-update-btn').style.display = 'inline-block';
document.getElementById('portal-delete-btn').style.display = 'inline-block';
// Set panel title
document.querySelector('#portal-editor-panel h3').textContent = 'Edit Portal';
}
function populateZoneDropdowns() {
var zoneASelect = document.getElementById('portal-zone-a');
var zoneBSelect = document.getElementById('portal-zone-b');
if (!zoneASelect || !zoneBSelect) return;
// Clear existing options
zoneASelect.innerHTML = '<option value="">-- Select Zone --</option>';
zoneBSelect.innerHTML = '<option value="">-- Select Zone --</option>';
// Add zone options
_zones.forEach(function (zone) {
var optionA = new Option(zone.name || zone.id, zone.id);
var optionB = new Option(zone.name || zone.id, zone.id);
zoneASelect.add(optionA);
zoneBSelect.add(optionB);
});
}
function updatePortalPanelFromMesh(mesh) {
// Update position display
var posDisplay = document.getElementById('portal-position-display');
if (posDisplay) {
posDisplay.textContent = 'X: ' + mesh.position.x.toFixed(2) +
' Y: ' + mesh.position.y.toFixed(2) +
' Z: ' + mesh.position.z.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 portal mesh intersections
var portalMeshes = [];
for (var i = 0; i < _portals.length; i++) {
var mesh = getPortalMesh(_portals[i].id);
if (mesh) portalMeshes.push(mesh);
}
var intersects = raycaster.intersectObjects(portalMeshes);
if (intersects.length > 0) {
var mesh = intersects[0].object;
if (mesh.userData && mesh.userData.portalID) {
selectPortal(mesh.userData.portalID);
return;
}
}
// Deselect if clicked elsewhere
deselectPortal();
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────
function onKeyDown(event) {
if (event.target.tagName === 'INPUT') return;
if (event.key === 'Escape') {
deselectPortal();
}
if (event.key === 'Delete' || event.key === 'Backspace') {
if (_selectedPortalID && _editMode) {
deleteSelectedPortal();
}
}
}
// ── Data from WebSocket/Viz3D ───────────────────────────────────────────────
function handlePortalUpdate(portals) {
_portals = portals.map(function (p) {
return {
id: p.id,
name: p.name,
zoneA: p.zone_a,
zoneB: p.zone_b,
p1_x: p.p1_x, p1_y: p.p1_y, p1_z: p.p1_z,
p2_x: p.p2_x, p2_y: p.p2_y, p2_z: p.p2_z,
p3_x: p.p3_x, p3_y: p.p3_y, p3_z: p.p3_z,
width: p.width,
height: p.height,
enabled: p.enabled
};
});
}
function handleZoneUpdate(zones) {
_zones = zones.map(function (z) {
return {
id: z.id,
name: z.name,
minX: z.x, minY: z.y, minZ: z.z,
maxX: z.x + z.w, maxY: z.y + z.h, maxZ: z.z + z.d
};
});
}
// ── 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('[PortalEditor] initialized');
}
// ── Tick (called from animation loop) ───────────────────────────────────────
function update() {
// Deselect if selected portal was deleted
if (_selectedPortalID && _editMode) {
var portal = _portals.find(function (p) { return p.id === _selectedPortalID; });
if (!portal) {
deselectPortal();
}
}
}
// ── Public API ────────────────────────────────────────────────────────────
return {
init: init,
update: update,
handlePortalUpdate: handlePortalUpdate,
handleZoneUpdate: handleZoneUpdate,
startNewPortal: startNewPortal,
saveNewPortal: saveNewPortal,
deleteSelectedPortal: deleteSelectedPortal,
selectPortal: selectPortal,
deselectPortal: deselectPortal,
togglePanel: function () {
var panel = document.getElementById('portal-editor-panel');
if (!panel) return;
var visible = panel.style.display !== 'none';
panel.style.display = visible ? 'none' : 'block';
var btn = document.getElementById('portal-editor-btn');
if (btn) btn.classList.toggle('active', !visible);
}
};
})();

View file

@ -34,6 +34,14 @@ const Viz3D = (function () {
let _ghostLine = null; // THREE.Line (dashed, from original to ghost)
let _ghostNodeMAC = null; // MAC of the node being moved
// Zone and portal rendering state
let _zoneMeshes = new Map(); // zoneID -> { mesh, label, occupantsLabel }
let _portalMeshes = new Map(); // portalID -> { mesh, label, flashEndTime }
let _zonesVisible = true; // Toggle state for zones layer
let _portalsVisible = true; // Toggle state for portals layer
let _currentZones = new Map(); // zoneID -> zone data
let _currentPortals = new Map(); // portalID -> portal data
const BLOB_COLORS = [0xef5350, 0x66bb6a, 0x42a5f5, 0xffa726, 0xab47bc, 0x26c6da];
const TRAIL_COLORS = [0xff8a80, 0xa5d6a7, 0x90caf9, 0xffcc80, 0xce93d8, 0x80deea];
@ -74,6 +82,9 @@ const Viz3D = (function () {
// Update anomaly zone pulse
updateAnomalyPulse(dt);
// Update portal flash animations
updatePortalFlashes(dt);
}
// ── room bounds ───────────────────────────────────────────────────────────
@ -1577,6 +1588,82 @@ const Viz3D = (function () {
if (line) { _scene.remove(line); _linkLines.delete(msg.id); }
}
function handleZoneChange(msg) {
var zone = msg.zone;
if (!zone) return;
if (msg.action === 'deleted') {
var existing = _zoneMeshes.get(zone.id);
if (existing) {
_scene.remove(existing.mesh);
_scene.remove(existing.label);
_scene.remove(existing.occupantsLabel);
existing.mesh.geometry.dispose();
existing.mesh.material.dispose();
_zoneMeshes.delete(zone.id);
}
_currentZones.delete(zone.id);
} else {
_currentZones.set(zone.id, zone);
var existing = _zoneMeshes.get(zone.id);
if (!existing) {
var zoneMesh = _createZoneMesh(zone);
_zoneMeshes.set(zone.id, zoneMesh);
} else {
// Update existing zone
existing.occupantsLabel.visible = zone.count > 0;
if (zone.count > 0) {
var peopleText = zone.people && zone.people.length > 0 ? zone.people.join(', ') : zone.count;
_updateTextSprite(existing.occupantsLabel, zone.name + ': ' + peopleText);
}
}
}
}
function handlePortalChange(msg) {
var portal = msg.portal;
if (!portal) return;
if (msg.action === 'deleted') {
var existing = _portalMeshes.get(portal.id);
if (existing) {
_scene.remove(existing.mesh);
_scene.remove(existing.label);
existing.mesh.geometry.dispose();
existing.mesh.material.dispose();
_portalMeshes.delete(portal.id);
}
_currentPortals.delete(portal.id);
} else {
_currentPortals.set(portal.id, portal);
var existing = _portalMeshes.get(portal.id);
if (!existing) {
var portalMesh = _createPortalMesh(portal);
_portalMeshes.set(portal.id, portalMesh);
}
}
}
function handleZoneOccupancy(msg) {
var zones = msg.zones || [];
zones.forEach(function(zoneOcc) {
var zoneMesh = _zoneMeshes.get(zoneOcc.id);
if (zoneMesh && zoneOcc.count > 0) {
zoneMesh.occupantsLabel.visible = true;
var zone = _currentZones.get(zoneOcc.id);
var zoneName = zone ? zone.name : zoneOcc.id;
_updateTextSprite(zoneMesh.occupantsLabel, zoneName + ': ' + zoneOcc.count);
}
});
}
function handleZoneTransition(msg) {
// Flash the portal to indicate crossing
if (msg.portal_id) {
flashPortal(msg.portal_id);
}
}
// ── view presets ──────────────────────────────────────────────────────────
function setViewPreset(preset, blobId) {
@ -2459,6 +2546,258 @@ const Viz3D = (function () {
_anomalyZones = [];
}
// ── Zone and Portal Rendering ───────────────────────────────────────────────
/**
* Create a zone mesh as a semi-transparent colored cuboid.
* @param {Object} zone - Zone data with id, name, x, y, z, w, d, h, color
* @returns {Object} { mesh, label, occupantsLabel }
*/
function _createZoneMesh(zone) {
var geometry = new THREE.BoxGeometry(zone.w || 4, zone.h || 2.5, zone.d || 3);
var color = zone.color ? parseInt(zone.color.replace('#', '0x')) : 0x3b82f6;
var material = new THREE.MeshLambertMaterial({
color: color,
transparent: true,
opacity: 0.1,
side: THREE.DoubleSide,
depthWrite: false
});
var mesh = new THREE.Mesh(geometry, material);
mesh.position.set(
(zone.x || 0) + (zone.w || 4) / 2,
(zone.y || 0) + (zone.h || 2.5) / 2,
(zone.z || 0) + (zone.d || 3) / 2
);
mesh.userData.zoneId = zone.id;
mesh.renderOrder = -1; // Render before other objects
_scene.add(mesh);
// Create zone label (floating text at zone centroid)
var label = _createTextSprite(zone.name || zone.id, color);
var cx = (zone.x || 0) + (zone.w || 4) / 2;
var cy = (zone.y || 0) + (zone.h || 2.5) / 2;
var cz = (zone.z || 0) + (zone.d || 3) / 2;
label.position.set(cx, cy + 0.5, cz);
_scene.add(label);
// Create occupants label (initially empty)
var occupantsLabel = _createTextSprite('', color);
occupantsLabel.position.set(cx, cy + 0.2, cz);
occupantsLabel.visible = false;
_scene.add(occupantsLabel);
return { mesh: mesh, label: label, occupantsLabel: occupantsLabel };
}
/**
* Create a portal mesh as a thin vertical plane.
* @param {Object} portal - Portal data with id, name, p1_x, p1_y, p1_z, p2_x, p2_y, p2_z, width, height
* @returns {Object} { mesh, label, flashEndTime }
*/
function _createPortalMesh(portal) {
var p1 = new THREE.Vector3(portal.p1_x || 0, portal.p1_y || 0, portal.p1_z || 0);
var p2 = new THREE.Vector3(portal.p2_x || 0, portal.p2_y || 0, portal.p2_z || 0);
var width = portal.width || 1.0;
var height = portal.height || 2.1;
// Calculate portal center
var center = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
// Create plane geometry (width x height)
var geometry = new THREE.PlaneGeometry(width, height);
var material = new THREE.MeshLambertMaterial({
color: 0xa855f7, // Purple
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide,
depthWrite: false
});
var mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(center);
// Calculate orientation (perpendicular to floor, facing along portal normal)
// For a vertical plane defined by two floor points, we need the horizontal direction
var dx = p2.x - p1.x;
var dz = p2.z - p1.z;
var angle = Math.atan2(dz, dx);
mesh.rotation.y = angle + Math.PI / 2;
mesh.userData.portalId = portal.id;
mesh.renderOrder = -1;
_scene.add(mesh);
// Create portal label at top edge
var label = _createTextSprite(portal.name || portal.id, '#a855f7');
label.position.set(center.x, center.y + height / 2 + 0.3, center.z);
_scene.add(label);
return { mesh: mesh, label: label, flashEndTime: 0 };
}
/**
* Update zones from the snapshot data.
* @param {Array} zones - Array of zone objects from snapshot
*/
function updateZones(zones) {
if (!zones) return;
var zoneIDs = new Set();
zones.forEach(function(zone) {
zoneIDs.add(zone.id);
_currentZones.set(zone.id, zone);
var existing = _zoneMeshes.get(zone.id);
if (!existing) {
// Create new zone mesh
var zoneMesh = _createZoneMesh(zone);
_zoneMeshes.set(zone.id, zoneMesh);
} else {
// Update existing zone
existing.occupantsLabel.visible = zone.count > 0;
if (zone.count > 0) {
var peopleText = zone.people && zone.people.length > 0 ? zone.people.join(', ') : zone.count;
_updateTextSprite(existing.occupantsLabel, zone.name + ': ' + peopleText);
}
}
});
// Remove zones that no longer exist
_zoneMeshes.forEach(function(zoneMesh, zoneID) {
if (!zoneIDs.has(zoneID)) {
_scene.remove(zoneMesh.mesh);
_scene.remove(zoneMesh.label);
_scene.remove(zoneMesh.occupantsLabel);
zoneMesh.mesh.geometry.dispose();
zoneMesh.mesh.material.dispose();
_zoneMeshes.delete(zoneID);
}
});
}
/**
* Update portals from the snapshot data.
* @param {Array} portals - Array of portal objects from snapshot
*/
function updatePortals(portals) {
if (!portals) return;
var portalIDs = new Set();
portals.forEach(function(portal) {
portalIDs.add(portal.id);
_currentPortals.set(portal.id, portal);
var existing = _portalMeshes.get(portal.id);
if (!existing) {
// Create new portal mesh
var portalMesh = _createPortalMesh(portal);
_portalMeshes.set(portal.id, portalMesh);
}
});
// Remove portals that no longer exist
_portalMeshes.forEach(function(portalMesh, portalID) {
if (!portalIDs.has(portalID)) {
_scene.remove(portalMesh.mesh);
_scene.remove(portalMesh.label);
portalMesh.mesh.geometry.dispose();
portalMesh.mesh.material.dispose();
_portalMeshes.delete(portalID);
}
});
}
/**
* Flash a portal to indicate a crossing event.
* @param {string} portalId - The portal ID to flash
*/
function flashPortal(portalId) {
var portalMesh = _portalMeshes.get(portalId);
if (!portalMesh) return;
// Set flash end time (1 second from now)
portalMesh.flashEndTime = Date.now() + 1000;
}
/**
* Update portal flash animations.
* Called from the main update loop.
* @param {number} dt - Delta time in seconds
*/
function updatePortalFlashes(dt) {
var now = Date.now();
_portalMeshes.forEach(function(portalMesh, portalId) {
if (portalMesh.flashEndTime > now) {
// Flash animation: increase opacity
var progress = (portalMesh.flashEndTime - now) / 1000; // 1 to 0
portalMesh.mesh.material.opacity = 0.3 + progress * 0.7;
} else if (portalMesh.mesh.material.opacity !== 0.3) {
portalMesh.mesh.material.opacity = 0.3;
}
});
}
/**
* Update the text content of a sprite label.
* @param {THREE.Sprite} sprite - The sprite to update
* @param {string} text - New text content
*/
function _updateTextSprite(sprite, text) {
if (!sprite || !sprite.material || !sprite.material.map) return;
var canvas = sprite.material.map.image;
var ctx = canvas.getContext('2d');
var color = '#4fc3f7'; // Default color
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.beginPath();
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
ctx.fill();
// Draw border
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
ctx.stroke();
// Draw text
ctx.fillStyle = color;
ctx.font = 'bold 28px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
sprite.material.map.needsUpdate = true;
}
/**
* Toggle zones visibility.
* @param {boolean} visible - Whether to show zones
*/
function toggleZonesVisible(visible) {
_zonesVisible = visible !== undefined ? visible : !_zonesVisible;
_zoneMeshes.forEach(function(zoneMesh) {
zoneMesh.mesh.visible = _zonesVisible;
zoneMesh.label.visible = _zonesVisible;
zoneMesh.occupantsLabel.visible = _zonesVisible && zoneMesh.occupantsLabel.visible;
});
}
/**
* Toggle portals visibility.
* @param {boolean} visible - Whether to show portals
*/
function togglePortalsVisible(visible) {
_portalsVisible = visible !== undefined ? visible : !_portalsVisible;
_portalMeshes.forEach(function(portalMesh) {
portalMesh.mesh.visible = _portalsVisible;
portalMesh.label.visible = _portalsVisible;
});
}
// ── Fresnel zone ellipsoid rendering for explainability ───────────────────────
// Configuration for Fresnel zone visualization
@ -3089,6 +3428,21 @@ const Viz3D = (function () {
return meshes;
},
nodeMeshes: function() { return Array.from(_nodeMeshes.values()); },
// Zone and portal update handlers for WebSocket messages
handleZoneUpdate: function(zones) {
updateZones(zones);
},
handlePortalUpdate: function(portals) {
updatePortals(portals);
},
// Zone and portal change handlers for REST API changes
handleZoneChange: handleZoneChange,
handlePortalChange: handlePortalChange,
handleZoneOccupancy: handleZoneOccupancy,
handleZoneTransition: handleZoneTransition,
flashPortal: flashPortal,
toggleZonesVisible: toggleZonesVisible,
togglePortalsVisible: togglePortalsVisible,
};
// ── Replay Mode Support ─────────────────────────────────────────────────────
// Store live blob states for replay mode restoration

518
dashboard/js/zone-editor.js Normal file
View file

@ -0,0 +1,518 @@
/**
* 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);
}
};
})();

View file

@ -1991,6 +1991,9 @@ func main() {
event.BlobID,
personName,
)
// Broadcast zone transition event
dashboardHub.BroadcastZoneTransition(event.PortalID, personName, event.FromZone, event.ToZone)
})
// Zone entry callback — broadcast event to dashboard

View file

@ -1241,3 +1241,38 @@ func (h *Hub) BroadcastNodeOffline(mac string, offlineDuration float64) {
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastZoneOccupancy sends zone occupancy counts to all dashboard clients.
// Broadcasted after every occupancy change (portal crossing, blob creation/removal).
func (h *Hub) BroadcastZoneOccupancy(occupancy map[string]ZoneOccupancySnapshot) {
// Convert to array format for JSON
zones := make([]map[string]interface{}, 0, len(occupancy))
for zoneID, occ := range occupancy {
zones = append(zones, map[string]interface{}{
"id": zoneID,
"count": occ.Count,
"blob_ids": occ.BlobIDs,
})
}
msg := map[string]interface{}{
"type": "zone_occupancy",
"zones": zones,
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}
// BroadcastZoneTransition sends a zone transition event to all dashboard clients.
// Fired when a blob crosses a portal from one zone to another.
func (h *Hub) BroadcastZoneTransition(portalID string, personLabel string, fromZone, toZone string) {
msg := map[string]interface{}{
"type": "zone_transition",
"portal_id": portalID,
"person": personLabel,
"from_zone": fromZone,
"to_zone": toZone,
"timestamp": time.Now().Format(time.RFC3339),
}
data, _ := json.Marshal(msg)
h.Broadcast(data)
}

View file

@ -801,6 +801,560 @@ func TestIsReconciled_NoZones(t *testing.T) {
}
}
// --- Crossing Detection Tests ---
func TestCrossingDetection_PlaneCrossing(t *testing.T) {
tests := []struct {
name string
portal Portal
prevPos struct{ X, Y, Z float64 }
currPos struct{ X, Y, Z float64 }
wantCrossing bool
wantDirection int // 1 = A->B, -1 = B->A
}{
{
name: "cross from A side to B side",
portal: Portal{
ID: "portal_1",
ZoneAID: "kitchen",
ZoneBID: "hallway",
// Vertical plane at x=5, facing -X direction
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0, // Normal pointing -X (A side is +X)
},
prevPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side
currPos: struct{ X, Y, Z float64 }{X: 4, Y: 1, Z: 0.5}, // B side
wantCrossing: true,
wantDirection: 1, // A->B
},
{
name: "cross from B side to A side",
portal: Portal{
ID: "portal_2",
ZoneAID: "kitchen",
ZoneBID: "hallway",
// Vertical plane at x=5, facing -X direction
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0,
},
prevPos: struct{ X, Y, Z float64 }{X: 4, Y: 1, Z: 0.5}, // B side
currPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side
wantCrossing: true,
wantDirection: -1, // B->A
},
{
name: "no crossing - both positions on same side",
portal: Portal{
ID: "portal_3",
ZoneAID: "kitchen",
ZoneBID: "hallway",
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0,
},
prevPos: struct{ X, Y, Z float64 }{X: 6, Y: 1, Z: 0.5}, // A side
currPos: struct{ X, Y, Z float64 }{X: 7, Y: 1, Z: 0.5}, // Still A side
wantCrossing: false,
wantDirection: 0,
},
{
name: "no crossing - movement parallel to plane",
portal: Portal{
ID: "portal_4",
ZoneAID: "kitchen",
ZoneBID: "hallway",
// Vertical plane at x=5
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0,
},
prevPos: struct{ X, Y, Z float64 }{X: 4.9, Y: 0, Z: 0}, // Just on B side
currPos: struct{ X, Y, Z float64 }{X: 4.9, Y: 2, Z: 1}, // Still B side, moved in YZ
wantCrossing: false,
wantDirection: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
// Create zones
kitchen := &Zone{
ID: "kitchen", Name: "Kitchen",
MinX: 5, MinY: 0, MinZ: 0, // Kitchen is on A side (x >= 5)
MaxX: 10, MaxY: 3, MaxZ: 3,
Enabled: true,
}
hallway := &Zone{
ID: "hallway", Name: "Hallway",
MinX: 0, MinY: 0, MinZ: 0, // Hallway is on B side (x <= 5)
MaxX: 5, MaxY: 3, MaxZ: 3,
Enabled: true,
}
if err := m.CreateZone(kitchen); err != nil {
t.Fatalf("CreateZone kitchen: %v", err)
}
if err := m.CreateZone(hallway); err != nil {
t.Fatalf("CreateZone hallway: %v", err)
}
// Create portal
if err := m.CreatePortal(&tt.portal); err != nil {
t.Fatalf("CreatePortal: %v", err)
}
// Set up crossing callback
var gotCrossing bool
var gotDirection int
var gotFromZone, gotToZone string
m.SetOnCrossing(func(event CrossingEvent) {
gotCrossing = true
gotDirection = event.Direction
gotFromZone = event.FromZone
gotToZone = event.ToZone
})
// Set initial position
m.UpdateBlobPosition(1, tt.prevPos.X, tt.prevPos.Y, tt.prevPos.Z)
// Move to current position
m.UpdateBlobPosition(1, tt.currPos.X, tt.currPos.Y, tt.currPos.Z)
if gotCrossing != tt.wantCrossing {
t.Errorf("got crossing %v, want %v", gotCrossing, tt.wantCrossing)
}
if tt.wantCrossing && gotDirection != tt.wantDirection {
t.Errorf("got direction %d, want %d", gotDirection, tt.wantDirection)
}
if tt.wantCrossing {
if gotFromZone != tt.portal.ZoneAID && gotFromZone != tt.portal.ZoneBID {
t.Errorf("got from_zone %s, want one of {%s, %s}", gotFromZone, tt.portal.ZoneAID, tt.portal.ZoneBID)
}
if gotToZone != tt.portal.ZoneAID && gotToZone != tt.portal.ZoneBID {
t.Errorf("got to_zone %s, want one of {%s, %s}", gotToZone, tt.portal.ZoneAID, tt.portal.ZoneBID)
}
}
})
}
}
func TestCrossingDetection_ParallelMovementWithinTolerance(t *testing.T) {
// Test that movement parallel to plane within 0.1m doesn't fire crossing
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
// Create zones
kitchen := &Zone{
ID: "kitchen", Name: "Kitchen",
MinX: 5, MinY: 0, MinZ: 0,
MaxX: 10, MaxY: 3, MaxZ: 3,
Enabled: true,
}
hallway := &Zone{
ID: "hallway", Name: "Hallway",
MinX: 0, MinY: 0, MinZ: 0,
MaxX: 5, MaxY: 3, MaxZ: 3,
Enabled: true,
}
m.CreateZone(kitchen)
m.CreateZone(hallway)
// Create portal
portal := &Portal{
ID: "portal_1",
ZoneAID: "kitchen",
ZoneBID: "hallway",
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0,
}
m.CreatePortal(portal)
// Set up crossing callback
var crossingCount int
m.SetOnCrossing(func(event CrossingEvent) {
crossingCount++
})
// Move blob parallel to plane at x=4.9 (0.1m from plane)
positions := []struct{ X, Y, Z float64 }{
{X: 4.9, Y: 0.5, Z: 0.5},
{X: 4.9, Y: 1.0, Z: 0.5},
{X: 4.9, Y: 1.5, Z: 0.5},
{X: 4.9, Y: 2.0, Z: 0.5},
}
for i, pos := range positions {
if i == 0 {
m.UpdateBlobPosition(1, pos.X, pos.Y, pos.Z)
} else {
m.UpdateBlobPosition(1, pos.X, pos.Y, pos.Z)
}
}
if crossingCount != 0 {
t.Errorf("got %d crossings, want 0 (movement parallel to plane within tolerance)", crossingCount)
}
}
func TestCrossingDetection_OutsideWidthBounds(t *testing.T) {
// Test that crossing outside portal width doesn't fire
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
// Create zones
kitchen := &Zone{
ID: "kitchen", Name: "Kitchen",
MinX: 5, MinY: 0, MinZ: 0,
MaxX: 10, MaxY: 3, MaxZ: 5,
Enabled: true,
}
hallway := &Zone{
ID: "hallway", Name: "Hallway",
MinX: 0, MinY: 0, MinZ: 0,
MaxX: 5, MaxY: 3, MaxZ: 5,
Enabled: true,
}
m.CreateZone(kitchen)
m.CreateZone(hallway)
// Create portal with 1m width at z=2
portal := &Portal{
ID: "portal_1",
ZoneAID: "kitchen",
ZoneBID: "hallway",
Width: 1.0,
Height: 2.1,
// Plane at x=5, centered at z=2
P1X: 5, P1Y: 0, P1Z: 2,
P2X: 5, P2Y: 2.1, P2Z: 2,
P3X: 5, P3Y: 0, P3Z: 3,
NX: -1, NY: 0, NZ: 0,
}
m.CreatePortal(portal)
// Set up crossing callback
var crossingCount int
m.SetOnCrossing(func(event CrossingEvent) {
crossingCount++
})
// Move blob across plane but far from portal center (z=0 vs z=2)
m.UpdateBlobPosition(1, 6, 1, 0) // A side, far from portal
m.UpdateBlobPosition(1, 4, 1, 0) // B side, far from portal
if crossingCount != 0 {
t.Errorf("got %d crossings, want 0 (crossing outside portal width bounds)", crossingCount)
}
}
func TestOccupancyCount_WithPortalCrossing(t *testing.T) {
// Test: Kitchen starts with 1 occupant, blob crosses portal to Living Room
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
// Create zones
kitchen := &Zone{
ID: "kitchen", Name: "Kitchen",
MinX: 5, MinY: 0, MinZ: 0,
MaxX: 10, MaxY: 3, MaxZ: 3,
Enabled: true,
}
livingRoom := &Zone{
ID: "living_room", Name: "Living Room",
MinX: 0, MinY: 0, MinZ: 0,
MaxX: 5, MaxY: 3, MaxZ: 3,
Enabled: true,
}
m.CreateZone(kitchen)
m.CreateZone(livingRoom)
// Create portal between them
portal := &Portal{
ID: "portal_1",
Name: "Kitchen-LR Door",
ZoneAID: "kitchen",
ZoneBID: "living_room",
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0,
}
m.CreatePortal(portal)
// Set up crossing and zone transition callbacks
var gotCrossing CrossingEvent
var gotEntry, gotExit ZoneTransitionEvent
m.SetOnCrossing(func(event CrossingEvent) {
gotCrossing = event
})
m.SetOnZoneEntry(func(event ZoneTransitionEvent) {
gotEntry = event
})
m.SetOnZoneExit(func(event ZoneTransitionEvent) {
gotExit = event
})
// Initial position: blob in kitchen
m.UpdateBlobPosition(1, 7, 1, 1)
// Check kitchen occupancy
kitchenOcc := m.GetZoneOccupancy("kitchen")
if kitchenOcc == nil || kitchenOcc.Count != 1 {
t.Errorf("kitchen: got count %v, want 1", kitchenOcc)
}
// Move blob to living room (cross portal)
m.UpdateBlobPosition(1, 3, 1, 1)
// Check occupancies after crossing
kitchenOcc = m.GetZoneOccupancy("kitchen")
if kitchenOcc == nil || kitchenOcc.Count != 0 {
t.Errorf("kitchen after crossing: got count %v, want 0", kitchenOcc)
}
lrOcc := m.GetZoneOccupancy("living_room")
if lrOcc == nil || lrOcc.Count != 1 {
t.Errorf("living_room after crossing: got count %v, want 1", lrOcc)
}
// Verify crossing event
if gotCrossing.PortalID != "portal_1" {
t.Errorf("got portal_id %s, want portal_1", gotCrossing.PortalID)
}
if gotCrossing.FromZone != "kitchen" {
t.Errorf("got from_zone %s, want kitchen", gotCrossing.FromZone)
}
if gotCrossing.ToZone != "living_room" {
t.Errorf("got to_zone %s, want living_room", gotCrossing.ToZone)
}
// Verify zone transition events
if gotEntry.Kind != "zone_entry" {
t.Errorf("got entry kind %s, want zone_entry", gotEntry.Kind)
}
if gotEntry.ZoneID != "living_room" {
t.Errorf("got entry zone_id %s, want living_room", gotEntry.ZoneID)
}
if gotExit.Kind != "zone_exit" {
t.Errorf("got exit kind %s, want zone_exit", gotExit.Kind)
}
if gotExit.ZoneID != "kitchen" {
t.Errorf("got exit zone_id %s, want kitchen", gotExit.ZoneID)
}
}
func TestZoneContainment_OnBoundsEdge(t *testing.T) {
// Test zone containment with position exactly on bounds_min edge
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
zone := &Zone{
ID: "test_zone", Name: "Test",
MinX: 1.0, MinY: 0.0, MinZ: 2.0,
MaxX: 5.0, MaxY: 3.0, MaxZ: 6.0,
Enabled: true,
}
m.CreateZone(zone)
tests := []struct {
name string
pos struct{ X, Y, Z float64 }
wantInZone bool
}{
{
name: "exactly on MinX edge",
pos: struct{ X, Y, Z float64 }{X: 1.0, Y: 1.5, Z: 3.0},
wantInZone: true,
},
{
name: "exactly on MinY edge",
pos: struct{ X, Y, Z float64 }{X: 2.5, Y: 0.0, Z: 3.0},
wantInZone: true,
},
{
name: "exactly on MinZ edge",
pos: struct{ X, Y, Z float64 }{X: 2.5, Y: 1.5, Z: 2.0},
wantInZone: true,
},
{
name: "exactly on MaxX edge",
pos: struct{ X, Y, Z float64 }{X: 5.0, Y: 1.5, Z: 3.0},
wantInZone: true,
},
{
name: "exactly on MaxY edge",
pos: struct{ X, Y, Z float64 }{X: 2.5, Y: 3.0, Z: 3.0},
wantInZone: true,
},
{
name: "exactly on MaxZ edge",
pos: struct{ X, Y, Z float64 }{X: 2.5, Y: 1.5, Z: 6.0},
wantInZone: true,
},
{
name: "exactly on corner (MinX, MinY, MinZ)",
pos: struct{ X, Y, Z float64 }{X: 1.0, Y: 0.0, Z: 2.0},
wantInZone: true,
},
{
name: "just outside MinX",
pos: struct{ X, Y, Z float64 }{X: 0.99, Y: 1.5, Z: 3.0},
wantInZone: false,
},
{
name: "just outside MaxX",
pos: struct{ X, Y, Z float64 }{X: 5.01, Y: 1.5, Z: 3.0},
wantInZone: false,
},
{
name: "center of zone",
pos: struct{ X, Y, Z float64 }{X: 3.0, Y: 1.5, Z: 4.0},
wantInZone: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Update blob position
m.UpdateBlobPosition(1, tt.pos.X, tt.pos.Y, tt.pos.Z)
// Check if blob is in zone
blobZone := m.GetBlobZone(1)
gotInZone := (blobZone == "test_zone")
if gotInZone != tt.wantInZone {
t.Errorf("got inZone %v, want %v", gotInZone, tt.wantInZone)
}
// Also verify occupancy count
occ := m.GetZoneOccupancy("test_zone")
if tt.wantInZone {
if occ == nil || occ.Count != 1 {
t.Errorf("zone occupancy: got %v, want count 1", occ)
}
} else {
if occ != nil && occ.Count > 0 {
t.Errorf("zone occupancy: got count %d, want 0 (blob not in zone)", occ.Count)
}
}
})
}
}
func TestZoneTransitionWebSocket_Broadcast(t *testing.T) {
// Test that zone_transition WebSocket message is broadcast with correct from_zone and to_zone
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
// Create zones
kitchen := &Zone{
ID: "kitchen", Name: "Kitchen",
MinX: 5, MinY: 0, MinZ: 0,
MaxX: 10, MaxY: 3, MaxZ: 3,
Enabled: true,
}
livingRoom := &Zone{
ID: "living_room", Name: "Living Room",
MinX: 0, MinY: 0, MinZ: 0,
MaxX: 5, MaxY: 3, MaxZ: 3,
Enabled: true,
}
m.CreateZone(kitchen)
m.CreateZone(livingRoom)
// Create portal
portal := &Portal{
ID: "portal_1",
Name: "Kitchen-LR Door",
ZoneAID: "kitchen",
ZoneBID: "living_room",
P1X: 5, P1Y: 0, P1Z: 0,
P2X: 5, P2Y: 2, P2Z: 0,
P3X: 5, P3Y: 0, P3Z: 1,
NX: -1, NY: 0, NZ: 0,
}
m.CreatePortal(portal)
// Set up callbacks to simulate WebSocket broadcast
var gotCrossing CrossingEvent
var gotEntry ZoneTransitionEvent
var gotExit ZoneTransitionEvent
m.SetOnCrossing(func(event CrossingEvent) {
gotCrossing = event
// Simulate WebSocket broadcast of zone_transition
})
m.SetOnZoneEntry(func(event ZoneTransitionEvent) {
gotEntry = event
// Simulate WebSocket broadcast of zone_entry
})
m.SetOnZoneExit(func(event ZoneTransitionEvent) {
gotExit = event
// Simulate WebSocket broadcast of zone_exit
})
// Move blob from kitchen to living room
m.UpdateBlobPosition(1, 7, 1, 1) // Kitchen
m.UpdateBlobPosition(1, 3, 1, 1) // Living room
// Verify crossing event has correct from_zone and to_zone
if gotCrossing.FromZone != "kitchen" {
t.Errorf("crossing from_zone: got %s, want kitchen", gotCrossing.FromZone)
}
if gotCrossing.ToZone != "living_room" {
t.Errorf("crossing to_zone: got %s, want living_room", gotCrossing.ToZone)
}
// Verify zone exit event
if gotExit.Kind != "zone_exit" {
t.Errorf("exit kind: got %s, want zone_exit", gotExit.Kind)
}
if gotExit.ZoneID != "kitchen" {
t.Errorf("exit zone_id: got %s, want kitchen", gotExit.ZoneID)
}
if gotExit.ZoneName != "Kitchen" {
t.Errorf("exit zone_name: got %s, want Kitchen", gotExit.ZoneName)
}
// Verify zone entry event
if gotEntry.Kind != "zone_entry" {
t.Errorf("entry kind: got %s, want zone_entry", gotEntry.Kind)
}
if gotEntry.ZoneID != "living_room" {
t.Errorf("entry zone_id: got %s, want living_room", gotEntry.ZoneID)
}
if gotEntry.ZoneName != "Living Room" {
t.Errorf("entry zone_name: got %s, want Living Room", gotEntry.ZoneName)
}
// Verify blob IDs are tracked
kitchenOcc := m.GetZoneOccupancy("kitchen")
if kitchenOcc == nil || kitchenOcc.Count != 0 {
t.Errorf("kitchen occupancy: got count %v, want 0", kitchenOcc)
}
lrOcc := m.GetZoneOccupancy("living_room")
if lrOcc == nil || lrOcc.Count != 1 {
t.Errorf("living_room occupancy: got count %v, want 1", lrOcc)
}
if lrOcc != nil && len(lrOcc.BlobIDs) != 1 {
t.Errorf("living_room blob IDs: got %v, want [1]", lrOcc.BlobIDs)
}
if lrOcc != nil && len(lrOcc.BlobIDs) > 0 && lrOcc.BlobIDs[0] != 1 {
t.Errorf("living_room blob IDs[0]: got %d, want 1", lrOcc.BlobIDs[0])
}
}
// --- Helper ---
// nowMsSinceMidnight returns a Unix ms timestamp the given duration after midnight today.