@blueharford/scrypted-spatial-awareness 0.5.9 → 0.6.1

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.9",
3
+ "version": "0.6.1",
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",
package/src/main.ts CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  LANDMARK_TEMPLATES,
26
26
  inferRelationships,
27
27
  DrawnZoneType,
28
+ GlobalZone,
29
+ GlobalZoneType,
30
+ CameraZoneMapping,
31
+ ClipPath,
28
32
  } from './models/topology';
29
33
  import { TrackedObject } from './models/tracked-object';
30
34
  import { Alert, AlertRule, createDefaultRules } from './models/alert';
@@ -1820,8 +1824,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1820
1824
  let updated = false;
1821
1825
 
1822
1826
  if (suggestion.type === 'landmark' && suggestion.landmark) {
1823
- // Calculate a reasonable position for the landmark
1824
- // Use the first visible camera's position as a starting point, or canvas center
1827
+ // Calculate position for the landmark WITHIN the camera's field of view
1825
1828
  let position = suggestion.landmark.position;
1826
1829
  if (!position || (position.x === 0 && position.y === 0)) {
1827
1830
  // Find a camera that can see this landmark
@@ -1829,12 +1832,35 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1829
1832
  const camera = visibleCameraId ? topology.cameras.find(c => c.deviceId === visibleCameraId) : null;
1830
1833
 
1831
1834
  if (camera?.floorPlanPosition) {
1832
- // Position near the camera with some offset
1833
- const offset = (topology.landmarks?.length || 0) * 30;
1835
+ // Get camera's FOV direction and range (cast to any for flexible access)
1836
+ const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
1837
+ const direction = fov.direction || 0;
1838
+ const range = fov.range || 80;
1839
+ const fovAngle = fov.angle || 90;
1840
+
1841
+ // Count existing landmarks from this camera to spread them out
1842
+ const existingFromCamera = (topology.landmarks || []).filter(l =>
1843
+ l.visibleFromCameras?.includes(visibleCameraId)
1844
+ ).length;
1845
+
1846
+ // Calculate position in front of camera within its FOV
1847
+ // Convert direction to radians (0 = up/north, 90 = right/east)
1848
+ const dirRad = (direction - 90) * Math.PI / 180;
1849
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
1850
+
1851
+ // Spread landmarks across the FOV cone at varying distances
1852
+ const angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6; // -0.6, 0, +0.6 of half FOV
1853
+ const distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3; // 50% or 80% of range
1854
+
1855
+ const finalAngle = dirRad + angleOffset;
1856
+ const distance = range * distanceMultiplier;
1857
+
1834
1858
  position = {
1835
- x: camera.floorPlanPosition.x + 50 + (offset % 100),
1836
- y: camera.floorPlanPosition.y + 50 + Math.floor(offset / 100) * 30,
1859
+ x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
1860
+ y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
1837
1861
  };
1862
+
1863
+ this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, dist=${distance.toFixed(0)}px`);
1838
1864
  } else {
1839
1865
  // Position in a grid pattern starting from center
1840
1866
  const landmarkCount = topology.landmarks?.length || 0;
@@ -1872,72 +1898,167 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1872
1898
  // Create a drawn zone from the discovery zone
1873
1899
  const zone = suggestion.zone;
1874
1900
 
1875
- // Find cameras that see this zone type to determine position
1876
- const cameraWithZone = suggestion.sourceCameras?.[0];
1877
- const camera = cameraWithZone ? topology.cameras.find(c => c.deviceId === cameraWithZone || c.name === cameraWithZone) : null;
1901
+ // Find cameras that see this zone
1902
+ const sourceCameras = suggestion.sourceCameras || [];
1903
+ const camera = sourceCameras[0]
1904
+ ? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
1905
+ : null;
1906
+
1907
+ // Create zone polygon WITHIN the camera's field of view
1908
+ let polygon: { x: number; y: number }[] = [];
1909
+ const timestamp = Date.now();
1878
1910
 
1879
- // Create a default polygon near the camera or at a default location
1880
- let centerX = 300;
1881
- let centerY = 200;
1882
1911
  if (camera?.floorPlanPosition) {
1883
- centerX = camera.floorPlanPosition.x;
1884
- centerY = camera.floorPlanPosition.y + 80;
1912
+ // Get camera's FOV direction and range (cast to any for flexible access)
1913
+ const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
1914
+ const direction = fov.direction || 0;
1915
+ const range = fov.range || 80;
1916
+ const fovAngle = fov.angle || 90;
1917
+
1918
+ // Convert direction to radians (0 = up/north, 90 = right/east)
1919
+ const dirRad = (direction - 90) * Math.PI / 180;
1920
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
1921
+
1922
+ // Count existing zones from this camera to offset new ones
1923
+ const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
1924
+ z.linkedCameras?.includes(sourceCameras[0])
1925
+ ).length;
1926
+
1927
+ // Create a wedge-shaped zone within the camera's FOV
1928
+ // Offset based on existing zones to avoid overlap
1929
+ const innerRadius = range * 0.3 + existingFromCamera * 20;
1930
+ const outerRadius = range * 0.8 + existingFromCamera * 20;
1931
+
1932
+ // Use a portion of the FOV for each zone
1933
+ const zoneSpread = halfFov * 0.7; // 70% of half FOV
1934
+
1935
+ const camX = camera.floorPlanPosition.x;
1936
+ const camY = camera.floorPlanPosition.y;
1937
+
1938
+ // Create arc polygon (wedge shape)
1939
+ const steps = 8;
1940
+ // Inner arc (from left to right)
1941
+ for (let i = 0; i <= steps; i++) {
1942
+ const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
1943
+ polygon.push({
1944
+ x: camX + Math.cos(angle) * innerRadius,
1945
+ y: camY + Math.sin(angle) * innerRadius,
1946
+ });
1947
+ }
1948
+ // Outer arc (from right to left)
1949
+ for (let i = steps; i >= 0; i--) {
1950
+ const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
1951
+ polygon.push({
1952
+ x: camX + Math.cos(angle) * outerRadius,
1953
+ y: camY + Math.sin(angle) * outerRadius,
1954
+ });
1955
+ }
1956
+
1957
+ this.console.log(`[Discovery] Creating zone "${zone.name}" in ${camera.name}'s FOV: dir=${direction}°`);
1958
+ } else {
1959
+ // Fallback: rectangular zone at default location
1960
+ const centerX = 300 + (topology.drawnZones?.length || 0) * 120;
1961
+ const centerY = 200;
1962
+ const size = 100;
1963
+ polygon = [
1964
+ { x: centerX - size/2, y: centerY - size/2 },
1965
+ { x: centerX + size/2, y: centerY - size/2 },
1966
+ { x: centerX + size/2, y: centerY + size/2 },
1967
+ { x: centerX - size/2, y: centerY + size/2 },
1968
+ ];
1885
1969
  }
1886
1970
 
1887
- // Create a rectangular zone (user can edit later)
1888
- const size = 100;
1971
+ // 1. Create DrawnZone (visual on floor plan)
1889
1972
  const drawnZone = {
1890
- id: `zone_${Date.now()}`,
1973
+ id: `zone_${timestamp}`,
1891
1974
  name: zone.name,
1892
1975
  type: (zone.type || 'custom') as DrawnZoneType,
1893
1976
  description: zone.description,
1894
- polygon: [
1895
- { x: centerX - size/2, y: centerY - size/2 },
1896
- { x: centerX + size/2, y: centerY - size/2 },
1897
- { x: centerX + size/2, y: centerY + size/2 },
1898
- { x: centerX - size/2, y: centerY + size/2 },
1899
- ] as any,
1977
+ polygon: polygon as any,
1978
+ linkedCameras: sourceCameras,
1900
1979
  };
1901
1980
 
1902
1981
  if (!topology.drawnZones) {
1903
1982
  topology.drawnZones = [];
1904
1983
  }
1905
1984
  topology.drawnZones.push(drawnZone);
1906
- updated = true;
1907
1985
 
1908
- this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type})`);
1986
+ // 2. Create GlobalZone (for tracking/alerting)
1987
+ const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
1988
+ const cameraZones: CameraZoneMapping[] = sourceCameras
1989
+ .map(camRef => {
1990
+ const cam = topology.cameras.find(c => c.deviceId === camRef || c.name === camRef);
1991
+ if (!cam) return null;
1992
+ return {
1993
+ cameraId: cam.deviceId,
1994
+ zone: [[0, 0], [100, 0], [100, 100], [0, 100]] as ClipPath, // Full frame default
1995
+ };
1996
+ })
1997
+ .filter((z): z is CameraZoneMapping => z !== null);
1998
+
1999
+ if (cameraZones.length > 0) {
2000
+ const globalZone: GlobalZone = {
2001
+ id: `global_${timestamp}`,
2002
+ name: zone.name,
2003
+ type: globalZoneType,
2004
+ cameraZones,
2005
+ };
2006
+
2007
+ if (!topology.globalZones) {
2008
+ topology.globalZones = [];
2009
+ }
2010
+ topology.globalZones.push(globalZone);
2011
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone and GlobalZone created`);
2012
+ } else {
2013
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone only (no cameras matched)`);
2014
+ }
2015
+
2016
+ updated = true;
1909
2017
  }
1910
2018
 
1911
2019
  if (suggestion.type === 'connection' && suggestion.connection) {
1912
2020
  // Add new connection to topology
1913
2021
  const conn = suggestion.connection;
1914
2022
 
1915
- // Ensure cameras have floor plan positions for visibility
1916
- const fromCamera = topology.cameras.find(c => c.deviceId === conn.fromCameraId || c.name === conn.fromCameraId);
1917
- const toCamera = topology.cameras.find(c => c.deviceId === conn.toCameraId || c.name === conn.toCameraId);
1918
-
1919
- // Auto-assign floor plan positions if missing
1920
- if (fromCamera && !fromCamera.floorPlanPosition) {
1921
- const idx = topology.cameras.indexOf(fromCamera);
1922
- fromCamera.floorPlanPosition = {
1923
- x: 150 + (idx % 3) * 200,
1924
- y: 150 + Math.floor(idx / 3) * 150,
1925
- };
1926
- this.console.log(`[Discovery] Auto-positioned camera: ${fromCamera.name}`);
1927
- }
1928
- if (toCamera && !toCamera.floorPlanPosition) {
1929
- const idx = topology.cameras.indexOf(toCamera);
1930
- toCamera.floorPlanPosition = {
1931
- x: 150 + (idx % 3) * 200,
1932
- y: 150 + Math.floor(idx / 3) * 150,
1933
- };
1934
- this.console.log(`[Discovery] Auto-positioned camera: ${toCamera.name}`);
2023
+ // Resolve camera references - LLM may return names instead of deviceIds
2024
+ // Use case-insensitive matching for names
2025
+ const fromCamera = topology.cameras.find(c =>
2026
+ c.deviceId === conn.fromCameraId ||
2027
+ c.name === conn.fromCameraId ||
2028
+ c.name.toLowerCase() === conn.fromCameraId.toLowerCase()
2029
+ );
2030
+ const toCamera = topology.cameras.find(c =>
2031
+ c.deviceId === conn.toCameraId ||
2032
+ c.name === conn.toCameraId ||
2033
+ c.name.toLowerCase() === conn.toCameraId.toLowerCase()
2034
+ );
2035
+
2036
+ // Don't create connection if cameras not found
2037
+ if (!fromCamera || !toCamera) {
2038
+ this.console.warn(`[Discovery] Cannot create connection: camera not found. from="${conn.fromCameraId}" to="${conn.toCameraId}"`);
2039
+ this.console.warn(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name} (${c.deviceId})`).join(', ')}`);
2040
+ return;
2041
+ }
2042
+
2043
+ // Check if connection already exists
2044
+ const existingConn = topology.connections.find(c =>
2045
+ (c.fromCameraId === fromCamera.deviceId && c.toCameraId === toCamera.deviceId) ||
2046
+ (c.bidirectional && c.fromCameraId === toCamera.deviceId && c.toCameraId === fromCamera.deviceId)
2047
+ );
2048
+ if (existingConn) {
2049
+ this.console.log(`[Discovery] Connection already exists between ${fromCamera.name} and ${toCamera.name}`);
2050
+ return;
2051
+ }
2052
+
2053
+ // Warn if cameras don't have positions yet
2054
+ if (!fromCamera.floorPlanPosition || !toCamera.floorPlanPosition) {
2055
+ this.console.warn(`[Discovery] Note: One or both cameras not positioned on floor plan yet`);
1935
2056
  }
1936
2057
 
1937
2058
  const newConnection = {
1938
2059
  id: `conn_${Date.now()}`,
1939
- fromCameraId: fromCamera?.deviceId || conn.fromCameraId,
1940
- toCameraId: toCamera?.deviceId || conn.toCameraId,
2060
+ fromCameraId: fromCamera.deviceId,
2061
+ toCameraId: toCamera.deviceId,
1941
2062
  bidirectional: conn.bidirectional,
1942
2063
  // Default exit/entry zones covering full frame
1943
2064
  exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
@@ -1953,7 +2074,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1953
2074
  topology.connections.push(newConnection);
1954
2075
  updated = true;
1955
2076
 
1956
- this.console.log(`[Discovery] Added connection: ${fromCamera?.name || conn.fromCameraId} -> ${toCamera?.name || conn.toCameraId}`);
2077
+ this.console.log(`[Discovery] Added connection: ${fromCamera.name} (${fromCamera.deviceId}) -> ${toCamera.name} (${toCamera.deviceId})`);
1957
2078
  }
1958
2079
 
1959
2080
  if (updated) {
@@ -1963,6 +2084,25 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1963
2084
  }
1964
2085
  }
1965
2086
 
2087
+ /** Map discovered zone types to GlobalZone types for tracking/alerting */
2088
+ private mapZoneTypeToGlobalType(type: string): GlobalZoneType {
2089
+ const mapping: Record<string, GlobalZoneType> = {
2090
+ 'yard': 'dwell',
2091
+ 'driveway': 'entry',
2092
+ 'street': 'entry',
2093
+ 'patio': 'dwell',
2094
+ 'walkway': 'entry',
2095
+ 'parking': 'dwell',
2096
+ 'garden': 'dwell',
2097
+ 'pool': 'restricted',
2098
+ 'entrance': 'entry',
2099
+ 'garage': 'entry',
2100
+ 'deck': 'dwell',
2101
+ 'custom': 'dwell',
2102
+ };
2103
+ return mapping[type] || 'dwell';
2104
+ }
2105
+
1966
2106
  private serveEditorUI(response: HttpResponse): void {
1967
2107
  response.send(EDITOR_HTML, {
1968
2108
  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;
@@ -1269,6 +1277,29 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1269
1277
  function drawCamera(camera) {
1270
1278
  const pos = camera.floorPlanPosition;
1271
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
1272
1303
  ctx.beginPath();
1273
1304
  ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
1274
1305
  ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
@@ -1276,12 +1307,41 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1276
1307
  ctx.strokeStyle = '#fff';
1277
1308
  ctx.lineWidth = 2;
1278
1309
  ctx.stroke();
1310
+
1311
+ // Draw camera icon/text
1279
1312
  ctx.fillStyle = '#fff';
1280
1313
  ctx.font = '12px sans-serif';
1281
1314
  ctx.textAlign = 'center';
1282
1315
  ctx.textBaseline = 'middle';
1283
1316
  ctx.fillText('CAM', pos.x, pos.y);
1284
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
+ }
1285
1345
  }
1286
1346
 
1287
1347
  function drawConnection(from, to, conn) {
@@ -1542,7 +1602,16 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1542
1602
 
1543
1603
  function showCameraProperties(camera) {
1544
1604
  const panel = document.getElementById('properties-panel');
1545
- 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>';
1546
1615
  }
1547
1616
 
1548
1617
  function showConnectionProperties(connection) {
@@ -1553,6 +1622,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1553
1622
  function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
1554
1623
  function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
1555
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
+ }
1556
1632
  function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
1557
1633
  function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
1558
1634
  function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
@@ -1696,10 +1772,30 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1696
1772
  setStatus('Drawings cleared', 'success');
1697
1773
  }
1698
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
+
1699
1794
  function closeModal(id) { document.getElementById(id).classList.remove('active'); }
1700
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'); }
1701
1796
 
1702
1797
  let dragging = null;
1798
+ let rotatingCamera = null;
1703
1799
 
1704
1800
  canvas.addEventListener('mousedown', (e) => {
1705
1801
  const rect = canvas.getBoundingClientRect();
@@ -1715,7 +1811,20 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1715
1811
  }
1716
1812
 
1717
1813
  if (currentTool === 'select') {
1718
- // 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
1719
1828
  for (const camera of topology.cameras) {
1720
1829
  if (camera.floorPlanPosition) {
1721
1830
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
@@ -1779,6 +1888,20 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1779
1888
  const x = e.clientX - rect.left;
1780
1889
  const y = e.clientY - rect.top;
1781
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
+
1782
1905
  if (dragging) {
1783
1906
  if (dragging.type === 'camera') {
1784
1907
  dragging.item.floorPlanPosition.x = x;
@@ -1801,6 +1924,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1801
1924
  });
1802
1925
 
1803
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
+
1804
1934
  if (isDrawing && currentDrawing) {
1805
1935
  if (!topology.drawings) topology.drawings = [];
1806
1936
  // Normalize room coordinates if drawn backwards