cyclecad 0.1.5 → 0.1.7

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.
package/CLAUDE.md CHANGED
@@ -67,6 +67,7 @@ SACHIN (vvlars@googlemail.com, GitHub: vvlars-cmd). Building cycleCAD — open-s
67
67
  | `app/js/advanced-ops.js` | 763 | Sweep, loft, sheet metal (bend/flange/tab/slot/unfold), spring, thread |
68
68
  | `app/js/assembly.js` | 1,103 | Assembly workspace: components, mate constraints, joints, explode/collapse |
69
69
  | `app/js/dxf-export.js` | 1,174 | DXF export: 2D sketch, 3D projection, multi-view engineering drawing |
70
+ | `app/mobile.html` | 1,277 | **Phone edition**: mobile-first 3D viewer for .step/.stp/.ipt/.iam. occt-import-js + touch controls |
70
71
  | `app/js/params.js` | 523 | Parameter editor, material selector (Steel/Al/ABS/Brass/Ti/Nylon) |
71
72
  | `app/js/tree.js` | 479 | Feature tree panel with rename, suppress, delete, context menus |
72
73
  | `app/js/inventor-parser.js` | 1,138 | OLE2/CFB binary parser for .ipt/.iam, 26 feature types, assembly constraints |
@@ -0,0 +1,1276 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="apple-mobile-web-app-title" content="cycleCAD Mobile">
9
+ <meta name="theme-color" content="#0a0e14">
10
+ <title>cycleCAD Mobile - 3D CAD Viewer</title>
11
+ <style>
12
+ :root {
13
+ --bg-primary: #0a0e14;
14
+ --bg-secondary: #141a24;
15
+ --bg-tertiary: #1a2332;
16
+ --text-primary: #e8eef2;
17
+ --text-secondary: #8b949e;
18
+ --accent-blue: #58a6ff;
19
+ --accent-green: #3fb950;
20
+ --accent-red: #f85149;
21
+ --border-color: #30363d;
22
+ --shadow-color: rgba(0, 0, 0, 0.3);
23
+ --safe-top: env(safe-area-inset-top, 0);
24
+ --safe-bottom: env(safe-area-inset-bottom, 0);
25
+ }
26
+
27
+ * {
28
+ margin: 0;
29
+ padding: 0;
30
+ box-sizing: border-box;
31
+ }
32
+
33
+ html, body {
34
+ width: 100%;
35
+ height: 100%;
36
+ overflow: hidden;
37
+ background: var(--bg-primary);
38
+ color: var(--text-primary);
39
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
40
+ -webkit-user-select: none;
41
+ user-select: none;
42
+ -webkit-touch-callout: none;
43
+ }
44
+
45
+ body {
46
+ display: flex;
47
+ flex-direction: column;
48
+ }
49
+
50
+ /* Top bar */
51
+ .top-bar {
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ height: 56px;
56
+ padding: calc(var(--safe-top) + 8px) 16px 8px;
57
+ background: var(--bg-secondary);
58
+ border-bottom: 1px solid var(--border-color);
59
+ flex-shrink: 0;
60
+ z-index: 200;
61
+ }
62
+
63
+ .logo {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 8px;
67
+ font-weight: 600;
68
+ font-size: 14px;
69
+ color: var(--accent-blue);
70
+ text-decoration: none;
71
+ }
72
+
73
+ .logo svg {
74
+ width: 20px;
75
+ height: 20px;
76
+ }
77
+
78
+ .file-name {
79
+ flex: 1;
80
+ text-align: center;
81
+ font-size: 13px;
82
+ color: var(--text-secondary);
83
+ margin: 0 16px;
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ }
88
+
89
+ .menu-btn {
90
+ width: 40px;
91
+ height: 40px;
92
+ border: none;
93
+ background: none;
94
+ color: var(--accent-blue);
95
+ cursor: pointer;
96
+ font-size: 20px;
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ border-radius: 8px;
101
+ transition: background 0.2s;
102
+ -webkit-tap-highlight-color: transparent;
103
+ }
104
+
105
+ .menu-btn:active {
106
+ background: rgba(88, 166, 255, 0.1);
107
+ }
108
+
109
+ /* Viewport */
110
+ #viewport {
111
+ flex: 1;
112
+ width: 100%;
113
+ touch-action: none;
114
+ background: linear-gradient(135deg, #0a0e14 0%, #141a24 100%);
115
+ }
116
+
117
+ /* Loading spinner */
118
+ .loading-overlay {
119
+ position: fixed;
120
+ top: 0;
121
+ left: 0;
122
+ right: 0;
123
+ bottom: 0;
124
+ background: rgba(0, 0, 0, 0.7);
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ z-index: 300;
129
+ opacity: 0;
130
+ pointer-events: none;
131
+ transition: opacity 0.3s;
132
+ }
133
+
134
+ .loading-overlay.active {
135
+ opacity: 1;
136
+ pointer-events: auto;
137
+ }
138
+
139
+ .spinner {
140
+ width: 48px;
141
+ height: 48px;
142
+ border: 3px solid rgba(88, 166, 255, 0.2);
143
+ border-top-color: var(--accent-blue);
144
+ border-radius: 50%;
145
+ animation: spin 1s linear infinite;
146
+ }
147
+
148
+ @keyframes spin {
149
+ to { transform: rotate(360deg); }
150
+ }
151
+
152
+ .spinner-text {
153
+ position: absolute;
154
+ bottom: 60px;
155
+ color: var(--text-secondary);
156
+ font-size: 12px;
157
+ }
158
+
159
+ /* Toast notifications */
160
+ .toast {
161
+ position: fixed;
162
+ bottom: calc(16px + var(--safe-bottom));
163
+ left: 16px;
164
+ right: 16px;
165
+ padding: 12px 16px;
166
+ background: var(--bg-secondary);
167
+ color: var(--text-primary);
168
+ border: 1px solid var(--border-color);
169
+ border-radius: 8px;
170
+ font-size: 13px;
171
+ z-index: 250;
172
+ animation: slideUp 0.3s ease-out;
173
+ max-width: calc(100% - 32px);
174
+ }
175
+
176
+ @keyframes slideUp {
177
+ from {
178
+ opacity: 0;
179
+ transform: translateY(20px);
180
+ }
181
+ to {
182
+ opacity: 1;
183
+ transform: translateY(0);
184
+ }
185
+ }
186
+
187
+ /* Floating action button */
188
+ .fab {
189
+ position: fixed;
190
+ bottom: calc(24px + var(--safe-bottom));
191
+ right: 24px;
192
+ width: 56px;
193
+ height: 56px;
194
+ background: var(--accent-blue);
195
+ border: none;
196
+ border-radius: 50%;
197
+ color: var(--bg-primary);
198
+ font-size: 24px;
199
+ display: flex;
200
+ align-items: center;
201
+ justify-content: center;
202
+ cursor: pointer;
203
+ box-shadow: 0 4px 12px var(--shadow-color);
204
+ z-index: 120;
205
+ transition: all 0.2s;
206
+ -webkit-tap-highlight-color: transparent;
207
+ }
208
+
209
+ .fab:active {
210
+ transform: scale(0.95);
211
+ box-shadow: 0 2px 6px var(--shadow-color);
212
+ }
213
+
214
+ #fileInput {
215
+ display: none;
216
+ }
217
+
218
+ /* Bottom sheet */
219
+ .bottom-sheet {
220
+ position: fixed;
221
+ bottom: 0;
222
+ left: 0;
223
+ right: 0;
224
+ background: var(--bg-secondary);
225
+ border-radius: 20px 20px 0 0;
226
+ border: 1px solid var(--border-color);
227
+ border-bottom: none;
228
+ max-height: 70vh;
229
+ display: flex;
230
+ flex-direction: column;
231
+ transform: translateY(calc(100% - 48px));
232
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
233
+ z-index: 100;
234
+ padding-bottom: var(--safe-bottom);
235
+ }
236
+
237
+ .bottom-sheet.open {
238
+ transform: translateY(0);
239
+ }
240
+
241
+ .sheet-handle {
242
+ width: 40px;
243
+ height: 4px;
244
+ background: var(--border-color);
245
+ border-radius: 2px;
246
+ margin: 12px auto;
247
+ flex-shrink: 0;
248
+ }
249
+
250
+ .sheet-header {
251
+ display: flex;
252
+ border-bottom: 1px solid var(--border-color);
253
+ flex-shrink: 0;
254
+ }
255
+
256
+ .sheet-tab {
257
+ flex: 1;
258
+ padding: 12px;
259
+ text-align: center;
260
+ font-size: 13px;
261
+ font-weight: 500;
262
+ color: var(--text-secondary);
263
+ border: none;
264
+ background: none;
265
+ cursor: pointer;
266
+ border-bottom: 2px solid transparent;
267
+ transition: all 0.2s;
268
+ -webkit-tap-highlight-color: transparent;
269
+ }
270
+
271
+ .sheet-tab.active {
272
+ color: var(--accent-blue);
273
+ border-bottom-color: var(--accent-blue);
274
+ }
275
+
276
+ .sheet-content {
277
+ flex: 1;
278
+ overflow-y: auto;
279
+ overflow-x: hidden;
280
+ padding: 12px;
281
+ -webkit-overflow-scrolling: touch;
282
+ }
283
+
284
+ .sheet-section-title {
285
+ font-size: 12px;
286
+ font-weight: 600;
287
+ color: var(--text-secondary);
288
+ text-transform: uppercase;
289
+ letter-spacing: 0.5px;
290
+ margin: 16px 0 8px;
291
+ padding: 0 4px;
292
+ }
293
+
294
+ .part-item {
295
+ padding: 12px;
296
+ background: var(--bg-tertiary);
297
+ border-radius: 8px;
298
+ margin-bottom: 8px;
299
+ border: 1px solid var(--border-color);
300
+ cursor: pointer;
301
+ transition: all 0.2s;
302
+ -webkit-tap-highlight-color: transparent;
303
+ }
304
+
305
+ .part-item:active {
306
+ background: var(--bg-secondary);
307
+ border-color: var(--accent-blue);
308
+ }
309
+
310
+ .part-item.selected {
311
+ background: var(--bg-secondary);
312
+ border-color: var(--accent-blue);
313
+ box-shadow: inset 0 0 0 1px var(--accent-blue);
314
+ }
315
+
316
+ .part-name {
317
+ font-size: 13px;
318
+ font-weight: 500;
319
+ color: var(--text-primary);
320
+ margin-bottom: 4px;
321
+ }
322
+
323
+ .part-meta {
324
+ font-size: 11px;
325
+ color: var(--text-secondary);
326
+ }
327
+
328
+ .info-row {
329
+ display: flex;
330
+ justify-content: space-between;
331
+ padding: 8px 0;
332
+ border-bottom: 1px solid var(--border-color);
333
+ font-size: 13px;
334
+ }
335
+
336
+ .info-row:last-child {
337
+ border-bottom: none;
338
+ }
339
+
340
+ .info-label {
341
+ color: var(--text-secondary);
342
+ font-weight: 500;
343
+ }
344
+
345
+ .info-value {
346
+ color: var(--text-primary);
347
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
348
+ font-size: 12px;
349
+ }
350
+
351
+ .tool-button {
352
+ display: flex;
353
+ align-items: center;
354
+ gap: 12px;
355
+ width: 100%;
356
+ padding: 12px;
357
+ margin-bottom: 8px;
358
+ background: var(--bg-tertiary);
359
+ border: 1px solid var(--border-color);
360
+ border-radius: 8px;
361
+ color: var(--text-primary);
362
+ font-size: 13px;
363
+ font-weight: 500;
364
+ cursor: pointer;
365
+ transition: all 0.2s;
366
+ -webkit-tap-highlight-color: transparent;
367
+ }
368
+
369
+ .tool-button:active {
370
+ background: var(--bg-secondary);
371
+ border-color: var(--accent-blue);
372
+ }
373
+
374
+ .tool-toggle {
375
+ margin-left: auto;
376
+ width: 40px;
377
+ height: 24px;
378
+ background: var(--border-color);
379
+ border: none;
380
+ border-radius: 12px;
381
+ cursor: pointer;
382
+ transition: background 0.2s;
383
+ position: relative;
384
+ -webkit-tap-highlight-color: transparent;
385
+ }
386
+
387
+ .tool-toggle.on {
388
+ background: var(--accent-green);
389
+ }
390
+
391
+ .tool-toggle::after {
392
+ content: '';
393
+ position: absolute;
394
+ top: 2px;
395
+ left: 2px;
396
+ width: 20px;
397
+ height: 20px;
398
+ background: white;
399
+ border-radius: 10px;
400
+ transition: left 0.2s;
401
+ }
402
+
403
+ .tool-toggle.on::after {
404
+ left: 18px;
405
+ }
406
+
407
+ .feature-tree {
408
+ font-size: 12px;
409
+ color: var(--text-secondary);
410
+ margin-top: 12px;
411
+ padding: 8px;
412
+ background: var(--bg-primary);
413
+ border-radius: 6px;
414
+ max-height: 200px;
415
+ overflow-y: auto;
416
+ }
417
+
418
+ .feature-item {
419
+ padding: 4px 0;
420
+ padding-left: 12px;
421
+ border-left: 2px solid var(--border-color);
422
+ margin-left: 0;
423
+ }
424
+
425
+ .feature-icon {
426
+ display: inline-block;
427
+ width: 16px;
428
+ margin-right: 6px;
429
+ text-align: center;
430
+ }
431
+
432
+ .slider-container {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 12px;
436
+ padding: 12px;
437
+ background: var(--bg-tertiary);
438
+ border-radius: 8px;
439
+ margin-bottom: 8px;
440
+ }
441
+
442
+ .slider-container label {
443
+ font-size: 12px;
444
+ color: var(--text-secondary);
445
+ min-width: 60px;
446
+ font-weight: 500;
447
+ }
448
+
449
+ input[type="range"] {
450
+ flex: 1;
451
+ accent-color: var(--accent-blue);
452
+ cursor: pointer;
453
+ }
454
+
455
+ .slider-value {
456
+ font-size: 12px;
457
+ color: var(--text-primary);
458
+ min-width: 30px;
459
+ text-align: right;
460
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
461
+ }
462
+
463
+ /* Color picker */
464
+ .color-grid {
465
+ display: grid;
466
+ grid-template-columns: repeat(5, 1fr);
467
+ gap: 8px;
468
+ margin-top: 8px;
469
+ }
470
+
471
+ .color-swatch {
472
+ width: 100%;
473
+ aspect-ratio: 1;
474
+ border-radius: 8px;
475
+ border: 2px solid transparent;
476
+ cursor: pointer;
477
+ transition: all 0.2s;
478
+ -webkit-tap-highlight-color: transparent;
479
+ }
480
+
481
+ .color-swatch:active {
482
+ transform: scale(0.95);
483
+ }
484
+
485
+ .color-swatch.selected {
486
+ border-color: var(--accent-blue);
487
+ box-shadow: 0 0 8px rgba(88, 166, 255, 0.4);
488
+ }
489
+
490
+ /* Scroll styling */
491
+ .sheet-content::-webkit-scrollbar {
492
+ width: 4px;
493
+ }
494
+
495
+ .sheet-content::-webkit-scrollbar-track {
496
+ background: transparent;
497
+ }
498
+
499
+ .sheet-content::-webkit-scrollbar-thumb {
500
+ background: var(--border-color);
501
+ border-radius: 2px;
502
+ }
503
+
504
+ .sheet-content::-webkit-scrollbar-thumb:active {
505
+ background: var(--text-secondary);
506
+ }
507
+
508
+ /* Fullscreen */
509
+ canvas:fullscreen {
510
+ display: block;
511
+ }
512
+
513
+ /* Message panel */
514
+ .empty-state {
515
+ display: flex;
516
+ flex-direction: column;
517
+ align-items: center;
518
+ justify-content: center;
519
+ height: 100%;
520
+ color: var(--text-secondary);
521
+ text-align: center;
522
+ padding: 32px 16px;
523
+ }
524
+
525
+ .empty-state-icon {
526
+ font-size: 40px;
527
+ margin-bottom: 12px;
528
+ opacity: 0.5;
529
+ }
530
+
531
+ .empty-state-text {
532
+ font-size: 13px;
533
+ line-height: 1.5;
534
+ }
535
+ </style>
536
+ </head>
537
+ <body>
538
+ <!-- Top bar -->
539
+ <div class="top-bar">
540
+ <a href="https://cyclecad.com" class="logo">
541
+ <svg viewBox="0 0 24 24" fill="currentColor">
542
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>
543
+ </svg>
544
+ cycleCAD
545
+ </a>
546
+ <div class="file-name" id="fileName">No file loaded</div>
547
+ <button class="menu-btn" id="menuBtn">⋮</button>
548
+ </div>
549
+
550
+ <!-- Viewport -->
551
+ <canvas id="viewport"></canvas>
552
+
553
+ <!-- Loading overlay -->
554
+ <div class="loading-overlay" id="loadingOverlay">
555
+ <div style="position: relative; display: flex; flex-direction: column; align-items: center;">
556
+ <div class="spinner"></div>
557
+ <div class="spinner-text" id="loadingText">Parsing file...</div>
558
+ </div>
559
+ </div>
560
+
561
+ <!-- Toast container -->
562
+ <div id="toastContainer"></div>
563
+
564
+ <!-- Floating action button -->
565
+ <button class="fab" id="fabButton" title="Open file">+</button>
566
+
567
+ <!-- File input -->
568
+ <input type="file" id="fileInput" accept=".step,.stp,.ipt,.iam" />
569
+
570
+ <!-- Bottom sheet -->
571
+ <div class="bottom-sheet" id="bottomSheet">
572
+ <div class="sheet-handle"></div>
573
+ <div class="sheet-header">
574
+ <button class="sheet-tab active" data-tab="parts">Parts</button>
575
+ <button class="sheet-tab" data-tab="info">Info</button>
576
+ <button class="sheet-tab" data-tab="tools">Tools</button>
577
+ </div>
578
+ <div class="sheet-content" id="sheetContent">
579
+ <!-- Parts tab content -->
580
+ <div id="partsTab" class="tab-content">
581
+ <div class="empty-state">
582
+ <div class="empty-state-icon">📁</div>
583
+ <div class="empty-state-text">No file loaded<br>Tap + to open a file</div>
584
+ </div>
585
+ </div>
586
+ <!-- Info tab content -->
587
+ <div id="infoTab" class="tab-content" style="display: none;">
588
+ <div class="empty-state">
589
+ <div class="empty-state-icon">ℹ️</div>
590
+ <div class="empty-state-text">Load a file to see details</div>
591
+ </div>
592
+ </div>
593
+ <!-- Tools tab content -->
594
+ <div id="toolsTab" class="tab-content" style="display: none;">
595
+ <div class="sheet-section-title">Display</div>
596
+ <button class="tool-button" id="wireframeBtn">
597
+ <span>Wireframe</span>
598
+ <button class="tool-toggle" onclick="event.stopPropagation()"></button>
599
+ </button>
600
+ <button class="tool-button" id="screenshotBtn">
601
+ <span>📸 Screenshot</span>
602
+ </button>
603
+
604
+ <div class="sheet-section-title">Background</div>
605
+ <div class="color-grid" id="colorGrid">
606
+ <div class="color-swatch selected" data-color="#0a0e14" style="background: #0a0e14;"></div>
607
+ <div class="color-swatch" data-color="#ffffff" style="background: #ffffff;"></div>
608
+ <div class="color-swatch" data-color="#2d3748" style="background: #2d3748;"></div>
609
+ <div class="color-swatch" data-color="#1a365d" style="background: #1a365d;"></div>
610
+ <div class="color-swatch" data-color="#22543d" style="background: #22543d;"></div>
611
+ </div>
612
+
613
+ <div class="sheet-section-title">Explode</div>
614
+ <div class="slider-container">
615
+ <label>Explode</label>
616
+ <input type="range" id="explodeSlider" min="0" max="1" step="0.01" value="0" />
617
+ <div class="slider-value" id="explodeValue">0%</div>
618
+ </div>
619
+ </div>
620
+ </div>
621
+ </div>
622
+
623
+ <script type="importmap">
624
+ {
625
+ "imports": {
626
+ "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
627
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
628
+ }
629
+ }
630
+ </script>
631
+
632
+ <script async src="https://cdn.jsdelivr.net/npm/occt-import-js@0.0.23/dist/occt-import-js.js"></script>
633
+
634
+ <script type="module">
635
+ import * as THREE from 'three';
636
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
637
+
638
+ // ============ STATE ============
639
+ const state = {
640
+ scene: null,
641
+ camera: null,
642
+ renderer: null,
643
+ controls: null,
644
+ currentFile: null,
645
+ currentMeshes: [],
646
+ selectedMesh: null,
647
+ isWireframe: false,
648
+ explodeAmount: 0,
649
+ meshCenters: [],
650
+ occtLoaded: false,
651
+ };
652
+
653
+ // ============ INITIALIZATION ============
654
+ function initRenderer() {
655
+ const canvas = document.getElementById('viewport');
656
+ const width = canvas.clientWidth;
657
+ const height = canvas.clientHeight;
658
+
659
+ state.renderer = new THREE.WebGLRenderer({
660
+ canvas,
661
+ antialias: true,
662
+ alpha: false,
663
+ precision: 'highp',
664
+ powerPreference: 'high-performance'
665
+ });
666
+ state.renderer.setSize(width, height);
667
+ state.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
668
+ state.renderer.shadowMap.enabled = true;
669
+ state.renderer.shadowMap.type = THREE.PCFSoftShadowShadowMap;
670
+ state.renderer.toneMapping = THREE.ACESFilmicToneMapping;
671
+ state.renderer.toneMappingExposure = 1;
672
+ state.renderer.setClearColor(0x0a0e14, 1);
673
+
674
+ state.scene = new THREE.Scene();
675
+ state.scene.background = new THREE.Color(0x0a0e14);
676
+
677
+ // Camera
678
+ const fov = 50;
679
+ const aspect = width / height;
680
+ state.camera = new THREE.PerspectiveCamera(fov, aspect, 0.1, 10000);
681
+ state.camera.position.set(200, 150, 250);
682
+
683
+ // Lighting
684
+ const ambLight = new THREE.AmbientLight(0xffffff, 0.5);
685
+ state.scene.add(ambLight);
686
+
687
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
688
+ dirLight.position.set(300, 300, 200);
689
+ dirLight.castShadow = true;
690
+ dirLight.shadow.mapSize.width = 2048;
691
+ dirLight.shadow.mapSize.height = 2048;
692
+ dirLight.shadow.camera.far = 1000;
693
+ state.scene.add(dirLight);
694
+
695
+ const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x545454, 0.4);
696
+ state.scene.add(hemiLight);
697
+
698
+ // Ground plane
699
+ const groundGeo = new THREE.PlaneGeometry(5000, 5000);
700
+ const groundMat = new THREE.ShadowMaterial({ opacity: 0.2 });
701
+ const ground = new THREE.Mesh(groundGeo, groundMat);
702
+ ground.rotation.x = -Math.PI / 2;
703
+ ground.position.y = -500;
704
+ ground.receiveShadow = true;
705
+ state.scene.add(ground);
706
+
707
+ // Controls
708
+ state.controls = new OrbitControls(state.camera, canvas);
709
+ state.controls.enableDamping = true;
710
+ state.controls.dampingFactor = 0.05;
711
+ state.controls.autoRotate = false;
712
+ state.controls.touches = {
713
+ ONE: THREE.TOUCH.ROTATE,
714
+ TWO: THREE.TOUCH.DOLLY_PAN,
715
+ };
716
+
717
+ // Handle window resize
718
+ window.addEventListener('resize', () => onWindowResize());
719
+
720
+ // Raycaster for selection
721
+ window._raycaster = new THREE.Raycaster();
722
+ window._mouse = new THREE.Vector2();
723
+
724
+ // Click handler
725
+ canvas.addEventListener('click', (e) => onCanvasClick(e));
726
+
727
+ // Animate
728
+ animate();
729
+ }
730
+
731
+ function animate() {
732
+ requestAnimationFrame(animate);
733
+
734
+ state.controls.update();
735
+
736
+ // Update explode
737
+ if (state.explodeAmount > 0) {
738
+ state.currentMeshes.forEach((mesh, idx) => {
739
+ const center = state.meshCenters[idx];
740
+ if (center) {
741
+ const offset = center.clone().normalize().multiplyScalar(state.explodeAmount * 50);
742
+ mesh.position.copy(center).add(offset);
743
+ }
744
+ });
745
+ } else {
746
+ state.currentMeshes.forEach((mesh, idx) => {
747
+ const center = state.meshCenters[idx];
748
+ if (center) mesh.position.copy(center);
749
+ });
750
+ }
751
+
752
+ state.renderer.render(state.scene, state.camera);
753
+ }
754
+
755
+ function onWindowResize() {
756
+ const width = window.innerWidth;
757
+ const height = window.innerHeight - 56; // subtract top bar
758
+ state.camera.aspect = width / height;
759
+ state.camera.updateProjectionMatrix();
760
+ state.renderer.setSize(width, height);
761
+ }
762
+
763
+ // ============ CANVAS INTERACTION ============
764
+ function onCanvasClick(event) {
765
+ if (!state.currentMeshes.length) return;
766
+
767
+ const canvas = document.getElementById('viewport');
768
+ const rect = canvas.getBoundingClientRect();
769
+ window._mouse.x = ((event.clientX - rect.left) / canvas.clientWidth) * 2 - 1;
770
+ window._mouse.y = -((event.clientY - rect.top) / canvas.clientHeight) * 2 + 1;
771
+
772
+ window._raycaster.setFromCamera(window._mouse, state.camera);
773
+ const intersects = window._raycaster.intersectObjects(state.currentMeshes);
774
+
775
+ if (intersects.length > 0) {
776
+ selectMesh(intersects[0].object);
777
+ }
778
+ }
779
+
780
+ function selectMesh(mesh) {
781
+ if (state.selectedMesh) {
782
+ state.selectedMesh.layers.disable(1);
783
+ }
784
+
785
+ state.selectedMesh = mesh;
786
+ mesh.layers.enable(1);
787
+
788
+ // Update parts list UI
789
+ const idx = state.currentMeshes.indexOf(mesh);
790
+ document.querySelectorAll('.part-item').forEach((el, i) => {
791
+ el.classList.toggle('selected', i === idx);
792
+ });
793
+
794
+ showToast(`Selected: ${mesh.userData.name || `Part ${idx + 1}`}`);
795
+ }
796
+
797
+ // ============ FILE HANDLING ============
798
+ async function handleFileSelect(file) {
799
+ if (!file) return;
800
+
801
+ showLoading(true, 'Reading file...');
802
+ state.currentFile = file;
803
+ document.getElementById('fileName').textContent = file.name;
804
+
805
+ try {
806
+ const buffer = await file.arrayBuffer();
807
+ const ext = file.name.toLowerCase().split('.').pop();
808
+
809
+ if (ext === 'step' || ext === 'stp') {
810
+ await loadSTEPFile(buffer);
811
+ } else if (ext === 'ipt' || ext === 'iam') {
812
+ loadInventorFile(buffer, file.name);
813
+ } else {
814
+ throw new Error('Unsupported file format');
815
+ }
816
+
817
+ showLoading(false);
818
+ showToast(`Loaded: ${file.name}`);
819
+ } catch (err) {
820
+ showLoading(false);
821
+ showToast(`Error: ${err.message}`, 'error');
822
+ console.error(err);
823
+ }
824
+ }
825
+
826
+ async function loadSTEPFile(buffer) {
827
+ showLoading(true, 'Parsing STEP file...');
828
+
829
+ // Wait for occt-import-js to load
830
+ let occt;
831
+ let retries = 0;
832
+ while (!window.occtimportjs && retries < 50) {
833
+ await new Promise(r => setTimeout(r, 100));
834
+ retries++;
835
+ }
836
+
837
+ if (!window.occtimportjs) {
838
+ throw new Error('STEP parser library failed to load');
839
+ }
840
+
841
+ occt = await window.occtimportjs();
842
+ showLoading(true, 'Converting to 3D...');
843
+
844
+ const result = occt.ReadStepFile(new Uint8Array(buffer), null);
845
+ clearScene();
846
+
847
+ let partIndex = 0;
848
+ result.meshes.forEach((meshData) => {
849
+ const geometry = new THREE.BufferGeometry();
850
+
851
+ if (meshData.attributes.position) {
852
+ geometry.setAttribute('position',
853
+ new THREE.BufferAttribute(new Float32Array(meshData.attributes.position.array), 3));
854
+ }
855
+
856
+ if (meshData.attributes.normal) {
857
+ geometry.setAttribute('normal',
858
+ new THREE.BufferAttribute(new Float32Array(meshData.attributes.normal.array), 3));
859
+ } else {
860
+ geometry.computeVertexNormals();
861
+ }
862
+
863
+ if (meshData.index) {
864
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(meshData.index.array), 1));
865
+ }
866
+
867
+ // Handle per-face coloring
868
+ if (meshData.brep_faces && meshData.brep_faces.length > 0) {
869
+ const materials = [];
870
+ const defaultColor = meshData.color ?
871
+ new THREE.Color(meshData.color[0], meshData.color[1], meshData.color[2]) :
872
+ new THREE.Color(0.8, 0.8, 0.85);
873
+
874
+ meshData.brep_faces.forEach((face, i) => {
875
+ const faceColor = face.color ?
876
+ new THREE.Color(face.color[0], face.color[1], face.color[2]) :
877
+ defaultColor;
878
+ materials.push(new THREE.MeshPhongMaterial({
879
+ color: faceColor,
880
+ side: THREE.DoubleSide,
881
+ shininess: 100,
882
+ shadowMap: { type: THREE.PCFSoftShadowShadowMap }
883
+ }));
884
+ const firstTriangle = face.first;
885
+ const lastTriangle = face.last + 1;
886
+ geometry.addGroup(firstTriangle * 3, (lastTriangle - firstTriangle) * 3, i);
887
+ });
888
+ const mesh = new THREE.Mesh(geometry, materials);
889
+ mesh.castShadow = true;
890
+ mesh.receiveShadow = true;
891
+ mesh.userData.name = `Part ${partIndex + 1}`;
892
+ mesh.userData.type = 'brep_face';
893
+ state.scene.add(mesh);
894
+ state.currentMeshes.push(mesh);
895
+ } else {
896
+ const color = meshData.color ?
897
+ new THREE.Color(meshData.color[0], meshData.color[1], meshData.color[2]) :
898
+ new THREE.Color(0.8, 0.8, 0.85);
899
+ const material = new THREE.MeshPhongMaterial({
900
+ color,
901
+ side: THREE.DoubleSide,
902
+ shininess: 100
903
+ });
904
+ const mesh = new THREE.Mesh(geometry, material);
905
+ mesh.castShadow = true;
906
+ mesh.receiveShadow = true;
907
+ mesh.userData.name = `Part ${partIndex + 1}`;
908
+ mesh.userData.type = 'mesh';
909
+ state.scene.add(mesh);
910
+ state.currentMeshes.push(mesh);
911
+ }
912
+
913
+ partIndex++;
914
+ });
915
+
916
+ state.meshCenters = state.currentMeshes.map(() => new THREE.Vector3());
917
+ fitToView();
918
+ updatePartsList();
919
+ updateInfoPanel();
920
+ }
921
+
922
+ function loadInventorFile(buffer, filename) {
923
+ clearScene();
924
+
925
+ const data = new Uint8Array(buffer);
926
+ const isOLE2 = data[0] === 0xd0 && data[1] === 0xcf;
927
+
928
+ if (!isOLE2) {
929
+ showToast('Not a valid Inventor file', 'error');
930
+ return;
931
+ }
932
+
933
+ // Extract text for feature detection
934
+ const text = extractStringFromBuffer(data);
935
+ const features = detectFeatures(text);
936
+
937
+ // Create placeholder mesh
938
+ const geometry = new THREE.BoxGeometry(100, 100, 100);
939
+ const material = new THREE.MeshPhongMaterial({
940
+ color: 0x58a6ff,
941
+ opacity: 0.7,
942
+ transparent: true
943
+ });
944
+ const mesh = new THREE.Mesh(geometry, material);
945
+ mesh.userData.name = filename;
946
+ mesh.userData.type = 'inventor';
947
+ mesh.userData.features = features;
948
+ mesh.castShadow = true;
949
+ state.scene.add(mesh);
950
+ state.currentMeshes.push(mesh);
951
+ state.meshCenters.push(new THREE.Vector3());
952
+
953
+ fitToView();
954
+ updatePartsList();
955
+ updateInfoPanel();
956
+
957
+ showToast('Inventor file loaded. For 3D preview, export as STEP from Inventor.', 'info');
958
+ }
959
+
960
+ function extractStringFromBuffer(data) {
961
+ let text = '';
962
+ for (let i = 0; i < data.length; i++) {
963
+ const char = String.fromCharCode(data[i]);
964
+ if (char >= ' ' && char <= '~') {
965
+ text += char;
966
+ } else if (text.length > 0 && text[text.length - 1] !== ' ') {
967
+ text += ' ';
968
+ }
969
+ }
970
+ return text;
971
+ }
972
+
973
+ function detectFeatures(text) {
974
+ const features = [];
975
+ const featureMap = {
976
+ 'ExtrudeFeature': '🟫 Extrude',
977
+ 'RevolveFeature': '🔄 Revolve',
978
+ 'FilletFeature': '🔘 Fillet',
979
+ 'ChamferFeature': '📐 Chamfer',
980
+ 'SketchFeature': '📝 Sketch',
981
+ 'BooleanFeature': '✏️ Boolean',
982
+ 'ShellFeature': '🎁 Shell',
983
+ 'WorkPlane': '✈️ Work Plane',
984
+ 'WorkAxis': '📏 Work Axis',
985
+ };
986
+
987
+ for (const [key, label] of Object.entries(featureMap)) {
988
+ if (text.includes(key)) features.push(label);
989
+ }
990
+
991
+ return features.length > 0 ? features : ['📦 Part'];
992
+ }
993
+
994
+ function clearScene() {
995
+ state.currentMeshes.forEach(mesh => {
996
+ state.scene.remove(mesh);
997
+ if (mesh.geometry) mesh.geometry.dispose();
998
+ if (Array.isArray(mesh.material)) {
999
+ mesh.material.forEach(m => m.dispose());
1000
+ } else {
1001
+ mesh.material.dispose();
1002
+ }
1003
+ });
1004
+ state.currentMeshes = [];
1005
+ state.meshCenters = [];
1006
+ state.selectedMesh = null;
1007
+ }
1008
+
1009
+ function fitToView() {
1010
+ const box = new THREE.Box3();
1011
+ state.currentMeshes.forEach(mesh => box.expandByObject(mesh));
1012
+
1013
+ const size = box.getSize(new THREE.Vector3());
1014
+ const maxDim = Math.max(size.x, size.y, size.z);
1015
+ const fov = state.camera.fov * (Math.PI / 180);
1016
+ let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
1017
+ cameraZ *= 1.5;
1018
+
1019
+ const center = box.getCenter(new THREE.Vector3());
1020
+ state.camera.position.copy(center);
1021
+ state.camera.position.z += cameraZ;
1022
+ state.camera.lookAt(center);
1023
+ state.controls.target.copy(center);
1024
+ state.controls.update();
1025
+ }
1026
+
1027
+ // ============ UI UPDATES ============
1028
+ function updatePartsList() {
1029
+ const container = document.getElementById('partsTab');
1030
+ if (state.currentMeshes.length === 0) {
1031
+ container.innerHTML = `
1032
+ <div class="empty-state">
1033
+ <div class="empty-state-icon">📁</div>
1034
+ <div class="empty-state-text">No parts loaded</div>
1035
+ </div>
1036
+ `;
1037
+ return;
1038
+ }
1039
+
1040
+ let html = '<div class="sheet-section-title">Parts (' + state.currentMeshes.length + ')</div>';
1041
+ state.currentMeshes.forEach((mesh, idx) => {
1042
+ const isSelected = mesh === state.selectedMesh;
1043
+ html += `
1044
+ <div class="part-item ${isSelected ? 'selected' : ''}" data-index="${idx}">
1045
+ <div class="part-name">${mesh.userData.name || `Part ${idx + 1}`}</div>
1046
+ <div class="part-meta">${mesh.userData.type}</div>
1047
+ </div>
1048
+ `;
1049
+ });
1050
+ container.innerHTML = html;
1051
+
1052
+ container.querySelectorAll('.part-item').forEach((el) => {
1053
+ el.addEventListener('click', () => {
1054
+ const idx = parseInt(el.dataset.index);
1055
+ selectMesh(state.currentMeshes[idx]);
1056
+ });
1057
+ });
1058
+ }
1059
+
1060
+ function updateInfoPanel() {
1061
+ const container = document.getElementById('infoTab');
1062
+ if (state.currentMeshes.length === 0) {
1063
+ container.innerHTML = `
1064
+ <div class="empty-state">
1065
+ <div class="empty-state-icon">ℹ️</div>
1066
+ <div class="empty-state-text">Load a file to see details</div>
1067
+ </div>
1068
+ `;
1069
+ return;
1070
+ }
1071
+
1072
+ const mesh = state.selectedMesh || state.currentMeshes[0];
1073
+ const box = new THREE.Box3().expandByObject(mesh);
1074
+ const size = box.getSize(new THREE.Vector3());
1075
+
1076
+ let html = `
1077
+ <div class="sheet-section-title">File</div>
1078
+ <div class="info-row">
1079
+ <span class="info-label">Name</span>
1080
+ <span class="info-value">${state.currentFile?.name || 'Unknown'}</span>
1081
+ </div>
1082
+ <div class="info-row">
1083
+ <span class="info-label">Size</span>
1084
+ <span class="info-value">${formatBytes(state.currentFile?.size || 0)}</span>
1085
+ </div>
1086
+ <div class="info-row">
1087
+ <span class="info-label">Parts</span>
1088
+ <span class="info-value">${state.currentMeshes.length}</span>
1089
+ </div>
1090
+
1091
+ <div class="sheet-section-title">Selected Part</div>
1092
+ <div class="info-row">
1093
+ <span class="info-label">Name</span>
1094
+ <span class="info-value">${mesh.userData.name || 'Part'}</span>
1095
+ </div>
1096
+ <div class="info-row">
1097
+ <span class="info-label">Type</span>
1098
+ <span class="info-value">${mesh.userData.type}</span>
1099
+ </div>
1100
+
1101
+ <div class="sheet-section-title">Dimensions</div>
1102
+ <div class="info-row">
1103
+ <span class="info-label">X</span>
1104
+ <span class="info-value">${size.x.toFixed(1)} mm</span>
1105
+ </div>
1106
+ <div class="info-row">
1107
+ <span class="info-label">Y</span>
1108
+ <span class="info-value">${size.y.toFixed(1)} mm</span>
1109
+ </div>
1110
+ <div class="info-row">
1111
+ <span class="info-label">Z</span>
1112
+ <span class="info-value">${size.z.toFixed(1)} mm</span>
1113
+ </div>
1114
+ <div class="info-row">
1115
+ <span class="info-label">Volume</span>
1116
+ <span class="info-value">${(size.x * size.y * size.z).toFixed(0)} mm³</span>
1117
+ </div>
1118
+ `;
1119
+
1120
+ if (mesh.userData.features && mesh.userData.features.length > 0) {
1121
+ html += `
1122
+ <div class="sheet-section-title">Features</div>
1123
+ <div class="feature-tree">
1124
+ ${mesh.userData.features.map(f => `<div class="feature-item">${f}</div>`).join('')}
1125
+ </div>
1126
+ `;
1127
+ }
1128
+
1129
+ container.innerHTML = html;
1130
+ }
1131
+
1132
+ function formatBytes(bytes) {
1133
+ if (bytes === 0) return '0 B';
1134
+ const k = 1024;
1135
+ const sizes = ['B', 'KB', 'MB', 'GB'];
1136
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
1137
+ return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
1138
+ }
1139
+
1140
+ // ============ TOOLS ============
1141
+ function toggleWireframe() {
1142
+ state.isWireframe = !state.isWireframe;
1143
+ state.currentMeshes.forEach(mesh => {
1144
+ if (Array.isArray(mesh.material)) {
1145
+ mesh.material.forEach(m => m.wireframe = state.isWireframe);
1146
+ } else {
1147
+ mesh.material.wireframe = state.isWireframe;
1148
+ }
1149
+ });
1150
+ document.querySelector('#toolsTab .tool-toggle').classList.toggle('on', state.isWireframe);
1151
+ }
1152
+
1153
+ function takeScreenshot() {
1154
+ const link = document.createElement('a');
1155
+ link.download = `cyclecad-${Date.now()}.png`;
1156
+ link.href = document.getElementById('viewport').toDataURL('image/png');
1157
+ link.click();
1158
+ showToast('Screenshot saved');
1159
+ }
1160
+
1161
+ function setBackgroundColor(color) {
1162
+ const c = new THREE.Color(color);
1163
+ state.renderer.setClearColor(c, 1);
1164
+ state.scene.background = c;
1165
+ document.querySelectorAll('.color-swatch').forEach(el => {
1166
+ el.classList.toggle('selected', el.dataset.color === color);
1167
+ });
1168
+ }
1169
+
1170
+ // ============ UI HELPERS ============
1171
+ function showToast(message, type = 'info') {
1172
+ const toast = document.createElement('div');
1173
+ toast.className = 'toast';
1174
+ toast.textContent = message;
1175
+ document.getElementById('toastContainer').appendChild(toast);
1176
+ setTimeout(() => toast.remove(), 3000);
1177
+ }
1178
+
1179
+ function showLoading(show, text = 'Loading...') {
1180
+ const overlay = document.getElementById('loadingOverlay');
1181
+ if (show) {
1182
+ overlay.classList.add('active');
1183
+ document.getElementById('loadingText').textContent = text;
1184
+ } else {
1185
+ overlay.classList.remove('active');
1186
+ }
1187
+ }
1188
+
1189
+ // ============ BOTTOM SHEET ============
1190
+ let sheetDragging = false;
1191
+ let sheetStartY = 0;
1192
+ const sheet = document.getElementById('bottomSheet');
1193
+
1194
+ sheet.addEventListener('touchstart', (e) => {
1195
+ sheetDragging = true;
1196
+ sheetStartY = e.touches[0].clientY;
1197
+ });
1198
+
1199
+ sheet.addEventListener('touchmove', (e) => {
1200
+ if (!sheetDragging) return;
1201
+ const delta = e.touches[0].clientY - sheetStartY;
1202
+ if (delta > 0) {
1203
+ sheet.style.transform = `translateY(calc(100% - 48px + ${delta}px))`;
1204
+ }
1205
+ });
1206
+
1207
+ sheet.addEventListener('touchend', (e) => {
1208
+ sheetDragging = false;
1209
+ const delta = e.changedTouches[0].clientY - sheetStartY;
1210
+ if (delta > 50) {
1211
+ sheet.classList.remove('open');
1212
+ } else {
1213
+ sheet.classList.add('open');
1214
+ }
1215
+ sheet.style.transform = '';
1216
+ });
1217
+
1218
+ document.querySelectorAll('.sheet-tab').forEach((btn) => {
1219
+ btn.addEventListener('click', () => {
1220
+ document.querySelectorAll('.sheet-tab').forEach(b => b.classList.remove('active'));
1221
+ document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none');
1222
+ btn.classList.add('active');
1223
+ document.getElementById(btn.dataset.tab + 'Tab').style.display = 'block';
1224
+ });
1225
+ });
1226
+
1227
+ // ============ EVENT LISTENERS ============
1228
+ document.getElementById('fabButton').addEventListener('click', () => {
1229
+ document.getElementById('fileInput').click();
1230
+ });
1231
+
1232
+ document.getElementById('fileInput').addEventListener('change', (e) => {
1233
+ if (e.target.files.length > 0) {
1234
+ handleFileSelect(e.target.files[0]);
1235
+ sheet.classList.add('open');
1236
+ }
1237
+ });
1238
+
1239
+ document.getElementById('wireframeBtn').addEventListener('click', toggleWireframe);
1240
+ document.getElementById('screenshotBtn').addEventListener('click', takeScreenshot);
1241
+
1242
+ document.querySelectorAll('.color-swatch').forEach((swatch) => {
1243
+ swatch.addEventListener('click', () => {
1244
+ setBackgroundColor(swatch.dataset.color);
1245
+ });
1246
+ });
1247
+
1248
+ document.getElementById('explodeSlider').addEventListener('input', (e) => {
1249
+ state.explodeAmount = parseFloat(e.target.value);
1250
+ document.getElementById('explodeValue').textContent = Math.round(state.explodeAmount * 100) + '%';
1251
+ });
1252
+
1253
+ document.getElementById('menuBtn').addEventListener('click', () => {
1254
+ sheet.classList.toggle('open');
1255
+ });
1256
+
1257
+ // Drag and drop
1258
+ document.getElementById('viewport').addEventListener('dragover', (e) => {
1259
+ e.preventDefault();
1260
+ e.stopPropagation();
1261
+ });
1262
+
1263
+ document.getElementById('viewport').addEventListener('drop', (e) => {
1264
+ e.preventDefault();
1265
+ e.stopPropagation();
1266
+ if (e.dataTransfer.files.length > 0) {
1267
+ handleFileSelect(e.dataTransfer.files[0]);
1268
+ }
1269
+ });
1270
+
1271
+ // Initialize
1272
+ initRenderer();
1273
+ showToast('Tap + to load a 3D file (STEP, IPT, or IAM)');
1274
+ </script>
1275
+ </body>
1276
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cyclecad",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Browser-based parametric 3D CAD modeler with AI-powered tools, native Inventor file parsing, and smart assembly management. No install required.",
5
5
  "main": "index.html",
6
6
  "scripts": {