formats.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /*
  2. formats.js
  3. Format registry and loading front-end for the 3D Viewer.
  4. Mesh formats go through the matching three.js loader. CAD formats
  5. (STEP / IGES / BREP) are tessellated by the OpenCascade based
  6. occt-import-js WASM kernel, which runs inside a worker so that a large
  7. assembly does not freeze the UI. The kernel is only fetched the first time
  8. a CAD file is opened.
  9. */
  10. import * as THREE from 'three';
  11. import { STLLoader } from 'three/addons/loaders/STLLoader.js';
  12. import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
  13. import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
  14. import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
  15. import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
  16. import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
  17. import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
  18. import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
  19. import { GCodeLoader } from 'three/addons/loaders/GCodeLoader.js';
  20. /*
  21. Supported extensions.
  22. label - shown in the file card ("STL File")
  23. up - native up axis of the data as three.js hands it back to
  24. us; the viewer works in a Z-up world, so "Y" sources get
  25. rotated a quarter turn about X on load
  26. ownMaterials - true when the format carries its own materials/colors, in
  27. which case the model color picker acts as an override
  28. unitToMM - millimetres per scene unit, so the file card can report a
  29. real size. glTF is metres by specification, and the
  30. Collada loader has already folded the file's own unit
  31. declaration down to metres; the CAD and printing formats
  32. are millimetres
  33. */
  34. export const FORMATS = {
  35. stl: { label: 'STL', up: 'Z', ownMaterials: false, unitToMM: 1 },
  36. obj: { label: 'OBJ', up: 'Y', ownMaterials: true, unitToMM: 1 },
  37. glb: { label: 'GLB', up: 'Y', ownMaterials: true, unitToMM: 1000 },
  38. gltf: { label: 'glTF', up: 'Y', ownMaterials: true, unitToMM: 1000 },
  39. ply: { label: 'PLY', up: 'Z', ownMaterials: false, unitToMM: 1 },
  40. '3mf': { label: '3MF', up: 'Z', ownMaterials: true, unitToMM: 1 },
  41. fbx: { label: 'FBX', up: 'Y', ownMaterials: true, unitToMM: 1 },
  42. dae: { label: 'Collada', up: 'Y', ownMaterials: true, unitToMM: 1000 },
  43. step: { label: 'STEP', up: 'Z', ownMaterials: true, unitToMM: 1 },
  44. stp: { label: 'STEP', up: 'Z', ownMaterials: true, unitToMM: 1 },
  45. iges: { label: 'IGES', up: 'Z', ownMaterials: true, unitToMM: 1 },
  46. igs: { label: 'IGES', up: 'Z', ownMaterials: true, unitToMM: 1 },
  47. brep: { label: 'BREP', up: 'Z', ownMaterials: true, unitToMM: 1 },
  48. // Sliced toolpaths. GCodeLoader rotates its own root a quarter turn to hand
  49. // back a Y-up object, so declaring "Y" here makes the viewer's pivot cancel
  50. // that back out and the print stands up the way it was sliced.
  51. gcode: { label: 'G-code', up: 'Y', ownMaterials: false, unitToMM: 1, toolpath: true },
  52. gco: { label: 'G-code', up: 'Y', ownMaterials: false, unitToMM: 1, toolpath: true }
  53. };
  54. export function extOf(filename) {
  55. const m = /\.([a-z0-9]+)\s*$/i.exec(filename || '');
  56. return m ? m[1].toLowerCase() : '';
  57. }
  58. export function isSupported(filename) {
  59. return Object.prototype.hasOwnProperty.call(FORMATS, extOf(filename));
  60. }
  61. export function supportedExtList() {
  62. return Object.keys(FORMATS).map(function (e) { return '.' + e; });
  63. }
  64. /* ------------------------------------------------------------------ */
  65. /* Source fetching */
  66. /* ------------------------------------------------------------------ */
  67. /*
  68. Read the whole model into memory first so that we can report real download
  69. progress and hand the same buffer to whichever parser is needed.
  70. */
  71. function fetchBuffer(url, onProgress) {
  72. return new Promise(function (resolve, reject) {
  73. const xhr = new XMLHttpRequest();
  74. xhr.open('GET', url, true);
  75. xhr.responseType = 'arraybuffer';
  76. xhr.onload = function () {
  77. if (xhr.status >= 200 && xhr.status < 300) {
  78. resolve(xhr.response);
  79. } else {
  80. reject(new Error('Server returned HTTP ' + xhr.status));
  81. }
  82. };
  83. xhr.onerror = function () { reject(new Error('Network error while downloading the model')); };
  84. xhr.onprogress = function (e) {
  85. if (onProgress) onProgress(e.lengthComputable ? e.loaded / e.total : -1, e.loaded);
  86. };
  87. xhr.send();
  88. });
  89. }
  90. function readFileBuffer(file, onProgress) {
  91. return new Promise(function (resolve, reject) {
  92. const fr = new FileReader();
  93. fr.onload = function () { resolve(fr.result); };
  94. fr.onerror = function () { reject(new Error('Could not read the dropped file')); };
  95. fr.onprogress = function (e) {
  96. if (onProgress) onProgress(e.lengthComputable ? e.loaded / e.total : -1, e.loaded);
  97. };
  98. fr.readAsArrayBuffer(file);
  99. });
  100. }
  101. function decodeText(buffer) {
  102. return new TextDecoder('utf-8').decode(new Uint8Array(buffer));
  103. }
  104. /*
  105. Sibling assets (.mtl files, textures, .bin chunks) live next to the model in
  106. the user's storage, but they are reached through a query-string media
  107. endpoint rather than a directory URL. A LoadingManager URL modifier maps the
  108. bare filenames the parsers ask for onto that endpoint.
  109. */
  110. function makeManager(resolveSibling) {
  111. const manager = new THREE.LoadingManager();
  112. if (!resolveSibling) return manager;
  113. manager.setURLModifier(function (url) {
  114. if (/^(https?:|blob:|data:|\/\/)/i.test(url)) return url;
  115. return resolveSibling(url);
  116. });
  117. return manager;
  118. }
  119. /* ------------------------------------------------------------------ */
  120. /* OpenCascade (STEP / IGES / BREP) */
  121. /* ------------------------------------------------------------------ */
  122. let occtWorker = null;
  123. let occtSeq = 0;
  124. function occtRequest(kind, bytes, onStatus) {
  125. if (!occtWorker) {
  126. occtWorker = new Worker(new URL('./occtWorker.js', import.meta.url));
  127. }
  128. const id = ++occtSeq;
  129. return new Promise(function (resolve, reject) {
  130. function onMessage(ev) {
  131. const msg = ev.data;
  132. if (!msg || msg.id !== id) return;
  133. if (msg.type === 'status') {
  134. if (onStatus) onStatus(msg.text);
  135. return;
  136. }
  137. occtWorker.removeEventListener('message', onMessage);
  138. if (msg.type === 'ok') resolve(msg.result);
  139. else reject(new Error(msg.error || 'The CAD kernel could not read this file'));
  140. }
  141. occtWorker.addEventListener('message', onMessage);
  142. occtWorker.addEventListener('error', function (e) {
  143. reject(new Error('CAD kernel failed to start: ' + (e.message || 'unknown error')));
  144. }, { once: true });
  145. occtWorker.postMessage({ id: id, kind: kind, buffer: bytes.buffer }, [bytes.buffer]);
  146. });
  147. }
  148. /*
  149. Turn one occt-import-js mesh description into a three.js Mesh. A STEP body
  150. is a single indexed buffer whose triangles are grouped per B-rep face, so
  151. per-face colors become material groups.
  152. */
  153. /*
  154. A STEP file that declares no colour still comes back from the kernel with
  155. OpenCascade's default neutral grey (around 0.60 on every channel) rather
  156. than no colour at all, so "did the author choose a colour" has to be
  157. answered by looking for actual chroma. A part deliberately authored pure
  158. grey reads as uncoloured here and picks up the model colour instead, which
  159. is the more useful default - grey is still one click away in the palette.
  160. */
  161. function isChromatic(rgb) {
  162. if (!rgb) return false;
  163. const max = Math.max(rgb[0], rgb[1], rgb[2]);
  164. const min = Math.min(rgb[0], rgb[1], rgb[2]);
  165. return (max - min) > 0.02;
  166. }
  167. function occtMeshHasColor(src) {
  168. if (isChromatic(src.color)) return true;
  169. const faces = src.brep_faces || [];
  170. for (let i = 0; i < faces.length; i++) {
  171. if (isChromatic(faces[i].color)) return true;
  172. }
  173. return false;
  174. }
  175. function buildOcctMesh(src) {
  176. const geometry = new THREE.BufferGeometry();
  177. geometry.setAttribute('position', new THREE.Float32BufferAttribute(src.attributes.position.array, 3));
  178. if (src.attributes.normal) {
  179. geometry.setAttribute('normal', new THREE.Float32BufferAttribute(src.attributes.normal.array, 3));
  180. }
  181. const index = Uint32Array.from(src.index.array);
  182. geometry.setIndex(new THREE.BufferAttribute(index, 1));
  183. if (!src.attributes.normal) geometry.computeVertexNormals();
  184. geometry.name = src.name || '';
  185. function makeMaterial(rgb) {
  186. return new THREE.MeshStandardMaterial({
  187. color: rgb ? new THREE.Color(rgb[0], rgb[1], rgb[2]) : 0xc9ccd1,
  188. roughness: 0.5,
  189. metalness: 0.08
  190. });
  191. }
  192. const materials = [makeMaterial(src.color)];
  193. const faces = src.brep_faces || [];
  194. if (faces.length > 0) {
  195. for (let i = 0; i < faces.length; i++) materials.push(makeMaterial(faces[i].color || src.color));
  196. const triangleCount = index.length / 3;
  197. let triangle = 0;
  198. let faceGroup = 0;
  199. while (triangle < triangleCount) {
  200. const first = triangle;
  201. let last, materialIndex;
  202. if (faceGroup >= faces.length) {
  203. last = triangleCount;
  204. materialIndex = 0;
  205. } else if (triangle < faces[faceGroup].first) {
  206. last = faces[faceGroup].first;
  207. materialIndex = 0;
  208. } else {
  209. last = faces[faceGroup].last + 1;
  210. materialIndex = faceGroup + 1;
  211. faceGroup++;
  212. }
  213. geometry.addGroup(first * 3, (last - first) * 3, materialIndex);
  214. triangle = last;
  215. }
  216. }
  217. const mesh = new THREE.Mesh(geometry, materials.length > 1 ? materials : materials[0]);
  218. mesh.name = src.name || '';
  219. return mesh;
  220. }
  221. /*
  222. Returns the tessellated group plus whether the file actually carried any
  223. colour. Plenty of STEP exports have no colour at all, and those should be
  224. treated like a bare mesh so the model colour picker drives them instead of
  225. leaving them stuck on the kernel's neutral grey.
  226. */
  227. function occtToGroup(result) {
  228. const group = new THREE.Group();
  229. const meshes = (result && result.meshes) || [];
  230. let coloured = false;
  231. for (let i = 0; i < meshes.length; i++) {
  232. if (occtMeshHasColor(meshes[i])) coloured = true;
  233. group.add(buildOcctMesh(meshes[i]));
  234. }
  235. if (group.children.length === 0) {
  236. throw new Error('The file contains no solid geometry that could be tessellated');
  237. }
  238. return { object: group, ownMaterials: coloured };
  239. }
  240. /* ------------------------------------------------------------------ */
  241. /* Parsers */
  242. /* ------------------------------------------------------------------ */
  243. function geometryToObject(geometry) {
  244. if (!geometry.attributes.normal) geometry.computeVertexNormals();
  245. // A brand new material here is only a placeholder; the viewer immediately
  246. // re-materialises geometry-only models with the current model color.
  247. const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
  248. mesh.userData.geometryOnly = true;
  249. return mesh;
  250. }
  251. /*
  252. loadModel(source, options)
  253. source - { url, resolveSibling } for a file in the user's storage, or
  254. { file } for a File dropped onto the window
  255. options - { filename, onProgress(fraction, label) }
  256. Resolves with { object, format, ext }.
  257. */
  258. export async function loadModel(source, options) {
  259. const opts = options || {};
  260. const filename = opts.filename || source.filename || '';
  261. const ext = extOf(filename);
  262. const format = FORMATS[ext];
  263. if (!format) throw new Error('"' + (ext ? '.' + ext : filename) + '" is not a supported 3D model format');
  264. const report = function (fraction, label) {
  265. if (opts.onProgress) opts.onProgress(fraction, label);
  266. };
  267. report(0, 'Downloading model...');
  268. const buffer = source.file
  269. ? await readFileBuffer(source.file, function (f) { report(f * 0.6, 'Reading file...'); })
  270. : await fetchBuffer(source.url, function (f) { report(f * 0.6, 'Downloading model...'); });
  271. // The CAD path hands its buffer to the worker as a transferable, which
  272. // detaches it here, so the size is recorded up front.
  273. const byteLength = buffer.byteLength;
  274. report(0.6, ext === 'step' || ext === 'stp' || ext === 'iges' || ext === 'igs' || ext === 'brep'
  275. ? 'Tessellating CAD geometry...'
  276. : 'Parsing model...');
  277. // Parsing runs on the main thread and a big model (or a long toolpath) can
  278. // hold it for a while, so yield once and let the progress overlay paint
  279. // before the browser goes quiet.
  280. await new Promise(function (resolve) { setTimeout(resolve, 0); });
  281. const manager = makeManager(source.resolveSibling);
  282. let object;
  283. // Most formats always carry their own materials; the CAD path decides per
  284. // file, because colour is optional in STEP/IGES/BREP.
  285. let ownMaterials = format.ownMaterials;
  286. switch (ext) {
  287. case 'stl':
  288. object = geometryToObject(new STLLoader(manager).parse(buffer));
  289. break;
  290. case 'ply':
  291. object = geometryToObject(new PLYLoader(manager).parse(buffer));
  292. break;
  293. case 'obj': {
  294. const loader = new OBJLoader(manager);
  295. const materials = await loadObjMaterials(source, filename, manager);
  296. if (materials) loader.setMaterials(materials);
  297. object = loader.parse(decodeText(buffer));
  298. break;
  299. }
  300. case 'glb':
  301. case 'gltf': {
  302. const loader = new GLTFLoader(manager);
  303. const gltf = await new Promise(function (resolve, reject) {
  304. loader.parse(buffer, '', resolve, reject);
  305. });
  306. object = gltf.scene || gltf.scenes[0];
  307. break;
  308. }
  309. case '3mf':
  310. object = new ThreeMFLoader(manager).parse(buffer);
  311. break;
  312. case 'fbx':
  313. object = new FBXLoader(manager).parse(buffer, '');
  314. break;
  315. case 'dae':
  316. object = new ColladaLoader(manager).parse(decodeText(buffer), '').scene;
  317. break;
  318. case 'gcode':
  319. case 'gco':
  320. object = new GCodeLoader(manager).parse(decodeText(buffer));
  321. break;
  322. case 'step':
  323. case 'stp':
  324. case 'iges':
  325. case 'igs':
  326. case 'brep': {
  327. const kind = (ext === 'step' || ext === 'stp') ? 'step' : (ext === 'brep' ? 'brep' : 'iges');
  328. const cad = occtToGroup(await occtRequest(kind, new Uint8Array(buffer), function (t) { report(-1, t); }));
  329. object = cad.object;
  330. ownMaterials = cad.ownMaterials;
  331. break;
  332. }
  333. }
  334. report(1, 'Preparing scene...');
  335. return { object: object, format: format, ext: ext, bytes: byteLength, ownMaterials: ownMaterials };
  336. }
  337. /*
  338. OBJ files keep their materials in a sibling .mtl. It is optional, so a
  339. missing or unreadable one is not an error - the model just renders in the
  340. current model color.
  341. */
  342. async function loadObjMaterials(source, filename, manager) {
  343. if (!source.resolveSibling) return null;
  344. const mtlName = filename.replace(/\.obj$/i, '.mtl');
  345. try {
  346. const resp = await fetch(source.resolveSibling(mtlName.split('/').pop()));
  347. if (!resp.ok) return null;
  348. const text = await resp.text();
  349. // The storage backend answers with a JSON error object for missing files.
  350. if (!text || text.trim().charAt(0) === '{') return null;
  351. const materials = new MTLLoader(manager).parse(text, '');
  352. materials.preload();
  353. return materials;
  354. } catch (e) {
  355. return null;
  356. }
  357. }