Files
PozzettiAndrea-ComfyUI-Geom…/javascript/viewer_slicer.html
T
PozzettiAndrea 414f759791 Vendor frontend JS into javascript/; drop the prestartup copy dance
The viewer JS/HTML/bundles are now committed in javascript/ (declared via
pyproject [tool.comfy] web = "javascript") instead of being copied from the
shared comfy-3d-viewers pip package at prestartup. Consequences:

- requirements.txt is now just comfy-env (host env installs nothing else,
  per the never-touch-the-host-env principle)
- prestartup_script.py no longer copies viewers or web_src; it keeps only
  setup_env() and the input-asset copies
- WEB_DIRECTORY attribute removed; the pyproject declaration is the single
  source (served at /extensions/comfyui-geometrypack/, keyed by project name)
- web_src/ deleted; its 6 widgets now live in javascript/js/ as the tracked
  source, edited directly
- viewer_pbr.html hardcoded-fallback URL updated to the new mount name

Iframe URLs auto-detect the mount from import.meta.url, so the folder rename
and mount-name change are transparent at runtime.
2026-08-12 17:37:09 +02:00

387 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>GeomPack Mesh Slicer</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #2a2a2a; overflow: hidden; font: 12px monospace; color: #ddd; }
#wrap { display: flex; flex-direction: column; width: 100%; height: 100%; }
#top { position: relative; flex: 1 1 0; min-height: 80px; background: #2a2a2a; }
#toolbar { flex: 0 0 auto; display: flex; flex-direction: column; gap: 4px; padding: 4px 6px; background: #1d1d1d; border-top: 1px solid #444; border-bottom: 1px solid #444; }
#toolbar .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
#bottom { position: relative; flex: 1 1 0; min-height: 80px; background: #222; cursor: crosshair; }
#section { position: relative; flex: 1 1 0; min-height: 80px; background: #151515; border-top: 1px solid #444; display: none; }
#status { flex: 1 1 auto; color: #9ad; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
button { background: #333; color: #ddd; border: 1px solid #555; border-radius: 4px; padding: 3px 8px; cursor: pointer; font: 11px monospace; }
button.active { background: #2c5d8a; border-color: #4a90c0; }
button:hover { background: #444; }
</style>
<script src="./js/vtk-gltf.js"></script>
</head>
<body>
<div id="wrap">
<div id="top"></div>
<div id="toolbar">
<div class="row">
<span>3d view:</span>
<button id="qpx">+X</button>
<button id="qnx">−X</button>
<button id="qpy">+Y</button>
<button id="qny">−Y</button>
<button id="qpz">+Z</button>
<button id="qnz">−Z</button>
<span id="status">load a mesh, then click 2 points in the bottom view to set the cut</span>
</div>
<div class="row">
<span>cut view:</span>
<button id="axXY" class="active">XY</button>
<button id="axYZ">YZ</button>
<button id="axXZ">XZ</button>
<button id="lock" class="active">🔒 2D lock</button>
<button id="flip">⇄ flip clip</button>
<button id="reset">reset clip</button>
<button id="r2d">▣ render 2D</button>
<label style="color:#bbb;font:11px monospace;">edge <input id="thick" type="number" min="1" max="20" value="2" style="width:44px;background:#333;color:#ddd;border:1px solid #555;border-radius:4px;font:11px monospace;"></label>
</div>
</div>
<div id="bottom"></div>
<div id="section"></div>
</div>
<script>
const vtkFullScreenRenderWindow = vtk.Rendering.Misc.vtkFullScreenRenderWindow;
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
const vtkActor = vtk.Rendering.Core.vtkActor;
const vtkMapper = vtk.Rendering.Core.vtkMapper;
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
const vtkPlane = vtk.Common.DataModel.vtkPlane;
const vtkCutter = vtk.Filters.Core.vtkCutter;
const statusEl = document.getElementById('status');
const setStatus = (t) => { statusEl.textContent = t; };
// ---- top: interactive 3D view (shows the clipped mesh) ----
const topWin = vtkFullScreenRenderWindow.newInstance({ background: [0.16, 0.16, 0.16], container: document.getElementById('top') });
const topRen = topWin.getRenderer();
const topRW = topWin.getRenderWindow();
topRW.getInteractor().setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance());
const topMapper = vtkMapper.newInstance();
const topActor = vtkActor.newInstance();
topActor.setMapper(topMapper);
topActor.getProperty().setColor(0.8, 0.8, 0.82);
topActor.getProperty().setEdgeVisibility(false);
topRen.addActor(topActor);
// ---- bottom: orthographic pick view ----
const bot = vtkFullScreenRenderWindow.newInstance({ background: [0.13, 0.13, 0.13], container: document.getElementById('bottom') });
const botRen = bot.getRenderer();
const botRW = bot.getRenderWindow();
const botMapper = vtkMapper.newInstance();
const botActor = vtkActor.newInstance();
botActor.setMapper(botMapper);
botActor.getProperty().setColor(0.7, 0.7, 0.72);
botRen.addActor(botActor);
// ---- section: optional 2D cross-section (mesh ∩ plane) via vtkCutter ----
const section = vtkFullScreenRenderWindow.newInstance({ background: [0.08, 0.08, 0.08], container: document.getElementById('section') });
const sectionRen = section.getRenderer();
const sectionRW = section.getRenderWindow();
sectionRW.getInteractor().setInteractorStyle(null);
const sectionMapper = vtkMapper.newInstance();
const sectionActor = vtkActor.newInstance();
sectionActor.setMapper(sectionMapper);
sectionActor.getProperty().setColor(1.0, 0.85, 0.2);
sectionActor.getProperty().setLineWidth(2);
sectionRen.addActor(sectionActor);
const cutter = vtkCutter.newInstance();
let render2D = false;
let thickness = 2;
let meshPoly = null;
function resizeAll() { topWin.resize(); bot.resize(); if (render2D) section.resize(); if (typeof redrawMarkers === 'function') redrawMarkers(); }
window.addEventListener('resize', resizeAll);
// axis basis: u = screen-right, v = screen-up, n = view normal (camera dir)
const AXES = {
XY: { n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
YZ: { n: [1, 0, 0], u: [0, 1, 0], v: [0, 0, 1] },
XZ: { n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, 1] },
};
let axis = 'XY';
let picks = []; // world points (the two cut endpoints) -- kept persistent
let clipNormalSign = 1;
let lastPlane = null; // {origin, normal}
let bounds = [0, 1, 0, 1, 0, 1];
let locked = true; // true = fixed 2D ortho (pick/drag enabled); false = orbit
let dragging = false;
function center() { return [(bounds[0]+bounds[1])/2, (bounds[2]+bounds[3])/2, (bounds[4]+bounds[5])/2]; }
function diag() { return Math.hypot(bounds[1]-bounds[0], bounds[3]-bounds[2], bounds[5]-bounds[4]) || 1; }
function positionBottomCamera() {
const A = AXES[axis], c = center(), d = diag();
const cam = botRen.getActiveCamera();
cam.setParallelProjection(true);
cam.setFocalPoint(c[0], c[1], c[2]);
cam.setPosition(c[0] + A.n[0]*d, c[1] + A.n[1]*d, c[2] + A.n[2]*d);
cam.setViewUp(A.v[0], A.v[1], A.v[2]);
botRen.resetCamera();
botRW.render();
}
// --- SVG overlay over the bottom view: draggable endpoints + red cut line ---
const SVGNS = 'http://www.w3.org/2000/svg';
const overlay = document.createElementNS(SVGNS, 'svg');
overlay.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;';
document.getElementById('bottom').appendChild(overlay);
const cutLine = document.createElementNS(SVGNS, 'line');
cutLine.setAttribute('stroke', '#ff3b30'); cutLine.setAttribute('stroke-width', '2.5');
cutLine.style.display = 'none';
overlay.appendChild(cutLine);
const handles = [0, 1].map((i) => {
// big transparent grab area so near-misses still catch the handle
const hit = document.createElementNS(SVGNS, 'circle');
hit.setAttribute('r', '16'); hit.setAttribute('fill', 'transparent');
hit.style.cssText = 'pointer-events:all;cursor:grab;display:none;';
hit.addEventListener('mousedown', (ev) => { ev.preventDefault(); ev.stopPropagation(); startDrag(i); });
hit.addEventListener('click', (ev) => ev.stopPropagation());
const dot = document.createElementNS(SVGNS, 'circle');
dot.setAttribute('r', '7'); dot.setAttribute('fill', i === 0 ? '#34c759' : '#ff9f0a');
dot.setAttribute('stroke', '#fff'); dot.setAttribute('stroke-width', '2');
dot.style.cssText = 'pointer-events:none;display:none;';
overlay.appendChild(hit); overlay.appendChild(dot);
return { hit, dot };
});
const botInteractor = botRW.getInteractor();
// Lock = no interactor style (no rotation) but interactor stays ENABLED so the
// render loop keeps painting. Unlock swaps in a trackball style.
botInteractor.setInteractorStyle(null);
// forward map: world -> screen px (inverse of clickToWorld; valid only when locked/ortho)
function worldToScreen(world) {
const A = AXES[axis];
const cam = botRen.getActiveCamera();
const fp = cam.getFocalPoint();
const s = cam.getParallelScale();
const rect = document.getElementById('bottom').getBoundingClientRect();
const W = rect.width, H = rect.height, aspect = W / H;
const dw = [world[0]-fp[0], world[1]-fp[1], world[2]-fp[2]];
const ndcx = (dw[0]*A.u[0]+dw[1]*A.u[1]+dw[2]*A.u[2]) / (s*aspect);
const ndcy = (dw[0]*A.v[0]+dw[1]*A.v[1]+dw[2]*A.v[2]) / s;
return { x: (ndcx+1)/2*W, y: (1-ndcy)/2*H };
}
function redrawMarkers() {
const show = locked; // handles/line only track the mesh in the fixed ortho view
for (let i = 0; i < 2; i++) {
const h = handles[i];
if (show && picks[i]) {
const p = worldToScreen(picks[i]);
for (const el of [h.hit, h.dot]) { el.setAttribute('cx', p.x); el.setAttribute('cy', p.y); el.style.display = 'block'; }
} else { h.hit.style.display = 'none'; h.dot.style.display = 'none'; }
}
if (show && picks[0] && picks[1]) {
const a = worldToScreen(picks[0]), b = worldToScreen(picks[1]);
cutLine.setAttribute('x1', a.x); cutLine.setAttribute('y1', a.y);
cutLine.setAttribute('x2', b.x); cutLine.setAttribute('y2', b.y);
cutLine.style.display = 'block';
} else cutLine.style.display = 'none';
}
// click in bottom canvas -> world point on the focal plane (analytic, matches parallel cam)
function clickToWorld(ev) {
const A = AXES[axis];
const rect = document.getElementById('bottom').getBoundingClientRect();
const W = rect.width, H = rect.height;
const px = ev.clientX - rect.left, py = ev.clientY - rect.top;
const ndcx = (px / W) * 2 - 1;
const ndcy = 1 - (py / H) * 2;
const cam = botRen.getActiveCamera();
const fp = cam.getFocalPoint();
const s = cam.getParallelScale(); // half viewport height in world units
const aspect = W / H;
const out = [0, 0, 0];
for (let i = 0; i < 3; i++) {
out[i] = fp[i] + ndcx * s * aspect * A.u[i] + ndcy * s * A.v[i];
}
return out;
}
// Tell the parent frame (preview_mesh_slicer.js) what's currently clipped/shown in the
// top 3D view, so the Python node can reproduce the same cut on the actual mesh data
// (execute() has no other way to see interactive browser-only state like lastPlane).
function postPlaneUpdate() {
if (!lastPlane) { parent.postMessage({ type: 'PLANE_UPDATE', hasPlane: false }, '*'); return; }
const n = lastPlane.normal.map(x => x * clipNormalSign);
parent.postMessage({ type: 'PLANE_UPDATE', hasPlane: true, origin: lastPlane.origin, normal: n }, '*');
}
function applyClip() {
if (!lastPlane) return;
const n = lastPlane.normal.map(x => x * clipNormalSign);
topMapper.removeAllClippingPlanes();
const plane = vtkPlane.newInstance({ origin: lastPlane.origin, normal: n });
topMapper.addClippingPlane(plane);
topRW.render();
updateSection();
postPlaneUpdate();
}
// Quick camera navigation for the TOP 3D view (view-cube style): reposition the
// orbit camera to look along a cardinal axis, centered on the mesh. Does NOT touch
// the clip plane -- that's still only ever set via the "cut view" pick controls below.
function setCameraView(dir) {
const c = center(), d = diag();
const cam = topRen.getActiveCamera();
cam.setFocalPoint(c[0], c[1], c[2]);
cam.setPosition(c[0] + dir[0]*d*2, c[1] + dir[1]*d*2, c[2] + dir[2]*d*2);
// pick an up vector that isn't parallel to the view direction
const up = Math.abs(dir[1]) > 0.9 ? [0, 0, 1] : [0, 1, 0];
cam.setViewUp(up[0], up[1], up[2]);
topRen.resetCamera(); // keeps direction/up set above, just fits distance to bounds
topRW.render();
setStatus(`3d view: looking along [${dir.join(', ')}]`);
}
document.getElementById('qpx').onclick = () => setCameraView([1, 0, 0]);
document.getElementById('qnx').onclick = () => setCameraView([-1, 0, 0]);
document.getElementById('qpy').onclick = () => setCameraView([0, 1, 0]);
document.getElementById('qny').onclick = () => setCameraView([0, -1, 0]);
document.getElementById('qpz').onclick = () => setCameraView([0, 0, 1]);
document.getElementById('qnz').onclick = () => setCameraView([0, 0, -1]);
function updateSection() {
if (!render2D || !lastPlane || !meshPoly) return;
try {
const plane = vtkPlane.newInstance({ origin: lastPlane.origin, normal: lastPlane.normal });
cutter.setCutFunction(plane);
cutter.setInputData(meshPoly);
cutter.update();
sectionMapper.setInputData(cutter.getOutputData());
sectionActor.getProperty().setLineWidth(thickness);
// face-on camera: look along the plane normal
const n = lastPlane.normal, o = lastPlane.origin, d = diag();
const ref = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0];
let up = [ n[1]*ref[2]-n[2]*ref[1], n[2]*ref[0]-n[0]*ref[2], n[0]*ref[1]-n[1]*ref[0] ];
const ul = Math.hypot(up[0], up[1], up[2]) || 1; up = up.map(x => x/ul);
const cam = sectionRen.getActiveCamera();
cam.setParallelProjection(true);
cam.setFocalPoint(o[0], o[1], o[2]);
cam.setPosition(o[0]+n[0]*d, o[1]+n[1]*d, o[2]+n[2]*d);
cam.setViewUp(up[0], up[1], up[2]);
sectionRen.resetCamera();
sectionRW.render();
} catch (e) { console.warn('[Slicer] section update failed', e); }
}
function setPlaneFromPicks() {
if (picks.length < 2) return;
const A = AXES[axis];
const [p1, p2] = picks;
const dir = [p2[0]-p1[0], p2[1]-p1[1], p2[2]-p1[2]];
// normal = dir x axis (lies in view plane, perpendicular to the drawn line)
let nrm = [
dir[1]*A.n[2] - dir[2]*A.n[1],
dir[2]*A.n[0] - dir[0]*A.n[2],
dir[0]*A.n[1] - dir[1]*A.n[0],
];
const L = Math.hypot(nrm[0], nrm[1], nrm[2]);
if (L < 1e-9) { setStatus('points too close / colinear — move a handle'); return; }
nrm = nrm.map(x => x / L);
lastPlane = { origin: p1.slice(), normal: nrm };
applyClip();
setStatus(`clip on ${axis}. drag the dots to adjust · ⇄ flip · reset`);
}
function startDrag(i) {
if (!locked) return;
dragging = true;
const move = (e) => { picks[i] = clickToWorld(e); redrawMarkers(); setPlaneFromPicks(); };
const up = () => { dragging = false; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
}
document.getElementById('bottom').addEventListener('click', (ev) => {
if (!locked || dragging || !botMapper.getInputData()) return;
const w = clickToWorld(ev);
if (picks.length >= 2) { // cut already set -- don't wipe it on a stray click
setStatus('cut set — drag the dots to adjust, or "reset clip" to start a new one');
return;
}
picks.push(w);
redrawMarkers();
if (picks.length === 2) setPlaneFromPicks();
else setStatus(`point 1 set on ${axis} — click the 2nd point (then drag the dots)`);
});
function setLocked(on) {
locked = on;
const b = document.getElementById('lock');
b.classList.toggle('active', on);
b.textContent = on ? '🔒 2D lock' : '🔓 rotate';
if (on) { positionBottomCamera(); botInteractor.setInteractorStyle(null); document.getElementById('bottom').style.cursor = 'crosshair'; }
else { botInteractor.setInteractorStyle(vtkInteractorStyleTrackballCamera.newInstance()); document.getElementById('bottom').style.cursor = 'grab'; }
botRW.render();
redrawMarkers();
setStatus(on ? `2D locked (${axis}) — click 2 points / drag dots to set the cut`
: 'view unlocked — drag to orbit & inspect; lock to edit the cut');
}
function setAxis(a) {
axis = a; picks = [];
for (const k of ['XY', 'YZ', 'XZ']) document.getElementById('ax' + k).classList.toggle('active', k === a);
setLocked(true); // axis pick implies locked 2D
setStatus(`cut view: ${a}. click 2 points in the bottom view.`);
}
document.getElementById('axXY').onclick = () => setAxis('XY');
document.getElementById('axYZ').onclick = () => setAxis('YZ');
document.getElementById('axXZ').onclick = () => setAxis('XZ');
document.getElementById('lock').onclick = () => setLocked(!locked);
document.getElementById('flip').onclick = () => { clipNormalSign *= -1; applyClip(); };
document.getElementById('reset').onclick = () => { lastPlane = null; picks = []; topMapper.removeAllClippingPlanes(); topRW.render(); redrawMarkers(); setStatus('clip cleared'); postPlaneUpdate(); };
document.getElementById('r2d').onclick = () => {
render2D = !render2D;
document.getElementById('r2d').classList.toggle('active', render2D);
document.getElementById('section').style.display = render2D ? 'block' : 'none';
if (render2D) setTimeout(() => { section.resize(); updateSection(); }, 30);
setStatus(render2D ? 'cross-section view on (mesh ∩ plane)' : 'cross-section view off');
};
document.getElementById('thick').addEventListener('input', (e) => {
thickness = Math.max(1, Number(e.target.value) || 2);
sectionActor.getProperty().setLineWidth(thickness);
sectionRW.render();
});
async function loadMesh(url) {
setStatus('loading mesh…');
const reader = vtkXMLPolyDataReader.newInstance();
const resp = await fetch(url);
if (!resp.ok) { setStatus('load failed: HTTP ' + resp.status); return; }
const ab = await resp.arrayBuffer();
reader.parseAsArrayBuffer(ab);
const poly = reader.getOutputData(0);
meshPoly = poly;
topMapper.setInputData(poly);
topMapper.setScalarVisibility(false);
botMapper.setInputData(poly);
botMapper.setScalarVisibility(false);
bounds = poly.getBounds();
lastPlane = null; clipNormalSign = 1; picks = [];
topMapper.removeAllClippingPlanes();
topRen.resetCamera();
topRW.render();
setLocked(true); // positions the ortho cam + redraws markers
setStatus('mesh loaded — pick cut view (XY/YZ/XZ), click 2 points, drag the dots to adjust.');
postPlaneUpdate(); // a new mesh invalidates any previous plane the parent had cached
}
window.addEventListener('message', (event) => {
const d = event.data || {};
if (d.type === 'LOAD_MESH' && d.filepath) loadMesh(d.filepath);
});
setTimeout(resizeAll, 50);
console.log('[GeomPack Slicer] ready');
</script>
</body>
</html>