sand3d-explorer / static /viewer.js
Thibault-SF's picture
Sand3D Explorer
e6ba88a
Raw History Blame Contribute Delete
8.77 kB
// 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();
}
}