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.
1680 lines
72 KiB
HTML
1680 lines
72 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>GeomPack VTK.js Dual Mesh Viewer</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
overflow: hidden;
|
|
background: #2a2a2a;
|
|
font-family: monospace;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
#viewerContainer {
|
|
flex: 1;
|
|
position: relative;
|
|
min-height: 0;
|
|
display: flex;
|
|
}
|
|
.viewport-container {
|
|
flex: 1;
|
|
position: relative;
|
|
background: #1a1a1a;
|
|
overflow: hidden;
|
|
}
|
|
.viewport-label {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
color: #888;
|
|
font-size: 11px;
|
|
z-index: 10;
|
|
pointer-events: none;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
padding: 4px 8px;
|
|
border-radius: 3px;
|
|
}
|
|
#sideBySideContainer {
|
|
display: none;
|
|
gap: 2px;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
#sideBySideContainer.active {
|
|
display: flex;
|
|
}
|
|
#overlayContainer {
|
|
display: none;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
#overlayContainer.active {
|
|
display: block;
|
|
}
|
|
#loading {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: white;
|
|
font-size: 14px;
|
|
text-align: center;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
}
|
|
#controls {
|
|
background: #1a1a1a;
|
|
border-top: 1px solid #444;
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
font-size: 12px;
|
|
color: #ccc;
|
|
min-height: 48px;
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
}
|
|
#controls button, #controls select {
|
|
background: #333;
|
|
color: #ccc;
|
|
border: 1px solid #555;
|
|
padding: 4px 8px;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
font-family: monospace;
|
|
cursor: pointer;
|
|
}
|
|
#controls button:hover {
|
|
background: #444;
|
|
}
|
|
#controls label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
#controls input[type="checkbox"] {
|
|
cursor: pointer;
|
|
}
|
|
.divider {
|
|
width: 1px;
|
|
height: 20px;
|
|
background: #444;
|
|
margin: 0 8px;
|
|
}
|
|
.field-controls {
|
|
display: none;
|
|
}
|
|
.field-controls.active {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* Settings panel */
|
|
#settingsPanel {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: #1a1a1a;
|
|
border: 1px solid #555;
|
|
border-radius: 6px;
|
|
padding: 16px;
|
|
z-index: 2000;
|
|
display: none;
|
|
min-width: 280px;
|
|
max-height: 80%;
|
|
overflow-y: auto;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
}
|
|
#settingsPanel.visible {
|
|
display: block;
|
|
}
|
|
#settingsPanel h3 {
|
|
margin: 0 0 12px 0;
|
|
color: #ccc;
|
|
font-size: 14px;
|
|
font-weight: normal;
|
|
border-bottom: 1px solid #333;
|
|
padding-bottom: 8px;
|
|
}
|
|
#settingsPanel .setting-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
#settingsPanel .setting-row label {
|
|
flex: 0 0 100px;
|
|
color: #ccc;
|
|
font-size: 12px;
|
|
}
|
|
#settingsPanel .setting-row input[type="color"] {
|
|
flex: 1;
|
|
height: 32px;
|
|
border: 1px solid #555;
|
|
background: #333;
|
|
cursor: pointer;
|
|
}
|
|
#settingsPanel .setting-row input[type="number"] {
|
|
flex: 1;
|
|
background: #333;
|
|
color: #ccc;
|
|
border: 1px solid #555;
|
|
padding: 6px 8px;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
font-family: monospace;
|
|
}
|
|
#settingsPanel .setting-row select {
|
|
flex: 1;
|
|
background: #333;
|
|
color: #ccc;
|
|
border: 1px solid #555;
|
|
padding: 6px 8px;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
font-family: monospace;
|
|
}
|
|
#settingsPanel .button-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 16px;
|
|
padding-top: 12px;
|
|
border-top: 1px solid #333;
|
|
}
|
|
#settingsPanel .button-row button {
|
|
flex: 1;
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
}
|
|
#settingsOverlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 1999;
|
|
display: none;
|
|
}
|
|
#settingsOverlay.visible {
|
|
display: block;
|
|
}
|
|
#settingsButton {
|
|
background: #333;
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 16px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="viewerContainer">
|
|
<!-- Side-by-side layout -->
|
|
<div id="sideBySideContainer">
|
|
<div class="viewport-container" id="leftViewport">
|
|
<div class="viewport-label">Mesh 1</div>
|
|
<div id="leftContainer" style="width:100%;height:100%"></div>
|
|
</div>
|
|
<div class="viewport-container" id="rightViewport">
|
|
<div class="viewport-label">Mesh 2</div>
|
|
<div id="rightContainer" style="width:100%;height:100%"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Overlay layout -->
|
|
<div id="overlayContainer">
|
|
<div id="singleContainer" style="width:100%;height:100%"></div>
|
|
</div>
|
|
|
|
<div id="loading">Ready to load meshes</div>
|
|
|
|
<!-- Settings overlay and panel -->
|
|
<div id="settingsOverlay"></div>
|
|
<div id="settingsPanel">
|
|
<h3>Appearance Settings</h3>
|
|
<div class="setting-row">
|
|
<label>Point Size:</label>
|
|
<input type="number" id="pointSize" min="1" max="20" step="1" value="10">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Mesh Color:</label>
|
|
<input type="color" id="meshColor" value="#99ccff">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Edge Width:</label>
|
|
<input type="number" id="edgeWidth" min="1" max="10" step="1" value="1">
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Render Mode:</label>
|
|
<select id="representationSettings">
|
|
<option value="2">Surface</option>
|
|
<option value="1">Wireframe</option>
|
|
<option value="0">Points</option>
|
|
</select>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>
|
|
<input type="checkbox" id="parallelProjectionSetting"> Parallel Projection
|
|
</label>
|
|
</div>
|
|
<div class="setting-row" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #444;">
|
|
<label style="font-weight: bold; color: #aaa;">Colormap:</label>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Preset:</label>
|
|
<select id="colormapSelector" style="width: 140px;"></select>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label><input type="checkbox" id="autoColorRange" checked> Auto Range</label>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Min:</label>
|
|
<input type="number" id="colormapMin" step="any" placeholder="Auto" style="width: 120px;" disabled>
|
|
</div>
|
|
<div class="setting-row">
|
|
<label>Max:</label>
|
|
<input type="number" id="colormapMax" step="any" placeholder="Auto" style="width: 120px;" disabled>
|
|
</div>
|
|
<div class="button-row">
|
|
<button id="applySettings">Apply</button>
|
|
<button id="cancelSettings">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="controls">
|
|
<!-- Settings button -->
|
|
<button id="settingsButton" title="Appearance Settings">[Settings]</button>
|
|
|
|
<label class="divider"></label>
|
|
|
|
<label>
|
|
<input type="checkbox" id="showEdges"> Edges
|
|
</label>
|
|
|
|
<label class="divider"></label>
|
|
|
|
<button id="viewPosX">+X</button>
|
|
<button id="viewNegX">-X</button>
|
|
<button id="viewPosY">+Y</button>
|
|
<button id="viewNegY">-Y</button>
|
|
<button id="viewPosZ">+Z</button>
|
|
<button id="viewNegZ">-Z</button>
|
|
|
|
<label class="divider"></label>
|
|
|
|
<button id="resetCamera">Reset Camera</button>
|
|
|
|
<!-- Field visualization controls -->
|
|
<div class="field-controls" id="fieldControls">
|
|
<label class="divider"></label>
|
|
<label>
|
|
Field Mode:
|
|
<select id="fieldMode">
|
|
<option value="none">None</option>
|
|
<option value="independent">Independent</option>
|
|
<option value="synchronized" selected>Synchronized</option>
|
|
</select>
|
|
</label>
|
|
<label id="field1Label" style="display:none;">
|
|
Mesh 1:
|
|
<select id="field1Selector"></select>
|
|
</label>
|
|
<label id="field2Label" style="display:none;">
|
|
Mesh 2:
|
|
<select id="field2Selector"></select>
|
|
</label>
|
|
<label id="sharedFieldLabel" style="display:none;">
|
|
Field:
|
|
<select id="sharedFieldSelector"></select>
|
|
</label>
|
|
<label id="channelLabel" style="display:none;">
|
|
Channel:
|
|
<select id="channelSelector">
|
|
<option value="magnitude">Magnitude</option>
|
|
<option value="0">X</option>
|
|
<option value="1">Y</option>
|
|
<option value="2">Z</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="./js/vtk-gltf.js"></script>
|
|
|
|
<script>
|
|
// Get VTK.js classes
|
|
const vtkFullScreenRenderWindow = vtk.Rendering.Misc.vtkFullScreenRenderWindow;
|
|
const vtkActor = vtk.Rendering.Core.vtkActor;
|
|
const vtkMapper = vtk.Rendering.Core.vtkMapper;
|
|
const vtkSTLReader = vtk.IO.Geometry.vtkSTLReader;
|
|
const vtkOBJReader = vtk.IO.Misc.vtkOBJReader;
|
|
const vtkXMLPolyDataReader = vtk.IO.XML.vtkXMLPolyDataReader;
|
|
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
|
|
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
|
|
const vtkColorTransferFunction = vtk.Rendering.Core.vtkColorTransferFunction;
|
|
const vtkColorMaps = vtk.Rendering.Core.vtkColorTransferFunction.vtkColorMaps;
|
|
const vtkGLTFImporter = vtk.IO.Geometry.vtkGLTFImporter;
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] Initializing...');
|
|
|
|
const loading = document.getElementById('loading');
|
|
|
|
// --- mesh download progress bar (aggregate across loads) ---
|
|
let _dlBar, _dlFill, _dl = {};
|
|
function _ensureDlBar() {
|
|
if (_dlBar) return;
|
|
const kf = document.createElement('style');
|
|
kf.textContent = '@keyframes gpDlIndet{0%{left:-40%}100%{left:100%}}';
|
|
document.head.appendChild(kf);
|
|
_dlBar = document.createElement('div');
|
|
_dlBar.style.cssText = 'position:absolute;bottom:12px;left:50%;transform:translateX(-50%);' +
|
|
'width:220px;height:8px;background:rgba(255,255,255,0.2);border-radius:4px;overflow:hidden;' +
|
|
'z-index:1001;display:none;';
|
|
_dlFill = document.createElement('div');
|
|
_dlFill.style.cssText = 'width:0%;height:100%;background:#00c8ff;transition:width .1s linear;';
|
|
_dlBar.appendChild(_dlFill);
|
|
document.body.appendChild(_dlBar);
|
|
}
|
|
// Determinate fill (0..1), or null to hide.
|
|
function setDownloadBar(frac) {
|
|
_ensureDlBar();
|
|
if (frac == null || isNaN(frac)) { _dlBar.style.display = 'none'; return; }
|
|
_dlBar.style.display = 'block';
|
|
_dlFill.style.animation = 'none';
|
|
_dlFill.style.position = 'static';
|
|
_dlFill.style.left = '';
|
|
_dlFill.style.transition = 'width .1s linear';
|
|
_dlFill.style.width = Math.max(0, Math.min(100, Math.round(frac * 100))) + '%';
|
|
}
|
|
// Indeterminate animated bar -- shown while a load is in flight but the
|
|
// total size is unknown (no Content-Length) or before headers arrive, so
|
|
// there is ALWAYS a visible indicator during loading.
|
|
function setDownloadIndeterminate() {
|
|
_ensureDlBar();
|
|
_dlBar.style.display = 'block';
|
|
_dlFill.style.transition = 'none';
|
|
_dlFill.style.position = 'absolute';
|
|
_dlFill.style.width = '40%';
|
|
_dlFill.style.left = '-40%';
|
|
_dlFill.style.animation = 'gpDlIndet 1.1s linear infinite';
|
|
}
|
|
function _dlReset() { _dl = {}; }
|
|
function _dlUpdate(key, loaded, total) {
|
|
_dl[key] = { loaded: loaded || 0, total: total || 0 };
|
|
let L = 0, T = 0;
|
|
for (const k in _dl) { L += _dl[k].loaded; T += _dl[k].total; }
|
|
if (T > 0) {
|
|
const f = Math.min(1, L / T);
|
|
setDownloadBar(f);
|
|
if (loading) loading.textContent = `Loading meshes… ${Math.round(f * 100)}%`;
|
|
}
|
|
}
|
|
async function fetchWithProgress(url, key) {
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
|
|
const total = Number(resp.headers.get('Content-Length')) || 0;
|
|
if (!resp.body || !total) { setDownloadIndeterminate(); const _b = await resp.arrayBuffer(); if (total) _dlUpdate(key, total, total); return _b; }
|
|
const reader = resp.body.getReader();
|
|
const chunks = []; let loaded = 0; _dlUpdate(key, 0, total);
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
chunks.push(value); loaded += value.length; _dlUpdate(key, loaded, total);
|
|
}
|
|
const out = new Uint8Array(loaded); let off = 0;
|
|
for (const c of chunks) { out.set(c, off); off += c.length; }
|
|
return out.buffer;
|
|
}
|
|
|
|
const sideBySideContainer = document.getElementById('sideBySideContainer');
|
|
const overlayContainer = document.getElementById('overlayContainer');
|
|
|
|
// Viewer state
|
|
let viewerLayout = null; // 'side_by_side' or 'overlay'
|
|
let isInitialized = false; // Track if viewer has been initialized
|
|
let currentMesh1Path = null; // Track loaded mesh paths
|
|
let currentMesh2Path = null;
|
|
let currentMeshPath = null; // For overlay mode
|
|
let leftRenderer, rightRenderer, singleRenderer;
|
|
let leftRenderWindow, rightRenderWindow, singleRenderWindow;
|
|
let leftInteractor, rightInteractor, singleInteractor;
|
|
let leftFullScreenRenderer, rightFullScreenRenderer, singleFullScreenRenderer;
|
|
let leftActor, leftMapper, rightActor, rightMapper, singleActor, singleMapper;
|
|
let leftPolydata, rightPolydata, singlePolydata;
|
|
let mesh1Bounds, mesh2Bounds, combinedBounds;
|
|
let fieldNames1 = [], fieldNames2 = [], commonFields = [];
|
|
|
|
// Camera state persistence
|
|
let _cameraWheelTimer = null;
|
|
let _pendingCameraState = null;
|
|
|
|
function sendCameraState() {
|
|
// Use left renderer for side-by-side, single for overlay
|
|
const cam = viewerLayout === 'side_by_side'
|
|
? leftRenderer?.getActiveCamera()
|
|
: singleRenderer?.getActiveCamera();
|
|
if (!cam) return;
|
|
window.parent.postMessage({
|
|
type: 'WIDGET_UPDATE',
|
|
widget: 'camera_state',
|
|
value: JSON.stringify({
|
|
position: cam.getPosition(),
|
|
focalPoint: cam.getFocalPoint(),
|
|
viewUp: cam.getViewUp()
|
|
})
|
|
}, '*');
|
|
}
|
|
|
|
function applyCameraState(cameraState) {
|
|
if (!cameraState) return false;
|
|
try {
|
|
const cs = typeof cameraState === 'string' ? JSON.parse(cameraState) : cameraState;
|
|
if (!cs.position || !cs.focalPoint || !cs.viewUp) return false;
|
|
if (viewerLayout === 'side_by_side') {
|
|
[leftRenderer, rightRenderer].forEach(r => {
|
|
if (!r) return;
|
|
const cam = r.getActiveCamera();
|
|
cam.setPosition(...cs.position);
|
|
cam.setFocalPoint(...cs.focalPoint);
|
|
cam.setViewUp(...cs.viewUp);
|
|
r.resetCameraClippingRange();
|
|
});
|
|
} else if (singleRenderer) {
|
|
const cam = singleRenderer.getActiveCamera();
|
|
cam.setPosition(...cs.position);
|
|
cam.setFocalPoint(...cs.focalPoint);
|
|
cam.setViewUp(...cs.viewUp);
|
|
singleRenderer.resetCameraClippingRange();
|
|
}
|
|
return true;
|
|
} catch(e) { return false; }
|
|
}
|
|
|
|
// Helper function to render with proper clipping range update (double RAF pattern)
|
|
function renderWithClippingRangeUpdate(renderer, renderWindow) {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
renderer.resetCameraClippingRange();
|
|
renderWindow.render();
|
|
});
|
|
});
|
|
}
|
|
|
|
// Initialize side-by-side layout
|
|
function initSideBySide() {
|
|
// Guard: prevent double initialization
|
|
if (leftFullScreenRenderer) {
|
|
return;
|
|
}
|
|
const leftContainer = document.getElementById('leftContainer');
|
|
const rightContainer = document.getElementById('rightContainer');
|
|
|
|
leftFullScreenRenderer = vtkFullScreenRenderWindow.newInstance({
|
|
background: [0.16, 0.16, 0.16],
|
|
container: leftContainer,
|
|
});
|
|
|
|
rightFullScreenRenderer = vtkFullScreenRenderWindow.newInstance({
|
|
background: [0.16, 0.16, 0.16],
|
|
container: rightContainer,
|
|
});
|
|
|
|
leftRenderer = leftFullScreenRenderer.getRenderer();
|
|
rightRenderer = rightFullScreenRenderer.getRenderer();
|
|
leftRenderWindow = leftFullScreenRenderer.getRenderWindow();
|
|
rightRenderWindow = rightFullScreenRenderer.getRenderWindow();
|
|
leftInteractor = leftRenderWindow.getInteractor();
|
|
rightInteractor = rightRenderWindow.getInteractor();
|
|
|
|
// Set up trackball camera controls
|
|
const leftInteractorStyle = vtkInteractorStyleTrackballCamera.newInstance();
|
|
const rightInteractorStyle = vtkInteractorStyleTrackballCamera.newInstance();
|
|
leftInteractor.setInteractorStyle(leftInteractorStyle);
|
|
rightInteractor.setInteractorStyle(rightInteractorStyle);
|
|
|
|
// Create actors and mappers
|
|
leftActor = vtkActor.newInstance();
|
|
rightActor = vtkActor.newInstance();
|
|
leftMapper = vtkMapper.newInstance();
|
|
rightMapper = vtkMapper.newInstance();
|
|
leftActor.setMapper(leftMapper);
|
|
rightActor.setMapper(rightMapper);
|
|
|
|
// Configure appearance
|
|
[leftActor.getProperty(), rightActor.getProperty()].forEach(property => {
|
|
property.setEdgeVisibility(false);
|
|
property.setColor(0.6, 0.8, 1.0); // Light blue
|
|
property.setAmbient(0.4);
|
|
property.setDiffuse(0.8);
|
|
property.setSpecular(0.3);
|
|
property.setSpecularPower(20);
|
|
property.setRepresentation(2);
|
|
});
|
|
|
|
leftRenderer.addActor(leftActor);
|
|
rightRenderer.addActor(rightActor);
|
|
|
|
leftFullScreenRenderer.resize();
|
|
rightFullScreenRenderer.resize();
|
|
|
|
setupCameraSync();
|
|
}
|
|
|
|
// Initialize overlay layout
|
|
function initOverlay() {
|
|
// Guard: prevent double initialization
|
|
if (singleFullScreenRenderer) {
|
|
return;
|
|
}
|
|
const singleContainer = document.getElementById('singleContainer');
|
|
|
|
singleFullScreenRenderer = vtkFullScreenRenderWindow.newInstance({
|
|
background: [0.16, 0.16, 0.16],
|
|
container: singleContainer,
|
|
});
|
|
|
|
singleRenderer = singleFullScreenRenderer.getRenderer();
|
|
singleRenderWindow = singleFullScreenRenderer.getRenderWindow();
|
|
singleInteractor = singleRenderWindow.getInteractor();
|
|
|
|
const singleInteractorStyle = vtkInteractorStyleTrackballCamera.newInstance();
|
|
singleInteractor.setInteractorStyle(singleInteractorStyle);
|
|
|
|
singleActor = vtkActor.newInstance();
|
|
singleMapper = vtkMapper.newInstance();
|
|
singleActor.setMapper(singleMapper);
|
|
|
|
const property = singleActor.getProperty();
|
|
property.setEdgeVisibility(false);
|
|
property.setColor(0.6, 0.8, 1.0); // Light blue
|
|
property.setAmbient(0.4);
|
|
property.setDiffuse(0.8);
|
|
property.setSpecular(0.3);
|
|
property.setSpecularPower(20);
|
|
property.setRepresentation(2);
|
|
|
|
singleRenderer.addActor(singleActor);
|
|
singleFullScreenRenderer.resize();
|
|
|
|
// Camera state tracking for overlay mode
|
|
singleInteractor.onEndAnimation(() => sendCameraState());
|
|
setTimeout(() => {
|
|
const canvas = document.querySelector('#singleContainer canvas');
|
|
if (canvas) {
|
|
canvas.addEventListener('wheel', () => {
|
|
clearTimeout(_cameraWheelTimer);
|
|
_cameraWheelTimer = setTimeout(sendCameraState, 150);
|
|
});
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
// Camera synchronization
|
|
let isSyncing = false;
|
|
let isLeftAnimating = false;
|
|
let isRightAnimating = false;
|
|
|
|
function synchronizeCameras(sourceCamera, targetCamera) {
|
|
if (isSyncing) return;
|
|
isSyncing = true;
|
|
|
|
targetCamera.setPosition(...sourceCamera.getPosition());
|
|
targetCamera.setFocalPoint(...sourceCamera.getFocalPoint());
|
|
targetCamera.setViewUp(...sourceCamera.getViewUp());
|
|
targetCamera.setParallelProjection(sourceCamera.getParallelProjection());
|
|
targetCamera.setClippingRange(...sourceCamera.getClippingRange());
|
|
targetCamera.setParallelScale(sourceCamera.getParallelScale());
|
|
targetCamera.setViewAngle(sourceCamera.getViewAngle());
|
|
|
|
isSyncing = false;
|
|
}
|
|
|
|
function setupCameraSync() {
|
|
// Animation-based sync
|
|
leftInteractor.onStartAnimation(() => { isLeftAnimating = true; });
|
|
leftInteractor.onAnimation(() => {
|
|
if (isLeftAnimating && !isRightAnimating) {
|
|
synchronizeCameras(leftRenderer.getActiveCamera(), rightRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
}
|
|
});
|
|
leftInteractor.onEndAnimation(() => {
|
|
isLeftAnimating = false;
|
|
if (!isRightAnimating) {
|
|
synchronizeCameras(leftRenderer.getActiveCamera(), rightRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
sendCameraState();
|
|
}
|
|
});
|
|
|
|
rightInteractor.onStartAnimation(() => { isRightAnimating = true; });
|
|
rightInteractor.onAnimation(() => {
|
|
if (isRightAnimating && !isLeftAnimating) {
|
|
synchronizeCameras(rightRenderer.getActiveCamera(), leftRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
}
|
|
});
|
|
rightInteractor.onEndAnimation(() => {
|
|
isRightAnimating = false;
|
|
if (!isLeftAnimating) {
|
|
synchronizeCameras(rightRenderer.getActiveCamera(), leftRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
sendCameraState();
|
|
}
|
|
});
|
|
|
|
// Mouse wheel zoom sync
|
|
setTimeout(() => {
|
|
const leftCanvas = document.querySelector('#leftContainer canvas');
|
|
const rightCanvas = document.querySelector('#rightContainer canvas');
|
|
|
|
if (leftCanvas) {
|
|
leftCanvas.addEventListener('wheel', () => {
|
|
setTimeout(() => {
|
|
if (!isRightAnimating) {
|
|
synchronizeCameras(leftRenderer.getActiveCamera(), rightRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
}
|
|
}, 0);
|
|
clearTimeout(_cameraWheelTimer);
|
|
_cameraWheelTimer = setTimeout(sendCameraState, 150);
|
|
});
|
|
}
|
|
|
|
if (rightCanvas) {
|
|
rightCanvas.addEventListener('wheel', () => {
|
|
setTimeout(() => {
|
|
if (!isLeftAnimating) {
|
|
synchronizeCameras(rightRenderer.getActiveCamera(), leftRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
}
|
|
}, 0);
|
|
clearTimeout(_cameraWheelTimer);
|
|
_cameraWheelTimer = setTimeout(sendCameraState, 150);
|
|
});
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
// Load mesh function
|
|
async function loadMesh(filepath, targetMapper, isMesh1 = true) {
|
|
console.log(`[GeomPack VTK Dual Viewer] Loading mesh ${isMesh1 ? '1' : '2'}:`, filepath);
|
|
|
|
try {
|
|
const isSTL = filepath.includes('.stl');
|
|
const isOBJ = filepath.includes('.obj');
|
|
const isVTP = filepath.includes('.vtp');
|
|
const isGLB = filepath.includes('.glb');
|
|
|
|
let reader;
|
|
if (isSTL) {
|
|
reader = vtkSTLReader.newInstance();
|
|
} else if (isOBJ) {
|
|
reader = vtkOBJReader.newInstance();
|
|
} else if (isVTP) {
|
|
reader = vtkXMLPolyDataReader.newInstance();
|
|
} else if (isGLB) {
|
|
reader = vtkGLTFImporter.newInstance();
|
|
} else {
|
|
throw new Error(`Unsupported file format: ${filepath}`);
|
|
}
|
|
|
|
const arrayBuffer = await fetchWithProgress(filepath, filepath);
|
|
|
|
// Yield once so the progress bar actually paints before the
|
|
// synchronous VTK parse/upload below blocks the main thread.
|
|
if (loading) loading.textContent = `Processing mesh ${isMesh1 ? '1' : '2'}…`;
|
|
await new Promise(r => setTimeout(r, 0));
|
|
|
|
if (isGLB) {
|
|
// GLB handling for overlay mode
|
|
reader.parseAsArrayBuffer(arrayBuffer);
|
|
await reader.update();
|
|
const source = reader.getOutputData();
|
|
targetMapper.setInputData(source);
|
|
targetMapper.setScalarVisibility(false);
|
|
targetMapper.update();
|
|
|
|
// Extract polydata and calculate bounds
|
|
const polydata = targetMapper.getInputData();
|
|
if (!polydata) {
|
|
throw new Error('Failed to extract polydata from GLB');
|
|
}
|
|
|
|
console.log(`[GeomPack VTK Dual Viewer] GLB loaded, extracting polydata...`);
|
|
|
|
const bounds = polydata.getBounds();
|
|
|
|
// Store polydata and bounds based on which mapper is being used
|
|
if (targetMapper === singleMapper) {
|
|
// Overlay mode
|
|
singlePolydata = polydata;
|
|
mesh1Bounds = bounds; // Used by updateCombinedBounds()
|
|
console.log(`[GeomPack VTK Dual Viewer] Stored to singlePolydata, bounds:`, bounds);
|
|
} else if (isMesh1) {
|
|
// Side-by-side mode, mesh 1
|
|
leftPolydata = polydata;
|
|
mesh1Bounds = bounds;
|
|
console.log(`[GeomPack VTK Dual Viewer] Stored to leftPolydata, bounds:`, bounds);
|
|
} else {
|
|
// Side-by-side mode, mesh 2
|
|
rightPolydata = polydata;
|
|
mesh2Bounds = bounds;
|
|
console.log(`[GeomPack VTK Dual Viewer] Stored to rightPolydata, bounds:`, bounds);
|
|
}
|
|
} else {
|
|
// STL/OBJ/VTP handling
|
|
if (isSTL) {
|
|
reader.parseAsArrayBuffer(arrayBuffer);
|
|
} else if (isOBJ) {
|
|
const text = new TextDecoder().decode(arrayBuffer);
|
|
reader.parseAsText(text);
|
|
} else if (isVTP) {
|
|
reader.parseAsArrayBuffer(arrayBuffer);
|
|
}
|
|
|
|
const polydata = reader.getOutputData();
|
|
if (!polydata) {
|
|
throw new Error('Failed to parse mesh data');
|
|
}
|
|
|
|
// Extract field information from reader output (before normals filter)
|
|
// We'll store the actual polydata reference later from mapper input
|
|
if (isVTP) {
|
|
const pointData = polydata.getPointData();
|
|
const cellData = polydata.getCellData();
|
|
const fields = [];
|
|
|
|
for (let i = 0; i < pointData.getNumberOfArrays(); i++) {
|
|
const array = pointData.getArray(i);
|
|
fields.push(array.getName());
|
|
}
|
|
for (let i = 0; i < cellData.getNumberOfArrays(); i++) {
|
|
const array = cellData.getArray(i);
|
|
fields.push(`face.${array.getName()}`);
|
|
}
|
|
|
|
if (isMesh1) {
|
|
fieldNames1 = fields;
|
|
} else {
|
|
fieldNames2 = fields;
|
|
}
|
|
}
|
|
|
|
// Set up pipeline
|
|
const normalsFilter = vtkPolyDataNormals.newInstance();
|
|
normalsFilter.setInputConnection(reader.getOutputPort());
|
|
targetMapper.setInputConnection(normalsFilter.getOutputPort());
|
|
targetMapper.setScalarVisibility(false);
|
|
targetMapper.update();
|
|
|
|
// CRITICAL: Store the polydata from mapper input, NOT reader output!
|
|
// The normals filter creates a new polydata with computed normals
|
|
const mapperPolydata = targetMapper.getInputData();
|
|
|
|
if (isMesh1) {
|
|
leftPolydata = mapperPolydata; // Store mapper's input
|
|
mesh1Bounds = mapperPolydata.getBounds();
|
|
} else {
|
|
rightPolydata = mapperPolydata; // Store mapper's input
|
|
mesh2Bounds = mapperPolydata.getBounds();
|
|
}
|
|
}
|
|
|
|
console.log(`[GeomPack VTK Dual Viewer] Mesh ${isMesh1 ? '1' : '2'} Loaded successfully`);
|
|
} catch (error) {
|
|
console.error(`[GeomPack VTK Dual Viewer] Error loading mesh ${isMesh1 ? '1' : '2'}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Calculate combined bounds
|
|
function updateCombinedBounds() {
|
|
if (mesh1Bounds && mesh2Bounds) {
|
|
combinedBounds = [
|
|
Math.min(mesh1Bounds[0], mesh2Bounds[0]),
|
|
Math.max(mesh1Bounds[1], mesh2Bounds[1]),
|
|
Math.min(mesh1Bounds[2], mesh2Bounds[2]),
|
|
Math.max(mesh1Bounds[3], mesh2Bounds[3]),
|
|
Math.min(mesh1Bounds[4], mesh2Bounds[4]),
|
|
Math.max(mesh1Bounds[5], mesh2Bounds[5])
|
|
];
|
|
} else if (mesh1Bounds) {
|
|
combinedBounds = mesh1Bounds;
|
|
} else if (mesh2Bounds) {
|
|
combinedBounds = mesh2Bounds;
|
|
}
|
|
}
|
|
|
|
// Position camera at an angle
|
|
function positionCamera(renderer, bounds) {
|
|
const camera = renderer.getActiveCamera();
|
|
const center = [
|
|
(bounds[0] + bounds[1]) / 2,
|
|
(bounds[2] + bounds[3]) / 2,
|
|
(bounds[4] + bounds[5]) / 2
|
|
];
|
|
const maxDim = Math.max(
|
|
bounds[1] - bounds[0],
|
|
bounds[3] - bounds[2],
|
|
bounds[5] - bounds[4]
|
|
);
|
|
const distance = maxDim * 2.5;
|
|
|
|
camera.setPosition(
|
|
center[0] + distance,
|
|
center[1] + distance,
|
|
center[2] + distance
|
|
);
|
|
camera.setFocalPoint(center[0], center[1], center[2]);
|
|
camera.setViewUp(0, 0, 1);
|
|
renderer.resetCamera(bounds);
|
|
}
|
|
|
|
// Field visualization
|
|
function applyFieldToMapper(mapper, polydata, fieldName, sharedRange = null) {
|
|
if (!fieldName || fieldName === 'none') {
|
|
mapper.setScalarVisibility(false);
|
|
return;
|
|
}
|
|
|
|
const isCellData = fieldName.startsWith('face.');
|
|
const actualFieldName = isCellData ? fieldName.substring(5) : fieldName;
|
|
|
|
const data = isCellData ? polydata.getCellData() : polydata.getPointData();
|
|
let scalarName = actualFieldName;
|
|
let scalarArray = data.getArrayByName(actualFieldName);
|
|
|
|
// X/Y/Z channel for multi-component fields (e.g. normals): extract the
|
|
// chosen component into a temp scalar array and colour by that.
|
|
const channel = document.getElementById('channelSelector')?.value || 'magnitude';
|
|
const channelActive = scalarArray && scalarArray.getNumberOfComponents() > 1 && channel !== 'magnitude';
|
|
if (channelActive) {
|
|
const comp = parseInt(channel);
|
|
const nc = scalarArray.getNumberOfComponents();
|
|
const nt = scalarArray.getNumberOfTuples();
|
|
const src = scalarArray.getData();
|
|
const out = new Float32Array(nt);
|
|
for (let i = 0; i < nt; i++) out[i] = src[i * nc + comp];
|
|
scalarName = actualFieldName + '__c' + comp;
|
|
const vtkDataArray = vtk.Common.Core.vtkDataArray;
|
|
if (data.getArrayByName(scalarName)) data.removeArrayByName(scalarName);
|
|
data.addArray(vtkDataArray.newInstance({ name: scalarName, numberOfComponents: 1, values: out }));
|
|
scalarArray = data.getArrayByName(scalarName);
|
|
}
|
|
|
|
if (scalarArray) {
|
|
const dataRange = scalarArray.getRange();
|
|
// Manual colormap range (settings) overrides everything when Auto is off.
|
|
const autoRange = document.getElementById('autoColorRange')?.checked !== false;
|
|
let min, max;
|
|
if (!autoRange) {
|
|
const mn = parseFloat(document.getElementById('colormapMin')?.value);
|
|
const mx = parseFloat(document.getElementById('colormapMax')?.value);
|
|
if (!isNaN(mn) && !isNaN(mx)) { min = mn; max = mx; }
|
|
}
|
|
if (min === undefined) {
|
|
// a per-component channel auto-ranges itself (the shared magnitude
|
|
// range wouldn't match), otherwise honour the synchronized range.
|
|
[min, max] = (channelActive || !sharedRange) ? dataRange : sharedRange;
|
|
// reflect the active auto range in the (disabled) Min/Max inputs
|
|
const mnEl = document.getElementById('colormapMin');
|
|
const mxEl = document.getElementById('colormapMax');
|
|
if (autoRange && mnEl && mxEl) { mnEl.value = (+min).toFixed(3); mxEl.value = (+max).toFixed(3); }
|
|
}
|
|
|
|
// Check for degenerate range - if shared range provided (synchronized mode), don't disable
|
|
const epsilon = 1e-10;
|
|
const isDegenerate = Math.abs(max - min) < epsilon;
|
|
|
|
if (isDegenerate && !sharedRange) {
|
|
console.warn(`[GeomPack VTK Dual Viewer] Field "${fieldName}" has zero-width range [${min}, ${max}] - disabling visualization.`);
|
|
mapper.setScalarVisibility(false);
|
|
return;
|
|
}
|
|
|
|
data.setActiveScalars(scalarName);
|
|
mapper.setScalarVisibility(true);
|
|
|
|
if (isCellData) {
|
|
mapper.setScalarModeToUseCellData();
|
|
} else {
|
|
mapper.setScalarModeToUsePointData();
|
|
}
|
|
|
|
mapper.setScalarRange(min, max);
|
|
|
|
// Apply colormap
|
|
try {
|
|
const colormapName = document.getElementById('colormapSelector')?.value || 'erdc_rainbow_bright';
|
|
const lookupTable = vtkColorTransferFunction.newInstance();
|
|
const preset = vtkColorMaps.getPresetByName(colormapName);
|
|
|
|
if (preset) {
|
|
lookupTable.applyColorMap(preset);
|
|
lookupTable.setMappingRange(min, max);
|
|
lookupTable.updateRange();
|
|
mapper.setLookupTable(lookupTable);
|
|
} else {
|
|
console.error(`[GeomPack VTK Dual Viewer] Colormap preset "${colormapName}" not found`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[GeomPack VTK Dual Viewer] Error applying colormap:`, error);
|
|
}
|
|
|
|
mapper.update();
|
|
} else {
|
|
console.warn(`[GeomPack VTK Dual Viewer] Field "${fieldName}" not found in polydata`);
|
|
}
|
|
}
|
|
|
|
// Populate colormap selector
|
|
function populateColormapSelector() {
|
|
const selector = document.getElementById('colormapSelector');
|
|
if (!selector) return;
|
|
|
|
// Only include VTK.js presets that are confirmed to exist
|
|
const presets = [
|
|
'erdc_rainbow_bright',
|
|
'Cool to Warm',
|
|
'Blue to Red Rainbow',
|
|
'X Ray',
|
|
'jet',
|
|
'hsv',
|
|
'hot',
|
|
'cool',
|
|
'rainbow',
|
|
'grayscale'
|
|
];
|
|
|
|
selector.innerHTML = '';
|
|
presets.forEach(preset => {
|
|
const option = document.createElement('option');
|
|
option.value = preset;
|
|
option.textContent = preset;
|
|
selector.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// Field mode controls
|
|
function updateFieldModeUI() {
|
|
const fieldMode = document.getElementById('fieldMode').value;
|
|
const field1Label = document.getElementById('field1Label');
|
|
const field2Label = document.getElementById('field2Label');
|
|
const sharedFieldLabel = document.getElementById('sharedFieldLabel');
|
|
|
|
// Hide all
|
|
field1Label.style.display = 'none';
|
|
field2Label.style.display = 'none';
|
|
sharedFieldLabel.style.display = 'none';
|
|
|
|
if (fieldMode === 'independent' && viewerLayout === 'side_by_side') {
|
|
field1Label.style.display = 'flex';
|
|
field2Label.style.display = 'flex';
|
|
} else if (fieldMode === 'synchronized') {
|
|
sharedFieldLabel.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// Populate field selectors
|
|
function populateFieldSelectors() {
|
|
commonFields = fieldNames1.filter(f => fieldNames2.includes(f));
|
|
|
|
const field1Selector = document.getElementById('field1Selector');
|
|
const field2Selector = document.getElementById('field2Selector');
|
|
const sharedFieldSelector = document.getElementById('sharedFieldSelector');
|
|
|
|
// Preserve current selections
|
|
const currentField1 = field1Selector?.value || 'none';
|
|
const currentField2 = field2Selector?.value || 'none';
|
|
const currentSharedField = sharedFieldSelector?.value || 'none';
|
|
|
|
if (field1Selector) {
|
|
field1Selector.innerHTML = '<option value="none">None</option>';
|
|
fieldNames1.forEach(field => {
|
|
const option = document.createElement('option');
|
|
option.value = field;
|
|
option.textContent = field;
|
|
if (field === currentField1) {
|
|
option.selected = true;
|
|
}
|
|
field1Selector.appendChild(option);
|
|
});
|
|
}
|
|
|
|
if (field2Selector) {
|
|
field2Selector.innerHTML = '<option value="none">None</option>';
|
|
fieldNames2.forEach(field => {
|
|
const option = document.createElement('option');
|
|
option.value = field;
|
|
option.textContent = field;
|
|
if (field === currentField2) {
|
|
option.selected = true;
|
|
}
|
|
field2Selector.appendChild(option);
|
|
});
|
|
}
|
|
|
|
if (sharedFieldSelector) {
|
|
sharedFieldSelector.innerHTML = '<option value="none">None</option>';
|
|
commonFields.forEach(field => {
|
|
const option = document.createElement('option');
|
|
option.value = field;
|
|
option.textContent = field;
|
|
if (field === currentSharedField) {
|
|
option.selected = true;
|
|
}
|
|
sharedFieldSelector.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// Show field controls if fields exist
|
|
if (fieldNames1.length > 0 || fieldNames2.length > 0) {
|
|
document.getElementById('fieldControls').classList.add('active');
|
|
const _cl = document.getElementById('channelLabel');
|
|
if (_cl) _cl.style.display = 'flex';
|
|
updateFieldModeUI();
|
|
}
|
|
}
|
|
|
|
// Listen for messages from parent window
|
|
window.addEventListener('message', async (event) => {
|
|
if (event.data.type === 'LOAD_DUAL_MESH') {
|
|
const data = event.data;
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] Received LOAD_DUAL_MESH message:', data);
|
|
|
|
viewerLayout = data.layout;
|
|
|
|
loading.textContent = 'Loading meshes...';
|
|
loading.style.display = 'block';
|
|
_dlReset();
|
|
setDownloadIndeterminate();
|
|
|
|
try {
|
|
if (viewerLayout === 'side_by_side') {
|
|
// Side-by-side mode
|
|
sideBySideContainer.classList.add('active');
|
|
overlayContainer.classList.remove('active');
|
|
initSideBySide();
|
|
|
|
// Check if meshes have changed before reloading
|
|
const mesh1Changed = currentMesh1Path !== data.mesh1Filepath;
|
|
const mesh2Changed = currentMesh2Path !== data.mesh2Filepath;
|
|
|
|
if (mesh1Changed || !leftPolydata) {
|
|
await loadMesh(data.mesh1Filepath, leftMapper, true);
|
|
currentMesh1Path = data.mesh1Filepath;
|
|
}
|
|
|
|
if (mesh2Changed || !rightPolydata) {
|
|
await loadMesh(data.mesh2Filepath, rightMapper, false);
|
|
currentMesh2Path = data.mesh2Filepath;
|
|
}
|
|
|
|
updateCombinedBounds();
|
|
|
|
_pendingCameraState = data.cameraState || null;
|
|
if (!applyCameraState(_pendingCameraState)) {
|
|
positionCamera(leftRenderer, combinedBounds);
|
|
positionCamera(rightRenderer, combinedBounds);
|
|
synchronizeCameras(leftRenderer.getActiveCamera(), rightRenderer.getActiveCamera());
|
|
}
|
|
|
|
// Apply opacity values
|
|
if (data.opacity1 !== undefined && leftActor) {
|
|
leftActor.getProperty().setOpacity(data.opacity1);
|
|
}
|
|
if (data.opacity2 !== undefined && rightActor) {
|
|
rightActor.getProperty().setOpacity(data.opacity2);
|
|
}
|
|
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
|
|
} else {
|
|
// Overlay mode
|
|
sideBySideContainer.classList.remove('active');
|
|
overlayContainer.classList.add('active');
|
|
initOverlay();
|
|
|
|
// Check if mesh has changed before reloading
|
|
const meshChanged = currentMeshPath !== data.meshFilepath;
|
|
|
|
if (meshChanged || !singlePolydata) {
|
|
await loadMesh(data.meshFilepath, singleMapper, true);
|
|
currentMeshPath = data.meshFilepath;
|
|
}
|
|
|
|
updateCombinedBounds();
|
|
_pendingCameraState = data.cameraState || null;
|
|
if (!applyCameraState(_pendingCameraState)) {
|
|
positionCamera(singleRenderer, combinedBounds);
|
|
}
|
|
|
|
// Apply average opacity for overlay mode
|
|
if (data.opacity1 !== undefined && data.opacity2 !== undefined && singleActor) {
|
|
const avgOpacity = (data.opacity1 + data.opacity2) / 2.0;
|
|
singleActor.getProperty().setOpacity(avgOpacity);
|
|
}
|
|
|
|
renderWithClippingRangeUpdate(singleRenderer, singleRenderWindow);
|
|
}
|
|
|
|
// Populate field selectors (preserves current selections)
|
|
populateColormapSelector();
|
|
populateFieldSelectors();
|
|
|
|
// Restore colormap from parent widget state
|
|
const selectedColormap = data.selectedColormap || "";
|
|
if (selectedColormap) {
|
|
const colormapSel = document.getElementById('colormapSelector');
|
|
if (colormapSel && Array.from(colormapSel.options).some(o => o.value === selectedColormap)) {
|
|
colormapSel.value = selectedColormap;
|
|
}
|
|
}
|
|
|
|
// Restore selected field from parent widget state
|
|
const selectedField = data.selectedField || "";
|
|
if (selectedField) {
|
|
const sharedSel = document.getElementById('sharedFieldSelector');
|
|
if (sharedSel && Array.from(sharedSel.options).some(o => o.value === selectedField)) {
|
|
sharedSel.value = selectedField;
|
|
}
|
|
}
|
|
|
|
// Re-apply field visualization after populating selectors
|
|
const fieldMode = document.getElementById('fieldMode')?.value || 'synchronized';
|
|
|
|
if (viewerLayout === 'side_by_side') {
|
|
if (fieldMode === 'independent') {
|
|
const field1 = document.getElementById('field1Selector')?.value;
|
|
const field2 = document.getElementById('field2Selector')?.value;
|
|
if (field1 && field1 !== 'none' && leftPolydata) {
|
|
applyFieldToMapper(leftMapper, leftPolydata, field1);
|
|
}
|
|
if (field2 && field2 !== 'none' && rightPolydata) {
|
|
applyFieldToMapper(rightMapper, rightPolydata, field2);
|
|
}
|
|
} else if (fieldMode === 'synchronized') {
|
|
const sharedField = document.getElementById('sharedFieldSelector')?.value;
|
|
if (sharedField && sharedField !== 'none') {
|
|
// Calculate shared range from both meshes
|
|
let sharedRange = null;
|
|
if (leftPolydata && rightPolydata) {
|
|
const isCellData = sharedField.startsWith('face.');
|
|
const actualFieldName = isCellData ? sharedField.substring(5) : sharedField;
|
|
|
|
const leftData = isCellData ? leftPolydata.getCellData() : leftPolydata.getPointData();
|
|
const rightData = isCellData ? rightPolydata.getCellData() : rightPolydata.getPointData();
|
|
|
|
const leftArray = leftData.getArrayByName(actualFieldName);
|
|
const rightArray = rightData.getArrayByName(actualFieldName);
|
|
|
|
if (leftArray && rightArray) {
|
|
const [leftMin, leftMax] = leftArray.getRange();
|
|
const [rightMin, rightMax] = rightArray.getRange();
|
|
sharedRange = [Math.min(leftMin, rightMin), Math.max(leftMax, rightMax)];
|
|
}
|
|
}
|
|
|
|
if (leftPolydata) applyFieldToMapper(leftMapper, leftPolydata, sharedField, sharedRange);
|
|
if (rightPolydata) applyFieldToMapper(rightMapper, rightPolydata, sharedField, sharedRange);
|
|
}
|
|
}
|
|
|
|
// Re-render both viewports
|
|
if (leftRenderWindow && rightRenderWindow) {
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
}
|
|
}
|
|
|
|
// Apply showEdges from parent widget state
|
|
if (data.showEdges !== undefined) {
|
|
const edgeViz = !!data.showEdges;
|
|
if (leftActor) leftActor.getProperty().setEdgeVisibility(edgeViz);
|
|
if (rightActor) rightActor.getProperty().setEdgeVisibility(edgeViz);
|
|
if (singleActor) singleActor.getProperty().setEdgeVisibility(edgeViz);
|
|
document.getElementById('showEdges').checked = edgeViz;
|
|
|
|
// Re-render after edge visibility change
|
|
if (viewerLayout === 'side_by_side') {
|
|
if (leftRenderWindow) leftRenderWindow.render();
|
|
if (rightRenderWindow) rightRenderWindow.render();
|
|
} else {
|
|
if (singleRenderWindow) singleRenderWindow.render();
|
|
}
|
|
}
|
|
|
|
// Mark as initialized
|
|
isInitialized = true;
|
|
|
|
loading.style.display = 'none';
|
|
setDownloadBar(null);
|
|
console.log('[GeomPack VTK Dual Viewer] Meshes Loaded successfully');
|
|
|
|
} catch (error) {
|
|
console.error('[GeomPack VTK Dual Viewer] Error loading meshes:', error);
|
|
loading.textContent = `Error: ${error.message}`;
|
|
loading.style.color = '#ff6b6b';
|
|
setDownloadBar(null);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Settings panel handlers
|
|
const settingsButton = document.getElementById('settingsButton');
|
|
const settingsPanel = document.getElementById('settingsPanel');
|
|
const settingsOverlay = document.getElementById('settingsOverlay');
|
|
const pointSizeInput = document.getElementById('pointSize');
|
|
const meshColorInput = document.getElementById('meshColor');
|
|
const edgeWidthInput = document.getElementById('edgeWidth');
|
|
const representationSettings = document.getElementById('representationSettings');
|
|
const parallelProjectionInput = document.getElementById('parallelProjectionSetting');
|
|
const applySettingsButton = document.getElementById('applySettings');
|
|
const cancelSettingsButton = document.getElementById('cancelSettings');
|
|
|
|
// Store current settings
|
|
let currentPointSize = 3;
|
|
let currentMeshColor = '#99ccff';
|
|
let currentEdgeWidth = 1;
|
|
let currentRepresentation = 2; // 2 = Surface
|
|
let currentParallelProjection = false;
|
|
|
|
// Open settings panel
|
|
settingsButton.addEventListener('click', () => {
|
|
// Update inputs with current values
|
|
pointSizeInput.value = currentPointSize;
|
|
meshColorInput.value = currentMeshColor;
|
|
edgeWidthInput.value = currentEdgeWidth;
|
|
representationSettings.value = currentRepresentation.toString();
|
|
parallelProjectionInput.checked = currentParallelProjection;
|
|
|
|
settingsPanel.classList.add('visible');
|
|
settingsOverlay.classList.add('visible');
|
|
});
|
|
|
|
// Close settings panel
|
|
function closeSettingsPanel() {
|
|
settingsPanel.classList.remove('visible');
|
|
settingsOverlay.classList.remove('visible');
|
|
}
|
|
|
|
// Close on overlay click
|
|
settingsOverlay.addEventListener('click', closeSettingsPanel);
|
|
|
|
// Cancel button
|
|
cancelSettingsButton.addEventListener('click', closeSettingsPanel);
|
|
|
|
// Apply settings
|
|
applySettingsButton.addEventListener('click', () => {
|
|
const newPointSize = parseFloat(pointSizeInput.value);
|
|
const newColor = meshColorInput.value;
|
|
const newEdgeWidth = parseFloat(edgeWidthInput.value);
|
|
const newRepresentation = parseInt(representationSettings.value);
|
|
const newParallelProjection = parallelProjectionInput.checked;
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] Applying settings - Point Size:', newPointSize, 'Color:', newColor, 'Edge Width:', newEdgeWidth, 'Representation:', newRepresentation, 'Parallel:', newParallelProjection);
|
|
|
|
// Convert hex color to RGB (0-1 range)
|
|
const r = parseInt(newColor.substr(1, 2), 16) / 255;
|
|
const g = parseInt(newColor.substr(3, 2), 16) / 255;
|
|
const b = parseInt(newColor.substr(5, 2), 16) / 255;
|
|
|
|
// Apply settings to actors
|
|
if (viewerLayout === 'side_by_side') {
|
|
// Apply to both actors in side-by-side mode
|
|
const leftProperty = leftActor.getProperty();
|
|
const rightProperty = rightActor.getProperty();
|
|
|
|
leftProperty.setPointSize(newPointSize);
|
|
leftProperty.setColor(r, g, b);
|
|
leftProperty.setLineWidth(newEdgeWidth);
|
|
leftProperty.setRepresentation(newRepresentation);
|
|
|
|
rightProperty.setPointSize(newPointSize);
|
|
rightProperty.setColor(r, g, b);
|
|
rightProperty.setLineWidth(newEdgeWidth);
|
|
rightProperty.setRepresentation(newRepresentation);
|
|
|
|
// Apply parallel projection to both cameras
|
|
leftRenderer.getActiveCamera().setParallelProjection(newParallelProjection);
|
|
rightRenderer.getActiveCamera().setParallelProjection(newParallelProjection);
|
|
|
|
leftRenderWindow.render();
|
|
rightRenderWindow.render();
|
|
} else {
|
|
// Apply to single actor in overlay mode
|
|
const property = singleActor.getProperty();
|
|
property.setPointSize(newPointSize);
|
|
property.setColor(r, g, b);
|
|
property.setLineWidth(newEdgeWidth);
|
|
property.setRepresentation(newRepresentation);
|
|
|
|
// Apply parallel projection to camera
|
|
singleRenderer.getActiveCamera().setParallelProjection(newParallelProjection);
|
|
|
|
singleRenderWindow.render();
|
|
}
|
|
|
|
// Commit the colormap range (Min/Max/Auto) to the active field(s).
|
|
reapplyFields();
|
|
|
|
// Update stored settings
|
|
currentPointSize = newPointSize;
|
|
currentMeshColor = newColor;
|
|
currentEdgeWidth = newEdgeWidth;
|
|
currentRepresentation = newRepresentation;
|
|
currentParallelProjection = newParallelProjection;
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] Settings applied - RGB:', [r, g, b], 'Point Size:', newPointSize, 'Line Width:', newEdgeWidth, 'Representation:', newRepresentation, 'Parallel:', newParallelProjection);
|
|
|
|
// Close panel
|
|
closeSettingsPanel();
|
|
});
|
|
|
|
// Event handlers
|
|
document.getElementById('showEdges').addEventListener('change', (e) => {
|
|
const edgeViz = e.target.checked;
|
|
if (viewerLayout === 'side_by_side') {
|
|
leftActor.getProperty().setEdgeVisibility(edgeViz);
|
|
rightActor.getProperty().setEdgeVisibility(edgeViz);
|
|
leftRenderWindow.render();
|
|
rightRenderWindow.render();
|
|
} else {
|
|
singleActor.getProperty().setEdgeVisibility(edgeViz);
|
|
singleRenderWindow.render();
|
|
}
|
|
window.parent.postMessage({ type: 'WIDGET_UPDATE', widget: 'show_edges', value: e.target.checked }, '*');
|
|
});
|
|
|
|
document.getElementById('resetCamera').addEventListener('click', () => {
|
|
if (!combinedBounds) return;
|
|
|
|
if (viewerLayout === 'side_by_side') {
|
|
positionCamera(leftRenderer, combinedBounds);
|
|
positionCamera(rightRenderer, combinedBounds);
|
|
synchronizeCameras(leftRenderer.getActiveCamera(), rightRenderer.getActiveCamera());
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
} else {
|
|
positionCamera(singleRenderer, combinedBounds);
|
|
renderWithClippingRangeUpdate(singleRenderer, singleRenderWindow);
|
|
}
|
|
});
|
|
|
|
// Field mode change
|
|
document.getElementById('fieldMode').addEventListener('change', () => {
|
|
updateFieldModeUI();
|
|
});
|
|
|
|
// Field selection
|
|
document.getElementById('field1Selector')?.addEventListener('change', (e) => {
|
|
if (leftPolydata) {
|
|
applyFieldToMapper(leftMapper, leftPolydata, e.target.value);
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
}
|
|
});
|
|
|
|
document.getElementById('field2Selector')?.addEventListener('change', (e) => {
|
|
if (rightPolydata) {
|
|
applyFieldToMapper(rightMapper, rightPolydata, e.target.value);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
}
|
|
});
|
|
|
|
document.getElementById('sharedFieldSelector')?.addEventListener('change', (e) => {
|
|
const fieldName = e.target.value;
|
|
window.parent.postMessage({
|
|
type: 'WIDGET_UPDATE',
|
|
widget: 'selected_field',
|
|
value: fieldName
|
|
}, '*');
|
|
if (viewerLayout === 'side_by_side') {
|
|
// Calculate shared range from both meshes for synchronized mode
|
|
let sharedRange = null;
|
|
if (fieldName && fieldName !== 'none' && leftPolydata && rightPolydata) {
|
|
const isCellData = fieldName.startsWith('face.');
|
|
const actualFieldName = isCellData ? fieldName.substring(5) : fieldName;
|
|
|
|
const leftData = isCellData ? leftPolydata.getCellData() : leftPolydata.getPointData();
|
|
const rightData = isCellData ? rightPolydata.getCellData() : rightPolydata.getPointData();
|
|
|
|
const leftArray = leftData.getArrayByName(actualFieldName);
|
|
const rightArray = rightData.getArrayByName(actualFieldName);
|
|
|
|
if (leftArray && rightArray) {
|
|
const [leftMin, leftMax] = leftArray.getRange();
|
|
const [rightMin, rightMax] = rightArray.getRange();
|
|
sharedRange = [Math.min(leftMin, rightMin), Math.max(leftMax, rightMax)];
|
|
}
|
|
}
|
|
|
|
if (leftPolydata) {
|
|
applyFieldToMapper(leftMapper, leftPolydata, fieldName, sharedRange);
|
|
}
|
|
if (rightPolydata) {
|
|
applyFieldToMapper(rightMapper, rightPolydata, fieldName, sharedRange);
|
|
}
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
}
|
|
});
|
|
|
|
// Re-apply the current field selections (used by colormap + channel changes)
|
|
function reapplyFields() {
|
|
const fieldMode = document.getElementById('fieldMode').value;
|
|
|
|
if (fieldMode === 'independent') {
|
|
const field1 = document.getElementById('field1Selector').value;
|
|
const field2 = document.getElementById('field2Selector').value;
|
|
|
|
if (leftPolydata) applyFieldToMapper(leftMapper, leftPolydata, field1);
|
|
if (rightPolydata) applyFieldToMapper(rightMapper, rightPolydata, field2);
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
} else if (fieldMode === 'synchronized') {
|
|
const field = document.getElementById('sharedFieldSelector').value;
|
|
|
|
// Calculate shared range from both meshes for synchronized mode
|
|
let sharedRange = null;
|
|
if (field && field !== 'none' && leftPolydata && rightPolydata) {
|
|
const isCellData = field.startsWith('face.');
|
|
const actualFieldName = isCellData ? field.substring(5) : field;
|
|
|
|
const leftData = isCellData ? leftPolydata.getCellData() : leftPolydata.getPointData();
|
|
const rightData = isCellData ? rightPolydata.getCellData() : rightPolydata.getPointData();
|
|
|
|
const leftArray = leftData.getArrayByName(actualFieldName);
|
|
const rightArray = rightData.getArrayByName(actualFieldName);
|
|
|
|
if (leftArray && rightArray) {
|
|
const [leftMin, leftMax] = leftArray.getRange();
|
|
const [rightMin, rightMax] = rightArray.getRange();
|
|
sharedRange = [Math.min(leftMin, rightMin), Math.max(leftMax, rightMax)];
|
|
}
|
|
}
|
|
|
|
if (leftPolydata) applyFieldToMapper(leftMapper, leftPolydata, field, sharedRange);
|
|
if (rightPolydata) applyFieldToMapper(rightMapper, rightPolydata, field, sharedRange);
|
|
if (viewerLayout === 'side_by_side') {
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
}
|
|
}
|
|
}
|
|
|
|
document.getElementById('colormapSelector')?.addEventListener('change', () => {
|
|
window.parent.postMessage({
|
|
type: 'WIDGET_UPDATE',
|
|
widget: 'selected_colormap',
|
|
value: document.getElementById('colormapSelector').value
|
|
}, '*');
|
|
reapplyFields();
|
|
});
|
|
|
|
// X/Y/Z channel change -> re-colour by the chosen component
|
|
document.getElementById('channelSelector')?.addEventListener('change', reapplyFields);
|
|
|
|
// Manual colormap range controls
|
|
document.getElementById('autoColorRange')?.addEventListener('change', (e) => {
|
|
document.getElementById('colormapMin').disabled = e.target.checked;
|
|
document.getElementById('colormapMax').disabled = e.target.checked;
|
|
reapplyFields();
|
|
});
|
|
document.getElementById('colormapMin')?.addEventListener('change', () => {
|
|
if (!document.getElementById('autoColorRange').checked) reapplyFields();
|
|
});
|
|
document.getElementById('colormapMax')?.addEventListener('change', () => {
|
|
if (!document.getElementById('autoColorRange').checked) reapplyFields();
|
|
});
|
|
|
|
// Camera view buttons
|
|
function setCameraView(direction) {
|
|
if (!combinedBounds) return;
|
|
|
|
const center = [
|
|
(combinedBounds[0] + combinedBounds[1]) / 2,
|
|
(combinedBounds[2] + combinedBounds[3]) / 2,
|
|
(combinedBounds[4] + combinedBounds[5]) / 2
|
|
];
|
|
const maxDim = Math.max(
|
|
combinedBounds[1] - combinedBounds[0],
|
|
combinedBounds[3] - combinedBounds[2],
|
|
combinedBounds[5] - combinedBounds[4]
|
|
);
|
|
const distance = maxDim * 2.5;
|
|
|
|
let position, viewUp;
|
|
switch(direction) {
|
|
case '+X':
|
|
position = [center[0] + distance, center[1], center[2]];
|
|
viewUp = [0, 0, 1];
|
|
break;
|
|
case '-X':
|
|
position = [center[0] - distance, center[1], center[2]];
|
|
viewUp = [0, 0, 1];
|
|
break;
|
|
case '+Y':
|
|
position = [center[0], center[1] + distance, center[2]];
|
|
viewUp = [0, 0, 1];
|
|
break;
|
|
case '-Y':
|
|
position = [center[0], center[1] - distance, center[2]];
|
|
viewUp = [0, 0, 1];
|
|
break;
|
|
case '+Z':
|
|
position = [center[0], center[1], center[2] + distance];
|
|
viewUp = [0, 1, 0];
|
|
break;
|
|
case '-Z':
|
|
position = [center[0], center[1], center[2] - distance];
|
|
viewUp = [0, 1, 0];
|
|
break;
|
|
}
|
|
|
|
if (viewerLayout === 'side_by_side') {
|
|
[leftRenderer.getActiveCamera(), rightRenderer.getActiveCamera()].forEach(camera => {
|
|
camera.setPosition(...position);
|
|
camera.setFocalPoint(...center);
|
|
camera.setViewUp(...viewUp);
|
|
});
|
|
leftRenderer.resetCamera();
|
|
rightRenderer.resetCamera();
|
|
renderWithClippingRangeUpdate(leftRenderer, leftRenderWindow);
|
|
renderWithClippingRangeUpdate(rightRenderer, rightRenderWindow);
|
|
} else {
|
|
const camera = singleRenderer.getActiveCamera();
|
|
camera.setPosition(...position);
|
|
camera.setFocalPoint(...center);
|
|
camera.setViewUp(...viewUp);
|
|
singleRenderer.resetCamera();
|
|
renderWithClippingRangeUpdate(singleRenderer, singleRenderWindow);
|
|
}
|
|
}
|
|
|
|
document.getElementById('viewPosX').addEventListener('click', () => setCameraView('+X'));
|
|
document.getElementById('viewNegX').addEventListener('click', () => setCameraView('-X'));
|
|
document.getElementById('viewPosY').addEventListener('click', () => setCameraView('+Y'));
|
|
document.getElementById('viewNegY').addEventListener('click', () => setCameraView('-Y'));
|
|
document.getElementById('viewPosZ').addEventListener('click', () => setCameraView('+Z'));
|
|
document.getElementById('viewNegZ').addEventListener('click', () => setCameraView('-Z'));
|
|
|
|
// Window resize
|
|
window.addEventListener('resize', () => {
|
|
if (viewerLayout === 'side_by_side') {
|
|
leftFullScreenRenderer?.resize();
|
|
rightFullScreenRenderer?.resize();
|
|
leftRenderWindow?.render();
|
|
rightRenderWindow?.render();
|
|
} else {
|
|
singleFullScreenRenderer?.resize();
|
|
singleRenderWindow?.render();
|
|
}
|
|
});
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] Ready');
|
|
|
|
// Debug: Check if edges button is visible
|
|
setTimeout(() => {
|
|
const showEdgesCheckbox = document.getElementById('showEdges');
|
|
const controlsDiv = document.getElementById('controls');
|
|
const bodyEl = document.body;
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Edges checkbox element:', showEdgesCheckbox);
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Controls div:', controlsDiv);
|
|
|
|
if (showEdgesCheckbox) {
|
|
const rect = showEdgesCheckbox.getBoundingClientRect();
|
|
const computedStyle = window.getComputedStyle(showEdgesCheckbox);
|
|
const parentStyle = window.getComputedStyle(controlsDiv);
|
|
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Edges checkbox position:', {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
bottom: rect.bottom,
|
|
right: rect.right
|
|
});
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Edges checkbox style:', {
|
|
display: computedStyle.display,
|
|
visibility: computedStyle.visibility,
|
|
opacity: computedStyle.opacity,
|
|
position: computedStyle.position
|
|
});
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Controls div style:', {
|
|
display: parentStyle.display,
|
|
visibility: parentStyle.visibility,
|
|
position: parentStyle.position,
|
|
bottom: parentStyle.bottom,
|
|
height: parentStyle.height
|
|
});
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Body/viewport dimensions:', {
|
|
bodyHeight: bodyEl.offsetHeight,
|
|
bodyScrollHeight: bodyEl.scrollHeight,
|
|
windowHeight: window.innerHeight,
|
|
viewportHeight: document.documentElement.clientHeight
|
|
});
|
|
console.log('[GeomPack VTK Dual Viewer] DEBUG - Is edges checkbox visible in viewport?',
|
|
rect.top >= 0 && rect.bottom <= window.innerHeight);
|
|
} else {
|
|
console.error('[GeomPack VTK Dual Viewer] DEBUG - Edges checkbox NOT FOUND!');
|
|
}
|
|
}, 1000);
|
|
</script>
|
|
</body>
|
|
</html>
|