@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/dist/plugin.zip CHANGED
Binary file
@@ -40224,20 +40224,34 @@ 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
40227
+ // Calculate position for the landmark WITHIN the camera's field of view
40229
40228
  let position = suggestion.landmark.position;
40230
40229
  if (!position || (position.x === 0 && position.y === 0)) {
40231
40230
  // Find a camera that can see this landmark
40232
40231
  const visibleCameraId = suggestion.landmark.visibleFromCameras?.[0];
40233
40232
  const camera = visibleCameraId ? topology.cameras.find(c => c.deviceId === visibleCameraId) : null;
40234
40233
  if (camera?.floorPlanPosition) {
40235
- // Position near the camera with some offset
40236
- const offset = (topology.landmarks?.length || 0) * 30;
40234
+ // Get camera's FOV direction and range (cast to any for flexible access)
40235
+ const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 });
40236
+ const direction = fov.direction || 0;
40237
+ const range = fov.range || 80;
40238
+ const fovAngle = fov.angle || 90;
40239
+ // Count existing landmarks from this camera to spread them out
40240
+ const existingFromCamera = (topology.landmarks || []).filter(l => l.visibleFromCameras?.includes(visibleCameraId)).length;
40241
+ // Calculate position in front of camera within its FOV
40242
+ // Convert direction to radians (0 = up/north, 90 = right/east)
40243
+ const dirRad = (direction - 90) * Math.PI / 180;
40244
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
40245
+ // Spread landmarks across the FOV cone at varying distances
40246
+ const angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6; // -0.6, 0, +0.6 of half FOV
40247
+ const distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3; // 50% or 80% of range
40248
+ const finalAngle = dirRad + angleOffset;
40249
+ const distance = range * distanceMultiplier;
40237
40250
  position = {
40238
- x: camera.floorPlanPosition.x + 50 + (offset % 100),
40239
- y: camera.floorPlanPosition.y + 50 + Math.floor(offset / 100) * 30,
40251
+ x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
40252
+ y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
40240
40253
  };
40254
+ this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, dist=${distance.toFixed(0)}px`);
40241
40255
  }
40242
40256
  else {
40243
40257
  // Position in a grid pattern starting from center
@@ -40271,64 +40285,141 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40271
40285
  if (suggestion.type === 'zone' && suggestion.zone) {
40272
40286
  // Create a drawn zone from the discovery zone
40273
40287
  const zone = suggestion.zone;
40274
- // Find cameras that see this zone type to determine position
40275
- const cameraWithZone = suggestion.sourceCameras?.[0];
40276
- const camera = cameraWithZone ? topology.cameras.find(c => c.deviceId === cameraWithZone || c.name === cameraWithZone) : null;
40277
- // Create a default polygon near the camera or at a default location
40278
- let centerX = 300;
40279
- let centerY = 200;
40288
+ // Find cameras that see this zone
40289
+ const sourceCameras = suggestion.sourceCameras || [];
40290
+ const camera = sourceCameras[0]
40291
+ ? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
40292
+ : null;
40293
+ // Create zone polygon WITHIN the camera's field of view
40294
+ let polygon = [];
40295
+ const timestamp = Date.now();
40280
40296
  if (camera?.floorPlanPosition) {
40281
- centerX = camera.floorPlanPosition.x;
40282
- centerY = camera.floorPlanPosition.y + 80;
40297
+ // Get camera's FOV direction and range (cast to any for flexible access)
40298
+ const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 });
40299
+ const direction = fov.direction || 0;
40300
+ const range = fov.range || 80;
40301
+ const fovAngle = fov.angle || 90;
40302
+ // Convert direction to radians (0 = up/north, 90 = right/east)
40303
+ const dirRad = (direction - 90) * Math.PI / 180;
40304
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
40305
+ // Count existing zones from this camera to offset new ones
40306
+ const existingFromCamera = (topology.drawnZones || []).filter((z) => z.linkedCameras?.includes(sourceCameras[0])).length;
40307
+ // Create a wedge-shaped zone within the camera's FOV
40308
+ // Offset based on existing zones to avoid overlap
40309
+ const innerRadius = range * 0.3 + existingFromCamera * 20;
40310
+ const outerRadius = range * 0.8 + existingFromCamera * 20;
40311
+ // Use a portion of the FOV for each zone
40312
+ const zoneSpread = halfFov * 0.7; // 70% of half FOV
40313
+ const camX = camera.floorPlanPosition.x;
40314
+ const camY = camera.floorPlanPosition.y;
40315
+ // Create arc polygon (wedge shape)
40316
+ const steps = 8;
40317
+ // Inner arc (from left to right)
40318
+ for (let i = 0; i <= steps; i++) {
40319
+ const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
40320
+ polygon.push({
40321
+ x: camX + Math.cos(angle) * innerRadius,
40322
+ y: camY + Math.sin(angle) * innerRadius,
40323
+ });
40324
+ }
40325
+ // Outer arc (from right to left)
40326
+ for (let i = steps; i >= 0; i--) {
40327
+ const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
40328
+ polygon.push({
40329
+ x: camX + Math.cos(angle) * outerRadius,
40330
+ y: camY + Math.sin(angle) * outerRadius,
40331
+ });
40332
+ }
40333
+ this.console.log(`[Discovery] Creating zone "${zone.name}" in ${camera.name}'s FOV: dir=${direction}°`);
40283
40334
  }
40284
- // Create a rectangular zone (user can edit later)
40285
- const size = 100;
40286
- const drawnZone = {
40287
- id: `zone_${Date.now()}`,
40288
- name: zone.name,
40289
- type: (zone.type || 'custom'),
40290
- description: zone.description,
40291
- polygon: [
40335
+ else {
40336
+ // Fallback: rectangular zone at default location
40337
+ const centerX = 300 + (topology.drawnZones?.length || 0) * 120;
40338
+ const centerY = 200;
40339
+ const size = 100;
40340
+ polygon = [
40292
40341
  { x: centerX - size / 2, y: centerY - size / 2 },
40293
40342
  { x: centerX + size / 2, y: centerY - size / 2 },
40294
40343
  { x: centerX + size / 2, y: centerY + size / 2 },
40295
40344
  { x: centerX - size / 2, y: centerY + size / 2 },
40296
- ],
40345
+ ];
40346
+ }
40347
+ // 1. Create DrawnZone (visual on floor plan)
40348
+ const drawnZone = {
40349
+ id: `zone_${timestamp}`,
40350
+ name: zone.name,
40351
+ type: (zone.type || 'custom'),
40352
+ description: zone.description,
40353
+ polygon: polygon,
40354
+ linkedCameras: sourceCameras,
40297
40355
  };
40298
40356
  if (!topology.drawnZones) {
40299
40357
  topology.drawnZones = [];
40300
40358
  }
40301
40359
  topology.drawnZones.push(drawnZone);
40360
+ // 2. Create GlobalZone (for tracking/alerting)
40361
+ const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
40362
+ const cameraZones = sourceCameras
40363
+ .map(camRef => {
40364
+ const cam = topology.cameras.find(c => c.deviceId === camRef || c.name === camRef);
40365
+ if (!cam)
40366
+ return null;
40367
+ return {
40368
+ cameraId: cam.deviceId,
40369
+ zone: [[0, 0], [100, 0], [100, 100], [0, 100]], // Full frame default
40370
+ };
40371
+ })
40372
+ .filter((z) => z !== null);
40373
+ if (cameraZones.length > 0) {
40374
+ const globalZone = {
40375
+ id: `global_${timestamp}`,
40376
+ name: zone.name,
40377
+ type: globalZoneType,
40378
+ cameraZones,
40379
+ };
40380
+ if (!topology.globalZones) {
40381
+ topology.globalZones = [];
40382
+ }
40383
+ topology.globalZones.push(globalZone);
40384
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone and GlobalZone created`);
40385
+ }
40386
+ else {
40387
+ this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone only (no cameras matched)`);
40388
+ }
40302
40389
  updated = true;
40303
- this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type})`);
40304
40390
  }
40305
40391
  if (suggestion.type === 'connection' && suggestion.connection) {
40306
40392
  // Add new connection to topology
40307
40393
  const conn = suggestion.connection;
40308
- // Ensure cameras have floor plan positions for visibility
40309
- const fromCamera = topology.cameras.find(c => c.deviceId === conn.fromCameraId || c.name === conn.fromCameraId);
40310
- const toCamera = topology.cameras.find(c => c.deviceId === conn.toCameraId || c.name === conn.toCameraId);
40311
- // Auto-assign floor plan positions if missing
40312
- if (fromCamera && !fromCamera.floorPlanPosition) {
40313
- const idx = topology.cameras.indexOf(fromCamera);
40314
- fromCamera.floorPlanPosition = {
40315
- x: 150 + (idx % 3) * 200,
40316
- y: 150 + Math.floor(idx / 3) * 150,
40317
- };
40318
- this.console.log(`[Discovery] Auto-positioned camera: ${fromCamera.name}`);
40394
+ // Resolve camera references - LLM may return names instead of deviceIds
40395
+ // Use case-insensitive matching for names
40396
+ const fromCamera = topology.cameras.find(c => c.deviceId === conn.fromCameraId ||
40397
+ c.name === conn.fromCameraId ||
40398
+ c.name.toLowerCase() === conn.fromCameraId.toLowerCase());
40399
+ const toCamera = topology.cameras.find(c => c.deviceId === conn.toCameraId ||
40400
+ c.name === conn.toCameraId ||
40401
+ c.name.toLowerCase() === conn.toCameraId.toLowerCase());
40402
+ // Don't create connection if cameras not found
40403
+ if (!fromCamera || !toCamera) {
40404
+ this.console.warn(`[Discovery] Cannot create connection: camera not found. from="${conn.fromCameraId}" to="${conn.toCameraId}"`);
40405
+ this.console.warn(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name} (${c.deviceId})`).join(', ')}`);
40406
+ return;
40319
40407
  }
40320
- if (toCamera && !toCamera.floorPlanPosition) {
40321
- const idx = topology.cameras.indexOf(toCamera);
40322
- toCamera.floorPlanPosition = {
40323
- x: 150 + (idx % 3) * 200,
40324
- y: 150 + Math.floor(idx / 3) * 150,
40325
- };
40326
- this.console.log(`[Discovery] Auto-positioned camera: ${toCamera.name}`);
40408
+ // Check if connection already exists
40409
+ const existingConn = topology.connections.find(c => (c.fromCameraId === fromCamera.deviceId && c.toCameraId === toCamera.deviceId) ||
40410
+ (c.bidirectional && c.fromCameraId === toCamera.deviceId && c.toCameraId === fromCamera.deviceId));
40411
+ if (existingConn) {
40412
+ this.console.log(`[Discovery] Connection already exists between ${fromCamera.name} and ${toCamera.name}`);
40413
+ return;
40414
+ }
40415
+ // Warn if cameras don't have positions yet
40416
+ if (!fromCamera.floorPlanPosition || !toCamera.floorPlanPosition) {
40417
+ this.console.warn(`[Discovery] Note: One or both cameras not positioned on floor plan yet`);
40327
40418
  }
40328
40419
  const newConnection = {
40329
40420
  id: `conn_${Date.now()}`,
40330
- fromCameraId: fromCamera?.deviceId || conn.fromCameraId,
40331
- toCameraId: toCamera?.deviceId || conn.toCameraId,
40421
+ fromCameraId: fromCamera.deviceId,
40422
+ toCameraId: toCamera.deviceId,
40332
40423
  bidirectional: conn.bidirectional,
40333
40424
  // Default exit/entry zones covering full frame
40334
40425
  exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]],
@@ -40342,7 +40433,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40342
40433
  };
40343
40434
  topology.connections.push(newConnection);
40344
40435
  updated = true;
40345
- this.console.log(`[Discovery] Added connection: ${fromCamera?.name || conn.fromCameraId} -> ${toCamera?.name || conn.toCameraId}`);
40436
+ this.console.log(`[Discovery] Added connection: ${fromCamera.name} (${fromCamera.deviceId}) -> ${toCamera.name} (${toCamera.deviceId})`);
40346
40437
  }
40347
40438
  if (updated) {
40348
40439
  // Save updated topology
@@ -40350,6 +40441,24 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
40350
40441
  this.trackingEngine.updateTopology(topology);
40351
40442
  }
40352
40443
  }
40444
+ /** Map discovered zone types to GlobalZone types for tracking/alerting */
40445
+ mapZoneTypeToGlobalType(type) {
40446
+ const mapping = {
40447
+ 'yard': 'dwell',
40448
+ 'driveway': 'entry',
40449
+ 'street': 'entry',
40450
+ 'patio': 'dwell',
40451
+ 'walkway': 'entry',
40452
+ 'parking': 'dwell',
40453
+ 'garden': 'dwell',
40454
+ 'pool': 'restricted',
40455
+ 'entrance': 'entry',
40456
+ 'garage': 'entry',
40457
+ 'deck': 'dwell',
40458
+ 'custom': 'dwell',
40459
+ };
40460
+ return mapping[type] || 'dwell';
40461
+ }
40353
40462
  serveEditorUI(response) {
40354
40463
  response.send(editor_html_1.EDITOR_HTML, {
40355
40464
  headers: { 'Content-Type': 'text/html' },
@@ -41480,7 +41589,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
41480
41589
  <button class="btn btn-small btn-primary" id="scan-now-btn" onclick="runDiscoveryScan()">Scan Now</button>
41481
41590
  </div>
41482
41591
  <div id="discovery-status" style="font-size: 11px; color: #888; margin-bottom: 8px;">
41483
- <span id="discovery-status-text">Not scanned yet</span>
41592
+ <span id="discovery-status-text">Position cameras first, then scan</span>
41484
41593
  </div>
41485
41594
  <div id="discovery-suggestions-list"></div>
41486
41595
  </div>
@@ -41513,6 +41622,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
41513
41622
  </div>
41514
41623
  <div class="toolbar-group">
41515
41624
  <button class="btn" onclick="clearDrawings()">Clear Drawings</button>
41625
+ <button class="btn" onclick="clearAllTopology()" style="background: #dc2626;">Delete All</button>
41516
41626
  </div>
41517
41627
  <div class="toolbar-group">
41518
41628
  <button class="btn btn-primary" onclick="saveTopology()">Save</button>
@@ -42015,6 +42125,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42015
42125
  let scanPollingInterval = null;
42016
42126
 
42017
42127
  async function runDiscoveryScan() {
42128
+ // Check if cameras are positioned on the floor plan
42129
+ const positionedCameras = topology.cameras.filter(c => c.floorPlanPosition);
42130
+ if (positionedCameras.length === 0) {
42131
+ 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');
42132
+ return;
42133
+ }
42134
+
42018
42135
  const scanBtn = document.getElementById('scan-now-btn');
42019
42136
  const statusText = document.getElementById('discovery-status-text');
42020
42137
  scanBtn.disabled = true;
@@ -42622,6 +42739,29 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42622
42739
  function drawCamera(camera) {
42623
42740
  const pos = camera.floorPlanPosition;
42624
42741
  const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
42742
+
42743
+ // Get FOV settings or defaults
42744
+ const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
42745
+ const direction = (fov.mode === 'simple' || !fov.mode) ? (fov.direction || 0) : 0;
42746
+ const fovAngle = (fov.mode === 'simple' || !fov.mode) ? (fov.angle || 90) : 90;
42747
+ const range = (fov.mode === 'simple' || !fov.mode) ? (fov.range || 80) : 80;
42748
+
42749
+ // Convert direction to radians (0 = up/north, 90 = right/east)
42750
+ const dirRad = (direction - 90) * Math.PI / 180;
42751
+ const halfFov = (fovAngle / 2) * Math.PI / 180;
42752
+
42753
+ // Draw FOV cone
42754
+ ctx.beginPath();
42755
+ ctx.moveTo(pos.x, pos.y);
42756
+ ctx.arc(pos.x, pos.y, range, dirRad - halfFov, dirRad + halfFov);
42757
+ ctx.closePath();
42758
+ ctx.fillStyle = isSelected ? 'rgba(233, 69, 96, 0.15)' : 'rgba(76, 175, 80, 0.15)';
42759
+ ctx.fill();
42760
+ ctx.strokeStyle = isSelected ? 'rgba(233, 69, 96, 0.5)' : 'rgba(76, 175, 80, 0.5)';
42761
+ ctx.lineWidth = 1;
42762
+ ctx.stroke();
42763
+
42764
+ // Draw camera circle
42625
42765
  ctx.beginPath();
42626
42766
  ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
42627
42767
  ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
@@ -42629,12 +42769,41 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42629
42769
  ctx.strokeStyle = '#fff';
42630
42770
  ctx.lineWidth = 2;
42631
42771
  ctx.stroke();
42772
+
42773
+ // Draw camera icon/text
42632
42774
  ctx.fillStyle = '#fff';
42633
42775
  ctx.font = '12px sans-serif';
42634
42776
  ctx.textAlign = 'center';
42635
42777
  ctx.textBaseline = 'middle';
42636
42778
  ctx.fillText('CAM', pos.x, pos.y);
42637
42779
  ctx.fillText(camera.name, pos.x, pos.y + 35);
42780
+
42781
+ // Draw direction handle (when selected) for rotation
42782
+ if (isSelected) {
42783
+ const handleLength = 45;
42784
+ const handleX = pos.x + Math.cos(dirRad) * handleLength;
42785
+ const handleY = pos.y + Math.sin(dirRad) * handleLength;
42786
+
42787
+ // Handle line
42788
+ ctx.beginPath();
42789
+ ctx.moveTo(pos.x + Math.cos(dirRad) * 20, pos.y + Math.sin(dirRad) * 20);
42790
+ ctx.lineTo(handleX, handleY);
42791
+ ctx.strokeStyle = '#ff6b6b';
42792
+ ctx.lineWidth = 3;
42793
+ ctx.stroke();
42794
+
42795
+ // Handle grip (circle at end)
42796
+ ctx.beginPath();
42797
+ ctx.arc(handleX, handleY, 8, 0, Math.PI * 2);
42798
+ ctx.fillStyle = '#ff6b6b';
42799
+ ctx.fill();
42800
+ ctx.strokeStyle = '#fff';
42801
+ ctx.lineWidth = 2;
42802
+ ctx.stroke();
42803
+
42804
+ // Store handle position for hit detection
42805
+ camera._handlePos = { x: handleX, y: handleY };
42806
+ }
42638
42807
  }
42639
42808
 
42640
42809
  function drawConnection(from, to, conn) {
@@ -42895,7 +43064,16 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42895
43064
 
42896
43065
  function showCameraProperties(camera) {
42897
43066
  const panel = document.getElementById('properties-panel');
42898
- 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>';
43067
+ const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
43068
+ panel.innerHTML = '<h3>Camera Properties</h3>' +
43069
+ '<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
43070
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
43071
+ '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
43072
+ '<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
43073
+ '<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>' +
43074
+ '<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>' +
43075
+ '<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>' +
43076
+ '<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
42899
43077
  }
42900
43078
 
42901
43079
  function showConnectionProperties(connection) {
@@ -42906,6 +43084,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
42906
43084
  function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
42907
43085
  function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
42908
43086
  function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
43087
+ function updateCameraFov(id, field, value) {
43088
+ const camera = topology.cameras.find(c => c.deviceId === id);
43089
+ if (!camera) return;
43090
+ if (!camera.fov) camera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
43091
+ camera.fov[field] = parseFloat(value);
43092
+ render();
43093
+ }
42909
43094
  function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
42910
43095
  function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
42911
43096
  function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
@@ -43049,10 +43234,30 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
43049
43234
  setStatus('Drawings cleared', 'success');
43050
43235
  }
43051
43236
 
43237
+ function clearAllTopology() {
43238
+ 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;
43239
+
43240
+ topology.cameras = [];
43241
+ topology.connections = [];
43242
+ topology.landmarks = [];
43243
+ topology.globalZones = [];
43244
+ topology.drawnZones = [];
43245
+ topology.drawings = [];
43246
+ topology.relationships = [];
43247
+
43248
+ selectedItem = null;
43249
+ document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
43250
+ updateCameraSelects();
43251
+ updateUI();
43252
+ render();
43253
+ setStatus('All topology data cleared', 'warning');
43254
+ }
43255
+
43052
43256
  function closeModal(id) { document.getElementById(id).classList.remove('active'); }
43053
43257
  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'); }
43054
43258
 
43055
43259
  let dragging = null;
43260
+ let rotatingCamera = null;
43056
43261
 
43057
43262
  canvas.addEventListener('mousedown', (e) => {
43058
43263
  const rect = canvas.getBoundingClientRect();
@@ -43068,7 +43273,20 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
43068
43273
  }
43069
43274
 
43070
43275
  if (currentTool === 'select') {
43071
- // Check cameras first
43276
+ // Check for rotation handle on selected camera first
43277
+ if (selectedItem?.type === 'camera') {
43278
+ const camera = topology.cameras.find(c => c.deviceId === selectedItem.id);
43279
+ if (camera?._handlePos) {
43280
+ const dist = Math.hypot(x - camera._handlePos.x, y - camera._handlePos.y);
43281
+ if (dist < 15) {
43282
+ rotatingCamera = camera;
43283
+ setStatus('Drag to rotate camera direction', 'warning');
43284
+ return;
43285
+ }
43286
+ }
43287
+ }
43288
+
43289
+ // Check cameras
43072
43290
  for (const camera of topology.cameras) {
43073
43291
  if (camera.floorPlanPosition) {
43074
43292
  const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
@@ -43132,6 +43350,20 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
43132
43350
  const x = e.clientX - rect.left;
43133
43351
  const y = e.clientY - rect.top;
43134
43352
 
43353
+ // Handle camera rotation
43354
+ if (rotatingCamera) {
43355
+ const pos = rotatingCamera.floorPlanPosition;
43356
+ const angle = Math.atan2(y - pos.y, x - pos.x);
43357
+ // Convert to our direction system (0 = up/north, 90 = right/east)
43358
+ const direction = (angle * 180 / Math.PI) + 90;
43359
+ if (!rotatingCamera.fov) {
43360
+ rotatingCamera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
43361
+ }
43362
+ rotatingCamera.fov.direction = ((direction % 360) + 360) % 360; // Normalize 0-360
43363
+ render();
43364
+ return;
43365
+ }
43366
+
43135
43367
  if (dragging) {
43136
43368
  if (dragging.type === 'camera') {
43137
43369
  dragging.item.floorPlanPosition.x = x;
@@ -43154,6 +43386,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
43154
43386
  });
43155
43387
 
43156
43388
  canvas.addEventListener('mouseup', (e) => {
43389
+ // Clear camera rotation
43390
+ if (rotatingCamera) {
43391
+ setStatus('Camera direction updated', 'success');
43392
+ rotatingCamera = null;
43393
+ return;
43394
+ }
43395
+
43157
43396
  if (isDrawing && currentDrawing) {
43158
43397
  if (!topology.drawings) topology.drawings = [];
43159
43398
  // Normalize room coordinates if drawn backwards