Download viewer.js from HelloSun/WFreeCAD: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/spaces/HelloSun/WFreeCAD/resolve/main/viewer.js
- Command line
-
hf download hf://spaces/HelloSun/WFreeCAD/viewer.js
-
curl -L -o viewer.js https://huggingface.co/spaces/HelloSun/WFreeCAD/resolve/main/viewer.js
16.9 kB
| import { | |
| THREE, | |
| OrbitControls, | |
| mergeGeometries, | |
| Brush, | |
| Evaluator, | |
| SUBTRACTION, | |
| INTERSECTION, | |
| } from './vendor/engine.js'; | |
| const Z = new THREE.Vector3(0, 0, 1); | |
| function disposeObject(object) { | |
| object.traverse((child) => { | |
| if (child.geometry) child.geometry.dispose(); | |
| if (child.material) { | |
| const materials = Array.isArray(child.material) ? child.material : [child.material]; | |
| for (const material of materials) material.dispose(); | |
| } | |
| }); | |
| } | |
| function cleanPoints(raw) { | |
| const points = []; | |
| for (const p of raw || []) { | |
| const v = new THREE.Vector2(Number(p[0]) || 0, Number(p[1]) || 0); | |
| if (points.length === 0 || points[points.length - 1].distanceToSquared(v) > 1e-12) { | |
| points.push(v); | |
| } | |
| } | |
| if (points.length > 1 && points[0].distanceToSquared(points[points.length - 1]) < 1e-12) { | |
| points.pop(); | |
| } | |
| let area = 0; | |
| for (let i = 0; i < points.length; i += 1) { | |
| const a = points[i]; | |
| const b = points[(i + 1) % points.length]; | |
| area += a.x * b.y - b.x * a.y; | |
| } | |
| if (area < 0) points.reverse(); | |
| return points; | |
| } | |
| function cleanGeometry(geometry) { | |
| let g = geometry.index ? geometry.toNonIndexed() : geometry; | |
| for (const name of Object.keys(g.attributes)) { | |
| if (name !== 'position' && name !== 'normal') g.deleteAttribute(name); | |
| } | |
| if (!g.attributes.normal) g.computeVertexNormals(); | |
| return g; | |
| } | |
| function orientAlong(geometry, dirArray, pointArray) { | |
| const dir = new THREE.Vector3().fromArray(dirArray || [0, 0, 1]); | |
| if (dir.lengthSq() < 1e-12) dir.set(0, 0, 1); | |
| dir.normalize(); | |
| if (Math.abs(dir.z - 1) > 1e-8) { | |
| const q = new THREE.Quaternion().setFromUnitVectors(Z, dir); | |
| geometry.applyQuaternion(q); | |
| } | |
| const p = pointArray || [0, 0, 0]; | |
| geometry.translate(p[0] || 0, p[1] || 0, p[2] || 0); | |
| } | |
| function primitive(node) { | |
| let geometry; | |
| const segments = 48; | |
| switch (node.op) { | |
| case 'box': { | |
| const l = Math.max(1e-4, Number(node.l) || 0); | |
| const w = Math.max(1e-4, Number(node.w) || 0); | |
| const h = Math.max(1e-4, Number(node.h) || 0); | |
| geometry = new THREE.BoxGeometry(l, w, h, 1, 1, 1); | |
| geometry.translate(l / 2, w / 2, h / 2); | |
| orientAlong(geometry, node.dir, node.pnt); | |
| return geometry; | |
| } | |
| case 'cylinder': { | |
| const r = Math.max(1e-4, Number(node.r) || 0); | |
| const h = Math.max(1e-4, Number(node.h) || 0); | |
| const angle = Math.min(360, Math.max(1, Number(node.angle) || 360)); | |
| geometry = new THREE.CylinderGeometry( | |
| r, | |
| r, | |
| h, | |
| segments, | |
| 1, | |
| false, | |
| 0, | |
| (angle / 360) * Math.PI * 2, | |
| ); | |
| geometry.rotateX(Math.PI / 2); | |
| geometry.translate(0, 0, h / 2); | |
| orientAlong(geometry, node.dir, node.pnt); | |
| return geometry; | |
| } | |
| case 'cone': { | |
| const r1 = Math.max(0, Number(node.r1) || 0); | |
| const r2 = Math.max(0, Number(node.r2) || 0); | |
| const h = Math.max(1e-4, Number(node.h) || 0); | |
| const angle = Math.min(360, Math.max(1, Number(node.angle) || 360)); | |
| geometry = new THREE.CylinderGeometry( | |
| r2, | |
| r1, | |
| h, | |
| segments, | |
| 1, | |
| false, | |
| 0, | |
| (angle / 360) * Math.PI * 2, | |
| ); | |
| geometry.rotateX(Math.PI / 2); | |
| geometry.translate(0, 0, h / 2); | |
| orientAlong(geometry, node.dir, node.pnt); | |
| return geometry; | |
| } | |
| case 'sphere': { | |
| const r = Math.max(1e-4, Number(node.r) || 0); | |
| geometry = new THREE.SphereGeometry(r, segments, 32); | |
| const p = node.pnt || [0, 0, 0]; | |
| geometry.translate(p[0] || 0, p[1] || 0, p[2] || 0); | |
| return geometry; | |
| } | |
| case 'torus': { | |
| const r1 = Math.max(1e-4, Number(node.r1) || 0); | |
| const r2 = Math.max(1e-4, Number(node.r2) || 0); | |
| geometry = new THREE.TorusGeometry(r1, Math.min(r2, r1 * 0.95), 20, 56); | |
| orientAlong(geometry, node.dir, node.pnt); | |
| return geometry; | |
| } | |
| case 'extrude': { | |
| const points = cleanPoints(node.profile); | |
| if (points.length < 3) return null; | |
| const vec = new THREE.Vector3().fromArray(node.vector || [0, 0, 1]); | |
| const depth = Math.max(1e-4, vec.length()); | |
| const shape = new THREE.Shape(points); | |
| geometry = new THREE.ExtrudeGeometry(shape, { | |
| depth, | |
| bevelEnabled: false, | |
| steps: 1, | |
| curveSegments: 24, | |
| }); | |
| if (depth > 1e-9) { | |
| const q = new THREE.Quaternion().setFromUnitVectors(Z, vec.clone().normalize()); | |
| geometry.applyQuaternion(q); | |
| } | |
| return geometry; | |
| } | |
| case 'face': { | |
| const points = cleanPoints(node.profile); | |
| if (points.length < 3) return null; | |
| geometry = new THREE.ShapeGeometry(new THREE.Shape(points), 24); | |
| return geometry; | |
| } | |
| case 'wire': { | |
| const points = cleanPoints(node.profile); | |
| if (points.length < 2) return null; | |
| const curve = new THREE.CatmullRomCurve3( | |
| points.map((p) => new THREE.Vector3(p.x, p.y, 0)), | |
| Boolean(node.closed), | |
| 'catmullrom', | |
| 0.08, | |
| ); | |
| geometry = new THREE.TubeGeometry( | |
| curve, | |
| Math.max(12, points.length * 3), | |
| 0.16, | |
| 8, | |
| Boolean(node.closed), | |
| ); | |
| return geometry; | |
| } | |
| default: | |
| return null; | |
| } | |
| } | |
| function makeBytes(geometry) { | |
| return cleanGeometry(geometry); | |
| } | |
| function unionGeometries(a, b) { | |
| const merged = mergeGeometries([a, b], false); | |
| return merged || a; | |
| } | |
| function csgGeometry(a, b, operation) { | |
| try { | |
| const evaluator = new Evaluator(); | |
| evaluator.attributes = ['position', 'normal']; | |
| evaluator.useGroups = false; | |
| const brushA = new Brush(a); | |
| const brushB = new Brush(b); | |
| brushA.updateMatrixWorld(); | |
| brushB.updateMatrixWorld(); | |
| const result = evaluator.evaluate(brushA, brushB, operation); | |
| const out = result.geometry; | |
| if (!out || !out.attributes.position || out.attributes.position.count === 0) return a; | |
| return cleanGeometry(out.clone()); | |
| } catch (error) { | |
| console.warn('CSG failed', error); | |
| return a; | |
| } | |
| } | |
| function nodeHasBoolean(node) { | |
| if (!node) return false; | |
| if (node.op === 'boolean') return true; | |
| if (node.op === 'transform') return nodeHasBoolean(node.child); | |
| if (node.op === 'group') return (node.children || []).some(nodeHasBoolean); | |
| return false; | |
| } | |
| function buildNode(node) { | |
| if (!node) return null; | |
| switch (node.op) { | |
| case 'boolean': { | |
| const a = buildNode(node.a); | |
| const b = buildNode(node.b); | |
| if (!a) return b; | |
| if (!b) return a; | |
| if (node.kind === 'cut') return csgGeometry(a, b, SUBTRACTION); | |
| if (node.kind === 'common') return csgGeometry(a, b, INTERSECTION); | |
| return unionGeometries(a, b); | |
| } | |
| case 'group': { | |
| const parts = (node.children || []).map(buildNode).filter(Boolean); | |
| if (parts.length === 0) return null; | |
| if (parts.length === 1) return parts[0]; | |
| return mergeGeometries(parts, false) || parts[0]; | |
| } | |
| case 'transform': { | |
| const child = buildNode(node.child); | |
| if (!child) return null; | |
| const matrix = new THREE.Matrix4().fromArray(node.m || []); | |
| child.applyMatrix4(matrix); | |
| return child; | |
| } | |
| default: | |
| return primitive(node); | |
| } | |
| } | |
| function makeMaterial(item) { | |
| const [r, g, b] = item.color || [0.8, 0.8, 0.8]; | |
| const color = new THREE.Color().setRGB(r, g, b, THREE.SRGBColorSpace); | |
| const luminance = r * 0.2126 + g * 0.7152 + b * 0.0722; | |
| const dark = luminance < 0.2; | |
| const opacity = typeof item.opacity === 'number' ? item.opacity : 1; | |
| return new THREE.MeshStandardMaterial({ | |
| color, | |
| roughness: dark ? 0.36 : 0.82, | |
| metalness: dark ? 0.12 : 0.02, | |
| transparent: opacity < 0.999, | |
| opacity, | |
| depthWrite: opacity > 0.999, | |
| side: THREE.FrontSide, | |
| }); | |
| } | |
| export function createViewer(canvas, options = {}) { | |
| const renderer = new THREE.WebGLRenderer({ | |
| canvas, | |
| antialias: true, | |
| alpha: true, | |
| powerPreference: 'high-performance', | |
| }); | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.05; | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFShadowMap; | |
| renderer.setClearColor(0x000000, 0); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 10000); | |
| camera.up.set(0, 0, 1); | |
| let viewDirection = new THREE.Vector3().fromArray(options.direction || [0.86, -1.35, 0.98]).normalize(); | |
| const showEdges = options.edges !== false; | |
| const controls = new OrbitControls(camera, canvas); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.075; | |
| controls.enablePan = false; | |
| controls.rotateSpeed = 0.6; | |
| controls.zoomSpeed = 0.8; | |
| controls.autoRotate = options.autoRotate !== false; | |
| controls.autoRotateSpeed = 0.75; | |
| scene.add(new THREE.HemisphereLight(0xfff8ec, 0xb2a690, 1.35)); | |
| const key = new THREE.DirectionalLight(0xfff2df, 2.7); | |
| key.castShadow = true; | |
| key.shadow.mapSize.set(2048, 2048); | |
| key.shadow.bias = -0.0004; | |
| key.shadow.radius = 3; | |
| scene.add(key); | |
| scene.add(key.target); | |
| const fill = new THREE.DirectionalLight(0xdfe9ff, 0.85); | |
| fill.position.set(-30, 45, 30); | |
| scene.add(fill); | |
| const rim = new THREE.DirectionalLight(0xffffff, 0.7); | |
| rim.position.set(20, 50, -35); | |
| scene.add(rim); | |
| const root = new THREE.Group(); | |
| scene.add(root); | |
| const floor = new THREE.Mesh( | |
| new THREE.PlaneGeometry(400, 400), | |
| new THREE.ShadowMaterial({ color: 0x2a241c, opacity: 0.16 }), | |
| ); | |
| floor.rotation.x = Math.PI / 2; | |
| floor.receiveShadow = true; | |
| floor.renderOrder = -2; | |
| scene.add(floor); | |
| let grid = null; | |
| let size = 10; | |
| let center = new THREE.Vector3(); | |
| let radius = 5; | |
| let fitted = false; | |
| let autoRotateEnabled = options.autoRotate !== false; | |
| let resumeAt = 0; | |
| function resize() { | |
| const parent = canvas.parentElement; | |
| if (!parent) return; | |
| const width = parent.clientWidth; | |
| const height = parent.clientHeight; | |
| if (width < 2 || height < 2) return; | |
| renderer.setSize(width, height, false); | |
| camera.aspect = width / height; | |
| camera.updateProjectionMatrix(); | |
| if (fitted) fit(false); | |
| } | |
| function fit(resetDirection) { | |
| if (!fitted) return; | |
| const aspect = camera.aspect || 1; | |
| const dir = viewDirection.clone(); | |
| if (!resetDirection) { | |
| const current = camera.position.clone().sub(controls.target).normalize(); | |
| if (current.lengthSq() > 0.5) dir.copy(current); | |
| } | |
| const forward = dir.clone().negate(); | |
| let up = camera.up.clone(); | |
| let right = new THREE.Vector3().crossVectors(forward, up); | |
| if (right.lengthSq() < 1e-8) { | |
| up.set(0, 1, 0); | |
| right = new THREE.Vector3().crossVectors(forward, up); | |
| } | |
| right.normalize(); | |
| const camUp = new THREE.Vector3().crossVectors(right, forward).normalize(); | |
| const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov * 0.5)); | |
| const tanH = tanV * aspect; | |
| const max = new THREE.Vector3(); | |
| const box = new THREE.Box3().setFromObject(root); | |
| if (box.isEmpty()) { | |
| box.setFromCenterAndSize(center, new THREE.Vector3(radius * 2, radius * 2, radius * 2)); | |
| } | |
| box.getSize(max); | |
| const half = max.multiplyScalar(0.5); | |
| const corners = []; | |
| for (const x of [-1, 1]) { | |
| for (const y of [-1, 1]) { | |
| for (const z of [-1, 1]) { | |
| corners.push( | |
| new THREE.Vector3(x * half.x, y * half.y, z * half.z), | |
| ); | |
| } | |
| } | |
| } | |
| let distance = 0; | |
| for (const corner of corners) { | |
| const x = corner.dot(right); | |
| const y = corner.dot(camUp); | |
| const z = corner.dot(forward); | |
| distance = Math.max(distance, Math.abs(x) / tanH - z, Math.abs(y) / tanV - z); | |
| } | |
| distance = Math.max(distance * 1.08, radius * 0.5); | |
| camera.position.copy(center).addScaledVector(dir, distance); | |
| camera.near = Math.max(0.01, distance - radius * 4); | |
| camera.far = distance + radius * 12; | |
| camera.updateProjectionMatrix(); | |
| controls.target.copy(center); | |
| controls.minDistance = radius * 0.25; | |
| controls.maxDistance = radius * 8; | |
| controls.update(); | |
| key.position.copy(center).add(new THREE.Vector3(0.7, -0.9, 1.5).normalize().multiplyScalar(radius * 3.6)); | |
| key.target.position.copy(center); | |
| key.target.updateMatrixWorld(); | |
| const extent = radius * 1.55; | |
| const shadow = key.shadow.camera; | |
| shadow.left = -extent; | |
| shadow.right = extent; | |
| shadow.top = extent; | |
| shadow.bottom = -extent; | |
| shadow.near = 0.1; | |
| shadow.far = radius * 12; | |
| shadow.updateProjectionMatrix(); | |
| } | |
| function setScene(sceneData, direction) { | |
| if (direction && direction.length === 3) { | |
| viewDirection = new THREE.Vector3().fromArray(direction).normalize(); | |
| } | |
| for (const child of [...root.children]) { | |
| root.remove(child); | |
| disposeObject(child); | |
| } | |
| if (grid) { | |
| scene.remove(grid); | |
| grid.geometry.dispose(); | |
| grid.material.dispose(); | |
| grid = null; | |
| } | |
| const objects = (sceneData && sceneData.objects) || []; | |
| let built = 0; | |
| let skipped = 0; | |
| for (const item of objects) { | |
| const geometry = buildNode(item.node); | |
| if (!geometry) { | |
| skipped += 1; | |
| continue; | |
| } | |
| const clean = makeBytes(geometry); | |
| const material = makeMaterial(item); | |
| const mesh = new THREE.Mesh(clean, material); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| mesh.name = item.name || 'Part'; | |
| if (showEdges && !nodeHasBoolean(item.node) && clean.attributes.position && clean.attributes.position.count < 180000) { | |
| const edges = new THREE.EdgesGeometry(clean, 48); | |
| const line = new THREE.LineSegments( | |
| edges, | |
| new THREE.LineBasicMaterial({ | |
| color: 0x2f2a22, | |
| transparent: true, | |
| opacity: 0.1, | |
| depthWrite: false, | |
| }), | |
| ); | |
| line.renderOrder = 3; | |
| mesh.add(line); | |
| } | |
| root.add(mesh); | |
| built += 1; | |
| } | |
| const box = new THREE.Box3().setFromObject(root); | |
| if (box.isEmpty()) { | |
| size = 10; | |
| center.set(0, 0, 0); | |
| radius = 5; | |
| fitted = false; | |
| floor.visible = false; | |
| controls.target.set(0, 0, 0); | |
| camera.position.set(18, -28, 22); | |
| camera.near = 0.1; | |
| camera.far = 1000; | |
| camera.updateProjectionMatrix(); | |
| controls.update(); | |
| return { objects: 0, skipped, visible: [] }; | |
| } | |
| box.getCenter(center); | |
| const dims = box.getSize(new THREE.Vector3()); | |
| radius = Math.max(1, dims.length() * 0.5); | |
| size = Math.max(20, Math.max(dims.x, dims.y, dims.z) * 3.2); | |
| fitted = true; | |
| floor.visible = true; | |
| floor.position.set(center.x, center.y, box.min.z - 0.005); | |
| floor.updateMatrixWorld(); | |
| grid = new THREE.GridHelper(size, 18, 0xb9ae9b, 0xd0c7b7); | |
| grid.rotation.x = Math.PI / 2; | |
| grid.position.set(center.x, center.y, box.min.z + 0.003); | |
| grid.material.transparent = true; | |
| grid.material.opacity = 0.24; | |
| grid.material.depthWrite = false; | |
| scene.add(grid); | |
| fit(true); | |
| return { | |
| objects: built, | |
| skipped, | |
| visible: root.children.map((o) => o.name), | |
| bounds: { min: box.min.toArray(), max: box.max.toArray() }, | |
| }; | |
| } | |
| function resetView() { | |
| if (!fitted) return; | |
| const box = new THREE.Box3().setFromObject(root); | |
| if (box.isEmpty()) return; | |
| box.getCenter(center); | |
| fit(true); | |
| resumeAt = performance.now() + 3500; | |
| } | |
| function setAutoRotate(enabled) { | |
| autoRotateEnabled = Boolean(enabled); | |
| controls.autoRotate = autoRotateEnabled; | |
| resumeAt = performance.now() + 3500; | |
| } | |
| function onPointerDown() { | |
| controls.autoRotate = false; | |
| resumeAt = performance.now() + 4500; | |
| } | |
| function onPointerUp() { | |
| if (autoRotateEnabled) { | |
| resumeAt = performance.now() + 900; | |
| } | |
| } | |
| canvas.addEventListener('pointerdown', onPointerDown); | |
| window.addEventListener('pointerup', onPointerUp); | |
| const observer = new ResizeObserver(resize); | |
| if (canvas.parentElement) observer.observe(canvas.parentElement); | |
| resize(); | |
| renderer.setAnimationLoop(() => { | |
| if (autoRotateEnabled && resumeAt && performance.now() > resumeAt) { | |
| controls.autoRotate = true; | |
| resumeAt = 0; | |
| } | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| }); | |
| return { | |
| setScene, | |
| resetView, | |
| setAutoRotate, | |
| resize, | |
| isAutoRotate: () => autoRotateEnabled, | |
| dispose() { | |
| renderer.setAnimationLoop(null); | |
| observer.disconnect(); | |
| window.removeEventListener('pointerup', onPointerUp); | |
| for (const child of [...root.children]) { | |
| root.remove(child); | |
| disposeObject(child); | |
| } | |
| controls.dispose(); | |
| renderer.dispose(); | |
| }, | |
| }; | |
| } | |