201 lines
7.2 KiB
TypeScript
201 lines
7.2 KiB
TypeScript
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<HTMLCanvasElement>(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 (
|
|
<div className="preview-wrap">
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="model-preview"
|
|
aria-label={"Interactive WebGL preview of " + model.name}
|
|
/>
|
|
<p className="muted" role="status">
|
|
{message}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|