@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/out/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueharford/scrypted-spatial-awareness",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "description": "Cross-camera object tracking for Scrypted NVR with spatial awareness",
5
5
  "author": "Joshua Seidel <blueharford>",
6
6
  "license": "Apache-2.0",
@@ -305,7 +305,7 @@ export class TopologyDiscoveryEngine {
305
305
  ],
306
306
  },
307
307
  ],
308
- max_tokens: 500,
308
+ max_tokens: 1500,
309
309
  temperature: 0.3,
310
310
  });
311
311
 
@@ -530,7 +530,7 @@ export class TopologyDiscoveryEngine {
530
530
 
531
531
  const result = await llm.getChatCompletion({
532
532
  messages: [{ role: 'user', content: prompt }],
533
- max_tokens: 800,
533
+ max_tokens: 2000,
534
534
  temperature: 0.4,
535
535
  });
536
536
 
package/src/main.ts CHANGED
@@ -24,6 +24,11 @@ import {
24
24
  LandmarkSuggestion,
25
25
  LANDMARK_TEMPLATES,
26
26
  inferRelationships,
27
+ DrawnZoneType,
28
+ GlobalZone,
29
+ GlobalZoneType,
30
+ CameraZoneMapping,
31
+ ClipPath,
27
32
  } from './models/topology';
28
33
  import { TrackedObject } from './models/tracked-object';
29
34
  import { Alert, AlertRule, createDefaultRules } from './models/alert';
@@ -1819,12 +1824,39 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1819
1824
  let updated = false;
1820
1825
 
1821
1826
  if (suggestion.type === 'landmark' && suggestion.landmark) {
1827
+ // Calculate a reasonable position for the landmark
1828
+ // Use the first visible camera's position as a starting point, or canvas center
1829
+ let position = suggestion.landmark.position;
1830
+ if (!position || (position.x === 0 && position.y === 0)) {
1831
+ // Find a camera that can see this landmark
1832
+ const visibleCameraId = suggestion.landmark.visibleFromCameras?.[0];
1833
+ const camera = visibleCameraId ? topology.cameras.find(c => c.deviceId === visibleCameraId) : null;
1834
+
1835
+ if (camera?.floorPlanPosition) {
1836
+ // Position near the camera with some offset
1837
+ const offset = (topology.landmarks?.length || 0) * 30;
1838
+ position = {
1839
+ x: camera.floorPlanPosition.x + 50 + (offset % 100),
1840
+ y: camera.floorPlanPosition.y + 50 + Math.floor(offset / 100) * 30,
1841
+ };
1842
+ } else {
1843
+ // Position in a grid pattern starting from center
1844
+ const landmarkCount = topology.landmarks?.length || 0;
1845
+ const gridSize = 80;
1846
+ const cols = 5;
1847
+ position = {
1848
+ x: 200 + (landmarkCount % cols) * gridSize,
1849
+ y: 100 + Math.floor(landmarkCount / cols) * gridSize,
1850
+ };
1851
+ }
1852
+ }
1853
+
1822
1854
  // Add new landmark to topology
1823
1855
  const landmark: Landmark = {
1824
1856
  id: `landmark_${Date.now()}`,
1825
1857
  name: suggestion.landmark.name!,
1826
1858
  type: suggestion.landmark.type!,
1827
- position: suggestion.landmark.position || { x: 0, y: 0 },
1859
+ position,
1828
1860
  description: suggestion.landmark.description,
1829
1861
  visibleFromCameras: suggestion.landmark.visibleFromCameras,
1830
1862
  aiSuggested: true,
@@ -1837,16 +1869,127 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1837
1869
  topology.landmarks.push(landmark);
1838
1870
  updated = true;
1839
1871
 
1840
- this.console.log(`[Discovery] Added landmark: ${landmark.name}`);
1872
+ this.console.log(`[Discovery] Added landmark: ${landmark.name} at (${position.x}, ${position.y})`);
1873
+ }
1874
+
1875
+ if (suggestion.type === 'zone' && suggestion.zone) {
1876
+ // Create a drawn zone from the discovery zone
1877
+ const zone = suggestion.zone;
1878
+
1879
+ // Find cameras that see this zone
1880
+ const sourceCameras = suggestion.sourceCameras || [];
1881
+ const camera = sourceCameras[0]
1882
+ ? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
1883
+ : null;
1884
+
1885
+ // Create a default polygon near the camera or at a default location
1886
+ let centerX = 300;
1887
+ let centerY = 200;
1888
+ if (camera?.floorPlanPosition) {
1889
+ centerX = camera.floorPlanPosition.x;
1890
+ centerY = camera.floorPlanPosition.y + 80;
1891
+ }
1892
+
1893
+ // Create a rectangular zone (user can edit later)
1894
+ const size = 100;
1895
+ const timestamp = Date.now();
1896
+
1897
+ // 1. Create DrawnZone (visual on floor plan)
1898
+ const drawnZone = {
1899
+ id: `zone_${timestamp}`,
1900
+ name: zone.name,
1901
+ type: (zone.type || 'custom') as DrawnZoneType,
1902
+ description: zone.description,
1903
+ polygon: [
1904
+ { x: centerX - size/2, y: centerY - size/2 },
1905
+ { x: centerX + size/2, y: centerY - size/2 },
1906
+ { x: centerX + size/2, y: centerY + size/2 },
1907
+ { x: centerX - size/2, y: centerY + size/2 },
1908
+ ] as any,
1909
+ linkedCameras: sourceCameras,
1910
+ };
1911
+
1912
+ if (!topology.drawnZones) {
1913
+ topology.drawnZones = [];
1914
+ }
1915
+ topology.drawnZones.push(drawnZone);
1916
+
1917
+ // 2. Create GlobalZone (for tracking/alerting)
1918
+ const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
1919
+ const cameraZones: CameraZoneMapping[] = sourceCameras
1920
+ .map(camRef => {
1921
+ const cam = topology.cameras.find(c => c.deviceId === camRef || c.name === camRef);
1922
+ if (!cam) return null;
1923
+ return {
1924
+ cameraId: cam.deviceId,
1925
+ zone: [[0, 0], [100, 0], [100, 100], [0, 100]] as ClipPath, // Full frame default
1926
+ };
1927
+ })
1928
+ .filter((z): z is CameraZoneMapping => z !== null);
1929
+
1930
+ if (cameraZones.length > 0) {
1931
+ const globalZone: GlobalZone = {
1932
+ id: `global_${timestamp}`,
1933
+ name: zone.name,
1934
+ type: globalZoneType,
1935
+ cameraZones,
1936
+ };
1937
+
1938
+ if (!topology.globalZones) {
1939
+ topology.globalZones = [];
1940
+ }
1941
+ topology.globalZones.push(globalZone);
1942
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone and GlobalZone created`);
1943
+ } else {
1944
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone only (no cameras matched)`);
1945
+ }
1946
+
1947
+ updated = true;
1841
1948
  }
1842
1949
 
1843
1950
  if (suggestion.type === 'connection' && suggestion.connection) {
1844
1951
  // Add new connection to topology
1845
1952
  const conn = suggestion.connection;
1953
+
1954
+ // Resolve camera references - LLM may return names instead of deviceIds
1955
+ // Use case-insensitive matching for names
1956
+ const fromCamera = topology.cameras.find(c =>
1957
+ c.deviceId === conn.fromCameraId ||
1958
+ c.name === conn.fromCameraId ||
1959
+ c.name.toLowerCase() === conn.fromCameraId.toLowerCase()
1960
+ );
1961
+ const toCamera = topology.cameras.find(c =>
1962
+ c.deviceId === conn.toCameraId ||
1963
+ c.name === conn.toCameraId ||
1964
+ c.name.toLowerCase() === conn.toCameraId.toLowerCase()
1965
+ );
1966
+
1967
+ // Don't create connection if cameras not found
1968
+ if (!fromCamera || !toCamera) {
1969
+ this.console.warn(`[Discovery] Cannot create connection: camera not found. from="${conn.fromCameraId}" to="${conn.toCameraId}"`);
1970
+ this.console.warn(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name} (${c.deviceId})`).join(', ')}`);
1971
+ return;
1972
+ }
1973
+
1974
+ // Check if connection already exists
1975
+ const existingConn = topology.connections.find(c =>
1976
+ (c.fromCameraId === fromCamera.deviceId && c.toCameraId === toCamera.deviceId) ||
1977
+ (c.bidirectional && c.fromCameraId === toCamera.deviceId && c.toCameraId === fromCamera.deviceId)
1978
+ );
1979
+ if (existingConn) {
1980
+ this.console.log(`[Discovery] Connection already exists between ${fromCamera.name} and ${toCamera.name}`);
1981
+ return;
1982
+ }
1983
+
1984
+ // Warn if cameras don't have positions yet
1985
+ if (!fromCamera.floorPlanPosition || !toCamera.floorPlanPosition) {
1986
+ this.console.warn(`[Discovery] Note: One or both cameras not positioned on floor plan yet`);
1987
+ }
1988
+
1846
1989
  const newConnection = {
1847
1990
  id: `conn_${Date.now()}`,
1848
- fromCameraId: conn.fromCameraId,
1849
- toCameraId: conn.toCameraId,
1991
+ fromCameraId: fromCamera.deviceId,
1992
+ toCameraId: toCamera.deviceId,
1850
1993
  bidirectional: conn.bidirectional,
1851
1994
  // Default exit/entry zones covering full frame
1852
1995
  exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
@@ -1862,7 +2005,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1862
2005
  topology.connections.push(newConnection);
1863
2006
  updated = true;
1864
2007
 
1865
- this.console.log(`[Discovery] Added connection: ${conn.fromCameraId} -> ${conn.toCameraId}`);
2008
+ this.console.log(`[Discovery] Added connection: ${fromCamera.name} (${fromCamera.deviceId}) -> ${toCamera.name} (${toCamera.deviceId})`);
1866
2009
  }
1867
2010
 
1868
2011
  if (updated) {
@@ -1872,6 +2015,25 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1872
2015
  }
1873
2016
  }
1874
2017
 
2018
+ /** Map discovered zone types to GlobalZone types for tracking/alerting */
2019
+ private mapZoneTypeToGlobalType(type: string): GlobalZoneType {
2020
+ const mapping: Record<string, GlobalZoneType> = {
2021
+ 'yard': 'dwell',
2022
+ 'driveway': 'entry',
2023
+ 'street': 'entry',
2024
+ 'patio': 'dwell',
2025
+ 'walkway': 'entry',
2026
+ 'parking': 'dwell',
2027
+ 'garden': 'dwell',
2028
+ 'pool': 'restricted',
2029
+ 'entrance': 'entry',
2030
+ 'garage': 'entry',
2031
+ 'deck': 'dwell',
2032
+ 'custom': 'dwell',
2033
+ };
2034
+ return mapping[type] || 'dwell';
2035
+ }
2036
+
1875
2037
  private serveEditorUI(response: HttpResponse): void {
1876
2038
  response.send(EDITOR_HTML, {
1877
2039
  headers: { 'Content-Type': 'text/html' },
@@ -127,7 +127,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
127
127
  <button class="btn btn-small btn-primary" id="scan-now-btn" onclick="runDiscoveryScan()">Scan Now</button>
128
128
  </div>
129
129
  <div id="discovery-status" style="font-size: 11px; color: #888; margin-bottom: 8px;">
130
- <span id="discovery-status-text">Not scanned yet</span>
130
+ <span id="discovery-status-text">Position cameras first, then scan</span>
131
131
  </div>
132
132
  <div id="discovery-suggestions-list"></div>
133
133
  </div>
@@ -160,6 +160,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
160
160
  </div>
161
161
  <div class="toolbar-group">
162
162
  <button class="btn" onclick="clearDrawings()">Clear Drawings</button>
163
+ <button class="btn" onclick="clearAllTopology()" style="background: #dc2626;">Delete All</button>
163
164
  </div>
164
165
  <div class="toolbar-group">
165
166
  <button class="btn btn-primary" onclick="saveTopology()">Save</button>
@@ -662,6 +663,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
662
663
  let scanPollingInterval = null;
663
664
 
664
665
  async function runDiscoveryScan() {
666
+ // Check if cameras are positioned on the floor plan
667
+ const positionedCameras = topology.cameras.filter(c => c.floorPlanPosition);
668
+ if (positionedCameras.length === 0) {
669
+ 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');
670
+ return;
671
+ }
672
+
665
673
  const scanBtn = document.getElementById('scan-now-btn');
666
674
  const statusText = document.getElementById('discovery-status-text');
667
675
  scanBtn.disabled = true;
@@ -1038,6 +1046,12 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1038
1046
  for (let i = 1; i < currentZonePoints.length; i++) {
1039
1047
  ctx.lineTo(currentZonePoints[i].x, currentZonePoints[i].y);
1040
1048
  }
1049
+ // Close the polygon if we have 3+ points
1050
+ if (currentZonePoints.length >= 3) {
1051
+ ctx.closePath();
1052
+ ctx.fillStyle = color;
1053
+ ctx.fill();
1054
+ }
1041
1055
  ctx.strokeStyle = strokeColor;
1042
1056
  ctx.lineWidth = 2;
1043
1057
  ctx.stroke();
@@ -1263,6 +1277,29 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1263
1277
  function drawCamera(camera) {
1264
1278
  const pos = camera.floorPlanPosition;
1265
1279
  const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
1280
+
1281
+ // Get FOV settings or defaults
1282
+ const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
1283
+ const direction = (fov.mode === 'simple' || !fov.mode) ? (fov.direction || 0) : 0;
1284
+ const fovAngle = (fov.mode === 'simple' || !fov.mode) ? (fov.angle || 90) : 90;
1285
+ const range = (fov.mode === 'simple' || !fov.mode) ? (fov.range || 80) : 80;
1286
+
1287
+ // Convert direction to radians (0 = up/north, 90 = right/east)
1288
+ const dirRad = (direction - 90) * Math.PI / 180;
1289
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
1290
+
1291
+ // Draw FOV cone
1292
+ ctx.beginPath();
1293
+ ctx.moveTo(pos.x, pos.y);
1294
+ ctx.arc(pos.x, pos.y, range, dirRad - halfFov, dirRad + halfFov);
1295
+ ctx.closePath();
1296
+ ctx.fillStyle = isSelected ? 'rgba(233, 69, 96, 0.15)' : 'rgba(76, 175, 80, 0.15)';
1297
+ ctx.fill();
1298
+ ctx.strokeStyle = isSelected ? 'rgba(233, 69, 96, 0.5)' : 'rgba(76, 175, 80, 0.5)';
1299
+ ctx.lineWidth = 1;
1300
+ ctx.stroke();
1301
+
1302
+ // Draw camera circle
1266
1303
  ctx.beginPath();
1267
1304
  ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
1268
1305
  ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
@@ -1270,12 +1307,41 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1270
1307
  ctx.strokeStyle = '#fff';
1271
1308
  ctx.lineWidth = 2;
1272
1309
  ctx.stroke();
1310
+
1311
+ // Draw camera icon/text
1273
1312
  ctx.fillStyle = '#fff';
1274
1313
  ctx.font = '12px sans-serif';
1275
1314
  ctx.textAlign = 'center';
1276
1315
  ctx.textBaseline = 'middle';
1277
1316
  ctx.fillText('CAM', pos.x, pos.y);
1278
1317
  ctx.fillText(camera.name, pos.x, pos.y + 35);
1318
+
1319
+ // Draw direction handle (when selected) for rotation
1320
+ if (isSelected) {
1321
+ const handleLength = 45;
1322
+ const handleX = pos.x + Math.cos(dirRad) * handleLength;
1323
+ const handleY = pos.y + Math.sin(dirRad) * handleLength;
1324
+
1325
+ // Handle line
1326
+ ctx.beginPath();
1327
+ ctx.moveTo(pos.x + Math.cos(dirRad) * 20, pos.y + Math.sin(dirRad) * 20);
1328
+ ctx.lineTo(handleX, handleY);
1329
+ ctx.strokeStyle = '#ff6b6b';
1330
+ ctx.lineWidth = 3;
1331
+ ctx.stroke();
1332
+
1333
+ // Handle grip (circle at end)
1334
+ ctx.beginPath();
1335
+ ctx.arc(handleX, handleY, 8, 0, Math.PI * 2);
1336
+ ctx.fillStyle = '#ff6b6b';
1337
+ ctx.fill();
1338
+ ctx.strokeStyle = '#fff';
1339
+ ctx.lineWidth = 2;
1340
+ ctx.stroke();
1341
+
1342
+ // Store handle position for hit detection
1343
+ camera._handlePos = { x: handleX, y: handleY };
1344
+ }
1279
1345
  }
1280
1346
 
1281
1347
  function drawConnection(from, to, conn) {
@@ -1536,7 +1602,16 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1536
1602
 
1537
1603
  function showCameraProperties(camera) {
1538
1604
  const panel = document.getElementById('properties-panel');
1539
- 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>';
1605
+ const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
1606
+ panel.innerHTML = '<h3>Camera Properties</h3>' +
1607
+ '<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
1608
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
1609
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
1610
+ '<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
1611
+ '<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>' +
1612
+ '<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>' +
1613
+ '<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>' +
1614
+ '<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
1540
1615
  }
1541
1616
 
1542
1617
  function showConnectionProperties(connection) {
@@ -1547,6 +1622,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1547
1622
  function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
1548
1623
  function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
1549
1624
  function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
1625
+ function updateCameraFov(id, field, value) {
1626
+ const camera = topology.cameras.find(c => c.deviceId === id);
1627
+ if (!camera) return;
1628
+ if (!camera.fov) camera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
1629
+ camera.fov[field] = parseFloat(value);
1630
+ render();
1631
+ }
1550
1632
  function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
1551
1633
  function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
1552
1634
  function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
@@ -1690,10 +1772,30 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1690
1772
  setStatus('Drawings cleared', 'success');
1691
1773
  }
1692
1774
 
1775
+ function clearAllTopology() {
1776
+ 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;
1777
+
1778
+ topology.cameras = [];
1779
+ topology.connections = [];
1780
+ topology.landmarks = [];
1781
+ topology.globalZones = [];
1782
+ topology.drawnZones = [];
1783
+ topology.drawings = [];
1784
+ topology.relationships = [];
1785
+
1786
+ selectedItem = null;
1787
+ document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
1788
+ updateCameraSelects();
1789
+ updateUI();
1790
+ render();
1791
+ setStatus('All topology data cleared', 'warning');
1792
+ }
1793
+
1693
1794
  function closeModal(id) { document.getElementById(id).classList.remove('active'); }
1694
1795
  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'); }
1695
1796
 
1696
1797
  let dragging = null;
1798
+ let rotatingCamera = null;
1697
1799
 
1698
1800
  canvas.addEventListener('mousedown', (e) => {
1699
1801
  const rect = canvas.getBoundingClientRect();
@@ -1709,7 +1811,20 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1709
1811
  }
1710
1812
 
1711
1813
  if (currentTool === 'select') {
1712
- // Check cameras first
1814
+ // Check for rotation handle on selected camera first
1815
+ if (selectedItem?.type === 'camera') {
1816
+ const camera = topology.cameras.find(c => c.deviceId === selectedItem.id);
1817
+ if (camera?._handlePos) {
1818
+ const dist = Math.hypot(x - camera._handlePos.x, y - camera._handlePos.y);
1819
+ if (dist < 15) {
1820
+ rotatingCamera = camera;
1821
+ setStatus('Drag to rotate camera direction', 'warning');
1822
+ return;
1823
+ }
1824
+ }
1825
+ }
1826
+
1827
+ // Check cameras
1713
1828
  for (const camera of topology.cameras) {
1714
1829
  if (camera.floorPlanPosition) {
1715
1830
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
@@ -1773,6 +1888,20 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1773
1888
  const x = e.clientX - rect.left;
1774
1889
  const y = e.clientY - rect.top;
1775
1890
 
1891
+ // Handle camera rotation
1892
+ if (rotatingCamera) {
1893
+ const pos = rotatingCamera.floorPlanPosition;
1894
+ const angle = Math.atan2(y - pos.y, x - pos.x);
1895
+ // Convert to our direction system (0 = up/north, 90 = right/east)
1896
+ const direction = (angle * 180 / Math.PI) + 90;
1897
+ if (!rotatingCamera.fov) {
1898
+ rotatingCamera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
1899
+ }
1900
+ rotatingCamera.fov.direction = ((direction % 360) + 360) % 360; // Normalize 0-360
1901
+ render();
1902
+ return;
1903
+ }
1904
+
1776
1905
  if (dragging) {
1777
1906
  if (dragging.type === 'camera') {
1778
1907
  dragging.item.floorPlanPosition.x = x;
@@ -1795,6 +1924,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1795
1924
  });
1796
1925
 
1797
1926
  canvas.addEventListener('mouseup', (e) => {
1927
+ // Clear camera rotation
1928
+ if (rotatingCamera) {
1929
+ setStatus('Camera direction updated', 'success');
1930
+ rotatingCamera = null;
1931
+ return;
1932
+ }
1933
+
1798
1934
  if (isDrawing && currentDrawing) {
1799
1935
  if (!topology.drawings) topology.drawings = [];
1800
1936
  // Normalize room coordinates if drawn backwards