- 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>
155 lines
6.2 KiB
TypeScript
155 lines
6.2 KiB
TypeScript
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>
|
||
);
|
||
}
|