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:
parent
23fd6e89b3
commit
72b9256ff4
10 changed files with 2270 additions and 5 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
b583990d434dd472d8bac8cfb60f3129ff93e56a
|
||||
5803bb790a995dc1ab91e8185a8bb5b08eb3faf7
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
533
dashboard/js/portal.js
Normal 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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -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
518
dashboard/js/zone-editor.js
Normal 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);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue