var ws = null; var charactersData = {}; var presets = []; function connectWebSocket() { var wsUrl = 'ws://' + window.location.hostname + ':{{WS_PORT}}/retargeting'; ws = new WebSocket(wsUrl); ws.onopen = function() { updateConnectionStatus(true); // 연결 즉시 캐릭터 목록 요청 send({ action: 'refresh' }); }; ws.onclose = function() { updateConnectionStatus(false); setTimeout(connectWebSocket, 3000); }; ws.onerror = function(e) { console.error('WS Error:', e); }; ws.onmessage = function(e) { handleMessage(JSON.parse(e.data)); }; } function updateConnectionStatus(c) { var s = document.getElementById('connectionStatus'); s.textContent = c ? '연결됨' : '연결 끊김'; s.className = 'connection-status ' + (c ? 'connected' : 'disconnected'); } function handleMessage(d) { if (d.type === 'characterList') updateCharacterList(d.characters); else if (d.type === 'characterData') updateCharacterData(d); else if (d.type === 'handPosePresets') { presets = d.presets; updateAllPresetGrids(); } else if (d.type === 'status') showToast(d.message, d.success ? 'success' : 'error'); } function updateCharacterList(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(createCharacterPanel(charId)); send({ action: 'getCharacterData', characterId: charId }); } } function createCharacterPanel(charId) { var panel = document.createElement('div'); panel.className = 'character-panel'; panel.id = 'panel_' + charId; panel.innerHTML = '
' + '

' + charId + '

' + '
' + '
' + '
' + createSectionsHTML(charId) + '
' + '
'; return panel; } function createSectionsHTML(charId) { var html = ''; // 1. 가중치 설정 (IK 가중치 통합) - 범위 슬라이더 사용 html += '
' + '' + '' + '
'; // 2. 힙 위치 보정 html += '
' + '' + '' + '
'; // 3. 무릎 위치 조정 html += '
' + '' + '' + '
'; // 4. 발 IK 위치 조정 html += '
' + '' + '' + '
'; // 5. 손가락 제어 설정 (손 포즈 프리셋) html += '
' + '' + '' + '
'; // 6. 손가락 복제 설정 html += '
' + '' + '' + '
'; // 7. 모션 설정 (실제 에디터와 동일) html += '
' + '' + '' + '
'; // 8. 바닥 높이 설정 html += '
' + '' + '' + '
'; // 9. 아바타/머리 크기 설정 html += '
' + '' + '' + '
'; // 10. 머리 회전 오프셋 html += '
' + '' + '' + '
'; // 11. 캘리브레이션 (마지막) html += '
' + '' + '' + '
'; return html; } function createSlider(charId, id, label, min, max, step) { var fullId = id + '_' + charId; return '
' + '
' + '' + '' + '
' + '
' + '' + '' + '' + '
' + '
'; } function createRangeSlider(charId, minId, maxId, label, min, max, step) { var minFullId = minId + '_' + charId; var maxFullId = maxId + '_' + charId; var fillId = 'rangeFill_' + minId + '_' + charId; return '
' + '
' + '' + '
' + '' + '~' + '' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
'; } function rangeSliderInput(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') { send({ action: 'updateValueRealtime', characterId: charId, property: minId, value: minVal }); } else { send({ action: 'updateValueRealtime', characterId: charId, property: maxId, value: maxVal }); } } function rangeSliderChange(charId, id) { var slider = document.getElementById(id + '_' + charId); send({ action: 'updateValueRealtime', characterId: charId, property: id, value: parseFloat(slider.value) }); } function rangeInputChange(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; rangeSliderInput(charId, minId, maxId, which); } function updateRangeSlider(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) + '%'; } function toggleCharacterPanel(charId) { var content = document.getElementById('content_' + charId); content.classList.toggle('expanded'); } function toggleSection(header) { header.classList.toggle('collapsed'); header.nextElementSibling.classList.toggle('hidden'); } function expandAll() { var contents = document.querySelectorAll('.character-content'); contents.forEach(function(c) { c.classList.add('expanded'); }); var headers = document.querySelectorAll('.section-header'); headers.forEach(function(h) { h.classList.remove('collapsed'); h.nextElementSibling.classList.remove('hidden'); }); } function collapseAll() { var contents = document.querySelectorAll('.character-content'); contents.forEach(function(c) { c.classList.remove('expanded'); }); var headers = document.querySelectorAll('.section-header'); headers.forEach(function(h) { h.classList.add('collapsed'); h.nextElementSibling.classList.add('hidden'); }); } function updateCharacterData(r) { var charId = r.characterId; var data = r.data; charactersData[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++) { setSliderValue(charId, fields[i], data[fields[i]]); } // 범위 슬라이더 업데이트 updateRangeSlider(charId, 'limbMinDistance', 'limbMaxDistance', data.limbMinDistance, data.limbMaxDistance); updateRangeSlider(charId, 'hipsMinDistance', 'hipsMaxDistance', data.hipsMinDistance, data.hipsMaxDistance); updateRangeSlider(charId, 'groundHipsMinHeight', 'groundHipsMaxHeight', data.groundHipsMinHeight, data.groundHipsMaxHeight); updateRangeSlider(charId, 'footHeightMinThreshold', 'footHeightMaxThreshold', data.footHeightMinThreshold, data.footHeightMaxThreshold); var fingerMode = document.getElementById('fingerCopyMode_' + charId); if (fingerMode) { fingerMode.value = data.fingerCopyMode; updateMingleSettingsVisibility(charId); } setCheckbox(charId, 'handPoseEnabled', data.handPoseEnabled); setCheckbox(charId, 'leftHandEnabled', data.leftHandEnabled); setCheckbox(charId, 'rightHandEnabled', data.rightHandEnabled); setCheckbox(charId, 'useMotionFilter', data.useMotionFilter); setCheckbox(charId, 'useBodyRoughMotion', data.useBodyRoughMotion); var cs = document.getElementById('calibrationStatus_' + charId); if (cs) { cs.textContent = data.hasCalibrationData ? '캘리브레이션 데이터가 저장되어 있습니다.' : '저장된 캘리브레이션 데이터가 없습니다.'; cs.className = 'calibration-status' + (data.hasCalibrationData ? ' has-data' : ''); } updatePresetGrid(charId); } function setSliderValue(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 setCheckbox(charId, id, checked) { var cb = document.getElementById(id + '_' + charId); if (cb) cb.checked = checked; } function sliderRealtime(charId, id) { var sl = document.getElementById(id + '_' + charId); var inp = document.getElementById(id + '_' + charId + 'Input'); if (inp) inp.value = Number(sl.value).toFixed(3); send({ action: 'updateValueRealtime', characterId: charId, property: id, value: parseFloat(sl.value) }); } function inputValueChange(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); send({ action: 'updateValueRealtime', characterId: charId, property: id, value: val }); } } } function inputKeyDown(event, charId, id) { if (event.key === 'Enter') { event.preventDefault(); inputValueChange(charId, id); event.target.blur(); } } function sliderFinish(charId, id) { // 드래그 완료 } function fineAdjust(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); send({ action: 'updateValueRealtime', characterId: charId, property: id, value: nv }); } function updateToggle(charId, prop, checked) { send({ 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 updateHandPoseEnabled(charId, checked) { send({ action: 'updateValueRealtime', characterId: charId, property: 'handPoseEnabled', value: checked ? 1 : 0 }); } function updateFingerMode(charId, val) { send({ action: 'updateValueRealtime', characterId: charId, property: 'fingerCopyMode', value: parseFloat(val) }); updateMingleSettingsVisibility(charId); } function updateMingleSettingsVisibility(charId) { var mingleDiv = document.getElementById('mingleSettings_' + charId); var fingerMode = document.getElementById('fingerCopyMode_' + charId); if (mingleDiv && fingerMode) { mingleDiv.style.display = fingerMode.value === '3' ? 'block' : 'none'; } } function calibrateIPose(charId) { send({ action: 'calibrateIPose', characterId: charId }); } function resetCalibration(charId) { send({ action: 'resetCalibration', characterId: charId }); } function calibrateMingleOpen(charId) { send({ action: 'calibrateMingleOpen', characterId: charId }); showToast('펼침 상태 기록됨', 'success'); } function calibrateMingleClose(charId) { send({ action: 'calibrateMingleClose', characterId: charId }); showToast('모음 상태 기록됨', 'success'); } function resetHips(charId) { ['hipsVertical','hipsForward','hipsHorizontal'].forEach(function(id) { setSliderValue(charId, id, 0); send({ action: 'updateValueRealtime', characterId: charId, property: id, value: 0 }); }); } function resetHeadRotation(charId) { ['headRotationOffsetX','headRotationOffsetY','headRotationOffsetZ'].forEach(function(id) { setSliderValue(charId, id, 0); send({ action: 'updateValueRealtime', characterId: charId, property: id, value: 0 }); }); } function calibrateHeadForward(charId) { send({ action: 'calibrateHeadForward', characterId: charId }); showToast('정면 캘리브레이션 실행됨', 'success'); // 값 새로고침 setTimeout(function() { send({ action: 'getCharacterData', characterId: charId }); }, 100); } function updateAllPresetGrids() { for (var charId in charactersData) { updatePresetGrid(charId); } } function updatePresetGrid(charId) { var grid = document.getElementById('presetGrid_' + charId); if (!grid) return; grid.innerHTML = ''; for (var i = 0; i < presets.length; i++) { var btn = document.createElement('button'); btn.className = 'preset-btn'; btn.textContent = presets[i]; btn.setAttribute('data-name', presets[i]); btn.setAttribute('data-char', charId); btn.onclick = function() { applyPreset(this.getAttribute('data-char'), this.getAttribute('data-name'), 'leftHandPreset'); }; btn.oncontextmenu = function(e) { e.preventDefault(); applyPreset(this.getAttribute('data-char'), this.getAttribute('data-name'), 'rightHandPreset'); }; grid.appendChild(btn); } } function applyPreset(charId, name, hand) { send({ 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; } function refresh() { send({ action: 'refresh' }); showToast('새로고침...', 'success'); } function send(data) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data)); } function showToast(msg, type) { var t = document.getElementById('toast'); t.textContent = msg; t.className = 'toast show ' + (type || ''); setTimeout(function() { t.className = 'toast'; }, 2000); } window.onload = function() { connectWebSocket(); };