| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- /*
- formats.js
- Format registry and loading front-end for the 3D Viewer.
- Mesh formats go through the matching three.js loader. CAD formats
- (STEP / IGES / BREP) are tessellated by the OpenCascade based
- occt-import-js WASM kernel, which runs inside a worker so that a large
- assembly does not freeze the UI. The kernel is only fetched the first time
- a CAD file is opened.
- */
- import * as THREE from 'three';
- import { STLLoader } from 'three/addons/loaders/STLLoader.js';
- import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
- import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
- import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
- import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
- import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
- import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
- import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
- import { GCodeLoader } from 'three/addons/loaders/GCodeLoader.js';
- /*
- Supported extensions.
- label - shown in the file card ("STL File")
- up - native up axis of the data as three.js hands it back to
- us; the viewer works in a Z-up world, so "Y" sources get
- rotated a quarter turn about X on load
- ownMaterials - true when the format carries its own materials/colors, in
- which case the model color picker acts as an override
- unitToMM - millimetres per scene unit, so the file card can report a
- real size. glTF is metres by specification, and the
- Collada loader has already folded the file's own unit
- declaration down to metres; the CAD and printing formats
- are millimetres
- */
- export const FORMATS = {
- stl: { label: 'STL', up: 'Z', ownMaterials: false, unitToMM: 1 },
- obj: { label: 'OBJ', up: 'Y', ownMaterials: true, unitToMM: 1 },
- glb: { label: 'GLB', up: 'Y', ownMaterials: true, unitToMM: 1000 },
- gltf: { label: 'glTF', up: 'Y', ownMaterials: true, unitToMM: 1000 },
- ply: { label: 'PLY', up: 'Z', ownMaterials: false, unitToMM: 1 },
- '3mf': { label: '3MF', up: 'Z', ownMaterials: true, unitToMM: 1 },
- fbx: { label: 'FBX', up: 'Y', ownMaterials: true, unitToMM: 1 },
- dae: { label: 'Collada', up: 'Y', ownMaterials: true, unitToMM: 1000 },
- step: { label: 'STEP', up: 'Z', ownMaterials: true, unitToMM: 1 },
- stp: { label: 'STEP', up: 'Z', ownMaterials: true, unitToMM: 1 },
- iges: { label: 'IGES', up: 'Z', ownMaterials: true, unitToMM: 1 },
- igs: { label: 'IGES', up: 'Z', ownMaterials: true, unitToMM: 1 },
- brep: { label: 'BREP', up: 'Z', ownMaterials: true, unitToMM: 1 },
- // Sliced toolpaths. GCodeLoader rotates its own root a quarter turn to hand
- // back a Y-up object, so declaring "Y" here makes the viewer's pivot cancel
- // that back out and the print stands up the way it was sliced.
- gcode: { label: 'G-code', up: 'Y', ownMaterials: false, unitToMM: 1, toolpath: true },
- gco: { label: 'G-code', up: 'Y', ownMaterials: false, unitToMM: 1, toolpath: true }
- };
- export function extOf(filename) {
- const m = /\.([a-z0-9]+)\s*$/i.exec(filename || '');
- return m ? m[1].toLowerCase() : '';
- }
- export function isSupported(filename) {
- return Object.prototype.hasOwnProperty.call(FORMATS, extOf(filename));
- }
- export function supportedExtList() {
- return Object.keys(FORMATS).map(function (e) { return '.' + e; });
- }
- /* ------------------------------------------------------------------ */
- /* Source fetching */
- /* ------------------------------------------------------------------ */
- /*
- Read the whole model into memory first so that we can report real download
- progress and hand the same buffer to whichever parser is needed.
- */
- function fetchBuffer(url, onProgress) {
- return new Promise(function (resolve, reject) {
- const xhr = new XMLHttpRequest();
- xhr.open('GET', url, true);
- xhr.responseType = 'arraybuffer';
- xhr.onload = function () {
- if (xhr.status >= 200 && xhr.status < 300) {
- resolve(xhr.response);
- } else {
- reject(new Error('Server returned HTTP ' + xhr.status));
- }
- };
- xhr.onerror = function () { reject(new Error('Network error while downloading the model')); };
- xhr.onprogress = function (e) {
- if (onProgress) onProgress(e.lengthComputable ? e.loaded / e.total : -1, e.loaded);
- };
- xhr.send();
- });
- }
- function readFileBuffer(file, onProgress) {
- return new Promise(function (resolve, reject) {
- const fr = new FileReader();
- fr.onload = function () { resolve(fr.result); };
- fr.onerror = function () { reject(new Error('Could not read the dropped file')); };
- fr.onprogress = function (e) {
- if (onProgress) onProgress(e.lengthComputable ? e.loaded / e.total : -1, e.loaded);
- };
- fr.readAsArrayBuffer(file);
- });
- }
- function decodeText(buffer) {
- return new TextDecoder('utf-8').decode(new Uint8Array(buffer));
- }
- /*
- Sibling assets (.mtl files, textures, .bin chunks) live next to the model in
- the user's storage, but they are reached through a query-string media
- endpoint rather than a directory URL. A LoadingManager URL modifier maps the
- bare filenames the parsers ask for onto that endpoint.
- */
- function makeManager(resolveSibling) {
- const manager = new THREE.LoadingManager();
- if (!resolveSibling) return manager;
- manager.setURLModifier(function (url) {
- if (/^(https?:|blob:|data:|\/\/)/i.test(url)) return url;
- return resolveSibling(url);
- });
- return manager;
- }
- /* ------------------------------------------------------------------ */
- /* OpenCascade (STEP / IGES / BREP) */
- /* ------------------------------------------------------------------ */
- let occtWorker = null;
- let occtSeq = 0;
- function occtRequest(kind, bytes, onStatus) {
- if (!occtWorker) {
- occtWorker = new Worker(new URL('./occtWorker.js', import.meta.url));
- }
- const id = ++occtSeq;
- return new Promise(function (resolve, reject) {
- function onMessage(ev) {
- const msg = ev.data;
- if (!msg || msg.id !== id) return;
- if (msg.type === 'status') {
- if (onStatus) onStatus(msg.text);
- return;
- }
- occtWorker.removeEventListener('message', onMessage);
- if (msg.type === 'ok') resolve(msg.result);
- else reject(new Error(msg.error || 'The CAD kernel could not read this file'));
- }
- occtWorker.addEventListener('message', onMessage);
- occtWorker.addEventListener('error', function (e) {
- reject(new Error('CAD kernel failed to start: ' + (e.message || 'unknown error')));
- }, { once: true });
- occtWorker.postMessage({ id: id, kind: kind, buffer: bytes.buffer }, [bytes.buffer]);
- });
- }
- /*
- Turn one occt-import-js mesh description into a three.js Mesh. A STEP body
- is a single indexed buffer whose triangles are grouped per B-rep face, so
- per-face colors become material groups.
- */
- /*
- A STEP file that declares no colour still comes back from the kernel with
- OpenCascade's default neutral grey (around 0.60 on every channel) rather
- than no colour at all, so "did the author choose a colour" has to be
- answered by looking for actual chroma. A part deliberately authored pure
- grey reads as uncoloured here and picks up the model colour instead, which
- is the more useful default - grey is still one click away in the palette.
- */
- function isChromatic(rgb) {
- if (!rgb) return false;
- const max = Math.max(rgb[0], rgb[1], rgb[2]);
- const min = Math.min(rgb[0], rgb[1], rgb[2]);
- return (max - min) > 0.02;
- }
- function occtMeshHasColor(src) {
- if (isChromatic(src.color)) return true;
- const faces = src.brep_faces || [];
- for (let i = 0; i < faces.length; i++) {
- if (isChromatic(faces[i].color)) return true;
- }
- return false;
- }
- function buildOcctMesh(src) {
- const geometry = new THREE.BufferGeometry();
- geometry.setAttribute('position', new THREE.Float32BufferAttribute(src.attributes.position.array, 3));
- if (src.attributes.normal) {
- geometry.setAttribute('normal', new THREE.Float32BufferAttribute(src.attributes.normal.array, 3));
- }
- const index = Uint32Array.from(src.index.array);
- geometry.setIndex(new THREE.BufferAttribute(index, 1));
- if (!src.attributes.normal) geometry.computeVertexNormals();
- geometry.name = src.name || '';
- function makeMaterial(rgb) {
- return new THREE.MeshStandardMaterial({
- color: rgb ? new THREE.Color(rgb[0], rgb[1], rgb[2]) : 0xc9ccd1,
- roughness: 0.5,
- metalness: 0.08
- });
- }
- const materials = [makeMaterial(src.color)];
- const faces = src.brep_faces || [];
- if (faces.length > 0) {
- for (let i = 0; i < faces.length; i++) materials.push(makeMaterial(faces[i].color || src.color));
- const triangleCount = index.length / 3;
- let triangle = 0;
- let faceGroup = 0;
- while (triangle < triangleCount) {
- const first = triangle;
- let last, materialIndex;
- if (faceGroup >= faces.length) {
- last = triangleCount;
- materialIndex = 0;
- } else if (triangle < faces[faceGroup].first) {
- last = faces[faceGroup].first;
- materialIndex = 0;
- } else {
- last = faces[faceGroup].last + 1;
- materialIndex = faceGroup + 1;
- faceGroup++;
- }
- geometry.addGroup(first * 3, (last - first) * 3, materialIndex);
- triangle = last;
- }
- }
- const mesh = new THREE.Mesh(geometry, materials.length > 1 ? materials : materials[0]);
- mesh.name = src.name || '';
- return mesh;
- }
- /*
- Returns the tessellated group plus whether the file actually carried any
- colour. Plenty of STEP exports have no colour at all, and those should be
- treated like a bare mesh so the model colour picker drives them instead of
- leaving them stuck on the kernel's neutral grey.
- */
- function occtToGroup(result) {
- const group = new THREE.Group();
- const meshes = (result && result.meshes) || [];
- let coloured = false;
- for (let i = 0; i < meshes.length; i++) {
- if (occtMeshHasColor(meshes[i])) coloured = true;
- group.add(buildOcctMesh(meshes[i]));
- }
- if (group.children.length === 0) {
- throw new Error('The file contains no solid geometry that could be tessellated');
- }
- return { object: group, ownMaterials: coloured };
- }
- /* ------------------------------------------------------------------ */
- /* Parsers */
- /* ------------------------------------------------------------------ */
- function geometryToObject(geometry) {
- if (!geometry.attributes.normal) geometry.computeVertexNormals();
- // A brand new material here is only a placeholder; the viewer immediately
- // re-materialises geometry-only models with the current model color.
- const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
- mesh.userData.geometryOnly = true;
- return mesh;
- }
- /*
- loadModel(source, options)
- source - { url, resolveSibling } for a file in the user's storage, or
- { file } for a File dropped onto the window
- options - { filename, onProgress(fraction, label) }
- Resolves with { object, format, ext }.
- */
- export async function loadModel(source, options) {
- const opts = options || {};
- const filename = opts.filename || source.filename || '';
- const ext = extOf(filename);
- const format = FORMATS[ext];
- if (!format) throw new Error('"' + (ext ? '.' + ext : filename) + '" is not a supported 3D model format');
- const report = function (fraction, label) {
- if (opts.onProgress) opts.onProgress(fraction, label);
- };
- report(0, 'Downloading model...');
- const buffer = source.file
- ? await readFileBuffer(source.file, function (f) { report(f * 0.6, 'Reading file...'); })
- : await fetchBuffer(source.url, function (f) { report(f * 0.6, 'Downloading model...'); });
- // The CAD path hands its buffer to the worker as a transferable, which
- // detaches it here, so the size is recorded up front.
- const byteLength = buffer.byteLength;
- report(0.6, ext === 'step' || ext === 'stp' || ext === 'iges' || ext === 'igs' || ext === 'brep'
- ? 'Tessellating CAD geometry...'
- : 'Parsing model...');
- // Parsing runs on the main thread and a big model (or a long toolpath) can
- // hold it for a while, so yield once and let the progress overlay paint
- // before the browser goes quiet.
- await new Promise(function (resolve) { setTimeout(resolve, 0); });
- const manager = makeManager(source.resolveSibling);
- let object;
- // Most formats always carry their own materials; the CAD path decides per
- // file, because colour is optional in STEP/IGES/BREP.
- let ownMaterials = format.ownMaterials;
- switch (ext) {
- case 'stl':
- object = geometryToObject(new STLLoader(manager).parse(buffer));
- break;
- case 'ply':
- object = geometryToObject(new PLYLoader(manager).parse(buffer));
- break;
- case 'obj': {
- const loader = new OBJLoader(manager);
- const materials = await loadObjMaterials(source, filename, manager);
- if (materials) loader.setMaterials(materials);
- object = loader.parse(decodeText(buffer));
- break;
- }
- case 'glb':
- case 'gltf': {
- const loader = new GLTFLoader(manager);
- const gltf = await new Promise(function (resolve, reject) {
- loader.parse(buffer, '', resolve, reject);
- });
- object = gltf.scene || gltf.scenes[0];
- break;
- }
- case '3mf':
- object = new ThreeMFLoader(manager).parse(buffer);
- break;
- case 'fbx':
- object = new FBXLoader(manager).parse(buffer, '');
- break;
- case 'dae':
- object = new ColladaLoader(manager).parse(decodeText(buffer), '').scene;
- break;
- case 'gcode':
- case 'gco':
- object = new GCodeLoader(manager).parse(decodeText(buffer));
- break;
- case 'step':
- case 'stp':
- case 'iges':
- case 'igs':
- case 'brep': {
- const kind = (ext === 'step' || ext === 'stp') ? 'step' : (ext === 'brep' ? 'brep' : 'iges');
- const cad = occtToGroup(await occtRequest(kind, new Uint8Array(buffer), function (t) { report(-1, t); }));
- object = cad.object;
- ownMaterials = cad.ownMaterials;
- break;
- }
- }
- report(1, 'Preparing scene...');
- return { object: object, format: format, ext: ext, bytes: byteLength, ownMaterials: ownMaterials };
- }
- /*
- OBJ files keep their materials in a sibling .mtl. It is optional, so a
- missing or unreadable one is not an error - the model just renders in the
- current model color.
- */
- async function loadObjMaterials(source, filename, manager) {
- if (!source.resolveSibling) return null;
- const mtlName = filename.replace(/\.obj$/i, '.mtl');
- try {
- const resp = await fetch(source.resolveSibling(mtlName.split('/').pop()));
- if (!resp.ok) return null;
- const text = await resp.text();
- // The storage backend answers with a JSON error object for missing files.
- if (!text || text.trim().charAt(0) === '{') return null;
- const materials = new MTLLoader(manager).parse(text, '');
- materials.preload();
- return materials;
- } catch (e) {
- return null;
- }
- }
|