Files
PozzettiAndrea-ComfyUI-Geom…/javascript/js/load_mesh_upload.js
T
PozzettiAndrea feb273ad0a Isolation-clean frontend: delete dead JS, guard listeners, leave shared DOM
- Delete 7 dead-squat widgets (registered UI only for other packs' nodes:
  UniRig/SAM3D/Mocap/FBXPreview/BVH/pointcloud) + their 6 orphaned viewer
  HTMLs + the entire unused js/viewer tree (21 files, imported by nothing;
  only ui/styles.css survives) + unused utils barrel.
- Rename namespace geompack -> geometrypack (= DisplayName, lowercased);
  all 17 surviving extensions now geometrypack.*.
- Add event.source provenance guards to every message listener (2 inline,
  2 factory handlers via new iframe param).
- Stop touching document.body: upload file input stays detached (click()
  works detached in modern browsers); folder autocomplete dropdown becomes
  a manual popover inside the widget's own DOM (top layer escapes canvas
  transforms without shared-DOM appends).

Passes comfy-test's javascript isolation lint with 0 errors.
2026-08-13 14:31:51 +02:00

210 lines
11 KiB
JavaScript

// Load Mesh: drag-n-drop / upload (with progress bar) + inline "View 3D",
// for GeomPackLoadMesh. Upload mirrors ComfyUI's official Load Image
// (POST /upload/image, subfolder=3d) but via XHR so we get upload progress.
import { app } from "../../../scripts/app.js";
const TAG = "[LoadMeshUpload]";
console.log(`${TAG} script loaded`);
const EXTENSION_FOLDER = (() => {
const m = import.meta.url.match(/\/extensions\/([^/]+)\//);
return m ? m[1] : "ComfyUI-GeometryPack";
})();
const EXTS = [".obj", ".ply", ".stl", ".off", ".gltf", ".glb", ".fbx", ".dae", ".3ds", ".vtp"];
const ACCEPT = EXTS.join(",");
const isMesh = (name) => EXTS.some((x) => name.toLowerCase().endsWith(x));
// XHR upload so we can report upload progress (fetch can't). Returns combo value.
function uploadMesh(file, onProgress) {
return new Promise((resolve, reject) => {
const body = new FormData();
body.append("image", file, file.name);
body.append("subfolder", "3d");
body.append("type", "input");
body.append("overwrite", "true");
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload/image");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total, e.loaded, e.total);
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const d = JSON.parse(xhr.responseText);
const sub = (d.subfolder || "").replace(/\\/g, "/");
resolve(sub ? `${sub}/${d.name}` : d.name);
} catch (e) { reject(e); }
} else {
reject(new Error(`${xhr.status} ${xhr.responseText}`));
}
};
xhr.onerror = () => reject(new Error("network error"));
if (onProgress) onProgress(0, 0, file.size);
xhr.send(body);
});
}
function viewUrlFor(value) {
const parts = String(value).replace(/\\/g, "/").split("/");
const fname = parts.pop();
return `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(parts.join("/"))}`;
}
function fileWidget(node) {
return node.widgets?.find((x) => x.name === "file_path");
}
function selectValue(node, val) {
const w = fileWidget(node);
if (!w) { console.warn(`${TAG} no file_path widget`); return; }
w.options = w.options || {};
w.options.values = w.options.values || [];
if (!w.options.values.includes(val)) w.options.values.push(val);
w.value = val;
try { w.callback?.(val); } catch (e) { /* noop */ }
node.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "geometrypack.loadmesh.upload",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "GeomPackLoadMesh") return;
console.log(`${TAG} registering for GeomPackLoadMesh`);
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onCreated?.apply(this, arguments);
const node = this;
// --- progress bar (DOM widget; collapses to 0 height when idle) ---
//
// IMPORTANT: ComfyUI's DOM-widget host <div> ("dom-widget size-full",
// position:fixed) is positioned/sized by its OWN reactive layer
// (Vue), driven by widget.isVisible()/widget.hidden -- it does NOT
// look at this element's own inline CSS (wrap.style.display) at all.
// Toggling only wrap.style.display leaves the *host* div fully
// "visible" from ComfyUI's point of view, at which point its height
// falls back to the "size-full" CSS class default (100% of the
// *viewport*, since it's position:fixed) with pointer-events:auto --
// an invisible, click-eating overlay stretching far below the node,
// permanently, regardless of whether anything is actually showing.
// Confirmed empirically (Playwright): computeSize()'s return value
// is not what drives this host div's real layout height either.
// The only thing that correctly collapses the host div (display:none
// AND pointer-events:none together) is the widget's own `hidden`
// flag, so that's the actual on/off switch -- wrap's inline style
// just controls the *content* once the host div is shown.
const wrap = document.createElement("div");
wrap.style.cssText = "width:100%;padding:0 6px;box-sizing:border-box;";
const label = document.createElement("div");
label.style.cssText = "font:10px monospace;color:#bbb;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
const track = document.createElement("div");
track.style.cssText = "width:100%;height:6px;background:rgba(255,255,255,0.18);border-radius:3px;overflow:hidden;";
const bar = document.createElement("div");
bar.style.cssText = "width:0%;height:100%;background:#00c8ff;transition:width 0.1s linear;";
track.appendChild(bar); wrap.appendChild(label); wrap.appendChild(track);
const progWidget = node.addDOMWidget("upload_progress", "div", wrap, {
getValue() { return ""; }, setValue() { },
});
progWidget.computeSize = (w) => [w, 26];
progWidget.hidden = true; // starts idle -- see note above
const showProgress = (name, frac) => {
progWidget.hidden = false;
const pct = Math.max(0, Math.min(100, Math.round((frac || 0) * 100)));
label.textContent = `⬆ ${name} — ${pct}%`;
bar.style.width = pct + "%";
node.setDirtyCanvas(true, true);
};
const hideProgress = () => { progWidget.hidden = true; node.setDirtyCanvas(true, true); };
async function uploadList(files) {
const meshes = [...files].filter((f) => isMesh(f.name));
if (!meshes.length) { console.warn(`${TAG} no mesh files in selection/drop`); return false; }
for (const f of meshes) {
try {
showProgress(f.name, 0);
const val = await uploadMesh(f, (frac) => showProgress(f.name, frac));
selectValue(node, val);
console.log(`${TAG} uploaded -> ${val}`);
} catch (e) {
console.error(`${TAG} upload failed for ${f.name}`, e);
alert("Mesh upload failed: " + e.message);
} finally {
hideProgress();
}
}
return true;
}
node._gpUploadList = uploadList;
// hidden file picker + upload button
const input = document.createElement("input");
input.type = "file"; input.accept = ACCEPT; input.multiple = true; input.style.display = "none";
input.addEventListener("change", async () => { await uploadList(input.files); input.value = ""; });
// Deliberately NOT attached to the DOM: input.click() opens the
// picker from a detached element in all modern browsers, and
// staying detached keeps us out of the shared document entirely.
node._gpFileInput = input;
node.addWidget("button", "⬆ upload / drop mesh", null, () => { console.log(`${TAG} upload button clicked`); input.click(); });
// inline View 3D
let iframe = null, viewWidget = null, savedSize = null;
const collapse = () => {
if (viewWidget && node.widgets) {
const i = node.widgets.indexOf(viewWidget); if (i >= 0) node.widgets.splice(i, 1);
}
if (iframe) iframe.remove(); iframe = null; viewWidget = null;
// Restore the pre-expand size instead of leaving the +380px
// stuck on the node -- otherwise every open/close cycle
// permanently grows the node's bounding box downward, which
// ends up silently overlapping (and blocking clicks on)
// whatever node sits below it on the canvas.
if (savedSize) { node.setSize(savedSize); savedSize = null; }
node.setDirtyCanvas(true, true);
};
const sendMesh = () => {
const w = fileWidget(node);
if (iframe?.contentWindow && w?.value)
iframe.contentWindow.postMessage({ type: "LOAD_MESH", filepath: viewUrlFor(w.value), timestamp: Date.now() }, "*");
};
node.addWidget("button", "👁 view 3d", null, () => {
if (iframe) { collapse(); return; }
const w = fileWidget(node);
if (!w || !w.value) { alert("Pick or upload a mesh first."); return; }
savedSize = [...node.size];
iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;height:100%;border:none;background:#2a2a2a;";
// VTK viewer: robust OBJ/PLY/STL/VTP reader (+ the download progress bar),
// unlike the Three.js viewer.html whose OBJLoader chokes on some OBJs.
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_vtk.html?v=` + Date.now();
viewWidget = node.addDOMWidget("loadmesh_view3d", "MESH_PREVIEW", iframe, { getValue() { return ""; }, setValue() { } });
viewWidget.computeSize = (width) => [width || 360, width || 360];
iframe.addEventListener("load", () => setTimeout(sendMesh, 150));
setTimeout(sendMesh, 600);
node.setSize([Math.max(node.size[0], 380), savedSize[1] + 380]); node.setDirtyCanvas(true, true);
});
return r;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this._gpFileInput?.remove();
return onRemoved?.apply(this, arguments);
};
const onDragOver = nodeType.prototype.onDragOver;
nodeType.prototype.onDragOver = function (e) {
if ([...(e?.dataTransfer?.items || [])].some((it) => it.kind === "file")) return true;
return onDragOver?.apply(this, arguments) ?? false;
};
const onDragDrop = nodeType.prototype.onDragDrop;
nodeType.prototype.onDragDrop = async function (e) {
console.log(`${TAG} drop; files=${e?.dataTransfer?.files?.length}`);
if (this._gpUploadList && await this._gpUploadList(e?.dataTransfer?.files || [])) return true;
return onDragDrop?.apply(this, arguments) ?? false;
};
},
});