WFreeCAD / viewer.js
HelloSun's picture
Initial WFreeCAD static space
ebc1b3a verified
Raw History Blame Contribute Delete
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();
},
};
}