Files
argo/web/model-viewer.js
T
2026-08-27 18:24:22 +02:00

584 lines
18 KiB
JavaScript

/**
* ARGO 3D Vehicle Model Viewer
* Clean, lightweight, hardware-accelerated WebGL STL renderer with:
* - Dynamic ARGO subsea caustic light shader
* - Curvature & Depth Ambient Occlusion (AO)
* - Titanium sub hull metallic materials + Cyan Fresnel rim glow
* - 60 FPS Orbit Controls (drag, wheel zoom, double-click reset)
* - High-speed Binary & ASCII STL parser with auto-centering
*/
(() => {
'use strict';
const canvas = document.querySelector('#model-canvas');
const stage = document.querySelector('.rov-stage');
if (!canvas || !stage) return;
let gl = null;
let shaderProgram = null;
let posBuffer = null;
let normBuffer = null;
let vertexCount = 0;
let modelLoaded = false;
let modelName = 'vehicle_model.stl';
// Uniform & Attribute Locations
let locPos = -1;
let locNorm = -1;
let locMVP = null;
let locNormalMat = null;
let locModelMat = null;
let locTime = null;
let locLight1 = null;
let locLight2 = null;
// Camera & Orbit State
let yaw = -0.45;
let pitch = 0.35;
let targetYaw = -0.45;
let targetPitch = 0.35;
let zoom = 1.0;
let targetZoom = 1.0;
let dragging = false;
let lastX = 0;
let lastY = 0;
let startTime = performance.now();
let isRendering = false;
// Initialize WebGL
function initGL() {
if (gl) return true;
try {
gl = canvas.getContext('webgl', {
antialias: true,
alpha: true,
depth: true,
preserveDrawingBuffer: false,
powerPreference: 'high-performance'
}) || canvas.getContext('experimental-webgl', { antialias: true, alpha: true, depth: true });
} catch (e) {
console.warn('WebGL initialization error:', e);
return false;
}
if (!gl) return false;
const vsSource = `
attribute vec3 a_position;
attribute vec3 a_normal;
uniform mat4 u_mvp;
uniform mat3 u_normalMat;
uniform mat4 u_modelMat;
varying vec3 v_normal;
varying vec3 v_modelPos;
void main() {
v_normal = normalize(u_normalMat * a_normal);
v_modelPos = a_position;
gl_Position = u_mvp * vec4(a_position, 1.0);
}
`;
const fsSource = `
precision mediump float;
varying vec3 v_normal;
varying vec3 v_modelPos;
uniform float u_time;
uniform vec3 u_light1;
uniform vec3 u_light2;
// ARGO subsea caustic wave generator
float getCaustics(vec2 p, float t) {
vec2 p1 = p * 3.8 + vec2(t * 0.35, t * 0.25);
vec2 p2 = p * 6.2 - vec2(t * 0.28, -t * 0.38);
vec2 p3 = p * 8.5 + vec2(-t * 0.18, t * 0.42);
float c = sin(p1.x + sin(p1.y * 1.2 + t * 0.6)) * 0.5 + 0.5;
c += sin(p2.y + cos(p2.x * 1.1 - t * 0.5)) * 0.5 + 0.5;
c += sin(p3.x * 0.8 + p3.y * 0.9 + t * 0.9) * 0.5 + 0.5;
return pow(c / 3.0, 3.0) * 2.2;
}
void main() {
vec3 n = normalize(v_normal);
if (n.z < 0.0) n = -n; // Two-sided CAD shading
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 l1 = normalize(u_light1);
vec3 l2 = normalize(u_light2);
// Diffuse & Ambient Lighting (Soft, lifted shadows)
float diff1 = max(dot(n, l1), 0.0);
float diff2 = max(dot(n, l2), 0.0) * 0.30;
float ambient = 0.46;
// Matte Specular (Soft diffuse sheen, no harsh shine)
vec3 h1 = normalize(l1 + viewDir);
float spec = pow(max(dot(n, h1), 0.0), 10.0) * 0.07;
// Subtle Cyan Fresnel Accent
float fresnel = pow(1.0 - max(dot(n, viewDir), 0.0), 2.8);
// Soft Ambient Occlusion (Gentle curvature without heavy dark shadows)
float dist = length(v_modelPos);
float ao = clamp(0.76 + 0.24 * (dist * 0.9 + abs(n.y) * 0.15), 0.68, 1.0);
// Caustic ripple projection
float caust = getCaustics(v_modelPos.xz + v_modelPos.yy * 0.25, u_time) * max(n.y * 0.65 + 0.35, 0.0);
// Clean Matte White Palette
vec3 hullDark = vec3(0.66, 0.72, 0.76);
vec3 hullLit = vec3(0.96, 0.98, 1.00);
vec3 cyanGlow = vec3(0.35, 0.88, 0.98);
vec3 caustColor = vec3(0.55, 0.92, 1.00);
// Final Composite
vec3 color = mix(hullDark, hullLit, clamp((diff1 * 0.50 + diff2 + ambient) * ao, 0.0, 1.0));
color += caustColor * caust * 0.18;
color += vec3(1.0) * spec;
color += cyanGlow * fresnel * 0.18;
gl_FragColor = vec4(color, 1.0);
}
`;
function createShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(s));
gl.deleteShader(s);
return null;
}
return s;
}
const vs = createShader(gl.VERTEX_SHADER, vsSource);
const fs = createShader(gl.FRAGMENT_SHADER, fsSource);
if (!vs || !fs) return false;
shaderProgram = gl.createProgram();
gl.attachShader(shaderProgram, vs);
gl.attachShader(shaderProgram, fs);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(shaderProgram));
return false;
}
locPos = gl.getAttribLocation(shaderProgram, 'a_position');
locNorm = gl.getAttribLocation(shaderProgram, 'a_normal');
locMVP = gl.getUniformLocation(shaderProgram, 'u_mvp');
locNormalMat = gl.getUniformLocation(shaderProgram, 'u_normalMat');
locModelMat = gl.getUniformLocation(shaderProgram, 'u_modelMat');
locTime = gl.getUniformLocation(shaderProgram, 'u_time');
locLight1 = gl.getUniformLocation(shaderProgram, 'u_light1');
locLight2 = gl.getUniformLocation(shaderProgram, 'u_light2');
posBuffer = gl.createBuffer();
normBuffer = gl.createBuffer();
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LEQUAL);
gl.clearDepth(1.0);
gl.disable(gl.CULL_FACE);
return true;
}
// Matrix math
function mat4Perspective(out, fovy, aspect, near, far) {
const f = 1.0 / Math.tan(fovy / 2);
const nf = 1 / (near - far);
out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0;
out[4] = 0; out[5] = f; out[6] = 0; out[7] = 0;
out[8] = 0; out[9] = 0; out[10] = (far + near) * nf; out[11] = -1;
out[12] = 0; out[13] = 0; out[14] = 2 * far * near * nf; out[15] = 0;
}
function mat4Multiply(out, a, b) {
for (let c = 0; c < 4; c++) {
const c4 = c * 4;
const b0 = b[c4], b1 = b[c4 + 1], b2 = b[c4 + 2], b3 = b[c4 + 3];
out[c4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
out[c4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
out[c4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
out[c4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
}
return out;
}
// Fast Binary & ASCII STL Parser
function parseSTL(buffer) {
const isBinary = buffer.byteLength >= 84 &&
(84 + new DataView(buffer).getUint32(80, true) * 50 <= buffer.byteLength);
let positions, normals;
if (isBinary) {
const view = new DataView(buffer);
const count = view.getUint32(80, true);
if (count === 0) throw new Error('Empty binary STL');
positions = new Float32Array(count * 9);
normals = new Float32Array(count * 9);
let pIdx = 0, nIdx = 0;
for (let i = 0; i < count; i++) {
const off = 84 + i * 50;
let nx = view.getFloat32(off, true);
let ny = view.getFloat32(off + 4, true);
let nz = view.getFloat32(off + 8, true);
const v0x = view.getFloat32(off + 12, true);
const v0y = view.getFloat32(off + 16, true);
const v0z = view.getFloat32(off + 20, true);
const v1x = view.getFloat32(off + 24, true);
const v1y = view.getFloat32(off + 28, true);
const v1z = view.getFloat32(off + 32, true);
const v2x = view.getFloat32(off + 36, true);
const v2y = view.getFloat32(off + 40, true);
const v2z = view.getFloat32(off + 44, true);
if (nx * nx + ny * ny + nz * nz < 1e-6) {
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
nx = ay * bz - az * by;
ny = az * bx - ax * bz;
nz = ax * by - ay * bx;
const len = Math.hypot(nx, ny, nz) || 1;
nx /= len; ny /= len; nz /= len;
}
positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z;
positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z;
positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z;
for (let k = 0; k < 3; k++) {
normals[nIdx++] = nx;
normals[nIdx++] = ny;
normals[nIdx++] = nz;
}
}
} else {
const text = new TextDecoder().decode(buffer);
const matches = [...text.matchAll(/vertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)/gi)];
if (matches.length < 3) throw new Error('Invalid ASCII STL');
const count = Math.floor(matches.length / 3);
positions = new Float32Array(count * 9);
normals = new Float32Array(count * 9);
let pIdx = 0, nIdx = 0;
for (let i = 0; i < count; i++) {
const i3 = i * 3;
const v0x = +matches[i3][1], v0y = +matches[i3][2], v0z = +matches[i3][3];
const v1x = +matches[i3 + 1][1], v1y = +matches[i3 + 1][2], v1z = +matches[i3 + 1][3];
const v2x = +matches[i3 + 2][1], v2y = +matches[i3 + 2][2], v2z = +matches[i3 + 2][3];
const ax = v1x - v0x, ay = v1y - v0y, az = v1z - v0z;
const bx = v2x - v0x, by = v2y - v0y, bz = v2z - v0z;
let nx = ay * bz - az * by;
let ny = az * bx - ax * bz;
let nz = ax * by - ay * bx;
const len = Math.hypot(nx, ny, nz) || 1;
nx /= len; ny /= len; nz /= len;
positions[pIdx++] = v0x; positions[pIdx++] = v0y; positions[pIdx++] = v0z;
positions[pIdx++] = v1x; positions[pIdx++] = v1y; positions[pIdx++] = v1z;
positions[pIdx++] = v2x; positions[pIdx++] = v2y; positions[pIdx++] = v2z;
for (let k = 0; k < 3; k++) {
normals[nIdx++] = nx;
normals[nIdx++] = ny;
normals[nIdx++] = nz;
}
}
}
// Center to origin and scale to fit unit sphere
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
for (let i = 0; i < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1], z = positions[i + 2];
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
}
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const cz = (minZ + maxZ) / 2;
const maxDim = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 1e-6);
const scale = 2.0 / maxDim;
for (let i = 0; i < positions.length; i += 3) {
positions[i] = (positions[i] - cx) * scale;
positions[i + 1] = (positions[i + 1] - cy) * scale;
positions[i + 2] = (positions[i + 2] - cz) * scale;
}
return { positions, normals, count: positions.length / 3 };
}
// Render Loop
function render() {
if (!modelLoaded || vertexCount === 0) {
isRendering = false;
return;
}
if (!gl) {
if (!initGL()) {
isRendering = false;
return;
}
}
// Inertia damping
yaw += (targetYaw - yaw) * 0.16;
pitch += (targetPitch - pitch) * 0.16;
zoom += (targetZoom - zoom) * 0.16;
// Viewport resize
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(10, Math.round(rect.width * dpr));
const height = Math.max(10, Math.round(rect.height * dpr));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(shaderProgram);
// Matrix calculations
const aspect = width / height;
const proj = new Float32Array(16);
mat4Perspective(proj, (42 * Math.PI) / 180, aspect, 0.1, 50.0);
const cosY = Math.cos(yaw), sinY = Math.sin(yaw);
const cosP = Math.cos(pitch), sinP = Math.sin(pitch);
const dist = 3.2 / zoom;
const model = new Float32Array(16);
model[0] = cosY;
model[1] = sinP * sinY;
model[2] = -cosP * sinY;
model[3] = 0;
model[4] = 0;
model[5] = cosP;
model[6] = sinP;
model[7] = 0;
model[8] = sinY;
model[9] = -sinP * cosY;
model[10] = cosP * cosY;
model[11] = 0;
model[12] = 0;
model[13] = 0;
model[14] = -dist;
model[15] = 1;
const mvp = new Float32Array(16);
mat4Multiply(mvp, proj, model);
const normalMat = new Float32Array([
model[0], model[1], model[2],
model[4], model[5], model[6],
model[8], model[9], model[10],
]);
const elapsedTime = (performance.now() - startTime) * 0.001;
gl.uniformMatrix4fv(locMVP, false, mvp);
gl.uniformMatrix3fv(locNormalMat, false, normalMat);
gl.uniformMatrix4fv(locModelMat, false, model);
gl.uniform1f(locTime, elapsedTime);
gl.uniform3f(locLight1, 0.65, 0.85, 0.60);
gl.uniform3f(locLight2, -0.60, -0.40, 0.30);
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.enableVertexAttribArray(locPos);
gl.vertexAttribPointer(locPos, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
gl.enableVertexAttribArray(locNorm);
gl.vertexAttribPointer(locNorm, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
// Continue animation loop for dynamic caustic wave shimmer
requestAnimationFrame(render);
}
function startRenderLoop() {
if (!isRendering && modelLoaded) {
isRendering = true;
requestAnimationFrame(render);
}
}
// Orbit Pointer Interaction
canvas.addEventListener('pointerdown', e => {
dragging = true;
lastX = e.clientX;
lastY = e.clientY;
canvas.setPointerCapture(e.pointerId);
canvas.style.cursor = 'grabbing';
});
canvas.addEventListener('pointermove', e => {
if (!dragging) return;
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
targetYaw += dx * 0.012;
targetPitch = Math.max(-1.45, Math.min(1.45, targetPitch + dy * 0.012));
lastX = e.clientX;
lastY = e.clientY;
});
const stopDrag = () => {
dragging = false;
canvas.style.cursor = 'grab';
};
canvas.addEventListener('pointerup', stopDrag);
canvas.addEventListener('pointercancel', stopDrag);
canvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.90 : 1.11;
targetZoom = Math.max(0.45, Math.min(3.5, targetZoom * factor));
}, { passive: false });
canvas.addEventListener('dblclick', () => {
targetYaw = -0.45;
targetPitch = 0.35;
targetZoom = 1.0;
});
// Early context init
initGL();
// Public Interface
window.rovModel = {
loadBuffer(buffer, name = 'Main Assembly V1 (2).stl') {
console.log(`[ROV 3D] Loading STL buffer "${name}" (${(buffer.byteLength / (1024 * 1024)).toFixed(2)} MB)...`);
const statusEl = document.querySelector('#model-status');
if (statusEl) {
statusEl.className = 'model-status';
statusEl.textContent = `Parsing ${name}…`;
}
try {
const mesh = parseSTL(buffer);
vertexCount = mesh.count;
modelLoaded = true;
modelName = name;
initGL();
if (gl) {
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW);
} else {
throw new Error('WebGL context unavailable');
}
stage.classList.add('model-loaded');
targetYaw = -0.45;
targetPitch = 0.35;
targetZoom = 1.0;
yaw = targetYaw;
pitch = targetPitch;
zoom = targetZoom;
startRenderLoop();
const tris = vertexCount / 3;
console.log(`[ROV 3D] Successfully parsed & uploaded ${tris.toLocaleString()} triangles to GPU.`);
if (statusEl) {
statusEl.textContent = `${name} • ${tris.toLocaleString()} \u0394`;
statusEl.classList.remove('error');
setTimeout(() => statusEl.classList.add('fade-out'), 3500);
}
return { name, triangles: tris };
} catch (err) {
console.error('[ROV 3D Error]', err);
if (statusEl) {
statusEl.className = 'model-status error';
statusEl.textContent = `STL Error: ${err.message}`;
}
throw err;
}
},
load(payload) {
if (!payload) throw new Error('No model payload provided');
if (payload instanceof ArrayBuffer) {
return this.loadBuffer(payload);
}
if (payload.buffer instanceof ArrayBuffer) {
return this.loadBuffer(payload.buffer, payload.name);
}
let rawBase64 = '';
let name = 'vehicle_model.stl';
if (typeof payload === 'string') {
rawBase64 = payload.includes(',') ? payload.split(',')[1] : payload;
} else if (payload.data) {
rawBase64 = payload.data.includes(',') ? payload.data.split(',')[1] : payload.data;
name = payload.name || name;
}
const binary = atob(rawBase64);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
return this.loadBuffer(bytes.buffer, name);
},
resetView() {
targetYaw = -0.45;
targetPitch = 0.35;
targetZoom = 1.0;
}
};
// Immediate auto-fetch of runtime default model
for (const url of [
'/model.stl',
'/model',
'/runtime/vehicle_model.stl',
'/assets/Main%20Assembly%20V1%20(2).stl',
'../runtime/vehicle_model.stl',
'../assets/Main%20Assembly%20V1%20(2).stl'
]) {
try {
fetch(url)
.then(r => r.ok ? r.arrayBuffer() : null)
.then(buf => {
if (buf && !modelLoaded) {
window.rovModel.loadBuffer(buf, 'Main Assembly V1 (2).stl');
const btn = document.querySelector('#load-model');
if (btn) btn.textContent = 'Replace STL';
}
})
.catch(() => {});
} catch (_) {}
}
})();