/**
* Spaxel Floor Plan Setup Module
*
* Handles floor plan image upload, calibration UI, and applying
* pixel-to-meter scale and rotation to the 3D ground plane texture.
*/
(function() {
'use strict';
// Module state
const state = {
panelVisible: false,
calibration: null, // { ax, ay, bx, by, distance_m, rotation_deg, meters_per_pixel }
imageLoaded: false,
calibrating: false,
pointA: null, // { x, y } in image pixels
pointB: null, // { x, y } in image pixels
imageURL: null
};
// DOM elements cache
let elements = {};
/**
* Initialize the floor plan setup module.
*/
function init() {
console.log('[FloorPlan] Initializing');
createPanel();
loadExistingFloorplan();
}
/**
* Create the floor plan setup panel DOM.
*/
function createPanel() {
// Check if panel already exists
if (document.getElementById('floorplan-panel')) {
cacheElements();
return;
}
const panel = document.createElement('div');
panel.id = 'floorplan-panel';
panel.className = 'floorplan-panel';
panel.style.display = 'none';
panel.innerHTML = `
2. Calibrate Scale
Click two points on the image and enter the real-world distance between them
Click on point A in the image
Point A:
--
Point B:
--
Pixel distance:
-- px
Calibration Status
`;
document.body.appendChild(panel);
cacheElements();
attachEventListeners();
}
/**
* Cache DOM elements for faster access.
*/
function cacheElements() {
elements = {
panel: document.getElementById('floorplan-panel'),
fileInput: document.getElementById('floorplan-file-input'),
fileName: document.getElementById('floorplan-file-name'),
preview: document.getElementById('floorplan-preview'),
previewImg: document.getElementById('floorplan-preview-img'),
calibrationSection: document.getElementById('calibration-section'),
calibrationStatusSection: document.getElementById('calibration-status-section'),
imageWrapper: document.getElementById('floorplan-image-wrapper'),
canvas: document.getElementById('floorplan-canvas'),
markerA: document.getElementById('marker-a'),
markerB: document.getElementById('marker-b'),
instructions: document.getElementById('floorplan-instructions'),
pointsInfo: document.getElementById('floorplan-points-info'),
distanceInput: document.getElementById('floorplan-distance-input'),
realDistanceInput: document.getElementById('real-distance'),
pointACoords: document.getElementById('point-a-coords'),
pointBCoords: document.getElementById('point-b-coords'),
pixelDistance: document.getElementById('pixel-distance'),
btnReset: document.getElementById('btn-reset'),
btnSave: document.getElementById('btn-save'),
statusScale: document.getElementById('status-scale'),
statusRotation: document.getElementById('status-rotation')
};
}
/**
* Attach event listeners.
*/
function attachEventListeners() {
elements.fileInput.addEventListener('change', handleFileSelect);
elements.canvas.addEventListener('click', handleCanvasClick);
elements.realDistanceInput.addEventListener('input', handleDistanceInput);
}
/**
* Toggle panel visibility.
*/
function togglePanel() {
state.panelVisible = !state.panelVisible;
elements.panel.style.display = state.panelVisible ? 'block' : 'none';
if (state.panelVisible && state.imageLoaded) {
drawCanvas();
}
}
/**
* Load existing floor plan data from server.
*/
function loadExistingFloorplan() {
fetch('/api/floorplan')
.then(res => res.json())
.then(data => {
if (data.image_url) {
state.imageURL = data.image_url;
state.imageLoaded = true;
elements.previewImg.src = data.image_url;
elements.preview.style.display = 'block';
elements.calibrationSection.style.display = 'block';
// Load image for canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = function() {
state.imageElement = img;
if (state.panelVisible) drawCanvas();
};
img.src = data.image_url;
}
if (data.calibration) {
state.calibration = data.calibration;
updateCalibrationStatus();
elements.calibrationStatusSection.style.display = 'block';
elements.calibrationSection.style.display = 'none';
// Apply calibration to Viz3D
applyCalibrationTo3D();
}
})
.catch(err => {
console.error('[FloorPlan] Failed to load floor plan:', err);
});
}
/**
* Handle file selection.
*/
function handleFileSelect(e) {
const file = e.target.files[0];
if (!file) return;
elements.fileName.textContent = file.name;
// Upload to server
const formData = new FormData();
formData.append('file', file);
fetch('/api/floorplan/image', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(data => {
if (data.ok) {
state.imageURL = data.image_url;
state.imageLoaded = true;
elements.previewImg.src = data.image_url;
elements.preview.style.display = 'block';
elements.calibrationSection.style.display = 'block';
// Load image for canvas
const img = new Image();
img.onload = function() {
state.imageElement = img;
drawCanvas();
};
img.src = data.image_url;
// Also update Viz3D texture
if (window.Viz3D && window.Viz3D.uploadFloorPlan) {
window.Viz3D.uploadFloorPlan(file);
}
}
})
.catch(err => {
console.error('[FloorPlan] Upload failed:', err);
elements.fileName.textContent = 'Upload failed';
});
}
/**
* Draw the floor plan image on canvas.
*/
function drawCanvas() {
if (!state.imageElement || !elements.canvas) return;
const img = state.imageElement;
const canvas = elements.canvas;
const ctx = canvas.getContext('2d');
// Calculate dimensions to fit the wrapper
const wrapper = elements.imageWrapper;
const maxWidth = wrapper.clientWidth - 20;
const maxHeight = 400;
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
state.canvasScale = scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw existing calibration points if available
if (state.pointA) drawMarker(state.pointA, 'A');
if (state.pointB) drawMarker(state.pointB, 'B');
// Draw line if both points exist
if (state.pointA && state.pointB) {
ctx.strokeStyle = 'rgba(79, 195, 247, 0.7)';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(state.pointA.x, state.pointA.y);
ctx.lineTo(state.pointB.x, state.pointB.y);
ctx.stroke();
ctx.setLineDash([]);
}
}
/**
* Draw a calibration marker on canvas.
*/
function drawMarker(point, label) {
const ctx = elements.canvas.getContext('2d');
ctx.fillStyle = label === 'A' ? '#4fc3f7' : '#66bb6a';
ctx.beginPath();
ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, point.x, point.y);
}
/**
* Handle canvas click for calibration point selection.
*/
function handleCanvasClick(e) {
if (!state.imageLoaded) return;
const rect = elements.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (!state.pointA) {
state.pointA = { x, y };
elements.instructions.innerHTML = 'Click on point