Release 3D Tools v0.1.0

This commit is contained in:
2026-09-01 14:22:42 +02:00
commit b20c67c890
58 changed files with 10820 additions and 0 deletions
+200
View File
@@ -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;
}