Files
PozzettiAndrea-ComfyUI-Geom…/web/viewer_vtk.html
T
2026-02-12 21:59:40 +01:00

666 lines
26 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeomPack VTK.js Viewer (Unified)</title>
<link rel="stylesheet" href="./js/viewer/ui/styles.css">
</head>
<body>
<div id="viewerContainer">
<div id="container"></div>
<div id="loading" class="loading">
<div class="spinner"></div>
<div class="loading-text">Ready to load mesh</div>
</div>
<!-- Settings overlay and panel -->
<div id="settingsOverlay" class="settings-overlay"></div>
<div id="settingsPanel" class="settings-panel">
<h3>Appearance Settings</h3>
<div class="setting-row">
<label>Point Size:</label>
<input type="number" id="pointSize" min="1" max="20" step="1" value="5">
</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="parallelProjection"> Parallel Projection
</label>
</div>
<div class="setting-row">
<label>
<input type="checkbox" id="showAxisIndicator"> Show Axis Indicator
</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 Range:</label>
</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;">
</div>
<div class="setting-row">
<label>Max:</label>
<input type="number" id="colormapMax" step="any" placeholder="Auto" style="width: 120px;">
</div>
<div class="button-row">
<button id="applySettings">Apply</button>
<button id="cancelSettings">Cancel</button>
</div>
</div>
</div>
<div id="controls">
<button id="settingsButton" class="settings-button" title="Appearance Settings">⚙</button>
<span class="control-separator"></span>
<label class="control-label">
<input type="checkbox" id="showEdges"> Edges
</label>
<span class="control-separator"></span>
<!-- Camera view buttons -->
<button class="view-button" data-view="+X">+X</button>
<button class="view-button" data-view="-X">-X</button>
<button class="view-button" data-view="+Y">+Y</button>
<button class="view-button" data-view="-Y">-Y</button>
<button class="view-button" data-view="+Z">+Z</button>
<button class="view-button" data-view="-Z">-Z</button>
<span class="control-separator"></span>
<label id="fieldLabel" class="control-label" style="display:none;">
Field:
<select id="fieldSelector"></select>
</label>
<label id="colormapLabel" class="control-label" style="display:none;">
Colormap:
<select id="colormapSelector"></select>
</label>
<button id="screenshot">Screenshot</button>
<button id="saveMesh" class="save-button">Save Mesh</button>
</div>
<!-- Load VTK.js bundle -->
<script src="./js/vtk-gltf.js"></script>
<!-- Load unified viewer bundle -->
<script src="./js/viewer-bundle.js"></script>
<script>
// Use unified viewer modules
const {
CameraController,
ActorManager,
FieldVisualization,
ScreenshotCapture,
AxisIndicator,
loadMesh,
detectFormat,
sendMeshLoaded,
sendError,
extractFilename,
hexToRgb,
rgbToString
} = window.GeomPackViewer;
console.log('[GeomPack VTK Viewer] Initializing unified viewer...');
// DOM elements
const loading = document.getElementById('loading');
const loadingText = loading.querySelector('.loading-text');
const container = document.getElementById('container');
// VTK setup
const vtkRenderWindow = vtk.Rendering.Core.vtkRenderWindow;
const vtkRenderer = vtk.Rendering.Core.vtkRenderer;
const vtkOpenGLRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow;
const vtkRenderWindowInteractor = vtk.Rendering.Core.vtkRenderWindowInteractor;
const vtkInteractorStyleTrackballCamera = vtk.Interaction.Style.vtkInteractorStyleTrackballCamera;
const vtkActor = vtk.Rendering.Core.vtkActor;
const vtkMapper = vtk.Rendering.Core.vtkMapper;
const vtkPolyDataNormals = vtk.Filters.Core.vtkPolyDataNormals;
// Create render window
const renderWindow = vtkRenderWindow.newInstance();
const renderer = vtkRenderer.newInstance();
renderer.setBackground(0.16, 0.16, 0.16);
renderWindow.addRenderer(renderer);
// Create OpenGL view
const openGLRenderWindow = vtkOpenGLRenderWindow.newInstance();
openGLRenderWindow.setContainer(container);
renderWindow.addView(openGLRenderWindow);
// Resize to container
const { width, height } = container.getBoundingClientRect();
openGLRenderWindow.setSize(width, height);
// Create interactor
const interactor = vtkRenderWindowInteractor.newInstance();
interactor.setView(openGLRenderWindow);
interactor.initialize();
interactor.bindEvents(container);
const interactorStyle = vtkInteractorStyleTrackballCamera.newInstance();
interactor.setInteractorStyle(interactorStyle);
// Initialize modules
const cameraController = new CameraController(renderer, renderWindow, {
distanceMultiplier: 2.5
});
const actorManager = new ActorManager(renderWindow);
const fieldVisualization = new FieldVisualization(vtk, renderWindow);
const screenshotCapture = new ScreenshotCapture(renderWindow);
// Create axis indicator (disabled by default)
const axisIndicator = new AxisIndicator(vtk, interactor, {
enabled: false,
corner: 'BOTTOM_LEFT'
});
// Create single actor/mapper for mesh
const actor = vtkActor.newInstance();
const mapper = vtkMapper.newInstance();
actor.setMapper(mapper);
// Configure default appearance
const property = actor.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);
renderer.addActor(actor);
// State
let currentReader = null;
let currentFilename = null;
let meshBounds = null;
let currentPolyData = null;
let isCurrentDataPointCloud = false;
// Settings state
let currentSettings = {
pointSize: 5,
meshColor: '#99ccff',
edgeWidth: 1,
representation: 2,
parallelProjection: false,
showAxisIndicator: false
};
// Show/hide loading
function showLoading(show, text = 'Loading mesh...') {
loading.style.display = show ? 'flex' : 'none';
loadingText.textContent = text;
}
// Load mesh function
async function loadMeshFile(filepath, lineWidth = 1.0) {
console.log('[GeomPack VTK Viewer] Loading:', filepath);
showLoading(true, 'Loading mesh...');
// Extract filename
const urlParams = new URLSearchParams(filepath.split('?')[1] || '');
currentFilename = urlParams.get('filename') || filepath.split('/').pop().split('?')[0];
try {
// Determine file type and create reader
const isSTL = filepath.includes('.stl');
const isOBJ = filepath.includes('.obj');
const isVTP = filepath.includes('.vtp');
if (!isSTL && !isOBJ && !isVTP) {
throw new Error('Unsupported format. Use STL, OBJ, or VTP.');
}
// Fetch file
const response = await fetch(filepath);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
// Create appropriate reader
if (isSTL) {
currentReader = vtk.IO.Geometry.vtkSTLReader.newInstance();
currentReader.parseAsArrayBuffer(arrayBuffer);
} else if (isOBJ) {
currentReader = vtk.IO.Misc.vtkOBJReader.newInstance();
const text = new TextDecoder().decode(arrayBuffer);
currentReader.parseAsText(text);
} else if (isVTP) {
currentReader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance();
currentReader.parseAsArrayBuffer(arrayBuffer);
}
const polydata = currentReader.getOutputData();
if (!polydata) {
throw new Error('Failed to parse mesh data');
}
currentPolyData = polydata;
meshBounds = polydata.getBounds();
// Detect point cloud (no polygons)
const numPolys = polydata.getNumberOfPolys();
isCurrentDataPointCloud = numPolys === 0;
if (isCurrentDataPointCloud) {
console.log('[GeomPack VTK Viewer] Detected point cloud, skipping normals filter');
// Connect mapper directly to polydata (no normals filter for point clouds)
mapper.setInputData(polydata);
// Lock representation to Points mode
property.setRepresentation(0);
property.setPointSize(currentSettings.pointSize);
} else {
// For meshes, use normals filter for proper shading
const normalsFilter = vtkPolyDataNormals.newInstance();
normalsFilter.setInputData(polydata);
normalsFilter.update();
// Use the filtered polydata so scalars are preserved
const filteredPolyData = normalsFilter.getOutputData();
mapper.setInputData(filteredPolyData);
currentPolyData = filteredPolyData; // Update reference for field visualization
}
mapper.update();
property.setLineWidth(lineWidth);
// Populate field selectors
populateFieldSelectors(polydata);
// Position camera
cameraController.positionInitialCamera(meshBounds);
// Render
renderWindow.render();
// Force resize and re-render
requestAnimationFrame(() => {
openGLRenderWindow.setSize(container.clientWidth, container.clientHeight);
renderer.resetCameraClippingRange();
renderWindow.render();
interactor.enable();
});
showLoading(false);
console.log('[GeomPack VTK Viewer] Mesh loaded:', polydata.getNumberOfPoints(), 'points');
} catch (error) {
console.error('[GeomPack VTK Viewer] Error:', error);
showLoading(true, `Error: ${error.message}`);
loading.style.color = '#ff6b6b';
if (window.parent) {
window.parent.postMessage({ type: 'MESH_ERROR', error: error.message }, '*');
}
}
}
// Populate field selectors from polydata
function populateFieldSelectors(polydata) {
const fieldSelector = document.getElementById('fieldSelector');
const fieldLabel = document.getElementById('fieldLabel');
const colormapSelector = document.getElementById('colormapSelector');
const colormapLabel = document.getElementById('colormapLabel');
fieldSelector.innerHTML = '<option value="">None</option>';
// Extract fields using FieldVisualization module
const fieldInfo = fieldVisualization.extractFields(polydata);
if (fieldInfo.hasFields) {
fieldInfo.allFields.forEach(field => {
const option = document.createElement('option');
option.value = field.fullName;
option.textContent = field.displayName;
fieldSelector.appendChild(option);
});
fieldLabel.style.display = 'flex';
// Populate colormap selector
const colormapOptions = fieldVisualization.getColormapOptions(true);
colormapSelector.innerHTML = '';
colormapOptions.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
if (opt.disabled) option.disabled = true;
if (opt.value === 'erdc_rainbow_bright') option.selected = true;
colormapSelector.appendChild(option);
});
colormapLabel.style.display = 'flex';
// Default to "None" - don't auto-select any field
// fieldSelector.value stays as "" (None option)
} else {
fieldLabel.style.display = 'none';
colormapLabel.style.display = 'none';
}
}
// Update colormap visualization
function updateColormap() {
const fieldName = document.getElementById('fieldSelector').value;
if (!fieldName || !currentPolyData) {
mapper.setScalarVisibility(false);
property.setColor(0.6, 0.8, 1.0); // Light blue
renderWindow.render();
return;
}
const colormapName = document.getElementById('colormapSelector').value || 'erdc_rainbow_bright';
const autoRange = document.getElementById('autoColorRange').checked;
let range = null;
if (!autoRange) {
const minVal = parseFloat(document.getElementById('colormapMin').value);
const maxVal = parseFloat(document.getElementById('colormapMax').value);
if (!isNaN(minVal) && !isNaN(maxVal)) {
range = [minVal, maxVal];
}
}
fieldVisualization.applyField(mapper, currentPolyData, fieldName, {
colormap: colormapName,
range: range,
render: false
});
// Update range inputs if auto
if (autoRange) {
const isCellData = fieldName.startsWith('cell:');
const actualName = isCellData ? fieldName.substring(5) : fieldName.substring(6);
const data = isCellData ? currentPolyData.getCellData() : currentPolyData.getPointData();
const array = data.getArrayByName(actualName);
if (array) {
const dataRange = array.getRange();
document.getElementById('colormapMin').value = dataRange[0].toFixed(3);
document.getElementById('colormapMax').value = dataRange[1].toFixed(3);
}
}
renderWindow.render();
}
// Camera view
function setCameraView(direction) {
if (!meshBounds) return;
cameraController.setCameraView(direction, meshBounds);
renderWindow.render();
}
// Event listeners - Camera views
document.querySelectorAll('.view-button').forEach(button => {
button.addEventListener('click', () => setCameraView(button.dataset.view));
});
// Edges toggle
document.getElementById('showEdges').addEventListener('change', (e) => {
property.setEdgeVisibility(e.target.checked);
renderWindow.render();
});
// Field selector
document.getElementById('fieldSelector').addEventListener('change', updateColormap);
// Colormap selector
document.getElementById('colormapSelector').addEventListener('change', updateColormap);
// Auto range toggle
document.getElementById('autoColorRange').addEventListener('change', (e) => {
document.getElementById('colormapMin').disabled = e.target.checked;
document.getElementById('colormapMax').disabled = e.target.checked;
updateColormap();
});
// Manual range inputs
document.getElementById('colormapMin').addEventListener('change', () => {
if (!document.getElementById('autoColorRange').checked) updateColormap();
});
document.getElementById('colormapMax').addEventListener('change', () => {
if (!document.getElementById('autoColorRange').checked) updateColormap();
});
// Screenshot
document.getElementById('screenshot').addEventListener('click', async () => {
renderWindow.render();
const image = await screenshotCapture.capture();
window.parent.postMessage({
type: 'SCREENSHOT',
image: image,
timestamp: Date.now()
}, '*');
});
// Save mesh
document.getElementById('saveMesh').addEventListener('click', async () => {
if (!currentFilename) {
alert('No mesh loaded.');
return;
}
const saveButton = document.getElementById('saveMesh');
const originalText = saveButton.textContent;
try {
saveButton.disabled = true;
saveButton.textContent = 'Saving...';
const response = await fetch('/geometrypack/save_preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ temp_filename: currentFilename })
});
const result = await response.json();
if (result.success) {
saveButton.textContent = '✓ Saved!';
alert(`Mesh saved!\n\nFilename: ${result.saved_filename}\nLocation: ComfyUI/output/`);
} else {
throw new Error(result.error);
}
} catch (error) {
saveButton.textContent = '✗ Error';
alert(`Error: ${error.message}`);
} finally {
setTimeout(() => {
saveButton.textContent = originalText;
saveButton.disabled = false;
}, 2000);
}
});
// Settings panel
const settingsButton = document.getElementById('settingsButton');
const settingsPanel = document.getElementById('settingsPanel');
const settingsOverlay = document.getElementById('settingsOverlay');
settingsButton.addEventListener('click', () => {
document.getElementById('pointSize').value = currentSettings.pointSize;
document.getElementById('meshColor').value = currentSettings.meshColor;
document.getElementById('edgeWidth').value = currentSettings.edgeWidth;
document.getElementById('representationSettings').value = currentSettings.representation;
document.getElementById('parallelProjection').checked = currentSettings.parallelProjection;
document.getElementById('showAxisIndicator').checked = currentSettings.showAxisIndicator;
// Lock representation for point clouds
const representationSelect = document.getElementById('representationSettings');
if (isCurrentDataPointCloud) {
representationSelect.value = '0';
representationSelect.disabled = true;
representationSelect.title = 'Point clouds can only be rendered in Points mode';
} else {
representationSelect.disabled = false;
representationSelect.title = '';
}
settingsPanel.classList.add('visible');
settingsOverlay.classList.add('visible');
});
function closeSettings() {
settingsPanel.classList.remove('visible');
settingsOverlay.classList.remove('visible');
}
settingsOverlay.addEventListener('click', closeSettings);
document.getElementById('cancelSettings').addEventListener('click', closeSettings);
document.getElementById('applySettings').addEventListener('click', () => {
const newPointSize = parseFloat(document.getElementById('pointSize').value);
const newColor = document.getElementById('meshColor').value;
const newEdgeWidth = parseFloat(document.getElementById('edgeWidth').value);
const newRepresentation = parseInt(document.getElementById('representationSettings').value);
const newParallel = document.getElementById('parallelProjection').checked;
const newAxisIndicator = document.getElementById('showAxisIndicator').checked;
// Convert hex to RGB
const rgb = hexToRgb(newColor);
// Disable scalar viz to show custom color
mapper.setScalarVisibility(false);
property.setColor(rgb[0], rgb[1], rgb[2]);
property.setLineWidth(newEdgeWidth);
property.setPointSize(newPointSize);
// Only apply representation change for non-point-cloud data
if (!isCurrentDataPointCloud) {
property.setRepresentation(newRepresentation);
}
cameraController.setParallelProjection(newParallel);
axisIndicator.setEnabled(newAxisIndicator);
// Update stored settings
currentSettings = {
pointSize: newPointSize,
meshColor: newColor,
edgeWidth: newEdgeWidth,
representation: newRepresentation,
parallelProjection: newParallel,
showAxisIndicator: newAxisIndicator
};
renderWindow.render();
closeSettings();
});
// Resize handling
const resizeObserver = new ResizeObserver(() => {
const { width, height } = container.getBoundingClientRect();
if (width > 0 && height > 0) {
openGLRenderWindow.setSize(width, height);
renderWindow.render();
}
});
resizeObserver.observe(container);
// Focus camera on a specific 3D point
function focusOnPoint(point, viewRadius = null) {
const camera = renderer.getActiveCamera();
// Get current camera direction (from focal point to camera position)
const currentFocalPoint = camera.getFocalPoint();
const currentPosition = camera.getPosition();
const direction = [
currentPosition[0] - currentFocalPoint[0],
currentPosition[1] - currentFocalPoint[1],
currentPosition[2] - currentFocalPoint[2]
];
// Normalize direction
const length = Math.sqrt(direction[0]**2 + direction[1]**2 + direction[2]**2);
if (length > 0) {
direction[0] /= length;
direction[1] /= length;
direction[2] /= length;
} else {
// Default to looking from +Z if no current direction
direction[0] = 0;
direction[1] = 0;
direction[2] = 1;
}
// Calculate view distance based on mesh bounds
let distance;
if (viewRadius !== null) {
distance = viewRadius * 2.5;
} else if (meshBounds) {
const maxDim = Math.max(
meshBounds[1] - meshBounds[0],
meshBounds[3] - meshBounds[2],
meshBounds[5] - meshBounds[4]
);
distance = maxDim * 0.3; // Closer view for focused point
} else {
distance = 10; // Fallback
}
// Position camera along the direction from the point
const newPosition = [
point[0] + direction[0] * distance,
point[1] + direction[1] * distance,
point[2] + direction[2] * distance
];
camera.setFocalPoint(...point);
camera.setPosition(...newPosition);
renderer.resetCameraClippingRange();
renderWindow.render();
}
// Listen for postMessage from parent
window.addEventListener('message', (event) => {
if (event.data.type === 'LOAD_MESH') {
const lineWidth = event.data.lineWidth || 1.0;
loadMeshFile(event.data.filepath, lineWidth);
} else if (event.data.type === 'FOCUS_ON_POINT') {
const point = event.data.point;
const radius = event.data.radius || null;
if (point && Array.isArray(point) && point.length === 3) {
console.log('[GeomPack VTK Viewer] Focusing on point:', point);
focusOnPoint(point, radius);
}
}
});
console.log('[GeomPack VTK Viewer] Ready (unified)');
</script>
</body>
</html>