Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Download static/viewer.js from Sandflow/sand3d-explorer: direct link, hf CLI and curl.
- Browser
- Download file 8.77 kB
-
https://huggingface.co/spaces/Sandflow/sand3d-explorer/resolve/main/static/viewer.js
- Command line
-
hf download hf://spaces/Sandflow/sand3d-explorer/static/viewer.js
-
curl -L -o viewer.js https://huggingface.co/spaces/Sandflow/sand3d-explorer/resolve/main/static/viewer.js
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(); | |
| } | |
| } | |