atproto Thingiverse but good
1const DEFAULT_STEP_URL = '/models/EInk_click_v101.step';
2const DEFAULT_LIN_DEFLECTION = 0.1;
3const DEFAULT_ANG_DEFLECTION = 0.1;
4
5let openCascadePromise;
6
7self.postMessage({ kind: 'ready' });
8
9self.onmessage = async (event) => {
10 const msg = event.data || {};
11 if (msg.command !== 'convert-step') {
12 return;
13 }
14
15 const timings = [];
16 const startedAt = performance.now();
17
18 try {
19 const stepUrl = msg.stepUrl || DEFAULT_STEP_URL;
20 const linDeflection = Number(msg.linDeflection ?? DEFAULT_LIN_DEFLECTION);
21 const angDeflection = Number(msg.angDeflection ?? DEFAULT_ANG_DEFLECTION);
22
23 mark(timings, 'fetch:start');
24 const stepBytes = new Uint8Array(await (await fetch(stepUrl)).arrayBuffer());
25 mark(timings, 'fetch:end');
26
27 mark(timings, 'init:start');
28 const oc = await loadOpenCascade();
29 validateRequiredBindings(oc);
30 mark(timings, 'init:end');
31
32 mark(timings, 'read:start');
33 writeFile(oc, '/model.step', stepBytes);
34 const doc = readStepIntoDocument(oc, '/model.step');
35 mark(timings, 'read:end');
36
37 mark(timings, 'tessellate:start');
38 const shape = firstFreeShape(oc, doc);
39 if (oc.BRepTools?.Clean && shape) {
40 oc.BRepTools.Clean(shape, false);
41 }
42 if (shape) {
43 new oc.BRepMesh_IncrementalMesh_2(shape, linDeflection, false, angDeflection, false);
44 }
45 mark(timings, 'tessellate:end');
46
47 mark(timings, 'export:start');
48 const glbBytes = exportDocumentToGlb(oc, doc, '/model.glb');
49 mark(timings, 'export:end');
50
51 self.postMessage({
52 kind: 'converted',
53 timings,
54 totalMs: performance.now() - startedAt,
55 stepBytes: stepBytes.byteLength,
56 glbBytes: glbBytes.byteLength,
57 buffer: glbBytes.buffer,
58 crossOriginIsolated: self.crossOriginIsolated === true,
59 hardwareConcurrency: navigator.hardwareConcurrency || null,
60 }, [glbBytes.buffer]);
61 } catch (error) {
62 self.postMessage({
63 kind: 'error',
64 timings,
65 totalMs: performance.now() - startedAt,
66 message: error instanceof Error ? error.message : String(error),
67 stack: error instanceof Error ? error.stack : undefined,
68 crossOriginIsolated: self.crossOriginIsolated === true,
69 hardwareConcurrency: navigator.hardwareConcurrency || null,
70 });
71 }
72};
73
74async function loadOpenCascade() {
75 if (!openCascadePromise) {
76 openCascadePromise = (async () => {
77 const openCascadeUrl = new URL('/vendor/opencascade/opencascade.js', self.location.origin).href;
78 const module = await import(openCascadeUrl);
79 const init = module.default || module.initOpenCascade || module.init;
80 if (typeof init !== 'function') {
81 throw new Error('OpenCascade module did not export an init function');
82 }
83 const options = {
84 locateFile(path) {
85 if (path.endsWith('.wasm')) {
86 return '/vendor/opencascade/opencascade.wasm';
87 }
88 return `/vendor/opencascade/${path}`;
89 },
90 };
91 return init(options);
92 })();
93 }
94 return openCascadePromise;
95}
96
97function writeFile(oc, path, bytes) {
98 if (oc.FS.analyzePath(path).exists) {
99 oc.FS.unlink(path);
100 }
101 oc.FS.writeFile(path, bytes);
102}
103
104function validateRequiredBindings(oc) {
105 const required = [
106 ['STEPCAFControl_Reader_1', 'STEPCAFControl_Reader'],
107 ['TDocStd_Document'],
108 ['Handle_TDocStd_Document_2'],
109 ['TCollection_ExtendedString_1'],
110 ['XCAFDoc_DocumentTool'],
111 ['XCAFDoc_ShapeTool'],
112 ['TDF_LabelSequence_1'],
113 ['BRepMesh_IncrementalMesh_2', 'BRepMesh_IncrementalMesh'],
114 ['RWGltf_CafWriter'],
115 ['TCollection_AsciiString_2'],
116 ['TColStd_IndexedDataMapOfStringString_1'],
117 ];
118 const missing = required
119 .filter((alternatives) => !alternatives.some((name) => oc[name] !== undefined))
120 .map((alternatives) => alternatives.join(' or '));
121 if (missing.length > 0) {
122 throw new Error(`OpenCascade.js build is missing required STEP→GLB bindings: ${missing.join(', ')}`);
123 }
124}
125
126function readStepIntoDocument(oc, path) {
127 const reader = constructFirst(oc, ['STEPCAFControl_Reader_1', 'STEPCAFControl_Reader']);
128 const readStatus = reader.ReadFile(path);
129 if (readStatus !== undefined && isFailureStatus(oc, readStatus)) {
130 throw new Error(`STEP read failed with status ${readStatus}`);
131 }
132
133 const doc = constructDocument(oc);
134 const handle = construct(oc, 'Handle_TDocStd_Document_2', doc);
135 const progress = optionalConstruct(oc, 'Message_ProgressRange_1');
136 const transferred = progress
137 ? callFirst(reader, ['Transfer_1', 'Transfer'], [handle, progress])
138 : callFirst(reader, ['Transfer_1', 'Transfer'], [handle]);
139 if (transferred === false) {
140 throw new Error('STEP transfer into XDE document failed');
141 }
142 return doc;
143}
144
145function constructDocument(oc) {
146 const name = construct(oc, 'TCollection_ExtendedString_1');
147 return construct(oc, 'TDocStd_Document', name);
148}
149
150function firstFreeShape(oc, doc) {
151 if (!oc.XCAFDoc_DocumentTool?.ShapeTool) {
152 return undefined;
153 }
154 const shapeToolHandle = oc.XCAFDoc_DocumentTool.ShapeTool(doc.Main());
155 const shapeTool = shapeToolHandle?.get ? shapeToolHandle.get() : shapeToolHandle;
156 const labels = construct(oc, 'TDF_LabelSequence_1');
157 shapeTool.GetFreeShapes(labels);
158 if (labels.Length && labels.Length() < 1) {
159 return undefined;
160 }
161 const label = labels.First();
162 return callFirst(oc.XCAFDoc_ShapeTool, ['GetShape_2', 'GetShape'], [label]);
163}
164
165function exportDocumentToGlb(oc, doc, outputPath) {
166 if (!oc.RWGltf_CafWriter) {
167 throw new Error('RWGltf_CafWriter is missing from this OpenCascade.js build');
168 }
169 if (oc.FS.analyzePath(outputPath).exists) {
170 oc.FS.unlink(outputPath);
171 }
172 const writer = new oc.RWGltf_CafWriter(construct(oc, 'TCollection_AsciiString_2', outputPath), true);
173 const handle = construct(oc, 'Handle_TDocStd_Document_2', doc);
174 const metadata = construct(oc, 'TColStd_IndexedDataMapOfStringString_1');
175 const progress = optionalConstruct(oc, 'Message_ProgressRange_1');
176 const exported = progress
177 ? callFirst(writer, ['Perform_2', 'Perform'], [handle, metadata, progress])
178 : callFirst(writer, ['Perform_2', 'Perform'], [handle, metadata]);
179 if (exported === false) {
180 throw new Error('RWGltf_CafWriter failed to export GLB');
181 }
182 return oc.FS.readFile(outputPath, { encoding: 'binary' });
183}
184
185function construct(oc, name, ...args) {
186 const ctor = oc[name];
187 if (typeof ctor !== 'function') {
188 throw new Error(`${name} is missing from this OpenCascade.js build`);
189 }
190 return new ctor(...args);
191}
192
193function optionalConstruct(oc, name, ...args) {
194 return typeof oc[name] === 'function' ? new oc[name](...args) : undefined;
195}
196
197function constructFirst(oc, names, ...args) {
198 for (const name of names) {
199 if (typeof oc[name] === 'function') {
200 return new oc[name](...args);
201 }
202 }
203 throw new Error(`${names.join(' or ')} is missing from this OpenCascade.js build`);
204}
205
206function callFirst(target, names, args) {
207 for (const name of names) {
208 if (typeof target?.[name] === 'function') {
209 return target[name](...args);
210 }
211 }
212 throw new Error(`missing method ${names.join(' or ')}`);
213}
214
215function isFailureStatus(oc, status) {
216 if (typeof oc.IFSelect_ReturnStatus?.IFSelect_RetDone === 'number') {
217 return status !== oc.IFSelect_ReturnStatus.IFSelect_RetDone;
218 }
219 return false;
220}
221
222function mark(timings, label) {
223 timings.push({ label, ms: performance.now() });
224}