// ============================================================ // DASHBOARD - Variables & State // ============================================================ var ws = null; var gsGrid = null; var isLocked = false; var state = { connected: false, cameras: null, currentCamera: null, items: null, currentItem: null, events: null, currentEvent: null, avatars: null, currentAvatar: null, system: null }; var statusInterval = null; var pingInterval = null; var STORAGE_KEY = 'streamingle-dashboard-layout'; var currentTab = 'dashboard'; // ============================================================ // RETARGETING - Variables & State // ============================================================ var rtWs = null; var rtCharactersData = {}; var rtPresets = []; var rtConnected = false; var rtInitialized = false; // ============================================================ // TAB SWITCHING // ============================================================ function switchTab(tab) { currentTab = tab; // Toggle tab buttons document.getElementById('tabBtnDashboard').className = 'tab-btn' + (tab === 'dashboard' ? ' active' : ''); document.getElementById('tabBtnRetargeting').className = 'tab-btn' + (tab === 'retargeting' ? ' active' : ''); // Toggle tab content document.getElementById('tabDashboard').className = 'tab-content' + (tab === 'dashboard' ? ' active' : ''); document.getElementById('tabRetargeting').className = 'tab-content' + (tab === 'retargeting' ? ' active' : ''); // Toggle header controls document.getElementById('dashboardControls').style.display = (tab === 'dashboard') ? '' : 'none'; document.getElementById('retargetingControls').style.display = (tab === 'retargeting') ? '' : 'none'; // Lazy connect retargeting WS if (tab === 'retargeting' && !rtInitialized) { rtInitialized = true; rtConnectWebSocket(); } } // ============================================================ // DASHBOARD - Widget Definitions // ============================================================ var WIDGET_DEFS = { cameras: { title: 'Cameras', badgeId: 'camera-badge', defaultPos: { x: 0, y: 0, w: 6, h: 4, minW: 3, minH: 2 }, content: function() { return '
'; } }, items: { title: 'Items', badgeId: 'item-badge', defaultPos: { x: 6, y: 0, w: 6, h: 4, minW: 3, minH: 2 }, content: function() { return '
' + '
' + '' + '' + '
'; } }, events: { title: 'Events', badgeId: 'event-badge', defaultPos: { x: 0, y: 4, w: 6, h: 3, minW: 3, minH: 2 }, content: function() { return '
'; } }, avatars: { title: 'Avatars', badgeId: 'avatar-badge', defaultPos: { x: 6, y: 4, w: 6, h: 4, minW: 3, minH: 2 }, content: function() { return '
'; } }, system: { title: 'System', badgeId: null, defaultPos: { x: 0, y: 8, w: 12, h: 5, minW: 4, minH: 3 }, content: function() { return '
' + '
Screenshot
' + '
' + '' + '' + '' + '
' + '
' + '
Motion Recording
' + '
' + '' + '' + '
' + '
' + '
Reconnect
' + '
' + '' + '' + '
' + '
' + '
Cloth / Markers
' + '
' + '' + '' + '
' + '
' + '
Retargeting Remote
' + '
' + '' + '' + '
'; } } }; var PANEL_ORDER = ['cameras', 'items', 'events', 'avatars', 'system']; // ============================================================ // DASHBOARD - GridStack Init // ============================================================ function initGrid() { gsGrid = GridStack.init({ column: 12, cellHeight: 70, margin: 6, animate: true, float: false, handle: '.widget-header', disableResize: false, disableDrag: false }, '#dashboardGrid'); var saved = loadLayout(); if (saved && saved.items && saved.items.length > 0) { for (var i = 0; i < saved.items.length; i++) { var item = saved.items[i]; if (WIDGET_DEFS[item.id]) { addWidget(item.id, item); } } if (saved.locked) { toggleLock(); } } else { for (var j = 0; j < PANEL_ORDER.length; j++) { var id = PANEL_ORDER[j]; addWidget(id, WIDGET_DEFS[id].defaultPos); } } gsGrid.on('change', function() { saveLayout(); }); var updateColumns = function() { if (!gsGrid) return; gsGrid.column(window.innerWidth <= 768 ? 1 : 12); }; updateColumns(); window.addEventListener('resize', updateColumns); window.addEventListener('orientationchange', function() { setTimeout(updateColumns, 150); }); } function addWidget(id, pos) { var def = WIDGET_DEFS[id]; if (!def) return; var badgeHtml = def.badgeId ? ' 0' : ''; var contentHtml = '
' + '
' + '' + def.title + badgeHtml + '' + '' + '
' + '
' + def.content() + '
' + '
'; gsGrid.addWidget({ id: id, x: pos.x, y: pos.y, w: pos.w, h: pos.h, minW: def.defaultPos.minW, minH: def.defaultPos.minH, content: contentHtml }); } // ============================================================ // DASHBOARD - Layout Persistence // ============================================================ function saveLayout() { if (!gsGrid) return; var items = gsGrid.save(false); var data = { items: items, locked: isLocked }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch(e) {} } function loadLayout() { try { var raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch(e) { return null; } } function resetLayout() { localStorage.removeItem(STORAGE_KEY); if (isLocked) toggleLock(); gsGrid.removeAll(); for (var i = 0; i < PANEL_ORDER.length; i++) { addWidget(PANEL_ORDER[i], WIDGET_DEFS[PANEL_ORDER[i]].defaultPos); } saveLayout(); renderAll(); hideSettings(); showToast('레이아웃 초기화됨', 'success'); } // ============================================================ // DASHBOARD - Panel Show/Hide // ============================================================ function hidePanel(id) { var el = document.querySelector('.grid-stack-item[gs-id="' + id + '"]'); if (el) gsGrid.removeWidget(el); saveLayout(); updateSettingsToggles(); } function showPanel(id) { if (document.querySelector('.grid-stack-item[gs-id="' + id + '"]')) return; addWidget(id, WIDGET_DEFS[id].defaultPos); rerenderWidget(id); saveLayout(); updateSettingsToggles(); } function getVisiblePanels() { if (!gsGrid) return []; var items = gsGrid.save(false); return items.map(function(item) { return item.id; }); } function rerenderWidget(id) { switch(id) { case 'cameras': renderCameras(); break; case 'items': renderItems(); break; case 'events': renderEvents(); break; case 'avatars': renderAvatars(); break; case 'system': renderHealth(); break; } } // ============================================================ // DASHBOARD - Lock/Unlock // ============================================================ function toggleLock() { isLocked = !isLocked; if (isLocked) { gsGrid.disable(); } else { gsGrid.enable(); } var btn = document.getElementById('btnLock'); btn.innerHTML = isLocked ? '🔒' : '🔓'; btn.className = 'btn-icon' + (isLocked ? ' locked' : ''); saveLayout(); } // ============================================================ // DASHBOARD - Settings Panel // ============================================================ function showSettings() { var body = document.getElementById('settingsBody'); body.innerHTML = ''; var visible = getVisiblePanels(); for (var i = 0; i < PANEL_ORDER.length; i++) { var id = PANEL_ORDER[i]; var def = WIDGET_DEFS[id]; var label = document.createElement('label'); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = visible.indexOf(id) !== -1; cb.setAttribute('data-panel', id); cb.onchange = function() { var panelId = this.getAttribute('data-panel'); if (this.checked) { showPanel(panelId); } else { hidePanel(panelId); } }; label.appendChild(cb); label.appendChild(document.createTextNode(def.title)); body.appendChild(label); } document.getElementById('settingsOverlay').classList.add('show'); } function hideSettings() { document.getElementById('settingsOverlay').classList.remove('show'); } function onSettingsOverlayClick(e) { if (e.target === document.getElementById('settingsOverlay')) { hideSettings(); } } function updateSettingsToggles() { var overlay = document.getElementById('settingsOverlay'); if (!overlay.classList.contains('show')) return; var visible = getVisiblePanels(); var checkboxes = overlay.querySelectorAll('input[data-panel]'); for (var i = 0; i < checkboxes.length; i++) { checkboxes[i].checked = visible.indexOf(checkboxes[i].getAttribute('data-panel')) !== -1; } } // ============================================================ // DASHBOARD - WebSocket // ============================================================ function connectWebSocket() { var wsUrl = 'ws://' + window.location.hostname + ':{{WS_PORT}}/'; ws = new WebSocket(wsUrl); ws.onopen = function() { state.connected = true; updateConnectionStatus(true); startPolling(); }; ws.onclose = function() { state.connected = false; updateConnectionStatus(false); stopPolling(); setTimeout(connectWebSocket, 3000); }; ws.onerror = function(e) { console.error('WS Error:', e); }; ws.onmessage = function(e) { try { handleMessage(JSON.parse(e.data)); } catch(err) { console.error('Parse error:', err); } }; } function send(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } } function startPolling() { stopPolling(); statusInterval = setInterval(function() { send({ type: 'get_system_status' }); }, 5000); pingInterval = setInterval(function() { send({ type: 'ping' }); }, 30000); send({ type: 'get_system_status' }); } function stopPolling() { if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } } // ============================================================ // DASHBOARD - Connection Status // ============================================================ function updateConnectionStatus(c) { var s = document.getElementById('connectionStatus'); s.textContent = c ? '연결됨' : '연결 끊김'; s.className = 'connection-status ' + (c ? 'connected' : 'disconnected'); } // ============================================================ // DASHBOARD - Message Handling // ============================================================ function handleMessage(msg) { switch (msg.type) { case 'connection_established': handleInitialState(msg.data); break; case 'camera_changed': state.cameras = msg.data.camera_data; state.currentCamera = msg.data.current_camera; renderCameras(); break; case 'item_changed': state.items = msg.data.item_data; state.currentItem = msg.data.current_item; renderItems(); break; case 'event_changed': state.events = msg.data.event_data; state.currentEvent = msg.data.current_event; renderEvents(); break; case 'avatar_outfit_changed': state.avatars = msg.data.avatar_outfit_data; state.currentAvatar = msg.data.current_avatar_outfit; renderAvatars(); break; case 'system_status_response': state.system = msg.data; renderHealth(); break; case 'pong': break; case 'drone_state_response': showToast(msg.data.is_drone_mode ? 'Drone Mode ON' : 'Drone Mode OFF', 'success'); break; } } function handleInitialState(data) { state.cameras = data.camera_data; state.currentCamera = data.current_camera; state.items = data.item_data; state.currentItem = data.current_item; state.events = data.event_data; state.currentEvent = data.current_event; state.avatars = data.avatar_outfit_data; state.currentAvatar = data.current_avatar_outfit; renderAll(); showToast('연결됨', 'success'); } // ============================================================ // DASHBOARD - Render Functions // ============================================================ function renderAll() { renderCameras(); renderItems(); renderEvents(); renderAvatars(); renderHealth(); } function renderCameras() { var grid = document.getElementById('cameraGrid'); var badge = document.getElementById('camera-badge'); if (!grid) return; if (!state.cameras || !state.cameras.presets) { grid.innerHTML = '
카메라 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.cameras.camera_count; grid.innerHTML = ''; for (var i = 0; i < state.cameras.presets.length; i++) { var p = state.cameras.presets[i]; var btn = document.createElement('button'); btn.className = 'preset-btn' + (p.isActive ? ' active' : ''); btn.textContent = p.name; btn.setAttribute('data-index', p.index); btn.onclick = function() { send({ type: 'switch_camera', data: { camera_index: parseInt(this.getAttribute('data-index')) } }); }; grid.appendChild(btn); } } function renderItems() { var grid = document.getElementById('itemGrid'); var badge = document.getElementById('item-badge'); if (!grid) return; if (!state.items || !state.items.items) { grid.innerHTML = '
아이템 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.items.item_count; grid.innerHTML = ''; for (var i = 0; i < state.items.items.length; i++) { var item = state.items.items[i]; var btn = document.createElement('button'); btn.className = 'preset-btn' + (item.isActive ? ' item-active' : ''); btn.textContent = (item.isActive ? '\u2611 ' : '\u2610 ') + item.name; btn.setAttribute('data-index', item.index); btn.onclick = function() { send({ type: 'toggle_item', data: { item_index: parseInt(this.getAttribute('data-index')) } }); }; grid.appendChild(btn); } } function renderEvents() { var grid = document.getElementById('eventGrid'); var badge = document.getElementById('event-badge'); if (!grid) return; if (!state.events || !state.events.events) { grid.innerHTML = '
이벤트 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.events.event_count; grid.innerHTML = ''; for (var i = 0; i < state.events.events.length; i++) { var evt = state.events.events[i]; var btn = document.createElement('button'); btn.className = 'preset-btn event-btn'; btn.textContent = '\u25B6 ' + evt.name; btn.setAttribute('data-index', evt.index); btn.onclick = function() { var idx = parseInt(this.getAttribute('data-index')); send({ type: 'execute_event', data: { event_index: idx } }); showToast('Event: ' + this.textContent.substring(2), 'success'); }; grid.appendChild(btn); } } function renderAvatars() { var container = document.getElementById('avatarContainer'); var badge = document.getElementById('avatar-badge'); if (!container) return; if (!state.avatars || !state.avatars.avatars) { container.innerHTML = '
아바타 없음
'; if (badge) badge.textContent = '0'; return; } if (badge) badge.textContent = state.avatars.avatar_count; container.innerHTML = ''; for (var a = 0; a < state.avatars.avatars.length; a++) { var avatar = state.avatars.avatars[a]; var group = document.createElement('div'); group.className = 'avatar-group'; var nameDiv = document.createElement('div'); nameDiv.className = 'avatar-name'; nameDiv.textContent = avatar.name; group.appendChild(nameDiv); var outfitGrid = document.createElement('div'); outfitGrid.className = 'outfit-grid'; if (avatar.outfits) { for (var o = 0; o < avatar.outfits.length; o++) { var outfit = avatar.outfits[o]; var btn = document.createElement('button'); btn.className = 'preset-btn' + (outfit.index === avatar.current_outfit_index ? ' active' : ''); btn.textContent = outfit.name; btn.setAttribute('data-avatar', avatar.index); btn.setAttribute('data-outfit', outfit.index); btn.onclick = function() { send({ type: 'set_avatar_outfit', data: { avatar_index: parseInt(this.getAttribute('data-avatar')), outfit_index: parseInt(this.getAttribute('data-outfit')) } }); }; outfitGrid.appendChild(btn); } } group.appendChild(outfitGrid); container.appendChild(group); } } function renderHealth() { if (!state.system) return; var s = state.system; setHealth('health-optitrack', s.optitrack && s.optitrack.connected, 'OptiTrack'); var facialCount = (s.facial_motion && s.facial_motion.client_count) || 0; setHealth('health-facial', facialCount > 0, 'Facial(' + facialCount + ')'); var isRec = s.recording && s.recording.is_recording; var recEl = document.getElementById('health-recording'); if (recEl) { var dot = recEl.querySelector('.health-dot'); dot.className = 'health-dot ' + (isRec ? 'recording' : 'offline'); recEl.querySelector('span:last-child').textContent = isRec ? 'REC' : 'IDLE'; } var remoteRunning = s.retargeting_remote && s.retargeting_remote.is_running; setHealth('health-remote', remoteRunning, 'Remote'); var clientCount = (s.websocket && s.websocket.connected_clients) || 0; setHealth('health-clients', true, 'WS(' + clientCount + ')'); var btnRec = document.getElementById('btnStartRec'); if (btnRec) { btnRec.textContent = isRec ? 'Recording...' : 'Start Rec'; btnRec.className = isRec ? 'btn btn-danger btn-sm' : 'btn btn-primary btn-sm'; } } function setHealth(elementId, online, label) { var el = document.getElementById(elementId); if (!el) return; var dot = el.querySelector('.health-dot'); dot.className = 'health-dot ' + (online ? 'online' : 'offline'); el.querySelector('span:last-child').textContent = label; } // ============================================================ // DASHBOARD - Commands // ============================================================ function sendCommand(cmd) { send({ type: cmd }); showToast(cmd.replace(/_/g, ' '), 'success'); } function requestFullState() { send({ type: 'get_full_state' }); send({ type: 'get_system_status' }); showToast('Refreshing...', 'success'); } // ============================================================ // SHARED - Toast // ============================================================ function showToast(msg, type) { var t = document.getElementById('toast'); t.textContent = msg; t.className = 'toast show ' + (type || ''); setTimeout(function() { t.className = 'toast'; }, 2000); } // ============================================================ // RETARGETING - WebSocket // ============================================================ function rtConnectWebSocket() { var rtWsPort = '{{RETARGETING_WS_PORT}}'; if (!rtWsPort || rtWsPort === '0') { rtUpdateConnectionStatus(false, '리타게팅 포트 미설정'); return; } var wsUrl = 'ws://' + window.location.hostname + ':' + rtWsPort + '/retargeting'; rtWs = new WebSocket(wsUrl); rtWs.onopen = function() { rtConnected = true; rtUpdateConnectionStatus(true); rtSend({ action: 'refresh' }); }; rtWs.onclose = function() { rtConnected = false; rtUpdateConnectionStatus(false); setTimeout(function() { if (rtInitialized) rtConnectWebSocket(); }, 3000); }; rtWs.onerror = function(e) { console.error('RT WS Error:', e); }; rtWs.onmessage = function(e) { try { rtHandleMessage(JSON.parse(e.data)); } catch(err) { console.error('RT Parse error:', err); } }; } function rtSend(data) { if (rtWs && rtWs.readyState === WebSocket.OPEN) { rtWs.send(JSON.stringify(data)); } } function rtUpdateConnectionStatus(c, msg) { var s = document.getElementById('rtConnectionStatus'); if (!s) return; s.textContent = msg || (c ? '리타게팅 연결됨' : '리타게팅 연결 끊김'); s.className = 'rt-connection-status ' + (c ? 'connected' : 'disconnected'); } // ============================================================ // RETARGETING - Message Handling // ============================================================ function rtHandleMessage(d) { if (d.type === 'characterList') rtUpdateCharacterList(d.characters); else if (d.type === 'characterData') rtUpdateCharacterData(d); else if (d.type === 'handPosePresets') { rtPresets = d.presets; rtUpdateAllPresetGrids(); } else if (d.type === 'status') showToast(d.message, d.success ? 'success' : 'error'); } function rtUpdateCharacterList(chars) { var container = document.getElementById('charactersContainer'); container.innerHTML = ''; if (chars.length === 0) { container.innerHTML = '
등록된 캐릭터가 없습니다
'; return; } for (var i = 0; i < chars.length; i++) { var charId = chars[i]; container.appendChild(rtCreateCharacterPanel(charId)); rtSend({ action: 'getCharacterData', characterId: charId }); } } // ============================================================ // RETARGETING - Character Panel Creation // ============================================================ function rtCreateCharacterPanel(charId) { var panel = document.createElement('div'); panel.className = 'character-panel'; panel.id = 'panel_' + charId; panel.innerHTML = '
' + '

' + charId + '

' + '
' + '
' + '
' + rtCreateSectionsHTML(charId) + '
' + '
'; return panel; } function rtCreateSectionsHTML(charId) { var html = ''; // 1. Weight Settings html += '
' + '' + '' + '
'; // 2. Hip Position html += '
' + '' + '' + '
'; // 3. Knee Position html += '
' + '' + '' + '
'; // 4. Foot IK html += '
' + '' + '' + '
'; // 5. Hand Pose Presets html += '
' + '' + '' + '
'; // 6. Finger Copy html += '
' + '' + '' + '
'; // 7. Motion Settings html += '
' + '' + '' + '
'; // 8. Floor Height html += '
' + '' + '' + '
'; // 9. Avatar/Head Scale html += '
' + '' + '' + '
'; // 10. Head Rotation Offset html += '
' + '' + '' + '
'; // 11. Calibration html += '
' + '' + '' + '
'; return html; } // ============================================================ // RETARGETING - Slider Helpers // ============================================================ function rtCreateSlider(charId, id, label, min, max, step) { var fullId = id + '_' + charId; return '
' + '
' + '' + '' + '
' + '
' + '' + '' + '' + '
' + '
'; } function rtCreateRangeSlider(charId, minId, maxId, label, min, max, step) { var minFullId = minId + '_' + charId; var maxFullId = maxId + '_' + charId; var fillId = 'rangeFill_' + minId + '_' + charId; return '
' + '
' + '' + '
' + '' + '~' + '' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; } // ============================================================ // RETARGETING - Range Slider Logic // ============================================================ function rtRangeSliderInput(charId, minId, maxId, which) { var minSlider = document.getElementById(minId + '_' + charId); var maxSlider = document.getElementById(maxId + '_' + charId); var minInput = document.getElementById(minId + '_' + charId + 'Input'); var maxInput = document.getElementById(maxId + '_' + charId + 'Input'); var fill = document.getElementById('rangeFill_' + minId + '_' + charId); var minVal = parseFloat(minSlider.value); var maxVal = parseFloat(maxSlider.value); if (which === 'min' && minVal > maxVal) { minSlider.value = maxVal; minVal = maxVal; } if (which === 'max' && maxVal < minVal) { maxSlider.value = minVal; maxVal = minVal; } minInput.value = minVal.toFixed(2); maxInput.value = maxVal.toFixed(2); var rangeMin = parseFloat(minSlider.min); var rangeMax = parseFloat(minSlider.max); var leftPercent = ((minVal - rangeMin) / (rangeMax - rangeMin)) * 100; var rightPercent = ((maxVal - rangeMin) / (rangeMax - rangeMin)) * 100; fill.style.left = leftPercent + '%'; fill.style.width = (rightPercent - leftPercent) + '%'; if (which === 'min') { rtSend({ action: 'updateValueRealtime', characterId: charId, property: minId, value: minVal }); } else { rtSend({ action: 'updateValueRealtime', characterId: charId, property: maxId, value: maxVal }); } } function rtRangeSliderChange(charId, id) { var slider = document.getElementById(id + '_' + charId); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: parseFloat(slider.value) }); } function rtRangeInputChange(charId, minId, maxId, which) { var minInput = document.getElementById(minId + '_' + charId + 'Input'); var maxInput = document.getElementById(maxId + '_' + charId + 'Input'); var minSlider = document.getElementById(minId + '_' + charId); var maxSlider = document.getElementById(maxId + '_' + charId); var minVal = parseFloat(minInput.value); var maxVal = parseFloat(maxInput.value); if (which === 'min' && minVal > maxVal) { minVal = maxVal; minInput.value = minVal.toFixed(2); } if (which === 'max' && maxVal < minVal) { maxVal = minVal; maxInput.value = maxVal.toFixed(2); } minSlider.value = minVal; maxSlider.value = maxVal; rtRangeSliderInput(charId, minId, maxId, which); } function rtUpdateRangeSlider(charId, minId, maxId, minVal, maxVal) { var minSlider = document.getElementById(minId + '_' + charId); var maxSlider = document.getElementById(maxId + '_' + charId); var minInput = document.getElementById(minId + '_' + charId + 'Input'); var maxInput = document.getElementById(maxId + '_' + charId + 'Input'); var fill = document.getElementById('rangeFill_' + minId + '_' + charId); if (!minSlider || !maxSlider) return; minSlider.value = minVal; maxSlider.value = maxVal; minInput.value = minVal.toFixed(2); maxInput.value = maxVal.toFixed(2); var rangeMin = parseFloat(minSlider.min); var rangeMax = parseFloat(minSlider.max); var leftPercent = ((minVal - rangeMin) / (rangeMax - rangeMin)) * 100; var rightPercent = ((maxVal - rangeMin) / (rangeMax - rangeMin)) * 100; fill.style.left = leftPercent + '%'; fill.style.width = (rightPercent - leftPercent) + '%'; } // ============================================================ // RETARGETING - Section Toggle // ============================================================ function rtToggleSection(header) { header.classList.toggle('collapsed'); header.nextElementSibling.classList.toggle('hidden'); } function rtExpandAll() { var headers = document.querySelectorAll('#tabRetargeting .rt-section-header'); headers.forEach(function(h) { h.classList.remove('collapsed'); h.nextElementSibling.classList.remove('hidden'); }); } function rtCollapseAll() { var headers = document.querySelectorAll('#tabRetargeting .rt-section-header'); headers.forEach(function(h) { h.classList.add('collapsed'); h.nextElementSibling.classList.add('hidden'); }); } // ============================================================ // RETARGETING - Character Data Update // ============================================================ function rtUpdateCharacterData(r) { var charId = r.characterId; var data = r.data; rtCharactersData[charId] = data; var fields = ['hipsVertical','hipsForward','hipsHorizontal','kneeFrontBackWeight','kneeInOutWeight', 'feetForwardBackward','feetNarrow','floorHeight','avatarScale','headScale', 'weightSmoothSpeed','chairSeatHeightOffset', 'filterBufferSize','bodyRoughness', 'headRotationOffsetX','headRotationOffsetY','headRotationOffsetZ']; for (var i = 0; i < fields.length; i++) { rtSetSliderValue(charId, fields[i], data[fields[i]]); } rtUpdateRangeSlider(charId, 'limbMinDistance', 'limbMaxDistance', data.limbMinDistance, data.limbMaxDistance); rtUpdateRangeSlider(charId, 'hipsMinDistance', 'hipsMaxDistance', data.hipsMinDistance, data.hipsMaxDistance); rtUpdateRangeSlider(charId, 'groundHipsMinHeight', 'groundHipsMaxHeight', data.groundHipsMinHeight, data.groundHipsMaxHeight); rtUpdateRangeSlider(charId, 'footHeightMinThreshold', 'footHeightMaxThreshold', data.footHeightMinThreshold, data.footHeightMaxThreshold); var fingerMode = document.getElementById('fingerCopyMode_' + charId); if (fingerMode) { fingerMode.value = data.fingerCopyMode; rtUpdateMingleSettingsVisibility(charId); } rtSetCheckbox(charId, 'handPoseEnabled', data.handPoseEnabled); rtSetCheckbox(charId, 'leftHandEnabled', data.leftHandEnabled); rtSetCheckbox(charId, 'rightHandEnabled', data.rightHandEnabled); rtSetCheckbox(charId, 'useMotionFilter', data.useMotionFilter); rtSetCheckbox(charId, 'useBodyRoughMotion', data.useBodyRoughMotion); var cs = document.getElementById('calibrationStatus_' + charId); if (cs) { cs.textContent = data.hasCalibrationData ? '캘리브레이션 데이터가 저장되어 있습니다.' : '저장된 캘리브레이션 데이터가 없습니다.'; cs.className = 'rt-calibration-status' + (data.hasCalibrationData ? ' has-data' : ''); } rtUpdatePresetGrid(charId); } function rtSetSliderValue(charId, id, val) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (sl && val !== undefined && val !== null) sl.value = val; if (inp && val !== undefined && val !== null) inp.value = Number(val).toFixed(3); } function rtSetCheckbox(charId, id, checked) { var cb = document.getElementById(id + '_' + charId); if (cb) cb.checked = checked; } // ============================================================ // RETARGETING - Slider Interactions // ============================================================ function rtSliderRealtime(charId, id) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (inp) inp.value = Number(sl.value).toFixed(3); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: parseFloat(sl.value) }); } function rtInputValueChange(charId, id) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (sl && inp) { var val = parseFloat(inp.value); if (!isNaN(val)) { val = Math.max(parseFloat(sl.min), Math.min(parseFloat(sl.max), val)); sl.value = val; inp.value = Number(val).toFixed(3); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: val }); } } } function rtInputKeyDown(event, charId, id) { if (event.key === 'Enter') { event.preventDefault(); rtInputValueChange(charId, id); event.target.blur(); } } function rtSliderFinish(charId, id) { // drag complete } function rtFineAdjust(charId, id, delta) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); var nv = Math.max(parseFloat(sl.min), Math.min(parseFloat(sl.max), parseFloat(sl.value) + delta)); sl.value = nv; if (inp) inp.value = Number(nv).toFixed(3); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: nv }); } // ============================================================ // RETARGETING - Toggles & Controls // ============================================================ function rtUpdateToggle(charId, prop, checked) { rtSend({ action: 'updateValueRealtime', characterId: charId, property: prop, value: checked ? 1 : 0 }); if ((prop === 'leftHandEnabled' || prop === 'rightHandEnabled') && checked) { var scriptCheckbox = document.getElementById('handPoseEnabled_' + charId); if (scriptCheckbox) scriptCheckbox.checked = true; } } function rtUpdateHandPoseEnabled(charId, checked) { rtSend({ action: 'updateValueRealtime', characterId: charId, property: 'handPoseEnabled', value: checked ? 1 : 0 }); } function rtUpdateFingerMode(charId, val) { rtSend({ action: 'updateValueRealtime', characterId: charId, property: 'fingerCopyMode', value: parseFloat(val) }); rtUpdateMingleSettingsVisibility(charId); } function rtUpdateMingleSettingsVisibility(charId) { var mingleDiv = document.getElementById('mingleSettings_' + charId); var fingerMode = document.getElementById('fingerCopyMode_' + charId); if (mingleDiv && fingerMode) { mingleDiv.style.display = fingerMode.value === '3' ? 'block' : 'none'; } } // ============================================================ // RETARGETING - Calibration & Reset // ============================================================ function rtCalibrateIPose(charId) { rtSend({ action: 'calibrateIPose', characterId: charId }); } function rtResetCalibration(charId) { rtSend({ action: 'resetCalibration', characterId: charId }); } function rtCalibrateMingleOpen(charId) { rtSend({ action: 'calibrateMingleOpen', characterId: charId }); showToast('펼침 상태 기록됨', 'success'); } function rtCalibrateMingleClose(charId) { rtSend({ action: 'calibrateMingleClose', characterId: charId }); showToast('모음 상태 기록됨', 'success'); } function rtResetHips(charId) { ['hipsVertical','hipsForward','hipsHorizontal'].forEach(function(id) { rtSetSliderValue(charId, id, 0); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: 0 }); }); } function rtResetHeadRotation(charId) { ['headRotationOffsetX','headRotationOffsetY','headRotationOffsetZ'].forEach(function(id) { rtSetSliderValue(charId, id, 0); rtSend({ action: 'updateValueRealtime', characterId: charId, property: id, value: 0 }); }); } function rtCalibrateHeadForward(charId) { rtSend({ action: 'calibrateHeadForward', characterId: charId }); showToast('정면 캘리브레이션 실행됨', 'success'); setTimeout(function() { rtSend({ action: 'getCharacterData', characterId: charId }); }, 100); } // ============================================================ // RETARGETING - Preset Grid // ============================================================ function rtUpdateAllPresetGrids() { for (var charId in rtCharactersData) { rtUpdatePresetGrid(charId); } } function rtUpdatePresetGrid(charId) { var grid = document.getElementById('presetGrid_' + charId); if (!grid) return; grid.innerHTML = ''; for (var i = 0; i < rtPresets.length; i++) { var btn = document.createElement('button'); btn.className = 'rt-preset-btn'; btn.textContent = rtPresets[i]; btn.setAttribute('data-name', rtPresets[i]); btn.setAttribute('data-char', charId); btn.onclick = function() { rtApplyPreset(this.getAttribute('data-char'), this.getAttribute('data-name'), 'leftHandPreset'); }; btn.oncontextmenu = function(e) { e.preventDefault(); rtApplyPreset(this.getAttribute('data-char'), this.getAttribute('data-name'), 'rightHandPreset'); }; grid.appendChild(btn); } } function rtApplyPreset(charId, name, hand) { rtSend({ action: 'setHandPosePreset', characterId: charId, property: hand, stringValue: name }); var scriptCheckbox = document.getElementById('handPoseEnabled_' + charId); if (scriptCheckbox) scriptCheckbox.checked = true; var handCheckbox = document.getElementById((hand === 'leftHandPreset' ? 'leftHandEnabled' : 'rightHandEnabled') + '_' + charId); if (handCheckbox) handCheckbox.checked = true; } // ============================================================ // RETARGETING - Refresh // ============================================================ function rtRefresh() { if (!rtConnected) { rtConnectWebSocket(); return; } rtSend({ action: 'refresh' }); showToast('새로고침...', 'success'); } // ============================================================ // INIT // ============================================================ window.onload = function() { initGrid(); connectWebSocket(); // Prevent pull-to-refresh on mobile when scrolling within the page document.body.addEventListener('touchmove', function(e) { if (e.target.closest('.widget-body, .characters-container, .character-content, .settings-body')) return; }, { passive: true }); };