// ============================================================ // SKELETON RIG v3 — Detecção, Visualização e Controle de Pose // Extensão modular para o NodeRig // ============================================================ (function () { "use strict"; // ============================================================ // ESTADO DO MÓDULO // ============================================================ var skeletonBones = []; var selectedBone = null; var jointHelpers = []; var boneLines = []; var skeletonGroup = null; var transformCtrl = null; var isSkeletonActive = false; var currentModel = null; var isOverlayVisible = true; var currentMode = "rotate"; // "rotate", "translate", "scale" var isDraggingGizmo = false; var skinnedMeshes = []; // SkinnedMesh encontrados no modelo var originalTransforms = {}; // Pose original dos bones {boneName: {pos, rot, scl}} var isIKEnabled = false; // Estado da Cinemática Inversa var jointToBoneMap = new Map(); var skeletonRaycaster = new THREE.Raycaster(); var skeletonMouse = new THREE.Vector2(); var selectedBoneB = null; // Segundo bone (par) var isBoneLinkEnabled = false; // Sincronizar transformações A→B var isMirrorEnabled = false; // Espelhar A→B var mirrorAxis = "YZ"; // Eixos negados no mirror (padrão Blender L/R) var uniformScale = false; // Escala uniforme X=Y=Z var BONE_B_COLOR = 0xa78bfa; // Roxo para Bone B var currentModelName = "default"; var savedPairs = {}; // {modelName: [{a, b}, ...]} var topZIndex = 100; var COLORS = { joint: 0xff8c00, jointSelected: 0x00ff88, bone: 0x00e5ff }; // ============================================================ // MÓDULO 1 — DETECÇÃO DE ESQUELETO // ============================================================ function detectSkeleton(object3D) { var bones = []; var meshes = []; object3D.traverse(function (child) { if (child.isBone) { bones.push(child); } if (child.isSkinnedMesh) { meshes.push(child); } }); skinnedMeshes = meshes; if (bones.length > 0) { console.log("[SkeletonRig] " + bones.length + " bones, " + meshes.length + " skinned meshes detectados."); } return bones; } /** * Salva a pose original (bind pose) de todos os bones para poder resetar depois. * Também salva a boneInverse para garantir que a pose de repouso do skinning está correta. */ function saveOriginalTransforms(bones) { originalTransforms = {}; bones.forEach(function (bone) { originalTransforms[bone.uuid] = { px: bone.position.x, py: bone.position.y, pz: bone.position.z, rx: bone.rotation.x, ry: bone.rotation.y, rz: bone.rotation.z, sx: bone.scale.x, sy: bone.scale.y, sz: bone.scale.z }; }); // Se há SkinnedMeshes, garantir que o bind pose está configurado corretamente. // Isso corrige modelos GLTF que não têm boneMatrices inicializadas. skinnedMeshes.forEach(function (mesh) { if (mesh.skeleton) { mesh.skeleton.calculateInverses(); mesh.skeleton.update(); } }); } // ============================================================ // MÓDULO 2 — VISUALIZAÇÃO DO ESQUELETO // ============================================================ function calculateJointRadius(object3D) { var box = new THREE.Box3().setFromObject(object3D); var size = box.getSize(new THREE.Vector3()); var maxDim = Math.max(size.x, size.y, size.z); return Math.max(0.015, Math.min(0.08, maxDim * 0.015)); } function createSkeletonVisualization(bones, object3D) { clearSkeletonVisualization(); skeletonGroup = new THREE.Group(); skeletonGroup.name = "__SkeletonRigOverlay__"; skeletonGroup.renderOrder = 999; var jointRadius = calculateJointRadius(object3D); var jointGeometry = new THREE.SphereGeometry(jointRadius, 12, 12); var jointMaterial = new THREE.MeshBasicMaterial({ color: COLORS.joint, transparent: true, opacity: 0.85, depthTest: false }); bones.forEach(function (bone) { var sphere = new THREE.Mesh(jointGeometry.clone(), jointMaterial.clone()); sphere.name = "__joint_" + bone.name; sphere.userData.isSkelJoint = true; sphere.userData.boneName = bone.name; var worldPos = new THREE.Vector3(); bone.getWorldPosition(worldPos); sphere.position.copy(worldPos); skeletonGroup.add(sphere); jointHelpers.push(sphere); jointToBoneMap.set(sphere, bone); }); var lineMaterial = new THREE.LineBasicMaterial({ color: COLORS.bone, transparent: true, opacity: 0.6, depthTest: false }); bones.forEach(function (bone) { if (bone.parent && bone.parent.isBone) { var geometry = new THREE.BufferGeometry(); var positions = new Float32Array(6); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); var line = new THREE.Line(geometry, lineMaterial.clone()); line.userData.childBone = bone; line.userData.parentBone = bone.parent; line.frustumCulled = false; skeletonGroup.add(line); boneLines.push(line); } }); scene.add(skeletonGroup); isSkeletonActive = true; showSkeletonBadge(bones.length); createToggleButton(); createBoneControlPanel(); } function clearSkeletonVisualization() { if (skeletonGroup) { scene.remove(skeletonGroup); skeletonGroup.traverse(function (child) { if (child.geometry) child.geometry.dispose(); if (child.material) child.material.dispose(); }); skeletonGroup = null; } jointHelpers = []; boneLines = []; jointToBoneMap.clear(); selectedBone = null; selectedBoneB = null; isBoneLinkEnabled = false; isMirrorEnabled = false; uniformScale = false; isSkeletonActive = false; skinnedMeshes = []; originalTransforms = {}; if (transformCtrl) transformCtrl.detach(); hideSkeletonBadge(); hideBoneLabel(); hideToggleButton(); hideBoneControlPanel(); isOverlayVisible = true; } function updateSkeletonVisualization() { if (!isSkeletonActive || !skeletonGroup) return; var worldPos = new THREE.Vector3(); var parentPos = new THREE.Vector3(); // Atualiza posição dos helpers (esferas) para seguir os bones jointHelpers.forEach(function (sphere) { var bone = jointToBoneMap.get(sphere); if (bone) { bone.getWorldPosition(worldPos); sphere.position.copy(worldPos); } }); // Atualiza as linhas de conexão entre bones boneLines.forEach(function (line) { var childBone = line.userData.childBone; var parentBone = line.userData.parentBone; if (childBone && parentBone) { childBone.getWorldPosition(worldPos); parentBone.getWorldPosition(parentPos); var p = line.geometry.attributes.position.array; p[0] = parentPos.x; p[1] = parentPos.y; p[2] = parentPos.z; p[3] = worldPos.x; p[4] = worldPos.y; p[5] = worldPos.z; line.geometry.attributes.position.needsUpdate = true; } }); // Sincroniza sliders se o gizmo está sendo arrastado if (selectedBone && isDraggingGizmo) { updateSlidersFromBone(); } // Deformação da malha: atualiza o skeleton no loop de renderização // Isso garante que o GPU receba as boneMatrices atualizadas a cada frame forceSkeletonUpdate(); } // ============================================================ // MÓDULO — ATUALIZAÇÃO DE MALHA (SKINNED MESH) // ============================================================ /** * Faz a deformação real da malha acontecer. * Precisa ser chamado no loop de animação e sempre que um bone muda. * A ordem correta é: * 1. updateMatrixWorld da raiz do esqueleto * 2. skeleton.update() → recalcula Bone Matrices para o GPU */ function forceSkeletonUpdate() { skinnedMeshes.forEach(function (mesh) { if (!mesh.skeleton) return; // 1. Propaga as matrizes de mundo pelo grafo de cena inteiro do mesh // Isso garante que rotações de bones pais cheguem aos filhos. if (mesh.parent) { mesh.parent.updateMatrixWorld(true); } else { mesh.updateMatrixWorld(true); } // 2. Atualiza as boneMatrices que serão enviadas ao shader de skinning mesh.skeleton.update(); }); } /** * Chamado após qualquer mudança manual via sliders/gizmo/reset. * Propaga as transformações pela hierarquia dos bones e deforma a malha. */ function propagateBoneChange() { if (selectedBone) { // Propaga a partir do bone alterado para baixo na hierarquia selectedBone.updateMatrixWorld(true); // Aplica tambem ao Bone B se Link ou Mirror estiver ativo if (isBoneLinkEnabled && selectedBoneB) { selectedBoneB.updateMatrixWorld(true); } } // Atualiza todos os SkinnedMesh (deformação da malha) forceSkeletonUpdate(); } /** * Algoritmo de Skinning Automático (Auto-Rig). * Vincula uma malha estática a um esqueleto baseado na proximidade dos vértices aos segmentos dos ossos. */ function bindStaticMeshToSkeleton(mesh, bones) { if (!mesh || !bones || bones.length === 0) return; if (mesh.isSkinnedMesh) { console.warn("[SkeletonRig] Objeto já é uma SkinnedMesh."); return; } console.log("[SkeletonRig] Iniciando Auto-Rig para: " + mesh.name); // Garante que a geometria seja BufferGeometry var geometry = mesh.geometry; if (!geometry.isBufferGeometry) { console.error("[SkeletonRig] Apenas BufferGeometry é suportado."); return; } var position = geometry.attributes.position; var vertexCount = position.count; var skinIndices = new Uint16Array(vertexCount * 4); var skinWeights = new Float32Array(vertexCount * 4); var v = new THREE.Vector3(); var boneStart = new THREE.Vector3(); var boneEnd = new THREE.Vector3(); var line = new THREE.Line3(); var closestPoint = new THREE.Vector3(); // 1. Mapear ossos para índices var boneToIndex = new Map(); bones.forEach((b, idx) => boneToIndex.set(b, idx)); // 2. Pré-calcular segmentos dos ossos no espaço local da malha var invMeshWorld = mesh.matrixWorld.clone().invert(); var segments = bones.map(function(bone) { bone.getWorldPosition(boneStart); // Se tiver filho, o segmento vai até o primeiro filho osso. Se não, é um ponto. var childBone = bone.children.find(c => c.isBone); if (childBone) { childBone.getWorldPosition(boneEnd); } else { // Se for ponta, cria um pequeno segmento na direção do pai boneEnd.copy(boneStart).add(new THREE.Vector3(0, 0.05, 0)); } return { index: boneToIndex.get(bone), line: new THREE.Line3(boneStart.applyMatrix4(invMeshWorld), boneEnd.applyMatrix4(invMeshWorld)) }; }); // 3. Processar vértices (Proximidade) for (var i = 0; i < vertexCount; i++) { v.fromBufferAttribute(position, i); var minDist = Infinity; var bestBoneIdx = 0; for (var s = 0; s < segments.length; s++) { var seg = segments[s]; seg.line.closestPointToPoint(v, true, closestPoint); var d = v.distanceToSquared(closestPoint); if (d < minDist) { minDist = d; bestBoneIdx = seg.index; } } // Atribui influência 100% ao osso mais próximo (Rigid Skinning) // Futuramente podemos suavizar entre os 4 mais próximos var idx4 = i * 4; skinIndices[idx4] = bestBoneIdx; skinWeights[idx4] = 1.0; } // 4. Aplicar Atributos geometry.setAttribute('skinIndex', new THREE.BufferAttribute(skinIndices, 4)); geometry.setAttribute('skinWeight', new THREE.BufferAttribute(skinWeights, 4)); // 5. Criar SkinnedMesh var skinnedMesh = new THREE.SkinnedMesh(geometry, mesh.material); skinnedMesh.name = mesh.name + "_Rigged"; skinnedMesh.position.copy(mesh.position); skinnedMesh.quaternion.copy(mesh.quaternion); skinnedMesh.scale.copy(mesh.scale); var skeleton = new THREE.Skeleton(bones); skinnedMesh.add(bones[0].root || bones[0]); // Adiciona a raiz do esqueleto se necessário, ou apenas vincula skinnedMesh.bind(skeleton); // 6. Substituir na cena var parent = mesh.parent; if (parent) { parent.add(skinnedMesh); parent.remove(mesh); } // Atualiza referências do módulo skinnedMeshes.push(skinnedMesh); if (typeof showStatus === "function") showStatus("Malha vinculada com sucesso!"); return skinnedMesh; } // ============================================================ // MÓDULO — CINEMÁTICA INVERSA (IK) DINÂMICA // ============================================================ function solveDynamicIK(effectorBone, targetPosition, chainLength, iterations) { if (!effectorBone || !effectorBone.parent) return; chainLength = chainLength || 2; // Ombro, Cotovelo (para alvo Pulso) iterations = iterations || 5; var chain = []; var curr = effectorBone.parent; for (var i = 0; i < chainLength; i++) { if (!curr || !curr.isBone) break; chain.push(curr); curr = curr.parent; } if (chain.length === 0) return; var effectorPos = new THREE.Vector3(); var jointPos = new THREE.Vector3(); var vEffector = new THREE.Vector3(); var vTarget = new THREE.Vector3(); var axis = new THREE.Vector3(); var q = new THREE.Quaternion(); for (var itr = 0; itr < iterations; itr++) { for (var i = 0; i < chain.length; i++) { var joint = chain[i]; joint.updateMatrixWorld(true); effectorBone.updateMatrixWorld(true); effectorPos.setFromMatrixPosition(effectorBone.matrixWorld); jointPos.setFromMatrixPosition(joint.matrixWorld); vEffector.subVectors(effectorPos, jointPos).normalize(); vTarget.subVectors(targetPosition, jointPos).normalize(); var angle = vEffector.angleTo(vTarget); if (angle > 0.0001) { axis.crossVectors(vEffector, vTarget).normalize(); // Converte o eixo de rotação global para o espaço local do joint atual var invRot = joint.parent ? joint.parent.matrixWorld.clone().extractRotation(joint.parent.matrixWorld).invert() : new THREE.Matrix4(); axis.transformDirection(invRot); var step = Math.min(angle, 0.5); // fator de suavização q.setFromAxisAngle(axis, step); joint.quaternion.multiplyQuaternions(q, joint.quaternion); joint.updateMatrixWorld(true); } } } } // ============================================================ // MÓDULO 3 — SELEÇÃO DE BONE // ============================================================ function handleJointSelection(event) { if (!isSkeletonActive || jointHelpers.length === 0) return false; if (!isOverlayVisible) return false; var rect = renderer.domElement.getBoundingClientRect(); skeletonMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; skeletonMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; skeletonRaycaster.setFromCamera(skeletonMouse, camera); var intersects = skeletonRaycaster.intersectObjects(jointHelpers, false); if (intersects.length > 0) { var hitSphere = intersects[0].object; var bone = jointToBoneMap.get(hitSphere); if (bone) { if (event.shiftKey) { selectBoneB(bone); if (typeof showStatus === "function") showStatus("Bone B selecionado: " + bone.name); } else { selectBone(bone, hitSphere); } return true; } } return false; } function selectBone(bone, sphere) { // Reset visual do joint anterior (Bone A) if (selectedBone) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === selectedBone) { jh.material.color.setHex(COLORS.joint); } }); } selectedBone = bone; // Encontra a esfera se não foi passada if (!sphere) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === bone) sphere = jh; }); } if (sphere) sphere.material.color.setHex(COLORS.jointSelected); // Re-aplica cor do Bone B (pode ter sido resetada acima se coincidiu) if (selectedBoneB && selectedBoneB !== bone) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === selectedBoneB) { jh.material.color.setHex(BONE_B_COLOR); } }); } // Attach TransformControls — apenas Bone A if (transformCtrl) { transformCtrl.attach(bone); transformCtrl.setMode(currentMode); } showBoneLabel(bone.name); showBoneControlPanel(); updateSlidersFromBone(); updateBoneSelector(); console.log("[SkeletonRig] Bone A selecionado: " + bone.name); } /** * Seleciona um bone pelo nome (usado pelo dropdown). */ function selectBoneByName(boneName) { for (var i = 0; i < skeletonBones.length; i++) { if (skeletonBones[i].name === boneName) { selectBone(skeletonBones[i], null); return; } } } function deselectBone() { if (selectedBone) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === selectedBone) { jh.material.color.setHex(COLORS.joint); } }); selectedBone = null; } // Tambem remove o Bone B if (selectedBoneB) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === selectedBoneB) { jh.material.color.setHex(COLORS.joint); } }); selectedBoneB = null; } isBoneLinkEnabled = false; isMirrorEnabled = false; if (transformCtrl) transformCtrl.detach(); hideBoneLabel(); hideBoneControlPanel(); } // ============================================================ // MÓDULO 4 — TransformControls // ============================================================ function initTransformControls() { if (typeof THREE.TransformControls === "undefined") { console.warn("[SkeletonRig] TransformControls não disponível."); return; } transformCtrl = new THREE.TransformControls(camera, renderer.domElement); transformCtrl.setMode("rotate"); transformCtrl.setSize(0.75); transformCtrl.setSpace("local"); scene.add(transformCtrl); transformCtrl.addEventListener("dragging-changed", function (event) { isDraggingGizmo = event.value; controls.enabled = !event.value; }); transformCtrl.addEventListener("change", function () { if (selectedBone && isDraggingGizmo) { if (isIKEnabled && currentMode === "translate") { // Tenta resgatar a translação atual solicitada pelo gizmo var targetPos = new THREE.Vector3(); selectedBone.getWorldPosition(targetPos); // IK: O osso alvo (ex: mão) não deve esticar (deslocar da origem mãe). // Reverte a translação local para a pose de repouso antes do solve. var orig = originalTransforms[selectedBone.uuid]; if (orig) { selectedBone.position.set(orig.px, orig.py, orig.pz); } else { selectedBone.position.set(0, 0, 0); } selectedBone.updateMatrixWorld(true); // Aplica IK dinâmico nos parentes solveDynamicIK(selectedBone, targetPos, 2, 8); } updateSlidersFromBone(); propagateBoneChange(); } }); console.log("[SkeletonRig] TransformControls inicializado."); } function setMode(mode) { currentMode = mode; if (transformCtrl) transformCtrl.setMode(mode); updateModeButtons(); updateSlidersFromBone(); } // ============================================================ // MÓDULO 5 — SISTEMA DE EVENTOS // ============================================================ function onSkeletonMouseDown(event) { if (event.button !== 0) return; // Bloqueia se clicou em elementos de UI if (event.target.closest(".dock, .scene-panel, .modal-overlay, .skeleton-badge, .bone-label, .skeleton-toggle, .bone-control-panel, .camera-hud")) { return; } if (!isSkeletonActive || !isOverlayVisible) return; // Se o gizmo está ativo, checa se clicamos nele primeiro para não trocar seleção acidentalmente if (transformCtrl && transformCtrl.object) { var rect = renderer.domElement.getBoundingClientRect(); skeletonMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; skeletonMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; skeletonRaycaster.setFromCamera(skeletonMouse, camera); // Intersectamos os filhos do transformCtrl para ver se clicou nas setas/aros var gizmoHits = skeletonRaycaster.intersectObjects(transformCtrl.children, true); if (gizmoHits.length > 0) { // Estamos interagindo com o gizmo, não mexe na seleção por clique return; } } // Se chegamos aqui, ou não há gizmo ou clicamos fora dele var handled = handleJointSelection(event); if (!handled && !event.shiftKey) { // Se clicar no vazio e sem Shift, deseleciona Bone A (e oculta painel) deselectBone(); } } function setupEventListeners() { // Usamos pointerdown direto, sem timeout, para garantir resposta imediata. // A lógica de intersecção do Gizmo acima cuida de não conflitar. renderer.domElement.addEventListener("pointerdown", function (event) { if (event.button !== 0) return; if (!isSkeletonActive || !isOverlayVisible) return; // Pequeno delay para permitir que o TransformControls capture se for o caso // mas reduzido para sentir "direto" setTimeout(function() { if (!isDraggingGizmo) onSkeletonMouseDown(event); }, 30); }); // Garantia de reset para evitar que o gizmo trave a seleção window.addEventListener("pointerup", function() { isDraggingGizmo = false; }); } // ============================================================ // MÓDULO 6 — INTEGRAÇÃO // ============================================================ function hookIntoModelLoading() { var originalInsertGLTF = window.insertGLTFIntoScene; if (typeof originalInsertGLTF !== "function") { setupSceneObserver(); return; } window.insertGLTFIntoScene = function (gltf, modelName, isFallback) { originalInsertGLTF.call(this, gltf, modelName, isFallback); processModelForSkeleton(gltf.scene); }; } function setupSceneObserver() { var lastCheckedCount = 0; setInterval(function () { if (typeof sceneRegistry !== "undefined" && sceneRegistry.length !== lastCheckedCount) { lastCheckedCount = sceneRegistry.length; if (sceneRegistry.length > 0) { processModelForSkeleton(sceneRegistry[sceneRegistry.length - 1].root); } } }, 1000); } function processModelForSkeleton(object3D) { var bones = detectSkeleton(object3D); if (bones.length > 0) { skeletonBones = bones; currentModel = object3D; currentModelName = object3D.name || "UnknownModel"; saveOriginalTransforms(bones); createSkeletonVisualization(bones, object3D); if (!transformCtrl) initTransformControls(); // Carrega pares salvos para este modelo loadSavedPairs(); // Informa se o modelo suporta deformação ou não var hasSkinning = skinnedMeshes.length > 0; var msg = "Rig detectado: " + bones.length + " bones"; if (hasSkinning) { msg += " | " + skinnedMeshes.length + " malha(s) com skinning ✓"; } else { msg += " | Sem skinning (mesh estática)"; } console.log("[SkeletonRig] " + msg); if (typeof showStatus === "function") showStatus(msg); } } function hookIntoClearScene() { var btn = document.getElementById("btn-confirm-clear"); if (btn) { btn.addEventListener("click", function () { clearSkeletonVisualization(); skeletonBones = []; currentModel = null; if (transformCtrl) transformCtrl.detach(); }); } } function hookIntoObjectRemoval() { var list = document.getElementById("scene-list"); if (list) { list.addEventListener("click", function (event) { if (event.target.classList.contains("scene-item-remove")) { setTimeout(function () { if (currentModel && !currentModel.parent) { clearSkeletonVisualization(); skeletonBones = []; currentModel = null; } }, 100); } }); } } // ============================================================ // UI — Badge + Label // ============================================================ function showSkeletonBadge(count) { var b = document.getElementById("skeleton-badge"); if (!b) { b = document.createElement("div"); b.id = "skeleton-badge"; b.className = "skeleton-badge"; document.body.appendChild(b); } b.innerHTML = '\uD83E\uDDB4' + 'Rig Detectado \u00B7 ' + count + ' bones'; b.classList.add("active"); } function hideSkeletonBadge() { var b = document.getElementById("skeleton-badge"); if (b) b.classList.remove("active"); } function formatBoneName(name) { return name .replace(/^mixamorig:?/i, "") .replace(/_/g, " ") .replace(/([A-Z])/g, " $1") .trim(); } function showBoneLabel(name) { var l = document.getElementById("bone-label"); if (!l) { l = document.createElement("div"); l.id = "bone-label"; l.className = "bone-label"; document.body.appendChild(l); } l.textContent = formatBoneName(name) || name; l.classList.add("active"); } function hideBoneLabel() { var l = document.getElementById("bone-label"); if (l) l.classList.remove("active"); } // ============================================================ // PAINEL DE CONTROLE DO BONE // ============================================================ function createBoneControlPanel() { if (document.getElementById("bone-control-panel")) return; var panel = document.createElement("div"); panel.id = "bone-control-panel"; panel.className = "bone-control-panel"; panel.innerHTML = // Header '
' + 'Controle do Bone' + '\u2014' + '
' + // Bone A selector '
' + 'A' + '' + '' + '
' + // Lista de Pares Salvos '' + // Bone B selector '
' + 'B' + '' + '
' + // Link / Mirror row '' + // Botões de Modo '
' + '' + '' + '' + '
' + // Escala Uniforme (visível apenas no modo Scale) '
' + '' + '' + '
' + // Slider X '
' + '
' + 'X' + '0.0\u00B0' + '
' + '' + '
' + // Slider Y '
' + '
' + 'Y' + '0.0\u00B0' + '
' + '' + '
' + // Slider Z '
' + '
' + 'Z' + '0.0\u00B0' + '
' + '' + '
' + // Botões de Ação '
' + '' + '' + '' + '' + '' + '' + '
'; document.body.appendChild(panel); // --- Event Listeners --- var sx = document.getElementById("bcp-slider-x"); var sy = document.getElementById("bcp-slider-y"); var sz = document.getElementById("bcp-slider-z"); // Handler com suporte a Escala Uniforme — sincroniza os 3 eixos ao eixo alterado function makeSliderHandler(triggerId) { return function () { if (uniformScale && currentMode === "scale") { var val = parseFloat(document.getElementById(triggerId).value); sx.value = val; sy.value = val; sz.value = val; } applySliderValues(); }; } sx.addEventListener("input", makeSliderHandler("bcp-slider-x")); sy.addEventListener("input", makeSliderHandler("bcp-slider-y")); sz.addEventListener("input", makeSliderHandler("bcp-slider-z")); // Bloqueia orbit/drag ao mexer nos sliders [sx, sy, sz].forEach(function (sl) { sl.addEventListener("mousedown", function () { controls.enabled = false; }); sl.addEventListener("mouseup", function () { controls.enabled = true; }); sl.addEventListener("pointerdown", function (e) { e.stopPropagation(); }); }); // Botões de modo document.getElementById("bcp-mode-rotate").addEventListener("click", function () { setMode("rotate"); }); document.getElementById("bcp-mode-translate").addEventListener("click", function () { setMode("translate"); }); document.getElementById("bcp-mode-scale").addEventListener("click", function () { setMode("scale"); }); // Reset bone individual document.getElementById("bcp-reset-btn").addEventListener("click", function () { resetSelectedBone(); }); // Reset todos os bones document.getElementById("bcp-reset-all-btn").addEventListener("click", function () { resetAllBones(); }); // Toggle IK Btn var ikToggleBtn = document.getElementById("bcp-ik-toggle"); if (ikToggleBtn) { ikToggleBtn.addEventListener("click", function () { isIKEnabled = !isIKEnabled; ikToggleBtn.style.color = isIKEnabled ? "#00ff88" : ""; ikToggleBtn.innerHTML = ' ' + (isIKEnabled ? 'IK: ON' : 'IK: OFF'); if (typeof showStatus === "function") showStatus("IK " + (isIKEnabled ? "Ativado" : "Desativado")); }); } // Vínculo (Bind) de Malha Estática var btnBind = document.getElementById("bcp-bind-mesh"); if (btnBind) { btnBind.addEventListener("click", function () { if (typeof activeItem === "undefined" || !activeItem) { if (typeof showStatus === "function") showStatus("Selecione um objeto na lista primeiro", "error"); return; } // Tenta encontrar uma malha estática no objeto selecionado var targetMesh = null; activeItem.root.traverse(function(child) { if (child.isMesh && !child.isSkinnedMesh && !child.userData.isSkelJoint) { targetMesh = child; } }); if (targetMesh && skeletonBones.length > 0) { bindStaticMeshToSkeleton(targetMesh, skeletonBones); } else { if (typeof showStatus === "function") showStatus(skeletonBones.length === 0 ? "Nenhum esqueleto detectado" : "Objeto já está vinculado ou é inválido", "error"); } }); } // Seletor Bone A document.getElementById("bcp-bone-select").addEventListener("change", function (e) { if (e.target.value) selectBoneByName(e.target.value); }); // Seletor Bone B document.getElementById("bcp-bone-select-b").addEventListener("change", function (e) { selectBoneBByName(e.target.value); }); // Salvar Par var savePairBtn = document.getElementById("bcp-save-pair-btn"); if (savePairBtn) { savePairBtn.addEventListener("click", function () { saveCurrentPair(); }); } // Link toggle var linkBtn = document.getElementById("bcp-link-btn"); linkBtn.addEventListener("click", function () { isBoneLinkEnabled = !isBoneLinkEnabled; linkBtn.classList.toggle("active", isBoneLinkEnabled); linkBtn.innerHTML = (isBoneLinkEnabled ? "\uD83D\uDD17 Link: ON" : "\uD83D\uDD13 Link: OFF"); if (typeof showStatus === "function") showStatus("Link " + (isBoneLinkEnabled ? "Ativado" : "Desativado")); }); // Mirror toggle var mirrorBtn = document.getElementById("bcp-mirror-btn"); mirrorBtn.addEventListener("click", function () { isMirrorEnabled = !isMirrorEnabled; mirrorBtn.classList.toggle("active", isMirrorEnabled); mirrorBtn.innerHTML = (isMirrorEnabled ? "\uD83D\uDD00 Mirror: ON" : "\uD83D\uDD00 Mirror: OFF"); var axisEl = document.getElementById("bcp-mirror-axis"); if (axisEl) axisEl.classList.toggle("enabled", isMirrorEnabled); if (typeof showStatus === "function") showStatus("Mirror " + (isMirrorEnabled ? "Ativado (Eixo: " + mirrorAxis + ")" : "Desativado")); }); // Mirror axis selector document.getElementById("bcp-mirror-axis").addEventListener("change", function (e) { mirrorAxis = e.target.value; if (typeof showStatus === "function") showStatus("Eixo de Espelho: " + mirrorAxis); }); // Reset bone B document.getElementById("bcp-reset-b-btn").addEventListener("click", function () { resetBoneB(); }); // Pose Aleatória document.getElementById("bcp-random-btn").addEventListener("click", function () { randomizeBone(); }); // Uniform Scale checkbox document.getElementById("bcp-uniform-scale").addEventListener("change", function () { uniformScale = this.checked; if (uniformScale && currentMode === "scale") { var val = parseFloat(document.getElementById("bcp-slider-x").value); document.getElementById("bcp-slider-y").value = val; document.getElementById("bcp-slider-z").value = val; applySliderValues(); } }); // Impedir propagação de cliques no painel panel.addEventListener("pointerdown", function (e) { e.stopPropagation(); }); panel.addEventListener("mousedown", function (e) { e.stopPropagation(); }); } /** * Popula ambos os dropdowns (A e B) com todos os bones disponíveis. */ function populateBoneSelector() { var select = document.getElementById("bcp-bone-select"); var selectB = document.getElementById("bcp-bone-select-b"); if (!select) return; select.innerHTML = ""; if (selectB) selectB.innerHTML = ""; skeletonBones.forEach(function (bone) { var label = formatBoneName(bone.name) || bone.name; var opt = document.createElement("option"); opt.value = bone.name; opt.textContent = label; select.appendChild(opt); if (selectB) { var optB = document.createElement("option"); optB.value = bone.name; optB.textContent = label; selectB.appendChild(optB); } }); } /** * Atualiza ambos os dropdowns para refletir os bones selecionados. */ function updateBoneSelector() { var select = document.getElementById("bcp-bone-select"); var selectB = document.getElementById("bcp-bone-select-b"); if (select && selectedBone) select.value = selectedBone.name; if (selectB && selectedBoneB) selectB.value = selectedBoneB.name; else if (selectB && !selectedBoneB) selectB.value = ""; } function showBoneControlPanel() { var panel = document.getElementById("bone-control-panel"); if (panel) { panel.classList.add("active"); var nameEl = document.getElementById("bcp-bone-name"); if (nameEl && selectedBone) { nameEl.textContent = formatBoneName(selectedBone.name) || selectedBone.name; } populateBoneSelector(); updateBoneSelector(); } updateModeButtons(); } function hideBoneControlPanel() { var panel = document.getElementById("bone-control-panel"); if (panel) panel.classList.remove("active"); } function updateModeButtons() { var btnR = document.getElementById("bcp-mode-rotate"); var btnT = document.getElementById("bcp-mode-translate"); var btnS = document.getElementById("bcp-mode-scale"); if (!btnR) return; if (btnR) btnR.classList.toggle("active", currentMode === "rotate"); if (btnT) btnT.classList.toggle("active", currentMode === "translate"); if (btnS) btnS.classList.toggle("active", currentMode === "scale"); // Mostrar/ocultar row de Escala Uniforme var uniformRow = document.getElementById("bcp-uniform-row"); if (uniformRow) uniformRow.classList.toggle("visible", currentMode === "scale"); updateSlidersFromBone(); } // ============================================================ // SLIDERS — Leitura e Aplicação // ============================================================ function applySliderValues() { if (!selectedBone) return; var sx = document.getElementById("bcp-slider-x"); var sy = document.getElementById("bcp-slider-y"); var sz = document.getElementById("bcp-slider-z"); var lx = document.getElementById("bcp-val-x"); var ly = document.getElementById("bcp-val-y"); var lz = document.getElementById("bcp-val-z"); if (!sx) return; var vx = parseFloat(sx.value); var vy = parseFloat(sy.value); var vz = parseFloat(sz.value); if (currentMode === "rotate") { var d2r = Math.PI / 180; selectedBone.rotation.x = vx * d2r; selectedBone.rotation.y = vy * d2r; selectedBone.rotation.z = vz * d2r; if (lx) lx.textContent = vx.toFixed(1) + "\u00B0"; if (ly) ly.textContent = vy.toFixed(1) + "\u00B0"; if (lz) lz.textContent = vz.toFixed(1) + "\u00B0"; } else if (currentMode === "translate") { selectedBone.position.x = vx; selectedBone.position.y = vy; selectedBone.position.z = vz; if (lx) lx.textContent = vx.toFixed(3); if (ly) ly.textContent = vy.toFixed(3); if (lz) lz.textContent = vz.toFixed(3); } else if (currentMode === "scale") { selectedBone.scale.x = vx; selectedBone.scale.y = vy; selectedBone.scale.z = vz; if (lx) lx.textContent = vx.toFixed(2); if (ly) ly.textContent = vy.toFixed(2); if (lz) lz.textContent = vz.toFixed(2); } // Aplica ao Bone B se o Link estiver ativo if (isBoneLinkEnabled && selectedBoneB) { applyToSecondaryBone(vx, vy, vz); } propagateBoneChange(); } /** * Traz o elemento para frente incrementando o z-index. */ function bringToFront(el) { if (!el) return; topZIndex++; el.style.zIndex = topZIndex; } function initStackingManagement() { // Event delegation no body para capturar cliques em qualquer painel flutuante document.body.addEventListener("pointerdown", function(e) { var panel = e.target.closest(".bone-control-panel, .scene-panel, .dock, .camera-hud, .skeleton-badge, .skeleton-toggle"); if (panel) { bringToFront(panel); } }, { capture: true }); } // ============================================================ // MÓDULO — DUAL BONE: Seleção, Mirror, Pares Salvos // ============================================================ function loadSavedPairs() { var raw = localStorage.getItem("noderig_saved_pairs"); if (raw) { try { savedPairs = JSON.parse(raw); } catch(e) { savedPairs = {}; } } renderSavedPairs(); } function saveCurrentPair() { if (!selectedBone || !selectedBoneB) { if (typeof showStatus === "function") showStatus("Selecione os dois ossos (A e B) primeiro", "error"); return; } if (!savedPairs[currentModelName]) savedPairs[currentModelName] = []; // Evita duplicatas var exists = savedPairs[currentModelName].some(p => p.a === selectedBone.name && p.b === selectedBoneB.name); if (exists) { if (typeof showStatus === "function") showStatus("Este par já está salvo"); return; } savedPairs[currentModelName].push({ a: selectedBone.name, b: selectedBoneB.name }); localStorage.setItem("noderig_saved_pairs", JSON.stringify(savedPairs)); renderSavedPairs(); if (typeof showStatus === "function") showStatus("Par salvo com sucesso!"); } function deleteSavedPair(index) { if (savedPairs[currentModelName]) { savedPairs[currentModelName].splice(index, 1); localStorage.setItem("noderig_saved_pairs", JSON.stringify(savedPairs)); renderSavedPairs(); } } function renderSavedPairs() { var section = document.getElementById("bcp-pairs-section"); var list = document.getElementById("bcp-pairs-list"); if (!list || !section) return; var modelPairs = savedPairs[currentModelName] || []; if (modelPairs.length === 0) { section.style.display = "none"; return; } section.style.display = "block"; list.innerHTML = ""; modelPairs.forEach((pair, idx) => { var item = document.createElement("div"); item.className = "bcp-pair-item"; var label = document.createElement("span"); label.className = "bcp-pair-label"; label.textContent = formatBoneName(pair.a) + " \u2194 " + formatBoneName(pair.b); label.title = pair.a + " + " + pair.b; label.onclick = function() { selectBoneByName(pair.a); selectBoneBByName(pair.b); // Ativa Link/Mirror automaticamente ao selecionar par salvo isMirrorEnabled = true; var mirrorBtn = document.getElementById("bcp-mirror-btn"); if (mirrorBtn) { mirrorBtn.classList.add("active"); mirrorBtn.innerHTML = "\uD83D\uDD00 Mirror: ON"; var axisEl = document.getElementById("bcp-mirror-axis"); if (axisEl) axisEl.classList.add("enabled"); } }; var delBtn = document.createElement("button"); delBtn.className = "bcp-pair-del"; delBtn.innerHTML = "\u2715"; delBtn.title = "Excluir Par"; delBtn.onclick = function(e) { e.stopPropagation(); deleteSavedPair(idx); }; item.appendChild(label); item.appendChild(delBtn); list.appendChild(item); }); } /** * Seleciona o Bone B (par) — colore de roxo, sem afetar o gizmo. */ function selectBoneB(bone) { // Reset cor do B anterior if (selectedBoneB) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === selectedBoneB && selectedBoneB !== selectedBone) { jh.material.color.setHex(COLORS.joint); } }); } selectedBoneB = bone; if (bone) { jointHelpers.forEach(function (jh) { if (jointToBoneMap.get(jh) === bone && bone !== selectedBone) { jh.material.color.setHex(BONE_B_COLOR); } }); } updateBoneSelector(); } /** * Seleciona o Bone B pelo nome (usado pelo dropdown B). */ function selectBoneBByName(boneName) { if (!boneName) { selectBoneB(null); return; } for (var i = 0; i < skeletonBones.length; i++) { if (skeletonBones[i].name === boneName) { selectBoneB(skeletonBones[i]); return; } } selectBoneB(null); } /** * Auto-detecta o par simétrico L↔R do bone A selecionado. * Suporta padrões: .L/.R _L/_R Left/Right l_/r_ */ function autoDetectPair() { if (!selectedBone) { if (typeof showStatus === "function") showStatus("Selecione o Bone A primeiro", "error"); return; } var name = selectedBone.name; var pairName = null; if (name.endsWith(".L")) pairName = name.slice(0, -2) + ".R"; else if (name.endsWith(".R")) pairName = name.slice(0, -2) + ".L"; else if (/_[Ll]$/.test(name)) pairName = name.slice(0, -2) + "_R"; else if (/_[Rr]$/.test(name)) pairName = name.slice(0, -2) + "_L"; else if (/[Ll]eft/.test(name)) pairName = name.replace(/[Ll]eft/, function (m) { return m[0] === "L" ? "Right" : "right"; }); else if (/[Rr]ight/.test(name)) pairName = name.replace(/[Rr]ight/, function (m) { return m[0] === "R" ? "Left" : "left"; }); else if (/^[Ll]_/.test(name)) pairName = "R_" + name.slice(2); else if (/^[Rr]_/.test(name)) pairName = "L_" + name.slice(2); if (pairName) { var found = skeletonBones.find(function (b) { return b.name === pairName; }); if (found) { selectBoneB(found); if (typeof showStatus === "function") showStatus("Par detectado: " + pairName); return; } } if (typeof showStatus === "function") showStatus("Par n\u00E3o encontrado para: " + (formatBoneName(name) || name), "error"); } /** * Aplica as transformações dos sliders ao Bone B, * com suporte a Mirror (negatição de eixos configurada). */ function applyToSecondaryBone(vx, vy, vz) { if (!selectedBoneB || !selectedBone) return; var mx = 1, my = 1, mz = 1; if (isMirrorEnabled) { if (mirrorAxis.indexOf("X") !== -1) mx = -1; if (mirrorAxis.indexOf("Y") !== -1) my = -1; if (mirrorAxis.indexOf("Z") !== -1) mz = -1; } if (currentMode === "rotate") { var d2r = Math.PI / 180; selectedBoneB.rotation.x = vx * d2r * mx; selectedBoneB.rotation.y = vy * d2r * my; selectedBoneB.rotation.z = vz * d2r * mz; } else if (currentMode === "translate") { selectedBoneB.position.x = vx * mx; selectedBoneB.position.y = vy * my; selectedBoneB.position.z = vz * mz; } else if (currentMode === "scale") { selectedBoneB.scale.x = vx; selectedBoneB.scale.y = vy; selectedBoneB.scale.z = vz; } selectedBoneB.updateMatrixWorld(true); } /** * Gera uma pose aleatória para o Bone A (e B se linkado). * Modo Rotate: rotações em [-45°, +45°] por eixo. * Modo Scale: escala uniforme em [0.5, 2.0]. */ function randomizeBone() { if (!selectedBone) { if (typeof showStatus === "function") showStatus("Selecione o Bone A primeiro", "error"); return; } var genRand = function (range) { return (Math.random() * 2 - 1) * range; }; if (currentMode === "rotate") { var rx = genRand(45), ry = genRand(45), rz = genRand(45); var d2r = Math.PI / 180; selectedBone.rotation.x = rx * d2r; selectedBone.rotation.y = ry * d2r; selectedBone.rotation.z = rz * d2r; if (isBoneLinkEnabled && selectedBoneB) { applyToSecondaryBone(rx, ry, rz); } } else if (currentMode === "translate") { var tx = genRand(0.3), ty = genRand(0.3), tz = genRand(0.3); selectedBone.position.x = tx; selectedBone.position.y = ty; selectedBone.position.z = tz; if (isBoneLinkEnabled && selectedBoneB) { applyToSecondaryBone(tx, ty, tz); } } else if (currentMode === "scale") { var s = 0.5 + Math.random() * 1.5; selectedBone.scale.setScalar(s); if (isBoneLinkEnabled && selectedBoneB) selectedBoneB.scale.setScalar(s); } propagateBoneChange(); updateSlidersFromBone(); if (typeof showStatus === "function") showStatus("Pose aleat\u00F3ria aplicada"); } /** * Reseta o Bone B para a pose original salva. */ function resetBoneB() { if (!selectedBoneB) { if (typeof showStatus === "function") showStatus("Nenhum Bone B selecionado", "error"); return; } var orig = originalTransforms[selectedBoneB.uuid]; if (orig) { selectedBoneB.position.set(orig.px, orig.py, orig.pz); selectedBoneB.rotation.set(orig.rx, orig.ry, orig.rz); selectedBoneB.scale.set(orig.sx, orig.sy, orig.sz); } else { selectedBoneB.position.set(0, 0, 0); selectedBoneB.rotation.set(0, 0, 0); selectedBoneB.scale.set(1, 1, 1); } selectedBoneB.updateMatrixWorld(true); propagateBoneChange(); if (typeof showStatus === "function") showStatus("Bone B resetado"); } function updateSlidersFromBone() { if (!selectedBone) return; var sx = document.getElementById("bcp-slider-x"); var sy = document.getElementById("bcp-slider-y"); var sz = document.getElementById("bcp-slider-z"); var lx = document.getElementById("bcp-val-x"); var ly = document.getElementById("bcp-val-y"); var lz = document.getElementById("bcp-val-z"); if (!sx) return; if (currentMode === "rotate") { var r2d = 180 / Math.PI; var rx = selectedBone.rotation.x * r2d; var ry = selectedBone.rotation.y * r2d; var rz = selectedBone.rotation.z * r2d; sx.min = -180; sx.max = 180; sx.step = 0.5; sy.min = -180; sy.max = 180; sy.step = 0.5; sz.min = -180; sz.max = 180; sz.step = 0.5; sx.value = rx; sy.value = ry; sz.value = rz; if (lx) lx.textContent = rx.toFixed(1) + "\u00B0"; if (ly) ly.textContent = ry.toFixed(1) + "\u00B0"; if (lz) lz.textContent = rz.toFixed(1) + "\u00B0"; } else if (currentMode === "translate") { sx.min = -2; sx.max = 2; sx.step = 0.005; sy.min = -2; sy.max = 2; sy.step = 0.005; sz.min = -2; sz.max = 2; sz.step = 0.005; sx.value = selectedBone.position.x; sy.value = selectedBone.position.y; sz.value = selectedBone.position.z; if (lx) lx.textContent = selectedBone.position.x.toFixed(3); if (ly) ly.textContent = selectedBone.position.y.toFixed(3); if (lz) lz.textContent = selectedBone.position.z.toFixed(3); } else if (currentMode === "scale") { sx.min = 0.01; sx.max = 3; sx.step = 0.01; sy.min = 0.01; sy.max = 3; sy.step = 0.01; sz.min = 0.01; sz.max = 3; sz.step = 0.01; sx.value = selectedBone.scale.x; sy.value = selectedBone.scale.y; sz.value = selectedBone.scale.z; if (lx) lx.textContent = selectedBone.scale.x.toFixed(2); if (ly) ly.textContent = selectedBone.scale.y.toFixed(2); if (lz) lz.textContent = selectedBone.scale.z.toFixed(2); } } // ============================================================ // RESET — Restaurar pose original // ============================================================ function resetSelectedBone() { if (!selectedBone) return; var orig = originalTransforms[selectedBone.uuid]; if (orig) { selectedBone.position.set(orig.px, orig.py, orig.pz); selectedBone.rotation.set(orig.rx, orig.ry, orig.rz); selectedBone.scale.set(orig.sx, orig.sy, orig.sz); } else { selectedBone.position.set(0, 0, 0); selectedBone.rotation.set(0, 0, 0); selectedBone.scale.set(1, 1, 1); } propagateBoneChange(); updateSlidersFromBone(); if (typeof showStatus === "function") showStatus("Bone resetado"); } function resetAllBones() { skeletonBones.forEach(function (bone) { var orig = originalTransforms[bone.uuid]; if (orig) { bone.position.set(orig.px, orig.py, orig.pz); bone.rotation.set(orig.rx, orig.ry, orig.rz); bone.scale.set(orig.sx, orig.sy, orig.sz); } }); propagateBoneChange(); updateSlidersFromBone(); if (typeof showStatus === "function") showStatus("Todos os bones resetados"); } // ============================================================ // VISIBILIDADE DO OVERLAY // ============================================================ function hideOverlay() { if (skeletonGroup) skeletonGroup.visible = false; if (transformCtrl) transformCtrl.visible = false; isOverlayVisible = false; updateToggleButton(); } function showOverlay() { if (skeletonGroup) skeletonGroup.visible = true; if (transformCtrl) transformCtrl.visible = true; isOverlayVisible = true; updateToggleButton(); } function toggleOverlay() { if (isOverlayVisible) hideOverlay(); else showOverlay(); } // ============================================================ // AUTO-HIDE NOS EXPORTS // ============================================================ function hookIntoExportButtons() { ["btn-export", "btn-save-input", "btn-send-memory"].forEach(function (id) { var btn = document.getElementById(id); if (!btn) return; btn.addEventListener("click", function () { if (!isSkeletonActive) return; if (skeletonGroup) skeletonGroup.visible = false; if (transformCtrl) transformCtrl.visible = false; setTimeout(function () { if (isOverlayVisible) { if (skeletonGroup) skeletonGroup.visible = true; if (transformCtrl) transformCtrl.visible = true; } }, 0); }, true); }); } // ============================================================ // BOTÃO TOGGLE // ============================================================ function createToggleButton() { var btn = document.getElementById("skeleton-toggle"); if (!btn) { btn = document.createElement("button"); btn.id = "skeleton-toggle"; btn.className = "skeleton-toggle"; document.body.appendChild(btn); btn.addEventListener("click", function () { toggleOverlay(); if (typeof showStatus === "function") showStatus(isOverlayVisible ? "Esqueleto vis\u00EDvel" : "Esqueleto oculto"); }); } updateToggleButton(); btn.classList.add("active"); } function updateToggleButton() { var btn = document.getElementById("skeleton-toggle"); if (!btn) return; if (isOverlayVisible) { btn.innerHTML = '\uD83D\uDC41'; btn.title = "Ocultar Visualização de Ossos"; btn.classList.remove("hidden-state"); } else { btn.innerHTML = '\uD83D\uDC41\u200D\uD83D\uDDE8'; btn.title = "Mostrar Visualização de Ossos"; btn.classList.add("hidden-state"); } } function hideToggleButton() { var btn = document.getElementById("skeleton-toggle"); if (btn) btn.classList.remove("active"); } // ============================================================ // AJUSTE DE LAYOUT (ESPAÇAMENTO VERTICAL) // ============================================================ var currentSpacingMode = 0; // 0: Normal, 1: Amplo function createLayoutControls() { if (document.getElementById("layout-adjuster")) return; var container = document.createElement("div"); container.id = "layout-adjuster"; container.className = "layout-adjuster"; var btnV = document.createElement("button"); btnV.className = "layout-adjust-btn"; btnV.innerHTML = '\u2195'; btnV.title = "Ajustar Espaçamento Vertical"; btnV.addEventListener("click", function() { currentSpacingMode = (currentSpacingMode + 1) % 2; applyLayoutSpacing(); }); container.appendChild(btnV); document.body.appendChild(container); } function applyLayoutSpacing() { var root = document.documentElement; if (currentSpacingMode === 1) { root.style.setProperty("--bcp-vertical-spacing", "18px"); if (typeof showStatus === "function") showStatus("Modo: Espaçamento Amplo"); } else { root.style.setProperty("--bcp-vertical-spacing", "12px"); if (typeof showStatus === "function") showStatus("Modo: Espaçamento Normal"); } } // ============================================================ // API GLOBAL // ============================================================ window.SkeletonRig = { hide: hideOverlay, show: showOverlay, toggle: toggleOverlay, setMode: setMode, resetAll: resetAllBones, isVisible: function () { return isOverlayVisible; }, isActive: function () { return isSkeletonActive; } }; // ============================================================ // LOOP DE ANIMAÇÃO // ============================================================ function injectIntoRenderLoop() { // Ao invés de substituir o requestAnimationFrame global, // usamos um loop dedicado que roda em paralelo ao loop do Three.js. // Isso evita condições de corrida com o renderer principal. (function skelLoop() { window.requestAnimationFrame(skelLoop); updateSkeletonVisualization(); })(); } // ============================================================ // INICIALIZAÇÃO // ============================================================ function init() { console.log("[SkeletonRig] M\u00F3dulo v3 inicializado."); injectIntoRenderLoop(); hookIntoModelLoading(); hookIntoClearScene(); hookIntoObjectRemoval(); hookIntoExportButtons(); setupEventListeners(); initStackingManagement(); createLayoutControls(); if (typeof sceneRegistry !== "undefined") { setTimeout(function () { sceneRegistry.forEach(function (item) { var bones = detectSkeleton(item.root); if (bones.length > 0) processModelForSkeleton(item.root); }); }, 500); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { setTimeout(init, 100); } })();