@blueharford/scrypted-spatial-awareness 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.zip CHANGED
Binary file
@@ -36275,7 +36275,7 @@ class TopologyDiscoveryEngine {
36275
36275
  ],
36276
36276
  },
36277
36277
  ],
36278
- max_tokens: 500,
36278
+ max_tokens: 1500,
36279
36279
  temperature: 0.3,
36280
36280
  });
36281
36281
  const content = result?.choices?.[0]?.message?.content;
@@ -36484,7 +36484,7 @@ class TopologyDiscoveryEngine {
36484
36484
  const prompt = CORRELATION_PROMPT.replace('{scenes}', scenesText);
36485
36485
  const result = await llm.getChatCompletion({
36486
36486
  messages: [{ role: 'user', content: prompt }],
36487
- max_tokens: 800,
36487
+ max_tokens: 2000,
36488
36488
  temperature: 0.4,
36489
36489
  });
36490
36490
  const content = result?.choices?.[0]?.message?.content;
@@ -40224,12 +40224,38 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40224
40224
  const topology = this.trackingEngine.getTopology();
40225
40225
  let updated = false;
40226
40226
  if (suggestion.type === 'landmark' && suggestion.landmark) {
40227
+ // Calculate a reasonable position for the landmark
40228
+ // Use the first visible camera's position as a starting point, or canvas center
40229
+ let position = suggestion.landmark.position;
40230
+ if (!position || (position.x === 0 && position.y === 0)) {
40231
+ // Find a camera that can see this landmark
40232
+ const visibleCameraId = suggestion.landmark.visibleFromCameras?.[0];
40233
+ const camera = visibleCameraId ? topology.cameras.find(c => c.deviceId === visibleCameraId) : null;
40234
+ if (camera?.floorPlanPosition) {
40235
+ // Position near the camera with some offset
40236
+ const offset = (topology.landmarks?.length || 0) * 30;
40237
+ position = {
40238
+ x: camera.floorPlanPosition.x + 50 + (offset % 100),
40239
+ y: camera.floorPlanPosition.y + 50 + Math.floor(offset / 100) * 30,
40240
+ };
40241
+ }
40242
+ else {
40243
+ // Position in a grid pattern starting from center
40244
+ const landmarkCount = topology.landmarks?.length || 0;
40245
+ const gridSize = 80;
40246
+ const cols = 5;
40247
+ position = {
40248
+ x: 200 + (landmarkCount % cols) * gridSize,
40249
+ y: 100 + Math.floor(landmarkCount / cols) * gridSize,
40250
+ };
40251
+ }
40252
+ }
40227
40253
  // Add new landmark to topology
40228
40254
  const landmark = {
40229
40255
  id: `landmark_${Date.now()}`,
40230
40256
  name: suggestion.landmark.name,
40231
40257
  type: suggestion.landmark.type,
40232
- position: suggestion.landmark.position || { x: 0, y: 0 },
40258
+ position,
40233
40259
  description: suggestion.landmark.description,
40234
40260
  visibleFromCameras: suggestion.landmark.visibleFromCameras,
40235
40261
  aiSuggested: true,
@@ -40240,15 +40266,107 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40240
40266
  }
40241
40267
  topology.landmarks.push(landmark);
40242
40268
  updated = true;
40243
- this.console.log(`[Discovery] Added landmark: ${landmark.name}`);
40269
+ this.console.log(`[Discovery] Added landmark: ${landmark.name} at (${position.x}, ${position.y})`);
40270
+ }
40271
+ if (suggestion.type === 'zone' && suggestion.zone) {
40272
+ // Create a drawn zone from the discovery zone
40273
+ const zone = suggestion.zone;
40274
+ // Find cameras that see this zone
40275
+ const sourceCameras = suggestion.sourceCameras || [];
40276
+ const camera = sourceCameras[0]
40277
+ ? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
40278
+ : null;
40279
+ // Create a default polygon near the camera or at a default location
40280
+ let centerX = 300;
40281
+ let centerY = 200;
40282
+ if (camera?.floorPlanPosition) {
40283
+ centerX = camera.floorPlanPosition.x;
40284
+ centerY = camera.floorPlanPosition.y + 80;
40285
+ }
40286
+ // Create a rectangular zone (user can edit later)
40287
+ const size = 100;
40288
+ const timestamp = Date.now();
40289
+ // 1. Create DrawnZone (visual on floor plan)
40290
+ const drawnZone = {
40291
+ id: `zone_${timestamp}`,
40292
+ name: zone.name,
40293
+ type: (zone.type || 'custom'),
40294
+ description: zone.description,
40295
+ polygon: [
40296
+ { x: centerX - size / 2, y: centerY - size / 2 },
40297
+ { x: centerX + size / 2, y: centerY - size / 2 },
40298
+ { x: centerX + size / 2, y: centerY + size / 2 },
40299
+ { x: centerX - size / 2, y: centerY + size / 2 },
40300
+ ],
40301
+ linkedCameras: sourceCameras,
40302
+ };
40303
+ if (!topology.drawnZones) {
40304
+ topology.drawnZones = [];
40305
+ }
40306
+ topology.drawnZones.push(drawnZone);
40307
+ // 2. Create GlobalZone (for tracking/alerting)
40308
+ const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
40309
+ const cameraZones = sourceCameras
40310
+ .map(camRef => {
40311
+ const cam = topology.cameras.find(c => c.deviceId === camRef || c.name === camRef);
40312
+ if (!cam)
40313
+ return null;
40314
+ return {
40315
+ cameraId: cam.deviceId,
40316
+ zone: [[0, 0], [100, 0], [100, 100], [0, 100]], // Full frame default
40317
+ };
40318
+ })
40319
+ .filter((z) => z !== null);
40320
+ if (cameraZones.length > 0) {
40321
+ const globalZone = {
40322
+ id: `global_${timestamp}`,
40323
+ name: zone.name,
40324
+ type: globalZoneType,
40325
+ cameraZones,
40326
+ };
40327
+ if (!topology.globalZones) {
40328
+ topology.globalZones = [];
40329
+ }
40330
+ topology.globalZones.push(globalZone);
40331
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone and GlobalZone created`);
40332
+ }
40333
+ else {
40334
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone only (no cameras matched)`);
40335
+ }
40336
+ updated = true;
40244
40337
  }
40245
40338
  if (suggestion.type === 'connection' && suggestion.connection) {
40246
40339
  // Add new connection to topology
40247
40340
  const conn = suggestion.connection;
40341
+ // Resolve camera references - LLM may return names instead of deviceIds
40342
+ // Use case-insensitive matching for names
40343
+ const fromCamera = topology.cameras.find(c => c.deviceId === conn.fromCameraId ||
40344
+ c.name === conn.fromCameraId ||
40345
+ c.name.toLowerCase() === conn.fromCameraId.toLowerCase());
40346
+ const toCamera = topology.cameras.find(c => c.deviceId === conn.toCameraId ||
40347
+ c.name === conn.toCameraId ||
40348
+ c.name.toLowerCase() === conn.toCameraId.toLowerCase());
40349
+ // Don't create connection if cameras not found
40350
+ if (!fromCamera || !toCamera) {
40351
+ this.console.warn(`[Discovery] Cannot create connection: camera not found. from="${conn.fromCameraId}" to="${conn.toCameraId}"`);
40352
+ this.console.warn(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name} (${c.deviceId})`).join(', ')}`);
40353
+ return;
40354
+ }
40355
+ // Check if connection already exists
40356
+ const existingConn = topology.connections.find(c => (c.fromCameraId === fromCamera.deviceId && c.toCameraId === toCamera.deviceId) ||
40357
+ (c.bidirectional && c.fromCameraId === toCamera.deviceId && c.toCameraId === fromCamera.deviceId));
40358
+ if (existingConn) {
40359
+ this.console.log(`[Discovery] Connection already exists between ${fromCamera.name} and ${toCamera.name}`);
40360
+ return;
40361
+ }
40362
+ // Warn if cameras don't have positions yet
40363
+ if (!fromCamera.floorPlanPosition || !toCamera.floorPlanPosition) {
40364
+ this.console.warn(`[Discovery] Note: One or both cameras not positioned on floor plan yet`);
40365
+ }
40248
40366
  const newConnection = {
40249
40367
  id: `conn_${Date.now()}`,
40250
- fromCameraId: conn.fromCameraId,
40251
- toCameraId: conn.toCameraId,
40368
+ fromCameraId: fromCamera.deviceId,
40369
+ toCameraId: toCamera.deviceId,
40252
40370
  bidirectional: conn.bidirectional,
40253
40371
  // Default exit/entry zones covering full frame
40254
40372
  exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]],
@@ -40262,7 +40380,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40262
40380
  };
40263
40381
  topology.connections.push(newConnection);
40264
40382
  updated = true;
40265
- this.console.log(`[Discovery] Added connection: ${conn.fromCameraId} -> ${conn.toCameraId}`);
40383
+ this.console.log(`[Discovery] Added connection: ${fromCamera.name} (${fromCamera.deviceId}) -> ${toCamera.name} (${toCamera.deviceId})`);
40266
40384
  }
40267
40385
  if (updated) {
40268
40386
  // Save updated topology
@@ -40270,6 +40388,24 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40270
40388
  this.trackingEngine.updateTopology(topology);
40271
40389
  }
40272
40390
  }
40391
+ /** Map discovered zone types to GlobalZone types for tracking/alerting */
40392
+ mapZoneTypeToGlobalType(type) {
40393
+ const mapping = {
40394
+ 'yard': 'dwell',
40395
+ 'driveway': 'entry',
40396
+ 'street': 'entry',
40397
+ 'patio': 'dwell',
40398
+ 'walkway': 'entry',
40399
+ 'parking': 'dwell',
40400
+ 'garden': 'dwell',
40401
+ 'pool': 'restricted',
40402
+ 'entrance': 'entry',
40403
+ 'garage': 'entry',
40404
+ 'deck': 'dwell',
40405
+ 'custom': 'dwell',
40406
+ };
40407
+ return mapping[type] || 'dwell';
40408
+ }
40273
40409
  serveEditorUI(response) {
40274
40410
  response.send(editor_html_1.EDITOR_HTML, {
40275
40411
  headers: { 'Content-Type': 'text/html' },
@@ -41400,7 +41536,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
41400
41536
  <button class="btn btn-small btn-primary" id="scan-now-btn" onclick="runDiscoveryScan()">Scan Now</button>
41401
41537
  </div>
41402
41538
  <div id="discovery-status" style="font-size: 11px; color: #888; margin-bottom: 8px;">
41403
- <span id="discovery-status-text">Not scanned yet</span>
41539
+ <span id="discovery-status-text">Position cameras first, then scan</span>
41404
41540
  </div>
41405
41541
  <div id="discovery-suggestions-list"></div>
41406
41542
  </div>
@@ -41433,6 +41569,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
41433
41569
  </div>
41434
41570
  <div class="toolbar-group">
41435
41571
  <button class="btn" onclick="clearDrawings()">Clear Drawings</button>
41572
+ <button class="btn" onclick="clearAllTopology()" style="background: #dc2626;">Delete All</button>
41436
41573
  </div>
41437
41574
  <div class="toolbar-group">
41438
41575
  <button class="btn btn-primary" onclick="saveTopology()">Save</button>
@@ -41935,6 +42072,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
41935
42072
  let scanPollingInterval = null;
41936
42073
 
41937
42074
  async function runDiscoveryScan() {
42075
+ // Check if cameras are positioned on the floor plan
42076
+ const positionedCameras = topology.cameras.filter(c => c.floorPlanPosition);
42077
+ if (positionedCameras.length === 0) {
42078
+ alert('Please position at least one camera on the floor plan before running discovery.\\n\\nSteps:\\n1. Click "Place Camera" in the toolbar\\n2. Click on the floor plan where the camera is located\\n3. Select the camera from the dropdown\\n4. Drag the rotation handle to set its direction\\n5. Then run discovery to detect zones and connections');
42079
+ return;
42080
+ }
42081
+
41938
42082
  const scanBtn = document.getElementById('scan-now-btn');
41939
42083
  const statusText = document.getElementById('discovery-status-text');
41940
42084
  scanBtn.disabled = true;
@@ -42311,6 +42455,12 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42311
42455
  for (let i = 1; i < currentZonePoints.length; i++) {
42312
42456
  ctx.lineTo(currentZonePoints[i].x, currentZonePoints[i].y);
42313
42457
  }
42458
+ // Close the polygon if we have 3+ points
42459
+ if (currentZonePoints.length >= 3) {
42460
+ ctx.closePath();
42461
+ ctx.fillStyle = color;
42462
+ ctx.fill();
42463
+ }
42314
42464
  ctx.strokeStyle = strokeColor;
42315
42465
  ctx.lineWidth = 2;
42316
42466
  ctx.stroke();
@@ -42536,6 +42686,29 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42536
42686
  function drawCamera(camera) {
42537
42687
  const pos = camera.floorPlanPosition;
42538
42688
  const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
42689
+
42690
+ // Get FOV settings or defaults
42691
+ const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
42692
+ const direction = (fov.mode === 'simple' || !fov.mode) ? (fov.direction || 0) : 0;
42693
+ const fovAngle = (fov.mode === 'simple' || !fov.mode) ? (fov.angle || 90) : 90;
42694
+ const range = (fov.mode === 'simple' || !fov.mode) ? (fov.range || 80) : 80;
42695
+
42696
+ // Convert direction to radians (0 = up/north, 90 = right/east)
42697
+ const dirRad = (direction - 90) * Math.PI / 180;
42698
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
42699
+
42700
+ // Draw FOV cone
42701
+ ctx.beginPath();
42702
+ ctx.moveTo(pos.x, pos.y);
42703
+ ctx.arc(pos.x, pos.y, range, dirRad - halfFov, dirRad + halfFov);
42704
+ ctx.closePath();
42705
+ ctx.fillStyle = isSelected ? 'rgba(233, 69, 96, 0.15)' : 'rgba(76, 175, 80, 0.15)';
42706
+ ctx.fill();
42707
+ ctx.strokeStyle = isSelected ? 'rgba(233, 69, 96, 0.5)' : 'rgba(76, 175, 80, 0.5)';
42708
+ ctx.lineWidth = 1;
42709
+ ctx.stroke();
42710
+
42711
+ // Draw camera circle
42539
42712
  ctx.beginPath();
42540
42713
  ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
42541
42714
  ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
@@ -42543,12 +42716,41 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42543
42716
  ctx.strokeStyle = '#fff';
42544
42717
  ctx.lineWidth = 2;
42545
42718
  ctx.stroke();
42719
+
42720
+ // Draw camera icon/text
42546
42721
  ctx.fillStyle = '#fff';
42547
42722
  ctx.font = '12px sans-serif';
42548
42723
  ctx.textAlign = 'center';
42549
42724
  ctx.textBaseline = 'middle';
42550
42725
  ctx.fillText('CAM', pos.x, pos.y);
42551
42726
  ctx.fillText(camera.name, pos.x, pos.y + 35);
42727
+
42728
+ // Draw direction handle (when selected) for rotation
42729
+ if (isSelected) {
42730
+ const handleLength = 45;
42731
+ const handleX = pos.x + Math.cos(dirRad) * handleLength;
42732
+ const handleY = pos.y + Math.sin(dirRad) * handleLength;
42733
+
42734
+ // Handle line
42735
+ ctx.beginPath();
42736
+ ctx.moveTo(pos.x + Math.cos(dirRad) * 20, pos.y + Math.sin(dirRad) * 20);
42737
+ ctx.lineTo(handleX, handleY);
42738
+ ctx.strokeStyle = '#ff6b6b';
42739
+ ctx.lineWidth = 3;
42740
+ ctx.stroke();
42741
+
42742
+ // Handle grip (circle at end)
42743
+ ctx.beginPath();
42744
+ ctx.arc(handleX, handleY, 8, 0, Math.PI * 2);
42745
+ ctx.fillStyle = '#ff6b6b';
42746
+ ctx.fill();
42747
+ ctx.strokeStyle = '#fff';
42748
+ ctx.lineWidth = 2;
42749
+ ctx.stroke();
42750
+
42751
+ // Store handle position for hit detection
42752
+ camera._handlePos = { x: handleX, y: handleY };
42753
+ }
42552
42754
  }
42553
42755
 
42554
42756
  function drawConnection(from, to, conn) {
@@ -42809,7 +43011,16 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42809
43011
 
42810
43012
  function showCameraProperties(camera) {
42811
43013
  const panel = document.getElementById('properties-panel');
42812
- panel.innerHTML = '<h3>Camera Properties</h3><div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div><div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div><div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div><div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
43014
+ const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
43015
+ panel.innerHTML = '<h3>Camera Properties</h3>' +
43016
+ '<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
43017
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
43018
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
43019
+ '<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
43020
+ '<div class="form-group"><label>Direction (0=up, 90=right)</label><input type="number" value="' + Math.round(fov.direction || 0) + '" min="0" max="359" onchange="updateCameraFov(\\'' + camera.deviceId + '\\', \\'direction\\', this.value)"></div>' +
43021
+ '<div class="form-group"><label>FOV Angle (degrees)</label><input type="number" value="' + (fov.angle || 90) + '" min="30" max="180" onchange="updateCameraFov(\\'' + camera.deviceId + '\\', \\'angle\\', this.value)"></div>' +
43022
+ '<div class="form-group"><label>Range (pixels)</label><input type="number" value="' + (fov.range || 80) + '" min="20" max="300" onchange="updateCameraFov(\\'' + camera.deviceId + '\\', \\'range\\', this.value)"></div>' +
43023
+ '<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
42813
43024
  }
42814
43025
 
42815
43026
  function showConnectionProperties(connection) {
@@ -42820,6 +43031,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42820
43031
  function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
42821
43032
  function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
42822
43033
  function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
43034
+ function updateCameraFov(id, field, value) {
43035
+ const camera = topology.cameras.find(c => c.deviceId === id);
43036
+ if (!camera) return;
43037
+ if (!camera.fov) camera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
43038
+ camera.fov[field] = parseFloat(value);
43039
+ render();
43040
+ }
42823
43041
  function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
42824
43042
  function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
42825
43043
  function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
@@ -42963,10 +43181,30 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42963
43181
  setStatus('Drawings cleared', 'success');
42964
43182
  }
42965
43183
 
43184
+ function clearAllTopology() {
43185
+ if (!confirm('DELETE ALL TOPOLOGY DATA?\\n\\nThis will remove:\\n- All cameras\\n- All connections\\n- All landmarks\\n- All zones\\n- All drawings\\n\\nThis cannot be undone.')) return;
43186
+
43187
+ topology.cameras = [];
43188
+ topology.connections = [];
43189
+ topology.landmarks = [];
43190
+ topology.globalZones = [];
43191
+ topology.drawnZones = [];
43192
+ topology.drawings = [];
43193
+ topology.relationships = [];
43194
+
43195
+ selectedItem = null;
43196
+ document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
43197
+ updateCameraSelects();
43198
+ updateUI();
43199
+ render();
43200
+ setStatus('All topology data cleared', 'warning');
43201
+ }
43202
+
42966
43203
  function closeModal(id) { document.getElementById(id).classList.remove('active'); }
42967
43204
  function setStatus(text, type) { document.getElementById('status-text').textContent = text; const dot = document.getElementById('status-dot'); dot.className = 'status-dot'; if (type === 'warning') dot.classList.add('warning'); if (type === 'error') dot.classList.add('error'); }
42968
43205
 
42969
43206
  let dragging = null;
43207
+ let rotatingCamera = null;
42970
43208
 
42971
43209
  canvas.addEventListener('mousedown', (e) => {
42972
43210
  const rect = canvas.getBoundingClientRect();
@@ -42982,7 +43220,20 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42982
43220
  }
42983
43221
 
42984
43222
  if (currentTool === 'select') {
42985
- // Check cameras first
43223
+ // Check for rotation handle on selected camera first
43224
+ if (selectedItem?.type === 'camera') {
43225
+ const camera = topology.cameras.find(c => c.deviceId === selectedItem.id);
43226
+ if (camera?._handlePos) {
43227
+ const dist = Math.hypot(x - camera._handlePos.x, y - camera._handlePos.y);
43228
+ if (dist < 15) {
43229
+ rotatingCamera = camera;
43230
+ setStatus('Drag to rotate camera direction', 'warning');
43231
+ return;
43232
+ }
43233
+ }
43234
+ }
43235
+
43236
+ // Check cameras
42986
43237
  for (const camera of topology.cameras) {
42987
43238
  if (camera.floorPlanPosition) {
42988
43239
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
@@ -43046,6 +43297,20 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
43046
43297
  const x = e.clientX - rect.left;
43047
43298
  const y = e.clientY - rect.top;
43048
43299
 
43300
+ // Handle camera rotation
43301
+ if (rotatingCamera) {
43302
+ const pos = rotatingCamera.floorPlanPosition;
43303
+ const angle = Math.atan2(y - pos.y, x - pos.x);
43304
+ // Convert to our direction system (0 = up/north, 90 = right/east)
43305
+ const direction = (angle * 180 / Math.PI) + 90;
43306
+ if (!rotatingCamera.fov) {
43307
+ rotatingCamera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
43308
+ }
43309
+ rotatingCamera.fov.direction = ((direction % 360) + 360) % 360; // Normalize 0-360
43310
+ render();
43311
+ return;
43312
+ }
43313
+
43049
43314
  if (dragging) {
43050
43315
  if (dragging.type === 'camera') {
43051
43316
  dragging.item.floorPlanPosition.x = x;
@@ -43068,6 +43333,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
43068
43333
  });
43069
43334
 
43070
43335
  canvas.addEventListener('mouseup', (e) => {
43336
+ // Clear camera rotation
43337
+ if (rotatingCamera) {
43338
+ setStatus('Camera direction updated', 'success');
43339
+ rotatingCamera = null;
43340
+ return;
43341
+ }
43342
+
43071
43343
  if (isDrawing && currentDrawing) {
43072
43344
  if (!topology.drawings) topology.drawings = [];
43073
43345
  // Normalize room coordinates if drawn backwards