// The object page's 3D view: a GLB written by sand3d's export, one node per named part, // nested by the joints. Three modes (parts coloured, materials, mesh), an exploded view, the // part's name under the pointer. import * as THREE from "three"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js"; // Distinct, light enough to read edges on, never the charter's orange as a flat fill. function partColour(i) { const h = (i * 0.618033988749895 + 0.72) % 1; return new THREE.Color().setHSL(h, 0.6, 0.6); } // `left_axle_bracket.001` -> `left axle bracket` export function partName(name) { return (name || "part").replace(/\.\d{3}$/, "").replace(/_/g, " ").trim(); } const EDGE_LIMIT = 1_500_000; // triangles: above, the mesh mode draws a wireframe instead of edges export class Viewer { constructor(host, { onReady, onHover } = {}) { this.host = host; this.onReady = onReady; this.onHover = onHover; this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMapping = THREE.NeutralToneMapping; host.appendChild(this.renderer.domElement); this.scene = new THREE.Scene(); const pmrem = new THREE.PMREMGenerator(this.renderer); this.envMap = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; this.scene.add(new THREE.HemisphereLight(0xffffff, 0xd9d6d0, 1.6)); const key = new THREE.DirectionalLight(0xffffff, 1.4); key.position.set(2, 3, 4); this.scene.add(key); this.camera = new THREE.PerspectiveCamera(35, 1, 0.001, 1000); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.addEventListener("change", () => this.dirty()); this.raycaster = new THREE.Raycaster(); this.pointer = null; this.hovered = null; this.mode = "parts"; this.t = 0; this._dirty = true; this._alive = true; this._resize = () => this.resize(); window.addEventListener("resize", this._resize); this.renderer.domElement.addEventListener("pointermove", (e) => { const r = this.renderer.domElement.getBoundingClientRect(); this.pointer = { x: ((e.clientX - r.left) / r.width) * 2 - 1, y: -((e.clientY - r.top) / r.height) * 2 + 1, cx: e.clientX - r.left, cy: e.clientY - r.top }; this._pick = true; this.dirty(); }); this.renderer.domElement.addEventListener("pointerleave", () => { this.pointer = null; this._pick = true; this.dirty(); }); this.resize(); this.loop(); } dirty() { this._dirty = true; } async load(url) { const gltf = await new GLTFLoader().loadAsync(url); if (!this._alive) return; const root = gltf.scene; this.root = root; this.scene.add(root); root.updateMatrixWorld(true); // the parts: every mesh node, in a stable order this.parts = []; root.traverse((o) => { if (o.isMesh) this.parts.push(o); }); let tris = 0; this.parts.forEach((m, i) => { m.userData.original = m.material; m.userData.colour = partColour(i); m.userData.partMat = new THREE.MeshStandardMaterial({ color: m.userData.colour, roughness: 0.65, metalness: 0, side: THREE.DoubleSide }); const g = m.geometry; tris += (g.index ? g.index.count : g.attributes.position.count) / 3; }); this.triangles = tris; // the object's frame, assembled const box = new THREE.Box3().setFromObject(root); this.center = box.getCenter(new THREE.Vector3()); this.radius = box.getSize(new THREE.Vector3()).length() / 2 || 1; // for the exploded view: each node's rest position, its parent's inverse linear part, // and, for a part, where its centroid sits from the object's centre (world) this.nodes = []; root.traverse((o) => { if (o === root) return; const inv = new THREE.Matrix4().copy(o.parent.matrixWorld).invert(); const entry = { o, rest: o.position.clone(), inv3: new THREE.Matrix3().setFromMatrix4(inv), want: null }; if (o.isMesh) { const c = new THREE.Box3().setFromObject(o, true).getCenter(new THREE.Vector3()); entry.want = c.sub(this.center); } this.nodes.push(entry); }); this.byObject = new Map(this.nodes.map((n) => [n.o, n])); this.frame(); this.setMode(this.mode); this.onReady && this.onReady({ parts: this.parts.length, triangles: tris }); } frame() { const d = this.radius / Math.sin((this.camera.fov * Math.PI) / 360) * 0.92; const dir = new THREE.Vector3(1, 0.75, 1.25).normalize(); this.camera.position.copy(this.center).addScaledVector(dir, d); this.camera.near = d / 200; this.camera.far = d * 50; this.camera.updateProjectionMatrix(); this.controls.target.copy(this.center); this.controls.update(); this.dirty(); } setMode(mode) { if (this.hovered && this.mode === "parts") this.hovered.material.emissive.setHex(0x000000); this.mode = mode; if (!this.parts) return; if (mode === "mesh" && !this._edgesBuilt) this.buildEdges(); for (const m of this.parts) { if (mode === "parts") m.material = m.userData.partMat; else if (mode === "materials") m.material = m.userData.original; else m.material = this._meshMat || (this._meshMat = new THREE.MeshStandardMaterial({ color: 0xe9e6e0, roughness: 0.8, metalness: 0, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 })); if (m.userData.edges) m.userData.edges.visible = mode === "mesh"; } this.scene.environment = mode === "materials" ? this.envMap : null; this.hovered = null; this.dirty(); } buildEdges() { this._edgesBuilt = true; const wire = this.triangles > EDGE_LIMIT; const mat = new THREE.LineBasicMaterial({ color: 0x121117, transparent: true, opacity: 0.8 }); for (const m of this.parts) { const g = wire ? new THREE.WireframeGeometry(m.geometry) : new THREE.EdgesGeometry(m.geometry, 20); const lines = new THREE.LineSegments(g, mat); lines.raycast = () => {}; m.add(lines); m.userData.edges = lines; } } // t in [0, 1]: every part moves out along its centroid's direction; a child is placed // where it wants to be, net of what its ancestors already moved. explode(t) { this.t = t; if (!this.nodes) return; const k = t * 0.9; const moved = new Map(); // node -> world offset it carries for (const n of this.nodes) { // parents before children (traverse order) const inherited = moved.get(n.o.parent) || new THREE.Vector3(); const own = n.want ? n.want.clone().multiplyScalar(k) : inherited.clone(); const delta = own.clone().sub(inherited).applyMatrix3(n.inv3); n.o.position.copy(n.rest).add(delta); moved.set(n.o, own); } this.root.updateMatrixWorld(true); this.dirty(); } pick() { this._pick = false; let hit = null; if (this.pointer && this.parts) { this.raycaster.setFromCamera(this.pointer, this.camera); const h = this.raycaster.intersectObjects(this.parts, false)[0]; hit = h ? h.object : null; } if (hit !== this.hovered) { if (this.hovered && this.mode === "parts") this.hovered.material.emissive.setHex(0x000000); this.hovered = hit; if (hit && this.mode === "parts") hit.material.emissive.setHex(0x221a33); } this.onHover && this.onHover(hit ? { name: partName(hit.name), x: this.pointer.cx, y: this.pointer.cy } : null); } resize() { const w = this.host.clientWidth, h = this.host.clientHeight; if (!w || !h) return; this.renderer.setSize(w, h, false); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.dirty(); } loop() { if (!this._alive) return; requestAnimationFrame(() => this.loop()); if (this.controls.update()) this._dirty = true; if (this._pick) this.pick(); if (!this._dirty || document.hidden) return; this._dirty = false; this.renderer.render(this.scene, this.camera); } dispose() { this._alive = false; window.removeEventListener("resize", this._resize); this.controls.dispose(); this.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); for (const m of [].concat(o.material || [], o.userData?.partMat || [])) m.dispose && m.dispose(); }); if (this._meshMat) this._meshMat.dispose(); this.envMap.dispose(); this.renderer.dispose(); this.renderer.domElement.remove(); } }