spaxel/dashboard/js/volume-editor.js
jedarden c817e96802 feat: implement repeated-setting change detection with guided calibration
Detects when user changes same config setting 3+ times within 24 hours.
Shows non-intrusive prompt offering help with guided calibration flow.

Guided calibration features:
- Test for false positives (walk around room)
- Test for missed motion (sit still)
- Suggest optimal value based on diurnal baseline SNR and link health
- Apply suggested value button

Files:
- dashboard/js/proactive.js: Complete implementation with localStorage tracking

Acceptance:
- Help prompt fires after 3+ changes in 24h
- Calibration flow tests both directions
- Suggests value based on system data
- Apply button works
2026-04-11 00:18:19 -04:00

560 lines
20 KiB
JavaScript

/**
* Spaxel Volume Editor - 3D trigger volume builder
*
* Provides interactive 3D volume creation and editing for automation triggers.
* Supports box and cylinder volumes with click-drag drawing and TransformControls.
*/
(function() {
'use strict';
// ── Module state ─────────────────────────────────────────────────────────────
let _scene = null;
let _camera = null;
let _controls = null;
let _renderer = null;
let _volumes = new Map(); // volume_id -> { mesh, shape, trigger }
let _editingVolume = null;
let _transformControls = null;
let _raycaster = new THREE.Raycaster();
let _mouse = new THREE.Vector2();
let _groundPlane = null;
let _drawMode = null; // 'box' | 'cylinder' | null
let _drawStart = null; // {x, z} for box start or cylinder center
let _drawPreview = null; // Preview mesh during drawing
let _heightSlider = null;
let _onVolumeCreated = null;
// Volume visualization materials
const VOLUME_MATERIALS = {
idle: new THREE.MeshBasicMaterial({
color: 0x4fc3f7,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
depthWrite: false
}),
active: new THREE.MeshBasicMaterial({
color: 0x4fc3f7,
transparent: true,
opacity: 0.25,
side: THREE.DoubleSide,
depthWrite: false
}),
edge: new THREE.LineBasicMaterial({
color: 0x4fc3f7,
transparent: true,
opacity: 0.8
}),
triggered: new THREE.MeshBasicMaterial({
color: 0xff9800, // Orange for triggered state
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide,
depthWrite: false
})
};
// ── Initialization ─────────────────────────────────────────────────────────────
function init(scene, camera, controls, renderer) {
_scene = scene;
_camera = camera;
_controls = controls;
_renderer = renderer;
// Create ground plane for raycasting
_createGroundPlane();
// Load TransformControls
_loadTransformControls();
// Setup event listeners
_setupEventListeners();
// Load existing volumes from state
_loadExistingVolumes();
// Subscribe to state changes
SpaxelState.subscribe('triggers', _onTriggersChanged);
console.log('[VolumeEditor] Initialized');
}
function _createGroundPlane() {
const groundGeo = new THREE.PlaneGeometry(100, 100);
const groundMat = new THREE.MeshBasicMaterial({ visible: false });
_groundPlane = new THREE.Mesh(groundGeo, groundMat);
_groundPlane.rotation.x = -Math.PI / 2;
_groundPlane.position.y = 0;
_scene.add(_groundPlane);
}
function _loadTransformControls() {
// Dynamically load TransformControls from the same CDN as Three.js
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js';
script.onload = () => {
if (typeof THREE.TransformControls !== 'undefined') {
_transformControls = new THREE.TransformControls(_camera, _renderer.domElement);
_transformControls.addEventListener('change', () => {
// Update volume shape based on transform
if (_editingVolume) {
_updateVolumeShapeFromTransform();
}
});
_transformControls.addEventListener('dragging-changed', (event) => {
_controls.enabled = !event.value;
});
_scene.add(_transformControls);
}
};
document.head.appendChild(script);
}
function _setupEventListeners() {
const canvas = _renderer.domElement;
canvas.addEventListener('pointerdown', _onPointerDown);
canvas.addEventListener('pointermove', _onPointerMove);
canvas.addEventListener('pointerup', _onPointerUp);
canvas.addEventListener('keydown', _onKeyDown);
}
function _loadExistingVolumes() {
const triggers = SpaxelState.triggers;
if (!triggers) return;
for (const id in triggers) {
const trigger = triggers[id];
if (trigger.shape_json) {
_createVolumeMesh(id, trigger.shape_json, trigger);
}
}
}
function _onTriggersChanged(triggers) {
// Reload volumes when triggers state changes
_clearAllVolumes();
_loadExistingVolumes();
}
// ── Volume creation ────────────────────────────────────────────────────────────────
function _createVolumeMesh(id, shape, trigger) {
let geometry, mesh, edges;
if (shape.type === 'box') {
geometry = new THREE.BoxGeometry(shape.w || 1, shape.h || 1, shape.d || 1);
mesh = new THREE.Mesh(geometry, VOLUME_MATERIALS.idle.clone());
mesh.position.set(
(shape.x || 0) + (shape.w || 1) / 2,
(shape.y || 0) + (shape.h || 1) / 2,
(shape.z || 0) + (shape.d || 1) / 2
);
} else if (shape.type === 'cylinder') {
geometry = new THREE.CylinderGeometry(
shape.r || 0.5,
shape.r || 0.5,
shape.h || 1,
32
);
mesh = new THREE.Mesh(geometry, VOLUME_MATERIALS.idle.clone());
mesh.position.set(
shape.cx || 0,
(shape.z || 0) + (shape.h || 1) / 2,
shape.cy || 0
);
} else {
console.warn('[VolumeEditor] Unknown shape type:', shape.type);
return;
}
mesh.userData.volumeId = id;
mesh.userData.shape = shape;
mesh.userData.trigger = trigger;
// Add edges
edges = new THREE.EdgesGeometry(geometry);
const line = new THREE.LineSegments(edges, VOLUME_MATERIALS.edge.clone());
mesh.add(line);
_scene.add(mesh);
_volumes.set(id, { mesh, shape, trigger, edges: line });
return mesh;
}
function _clearAllVolumes() {
_volumes.forEach((vol) => {
_scene.remove(vol.mesh);
vol.mesh.geometry.dispose();
});
_volumes.clear();
}
// ── Drawing interaction ────────────────────────────────────────────────────────────
function _onPointerDown(event) {
if (_drawMode === null || event.button !== 0) return;
// Calculate mouse position in normalized device coordinates
const rect = _renderer.domElement.getBoundingClientRect();
_mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
_raycaster.setFromCamera(_mouse, _camera);
const intersects = _raycaster.intersectObject(_groundPlane);
if (intersects.length > 0) {
const point = intersects[0].point;
_drawStart = { x: point.x, z: point.z };
if (_drawMode === 'box') {
// Create box preview
const boxGeo = new THREE.BoxGeometry(0.1, 0.1, 0.1);
_drawPreview = new THREE.Mesh(boxGeo, VOLUME_MATERIALS.active.clone());
_drawPreview.position.set(point.x, 0.05, point.z);
_scene.add(_drawPreview);
} else if (_drawMode === 'cylinder') {
// Create cylinder preview
const cylGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.1, 32);
_drawPreview = new THREE.Mesh(cylGeo, VOLUME_MATERIALS.active.clone());
_drawPreview.position.set(point.x, 0.05, point.z);
_scene.add(_drawPreview);
}
}
}
function _onPointerMove(event) {
if (!_drawStart || !_drawPreview) return;
const rect = _renderer.domElement.getBoundingClientRect();
_mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
_raycaster.setFromCamera(_mouse, _camera);
const intersects = _raycaster.intersectObject(_groundPlane);
if (intersects.length > 0) {
const point = intersects[0].point;
if (_drawMode === 'box') {
// Update box dimensions
const width = Math.abs(point.x - _drawStart.x);
const depth = Math.abs(point.z - _drawStart.z);
const height = 1.0; // Default height
_drawPreview.scale.set(width * 10, height * 10, depth * 10);
_drawPreview.position.set(
Math.min(point.x, _drawStart.x) + width / 2,
height / 2,
Math.min(point.z, _drawStart.z) + depth / 2
);
} else if (_drawMode === 'cylinder') {
// Update cylinder dimensions
const radius = Math.sqrt(
Math.pow(point.x - _drawStart.x, 2) +
Math.pow(point.z - _drawStart.z, 2)
);
const height = 1.0;
_drawPreview.scale.set(radius * 10, height * 10, radius * 10);
_drawPreview.position.set(
_drawStart.x,
height / 2,
_drawStart.z
);
}
}
}
function _onPointerUp(event) {
if (!_drawStart || !_drawPreview) return;
// Get final dimensions
const scale = _drawPreview.scale;
const pos = _drawPreview.position;
let shape;
if (_drawMode === 'box') {
shape = {
type: 'box',
x: pos.x - scale.x / 10,
y: pos.y - scale.y / 10,
z: pos.z - scale.z / 10,
w: scale.x / 5,
h: scale.y / 5,
d: scale.z / 5
};
} else if (_drawMode === 'cylinder') {
shape = {
type: 'cylinder',
cx: pos.x,
cy: pos.z,
z: pos.z - scale.y / 10,
r: scale.x / 10,
h: scale.y / 5
};
}
// Remove preview
_scene.remove(_drawPreview);
_drawPreview.geometry.dispose();
_drawPreview = null;
// Show height dialog
_showHeightDialog(shape);
// Reset draw state
_drawStart = null;
}
function _showHeightDialog(shape) {
const height = shape.h || 1.0;
// Create a simple dialog
const dialog = document.createElement('div');
dialog.className = 'volume-height-dialog';
dialog.innerHTML = `
<div class="dialog-content">
<h3>Set Volume Height</h3>
<label>Height (meters):</label>
<input type="range" id="volume-height-slider" min="0.1" max="5" step="0.1" value="${height}">
<span id="volume-height-value">${height.toFixed(1)}</span>
<div class="dialog-buttons">
<button id="volume-height-cancel" class="btn btn-secondary">Cancel</button>
<button id="volume-height-confirm" class="btn btn-primary">Create</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const slider = dialog.querySelector('#volume-height-slider');
const value = dialog.querySelector('#volume-height-value');
const cancel = dialog.querySelector('#volume-height-cancel');
const confirm = dialog.querySelector('#volume-height-confirm');
slider.oninput = () => {
value.textContent = parseFloat(slider.value).toFixed(1);
};
cancel.onclick = () => {
document.body.removeChild(dialog);
};
confirm.onclick = () => {
const newHeight = parseFloat(slider.value);
shape.h = newHeight;
document.body.removeChild(dialog);
// Create the volume
if (_onVolumeCreated) {
_onVolumeCreated(shape);
}
};
}
// ── Volume editing ────────────────────────────────────────────────────────────────
function startEditing(volumeId) {
const vol = _volumes.get(volumeId);
if (!vol) return;
_editingVolume = volumeId;
_transformControls.attach(vol.mesh);
_controls.enabled = false;
}
function stopEditing() {
if (_transformControls) {
_transformControls.detach();
}
_editingVolume = null;
_controls.enabled = true;
}
function _updateVolumeShapeFromTransform() {
if (!_editingVolume) return;
const vol = _volumes.get(_editingVolume);
if (!vol) return;
const mesh = vol.mesh;
const shape = vol.shape;
// Update shape based on mesh position and scale
if (shape.type === 'box') {
const scale = mesh.scale;
const pos = mesh.position;
shape.w = scale.x;
shape.h = scale.y;
shape.d = scale.z;
shape.x = pos.x - scale.x / 2;
shape.y = pos.y - scale.y / 2;
shape.z = pos.z - scale.z / 2;
} else if (shape.type === 'cylinder') {
const scale = mesh.scale;
const pos = mesh.position;
shape.r = scale.x;
shape.h = scale.y;
shape.cx = pos.x;
shape.cy = pos.z;
shape.z = pos.y - scale.y / 2;
}
// Update edges
if (vol.edges) {
mesh.remove(vol.edges);
vol.edges.geometry.dispose();
const edges = new THREE.EdgesGeometry(mesh.geometry);
vol.edges = new THREE.LineSegments(edges, VOLUME_MATERIALS.edge.clone());
mesh.add(vol.edges);
}
// Notify callback of shape change
if (_onVolumeChanged) {
_onVolumeChanged(_editingVolume, shape);
}
}
// ── Volume deletion ────────────────────────────────────────────────────────────────
function deleteVolume(volumeId) {
const vol = _volumes.get(volumeId);
if (!vol) return;
// Detach from transform controls if attached
if (_transformControls && _transformControls.object === vol.mesh) {
_transformControls.detach();
}
_scene.remove(vol.mesh);
vol.mesh.geometry.dispose();
_volumes.delete(volumeId);
// Notify callback
if (_onVolumeDeleted) {
_onVolumeDeleted(volumeId);
}
}
// ── Volume visualization ────────────────────────────────────────────────────────────
function setTriggerState(triggerId, state) {
const vol = _volumes.get(triggerId);
if (!vol) return;
const mesh = vol.mesh;
if (state === 'triggered') {
mesh.material = VOLUME_MATERIALS.triggered.clone();
// Pulse animation
_animatePulse(triggerId);
} else if (state === 'active') {
mesh.material = VOLUME_MATERIALS.active.clone();
} else {
mesh.material = VOLUME_MATERIALS.idle.clone();
}
}
function _animatePulse(triggerId) {
const vol = _volumes.get(triggerId);
if (!vol) return;
const mesh = vol.mesh;
const baseOpacity = 0.3;
const pulseDuration = 500; // ms
const startTime = Date.now();
function pulse() {
if (!_volumes.has(triggerId)) return;
const elapsed = Date.now() - startTime;
if (elapsed > pulseDuration * 2) {
// Reset to base state
mesh.material.opacity = baseOpacity;
setTriggerState(triggerId, 'idle');
return;
}
// Sine wave pulse
const progress = (elapsed % pulseDuration) / pulseDuration;
mesh.material.opacity = baseOpacity + Math.sin(progress * Math.PI) * 0.2;
requestAnimationFrame(pulse);
}
requestAnimationFrame(pulse);
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────────────
function _onKeyDown(event) {
if (event.key === 'Escape') {
if (_drawMode !== null) {
cancelDrawMode();
} else if (_editingVolume !== null) {
stopEditing();
}
} else if (event.key === 'Delete' || event.key === 'Backspace') {
if (_editingVolume !== null && document.activeElement.tagName !== 'INPUT') {
// Confirm deletion
if (confirm('Delete this volume?')) {
deleteVolume(_editingVolume);
stopEditing();
}
}
}
}
// ── Public API ────────────────────────────────────────────────────────────────────
function startDrawMode(mode) {
_drawMode = mode; // 'box' | 'cylinder'
_controls.enabled = false;
_renderer.domElement.style.cursor = 'crosshair';
}
function cancelDrawMode() {
_drawMode = null;
_drawStart = null;
if (_drawPreview) {
_scene.remove(_drawPreview);
_drawPreview = null;
}
_controls.enabled = true;
_renderer.domElement.style.cursor = 'default';
}
// ── Callbacks for integration ───────────────────────────────────────────────────────
function onVolumeCreated(callback) {
_onVolumeCreated = callback;
}
function onVolumeChanged(callback) {
_onVolumeChanged = callback;
}
function onVolumeDeleted(callback) {
_onVolumeDeleted = callback;
}
// Export public API
window.VolumeEditor = {
init,
startDrawMode,
cancelDrawMode,
startEditing,
stopEditing,
deleteVolume,
setTriggerState,
getVolumeMeshes: function() {
// Return array of trigger volume meshes for raycasting
const meshes = [];
_volumes.forEach(function(vol) {
meshes.push(vol.mesh);
});
return meshes;
},
onVolumeCreated,
onVolumeChanged,
onVolumeDeleted
};
console.log('[VolumeEditor] Module loaded');
})();