Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 8,773 Bytes
e6ba88a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | // 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();
}
}
|