Release 3D Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user