/** * 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 = `