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