@blueharford/scrypted-spatial-awareness 0.6.16 → 0.6.19

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.6.16",
3
+ "version": "0.6.19",
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",
@@ -105,7 +105,10 @@ For EACH landmark, estimate its DISTANCE from the camera:
105
105
  - Street/road
106
106
  - Neighbor's yard
107
107
 
108
- For each zone, estimate what percentage of the image it covers (0.0 to 1.0).
108
+ For each zone, provide:
109
+ - coverage: percentage of the image it covers (0.0 to 1.0)
110
+ - distance: how far the CENTER of the zone is from camera ("close", "near", "medium", "far", "distant")
111
+ - boundingBox: [x, y, width, height] in normalized coordinates (0-1) where the zone appears in the image
109
112
 
110
113
  ## 3. EDGES - What's at each edge of the frame:
111
114
  This helps understand what's just out of view.
@@ -123,8 +126,9 @@ Respond with ONLY valid JSON:
123
126
  {"name": "Large Oak Tree", "type": "feature", "distance": "far", "confidence": 0.85, "description": "Mature oak tree near property line, roughly 80 feet from camera"}
124
127
  ],
125
128
  "zones": [
126
- {"name": "Front Yard", "type": "yard", "coverage": 0.5, "description": "Grass lawn with some bare patches"},
127
- {"name": "Driveway", "type": "driveway", "coverage": 0.25, "description": "Concrete driveway leading to garage"}
129
+ {"name": "Front Yard", "type": "yard", "coverage": 0.5, "distance": "medium", "boundingBox": [0.2, 0.4, 0.6, 0.4], "description": "Grass lawn with some bare patches"},
130
+ {"name": "Driveway", "type": "driveway", "coverage": 0.25, "distance": "near", "boundingBox": [0.6, 0.5, 0.3, 0.4], "description": "Concrete driveway leading to garage"},
131
+ {"name": "Street", "type": "street", "coverage": 0.1, "distance": "distant", "boundingBox": [0.0, 0.1, 1.0, 0.15], "description": "Public road beyond property line"}
128
132
  ],
129
133
  "edges": {
130
134
  "top": "sky, tree canopy",
@@ -379,13 +383,28 @@ export class TopologyDiscoveryEngine {
379
383
  try {
380
384
  this.console.log(`[Discovery] Trying ${formatType} image format for ${cameraName}...`);
381
385
 
386
+ // Build prompt with camera context (height)
387
+ const cameraNode = this.topology ? findCamera(this.topology, cameraId) : null;
388
+ const mountHeight = cameraNode?.context?.mountHeight || 8;
389
+ const cameraRange = (cameraNode?.fov as any)?.range || 80;
390
+
391
+ // Add camera-specific context to the prompt
392
+ const contextPrefix = `CAMERA INFORMATION:
393
+ - Camera Name: ${cameraName}
394
+ - Mount Height: ${mountHeight} feet above ground
395
+ - Approximate viewing range: ${cameraRange} feet
396
+
397
+ Use the mount height to help estimate distances - objects at ground level will appear at different angles depending on distance from a camera mounted at ${mountHeight} feet.
398
+
399
+ `;
400
+
382
401
  // Build multimodal message with provider-specific image format
383
402
  const result = await llm.getChatCompletion({
384
403
  messages: [
385
404
  {
386
405
  role: 'user',
387
406
  content: [
388
- { type: 'text', text: SCENE_ANALYSIS_PROMPT },
407
+ { type: 'text', text: contextPrefix + SCENE_ANALYSIS_PROMPT },
389
408
  buildImageContent(imageData, formatType),
390
409
  ],
391
410
  },
@@ -425,7 +444,8 @@ export class TopologyDiscoveryEngine {
425
444
  coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
426
445
  description: z.description || '',
427
446
  boundingBox: z.boundingBox,
428
- }));
447
+ distance: this.mapDistance(z.distance), // Parse distance for zones too
448
+ } as DiscoveredZone & { distance?: DistanceEstimate }));
429
449
  }
430
450
 
431
451
  if (parsed.edges && typeof parsed.edges === 'object') {
@@ -545,6 +565,27 @@ export class TopologyDiscoveryEngine {
545
565
  return 'medium'; // Default to medium if not specified
546
566
  }
547
567
 
568
+ /** Get default distance in feet based on zone type */
569
+ private getDefaultZoneDistance(zoneType: string): number {
570
+ switch (zoneType) {
571
+ case 'patio':
572
+ case 'walkway':
573
+ return 10; // Close zones
574
+ case 'driveway':
575
+ case 'parking':
576
+ return 25; // Near zones
577
+ case 'yard':
578
+ case 'garden':
579
+ case 'pool':
580
+ return 40; // Medium zones
581
+ case 'street':
582
+ return 100; // Far zones
583
+ case 'unknown':
584
+ default:
585
+ return 50; // Default to medium distance
586
+ }
587
+ }
588
+
548
589
  /** Try to parse JSON with recovery for truncated responses */
549
590
  private parseJsonWithRecovery(jsonStr: string, context: string): any {
550
591
  // First, try direct parse
@@ -867,6 +908,10 @@ export class TopologyDiscoveryEngine {
867
908
  // Generate zone suggestions (even for smaller coverage - 10% is enough)
868
909
  for (const zone of analysis.zones) {
869
910
  if (zone.coverage >= 0.1) {
911
+ // Calculate distance in feet from distance estimate (for zones with distance info)
912
+ const zoneWithDist = zone as DiscoveredZone & { distance?: DistanceEstimate };
913
+ const distanceFeet = zoneWithDist.distance ? distanceToFeet(zoneWithDist.distance) : this.getDefaultZoneDistance(zone.type);
914
+
870
915
  const suggestion: DiscoverySuggestion = {
871
916
  id: `zone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
872
917
  type: 'zone',
@@ -874,10 +919,14 @@ export class TopologyDiscoveryEngine {
874
919
  sourceCameras: [analysis.cameraId],
875
920
  confidence: Math.min(0.9, 0.5 + zone.coverage), // Higher coverage = higher confidence
876
921
  status: 'pending',
877
- zone: zone,
922
+ zone: {
923
+ ...zone,
924
+ // Include distance metadata for positioning
925
+ distanceFeet: distanceFeet,
926
+ } as any,
878
927
  };
879
928
  this.suggestions.set(suggestion.id, suggestion);
880
- this.console.log(`[Discovery] Zone suggestion: ${zone.name} (${zone.type}, ${Math.round(zone.coverage * 100)}% coverage)`);
929
+ this.console.log(`[Discovery] Zone suggestion: ${zone.name} (${zone.type}, ${Math.round(zone.coverage * 100)}% coverage, ~${distanceFeet}ft)`);
881
930
  }
882
931
  }
883
932
  }
package/src/main.ts CHANGED
@@ -1904,71 +1904,46 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1904
1904
  const dirRad = (direction - 90) * Math.PI / 180;
1905
1905
  const halfFov = (fovAngle / 2) * Math.PI / 180;
1906
1906
 
1907
- // Use landmark TYPE to determine distance - some things are inherently far away
1908
- // regardless of where they appear in the camera frame
1909
- const landmarkType = suggestion.landmark.type;
1910
- const farTypes = ['neighbor', 'boundary', 'street']; // Always place at edge of FOV
1911
- const isFarType = farTypes.includes(landmarkType || '');
1907
+ // Get floor plan scale (pixels per foot) - default 5
1908
+ const floorPlanScale = topology.floorPlanScale || 5;
1912
1909
 
1913
- // Use bounding box if available for horizontal positioning
1914
- // boundingBox format: [x, y, width, height] normalized 0-1
1915
- const bbox = (suggestion.landmark as any).boundingBox as [number, number, number, number] | undefined;
1910
+ // Use the ACTUAL distance from LLM analysis (distanceFeet) - this is the key fix!
1911
+ // The LLM estimates distance based on object size, perspective, and camera context
1912
+ const landmarkData = suggestion.landmark as any;
1913
+ const distanceFeet = landmarkData.distanceFeet || 50; // Default 50ft if not set
1914
+ const distanceInPixels = distanceFeet * floorPlanScale;
1915
+
1916
+ // Use bounding box for horizontal positioning within the FOV
1917
+ const bbox = landmarkData.boundingBox as [number, number, number, number] | undefined;
1916
1918
 
1917
1919
  let angleOffset: number;
1918
- let distanceMultiplier: number;
1919
1920
 
1920
1921
  if (bbox && bbox.length >= 2) {
1921
1922
  // Use bounding box X for horizontal position in FOV
1922
1923
  const bboxCenterX = bbox[0] + (bbox[2] || 0) / 2; // 0 = left edge, 1 = right edge
1923
-
1924
1924
  // Map X position to angle within FOV
1925
1925
  angleOffset = (bboxCenterX - 0.5) * 2 * halfFov;
1926
-
1927
- // For distance: use TYPE first, then bbox Y as hint
1928
- if (isFarType) {
1929
- // Neighbors, boundaries, streets are BEYOND the camera's normal range
1930
- // Place at 150-200% of range to indicate they're distant background features
1931
- distanceMultiplier = 1.5 + Math.random() * 0.5; // 150-200% of range
1932
- this.console.log(`[Discovery] Far-type landmark "${landmarkType}" placed BEYOND FOV (${(distanceMultiplier * 100).toFixed(0)}% of range)`);
1933
- } else {
1934
- // For other types, use bbox Y as a hint (but cap minimum distance)
1935
- const bboxCenterY = bbox[1] + (bbox[3] || 0) / 2;
1936
- // Map Y to distance: 0 (top) = far, 1 (bottom) = closer (but not too close)
1937
- distanceMultiplier = Math.max(0.4, 0.9 - (bboxCenterY * 0.5));
1938
- }
1939
-
1940
- this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] for horizontal position`);
1926
+ this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] for horizontal angle offset`);
1941
1927
  } else {
1942
- // No bounding box - use type and spread pattern
1928
+ // No bounding box - spread horizontally based on existing landmarks
1943
1929
  const cameraDeviceId = camera.deviceId;
1944
1930
  const existingFromCamera = (topology.landmarks || []).filter(l =>
1945
1931
  l.visibleFromCameras?.includes(cameraDeviceId) ||
1946
1932
  l.visibleFromCameras?.includes(camera.name)
1947
1933
  ).length;
1948
-
1949
1934
  // Spread horizontally across FOV
1950
1935
  angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6;
1951
-
1952
- // Distance based on type
1953
- if (isFarType) {
1954
- // Neighbors, boundaries, streets are BEYOND the camera's normal range
1955
- distanceMultiplier = 1.5 + Math.random() * 0.5; // 150-200% of range
1956
- this.console.log(`[Discovery] Far-type landmark "${landmarkType}" (no bbox) placed BEYOND FOV`);
1957
- } else {
1958
- distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3;
1959
- this.console.log(`[Discovery] No bounding box, using fallback spread (existing: ${existingFromCamera})`);
1960
- }
1936
+ this.console.log(`[Discovery] No bounding box, spreading horizontally (existing: ${existingFromCamera})`);
1961
1937
  }
1962
1938
 
1963
1939
  const finalAngle = dirRad + angleOffset;
1964
- const distance = range * distanceMultiplier;
1965
1940
 
1966
1941
  position = {
1967
- x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
1968
- y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
1942
+ x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distanceInPixels,
1943
+ y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distanceInPixels,
1969
1944
  };
1970
1945
 
1971
- this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, angle=${(angleOffset * 180 / Math.PI).toFixed(1)}°, dist=${distance.toFixed(0)}px`);
1946
+ this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" at ${distanceFeet}ft (${distanceInPixels}px) from ${camera.name}: dir=${direction}°, angle=${(angleOffset * 180 / Math.PI).toFixed(1)}°`);
1972
1947
  } else {
1973
1948
  // Position in a grid pattern starting from center
1974
1949
  const landmarkCount = topology.landmarks?.length || 0;
@@ -2029,12 +2004,14 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2029
2004
  const timestamp = Date.now();
2030
2005
 
2031
2006
  if (camera?.floorPlanPosition) {
2032
- // Get camera's FOV direction and range (cast to any for flexible access)
2007
+ // Get camera's FOV direction (cast to any for flexible access)
2033
2008
  const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
2034
2009
  const direction = fov.direction || 0;
2035
- const range = fov.range || 80;
2036
2010
  const fovAngle = fov.angle || 90;
2037
2011
 
2012
+ // Get floor plan scale (pixels per foot)
2013
+ const floorPlanScale = topology.floorPlanScale || 5;
2014
+
2038
2015
  // Convert direction to radians (0 = up/north, 90 = right/east)
2039
2016
  const dirRad = (direction - 90) * Math.PI / 180;
2040
2017
  const halfFov = (fovAngle / 2) * Math.PI / 180;
@@ -2042,47 +2019,52 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2042
2019
  const camX = camera.floorPlanPosition.x;
2043
2020
  const camY = camera.floorPlanPosition.y;
2044
2021
 
2045
- // Use bounding box if available to position zone accurately within FOV
2022
+ // Use distanceFeet from the zone metadata for accurate positioning
2023
+ const zoneData = zone as any;
2024
+ const distanceFeet = zoneData.distanceFeet || 40; // Default 40ft if not set
2025
+ const distanceInPixels = distanceFeet * floorPlanScale;
2026
+
2027
+ // Zone size based on coverage (larger coverage = wider zone)
2028
+ const zoneWidthFeet = Math.sqrt(zone.coverage) * 30; // e.g., 50% coverage = ~21ft wide
2029
+ const zoneWidthPixels = zoneWidthFeet * floorPlanScale;
2030
+ const zoneDepthPixels = (zone.coverage * 20) * floorPlanScale; // Depth based on coverage
2031
+
2032
+ // Use bounding box for horizontal positioning if available
2046
2033
  const bbox = zone.boundingBox; // [x, y, width, height] normalized 0-1
2047
2034
 
2048
- let innerRadius: number;
2049
- let outerRadius: number;
2050
2035
  let angleStart: number;
2051
2036
  let angleEnd: number;
2037
+ let innerRadius: number;
2038
+ let outerRadius: number;
2052
2039
 
2053
2040
  if (bbox && bbox.length >= 4) {
2054
- // Map bounding box to position within FOV
2041
+ // Map bounding box X to angle within FOV
2055
2042
  const bboxLeft = bbox[0];
2056
2043
  const bboxRight = bbox[0] + bbox[2];
2057
- const bboxTop = bbox[1];
2058
- const bboxBottom = bbox[1] + bbox[3];
2059
-
2060
- // Map X to angle within FOV (0 = left edge, 1 = right edge)
2061
2044
  angleStart = dirRad + (bboxLeft - 0.5) * 2 * halfFov;
2062
2045
  angleEnd = dirRad + (bboxRight - 0.5) * 2 * halfFov;
2063
2046
 
2064
- // Map Y to distance (0 = far, 1 = close)
2065
- innerRadius = range * (0.9 - bboxBottom * 0.6);
2066
- outerRadius = range * (0.9 - bboxTop * 0.6);
2067
-
2068
- // Ensure min size
2069
- if (outerRadius - innerRadius < 20) {
2070
- outerRadius = innerRadius + 20;
2071
- }
2047
+ // Use distanceFeet for depth, with a spread based on coverage
2048
+ innerRadius = Math.max(distanceInPixels - zoneDepthPixels / 2, 10);
2049
+ outerRadius = distanceInPixels + zoneDepthPixels / 2;
2072
2050
 
2073
- this.console.log(`[Discovery] Zone "${zone.name}" using bbox [${bbox.join(',')}] → angles ${(angleStart * 180 / Math.PI).toFixed(1)}° to ${(angleEnd * 180 / Math.PI).toFixed(1)}°`);
2051
+ this.console.log(`[Discovery] Zone "${zone.name}" at ${distanceFeet}ft using bbox [${bbox.join(',')}]`);
2074
2052
  } else {
2075
- // Fallback: wedge-shaped zone offset by existing count
2053
+ // Fallback: wedge-shaped zone covering portion of FOV
2076
2054
  const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
2077
2055
  z.linkedCameras?.includes(sourceCameras[0])
2078
2056
  ).length;
2079
2057
 
2080
- innerRadius = range * 0.3 + existingFromCamera * 20;
2081
- outerRadius = range * 0.8 + existingFromCamera * 20;
2082
- angleStart = dirRad - halfFov * 0.7;
2083
- angleEnd = dirRad + halfFov * 0.7;
2058
+ // Spread horizontally based on existing zones
2059
+ const offset = (existingFromCamera % 3 - 1) * halfFov * 0.5;
2060
+ angleStart = dirRad + offset - halfFov * 0.3;
2061
+ angleEnd = dirRad + offset + halfFov * 0.3;
2062
+
2063
+ // Use distanceFeet for depth
2064
+ innerRadius = Math.max(distanceInPixels - zoneDepthPixels / 2, 10);
2065
+ outerRadius = distanceInPixels + zoneDepthPixels / 2;
2084
2066
 
2085
- this.console.log(`[Discovery] Zone "${zone.name}" using fallback spread (existing: ${existingFromCamera})`);
2067
+ this.console.log(`[Discovery] Zone "${zone.name}" at ${distanceFeet}ft using fallback spread`);
2086
2068
  }
2087
2069
 
2088
2070
  // Create arc polygon
@@ -2104,7 +2086,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2104
2086
  });
2105
2087
  }
2106
2088
 
2107
- this.console.log(`[Discovery] Creating zone "${zone.name}" in ${camera.name}'s FOV: dir=${direction}°`);
2089
+ this.console.log(`[Discovery] Creating zone "${zone.name}" at ${distanceFeet}ft (${distanceInPixels}px) from ${camera.name}`);
2108
2090
  } else {
2109
2091
  // Fallback: rectangular zone at default location
2110
2092
  const centerX = 300 + (topology.drawnZones?.length || 0) * 120;
@@ -238,7 +238,7 @@ export interface DrawnZone {
238
238
  /** Zone type */
239
239
  type: DrawnZoneType;
240
240
  /** Polygon points on floor plan (x, y coordinates) */
241
- polygon: Point[];
241
+ polygon: FloorPlanPosition[];
242
242
  /** Custom color override (optional) */
243
243
  color?: string;
244
244
  /** Linked camera IDs that can see this zone */
@@ -354,6 +354,8 @@ export interface CameraTopology {
354
354
  floorPlan?: FloorPlanConfig;
355
355
  /** Pending AI landmark suggestions */
356
356
  pendingSuggestions?: LandmarkSuggestion[];
357
+ /** Floor plan scale in pixels per foot (for distance calculations) */
358
+ floorPlanScale?: number;
357
359
  }
358
360
 
359
361
  // ==================== Helper Functions ====================
@@ -454,6 +454,7 @@ export const EDITOR_HTML = `<!DOCTYPE html>
454
454
  const response = await fetch('../api/topology');
455
455
  if (response.ok) {
456
456
  topology = await response.json();
457
+ console.log('[Load] Loaded topology with', (topology.drawnZones || []).length, 'zones');
457
458
  if (!topology.drawings) topology.drawings = [];
458
459
  // Load floor plan scale if saved
459
460
  if (topology.floorPlanScale) {
@@ -971,14 +972,24 @@ export const EDITOR_HTML = `<!DOCTYPE html>
971
972
  async function saveTopology() {
972
973
  try {
973
974
  setStatus('Saving...', 'warning');
975
+ console.log('[Save] Saving topology with', (topology.drawnZones || []).length, 'zones');
974
976
  const response = await fetch('../api/topology', {
975
977
  method: 'PUT',
976
978
  headers: { 'Content-Type': 'application/json' },
977
979
  body: JSON.stringify(topology)
978
980
  });
979
- if (response.ok) { setStatus('Saved successfully', 'success'); }
980
- else { setStatus('Failed to save', 'error'); }
981
- } catch (e) { console.error('Failed to save topology:', e); setStatus('Failed to save', 'error'); }
981
+ if (response.ok) {
982
+ console.log('[Save] Save successful');
983
+ setStatus('Saved successfully', 'success');
984
+ } else {
985
+ const errorText = await response.text();
986
+ console.error('[Save] Save failed:', response.status, errorText);
987
+ setStatus('Failed to save: ' + errorText, 'error');
988
+ }
989
+ } catch (e) {
990
+ console.error('Failed to save topology:', e);
991
+ setStatus('Failed to save', 'error');
992
+ }
982
993
  }
983
994
 
984
995
  function resizeCanvas() {
@@ -1632,8 +1643,12 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1632
1643
  const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
1633
1644
  // Convert stored pixel range to feet for display
1634
1645
  const rangeInFeet = Math.round(pixelsToFeet(fov.range || 80));
1646
+ // Get mount height from context or default to 8
1647
+ const mountHeight = camera.context?.mountHeight || 8;
1635
1648
  panel.innerHTML = '<h3>Camera Properties</h3>' +
1636
1649
  '<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
1650
+ '<div class="form-group"><label>Mount Height (feet)</label><input type="number" value="' + mountHeight + '" min="1" max="40" step="0.5" onchange="updateCameraMountHeight(\\'' + camera.deviceId + '\\', this.value)"></div>' +
1651
+ '<div style="font-size: 11px; color: #666; margin-top: -10px; margin-bottom: 10px;">Height affects distance estimation in discovery</div>' +
1637
1652
  '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
1638
1653
  '<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
1639
1654
  '<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
@@ -1652,6 +1667,12 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1652
1667
  function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
1653
1668
  function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
1654
1669
  function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
1670
+ function updateCameraMountHeight(id, value) {
1671
+ const camera = topology.cameras.find(c => c.deviceId === id);
1672
+ if (!camera) return;
1673
+ if (!camera.context) camera.context = {};
1674
+ camera.context.mountHeight = parseFloat(value) || 8;
1675
+ }
1655
1676
  function updateCameraFov(id, field, value) {
1656
1677
  const camera = topology.cameras.find(c => c.deviceId === id);
1657
1678
  if (!camera) return;
@@ -1768,9 +1789,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1768
1789
  polygon: currentZonePoints.map(pt => ({ x: pt.x, y: pt.y })),
1769
1790
  };
1770
1791
 
1792
+ console.log('[Zone] Creating zone:', zone.name, 'with', zone.polygon.length, 'points');
1793
+
1771
1794
  if (!topology.drawnZones) topology.drawnZones = [];
1772
1795
  topology.drawnZones.push(zone);
1773
1796
 
1797
+ console.log('[Zone] Total zones now:', topology.drawnZones.length);
1798
+
1774
1799
  // Reset state
1775
1800
  zoneDrawingMode = false;
1776
1801
  currentZonePoints = [];
@@ -1779,7 +1804,19 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1779
1804
  setTool('select');
1780
1805
  updateUI();
1781
1806
  render();
1782
- setStatus('Zone "' + zone.name + '" created with ' + zone.polygon.length + ' points', 'success');
1807
+
1808
+ console.log('[Zone] After updateUI/render, zones in topology:', (topology.drawnZones || []).length);
1809
+
1810
+ setStatus('Zone "' + zone.name + '" created - saving...', 'success');
1811
+
1812
+ // Auto-save after creating a zone
1813
+ saveTopology().then(() => {
1814
+ console.log('[Zone] Save completed, zones in topology:', (topology.drawnZones || []).length);
1815
+ setStatus('Zone "' + zone.name + '" saved', 'success');
1816
+ }).catch(err => {
1817
+ console.error('Failed to auto-save zone:', err);
1818
+ setStatus('Zone created but auto-save failed - click Save', 'warning');
1819
+ });
1783
1820
  }
1784
1821
 
1785
1822
  function selectZone(id) {
@@ -1804,9 +1841,9 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1804
1841
  '<button class="btn btn-primary" onclick="deleteZone(\\'' + id + '\\')" style="background: #dc2626; width: 100%;">Delete Zone</button>';
1805
1842
  }
1806
1843
 
1807
- function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); } }
1808
- function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); } }
1809
- function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; } }
1844
+ function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); saveTopology(); } }
1845
+ function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); saveTopology(); } }
1846
+ function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; saveTopology(); } }
1810
1847
  function deleteZone(id) {
1811
1848
  if (!confirm('Delete this zone?')) return;
1812
1849
  topology.drawnZones = (topology.drawnZones || []).filter(z => z.id !== id);
@@ -1814,7 +1851,8 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1814
1851
  document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
1815
1852
  updateUI();
1816
1853
  render();
1817
- setStatus('Zone deleted', 'success');
1854
+ setStatus('Zone deleted - saving...', 'success');
1855
+ saveTopology().then(() => setStatus('Zone deleted', 'success'));
1818
1856
  }
1819
1857
 
1820
1858
  function useBlankCanvas() {