import { useEffect, useRef, useState } from "react"; import { modelStats, type Model3D } from "../core/model"; const PREVIEW_TRIANGLES = 200_000; export function ModelPreview({ model }: { model: Model3D }) { const canvasRef = useRef(null); const [message, setMessage] = useState(""); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const gl = canvas.getContext("webgl", { alpha: true, antialias: true, depth: true, failIfMajorPerformanceCaveat: false, preserveDrawingBuffer: false, }); if (!gl) { setMessage("WebGL is unavailable. Inspection and export still work."); return; } const vertexShader = compile( gl, gl.VERTEX_SHADER, [ "attribute vec3 aPosition;", "attribute vec3 aNormal;", "uniform vec3 uCenter;", "uniform float uScale;", "uniform vec2 uRotation;", "uniform float uAspect;", "varying float vLight;", "void main() {", " vec3 p = (aPosition - uCenter) * uScale;", " vec3 n = aNormal;", " float cx = cos(uRotation.y), sx = sin(uRotation.y);", " float cy = cos(uRotation.x), sy = sin(uRotation.x);", " p = vec3(p.x, p.y * cx - p.z * sx, p.y * sx + p.z * cx);", " n = vec3(n.x, n.y * cx - n.z * sx, n.y * sx + n.z * cx);", " p = vec3(p.x * cy + p.z * sy, p.y, -p.x * sy + p.z * cy);", " n = vec3(n.x * cy + n.z * sy, n.y, -n.x * sy + n.z * cy);", " gl_Position = vec4(p.x / uAspect, p.y, p.z * 0.35, 1.0);", " vLight = 0.25 + 0.75 * abs(dot(normalize(n), normalize(vec3(0.4, 0.7, 1.0))));", "}", ].join("\n"), ); const fragmentShader = compile( gl, gl.FRAGMENT_SHADER, [ "precision mediump float;", "varying float vLight;", "void main() {", " gl_FragColor = vec4(vec3(0.38, 0.31, 0.85) * vLight + vec3(0.15), 1.0);", "}", ].join("\n"), ); const program = gl.createProgram(); if (!program) throw new Error("WebGL program allocation failed."); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) || "WebGL linking failed."); gl.useProgram(program); const triangleCount = Math.min(model.indices.length / 3, PREVIEW_TRIANGLES); const interleaved = new Float32Array(triangleCount * 3 * 6); for (let index = 0; index < triangleCount * 3; index += 1) { const source = model.indices[index] ?? 0; const target = index * 6; interleaved[target] = model.positions[source * 3] ?? 0; interleaved[target + 1] = model.positions[source * 3 + 1] ?? 0; interleaved[target + 2] = model.positions[source * 3 + 2] ?? 0; interleaved[target + 3] = model.normals[source * 3] ?? 0; interleaved[target + 4] = model.normals[source * 3 + 1] ?? 0; interleaved[target + 5] = model.normals[source * 3 + 2] ?? 1; } const buffer = gl.createBuffer(); if (!buffer) throw new Error("WebGL buffer allocation failed."); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, interleaved, gl.STATIC_DRAW); const position = gl.getAttribLocation(program, "aPosition"); const normal = gl.getAttribLocation(program, "aNormal"); gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(normal); gl.vertexAttribPointer(normal, 3, gl.FLOAT, false, 24, 12); const stats = modelStats(model); const center = stats.bounds.min.map( (value, axis) => (value + stats.bounds.max[axis]!) / 2, ); const extent = Math.max(...stats.bounds.size, 1e-9); const centerLocation = gl.getUniformLocation(program, "uCenter"); const scaleLocation = gl.getUniformLocation(program, "uScale"); const rotationLocation = gl.getUniformLocation(program, "uRotation"); const aspectLocation = gl.getUniformLocation(program, "uAspect"); let yaw = 0.55; let pitch = -0.35; let dragging = false; let previousX = 0; let previousY = 0; gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE); gl.clearColor(0, 0, 0, 0); const draw = () => { const ratio = Math.min(window.devicePixelRatio || 1, 2); const width = Math.max(1, Math.floor(canvas.clientWidth * ratio)); const height = Math.max(1, Math.floor(canvas.clientHeight * ratio)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } gl.viewport(0, 0, width, height); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.uniform3f( centerLocation, center[0] ?? 0, center[1] ?? 0, center[2] ?? 0, ); gl.uniform1f(scaleLocation, 1.35 / extent); gl.uniform2f(rotationLocation, yaw, pitch); gl.uniform1f(aspectLocation, width / height); gl.drawArrays(gl.TRIANGLES, 0, triangleCount * 3); }; const resize = new ResizeObserver(draw); resize.observe(canvas); const down = (event: PointerEvent) => { dragging = true; previousX = event.clientX; previousY = event.clientY; canvas.setPointerCapture(event.pointerId); }; const move = (event: PointerEvent) => { if (!dragging) return; yaw += (event.clientX - previousX) * 0.01; pitch += (event.clientY - previousY) * 0.01; previousX = event.clientX; previousY = event.clientY; draw(); }; const up = () => { dragging = false; }; canvas.addEventListener("pointerdown", down); canvas.addEventListener("pointermove", move); canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", up); setMessage( model.indices.length / 3 > PREVIEW_TRIANGLES ? "Preview is capped at the first 200,000 triangles; statistics and exports use the full mesh." : "Drag the preview to rotate it.", ); draw(); return () => { resize.disconnect(); canvas.removeEventListener("pointerdown", down); canvas.removeEventListener("pointermove", move); canvas.removeEventListener("pointerup", up); canvas.removeEventListener("pointercancel", up); gl.deleteBuffer(buffer); gl.deleteProgram(program); gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); }; }, [model]); return (

{message}

); } function compile( gl: WebGLRenderingContext, type: number, source: string, ): WebGLShader { const shader = gl.createShader(type); if (!shader) throw new Error("WebGL shader allocation failed."); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) || "WebGL shader failed."); return shader; }