@blueharford/scrypted-spatial-awareness 0.5.9 → 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 +215 -29
- package/out/main.nodejs.js.map +1 -1
- package/out/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/main.ts +100 -29
- package/src/ui/editor-html.ts +133 -3
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/out/main.nodejs.js
CHANGED
|
@@ -40271,9 +40271,11 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40271
40271
|
if (suggestion.type === 'zone' && suggestion.zone) {
|
|
40272
40272
|
// Create a drawn zone from the discovery zone
|
|
40273
40273
|
const zone = suggestion.zone;
|
|
40274
|
-
// Find cameras that see this zone
|
|
40275
|
-
const
|
|
40276
|
-
const camera =
|
|
40274
|
+
// Find cameras that see this zone
|
|
40275
|
+
const sourceCameras = suggestion.sourceCameras || [];
|
|
40276
|
+
const camera = sourceCameras[0]
|
|
40277
|
+
? topology.cameras.find(c => c.deviceId === sourceCameras[0] || c.name === sourceCameras[0])
|
|
40278
|
+
: null;
|
|
40277
40279
|
// Create a default polygon near the camera or at a default location
|
|
40278
40280
|
let centerX = 300;
|
|
40279
40281
|
let centerY = 200;
|
|
@@ -40283,8 +40285,10 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40283
40285
|
}
|
|
40284
40286
|
// Create a rectangular zone (user can edit later)
|
|
40285
40287
|
const size = 100;
|
|
40288
|
+
const timestamp = Date.now();
|
|
40289
|
+
// 1. Create DrawnZone (visual on floor plan)
|
|
40286
40290
|
const drawnZone = {
|
|
40287
|
-
id: `zone_${
|
|
40291
|
+
id: `zone_${timestamp}`,
|
|
40288
40292
|
name: zone.name,
|
|
40289
40293
|
type: (zone.type || 'custom'),
|
|
40290
40294
|
description: zone.description,
|
|
@@ -40294,41 +40298,75 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40294
40298
|
{ x: centerX + size / 2, y: centerY + size / 2 },
|
|
40295
40299
|
{ x: centerX - size / 2, y: centerY + size / 2 },
|
|
40296
40300
|
],
|
|
40301
|
+
linkedCameras: sourceCameras,
|
|
40297
40302
|
};
|
|
40298
40303
|
if (!topology.drawnZones) {
|
|
40299
40304
|
topology.drawnZones = [];
|
|
40300
40305
|
}
|
|
40301
40306
|
topology.drawnZones.push(drawnZone);
|
|
40307
|
+
// 2. Create GlobalZone (for tracking/alerting)
|
|
40308
|
+
const globalZoneType = this.mapZoneTypeToGlobalType(zone.type);
|
|
40309
|
+
const cameraZones = sourceCameras
|
|
40310
|
+
.map(camRef => {
|
|
40311
|
+
const cam = topology.cameras.find(c => c.deviceId === camRef || c.name === camRef);
|
|
40312
|
+
if (!cam)
|
|
40313
|
+
return null;
|
|
40314
|
+
return {
|
|
40315
|
+
cameraId: cam.deviceId,
|
|
40316
|
+
zone: [[0, 0], [100, 0], [100, 100], [0, 100]], // Full frame default
|
|
40317
|
+
};
|
|
40318
|
+
})
|
|
40319
|
+
.filter((z) => z !== null);
|
|
40320
|
+
if (cameraZones.length > 0) {
|
|
40321
|
+
const globalZone = {
|
|
40322
|
+
id: `global_${timestamp}`,
|
|
40323
|
+
name: zone.name,
|
|
40324
|
+
type: globalZoneType,
|
|
40325
|
+
cameraZones,
|
|
40326
|
+
};
|
|
40327
|
+
if (!topology.globalZones) {
|
|
40328
|
+
topology.globalZones = [];
|
|
40329
|
+
}
|
|
40330
|
+
topology.globalZones.push(globalZone);
|
|
40331
|
+
this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone and GlobalZone created`);
|
|
40332
|
+
}
|
|
40333
|
+
else {
|
|
40334
|
+
this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type}) - DrawnZone only (no cameras matched)`);
|
|
40335
|
+
}
|
|
40302
40336
|
updated = true;
|
|
40303
|
-
this.console.log(`[Discovery] Added zone: ${zone.name} (${zone.type})`);
|
|
40304
40337
|
}
|
|
40305
40338
|
if (suggestion.type === 'connection' && suggestion.connection) {
|
|
40306
40339
|
// Add new connection to topology
|
|
40307
40340
|
const conn = suggestion.connection;
|
|
40308
|
-
//
|
|
40309
|
-
|
|
40310
|
-
const
|
|
40311
|
-
|
|
40312
|
-
|
|
40313
|
-
|
|
40314
|
-
|
|
40315
|
-
|
|
40316
|
-
|
|
40317
|
-
|
|
40318
|
-
this.console.
|
|
40341
|
+
// Resolve camera references - LLM may return names instead of deviceIds
|
|
40342
|
+
// Use case-insensitive matching for names
|
|
40343
|
+
const fromCamera = topology.cameras.find(c => c.deviceId === conn.fromCameraId ||
|
|
40344
|
+
c.name === conn.fromCameraId ||
|
|
40345
|
+
c.name.toLowerCase() === conn.fromCameraId.toLowerCase());
|
|
40346
|
+
const toCamera = topology.cameras.find(c => c.deviceId === conn.toCameraId ||
|
|
40347
|
+
c.name === conn.toCameraId ||
|
|
40348
|
+
c.name.toLowerCase() === conn.toCameraId.toLowerCase());
|
|
40349
|
+
// Don't create connection if cameras not found
|
|
40350
|
+
if (!fromCamera || !toCamera) {
|
|
40351
|
+
this.console.warn(`[Discovery] Cannot create connection: camera not found. from="${conn.fromCameraId}" to="${conn.toCameraId}"`);
|
|
40352
|
+
this.console.warn(`[Discovery] Available cameras: ${topology.cameras.map(c => `${c.name} (${c.deviceId})`).join(', ')}`);
|
|
40353
|
+
return;
|
|
40319
40354
|
}
|
|
40320
|
-
if
|
|
40321
|
-
|
|
40322
|
-
toCamera.
|
|
40323
|
-
|
|
40324
|
-
|
|
40325
|
-
|
|
40326
|
-
|
|
40355
|
+
// Check if connection already exists
|
|
40356
|
+
const existingConn = topology.connections.find(c => (c.fromCameraId === fromCamera.deviceId && c.toCameraId === toCamera.deviceId) ||
|
|
40357
|
+
(c.bidirectional && c.fromCameraId === toCamera.deviceId && c.toCameraId === fromCamera.deviceId));
|
|
40358
|
+
if (existingConn) {
|
|
40359
|
+
this.console.log(`[Discovery] Connection already exists between ${fromCamera.name} and ${toCamera.name}`);
|
|
40360
|
+
return;
|
|
40361
|
+
}
|
|
40362
|
+
// Warn if cameras don't have positions yet
|
|
40363
|
+
if (!fromCamera.floorPlanPosition || !toCamera.floorPlanPosition) {
|
|
40364
|
+
this.console.warn(`[Discovery] Note: One or both cameras not positioned on floor plan yet`);
|
|
40327
40365
|
}
|
|
40328
40366
|
const newConnection = {
|
|
40329
40367
|
id: `conn_${Date.now()}`,
|
|
40330
|
-
fromCameraId: fromCamera
|
|
40331
|
-
toCameraId: toCamera
|
|
40368
|
+
fromCameraId: fromCamera.deviceId,
|
|
40369
|
+
toCameraId: toCamera.deviceId,
|
|
40332
40370
|
bidirectional: conn.bidirectional,
|
|
40333
40371
|
// Default exit/entry zones covering full frame
|
|
40334
40372
|
exitZone: [[0, 0], [100, 0], [100, 100], [0, 100]],
|
|
@@ -40342,7 +40380,7 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40342
40380
|
};
|
|
40343
40381
|
topology.connections.push(newConnection);
|
|
40344
40382
|
updated = true;
|
|
40345
|
-
this.console.log(`[Discovery] Added connection: ${fromCamera
|
|
40383
|
+
this.console.log(`[Discovery] Added connection: ${fromCamera.name} (${fromCamera.deviceId}) -> ${toCamera.name} (${toCamera.deviceId})`);
|
|
40346
40384
|
}
|
|
40347
40385
|
if (updated) {
|
|
40348
40386
|
// Save updated topology
|
|
@@ -40350,6 +40388,24 @@ class SpatialAwarenessPlugin extends sdk_1.ScryptedDeviceBase {
|
|
|
40350
40388
|
this.trackingEngine.updateTopology(topology);
|
|
40351
40389
|
}
|
|
40352
40390
|
}
|
|
40391
|
+
/** Map discovered zone types to GlobalZone types for tracking/alerting */
|
|
40392
|
+
mapZoneTypeToGlobalType(type) {
|
|
40393
|
+
const mapping = {
|
|
40394
|
+
'yard': 'dwell',
|
|
40395
|
+
'driveway': 'entry',
|
|
40396
|
+
'street': 'entry',
|
|
40397
|
+
'patio': 'dwell',
|
|
40398
|
+
'walkway': 'entry',
|
|
40399
|
+
'parking': 'dwell',
|
|
40400
|
+
'garden': 'dwell',
|
|
40401
|
+
'pool': 'restricted',
|
|
40402
|
+
'entrance': 'entry',
|
|
40403
|
+
'garage': 'entry',
|
|
40404
|
+
'deck': 'dwell',
|
|
40405
|
+
'custom': 'dwell',
|
|
40406
|
+
};
|
|
40407
|
+
return mapping[type] || 'dwell';
|
|
40408
|
+
}
|
|
40353
40409
|
serveEditorUI(response) {
|
|
40354
40410
|
response.send(editor_html_1.EDITOR_HTML, {
|
|
40355
40411
|
headers: { 'Content-Type': 'text/html' },
|
|
@@ -41480,7 +41536,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
41480
41536
|
<button class="btn btn-small btn-primary" id="scan-now-btn" onclick="runDiscoveryScan()">Scan Now</button>
|
|
41481
41537
|
</div>
|
|
41482
41538
|
<div id="discovery-status" style="font-size: 11px; color: #888; margin-bottom: 8px;">
|
|
41483
|
-
<span id="discovery-status-text">
|
|
41539
|
+
<span id="discovery-status-text">Position cameras first, then scan</span>
|
|
41484
41540
|
</div>
|
|
41485
41541
|
<div id="discovery-suggestions-list"></div>
|
|
41486
41542
|
</div>
|
|
@@ -41513,6 +41569,7 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
41513
41569
|
</div>
|
|
41514
41570
|
<div class="toolbar-group">
|
|
41515
41571
|
<button class="btn" onclick="clearDrawings()">Clear Drawings</button>
|
|
41572
|
+
<button class="btn" onclick="clearAllTopology()" style="background: #dc2626;">Delete All</button>
|
|
41516
41573
|
</div>
|
|
41517
41574
|
<div class="toolbar-group">
|
|
41518
41575
|
<button class="btn btn-primary" onclick="saveTopology()">Save</button>
|
|
@@ -42015,6 +42072,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
42015
42072
|
let scanPollingInterval = null;
|
|
42016
42073
|
|
|
42017
42074
|
async function runDiscoveryScan() {
|
|
42075
|
+
// Check if cameras are positioned on the floor plan
|
|
42076
|
+
const positionedCameras = topology.cameras.filter(c => c.floorPlanPosition);
|
|
42077
|
+
if (positionedCameras.length === 0) {
|
|
42078
|
+
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');
|
|
42079
|
+
return;
|
|
42080
|
+
}
|
|
42081
|
+
|
|
42018
42082
|
const scanBtn = document.getElementById('scan-now-btn');
|
|
42019
42083
|
const statusText = document.getElementById('discovery-status-text');
|
|
42020
42084
|
scanBtn.disabled = true;
|
|
@@ -42622,6 +42686,29 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
42622
42686
|
function drawCamera(camera) {
|
|
42623
42687
|
const pos = camera.floorPlanPosition;
|
|
42624
42688
|
const isSelected = selectedItem?.type === 'camera' && selectedItem?.id === camera.deviceId;
|
|
42689
|
+
|
|
42690
|
+
// Get FOV settings or defaults
|
|
42691
|
+
const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
42692
|
+
const direction = (fov.mode === 'simple' || !fov.mode) ? (fov.direction || 0) : 0;
|
|
42693
|
+
const fovAngle = (fov.mode === 'simple' || !fov.mode) ? (fov.angle || 90) : 90;
|
|
42694
|
+
const range = (fov.mode === 'simple' || !fov.mode) ? (fov.range || 80) : 80;
|
|
42695
|
+
|
|
42696
|
+
// Convert direction to radians (0 = up/north, 90 = right/east)
|
|
42697
|
+
const dirRad = (direction - 90) * Math.PI / 180;
|
|
42698
|
+
const halfFov = (fovAngle / 2) * Math.PI / 180;
|
|
42699
|
+
|
|
42700
|
+
// Draw FOV cone
|
|
42701
|
+
ctx.beginPath();
|
|
42702
|
+
ctx.moveTo(pos.x, pos.y);
|
|
42703
|
+
ctx.arc(pos.x, pos.y, range, dirRad - halfFov, dirRad + halfFov);
|
|
42704
|
+
ctx.closePath();
|
|
42705
|
+
ctx.fillStyle = isSelected ? 'rgba(233, 69, 96, 0.15)' : 'rgba(76, 175, 80, 0.15)';
|
|
42706
|
+
ctx.fill();
|
|
42707
|
+
ctx.strokeStyle = isSelected ? 'rgba(233, 69, 96, 0.5)' : 'rgba(76, 175, 80, 0.5)';
|
|
42708
|
+
ctx.lineWidth = 1;
|
|
42709
|
+
ctx.stroke();
|
|
42710
|
+
|
|
42711
|
+
// Draw camera circle
|
|
42625
42712
|
ctx.beginPath();
|
|
42626
42713
|
ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
|
|
42627
42714
|
ctx.fillStyle = isSelected ? '#e94560' : '#0f3460';
|
|
@@ -42629,12 +42716,41 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
42629
42716
|
ctx.strokeStyle = '#fff';
|
|
42630
42717
|
ctx.lineWidth = 2;
|
|
42631
42718
|
ctx.stroke();
|
|
42719
|
+
|
|
42720
|
+
// Draw camera icon/text
|
|
42632
42721
|
ctx.fillStyle = '#fff';
|
|
42633
42722
|
ctx.font = '12px sans-serif';
|
|
42634
42723
|
ctx.textAlign = 'center';
|
|
42635
42724
|
ctx.textBaseline = 'middle';
|
|
42636
42725
|
ctx.fillText('CAM', pos.x, pos.y);
|
|
42637
42726
|
ctx.fillText(camera.name, pos.x, pos.y + 35);
|
|
42727
|
+
|
|
42728
|
+
// Draw direction handle (when selected) for rotation
|
|
42729
|
+
if (isSelected) {
|
|
42730
|
+
const handleLength = 45;
|
|
42731
|
+
const handleX = pos.x + Math.cos(dirRad) * handleLength;
|
|
42732
|
+
const handleY = pos.y + Math.sin(dirRad) * handleLength;
|
|
42733
|
+
|
|
42734
|
+
// Handle line
|
|
42735
|
+
ctx.beginPath();
|
|
42736
|
+
ctx.moveTo(pos.x + Math.cos(dirRad) * 20, pos.y + Math.sin(dirRad) * 20);
|
|
42737
|
+
ctx.lineTo(handleX, handleY);
|
|
42738
|
+
ctx.strokeStyle = '#ff6b6b';
|
|
42739
|
+
ctx.lineWidth = 3;
|
|
42740
|
+
ctx.stroke();
|
|
42741
|
+
|
|
42742
|
+
// Handle grip (circle at end)
|
|
42743
|
+
ctx.beginPath();
|
|
42744
|
+
ctx.arc(handleX, handleY, 8, 0, Math.PI * 2);
|
|
42745
|
+
ctx.fillStyle = '#ff6b6b';
|
|
42746
|
+
ctx.fill();
|
|
42747
|
+
ctx.strokeStyle = '#fff';
|
|
42748
|
+
ctx.lineWidth = 2;
|
|
42749
|
+
ctx.stroke();
|
|
42750
|
+
|
|
42751
|
+
// Store handle position for hit detection
|
|
42752
|
+
camera._handlePos = { x: handleX, y: handleY };
|
|
42753
|
+
}
|
|
42638
42754
|
}
|
|
42639
42755
|
|
|
42640
42756
|
function drawConnection(from, to, conn) {
|
|
@@ -42895,7 +43011,16 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
42895
43011
|
|
|
42896
43012
|
function showCameraProperties(camera) {
|
|
42897
43013
|
const panel = document.getElementById('properties-panel');
|
|
42898
|
-
|
|
43014
|
+
const fov = camera.fov || { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
43015
|
+
panel.innerHTML = '<h3>Camera Properties</h3>' +
|
|
43016
|
+
'<div class="form-group"><label>Name</label><input type="text" value="' + camera.name + '" onchange="updateCameraName(\\'' + camera.deviceId + '\\', this.value)"></div>' +
|
|
43017
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isEntryPoint ? 'checked' : '') + ' onchange="updateCameraEntry(\\'' + camera.deviceId + '\\', this.checked)">Entry Point</label></div>' +
|
|
43018
|
+
'<div class="form-group"><label class="checkbox-group"><input type="checkbox" ' + (camera.isExitPoint ? 'checked' : '') + ' onchange="updateCameraExit(\\'' + camera.deviceId + '\\', this.checked)">Exit Point</label></div>' +
|
|
43019
|
+
'<h4 style="margin-top: 15px; margin-bottom: 10px; color: #888;">Field of View</h4>' +
|
|
43020
|
+
'<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>' +
|
|
43021
|
+
'<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>' +
|
|
43022
|
+
'<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>' +
|
|
43023
|
+
'<div class="form-group"><button class="btn" style="width: 100%; background: #f44336;" onclick="deleteCamera(\\'' + camera.deviceId + '\\')">Delete Camera</button></div>';
|
|
42899
43024
|
}
|
|
42900
43025
|
|
|
42901
43026
|
function showConnectionProperties(connection) {
|
|
@@ -42906,6 +43031,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
42906
43031
|
function updateCameraName(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.name = value; updateUI(); }
|
|
42907
43032
|
function updateCameraEntry(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isEntryPoint = value; }
|
|
42908
43033
|
function updateCameraExit(id, value) { const camera = topology.cameras.find(c => c.deviceId === id); if (camera) camera.isExitPoint = value; }
|
|
43034
|
+
function updateCameraFov(id, field, value) {
|
|
43035
|
+
const camera = topology.cameras.find(c => c.deviceId === id);
|
|
43036
|
+
if (!camera) return;
|
|
43037
|
+
if (!camera.fov) camera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
43038
|
+
camera.fov[field] = parseFloat(value);
|
|
43039
|
+
render();
|
|
43040
|
+
}
|
|
42909
43041
|
function updateConnectionName(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.name = value; updateUI(); }
|
|
42910
43042
|
function updateTransitTime(id, field, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.transitTime[field] = parseInt(value) * 1000; }
|
|
42911
43043
|
function updateConnectionBidi(id, value) { const conn = topology.connections.find(c => c.id === id); if (conn) conn.bidirectional = value; render(); }
|
|
@@ -43049,10 +43181,30 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
43049
43181
|
setStatus('Drawings cleared', 'success');
|
|
43050
43182
|
}
|
|
43051
43183
|
|
|
43184
|
+
function clearAllTopology() {
|
|
43185
|
+
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;
|
|
43186
|
+
|
|
43187
|
+
topology.cameras = [];
|
|
43188
|
+
topology.connections = [];
|
|
43189
|
+
topology.landmarks = [];
|
|
43190
|
+
topology.globalZones = [];
|
|
43191
|
+
topology.drawnZones = [];
|
|
43192
|
+
topology.drawings = [];
|
|
43193
|
+
topology.relationships = [];
|
|
43194
|
+
|
|
43195
|
+
selectedItem = null;
|
|
43196
|
+
document.getElementById('properties-panel').innerHTML = '<h3>Properties</h3><p style="color: #666;">Select an item to edit.</p>';
|
|
43197
|
+
updateCameraSelects();
|
|
43198
|
+
updateUI();
|
|
43199
|
+
render();
|
|
43200
|
+
setStatus('All topology data cleared', 'warning');
|
|
43201
|
+
}
|
|
43202
|
+
|
|
43052
43203
|
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
|
43053
43204
|
function setStatus(text, type) { document.getElementById('status-text').textContent = text; const dot = document.getElementById('status-dot'); dot.className = 'status-dot'; if (type === 'warning') dot.classList.add('warning'); if (type === 'error') dot.classList.add('error'); }
|
|
43054
43205
|
|
|
43055
43206
|
let dragging = null;
|
|
43207
|
+
let rotatingCamera = null;
|
|
43056
43208
|
|
|
43057
43209
|
canvas.addEventListener('mousedown', (e) => {
|
|
43058
43210
|
const rect = canvas.getBoundingClientRect();
|
|
@@ -43068,7 +43220,20 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
43068
43220
|
}
|
|
43069
43221
|
|
|
43070
43222
|
if (currentTool === 'select') {
|
|
43071
|
-
// Check
|
|
43223
|
+
// Check for rotation handle on selected camera first
|
|
43224
|
+
if (selectedItem?.type === 'camera') {
|
|
43225
|
+
const camera = topology.cameras.find(c => c.deviceId === selectedItem.id);
|
|
43226
|
+
if (camera?._handlePos) {
|
|
43227
|
+
const dist = Math.hypot(x - camera._handlePos.x, y - camera._handlePos.y);
|
|
43228
|
+
if (dist < 15) {
|
|
43229
|
+
rotatingCamera = camera;
|
|
43230
|
+
setStatus('Drag to rotate camera direction', 'warning');
|
|
43231
|
+
return;
|
|
43232
|
+
}
|
|
43233
|
+
}
|
|
43234
|
+
}
|
|
43235
|
+
|
|
43236
|
+
// Check cameras
|
|
43072
43237
|
for (const camera of topology.cameras) {
|
|
43073
43238
|
if (camera.floorPlanPosition) {
|
|
43074
43239
|
const dist = Math.hypot(x - camera.floorPlanPosition.x, y - camera.floorPlanPosition.y);
|
|
@@ -43132,6 +43297,20 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
43132
43297
|
const x = e.clientX - rect.left;
|
|
43133
43298
|
const y = e.clientY - rect.top;
|
|
43134
43299
|
|
|
43300
|
+
// Handle camera rotation
|
|
43301
|
+
if (rotatingCamera) {
|
|
43302
|
+
const pos = rotatingCamera.floorPlanPosition;
|
|
43303
|
+
const angle = Math.atan2(y - pos.y, x - pos.x);
|
|
43304
|
+
// Convert to our direction system (0 = up/north, 90 = right/east)
|
|
43305
|
+
const direction = (angle * 180 / Math.PI) + 90;
|
|
43306
|
+
if (!rotatingCamera.fov) {
|
|
43307
|
+
rotatingCamera.fov = { mode: 'simple', angle: 90, direction: 0, range: 80 };
|
|
43308
|
+
}
|
|
43309
|
+
rotatingCamera.fov.direction = ((direction % 360) + 360) % 360; // Normalize 0-360
|
|
43310
|
+
render();
|
|
43311
|
+
return;
|
|
43312
|
+
}
|
|
43313
|
+
|
|
43135
43314
|
if (dragging) {
|
|
43136
43315
|
if (dragging.type === 'camera') {
|
|
43137
43316
|
dragging.item.floorPlanPosition.x = x;
|
|
@@ -43154,6 +43333,13 @@ exports.EDITOR_HTML = `<!DOCTYPE html>
|
|
|
43154
43333
|
});
|
|
43155
43334
|
|
|
43156
43335
|
canvas.addEventListener('mouseup', (e) => {
|
|
43336
|
+
// Clear camera rotation
|
|
43337
|
+
if (rotatingCamera) {
|
|
43338
|
+
setStatus('Camera direction updated', 'success');
|
|
43339
|
+
rotatingCamera = null;
|
|
43340
|
+
return;
|
|
43341
|
+
}
|
|
43342
|
+
|
|
43157
43343
|
if (isDrawing && currentDrawing) {
|
|
43158
43344
|
if (!topology.drawings) topology.drawings = [];
|
|
43159
43345
|
// Normalize room coordinates if drawn backwards
|