cyclecad 1.1.2 → 1.3.0

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,584 @@
1
+ /**
2
+ * STEP Viewer Module for cycleCAD
3
+ * Drag-and-drop STEP/STP file import with interactive 3D viewer
4
+ *
5
+ * Features:
6
+ * - Drag-drop and file picker import
7
+ * - occt-import-js for <50MB, server converter for larger files
8
+ * - Instant Three.js rendering with part coloring
9
+ * - Part selection, visibility toggle, BOM export
10
+ * - Exploded view slider
11
+ * - Share & embed code generation
12
+ */
13
+
14
+ const PALETTE = [0x4488CC, 0xCC4444, 0x44AA44, 0xCCAA44, 0x8844CC, 0x44CCAA,
15
+ 0xCC6644, 0x4466CC, 0xAA44CC, 0x44CC66, 0xCC4488, 0x88CC44];
16
+
17
+ const OCCT_WASM_URL = 'https://cdn.jsdelivr.net/npm/occt-import-js@0.0.23/dist/occt-import-js.wasm';
18
+ const OCCT_CDN_BASE = 'https://cdn.jsdelivr.net/npm/occt-import-js@0.0.23/dist/';
19
+ const SIZE_THRESHOLD = 50 * 1024 * 1024; // 50MB threshold for browser vs server parsing
20
+
21
+ let stepViewerState = {
22
+ scene: null,
23
+ camera: null,
24
+ renderer: null,
25
+ allParts: [],
26
+ meshMap: new Map(), // mesh → part data
27
+ selectedMesh: null,
28
+ explodeAmount: 0,
29
+ originalPositions: new Map(),
30
+ centerOfMass: new THREE.Vector3(),
31
+ container: null,
32
+ partListPanel: null,
33
+ dropOverlay: null,
34
+ converterUrl: localStorage.getItem('ev_converter_url') || 'http://localhost:8787'
35
+ };
36
+
37
+ export async function initStepViewer(container) {
38
+ stepViewerState.container = container;
39
+
40
+ // Setup drop overlay
41
+ setupDropOverlay(container);
42
+
43
+ // Setup file input
44
+ setupFileInput();
45
+
46
+ // Setup part list panel
47
+ setupPartListPanel();
48
+
49
+ // Setup Three.js scene (reuse global or create new)
50
+ if (window._scene && window._camera && window._renderer) {
51
+ stepViewerState.scene = window._scene;
52
+ stepViewerState.camera = window._camera;
53
+ stepViewerState.renderer = window._renderer;
54
+ } else {
55
+ setupScene(container);
56
+ }
57
+
58
+ // Setup drag-drop
59
+ setupDragDrop(container);
60
+
61
+ // Setup part selection click handler
62
+ setupPartSelection();
63
+ }
64
+
65
+ function setupDropOverlay(container) {
66
+ const overlay = document.createElement('div');
67
+ overlay.id = 'step-drop-overlay';
68
+ overlay.style.cssText = `
69
+ position: fixed;
70
+ top: 0;
71
+ left: 0;
72
+ width: 100%;
73
+ height: 100%;
74
+ background: rgba(0, 0, 0, 0.7);
75
+ border: 3px dashed #0284C7;
76
+ display: none;
77
+ flex-direction: column;
78
+ justify-content: center;
79
+ align-items: center;
80
+ z-index: 9999;
81
+ pointer-events: none;
82
+ font-family: Calibri, sans-serif;
83
+ `;
84
+
85
+ overlay.innerHTML = `
86
+ <div style="text-align: center; pointer-events: auto;">
87
+ <div style="font-size: 48px; margin-bottom: 20px;">📦</div>
88
+ <h2 style="color: #0284C7; margin: 0 0 10px 0; font-size: 24px;">Drop STEP file here</h2>
89
+ <p style="color: #aaa; margin: 0; font-size: 14px;">.step, .stp up to 500MB</p>
90
+ </div>
91
+ `;
92
+
93
+ document.body.appendChild(overlay);
94
+ stepViewerState.dropOverlay = overlay;
95
+ }
96
+
97
+ function setupFileInput() {
98
+ let input = document.getElementById('step-file-input');
99
+ if (!input) {
100
+ input = document.createElement('input');
101
+ input.id = 'step-file-input';
102
+ input.type = 'file';
103
+ input.accept = '.step,.stp,.STEP,.STP';
104
+ input.style.display = 'none';
105
+ document.body.appendChild(input);
106
+
107
+ input.addEventListener('change', async (e) => {
108
+ if (e.target.files[0]) {
109
+ await importStepFile(e.target.files[0]);
110
+ }
111
+ });
112
+ }
113
+ }
114
+
115
+ function setupDragDrop(container) {
116
+ const overlay = stepViewerState.dropOverlay;
117
+
118
+ container.addEventListener('dragover', (e) => {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ if (e.dataTransfer.types.includes('Files')) {
122
+ overlay.style.display = 'flex';
123
+ overlay.style.pointerEvents = 'auto';
124
+ }
125
+ });
126
+
127
+ container.addEventListener('dragleave', (e) => {
128
+ if (e.target === container) {
129
+ overlay.style.display = 'none';
130
+ overlay.style.pointerEvents = 'none';
131
+ }
132
+ });
133
+
134
+ container.addEventListener('drop', async (e) => {
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ overlay.style.display = 'none';
138
+ overlay.style.pointerEvents = 'none';
139
+
140
+ const files = e.dataTransfer.files;
141
+ for (let file of files) {
142
+ if (file.name.match(/\.(step|stp)$/i)) {
143
+ await importStepFile(file);
144
+ break; // Only load first STEP file
145
+ }
146
+ }
147
+ });
148
+ }
149
+
150
+ function setupPartListPanel() {
151
+ let panel = document.getElementById('step-part-list-panel');
152
+ if (panel) {
153
+ panel.remove();
154
+ }
155
+
156
+ panel = document.createElement('div');
157
+ panel.id = 'step-part-list-panel';
158
+ panel.style.cssText = `
159
+ position: fixed;
160
+ left: 0;
161
+ top: 0;
162
+ width: 320px;
163
+ height: 100vh;
164
+ background: #1e1e1e;
165
+ border-right: 1px solid #333;
166
+ color: #ddd;
167
+ font-family: Calibri, sans-serif;
168
+ font-size: 13px;
169
+ z-index: 100;
170
+ display: none;
171
+ flex-direction: column;
172
+ overflow: hidden;
173
+ box-shadow: 2px 0 8px rgba(0,0,0,0.5);
174
+ `;
175
+
176
+ panel.innerHTML = `
177
+ <div style="padding: 12px; border-bottom: 1px solid #333; font-weight: bold; display: flex; justify-content: space-between; align-items: center;">
178
+ <span>Parts</span>
179
+ <button id="step-part-list-close" style="background: none; border: none; color: #aaa; cursor: pointer; font-size: 18px; padding: 0; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">×</button>
180
+ </div>
181
+ <div id="step-part-list-content" style="overflow-y: auto; flex: 1; padding: 8px;">
182
+ <div style="color: #666; padding: 12px; text-align: center;">No model loaded</div>
183
+ </div>
184
+ <div style="border-top: 1px solid #333; padding: 8px; display: flex; gap: 8px; flex-direction: column;">
185
+ <button id="step-export-bom" style="padding: 8px; background: #0284C7; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: bold;">📊 Export BOM</button>
186
+ <button id="step-share-code" style="padding: 8px; background: #666; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: bold;">🔗 Share / Embed</button>
187
+ </div>
188
+ `;
189
+
190
+ document.body.appendChild(panel);
191
+ stepViewerState.partListPanel = panel;
192
+
193
+ document.getElementById('step-part-list-close').addEventListener('click', () => {
194
+ panel.style.display = 'none';
195
+ });
196
+
197
+ document.getElementById('step-export-bom').addEventListener('click', exportBOM);
198
+ document.getElementById('step-share-code').addEventListener('click', () => {
199
+ const code = getEmbedCode();
200
+ const dialog = prompt('Embed code (copy to share on web):\n\n', code);
201
+ });
202
+ }
203
+
204
+ function setupScene(container) {
205
+ const width = container.clientWidth;
206
+ const height = container.clientHeight;
207
+
208
+ stepViewerState.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 100000);
209
+ stepViewerState.camera.position.set(500, 400, 500);
210
+
211
+ stepViewerState.scene = new THREE.Scene();
212
+ stepViewerState.scene.background = new THREE.Color(0x222222);
213
+
214
+ // Lights
215
+ const ambLight = new THREE.AmbientLight(0xffffff, 0.6);
216
+ stepViewerState.scene.add(ambLight);
217
+
218
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
219
+ dirLight.position.set(300, 400, 300);
220
+ dirLight.castShadow = true;
221
+ dirLight.shadow.camera.far = 2000;
222
+ stepViewerState.scene.add(dirLight);
223
+
224
+ stepViewerState.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
225
+ stepViewerState.renderer.setSize(width, height);
226
+ stepViewerState.renderer.shadowMap.enabled = true;
227
+ container.appendChild(stepViewerState.renderer.domElement);
228
+
229
+ // Grid
230
+ const gridHelper = new THREE.GridHelper(1000, 20, 0x444444, 0x222222);
231
+ stepViewerState.scene.add(gridHelper);
232
+
233
+ // Animate loop
234
+ function animate() {
235
+ requestAnimationFrame(animate);
236
+ stepViewerState.renderer.render(stepViewerState.scene, stepViewerState.camera);
237
+ }
238
+ animate();
239
+
240
+ // Handle resize
241
+ window.addEventListener('resize', () => {
242
+ const w = container.clientWidth;
243
+ const h = container.clientHeight;
244
+ stepViewerState.camera.aspect = w / h;
245
+ stepViewerState.camera.updateProjectionMatrix();
246
+ stepViewerState.renderer.setSize(w, h);
247
+ });
248
+ }
249
+
250
+ function setupPartSelection() {
251
+ const canvas = stepViewerState.renderer.domElement;
252
+ const raycaster = new THREE.Raycaster();
253
+ const mouse = new THREE.Vector2();
254
+
255
+ canvas.addEventListener('click', (e) => {
256
+ const rect = canvas.getBoundingClientRect();
257
+ mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
258
+ mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
259
+
260
+ raycaster.setFromCamera(mouse, stepViewerState.camera);
261
+ const intersects = raycaster.intersectObjects(stepViewerState.scene.children, true);
262
+
263
+ // Deselect previous
264
+ if (stepViewerState.selectedMesh) {
265
+ const prevPart = stepViewerState.meshMap.get(stepViewerState.selectedMesh);
266
+ if (prevPart) {
267
+ stepViewerState.selectedMesh.material.emissive.setHex(0x000000);
268
+ updatePartListHighlight(null);
269
+ }
270
+ }
271
+
272
+ // Select new
273
+ for (let hit of intersects) {
274
+ const part = stepViewerState.meshMap.get(hit.object);
275
+ if (part) {
276
+ stepViewerState.selectedMesh = hit.object;
277
+ hit.object.material.emissive.setHex(0x444444);
278
+ updatePartListHighlight(part.index);
279
+ console.log(`[STEP] Selected: ${part.name}`);
280
+ break;
281
+ }
282
+ }
283
+ });
284
+ }
285
+
286
+ export async function importStepFile(file) {
287
+ console.log(`[STEP] Importing ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)...`);
288
+
289
+ const statusDiv = showImportStatus('Parsing STEP file...');
290
+
291
+ try {
292
+ let meshes = [];
293
+
294
+ // Choose parsing method
295
+ if (file.size < SIZE_THRESHOLD) {
296
+ console.log('[STEP] Using browser WASM (occt-import-js)');
297
+ meshes = await parseViaOCCT(file);
298
+ } else {
299
+ console.log('[STEP] Using server converter (file >50MB)');
300
+ meshes = await parseViaServer(file);
301
+ }
302
+
303
+ statusDiv.textContent = `Building 3D scene (${meshes.length} parts)...`;
304
+ buildScene(meshes, file.name);
305
+
306
+ statusDiv.textContent = `✓ Loaded ${meshes.length} parts from ${file.name}`;
307
+ setTimeout(() => statusDiv.remove(), 3000);
308
+
309
+ // Show part list
310
+ stepViewerState.partListPanel.style.display = 'flex';
311
+
312
+ } catch (err) {
313
+ console.error('[STEP] Import failed:', err);
314
+ statusDiv.textContent = `✗ Error: ${err.message}`;
315
+ statusDiv.style.color = '#ff6666';
316
+ setTimeout(() => statusDiv.remove(), 5000);
317
+ }
318
+ }
319
+
320
+ async function parseViaOCCT(file) {
321
+ const occtImportJs = await import(OCCT_CDN_BASE + 'occt-import-js.js');
322
+ const occt = await occtImportJs.default({
323
+ locateFile: (filename) => {
324
+ if (filename.endsWith('.wasm')) return OCCT_WASM_URL;
325
+ return OCCT_CDN_BASE + filename;
326
+ }
327
+ });
328
+
329
+ const buffer = new Uint8Array(await file.arrayBuffer());
330
+ const result = occt.ReadStepFile(buffer, null);
331
+
332
+ if (!result || !result.meshes || result.meshes.length === 0) {
333
+ throw new Error('No meshes extracted from STEP file');
334
+ }
335
+
336
+ return result.meshes.map((mesh, i) => ({
337
+ name: mesh.name || `Part_${i}`,
338
+ index: i,
339
+ position: mesh.attributes.position.array,
340
+ normal: mesh.attributes.normal?.array,
341
+ index: mesh.attributes.index?.array,
342
+ color: mesh.color || PALETTE[i % PALETTE.length]
343
+ }));
344
+ }
345
+
346
+ async function parseViaServer(file) {
347
+ const formData = new FormData();
348
+ formData.append('file', file);
349
+
350
+ const response = await fetch(`${stepViewerState.converterUrl}/convert/metadata`, {
351
+ method: 'POST',
352
+ body: formData
353
+ });
354
+
355
+ if (!response.ok) {
356
+ throw new Error(`Server error: ${response.statusText}`);
357
+ }
358
+
359
+ const data = await response.json();
360
+ return data.meshes || [];
361
+ }
362
+
363
+ function buildScene(meshes, filename) {
364
+ // Clear previous
365
+ stepViewerState.allParts = [];
366
+ stepViewerState.meshMap.clear();
367
+ stepViewerState.originalPositions.clear();
368
+
369
+ let group = new THREE.Group();
370
+ let bbox = new THREE.Box3();
371
+ let centerOfMass = new THREE.Vector3();
372
+ let totalVolume = 0;
373
+
374
+ meshes.forEach((meshData, idx) => {
375
+ const geometry = new THREE.BufferGeometry();
376
+
377
+ const positions = new Float32Array(meshData.position);
378
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
379
+
380
+ if (meshData.normal) {
381
+ const normals = new Float32Array(meshData.normal);
382
+ geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
383
+ } else {
384
+ geometry.computeVertexNormals();
385
+ }
386
+
387
+ if (meshData.index) {
388
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(meshData.index), 1));
389
+ }
390
+
391
+ geometry.computeBoundingBox();
392
+ bbox.expandByObject(new THREE.Object3D());
393
+
394
+ const color = new THREE.Color(meshData.color);
395
+ const material = new THREE.MeshPhongMaterial({
396
+ color: color,
397
+ emissive: 0x000000,
398
+ shininess: 100
399
+ });
400
+
401
+ const mesh = new THREE.Mesh(geometry, material);
402
+ mesh.castShadow = true;
403
+ mesh.receiveShadow = true;
404
+
405
+ group.add(mesh);
406
+
407
+ const partData = {
408
+ name: meshData.name,
409
+ index: idx,
410
+ mesh: mesh,
411
+ visible: true,
412
+ originalPosition: mesh.position.clone()
413
+ };
414
+
415
+ stepViewerState.meshMap.set(mesh, partData);
416
+ stepViewerState.allParts.push(partData);
417
+ stepViewerState.originalPositions.set(mesh, mesh.position.clone());
418
+ });
419
+
420
+ stepViewerState.scene.add(group);
421
+
422
+ // Fit camera
423
+ const size = new THREE.Vector3();
424
+ bbox.getSize(size);
425
+ const maxDim = Math.max(size.x, size.y, size.z);
426
+ const fov = stepViewerState.camera.fov * (Math.PI / 180);
427
+ const cameraDistance = maxDim / (2 * Math.tan(fov / 2)) * 1.5;
428
+
429
+ stepViewerState.camera.position.set(cameraDistance, cameraDistance * 0.6, cameraDistance);
430
+ stepViewerState.camera.lookAt(group.position);
431
+
432
+ // Update part list
433
+ updatePartList();
434
+ }
435
+
436
+ function updatePartList() {
437
+ const content = document.getElementById('step-part-list-content');
438
+ if (!content) return;
439
+
440
+ const html = stepViewerState.allParts.map((part, i) => `
441
+ <div class="step-part-item" data-index="${i}" style="
442
+ padding: 8px;
443
+ margin: 4px 0;
444
+ background: #2a2a2a;
445
+ border-radius: 4px;
446
+ cursor: pointer;
447
+ display: flex;
448
+ align-items: center;
449
+ gap: 8px;
450
+ border-left: 3px solid #${part.mesh.material.color.getHexString()};
451
+ user-select: none;
452
+ ">
453
+ <div style="flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px;">
454
+ ${part.name}
455
+ </div>
456
+ <button class="step-toggle-visibility" data-index="${i}" style="
457
+ background: none;
458
+ border: none;
459
+ color: ${part.visible ? '#44aa44' : '#666'};
460
+ cursor: pointer;
461
+ font-size: 14px;
462
+ padding: 0;
463
+ width: 20px;
464
+ height: 20px;
465
+ ">${part.visible ? '👁' : '🚫'}</button>
466
+ </div>
467
+ `).join('');
468
+
469
+ content.innerHTML = html || '<div style="color: #666; text-align: center; padding: 12px;">No parts</div>';
470
+
471
+ // Wire up event handlers
472
+ content.querySelectorAll('.step-part-item').forEach(el => {
473
+ el.addEventListener('click', (e) => {
474
+ if (!e.target.classList.contains('step-toggle-visibility')) {
475
+ const idx = parseInt(el.dataset.index);
476
+ const part = stepViewerState.allParts[idx];
477
+ part.mesh.material.emissive.setHex(0x444444);
478
+ stepViewerState.selectedMesh = part.mesh;
479
+ updatePartListHighlight(idx);
480
+ }
481
+ });
482
+ });
483
+
484
+ content.querySelectorAll('.step-toggle-visibility').forEach(btn => {
485
+ btn.addEventListener('click', (e) => {
486
+ e.stopPropagation();
487
+ const idx = parseInt(btn.dataset.index);
488
+ const part = stepViewerState.allParts[idx];
489
+ part.visible = !part.visible;
490
+ part.mesh.visible = part.visible;
491
+ btn.textContent = part.visible ? '👁' : '🚫';
492
+ btn.style.color = part.visible ? '#44aa44' : '#666';
493
+ });
494
+ });
495
+ }
496
+
497
+ function updatePartListHighlight(idx) {
498
+ document.querySelectorAll('.step-part-item').forEach(el => {
499
+ el.style.background = idx !== null && parseInt(el.dataset.index) === idx ? '#0284C7' : '#2a2a2a';
500
+ });
501
+ }
502
+
503
+ export function explodeView(amount) {
504
+ stepViewerState.explodeAmount = Math.max(0, Math.min(1, amount));
505
+
506
+ stepViewerState.allParts.forEach(part => {
507
+ const origPos = stepViewerState.originalPositions.get(part.mesh);
508
+ const direction = origPos.clone().normalize();
509
+ const distance = 200 * stepViewerState.explodeAmount;
510
+
511
+ part.mesh.position.copy(origPos).addScaledVector(direction, distance);
512
+ });
513
+ }
514
+
515
+ export function exportBOM() {
516
+ if (stepViewerState.allParts.length === 0) {
517
+ alert('No parts loaded');
518
+ return;
519
+ }
520
+
521
+ const csv = ['Part Number,Name,Material\n'];
522
+ stepViewerState.allParts.forEach((part, i) => {
523
+ csv.push(`${i + 1},"${part.name}",\n`);
524
+ });
525
+
526
+ const blob = new Blob([csv.join('')], { type: 'text/csv' });
527
+ const url = URL.createObjectURL(blob);
528
+ const a = document.createElement('a');
529
+ a.href = url;
530
+ a.download = `bom_${Date.now()}.csv`;
531
+ a.click();
532
+ URL.revokeObjectURL(url);
533
+
534
+ console.log('[STEP] BOM exported');
535
+ }
536
+
537
+ export function getEmbedCode() {
538
+ const url = window.location.origin + window.location.pathname +
539
+ `?step_viewer=true&model=${btoa('imported_' + Date.now())}`;
540
+
541
+ return `<iframe
542
+ src="${url}"
543
+ width="1200" height="800"
544
+ style="border: none; border-radius: 8px;"
545
+ allow="fullscreen"
546
+ ></iframe>`;
547
+ }
548
+
549
+ function showImportStatus(message) {
550
+ let div = document.getElementById('step-import-status');
551
+ if (!div) {
552
+ div = document.createElement('div');
553
+ div.id = 'step-import-status';
554
+ document.body.appendChild(div);
555
+ }
556
+
557
+ div.textContent = message;
558
+ div.style.cssText = `
559
+ position: fixed;
560
+ bottom: 20px;
561
+ right: 20px;
562
+ background: #0284C7;
563
+ color: white;
564
+ padding: 12px 20px;
565
+ border-radius: 6px;
566
+ font-family: Calibri, sans-serif;
567
+ font-size: 13px;
568
+ z-index: 1000;
569
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
570
+ `;
571
+
572
+ return div;
573
+ }
574
+
575
+ // Register on window
576
+ window.stepViewer = {
577
+ initStepViewer,
578
+ importStepFile,
579
+ explodeView,
580
+ exportBOM,
581
+ getEmbedCode
582
+ };
583
+
584
+ console.log('[STEP Viewer] Module loaded. Use window.stepViewer.initStepViewer(container)');