Update UI layout, window controls, and vehicle styling
This commit is contained in:
+562
-83
@@ -1,104 +1,583 @@
|
||||
/**
|
||||
* 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');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let vertices = null, frame = 0, yaw = 0, pitch = 0, zoom = 1;
|
||||
let dragging = false, lastX = 0, lastY = 0;
|
||||
// Still below native resolution, but high enough for clean assembly contours.
|
||||
const renderScale = .82;
|
||||
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;
|
||||
|
||||
function parseStl(buffer) {
|
||||
const view = new DataView(buffer);
|
||||
let raw = [];
|
||||
const binaryCount = buffer.byteLength >= 84 ? view.getUint32(80, true) : 0;
|
||||
const isBinary = binaryCount > 0 && 84 + binaryCount * 50 <= buffer.byteLength;
|
||||
if (isBinary) {
|
||||
for (let t = 0; t < binaryCount; t++) {
|
||||
const offset = 84 + t * 50 + 12;
|
||||
for (let v = 0; v < 3; v++) {
|
||||
raw.push(view.getFloat32(offset + v * 12, true));
|
||||
raw.push(view.getFloat32(offset + v * 12 + 4, true));
|
||||
raw.push(view.getFloat32(offset + v * 12 + 8, true));
|
||||
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('This file does not contain a readable STL mesh');
|
||||
for (let i = 0; i + 2 < matches.length; i += 3) {
|
||||
for (let j = 0; j < 3; j++) raw.push(+matches[i+j][1], +matches[i+j][2], +matches[i+j][3]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (raw.length < 9) throw new Error('The STL mesh is empty');
|
||||
let minX=Infinity,minY=Infinity,minZ=Infinity,maxX=-Infinity,maxY=-Infinity,maxZ=-Infinity;
|
||||
for(let i=0;i<raw.length;i+=3){minX=Math.min(minX,raw[i]);maxX=Math.max(maxX,raw[i]);minY=Math.min(minY,raw[i+1]);maxY=Math.max(maxY,raw[i+1]);minZ=Math.min(minZ,raw[i+2]);maxZ=Math.max(maxZ,raw[i+2])}
|
||||
const cx=(minX+maxX)/2,cy=(minY+maxY)/2,cz=(minZ+maxZ)/2,scale=2/Math.max(maxX-minX,maxY-minY,maxZ-minZ,1e-9);
|
||||
for(let i=0;i<raw.length;i+=3){raw[i]=(raw[i]-cx)*scale;raw[i+1]=(raw[i+1]-cy)*scale;raw[i+2]=(raw[i+2]-cz)*scale}
|
||||
return new Float32Array(raw);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
frame=0;if(!vertices)return;
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
const width=Math.max(1,Math.round(rect.width*renderScale)),height=Math.max(1,Math.round(rect.height*renderScale));
|
||||
if(canvas.width!==width||canvas.height!==height){canvas.width=width;canvas.height=height}
|
||||
ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,width,height);
|
||||
const mask=document.createElement('canvas'),maskCtx=mask.getContext('2d');
|
||||
mask.width=width;mask.height=height;
|
||||
const size=Math.min(width,height)*.43*zoom,cx=width/2,cy=height/2;
|
||||
const cosY=Math.cos(yaw),sinY=Math.sin(yaw),cosP=Math.cos(pitch),sinP=Math.sin(pitch);
|
||||
const project=(x,y,z)=>{
|
||||
const rx=x*cosY+z*sinY,rz=-x*sinY+z*cosY;
|
||||
return [rx,y*cosP-rz*sinP];
|
||||
};
|
||||
maskCtx.fillStyle='#fff';
|
||||
maskCtx.beginPath();
|
||||
for(let i=0;i<vertices.length;i+=9){
|
||||
const a=project(vertices[i],vertices[i+1],vertices[i+2]);
|
||||
const b=project(vertices[i+3],vertices[i+4],vertices[i+5]);
|
||||
const c=project(vertices[i+6],vertices[i+7],vertices[i+8]);
|
||||
maskCtx.moveTo(cx+a[0]*size,cy-a[1]*size);
|
||||
maskCtx.lineTo(cx+b[0]*size,cy-b[1]*size);
|
||||
maskCtx.lineTo(cx+c[0]*size,cy-c[1]*size);
|
||||
maskCtx.closePath();
|
||||
// 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;
|
||||
}
|
||||
// Rasterize the union once. Filling faces individually creates faint
|
||||
// antialiased seams that resemble a wireframe on dense assemblies.
|
||||
maskCtx.fill();
|
||||
// A filtered copy creates only the exterior contour; triangle boundaries
|
||||
// never enter the final image because the source is a single solid mask.
|
||||
ctx.save();
|
||||
ctx.filter='drop-shadow(0 0 1.2px rgba(8,15,17,.9)) drop-shadow(0 4px 5px rgba(0,0,0,.28))';
|
||||
ctx.drawImage(mask,0,0);ctx.restore();
|
||||
const surface=document.createElement('canvas'),surfaceCtx=surface.getContext('2d');
|
||||
surface.width=width;surface.height=height;
|
||||
const gradient=surfaceCtx.createLinearGradient(0,0,width,height);
|
||||
gradient.addColorStop(0,'#ffffff');gradient.addColorStop(.58,'#f4f5f2');gradient.addColorStop(1,'#dfe2de');
|
||||
surfaceCtx.fillStyle=gradient;surfaceCtx.fillRect(0,0,width,height);
|
||||
surfaceCtx.globalCompositeOperation='destination-in';surfaceCtx.drawImage(mask,0,0);
|
||||
ctx.drawImage(surface,0,0);
|
||||
}
|
||||
function requestDraw(){if(!frame)frame=requestAnimationFrame(draw)}
|
||||
canvas.addEventListener('pointerdown',event=>{dragging=true;lastX=event.clientX;lastY=event.clientY;canvas.setPointerCapture(event.pointerId)});
|
||||
canvas.addEventListener('pointermove',event=>{if(!dragging)return;yaw+=(event.clientX-lastX)*.012;pitch=Math.max(-1.45,Math.min(1.45,pitch+(event.clientY-lastY)*.012));lastX=event.clientX;lastY=event.clientY;requestDraw()});
|
||||
canvas.addEventListener('pointerup',()=>dragging=false);
|
||||
canvas.addEventListener('pointercancel',()=>dragging=false);
|
||||
canvas.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.max(.55,Math.min(2.2,zoom*(event.deltaY>0?.9:1.1)));requestDraw()},{passive:false});
|
||||
addEventListener('resize',requestDraw);
|
||||
|
||||
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) {
|
||||
vertices=parseStl(buffer);stage.classList.add('model-loaded');
|
||||
document.querySelector('#model-file-name').textContent=name;
|
||||
yaw=0;pitch=0;zoom=1;
|
||||
requestDraw();
|
||||
return {name,triangles:vertices.length/9};
|
||||
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?.data) throw new Error('The selected STL returned no mesh data');
|
||||
const binary=atob(payload.data),bytes=new Uint8Array(binary.length);
|
||||
for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);
|
||||
return this.loadBuffer(bytes.buffer,payload.name);
|
||||
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 (_) {}
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user