- 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.
210 lines
11 KiB
JavaScript
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;
|
|
};
|
|
},
|
|
});
|