@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/main.nodejs.js +1 -1
- package/dist/main.nodejs.js.map +1 -1
- package/dist/plugin.zip +0 -0
- package/out/main.nodejs.js +287 -48
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +188 -48
- package/src/ui/editor-html.ts +133 -3
package/out/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
LANDMARK_TEMPLATES,
|
|
26
26
|
inferRelationships,
|
|
27
27
|
DrawnZoneType,
|
|
28
|
+
GlobalZone,
|
|
29
|
+
GlobalZoneType,
|
|
30
|
+
CameraZoneMapping,
|
|
31
|
+
ClipPath,
|
|
28
32
|
} from './models/topology';
|
|
29
33
|
import { TrackedObject } from './models/tracked-object';
|
|
30
34
|
import { Alert, AlertRule, createDefaultRules } from './models/alert';
|
|
@@ -1820,8 +1824,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1820
1824
|
let updated = false;
|
|
1821
1825
|
|
|
1822
1826
|
if (suggestion.type === 'landmark' && suggestion.landmark) {
|
|
1823
|
-
// Calculate
|
|
1824
|
-
// Use the first visible camera's position as a starting point, or canvas center
|
|
1827
|
+
// Calculate position for the landmark WITHIN the camera's field of view
|
|
1825
1828
|
let position = suggestion.landmark.position;
|
|
1826
1829
|
if (!position || (position.x === 0 && position.y === 0)) {
|
|
1827
1830
|
// Find a camera that can see this landmark
|
|
@@ -1829,12 +1832,35 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1829
1832
|
const camera = visibleCameraId ? topology.cameras.find(c => c.deviceId === visibleCameraId) : null;
|
|
1830
1833
|
|
|
1831
1834
|
if (camera?.floorPlanPosition) {
|
|
1832
|
-
//
|
|
1833
|
-
const
|
|
1835
|
+
// Get camera's FOV direction and range (cast to any for flexible access)
|
|
1836
|
+
const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
|
|
1837
|
+
const direction = fov.direction || 0;
|
|
1838
|
+
const range = fov.range || 80;
|
|
1839
|
+
const fovAngle = fov.angle || 90;
|
|
1840
|
+
|
|
1841
|
+
// Count existing landmarks from this camera to spread them out
|
|
1842
|
+
const existingFromCamera = (topology.landmarks || []).filter(l =>
|
|
1843
|
+
l.visibleFromCameras?.includes(visibleCameraId)
|
|
1844
|
+
).length;
|
|
1845
|
+
|
|
1846
|
+
// Calculate position in front of camera within its FOV
|
|
1847
|
+
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
1848
|
+
const dirRad = (direction - 90) * Math.PI / 180;
|
|
1849
|
+
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
1850
|
+
|
|
1851
|
+
// Spread landmarks across the FOV cone at varying distances
|
|
1852
|
+
const angleOffset = (existingFromCamera % 3 - 1) * halfFov * 0.6; // -0.6, 0, +0.6 of half FOV
|
|
1853
|
+
const distanceMultiplier = 0.5 + (existingFromCamera % 2) * 0.3; // 50% or 80% of range
|
|
1854
|
+
|
|
1855
|
+
const finalAngle = dirRad + angleOffset;
|
|
1856
|
+
const distance = range * distanceMultiplier;
|
|
1857
|
+
|
|
1834
1858
|
position = {
|
|
1835
|
-
x: camera.floorPlanPosition.x +
|
|
1836
|
-
y: camera.floorPlanPosition.y +
|
|
1859
|
+
x: camera.floorPlanPosition.x + Math.cos(finalAngle) * distance,
|
|
1860
|
+
y: camera.floorPlanPosition.y + Math.sin(finalAngle) * distance,
|
|
1837
1861
|
};
|
|
1862
|
+
|
|
1863
|
+
this.console.log(`[Discovery] Placing landmark "${suggestion.landmark.name}" in ${camera.name}'s FOV: dir=${direction}°, dist=${distance.toFixed(0)}px`);
|
|
1838
1864
|
} else {
|
|
1839
1865
|
// Position in a grid pattern starting from center
|
|
1840
1866
|
const landmarkCount = topology.landmarks?.length || 0;
|
|
@@ -1872,72 +1898,167 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1872
1898
|
// Create a drawn zone from the discovery zone
|
|
1873
1899
|
const zone = suggestion.zone;
|
|
1874
1900
|
|
|
1875
|
-
// Find cameras that see this zone
|
|
1876
|
-
const
|
|
1877
|
-
const camera =
|
|
1901
|
+
// Find cameras that see this zone
|
|
1902
|
+
const sourceCameras = suggestion.sourceCameras || [];
|
|
1903
|
+
const camera = sourceCameras[0]
|
|
1904
|
+
? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
|
|
1905
|
+
: null;
|
|
1906
|
+
|
|
1907
|
+
// Create zone polygon WITHIN the camera's field of view
|
|
1908
|
+
let polygon: { x: number; y: number }[] = [];
|
|
1909
|
+
const timestamp = Date.now();
|
|
1878
1910
|
|
|
1879
|
-
// Create a default polygon near the camera or at a default location
|
|
1880
|
-
let centerX = 300;
|
|
1881
|
-
let centerY = 200;
|
|
1882
1911
|
if (camera?.floorPlanPosition) {
|
|
1883
|
-
|
|
1884
|
-
|
|
1912
|
+
// Get camera's FOV direction and range (cast to any for flexible access)
|
|
1913
|
+
const fov = (camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 }) as any;
|
|
1914
|
+
const direction = fov.direction || 0;
|
|
1915
|
+
const range = fov.range || 80;
|
|
1916
|
+
const fovAngle = fov.angle || 90;
|
|
1917
|
+
|
|
1918
|
+
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
1919
|
+
const dirRad = (direction - 90) * Math.PI / 180;
|
|
1920
|
+
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
1921
|
+
|
|
1922
|
+
// Count existing zones from this camera to offset new ones
|
|
1923
|
+
const existingFromCamera = (topology.drawnZones || []).filter((z: any) =>
|
|
1924
|
+
z.linkedCameras?.includes(sourceCameras[0])
|
|
1925
|
+
).length;
|
|
1926
|
+
|
|
1927
|
+
// Create a wedge-shaped zone within the camera's FOV
|
|
1928
|
+
// Offset based on existing zones to avoid overlap
|
|
1929
|
+
const innerRadius = range * 0.3 + existingFromCamera * 20;
|
|
1930
|
+
const outerRadius = range * 0.8 + existingFromCamera * 20;
|
|
1931
|
+
|
|
1932
|
+
// Use a portion of the FOV for each zone
|
|
1933
|
+
const zoneSpread = halfFov * 0.7; // 70% of half FOV
|
|
1934
|
+
|
|
1935
|
+
const camX = camera.floorPlanPosition.x;
|
|
1936
|
+
const camY = camera.floorPlanPosition.y;
|
|
1937
|
+
|
|
1938
|
+
// Create arc polygon (wedge shape)
|
|
1939
|
+
const steps = 8;
|
|
1940
|
+
// Inner arc (from left to right)
|
|
1941
|
+
for (let i = 0; i <= steps; i++) {
|
|
1942
|
+
const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
|
|
1943
|
+
polygon.push({
|
|
1944
|
+
x: camX + Math.cos(angle) * innerRadius,
|
|
1945
|
+
y: camY + Math.sin(angle) * innerRadius,
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
// Outer arc (from right to left)
|
|
1949
|
+
for (let i = steps; i >= 0; i--) {
|
|
1950
|
+
const angle = dirRad - zoneSpread + (zoneSpread * 2 * i / steps);
|
|
1951
|
+
polygon.push({
|
|
1952
|
+
x: camX + Math.cos(angle) * outerRadius,
|
|
1953
|
+
y: camY + Math.sin(angle) * outerRadius,
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
this.console.log(`[Discovery] Creating zone "${zone.name}" in ${camera.name}'s FOV: dir=${direction}°`);
|
|
1958
|
+
} else {
|
|
1959
|
+
// Fallback: rectangular zone at default location
|
|
1960
|
+
const centerX = 300 + (topology.drawnZones?.length || 0) * 120;
|
|
1961
|
+
const centerY = 200;
|
|
1962
|
+
const size = 100;
|
|
1963
|
+
polygon = [
|
|
1964
|
+
{ x: centerX - size/2, y: centerY - size/2 },
|
|
1965
|
+
{ x: centerX + size/2, y: centerY - size/2 },
|
|
1966
|
+
{ x: centerX + size/2, y: centerY + size/2 },
|
|
1967
|
+
{ x: centerX - size/2, y: centerY + size/2 },
|
|
1968
|
+
];
|
|
1885
1969
|
}
|
|
1886
1970
|
|
|
1887
|
-
// Create
|
|
1888
|
-
const size = 100;
|
|
1971
|
+
// 1. Create DrawnZone (visual on floor plan)
|
|
1889
1972
|
const drawnZone = {
|
|
1890
|
-
id: `zone_${
|
|
1973
|
+
id: `zone_${timestamp}`,
|
|
1891
1974
|
name: zone.name,
|
|
1892
1975
|
type: (zone.type || 'custom') as DrawnZoneType,
|
|
1893
1976
|
description: zone.description,
|
|
1894
|
-
polygon:
|
|
1895
|
-
|
|
1896
|
-
{ x: centerX + size/2, y: centerY - size/2 },
|
|
1897
|
-
{ x: centerX + size/2, y: centerY + size/2 },
|
|
1898
|
-
{ x: centerX - size/2, y: centerY + size/2 },
|
|
1899
|
-
] as any,
|
|
1977
|
+
polygon: polygon as any,
|
|
1978
|
+
linkedCameras: sourceCameras,
|
|
1900
1979
|
};
|
|
1901
1980
|
|
|
1902
1981
|
if (!topology.drawnZones) {
|
|
1903
1982
|
topology.drawnZones = [];
|
|
1904
1983
|
}
|
|
1905
1984
|
topology.drawnZones.push(drawnZone);
|
|
1906
|
-
updated = true;
|
|
1907
1985
|
|
|
1908
|
-
|
|
1986
|
+
// 2. Create GlobalZone (for tracking/alerting)
|
|
1987
|
+
const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
|
|
1988
|
+
const cameraZones: CameraZoneMapping[] = sourceCameras
|
|
1989
|
+
.map(camRef => {
|
|
1990
|
+
const cam = topology.cameras.find(c => c.deviceId === camRef || c.name === camRef);
|
|
1991
|
+
if (!cam) return null;
|
|
1992
|
+
return {
|
|
1993
|
+
cameraId: cam.deviceId,
|
|
1994
|
+
zone: [[0, 0], [100, 0], [100, 100], [0, 100]] as ClipPath, // Full frame default
|
|
1995
|
+
};
|
|
1996
|
+
})
|
|
1997
|
+
.filter((z): z is CameraZoneMapping => z !== null);
|
|
1998
|
+
|
|
1999
|
+
if (cameraZones.length > 0) {
|
|
2000
|
+
const globalZone: GlobalZone = {
|
|
2001
|
+
id: `global_${timestamp}`,
|
|
2002
|
+
name: zone.name,
|
|
2003
|
+
type: globalZoneType,
|
|
2004
|
+
cameraZones,
|
|
2005
|
+
};
|
|
2006
|
+
|
|
2007
|
+
if (!topology.globalZones) {
|
|
2008
|
+
topology.globalZones = [];
|
|
2009
|
+
}
|
|
2010
|
+
topology.globalZones.push(globalZone);
|
|
2011
|
+
this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone and GlobalZone created`);
|
|
2012
|
+
} else {
|
|
2013
|
+
this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone only (no cameras matched)`);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
updated = true;
|
|
1909
2017
|
}
|
|
1910
2018
|
|
|
1911
2019
|
if (suggestion.type === 'connection' && suggestion.connection) {
|
|
1912
2020
|
// Add new connection to topology
|
|
1913
2021
|
const conn = suggestion.connection;
|
|
1914
2022
|
|
|
1915
|
-
//
|
|
1916
|
-
|
|
1917
|
-
const
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
if
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2023
|
+
// Resolve camera references - LLM may return names instead of deviceIds
|
|
2024
|
+
// Use case-insensitive matching for names
|
|
2025
|
+
const fromCamera = topology.cameras.find(c =>
|
|
2026
|
+
c.deviceId === conn.fromCameraId ||
|
|
2027
|
+
c.name === conn.fromCameraId ||
|
|
2028
|
+
c.name.toLowerCase() === conn.fromCameraId.toLowerCase()
|
|
2029
|
+
);
|
|
2030
|
+
const toCamera = topology.cameras.find(c =>
|
|
2031
|
+
c.deviceId === conn.toCameraId ||
|
|
2032
|
+
c.name === conn.toCameraId ||
|
|
2033
|
+
c.name.toLowerCase() === conn.toCameraId.toLowerCase()
|
|
2034
|
+
);
|
|
2035
|
+
|
|
2036
|
+
// Don't create connection if cameras not found
|
|
2037
|
+
if (!fromCamera || !toCamera) {
|
|
2038
|
+
this.console.warn(`[Discovery] Cannot create connection: camera not found. from="${conn.fromCameraId}" to="${conn.toCameraId}"`);
|
|
2039
|
+
this.console.warn(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name} (${c.deviceId})`).join(', ')}`);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// Check if connection already exists
|
|
2044
|
+
const existingConn = topology.connections.find(c =>
|
|
2045
|
+
(c.fromCameraId === fromCamera.deviceId && c.toCameraId === toCamera.deviceId) ||
|
|
2046
|
+
(c.bidirectional && c.fromCameraId === toCamera.deviceId && c.toCameraId === fromCamera.deviceId)
|
|
2047
|
+
);
|
|
2048
|
+
if (existingConn) {
|
|
2049
|
+
this.console.log(`[Discovery] Connection already exists between ${fromCamera.name} and ${toCamera.name}`);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Warn if cameras don't have positions yet
|
|
2054
|
+
if (!fromCamera.floorPlanPosition || !toCamera.floorPlanPosition) {
|
|
2055
|
+
this.console.warn(`[Discovery] Note: One or both cameras not positioned on floor plan yet`);
|
|
1935
2056
|
}
|
|
1936
2057
|
|
|
1937
2058
|
const newConnection = {
|
|
1938
2059
|
id: `conn_${Date.now()}`,
|
|
1939
|
-
fromCameraId: fromCamera
|
|
1940
|
-
toCameraId: toCamera
|
|
2060
|
+
fromCameraId: fromCamera.deviceId,
|
|
2061
|
+
toCameraId: toCamera.deviceId,
|
|
1941
2062
|
bidirectional: conn.bidirectional,
|
|
1942
2063
|
// Default exit/entry zones covering full frame
|
|
1943
2064
|
exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]] as [number, number][],
|
|
@@ -1953,7 +2074,7 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1953
2074
|
topology.connections.push(newConnection);
|
|
1954
2075
|
updated = true;
|
|
1955
2076
|
|
|
1956
|
-
this.console.log(`[Discovery] Added connection: ${fromCamera
|
|
2077
|
+
this.console.log(`[Discovery] Added connection: ${fromCamera.name} (${fromCamera.deviceId}) -> ${toCamera.name} (${toCamera.deviceId})`);
|
|
1957
2078
|
}
|
|
1958
2079
|
|
|
1959
2080
|
if (updated) {
|
|
@@ -1963,6 +2084,25 @@ export class SpatialAwarenessPlugin extends ScryptedDeviceBase
|
|
|
1963
2084
|
}
|
|
1964
2085
|
}
|
|
1965
2086
|
|
|
2087
|
+
/** Map discovered zone types to GlobalZone types for tracking/alerting */
|
|
2088
|
+
private mapZoneTypeToGlobalType(type: string): GlobalZoneType {
|
|
2089
|
+
const mapping: Record<string, GlobalZoneType> = {
|
|
2090
|
+
'yard': 'dwell',
|
|
2091
|
+
'driveway': 'entry',
|
|
2092
|
+
'street': 'entry',
|
|
2093
|
+
'patio': 'dwell',
|
|
2094
|
+
'walkway': 'entry',
|
|
2095
|
+
'parking': 'dwell',
|
|
2096
|
+
'garden': 'dwell',
|
|
2097
|
+
'pool': 'restricted',
|
|
2098
|
+
'entrance': 'entry',
|
|
2099
|
+
'garage': 'entry',
|
|
2100
|
+
'deck': 'dwell',
|
|
2101
|
+
'custom': 'dwell',
|
|
2102
|
+
};
|
|
2103
|
+
return mapping[type] || 'dwell';
|
|
2104
|
+
}
|
|
2105
|
+
|
|
1966
2106
|
private serveEditorUI(response: HttpResponse): void {
|
|
1967
2107
|
response.send(EDITOR_HTML, {
|
|
1968
2108
|
headers: { 'Content-Type': 'text/html' },
|
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;
|
|
@@ -1269,6 +1277,29 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1269
1277
|
function drawCamera(camera) {
|
|
1270
1278
|
const pos = camera.floorPlanPosition;
|
|
1271
1279
|
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
1280
|
+
|
|
1281
|
+
// Get FOV settings or defaults
|
|
1282
|
+
const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
1283
|
+
const direction = (fov.mode === 'simple' || !fov.mode) ? (fov.direction || 0) : 0;
|
|
1284
|
+
const fovAngle = (fov.mode === 'simple' || !fov.mode) ? (fov.angle || 90) : 90;
|
|
1285
|
+
const range = (fov.mode === 'simple' || !fov.mode) ? (fov.range || 80) : 80;
|
|
1286
|
+
|
|
1287
|
+
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
1288
|
+
const dirRad = (direction - 90) * Math.PI / 180;
|
|
1289
|
+
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
1290
|
+
|
|
1291
|
+
// Draw FOV cone
|
|
1292
|
+
ctx.beginPath();
|
|
1293
|
+
ctx.moveTo(pos.x, pos.y);
|
|
1294
|
+
ctx.arc(pos.x, pos.y, range, dirRad - halfFov, dirRad + halfFov);
|
|
1295
|
+
ctx.closePath();
|
|
1296
|
+
ctx.fillStyle = isSelected ? 'rgba(233, 69, 96, 0.15)' : 'rgba(76, 175, 80, 0.15)';
|
|
1297
|
+
ctx.fill();
|
|
1298
|
+
ctx.strokeStyle = isSelected ? 'rgba(233, 69, 96, 0.5)' : 'rgba(76, 175, 80, 0.5)';
|
|
1299
|
+
ctx.lineWidth = 1;
|
|
1300
|
+
ctx.stroke();
|
|
1301
|
+
|
|
1302
|
+
// Draw camera circle
|
|
1272
1303
|
ctx.beginPath();
|
|
1273
1304
|
ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
|
|
1274
1305
|
ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
|
|
@@ -1276,12 +1307,41 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1276
1307
|
ctx.strokeStyle = '#fff';
|
|
1277
1308
|
ctx.lineWidth = 2;
|
|
1278
1309
|
ctx.stroke();
|
|
1310
|
+
|
|
1311
|
+
// Draw camera icon/text
|
|
1279
1312
|
ctx.fillStyle = '#fff';
|
|
1280
1313
|
ctx.font = '12px sans-serif';
|
|
1281
1314
|
ctx.textAlign = 'center';
|
|
1282
1315
|
ctx.textBaseline = 'middle';
|
|
1283
1316
|
ctx.fillText('CAM', pos.x, pos.y);
|
|
1284
1317
|
ctx.fillText(camera.name, pos.x, pos.y + 35);
|
|
1318
|
+
|
|
1319
|
+
// Draw direction handle (when selected) for rotation
|
|
1320
|
+
if (isSelected) {
|
|
1321
|
+
const handleLength = 45;
|
|
1322
|
+
const handleX = pos.x + Math.cos(dirRad) * handleLength;
|
|
1323
|
+
const handleY = pos.y + Math.sin(dirRad) * handleLength;
|
|
1324
|
+
|
|
1325
|
+
// Handle line
|
|
1326
|
+
ctx.beginPath();
|
|
1327
|
+
ctx.moveTo(pos.x + Math.cos(dirRad) * 20, pos.y + Math.sin(dirRad) * 20);
|
|
1328
|
+
ctx.lineTo(handleX, handleY);
|
|
1329
|
+
ctx.strokeStyle = '#ff6b6b';
|
|
1330
|
+
ctx.lineWidth = 3;
|
|
1331
|
+
ctx.stroke();
|
|
1332
|
+
|
|
1333
|
+
// Handle grip (circle at end)
|
|
1334
|
+
ctx.beginPath();
|
|
1335
|
+
ctx.arc(handleX, handleY, 8, 0, Math.PI * 2);
|
|
1336
|
+
ctx.fillStyle = '#ff6b6b';
|
|
1337
|
+
ctx.fill();
|
|
1338
|
+
ctx.strokeStyle = '#fff';
|
|
1339
|
+
ctx.lineWidth = 2;
|
|
1340
|
+
ctx.stroke();
|
|
1341
|
+
|
|
1342
|
+
// Store handle position for hit detection
|
|
1343
|
+
camera._handlePos = { x: handleX, y: handleY };
|
|
1344
|
+
}
|
|
1285
1345
|
}
|
|
1286
1346
|
|
|
1287
1347
|
function drawConnection(from, to, conn) {
|
|
@@ -1542,7 +1602,16 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1542
1602
|
|
|
1543
1603
|
function showCameraProperties(camera) {
|
|
1544
1604
|
const panel = document.getElementById('properties-panel');
|
|
1545
|
-
|
|
1605
|
+
const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
1606
|
+
panel.innerHTML = '<h3>Camera Properties</h3>' +
|
|
1607
|
+
'<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
|
|
1608
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
|
|
1609
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
|
|
1610
|
+
'<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
|
|
1611
|
+
'<div class="form-group"><label>Direction (0=up, 90=right)</label><input type="number" value="' + Math.round(fov.direction || 0) + '" min="0" max="359" onchange="updateCameraFov(\\'' + camera.deviceId + '\\', \\'direction\\', this.value)"></div>' +
|
|
1612
|
+
'<div class="form-group"><label>FOV Angle (degrees)</label><input type="number" value="' + (fov.angle || 90) + '" min="30" max="180" onchange="updateCameraFov(\\'' + camera.deviceId + '\\', \\'angle\\', this.value)"></div>' +
|
|
1613
|
+
'<div class="form-group"><label>Range (pixels)</label><input type="number" value="' + (fov.range || 80) + '" min="20" max="300" onchange="updateCameraFov(\\'' + camera.deviceId + '\\', \\'range\\', this.value)"></div>' +
|
|
1614
|
+
'<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
|
|
1546
1615
|
}
|
|
1547
1616
|
|
|
1548
1617
|
function showConnectionProperties(connection) {
|
|
@@ -1553,6 +1622,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1553
1622
|
function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
|
|
1554
1623
|
function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
|
|
1555
1624
|
function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
|
|
1625
|
+
function updateCameraFov(id, field, value) {
|
|
1626
|
+
const camera = topology.cameras.find(c => c.deviceId === id);
|
|
1627
|
+
if (!camera) return;
|
|
1628
|
+
if (!camera.fov) camera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
1629
|
+
camera.fov[field] = parseFloat(value);
|
|
1630
|
+
render();
|
|
1631
|
+
}
|
|
1556
1632
|
function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
|
|
1557
1633
|
function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
|
|
1558
1634
|
function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
|
|
@@ -1696,10 +1772,30 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1696
1772
|
setStatus('Drawings cleared', 'success');
|
|
1697
1773
|
}
|
|
1698
1774
|
|
|
1775
|
+
function clearAllTopology() {
|
|
1776
|
+
if (!confirm('DELETE ALL TOPOLOGY DATA?\\n\\nThis will remove:\\n- All cameras\\n- All connections\\n- All landmarks\\n- All zones\\n- All drawings\\n\\nThis cannot be undone.')) return;
|
|
1777
|
+
|
|
1778
|
+
topology.cameras = [];
|
|
1779
|
+
topology.connections = [];
|
|
1780
|
+
topology.landmarks = [];
|
|
1781
|
+
topology.globalZones = [];
|
|
1782
|
+
topology.drawnZones = [];
|
|
1783
|
+
topology.drawings = [];
|
|
1784
|
+
topology.relationships = [];
|
|
1785
|
+
|
|
1786
|
+
selectedItem = null;
|
|
1787
|
+
document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
|
|
1788
|
+
updateCameraSelects();
|
|
1789
|
+
updateUI();
|
|
1790
|
+
render();
|
|
1791
|
+
setStatus('All topology data cleared', 'warning');
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1699
1794
|
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
|
1700
1795
|
function setStatus(text, type) { document.getElementById('status-text').textContent = text; const dot = document.getElementById('status-dot'); dot.className = 'status-dot'; if (type === 'warning') dot.classList.add('warning'); if (type === 'error') dot.classList.add('error'); }
|
|
1701
1796
|
|
|
1702
1797
|
let dragging = null;
|
|
1798
|
+
let rotatingCamera = null;
|
|
1703
1799
|
|
|
1704
1800
|
canvas.addEventListener('mousedown', (e) => {
|
|
1705
1801
|
const rect = canvas.getBoundingClientRect();
|
|
@@ -1715,7 +1811,20 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1715
1811
|
}
|
|
1716
1812
|
|
|
1717
1813
|
if (currentTool === 'select') {
|
|
1718
|
-
// Check
|
|
1814
|
+
// Check for rotation handle on selected camera first
|
|
1815
|
+
if (selectedItem?.type === 'camera') {
|
|
1816
|
+
const camera = topology.cameras.find(c => c.deviceId === selectedItem.id);
|
|
1817
|
+
if (camera?._handlePos) {
|
|
1818
|
+
const dist = Math.hypot(x - camera._handlePos.x, y - camera._handlePos.y);
|
|
1819
|
+
if (dist < 15) {
|
|
1820
|
+
rotatingCamera = camera;
|
|
1821
|
+
setStatus('Drag to rotate camera direction', 'warning');
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Check cameras
|
|
1719
1828
|
for (const camera of topology.cameras) {
|
|
1720
1829
|
if (camera.floorPlanPosition) {
|
|
1721
1830
|
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
@@ -1779,6 +1888,20 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1779
1888
|
const x = e.clientX - rect.left;
|
|
1780
1889
|
const y = e.clientY - rect.top;
|
|
1781
1890
|
|
|
1891
|
+
// Handle camera rotation
|
|
1892
|
+
if (rotatingCamera) {
|
|
1893
|
+
const pos = rotatingCamera.floorPlanPosition;
|
|
1894
|
+
const angle = Math.atan2(y - pos.y, x - pos.x);
|
|
1895
|
+
// Convert to our direction system (0 = up/north, 90 = right/east)
|
|
1896
|
+
const direction = (angle * 180 / Math.PI) + 90;
|
|
1897
|
+
if (!rotatingCamera.fov) {
|
|
1898
|
+
rotatingCamera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
1899
|
+
}
|
|
1900
|
+
rotatingCamera.fov.direction = ((direction % 360) + 360) % 360; // Normalize 0-360
|
|
1901
|
+
render();
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1782
1905
|
if (dragging) {
|
|
1783
1906
|
if (dragging.type === 'camera') {
|
|
1784
1907
|
dragging.item.floorPlanPosition.x = x;
|
|
@@ -1801,6 +1924,13 @@ export const EDITOR_HTML = `<!DOCTYPE html>
|
|
|
1801
1924
|
});
|
|
1802
1925
|
|
|
1803
1926
|
canvas.addEventListener('mouseup', (e) => {
|
|
1927
|
+
// Clear camera rotation
|
|
1928
|
+
if (rotatingCamera) {
|
|
1929
|
+
setStatus('Camera direction updated', 'success');
|
|
1930
|
+
rotatingCamera = null;
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1804
1934
|
if (isDrawing && currentDrawing) {
|
|
1805
1935
|
if (!topology.drawings) topology.drawings = [];
|
|
1806
1936
|
// Normalize room coordinates if drawn backwards
|