Files
codecraft/web/src/components/ModelViewer.tsx
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:03:26 +01:00

155 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "./Icon";
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
// Loaded on demand only when such a file is opened.
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import occtScriptUrl from "occt-import-js/dist/occt-import-js.js?url";
import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url";
let occtPromise: Promise<any> | undefined;
/** Load the OpenCascade importer once (script tag + wasm URL). */
function loadOcct(): Promise<any> {
occtPromise ??= new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = occtScriptUrl;
s.onload = () => (window as any).occtimportjs({ locateFile: () => occtWasmUrl }).then(resolve, reject);
s.onerror = () => reject(new Error("could not load the STEP importer"));
document.head.appendChild(s);
});
return occtPromise;
}
function cssColor(name: string, fallback: string) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
async function loadObject(url: string, ext: string): Promise<THREE.Object3D> {
const material = new THREE.MeshStandardMaterial({ color: cssColor("--blue", "#83a598"), metalness: 0.1, roughness: 0.6, side: THREE.DoubleSide });
switch (ext) {
case "glb":
case "gltf":
return (await new GLTFLoader().loadAsync(url)).scene;
case "stl": {
const geo = await new STLLoader().loadAsync(url);
geo.computeVertexNormals();
return new THREE.Mesh(geo, material);
}
case "obj": {
const obj = await new OBJLoader().loadAsync(url);
obj.traverse((c) => c instanceof THREE.Mesh && (c.material = material));
return obj;
}
default: {
// STEP / IGES: tessellate with OpenCascade in the browser.
const [occt, buf] = await Promise.all([loadOcct(), fetch(url, { credentials: "same-origin" }).then((r) => r.arrayBuffer())]);
const data = new Uint8Array(buf);
const res = ext === "igs" || ext === "iges" ? occt.ReadIgesFile(data, null) : occt.ReadStepFile(data, null);
if (!res.success) throw new Error("the file could not be read");
const group = new THREE.Group();
for (const m of res.meshes) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.attributes.position.array, 3));
if (m.attributes.normal) geo.setAttribute("normal", new THREE.Float32BufferAttribute(m.attributes.normal.array, 3));
else geo.computeVertexNormals();
if (m.index) geo.setIndex(new THREE.Uint32BufferAttribute(m.index.array, 1));
const mat = m.color ? material.clone() : material;
if (m.color) (mat as THREE.MeshStandardMaterial).color = new THREE.Color(m.color[0], m.color[1], m.color[2]);
group.add(new THREE.Mesh(geo, mat));
}
return group;
}
}
}
export function ModelViewer({ url, ext }: { url: string; ext: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<{ loading: boolean; error?: string; info?: string }>({ loading: true });
useEffect(() => {
const el = host.current!;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(cssColor("--bg0", "#282828"));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.8);
sun.position.set(3, 5, 4);
scene.add(sun);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1e6);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const resize = () => {
const w = el.clientWidth || 300;
const h = el.clientHeight || 300;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(el);
resize();
let alive = true;
let frame = 0;
const loop = () => {
if (!alive) return;
controls.update();
renderer.render(scene, camera);
frame = requestAnimationFrame(loop);
};
loop();
loadObject(url, ext)
.then((obj) => {
if (!alive) return;
scene.add(obj);
// Frame the model.
const box = new THREE.Box3().setFromObject(obj);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.length() / 2, 1e-3);
camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 1.0, radius * 1.8));
camera.near = radius / 100;
camera.far = radius * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
let tris = 0;
obj.traverse((c) => {
if (c instanceof THREE.Mesh) tris += (c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count) / 3;
});
setState({ loading: false, info: `${Math.round(tris).toLocaleString()} triangles · ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}` });
})
.catch((e) => alive && setState({ loading: false, error: String(e?.message ?? e) }));
return () => {
alive = false;
cancelAnimationFrame(frame);
ro.disconnect();
controls.dispose();
scene.traverse((c) => {
if (c instanceof THREE.Mesh) {
c.geometry.dispose();
(Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose());
}
});
renderer.dispose();
el.removeChild(renderer.domElement);
};
}, [url, ext]);
return (
<div className="model-viewer">
<div ref={host} className="model-canvas" />
<div className="model-info">{state.loading ? "…" : state.error ? <><Icon name="warning" /> {state.error}</> : state.info}</div>
</div>
);
}