/** * 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 (_) {} } })();