1738 lines
70 KiB
JavaScript
1738 lines
70 KiB
JavaScript
// ============================================================
|
|
// 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 = '<span class="skeleton-badge-icon">\uD83E\uDDB4</span>' +
|
|
'<span class="skeleton-badge-text">Rig Detectado \u00B7 ' + count + ' bones</span>';
|
|
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
|
|
'<div class="bcp-header">' +
|
|
'<span class="bcp-title">Controle do Bone</span>' +
|
|
'<span class="bcp-bone-name" id="bcp-bone-name">\u2014</span>' +
|
|
'</div>' +
|
|
|
|
// Bone A selector
|
|
'<div class="bcp-selector-row">' +
|
|
'<span class="bcp-bone-badge bcp-bone-badge-a">A</span>' +
|
|
'<select class="bcp-bone-select" id="bcp-bone-select"></select>' +
|
|
'<button class="bcp-save-pair-btn" id="bcp-save-pair-btn" title="Salvar par Bone A + Bone B no esqueleto atual">\uD83D\uDCBE Salvar Par</button>' +
|
|
'</div>' +
|
|
|
|
// Lista de Pares Salvos
|
|
'<div class="bcp-pairs-section" id="bcp-pairs-section" style="display:none;">' +
|
|
'<div class="bcp-pairs-header">' +
|
|
'<span class="bcp-pairs-title">Pares Salvos</span>' +
|
|
'</div>' +
|
|
'<div class="bcp-pairs-list" id="bcp-pairs-list"></div>' +
|
|
'</div>' +
|
|
|
|
// Bone B selector
|
|
'<div class="bcp-selector-row">' +
|
|
'<span class="bcp-bone-badge bcp-bone-badge-b">B</span>' +
|
|
'<select class="bcp-bone-select bcp-bone-select-b" id="bcp-bone-select-b"><option value="">\u2014 Nenhum \u2014</option></select>' +
|
|
'</div>' +
|
|
|
|
// Link / Mirror row
|
|
'<div class="bcp-link-row">' +
|
|
'<button class="bcp-link-btn" id="bcp-link-btn">\uD83D\uDD13 Link: OFF</button>' +
|
|
'<button class="bcp-mirror-btn" id="bcp-mirror-btn">\uD83D\uDD00 Mirror: OFF</button>' +
|
|
'<select class="bcp-mirror-axis" id="bcp-mirror-axis">' +
|
|
'<option value="YZ">YZ</option>' +
|
|
'<option value="Y">Y</option>' +
|
|
'<option value="Z">Z</option>' +
|
|
'<option value="X">X</option>' +
|
|
'<option value="XY">XY</option>' +
|
|
'<option value="XZ">XZ</option>' +
|
|
'</select>' +
|
|
'</div>' +
|
|
|
|
// Botões de Modo
|
|
'<div class="bcp-mode-row">' +
|
|
'<button class="bcp-mode-btn active" id="bcp-mode-rotate" title="Rota\u00E7\u00E3o">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6"/><path d="M21.34 13.72A10 10 0 1 1 18.57 4.34L21.5 2"/></svg>' +
|
|
' Rotacionar' +
|
|
'</button>' +
|
|
'<button class="bcp-mode-btn" id="bcp-mode-translate" title="Movimento">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>' +
|
|
' Mover' +
|
|
'</button>' +
|
|
'<button class="bcp-mode-btn" id="bcp-mode-scale" title="Escala">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>' +
|
|
' Escala' +
|
|
'</button>' +
|
|
'</div>' +
|
|
|
|
// Escala Uniforme (visível apenas no modo Scale)
|
|
'<div class="bcp-uniform-row" id="bcp-uniform-row">' +
|
|
'<input type="checkbox" class="bcp-uniform-checkbox" id="bcp-uniform-scale">' +
|
|
'<label class="bcp-uniform-label" for="bcp-uniform-scale">Escala Uniforme (X=Y=Z)</label>' +
|
|
'</div>' +
|
|
|
|
// Slider X
|
|
'<div class="bcp-slider-group">' +
|
|
'<div class="bcp-slider-header">' +
|
|
'<span class="bcp-axis bcp-axis-x">X</span>' +
|
|
'<span class="bcp-value" id="bcp-val-x">0.0\u00B0</span>' +
|
|
'</div>' +
|
|
'<input type="range" class="bcp-slider bcp-slider-x" id="bcp-slider-x" min="-180" max="180" step="0.5" value="0">' +
|
|
'</div>' +
|
|
|
|
// Slider Y
|
|
'<div class="bcp-slider-group">' +
|
|
'<div class="bcp-slider-header">' +
|
|
'<span class="bcp-axis bcp-axis-y">Y</span>' +
|
|
'<span class="bcp-value" id="bcp-val-y">0.0\u00B0</span>' +
|
|
'</div>' +
|
|
'<input type="range" class="bcp-slider bcp-slider-y" id="bcp-slider-y" min="-180" max="180" step="0.5" value="0">' +
|
|
'</div>' +
|
|
|
|
// Slider Z
|
|
'<div class="bcp-slider-group">' +
|
|
'<div class="bcp-slider-header">' +
|
|
'<span class="bcp-axis bcp-axis-z">Z</span>' +
|
|
'<span class="bcp-value" id="bcp-val-z">0.0\u00B0</span>' +
|
|
'</div>' +
|
|
'<input type="range" class="bcp-slider bcp-slider-z" id="bcp-slider-z" min="-180" max="180" step="0.5" value="0">' +
|
|
'</div>' +
|
|
|
|
// Botões de Ação
|
|
'<div class="bcp-action-row">' +
|
|
'<button class="bcp-reset-btn" id="bcp-reset-btn" title="Resetar Bone A">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>' +
|
|
' Reset A' +
|
|
'</button>' +
|
|
'<button class="bcp-reset-btn bcp-reset-b" id="bcp-reset-b-btn" title="Resetar Bone B">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>' +
|
|
' Reset B' +
|
|
'</button>' +
|
|
'<button class="bcp-reset-btn bcp-reset-all" id="bcp-reset-all-btn" title="Resetar Todos os Bones">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>' +
|
|
' Reset Tudo' +
|
|
'</button>' +
|
|
'<button class="bcp-reset-btn" id="bcp-ik-toggle" title="Ativar/Desativar Cinem\u00E1tica Inversa (IK)" style="background:var(--bg-lighter);">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v7"/><path d="M12 15v7"/><path d="M2 12h7"/><path d="M15 12h7"/></svg>' +
|
|
' IK: OFF' +
|
|
'</button>' +
|
|
'<button class="bcp-reset-btn" id="bcp-bind-mesh" title="Vincular Malha Selecionada ao Esqueleto Ativo (Auto-Rig)" style="background:rgba(109,40,217,0.15); border-color:var(--accent);">' +
|
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.2 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.2L12 21l1.9-5.8a2 2 0 0 1 1.2-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.2L12 3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/></svg>' +
|
|
' Vincular' +
|
|
'</button>' +
|
|
'<button class="bcp-random-btn" id="bcp-random-btn" title="Pose Aleat\u00F3ria no Bone Selecionado">\uD83C\uDFB2 Aleat\u00F3rio</button>' +
|
|
'</div>';
|
|
|
|
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 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v7"/><path d="M12 15v7"/><path d="M2 12h7"/><path d="M15 12h7"/></svg> ' +
|
|
(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 = "<option value=''>\u2014 Nenhum \u2014</option>";
|
|
|
|
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 = '<span class="toggle-icon">\uD83D\uDC41</span>';
|
|
btn.title = "Ocultar Visualização de Ossos";
|
|
btn.classList.remove("hidden-state");
|
|
} else {
|
|
btn.innerHTML = '<span class="toggle-icon">\uD83D\uDC41\u200D\uD83D\uDDE8</span>';
|
|
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 = '<span class="layout-adjust-icon">\u2195</span>';
|
|
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);
|
|
}
|
|
|
|
})();
|