cyclecad 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,510 @@
1
+ /**
2
+ * STEP Module - Import/Export AP203/AP214 STEP files
3
+ * Microkernel LEGO block for cycleCAD
4
+ *
5
+ * Dual-path architecture:
6
+ * 1. Browser WASM (occt-import-js) for files < 50MB
7
+ * 2. Server-side converter (Python/CadQuery) for files >= 50MB
8
+ * 3. B-Rep kernel (if available) for native STEP I/O
9
+ */
10
+
11
+ const StepModule = {
12
+ id: 'step-io',
13
+ name: 'STEP Import/Export',
14
+ version: '1.0.0',
15
+ category: 'data',
16
+ dependencies: ['viewport'],
17
+ memoryEstimate: 60, // OpenCascade.js WASM ~50MB
18
+
19
+ // ========== MODULE STATE ==========
20
+ state: {
21
+ importInProgress: false,
22
+ workerReady: false,
23
+ serverURL: 'http://localhost:8787/convert',
24
+ useBrepKernel: false,
25
+ lastImportInfo: null,
26
+ },
27
+
28
+ kernel: null,
29
+ worker: null,
30
+ workerHeartbeat: null,
31
+
32
+ // ========== INITIALIZATION ==========
33
+ async init(kernel) {
34
+ this.kernel = kernel;
35
+ this.state.serverURL = localStorage.getItem('ev_converter_url') || 'http://localhost:8787/convert';
36
+
37
+ // Check if brep-kernel is available
38
+ const brepModule = kernel.modules?.find(m => m.id === 'brep-kernel');
39
+ this.state.useBrepKernel = !!brepModule;
40
+
41
+ // Initialize Web Worker for WASM parsing
42
+ this.initWorker();
43
+
44
+ // Register commands with kernel
45
+ kernel.registerCommand('step.import', (file) => this.import(file));
46
+ kernel.registerCommand('step.export', (filename) => this.export(filename));
47
+ kernel.registerCommand('step.importFromURL', (url) => this.importFromURL(url));
48
+ kernel.registerCommand('step.getMetadata', (file) => this.getMetadata(file));
49
+ kernel.registerCommand('step.setServerURL', (url) => this.setServerURL(url));
50
+
51
+ console.log('[StepModule] Initialized', {
52
+ serverURL: this.state.serverURL,
53
+ useBrepKernel: this.state.useBrepKernel,
54
+ });
55
+ },
56
+
57
+ // ========== WORKER SETUP ==========
58
+ initWorker() {
59
+ const workerCode = `
60
+ let occtImport = null;
61
+ let heartbeat = null;
62
+
63
+ // Load occt-import-js via importScripts
64
+ importScripts('https://cdn.jsdelivr.net/npm/occt-import-js@0.0.23/dist/occt-import-js.umd.js');
65
+
66
+ // Initialize WASM
67
+ (async () => {
68
+ try {
69
+ occtImport = await window.occtImportJs({
70
+ locateFile: (p) => 'https://cdn.jsdelivr.net/npm/occt-import-js@0.0.23/dist/' + p
71
+ });
72
+ postMessage({ type: 'ready' });
73
+ } catch (e) {
74
+ postMessage({ type: 'error', error: 'Failed to load WASM: ' + e.message });
75
+ }
76
+ })();
77
+
78
+ self.onmessage = async (e) => {
79
+ const { type, data } = e.data;
80
+
81
+ if (type === 'parse') {
82
+ try {
83
+ // Clear old heartbeat
84
+ if (heartbeat) clearInterval(heartbeat);
85
+
86
+ // Send heartbeat every 5s
87
+ heartbeat = setInterval(() => {
88
+ postMessage({ type: 'heartbeat' });
89
+ }, 5000);
90
+
91
+ const startTime = performance.now();
92
+ const result = occtImport.ReadStepFile(data.buffer, data.deflection || 0.01);
93
+ const parseTime = performance.now() - startTime;
94
+
95
+ if (!result || !result.meshes || result.meshes.length === 0) {
96
+ postMessage({
97
+ type: 'error',
98
+ error: 'No meshes extracted from STEP file'
99
+ });
100
+ return;
101
+ }
102
+
103
+ // Copy mesh data from WASM heap (avoid view invalidation)
104
+ const meshes = [];
105
+ for (let i = 0; i < result.meshes.length; i++) {
106
+ const m = result.meshes[i];
107
+ if (!m.attributes || !m.attributes.position) continue;
108
+
109
+ const posArray = m.attributes.position.array;
110
+ const normArray = m.attributes.normal?.array || null;
111
+ const colorArray = m.attributes.color?.array || null;
112
+ const indexArray = m.index?.array || null;
113
+
114
+ // Tight copy loop to prevent WASM heap reallocation issues
115
+ meshes.push({
116
+ name: m.name || 'Part_' + i,
117
+ position: new Float32Array(posArray.slice ? posArray.slice(0) : Array.from(posArray)),
118
+ normal: normArray ? new Float32Array(normArray.slice ? normArray.slice(0) : Array.from(normArray)) : null,
119
+ color: colorArray ? new Uint8Array(colorArray.slice ? colorArray.slice(0) : Array.from(colorArray)) : null,
120
+ index: indexArray ? new Uint32Array(indexArray.slice ? indexArray.slice(0) : Array.from(indexArray)) : null,
121
+ });
122
+ }
123
+
124
+ clearInterval(heartbeat);
125
+ postMessage({
126
+ type: 'complete',
127
+ data: {
128
+ meshes,
129
+ parseTime,
130
+ partCount: result.meshes.length,
131
+ }
132
+ }, meshes.map(m => m.position.buffer).filter(b => b));
133
+
134
+ } catch (e) {
135
+ clearInterval(heartbeat);
136
+ postMessage({
137
+ type: 'error',
138
+ error: 'WASM parse failed: ' + e.message
139
+ });
140
+ }
141
+ }
142
+ };
143
+ `;
144
+
145
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
146
+ const workerURL = URL.createObjectURL(blob);
147
+ this.worker = new Worker(workerURL);
148
+
149
+ this.worker.onmessage = (e) => {
150
+ const { type, data, error } = e.data;
151
+ if (type === 'ready') {
152
+ this.state.workerReady = true;
153
+ console.log('[StepModule] Worker ready');
154
+ } else if (type === 'heartbeat') {
155
+ // Worker is alive, reset timeout
156
+ if (this.workerHeartbeat) clearTimeout(this.workerHeartbeat);
157
+ }
158
+ };
159
+ },
160
+
161
+ // ========== IMPORT ==========
162
+ async import(file) {
163
+ if (!(file instanceof File) && !(file instanceof Blob)) {
164
+ this.emit('step:importError', { error: 'Invalid file type', suggestion: 'Pass a File or Blob object' });
165
+ return;
166
+ }
167
+
168
+ const filename = file.name || 'model.step';
169
+ const fileSize = file.size;
170
+ this.state.importInProgress = true;
171
+
172
+ // File size check
173
+ if (fileSize > 100 * 1024 * 1024) { // 100MB
174
+ const confirm = window.confirm(
175
+ `This STEP file is ${(fileSize / 1024 / 1024).toFixed(1)}MB. Large files may freeze the browser.\n\n` +
176
+ `Recommended: Use server-side converter or split the assembly.\n\n` +
177
+ `Continue anyway?`
178
+ );
179
+ if (!confirm) {
180
+ this.state.importInProgress = false;
181
+ return;
182
+ }
183
+ }
184
+
185
+ this.emit('step:importStart', { filename, size: fileSize });
186
+
187
+ try {
188
+ let meshes;
189
+
190
+ // Route: >= 50MB or WASM unavailable → server
191
+ if (fileSize >= 50 * 1024 * 1024 || !this.state.workerReady) {
192
+ console.log('[StepModule] Using server-side converter');
193
+ meshes = await this.importViaServer(file);
194
+ } else {
195
+ // Route: < 50MB → WASM Worker
196
+ console.log('[StepModule] Using browser WASM');
197
+ meshes = await this.importViaWASM(file);
198
+ }
199
+
200
+ // Create Three.js objects and add to scene
201
+ const partCount = await this.createMeshesInScene(meshes, filename);
202
+
203
+ this.state.lastImportInfo = { partCount, filename, timestamp: Date.now() };
204
+ this.state.importInProgress = false;
205
+
206
+ this.emit('step:importComplete', {
207
+ partCount,
208
+ duration: Date.now() - this.state.lastImportInfo.timestamp,
209
+ });
210
+
211
+ } catch (e) {
212
+ this.state.importInProgress = false;
213
+ console.error('[StepModule] Import failed:', e);
214
+
215
+ let suggestion = 'Check file format and try again.';
216
+ if (e.message.includes('WASM') || e.message.includes('memory')) {
217
+ suggestion = 'File too large for browser. Try server converter or split assembly.';
218
+ }
219
+
220
+ this.emit('step:importError', { error: e.message, suggestion });
221
+ }
222
+ },
223
+
224
+ async importViaWASM(file) {
225
+ return new Promise((resolve, reject) => {
226
+ const reader = new FileReader();
227
+
228
+ reader.onload = async (e) => {
229
+ const buffer = e.target.result;
230
+ const deflection = file.size > 50 * 1024 * 1024 ? 0.05 : 0.01; // Coarser for large files
231
+
232
+ // Set 90s timeout
233
+ const timeout = setTimeout(() => {
234
+ this.worker.terminate();
235
+ this.initWorker(); // Restart worker
236
+ reject(new Error('WASM parsing timeout (90s). File too complex for browser.'));
237
+ }, 90000);
238
+
239
+ this.worker.onmessage = (e) => {
240
+ const { type, data, error } = e.data;
241
+
242
+ if (type === 'complete') {
243
+ clearTimeout(timeout);
244
+ resolve(data.meshes);
245
+ } else if (type === 'error') {
246
+ clearTimeout(timeout);
247
+ reject(new Error(error));
248
+ }
249
+ };
250
+
251
+ this.worker.postMessage({ type: 'parse', data: { buffer, deflection } });
252
+ };
253
+
254
+ reader.onerror = () => reject(new Error('File read failed'));
255
+ reader.readAsArrayBuffer(file);
256
+ });
257
+ },
258
+
259
+ async importViaServer(file) {
260
+ const formData = new FormData();
261
+ formData.append('file', file);
262
+
263
+ try {
264
+ const response = await fetch(this.state.serverURL, {
265
+ method: 'POST',
266
+ body: formData,
267
+ });
268
+
269
+ if (!response.ok) {
270
+ throw new Error(`Server error: ${response.status}`);
271
+ }
272
+
273
+ const glbBuffer = await response.arrayBuffer();
274
+
275
+ // Load GLB with Three.js GLTFLoader
276
+ const loader = new THREE.GLTFLoader();
277
+ return new Promise((resolve, reject) => {
278
+ loader.parse(glbBuffer, '', (gltf) => {
279
+ // Extract meshes from GLTF
280
+ const meshes = [];
281
+ gltf.scene.traverse((node) => {
282
+ if (node.isMesh && node.geometry) {
283
+ const pos = node.geometry.attributes.position;
284
+ const norm = node.geometry.attributes.normal;
285
+ const indices = node.geometry.index;
286
+
287
+ meshes.push({
288
+ name: node.name || 'Part',
289
+ position: pos.array,
290
+ normal: norm ? norm.array : null,
291
+ color: node.geometry.attributes.color?.array || null,
292
+ index: indices ? indices.array : null,
293
+ });
294
+ }
295
+ });
296
+ resolve(meshes);
297
+ }, reject);
298
+ });
299
+ } catch (e) {
300
+ throw new Error(`Server import failed: ${e.message}`);
301
+ }
302
+ },
303
+
304
+ async createMeshesInScene(meshes, filename) {
305
+ let partCount = 0;
306
+
307
+ for (const meshData of meshes) {
308
+ const geometry = new THREE.BufferGeometry();
309
+
310
+ geometry.setAttribute('position', new THREE.BufferAttribute(meshData.position, 3));
311
+ if (meshData.normal) {
312
+ geometry.setAttribute('normal', new THREE.BufferAttribute(meshData.normal, 3));
313
+ } else {
314
+ geometry.computeVertexNormals();
315
+ }
316
+
317
+ if (meshData.index) {
318
+ geometry.setIndex(new THREE.BufferAttribute(meshData.index, 1));
319
+ }
320
+
321
+ const material = new THREE.MeshStandardMaterial({
322
+ color: 0xcccccc,
323
+ metalness: 0.3,
324
+ roughness: 0.7,
325
+ });
326
+
327
+ const mesh = new THREE.Mesh(geometry, material);
328
+ mesh.name = meshData.name;
329
+ mesh.userData = {
330
+ partIndex: partCount,
331
+ source: 'step-import',
332
+ filename,
333
+ };
334
+
335
+ // Add to scene via kernel
336
+ this.kernel.exec('viewport.addMesh', { mesh, name: meshData.name });
337
+
338
+ partCount++;
339
+ }
340
+
341
+ return partCount;
342
+ },
343
+
344
+ // ========== IMPORT FROM URL ==========
345
+ async importFromURL(url) {
346
+ try {
347
+ this.emit('step:importStart', { filename: url.split('/').pop(), size: 0 });
348
+ const response = await fetch(url);
349
+ const blob = await response.blob();
350
+ const file = new File([blob], url.split('/').pop(), { type: 'application/octet-stream' });
351
+ return this.import(file);
352
+ } catch (e) {
353
+ this.emit('step:importError', { error: e.message, suggestion: 'Check URL and try again.' });
354
+ }
355
+ },
356
+
357
+ // ========== EXPORT ==========
358
+ async export(filename = 'model.step') {
359
+ if (!this.state.useBrepKernel) {
360
+ this.emit('step:exportError', { error: 'B-Rep kernel not available', suggestion: 'Load brep-kernel module first.' });
361
+ return;
362
+ }
363
+
364
+ try {
365
+ // Get all shapes from scene via kernel
366
+ const shapes = this.kernel.exec('viewport.getAllShapes');
367
+ if (!shapes || shapes.length === 0) {
368
+ throw new Error('No shapes in scene to export');
369
+ }
370
+
371
+ // Use brep-kernel to export
372
+ const brepModule = this.kernel.modules.find(m => m.id === 'brep-kernel');
373
+ const stepBuffer = brepModule.exec('exportSTEP', { shapes });
374
+
375
+ // Download
376
+ const blob = new Blob([stepBuffer], { type: 'application/octet-stream' });
377
+ const url = URL.createObjectURL(blob);
378
+ const a = document.createElement('a');
379
+ a.href = url;
380
+ a.download = filename;
381
+ document.body.appendChild(a);
382
+ a.click();
383
+ document.body.removeChild(a);
384
+ URL.revokeObjectURL(url);
385
+
386
+ this.emit('step:exportComplete', { filename, size: stepBuffer.byteLength });
387
+ } catch (e) {
388
+ console.error('[StepModule] Export failed:', e);
389
+ this.emit('step:exportError', { error: e.message });
390
+ }
391
+ },
392
+
393
+ // ========== METADATA ==========
394
+ async getMetadata(file) {
395
+ // Quick parse to get part names without full geometry
396
+ // Uses WASM but only reads metadata
397
+ return new Promise((resolve) => {
398
+ const reader = new FileReader();
399
+ reader.onload = () => {
400
+ // Simplified: count PART entities in ASCII STEP
401
+ const text = new TextDecoder().decode(reader.result.slice(0, 100000));
402
+ const partCount = (text.match(/^PART\(/gm) || []).length;
403
+ resolve({ partCount, filename: file.name });
404
+ };
405
+ reader.readAsArrayBuffer(file);
406
+ });
407
+ },
408
+
409
+ // ========== CONFIGURATION ==========
410
+ setServerURL(url) {
411
+ this.state.serverURL = url;
412
+ localStorage.setItem('ev_converter_url', url);
413
+ console.log('[StepModule] Server URL updated:', url);
414
+ },
415
+
416
+ // ========== UI ==========
417
+ getUI() {
418
+ const container = document.createElement('div');
419
+ container.id = 'step-panel';
420
+ container.style.cssText = `
421
+ position: relative;
422
+ padding: 16px;
423
+ background: #1e1e1e;
424
+ border-radius: 8px;
425
+ color: #e0e0e0;
426
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
427
+ `;
428
+
429
+ container.innerHTML = `
430
+ <div style="margin-bottom: 12px;">
431
+ <h3 style="margin: 0 0 8px 0; font-size: 14px; color: #fff;">STEP Import/Export</h3>
432
+ <p style="margin: 0; font-size: 12px; color: #999;">AP203/AP214 STEP files</p>
433
+ </div>
434
+
435
+ <div style="display: flex; gap: 8px; margin-bottom: 12px;">
436
+ <button id="step-import-btn" style="flex: 1; padding: 8px; background: #0284c7; border: none; border-radius: 4px; color: #fff; cursor: pointer; font-size: 12px;">
437
+ Import STEP
438
+ </button>
439
+ <button id="step-export-btn" style="flex: 1; padding: 8px; background: #6b7280; border: none; border-radius: 4px; color: #fff; cursor: pointer; font-size: 12px;" ${!this.state.useBrepKernel ? 'disabled' : ''}>
440
+ Export STEP
441
+ </button>
442
+ </div>
443
+
444
+ <div id="step-progress" style="display: none; margin-bottom: 12px;">
445
+ <div style="height: 4px; background: #333; border-radius: 2px; overflow: hidden;">
446
+ <div id="step-progress-bar" style="height: 100%; background: #0284c7; width: 0%; transition: width 0.2s;"></div>
447
+ </div>
448
+ <div style="font-size: 11px; color: #999; margin-top: 4px;" id="step-progress-text">Importing...</div>
449
+ </div>
450
+
451
+ <div style="margin-bottom: 12px;">
452
+ <label style="display: block; font-size: 11px; color: #999; margin-bottom: 4px;">Server URL</label>
453
+ <input id="step-server-url" type="text" value="${this.state.serverURL}" style="width: 100%; padding: 4px; background: #333; border: 1px solid #444; border-radius: 4px; color: #e0e0e0; font-size: 11px; box-sizing: border-box;">
454
+ </div>
455
+
456
+ <div style="font-size: 11px; color: #666; line-height: 1.4;">
457
+ <strong>Import:</strong> Files <50MB use browser WASM, ≥50MB use server<br>
458
+ <strong>Export:</strong> Requires B-Rep kernel module
459
+ </div>
460
+ `;
461
+
462
+ container.addEventListener('click', (e) => {
463
+ if (e.target.id === 'step-import-btn') {
464
+ const input = document.createElement('input');
465
+ input.type = 'file';
466
+ input.accept = '.step,.stp';
467
+ input.onchange = (e) => {
468
+ if (e.target.files[0]) {
469
+ this.import(e.target.files[0]);
470
+ }
471
+ };
472
+ input.click();
473
+ } else if (e.target.id === 'step-export-btn') {
474
+ this.export('model.step');
475
+ }
476
+ });
477
+
478
+ container.addEventListener('change', (e) => {
479
+ if (e.target.id === 'step-server-url') {
480
+ this.setServerURL(e.target.value);
481
+ }
482
+ });
483
+
484
+ // Listen to events
485
+ this.kernel.on('step:importProgress', (data) => {
486
+ const progress = container.querySelector('#step-progress');
487
+ progress.style.display = 'block';
488
+ container.querySelector('#step-progress-bar').style.width = (data.percent || 0) + '%';
489
+ container.querySelector('#step-progress-text').textContent = data.message || 'Importing...';
490
+ });
491
+
492
+ this.kernel.on('step:importComplete', () => {
493
+ setTimeout(() => {
494
+ container.querySelector('#step-progress').style.display = 'none';
495
+ }, 500);
496
+ });
497
+
498
+ return container;
499
+ },
500
+
501
+ // ========== EVENT EMISSION ==========
502
+ emit(eventName, data) {
503
+ if (this.kernel && this.kernel.emit) {
504
+ this.kernel.emit(eventName, data);
505
+ }
506
+ console.log(`[StepModule] ${eventName}`, data);
507
+ },
508
+ };
509
+
510
+ export default StepModule;