Files
WizarDiego-NodeRig/frontend/app.js
T

629 lines
23 KiB
JavaScript

// ============================================================
// RENDERER + SCENE + CAMERA
// ============================================================
const scene = new THREE.Scene();
const SCENE_BG_SOLID = new THREE.Color(0x131316); // cor solida original
scene.background = SCENE_BG_SOLID;
const camera = new THREE.PerspectiveCamera(35, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(-2, 3, 9);
camera.lookAt(0, 1.5, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, alpha: true });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
// ============================================================
// LIGHTING
// ============================================================
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(4, 10, 6);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.bias = -0.001;
scene.add(dirLight);
const fillLight = new THREE.DirectionalLight(0x90b0ff, 0.3);
fillLight.position.set(-4, 3, -4);
scene.add(fillLight);
// Apply saved light preference
const savedLight = localStorage.getItem("noderig_light");
if (savedLight !== null) {
const lv = parseFloat(savedLight);
dirLight.intensity = lv;
ambientLight.intensity = lv * 0.5;
}
// ============================================================
// ENVIRONMENT GEOMETRY
// ============================================================
const gridHelper = new THREE.GridHelper(20, 20, 0x333333, 0x1a1a1a);
gridHelper.position.y = -0.01;
scene.add(gridHelper);
const planeGeo = new THREE.PlaneGeometry(20, 20);
const planeMat = new THREE.MeshStandardMaterial({ color: 0x131316, roughness: 1 });
const plane = new THREE.Mesh(planeGeo, planeMat);
plane.rotation.x = -Math.PI / 2;
plane.receiveShadow = true;
scene.add(plane);
// ============================================================
// ORBIT CONTROLS
// ============================================================
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN
};
// ============================================================
// GLTF LOADER
// ============================================================
const gltfLoader = new THREE.GLTFLoader();
// ============================================================
// SCENE OBJECT REGISTRY — cada entrada: { id, name, root }
// ============================================================
const sceneRegistry = [];
let activeItem = null; // objeto do registry que está selecionado
// ============================================================
// SCENE GRAPH UI
// ============================================================
const sceneListEl = document.getElementById("scene-list");
function refreshSceneGraph() {
sceneListEl.innerHTML = "";
if (sceneRegistry.length === 0) {
sceneListEl.innerHTML = '<li class="scene-empty">Nenhum objeto na cena.</li>';
return;
}
sceneRegistry.forEach((item) => {
const li = document.createElement("li");
li.className = "scene-item" + (item === activeItem ? " active" : "");
li.dataset.id = item.id;
li.innerHTML = `
<span class="scene-item-icon">◈</span>
<span class="scene-item-name" title="${item.name}">${item.name}</span>
<button class="scene-item-remove" title="Remover da cena">✕</button>
`;
// Selecionar objeto: click no item ativa drag deste objeto
li.addEventListener("click", (e) => {
if (e.target.classList.contains("scene-item-remove")) return;
activeItem = item;
refreshSceneGraph();
showStatus(`Selecionado: ${item.name}`);
});
// Remover objeto individual
li.querySelector(".scene-item-remove").addEventListener("click", () => {
scene.remove(item.root);
const idx = sceneRegistry.indexOf(item);
if (idx !== -1) sceneRegistry.splice(idx, 1);
if (activeItem === item) activeItem = null;
refreshSceneGraph();
showStatus(`${item.name} removido da cena`);
});
sceneListEl.appendChild(li);
});
}
function registerObject(root, name) {
const item = { id: Date.now() + Math.random(), name, root };
sceneRegistry.push(item);
activeItem = item; // auto-seleciona o recém carregado
refreshSceneGraph();
return item;
}
// ============================================================
// PRIMITIVE DUMMY (fallback enquanto nenhum GLB for carregado)
// ============================================================
const material = new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.2, metalness: 0.1 });
function buildDummy() {
const character = new THREE.Group();
character.name = "Manequim";
const body = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.8, 0.6), material);
body.position.y = 1; body.castShadow = true; body.receiveShadow = true;
character.add(body);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.44, 32, 32), material);
head.position.y = 2.35; head.castShadow = true; head.receiveShadow = true;
character.add(head);
const armGeo = new THREE.BoxGeometry(0.33, 1.3, 0.33);
const leftArm = new THREE.Mesh(armGeo, material);
leftArm.position.set(-0.77, 0.9, 0); leftArm.castShadow = true;
character.add(leftArm);
const rightArm = new THREE.Mesh(armGeo, material);
rightArm.position.set(0.77, 0.9, 0); rightArm.castShadow = true;
character.add(rightArm);
const legGeo = new THREE.BoxGeometry(0.38, 1.3, 0.38);
const leftLeg = new THREE.Mesh(legGeo, material);
leftLeg.position.set(-0.31, -0.15, 0); leftLeg.castShadow = true;
character.add(leftLeg);
const rightLeg = new THREE.Mesh(legGeo, material);
rightLeg.position.set(0.31, -0.15, 0); rightLeg.castShadow = true;
character.add(rightLeg);
return character;
}
// ============================================================
// INSERT GLTF INTO SCENE
// ============================================================
function insertGLTFIntoScene(gltf, modelName = "Modelo", isFallback = false) {
const model = gltf.scene;
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
model.position.sub(center);
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true;
child.receiveShadow = true;
if (child.material) child.material.side = THREE.DoubleSide;
}
});
const rootGroup = new THREE.Group();
rootGroup.name = modelName;
rootGroup.add(model);
const size = box.getSize(new THREE.Vector3());
rootGroup.position.set(0, size.y / 2, 0);
scene.add(rootGroup);
registerObject(rootGroup, modelName);
if (!isFallback) showStatus(`${modelName} carregado!`);
}
// ============================================================
// INITIAL LOAD — tenta last_loaded.glb → mannequin → dummy
// ============================================================
gltfLoader.load("/noderig_assets/models/last_loaded.glb?t=" + Date.now(), (gltf) => {
insertGLTFIntoScene(gltf, "last_loaded", true);
console.log("Modelo anterior restaurado.");
}, undefined, () => {
gltfLoader.load("/noderig_assets/characters/mannequin.gltf", (gltf) => {
insertGLTFIntoScene(gltf, "Manequim", true);
}, undefined, () => {
// Último fallback: dummy primitivo
const dummy = buildDummy();
scene.add(dummy);
registerObject(dummy, "Manequim");
console.log("Usando manequim primitivo embutido.");
});
});
// ============================================================
// USER MODEL UPLOAD
// ============================================================
const modelUploadInput = document.getElementById("model-upload");
const btnLoadModel = document.getElementById("btn-load");
if (btnLoadModel && modelUploadInput) {
btnLoadModel.addEventListener("click", () => modelUploadInput.click());
modelUploadInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (ext !== 'glb' && ext !== 'gltf') {
showStatus("Por favor suba um arquivo .glb ou .gltf", "error");
return;
}
const modelName = file.name.replace(/\.[^/.]+$/, "");
const url = URL.createObjectURL(file);
showStatus("Processando modelo 3D...");
gltfLoader.load(url, (gltf) => {
insertGLTFIntoScene(gltf, modelName, false);
// Salvar no backend para persistir entre sessões
const formData = new FormData();
formData.append("file", file);
fetch("/NodeRing/UploadGLB", { method: "POST", body: formData })
.then(r => r.json())
.then(d => { if (d.status === "success") showStatus(`${modelName} salvo permanentemente!`); })
.catch(err => console.error("Erro ao salvar GLB:", err));
}, undefined, (error) => {
console.error(error);
showStatus("Erro ao ler o arquivo GLB/GLTF", "error");
});
// Limpa o input para permitir recarregar o mesmo arquivo
e.target.value = "";
});
}
// ============================================================
// MOUSE DRAG — move objeto ATIVO selecionado no scene graph
// Botão esquerdo: Arrastar objeto ativo
// Botão esquerdo sem objeto ativo: Orbit/Rotate (comportamento padrão)
// ============================================================
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const dragPlane = new THREE.Plane();
const planeIntersect = new THREE.Vector3();
const dragOffset = new THREE.Vector3();
let isDragging = false;
let dragTarget = null;
window.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
// Se nenhum objeto estiver selecionado na lista, deixa o OrbitControls operar normalmente
if (!activeItem) return;
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// Verificar se o click atingiu o objeto ativo
const intersects = raycaster.intersectObject(activeItem.root, true);
if (intersects.length > 0) {
isDragging = true;
dragTarget = activeItem.root;
controls.enabled = false;
dragPlane.setFromNormalAndCoplanarPoint(
camera.getWorldDirection(dragPlane.normal),
dragTarget.position
);
if (raycaster.ray.intersectPlane(dragPlane, planeIntersect)) {
dragOffset.copy(planeIntersect).sub(dragTarget.position);
}
}
});
window.addEventListener("mouseup", () => {
isDragging = false;
dragTarget = null;
controls.enabled = true;
});
window.addEventListener("mousemove", (e) => {
if (!isDragging || !dragTarget) return;
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
if (raycaster.ray.intersectPlane(dragPlane, planeIntersect)) {
dragTarget.position.copy(planeIntersect.sub(dragOffset));
}
});
// ============================================================
// RESOLUTION + RESIZE
// ============================================================
const resSelector = document.getElementById("res-selector");
let targetWidth = 1024;
let targetHeight = 1024;
function updateResolution() {
if (resSelector) {
const [w, h] = resSelector.value.split("x").map(Number);
targetWidth = w;
targetHeight = h;
}
camera.aspect = targetWidth / targetHeight;
camera.updateProjectionMatrix();
const scale = Math.min(window.innerWidth / targetWidth, window.innerHeight / targetHeight) * 0.95;
renderer.setSize(targetWidth, targetHeight);
renderer.domElement.style.position = "absolute";
renderer.domElement.style.width = `${targetWidth * scale}px`;
renderer.domElement.style.height = `${targetHeight * scale}px`;
renderer.domElement.style.left = `${(window.innerWidth - targetWidth * scale) / 2}px`;
renderer.domElement.style.top = `${(window.innerHeight - targetHeight * scale) / 2}px`;
}
if (resSelector) resSelector.addEventListener("change", updateResolution);
window.addEventListener("resize", updateResolution);
updateResolution();
// ============================================================
// LIGHT SLIDER
// ============================================================
const lightSlider = document.getElementById("light-intensity");
const lightValEl = document.getElementById("light-val");
if (lightSlider) {
if (savedLight !== null) {
lightSlider.value = savedLight;
if (lightValEl) lightValEl.innerText = parseFloat(savedLight).toFixed(1);
}
lightSlider.addEventListener("input", (e) => {
const val = parseFloat(e.target.value);
dirLight.intensity = val;
ambientLight.intensity = val * 0.5;
localStorage.setItem("noderig_light", val);
if (lightValEl) lightValEl.innerText = val.toFixed(1);
});
}
// ============================================================
// STATUS TOAST
// ============================================================
const statusMsg = document.getElementById("status-message");
function showStatus(msg, type = "success") {
statusMsg.innerText = msg;
statusMsg.className = `status ${type} show`;
setTimeout(() => { statusMsg.className = `status ${type}`; }, 3500);
}
// ============================================================
// EXPORT HELPER — respeita o toggle de fundo na exportação
// ============================================================
function renderForExport() {
if (bgExportEnabled) {
// Incluir fundo na saída
renderer.render(scene, camera);
} else {
// Fundo somente visual — exporta com cor sólida
const savedBg = scene.background;
scene.background = SCENE_BG_SOLID;
renderer.render(scene, camera);
scene.background = savedBg;
}
}
// ============================================================
// SAVE /input
// ============================================================
const btnSaveInput = document.getElementById("btn-save-input");
if (btnSaveInput) {
btnSaveInput.addEventListener("click", async () => {
const orig = btnSaveInput.innerHTML;
btnSaveInput.textContent = "Salvando...";
renderForExport();
const dataURL = renderer.domElement.toDataURL("image/png");
try {
const res = await fetch("/NodeRing/SaveInput", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: dataURL })
});
if (res.ok) showStatus("Salvo na pasta /input!");
else throw Error();
} catch { showStatus("Erro ao salvar no /input", "error"); }
finally { btnSaveInput.innerHTML = orig; }
});
}
// ============================================================
// SEND TO NODE MEMORY
// ============================================================
const btnSendMemory = document.getElementById("btn-send-memory");
if (btnSendMemory) {
btnSendMemory.addEventListener("click", async () => {
const orig = btnSendMemory.innerHTML;
btnSendMemory.textContent = "Enviando...";
renderForExport();
const dataURL = renderer.domElement.toDataURL("image/png");
try {
const res = await fetch("/NodeRing", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_base64: dataURL,
background_base64: currentBgBase64
})
});
if (res.ok) showStatus("Enviado para Memória do Nó!");
else throw Error();
} catch { showStatus("Erro ao atualizar nó", "error"); }
finally { btnSendMemory.innerHTML = orig; }
});
}
/**
* Sincronização rápida apenas do fundo
*/
async function syncBgWithBackend() {
if (!currentBgBase64) return;
try {
await fetch("/NodeRing", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
image_base64: renderer.domElement.toDataURL("image/png"), // Envia o frame atual também
background_base64: currentBgBase64
})
});
} catch (e) {
console.error("Erro ao sincronizar fundo:", e);
}
}
// ============================================================
// GENERATE (Queue)
// ============================================================
const btnGenerate = document.getElementById("btn-generate");
if (btnGenerate) {
btnGenerate.addEventListener("click", () => {
if (btnSendMemory) btnSendMemory.click();
setTimeout(() => {
if (window.parent?.app?.queuePrompt) {
window.parent.app.queuePrompt(0, 1);
showStatus("Processamento Iniciado (Queue)!");
} else {
showStatus("ComfyUI não detectado no contexto.", "error");
}
}, 300);
});
}
// ============================================================
// CLEAR SCENE MODAL
// ============================================================
const btnClear = document.getElementById("btn-clear");
const modal = document.getElementById("clear-modal");
const btnCancel = document.getElementById("btn-cancel");
const btnConfirmClear = document.getElementById("btn-confirm-clear");
if (btnClear && modal) btnClear.addEventListener("click", () => modal.classList.add("active"));
if (btnCancel && modal) btnCancel.addEventListener("click", () => modal.classList.remove("active"));
if (btnConfirmClear && modal) {
btnConfirmClear.addEventListener("click", () => {
modal.classList.remove("active");
sceneRegistry.forEach(item => scene.remove(item.root));
sceneRegistry.length = 0;
activeItem = null;
isDragging = false;
dragTarget = null;
controls.enabled = true;
refreshSceneGraph();
fetch("/NodeRing/ClearGLB", { method: "POST" }).catch(() => {});
showStatus("Cena limpa com sucesso!");
});
}
// ============================================================
// BACKGROUND PLATE — imagem de fundo com opção de exportação
// ============================================================
const bgUploadInput = document.getElementById("bg-upload");
const btnBgLoad = document.getElementById("btn-bg-load");
const btnBgRemove = document.getElementById("btn-bg-remove");
const btnBgExportToggle = document.getElementById("btn-bg-export-toggle");
let bgExportEnabled = false; // false = somente visual, true = inclui na exportação
let currentBgBase64 = ""; // Base64 do fundo para saída separada
if (btnBgLoad && bgUploadInput) {
btnBgLoad.addEventListener("click", () => bgUploadInput.click());
bgUploadInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
// Converter para Base64 para sincronizar com o nó
const reader = new FileReader();
reader.onload = (re) => {
currentBgBase64 = re.target.result;
// Sincroniza imediatamente com o servidor para o output secundário do nó
syncBgWithBackend();
};
reader.readAsDataURL(file);
const url = URL.createObjectURL(file);
const loader = new THREE.TextureLoader();
loader.load(url, (texture) => {
scene.background = texture;
if (btnBgRemove) btnBgRemove.style.display = "";
if (btnBgExportToggle) btnBgExportToggle.style.display = "";
btnBgLoad.style.opacity = "0.65";
showStatus(`Fundo '${file.name}' carregado.`);
}, undefined, () => {
showStatus("Erro ao carregar a imagem de fundo.", "error");
});
e.target.value = "";
});
}
if (btnBgExportToggle) {
btnBgExportToggle.addEventListener("click", () => {
bgExportEnabled = !bgExportEnabled;
if (bgExportEnabled) {
btnBgExportToggle.classList.add("bg-export-toggle--on");
btnBgExportToggle.title = "Fundo INCLUÍDO na exportação — clique para desativar";
showStatus("Fundo será INCLUÍDO na exportação!");
} else {
btnBgExportToggle.classList.remove("bg-export-toggle--on");
btnBgExportToggle.title = "Incluir fundo na exportação para o ComfyUI";
showStatus("Fundo voltou a ser somente guia visual.");
}
});
}
if (btnBgRemove) {
btnBgRemove.addEventListener("click", () => {
scene.background = SCENE_BG_SOLID;
currentBgBase64 = ""; // Limpa a referência do fundo
syncBgWithBackend(); // Sincroniza a remoção (enviará string vazia)
if (btnBgLoad) btnBgLoad.style.opacity = "";
if (btnBgRemove) btnBgRemove.style.display = "none";
if (btnBgExportToggle) btnBgExportToggle.style.display = "none";
// Resetar o toggle ao remover o fundo
bgExportEnabled = false;
btnBgExportToggle.classList.remove("bg-export-toggle--on");
showStatus("Fundo removido.");
});
}
// ============================================================
// CAMERA DISTANCE HUD
// ============================================================
const hudDistVal = document.getElementById("hud-dist-val");
const hudFill = document.getElementById("hud-fill");
const HUD_MAX_DIST = 30; // unidades Three.js (metros de cena)
function updateHUD() {
if (!hudDistVal || !hudFill) return;
const dist = camera.position.distanceTo(controls.target);
hudDistVal.textContent = dist.toFixed(2);
const pct = Math.min((dist / HUD_MAX_DIST) * 100, 100);
hudFill.style.width = pct + "%";
// Cor da barra: verde → âmbar → vermelho conforme a distância
if (pct < 40) hudFill.style.background = "linear-gradient(90deg, #34d399, #6ee7b7)";
else if (pct < 75) hudFill.style.background = "linear-gradient(90deg, #fbbf24, #f59e0b)";
else hudFill.style.background = "linear-gradient(90deg, #f87171, #ef4444)";
}
// ============================================================
// ANIMATION LOOP
// ============================================================
function animate() {
requestAnimationFrame(animate);
controls.update();
updateHUD();
renderer.render(scene, camera);
}
animate();
console.log("NodeRig Editor v4.2 Initialized");