@blueharford/scrypted-spatial-awareness 0.6.17 → 0.6.20

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.17",
3
+ "version": "0.6.20",
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",
@@ -440,7 +444,8 @@ Use the mount height to help estimate distances - objects at ground level will a
440
444
  coverage: typeof z.coverage === 'number' ? z.coverage : 0.5,
441
445
  description: z.description || '',
442
446
  boundingBox: z.boundingBox,
443
- }));
447
+ distance: this.mapDistance(z.distance), // Parse distance for zones too
448
+ } as DiscoveredZone & { distance?: DistanceEstimate }));
444
449
  }
445
450
 
446
451
  if (parsed.edges && typeof parsed.edges === 'object') {
@@ -560,6 +565,27 @@ Use the mount height to help estimate distances - objects at ground level will a
560
565
  return 'medium'; // Default to medium if not specified
561
566
  }
562
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
+
563
589
  /** Try to parse JSON with recovery for truncated responses */
564
590
  private parseJsonWithRecovery(jsonStr: string, context: string): any {
565
591
  // First, try direct parse
@@ -882,6 +908,10 @@ Use the mount height to help estimate distances - objects at ground level will a
882
908
  // Generate zone suggestions (even for smaller coverage - 10% is enough)
883
909
  for (const zone of analysis.zones) {
884
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
+
885
915
  const suggestion: DiscoverySuggestion = {
886
916
  id: `zone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
887
917
  type: 'zone',
@@ -889,10 +919,14 @@ Use the mount height to help estimate distances - objects at ground level will a
889
919
  sourceCameras: [analysis.cameraId],
890
920
  confidence: Math.min(0.9, 0.5 + zone.coverage), // Higher coverage = higher confidence
891
921
  status: 'pending',
892
- zone: zone,
922
+ zone: {
923
+ ...zone,
924
+ // Include distance metadata for positioning
925
+ distanceFeet: distanceFeet,
926
+ } as any,
893
927
  };
894
928
  this.suggestions.set(suggestion.id, suggestion);
895
- 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)`);
896
930
  }
897
931
  }
898
932
  }
package/src/main.ts CHANGED
@@ -1062,18 +1062,24 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1062
1062
  if (request.method === 'GET') {
1063
1063
  const topologyJson = this.storage.getItem('topology');
1064
1064
  const topology = topologyJson ? JSON.parse(topologyJson) : createEmptyTopology();
1065
+ this.console.log(`[Topology API] GET - drawnZones: ${topology.drawnZones?.length || 0}`);
1065
1066
  response.send(JSON.stringify(topology), {
1066
1067
  headers: { 'Content-Type': 'application/json' },
1067
1068
  });
1068
1069
  } else if (request.method === 'PUT' || request.method === 'POST') {
1069
1070
  try {
1070
1071
  const topology = JSON.parse(request.body!) as CameraTopology;
1072
+ this.console.log(`[Topology API] PUT received - drawnZones: ${topology.drawnZones?.length || 0}`);
1073
+ if (topology.drawnZones?.length) {
1074
+ this.console.log(`[Topology API] Zone names: ${topology.drawnZones.map(z => z.name).join(', ')}`);
1075
+ }
1071
1076
  this.storage.setItem('topology', JSON.stringify(topology));
1072
1077
  await this.startTrackingEngine(topology);
1073
1078
  response.send(JSON.stringify({ success: true }), {
1074
1079
  headers: { 'Content-Type': 'application/json' },
1075
1080
  });
1076
1081
  } catch (e) {
1082
+ this.console.error(`[Topology API] PUT error:`, e);
1077
1083
  response.send(JSON.stringify({ error: 'Invalid topology JSON' }), {
1078
1084
  code: 400,
1079
1085
  headers: { 'Content-Type': 'application/json' },
@@ -1904,71 +1910,46 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
1904
1910
  const dirRad = (direction - 90) * Math.PI / 180;
1905
1911
  const halfFov = (fovAngle / 2) * Math.PI / 180;
1906
1912
 
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 || '');
1913
+ // Get floor plan scale (pixels per foot) - default 5
1914
+ const floorPlanScale = topology.floorPlanScale || 5;
1915
+
1916
+ // Use the ACTUAL distance from LLM analysis (distanceFeet) - this is the key fix!
1917
+ // The LLM estimates distance based on object size, perspective, and camera context
1918
+ const landmarkData = suggestion.landmark as any;
1919
+ const distanceFeet = landmarkData.distanceFeet || 50; // Default 50ft if not set
1920
+ const distanceInPixels = distanceFeet * floorPlanScale;
1912
1921
 
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;
1922
+ // Use bounding box for horizontal positioning within the FOV
1923
+ const bbox = landmarkData.boundingBox as [number, number, number, number] | undefined;
1916
1924
 
1917
1925
  let angleOffset: number;
1918
- let distanceMultiplier: number;
1919
1926
 
1920
1927
  if (bbox && bbox.length >= 2) {
1921
1928
  // Use bounding box X for horizontal position in FOV
1922
1929
  const bboxCenterX = bbox[0] + (bbox[2] || 0) / 2; // 0 = left edge, 1 = right edge
1923
-
1924
1930
  // Map X position to angle within FOV
1925
1931
  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`);
1932
+ this.console.log(`[Discovery] Using bounding box [${bbox.join(',')}] for horizontal angle offset`);
1941
1933
  } else {
1942
- // No bounding box - use type and spread pattern
1934
+ // No bounding box - spread horizontally based on existing landmarks
1943
1935
  const cameraDeviceId = camera.deviceId;
1944
1936
  const existingFromCamera = (topology.landmarks || []).filter(l =>
1945
1937
  l.visibleFromCameras?.includes(cameraDeviceId) ||
1946
1938
  l.visibleFromCameras?.includes(camera.name)
1947
1939
  ).length;
1948
-
1949
1940
  // Spread horizontally across FOV
1950
1941
  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
- }
1942
+ this.console.log(`[Discovery] No bounding box, spreading horizontally (existing: ${existingFromCamera})`);
1961
1943
  }
1962
1944
 
1963
1945
  const finalAngle = dirRad + angleOffset;
1964
- const distance = range * distanceMultiplier;
1965
1946
 
1966
1947
  position = {
1967
- x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
1968
- y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
1948
+ x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distanceInPixels,
1949
+ y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distanceInPixels,
1969
1950
  };
1970
1951
 
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`);
1952
+ 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
1953
  } else {
1973
1954
  // Position in a grid pattern starting from center
1974
1955
  const landmarkCount = topology.landmarks?.length || 0;
@@ -2029,12 +2010,14 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2029
2010
  const timestamp = Date.now();
2030
2011
 
2031
2012
  if (camera?.floorPlanPosition) {
2032
- // Get camera's FOV direction and range (cast to any for flexible access)
2013
+ // Get camera's FOV direction (cast to any for flexible access)
2033
2014
  const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
2034
2015
  const direction = fov.direction || 0;
2035
- const range = fov.range || 80;
2036
2016
  const fovAngle = fov.angle || 90;
2037
2017
 
2018
+ // Get floor plan scale (pixels per foot)
2019
+ const floorPlanScale = topology.floorPlanScale || 5;
2020
+
2038
2021
  // Convert direction to radians (0 = up/north, 90 = right/east)
2039
2022
  const dirRad = (direction - 90) * Math.PI / 180;
2040
2023
  const halfFov = (fovAngle / 2) * Math.PI / 180;
@@ -2042,47 +2025,52 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2042
2025
  const camX = camera.floorPlanPosition.x;
2043
2026
  const camY = camera.floorPlanPosition.y;
2044
2027
 
2045
- // Use bounding box if available to position zone accurately within FOV
2028
+ // Use distanceFeet from the zone metadata for accurate positioning
2029
+ const zoneData = zone as any;
2030
+ const distanceFeet = zoneData.distanceFeet || 40; // Default 40ft if not set
2031
+ const distanceInPixels = distanceFeet * floorPlanScale;
2032
+
2033
+ // Zone size based on coverage (larger coverage = wider zone)
2034
+ const zoneWidthFeet = Math.sqrt(zone.coverage) * 30; // e.g., 50% coverage = ~21ft wide
2035
+ const zoneWidthPixels = zoneWidthFeet * floorPlanScale;
2036
+ const zoneDepthPixels = (zone.coverage * 20) * floorPlanScale; // Depth based on coverage
2037
+
2038
+ // Use bounding box for horizontal positioning if available
2046
2039
  const bbox = zone.boundingBox; // [x, y, width, height] normalized 0-1
2047
2040
 
2048
- let innerRadius: number;
2049
- let outerRadius: number;
2050
2041
  let angleStart: number;
2051
2042
  let angleEnd: number;
2043
+ let innerRadius: number;
2044
+ let outerRadius: number;
2052
2045
 
2053
2046
  if (bbox && bbox.length >= 4) {
2054
- // Map bounding box to position within FOV
2047
+ // Map bounding box X to angle within FOV
2055
2048
  const bboxLeft = bbox[0];
2056
2049
  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
2050
  angleStart = dirRad + (bboxLeft - 0.5) * 2 * halfFov;
2062
2051
  angleEnd = dirRad + (bboxRight - 0.5) * 2 * halfFov;
2063
2052
 
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);
2053
+ // Use distanceFeet for depth, with a spread based on coverage
2054
+ innerRadius = Math.max(distanceInPixels - zoneDepthPixels / 2, 10);
2055
+ outerRadius = distanceInPixels + zoneDepthPixels / 2;
2067
2056
 
2068
- // Ensure min size
2069
- if (outerRadius - innerRadius < 20) {
2070
- outerRadius = innerRadius + 20;
2071
- }
2072
-
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)}°`);
2057
+ this.console.log(`[Discovery] Zone "${zone.name}" at ${distanceFeet}ft using bbox [${bbox.join(',')}]`);
2074
2058
  } else {
2075
- // Fallback: wedge-shaped zone offset by existing count
2059
+ // Fallback: wedge-shaped zone covering portion of FOV
2076
2060
  const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
2077
2061
  z.linkedCameras?.includes(sourceCameras[0])
2078
2062
  ).length;
2079
2063
 
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;
2064
+ // Spread horizontally based on existing zones
2065
+ const offset = (existingFromCamera % 3 - 1) * halfFov * 0.5;
2066
+ angleStart = dirRad + offset - halfFov * 0.3;
2067
+ angleEnd = dirRad + offset + halfFov * 0.3;
2068
+
2069
+ // Use distanceFeet for depth
2070
+ innerRadius = Math.max(distanceInPixels - zoneDepthPixels / 2, 10);
2071
+ outerRadius = distanceInPixels + zoneDepthPixels / 2;
2084
2072
 
2085
- this.console.log(`[Discovery] Zone "${zone.name}" using fallback spread (existing: ${existingFromCamera})`);
2073
+ this.console.log(`[Discovery] Zone "${zone.name}" at ${distanceFeet}ft using fallback spread`);
2086
2074
  }
2087
2075
 
2088
2076
  // Create arc polygon
@@ -2104,7 +2092,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
2104
2092
  });
2105
2093
  }
2106
2094
 
2107
- this.console.log(`[Discovery] Creating zone "${zone.name}" in ${camera.name}'s FOV: dir=${direction}°`);
2095
+ this.console.log(`[Discovery] Creating zone "${zone.name}" at ${distanceFeet}ft (${distanceInPixels}px) from ${camera.name}`);
2108
2096
  } else {
2109
2097
  // Fallback: rectangular zone at default location
2110
2098
  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() {
@@ -1598,9 +1609,11 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1598
1609
  // Zone list
1599
1610
  const zoneList = document.getElementById('zone-list');
1600
1611
  const zones = topology.drawnZones || [];
1612
+ console.log('[updateUI] Zone list update - topology.drawnZones:', zones.length, 'zones');
1601
1613
  if (zones.length === 0) {
1602
1614
  zoneList.innerHTML = '<div class="zone-item" style="color: #666; text-align: center; cursor: default; padding: 8px;">No zones drawn</div>';
1603
1615
  } else {
1616
+ console.log('[updateUI] Rendering', zones.length, 'zones:', zones.map(z => z.name).join(', '));
1604
1617
  zoneList.innerHTML = zones.map(z => {
1605
1618
  const color = ZONE_STROKE_COLORS[z.type] || ZONE_STROKE_COLORS.custom;
1606
1619
  return '<div class="camera-item ' + (selectedItem?.type === 'zone' && selectedItem?.id === z.id ? 'selected' : '') + '" onclick="selectZone(\\'' + z.id + '\\')" style="border-left: 3px solid ' + color + ';"><div class="camera-name">' + z.name + '</div><div class="camera-info">' + z.type + ' | ' + z.polygon.length + ' points</div></div>';
@@ -1778,9 +1791,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1778
1791
  polygon: currentZonePoints.map(pt => ({ x: pt.x, y: pt.y })),
1779
1792
  };
1780
1793
 
1794
+ console.log('[Zone] Creating zone:', zone.name, 'with', zone.polygon.length, 'points');
1795
+
1781
1796
  if (!topology.drawnZones) topology.drawnZones = [];
1782
1797
  topology.drawnZones.push(zone);
1783
1798
 
1799
+ console.log('[Zone] Total zones now:', topology.drawnZones.length);
1800
+
1784
1801
  // Reset state
1785
1802
  zoneDrawingMode = false;
1786
1803
  currentZonePoints = [];
@@ -1789,7 +1806,19 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1789
1806
  setTool('select');
1790
1807
  updateUI();
1791
1808
  render();
1792
- setStatus('Zone "' + zone.name + '" created with ' + zone.polygon.length + ' points', 'success');
1809
+
1810
+ console.log('[Zone] After updateUI/render, zones in topology:', (topology.drawnZones || []).length);
1811
+
1812
+ setStatus('Zone "' + zone.name + '" created - saving...', 'success');
1813
+
1814
+ // Auto-save after creating a zone
1815
+ saveTopology().then(() => {
1816
+ console.log('[Zone] Save completed, zones in topology:', (topology.drawnZones || []).length);
1817
+ setStatus('Zone "' + zone.name + '" saved', 'success');
1818
+ }).catch(err => {
1819
+ console.error('Failed to auto-save zone:', err);
1820
+ setStatus('Zone created but auto-save failed - click Save', 'warning');
1821
+ });
1793
1822
  }
1794
1823
 
1795
1824
  function selectZone(id) {
@@ -1814,9 +1843,9 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1814
1843
  '<button class="btn btn-primary" onclick="deleteZone(\\'' + id + '\\')" style="background: #dc2626; width: 100%;">Delete Zone</button>';
1815
1844
  }
1816
1845
 
1817
- function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); } }
1818
- function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); } }
1819
- function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; } }
1846
+ function updateZoneName(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.name = value; updateUI(); render(); saveTopology(); } }
1847
+ function updateZoneType(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.type = value; updateUI(); render(); saveTopology(); } }
1848
+ function updateZoneDesc(id, value) { const z = (topology.drawnZones || []).find(z => z.id === id); if (z) { z.description = value || undefined; saveTopology(); } }
1820
1849
  function deleteZone(id) {
1821
1850
  if (!confirm('Delete this zone?')) return;
1822
1851
  topology.drawnZones = (topology.drawnZones || []).filter(z => z.id !== id);
@@ -1824,7 +1853,8 @@ export const EDITOR_HTML = `<!DOCTYPE html>
1824
1853
  document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
1825
1854
  updateUI();
1826
1855
  render();
1827
- setStatus('Zone deleted', 'success');
1856
+ setStatus('Zone deleted - saving...', 'success');
1857
+ saveTopology().then(() => setStatus('Zone deleted', 'success'));
1828
1858
  }
1829
1859
 
1830
1860
  function useBlankCanvas() {