|
@@ -46,6 +46,9 @@ let scene, camera, renderer, sphere, controls;
|
|
|
let raycaster = new THREE.Raycaster();
|
|
let raycaster = new THREE.Raycaster();
|
|
|
let mouse = new THREE.Vector2();
|
|
let mouse = new THREE.Vector2();
|
|
|
let markers = [];
|
|
let markers = [];
|
|
|
|
|
+let tempMarker = null;
|
|
|
|
|
+let previewSprites = [];
|
|
|
|
|
+let tempPreview = null;
|
|
|
let scanPollInterval = null;
|
|
let scanPollInterval = null;
|
|
|
let controlsGloballyDisabled = false;
|
|
let controlsGloballyDisabled = false;
|
|
|
|
|
|
|
@@ -156,6 +159,61 @@ function clearMarkers() {
|
|
|
markers = [];
|
|
markers = [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function clearTempMarker() {
|
|
|
|
|
+ if (tempMarker) {
|
|
|
|
|
+ scene.remove(tempMarker);
|
|
|
|
|
+ tempMarker = null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setTempMarker(pan, tilt) {
|
|
|
|
|
+ clearTempMarker();
|
|
|
|
|
+ tempMarker = addMarker(pan, tilt, 0x3b82f6);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearPreviewSprites() {
|
|
|
|
|
+ previewSprites.forEach(s => scene.remove(s));
|
|
|
|
|
+ previewSprites = [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function addPreviewPlane(pan, tilt, imageUrl) {
|
|
|
|
|
+ const loader = new THREE.TextureLoader();
|
|
|
|
|
+ const mesh = new THREE.Mesh(
|
|
|
|
|
+ new THREE.PlaneGeometry(1, 1),
|
|
|
|
|
+ new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.95 })
|
|
|
|
|
+ );
|
|
|
|
|
+ mesh.visible = false;
|
|
|
|
|
+ scene.add(mesh);
|
|
|
|
|
+ previewSprites.push(mesh);
|
|
|
|
|
+
|
|
|
|
|
+ loader.load(imageUrl, (texture) => {
|
|
|
|
|
+ const imageWidth = texture.image.width || 100;
|
|
|
|
|
+ const imageHeight = texture.image.height || 100;
|
|
|
|
|
+ const aspect = imageWidth / imageHeight;
|
|
|
|
|
+ const baseSize = 5;
|
|
|
|
|
+ mesh.geometry.dispose();
|
|
|
|
|
+ mesh.geometry = new THREE.PlaneGeometry(baseSize * aspect, baseSize);
|
|
|
|
|
+ mesh.material.map = texture;
|
|
|
|
|
+ mesh.material.needsUpdate = true;
|
|
|
|
|
+
|
|
|
|
|
+ const r = 46;
|
|
|
|
|
+ const phi = (90 - tilt) * Math.PI / 180;
|
|
|
|
|
+ const theta = (pan - 90) * Math.PI / 180;
|
|
|
|
|
+ const x = r * Math.sin(phi) * Math.cos(theta);
|
|
|
|
|
+ const y = r * Math.cos(phi);
|
|
|
|
|
+ const z = r * Math.sin(phi) * Math.sin(theta);
|
|
|
|
|
+ mesh.position.set(x, y, z);
|
|
|
|
|
+ // 让平面法线朝外(贴合球面)
|
|
|
|
|
+ mesh.lookAt(x * 2, y * 2, z * 2);
|
|
|
|
|
+ mesh.visible = true;
|
|
|
|
|
+ mesh.userData = { type: 'preview', pan, tilt, imageUrl };
|
|
|
|
|
+ }, undefined, (err) => {
|
|
|
|
|
+ log(`加载预览图失败: ${err.message || err}`);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return mesh;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function addMarker(pan, tilt, color = 0x22c55e) {
|
|
function addMarker(pan, tilt, color = 0x22c55e) {
|
|
|
const r = 49.5;
|
|
const r = 49.5;
|
|
|
const phi = (90 - tilt) * Math.PI / 180;
|
|
const phi = (90 - tilt) * Math.PI / 180;
|
|
@@ -185,7 +243,8 @@ function onPanoramaClick(event) {
|
|
|
const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI;
|
|
const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI;
|
|
|
document.getElementById('inp-pan').value = pan.toFixed(2);
|
|
document.getElementById('inp-pan').value = pan.toFixed(2);
|
|
|
document.getElementById('inp-tilt').value = tilt.toFixed(2);
|
|
document.getElementById('inp-tilt').value = tilt.toFixed(2);
|
|
|
- addMarker(pan, tilt, 0x3b82f6);
|
|
|
|
|
|
|
+ tempPreview = null;
|
|
|
|
|
+ setTempMarker(pan, tilt);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function animate() {
|
|
function animate() {
|
|
@@ -226,6 +285,9 @@ async function loadGroups() {
|
|
|
|
|
|
|
|
function onGroupChange() {
|
|
function onGroupChange() {
|
|
|
currentGroup = document.getElementById('group-select').value;
|
|
currentGroup = document.getElementById('group-select').value;
|
|
|
|
|
+ clearTempMarker();
|
|
|
|
|
+ clearPreviewSprites();
|
|
|
|
|
+ tempPreview = null;
|
|
|
loadPanorama(currentGroup);
|
|
loadPanorama(currentGroup);
|
|
|
renderVideos(currentGroup);
|
|
renderVideos(currentGroup);
|
|
|
loadPoints(currentGroup);
|
|
loadPoints(currentGroup);
|
|
@@ -256,36 +318,50 @@ async function loadPoints(groupId) {
|
|
|
try {
|
|
try {
|
|
|
const data = await API.get(`/api/points/${groupId}`);
|
|
const data = await API.get(`/api/points/${groupId}`);
|
|
|
clearMarkers();
|
|
clearMarkers();
|
|
|
|
|
+ clearPreviewSprites();
|
|
|
const ul = document.getElementById('points');
|
|
const ul = document.getElementById('points');
|
|
|
ul.innerHTML = '';
|
|
ul.innerHTML = '';
|
|
|
data.points.forEach(p => {
|
|
data.points.forEach(p => {
|
|
|
addMarker(p.pan, p.tilt, 0x22c55e);
|
|
addMarker(p.pan, p.tilt, 0x22c55e);
|
|
|
|
|
+ if (p.preview_image) {
|
|
|
|
|
+ addPreviewPlane(p.pan, p.tilt, `/api/preview-image?path=${encodeURIComponent(p.preview_image)}`);
|
|
|
|
|
+ }
|
|
|
const li = document.createElement('li');
|
|
const li = document.createElement('li');
|
|
|
|
|
|
|
|
const span = document.createElement('span');
|
|
const span = document.createElement('span');
|
|
|
span.textContent = `P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}`;
|
|
span.textContent = `P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}`;
|
|
|
|
|
|
|
|
- const btn = document.createElement('button');
|
|
|
|
|
- btn.dataset.id = String(p.id);
|
|
|
|
|
- btn.textContent = '删除';
|
|
|
|
|
- btn.onclick = async () => {
|
|
|
|
|
|
|
+ const previewBtn = document.createElement('button');
|
|
|
|
|
+ previewBtn.textContent = '预览';
|
|
|
|
|
+ previewBtn.onclick = async () => {
|
|
|
|
|
+ document.getElementById('inp-pan').value = p.pan.toFixed(2);
|
|
|
|
|
+ document.getElementById('inp-tilt').value = p.tilt.toFixed(2);
|
|
|
|
|
+ document.getElementById('inp-zoom').value = p.zoom;
|
|
|
|
|
+ await runPreview(groupId, p.pan, p.tilt, p.zoom, p.id);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const delBtn = document.createElement('button');
|
|
|
|
|
+ delBtn.dataset.id = String(p.id);
|
|
|
|
|
+ delBtn.textContent = '删除';
|
|
|
|
|
+ delBtn.onclick = async () => {
|
|
|
if (!currentGroup) {
|
|
if (!currentGroup) {
|
|
|
log('未选择摄像头组');
|
|
log('未选择摄像头组');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
if (!confirm('确定删除该扫描点?')) return;
|
|
if (!confirm('确定删除该扫描点?')) return;
|
|
|
- btn.disabled = true;
|
|
|
|
|
|
|
+ delBtn.disabled = true;
|
|
|
try {
|
|
try {
|
|
|
await API.del(`/api/points/${groupId}/${p.id}`);
|
|
await API.del(`/api/points/${groupId}/${p.id}`);
|
|
|
loadPoints(groupId);
|
|
loadPoints(groupId);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
log(`删除失败: ${e.message}`);
|
|
log(`删除失败: ${e.message}`);
|
|
|
- btn.disabled = false;
|
|
|
|
|
|
|
+ delBtn.disabled = false;
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
li.appendChild(span);
|
|
li.appendChild(span);
|
|
|
- li.appendChild(btn);
|
|
|
|
|
|
|
+ li.appendChild(previewBtn);
|
|
|
|
|
+ li.appendChild(delBtn);
|
|
|
ul.appendChild(li);
|
|
ul.appendChild(li);
|
|
|
});
|
|
});
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
@@ -384,6 +460,32 @@ document.getElementById('btn-poll-stop').addEventListener('click', async () => {
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+async function runPreview(groupId, pan, tilt, zoom, pointId = null) {
|
|
|
|
|
+ const payload = { pan, tilt, zoom };
|
|
|
|
|
+ if (pointId !== null) {
|
|
|
|
|
+ payload.point_id = pointId;
|
|
|
|
|
+ }
|
|
|
|
|
+ const result = await API.post(`/api/preview/${groupId}`, payload);
|
|
|
|
|
+ log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
|
|
|
|
|
+ if (result.snapshot_url) {
|
|
|
|
|
+ log(`预览抓拍已保存: ${result.snapshot_path}`);
|
|
|
|
|
+ if (pointId === null) {
|
|
|
|
|
+ // 未保存的临时点:记录本次预览图,保存时可关联
|
|
|
|
|
+ if (tempPreview && tempPreview.mesh) {
|
|
|
|
|
+ scene.remove(tempPreview.mesh);
|
|
|
|
|
+ previewSprites = previewSprites.filter(s => s !== tempPreview.mesh);
|
|
|
|
|
+ }
|
|
|
|
|
+ clearTempMarker();
|
|
|
|
|
+ const mesh = addPreviewPlane(pan, tilt, result.snapshot_url);
|
|
|
|
|
+ tempPreview = { pan, tilt, url: result.snapshot_url, path: result.snapshot_path, mesh };
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 已保存点:刷新列表以显示最新预览图
|
|
|
|
|
+ loadPoints(groupId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
document.getElementById('btn-preview').addEventListener('click', async () => {
|
|
document.getElementById('btn-preview').addEventListener('click', async () => {
|
|
|
if (!currentGroup) {
|
|
if (!currentGroup) {
|
|
|
log('未选择摄像头组');
|
|
log('未选择摄像头组');
|
|
@@ -402,9 +504,13 @@ document.getElementById('btn-preview').addEventListener('click', async () => {
|
|
|
log('错误:zoom 必须是大于等于 1 的整数');
|
|
log('错误:zoom 必须是大于等于 1 的整数');
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
|
|
|
|
|
- // Preview is implemented by the user visually confirming via the PTZ live stream window.
|
|
|
|
|
- // A future backend endpoint could move the PTZ to this exact point.
|
|
|
|
|
|
|
+ await withDisabled('btn-preview', async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await runPreview(currentGroup, pan, tilt, zoom);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ log(`预览失败: ${e.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
document.getElementById('btn-add').addEventListener('click', async () => {
|
|
document.getElementById('btn-add').addEventListener('click', async () => {
|
|
@@ -442,9 +548,14 @@ document.getElementById('btn-add').addEventListener('click', async () => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const payload = { pan, tilt, zoom, dwell_time: dwellTime };
|
|
const payload = { pan, tilt, zoom, dwell_time: dwellTime };
|
|
|
|
|
+ if (tempPreview) {
|
|
|
|
|
+ payload.preview_image = tempPreview.path;
|
|
|
|
|
+ }
|
|
|
try {
|
|
try {
|
|
|
await API.post(`/api/points/${currentGroup}`, payload);
|
|
await API.post(`/api/points/${currentGroup}`, payload);
|
|
|
log('扫描点已保存');
|
|
log('扫描点已保存');
|
|
|
|
|
+ clearTempMarker();
|
|
|
|
|
+ tempPreview = null;
|
|
|
loadPoints(currentGroup);
|
|
loadPoints(currentGroup);
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
log(`保存失败: ${e.message}`);
|
|
log(`保存失败: ${e.message}`);
|