cyclecad 0.1.3 → 0.1.4

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/app/js/app.js CHANGED
@@ -3,7 +3,8 @@
3
3
  * Wires all modules together and manages application state
4
4
  */
5
5
 
6
- import { initViewport, setView, addToScene, removeFromScene, getScene, getCamera } from './viewport.js';
6
+ import { initViewport, setView, addToScene, removeFromScene, getScene, getCamera, toggleGrid as vpToggleGrid, toggleWireframe as vpToggleWireframe, fitToObject } from './viewport.js';
7
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
7
8
  import { startSketch, endSketch, setTool, getEntities } from './sketch.js';
8
9
  import { extrudeProfile, createPrimitive, rebuildFeature } from './operations.js';
9
10
  import { initChat, parseCADPrompt } from './ai-chat.js';
@@ -645,8 +646,36 @@ function redo() {
645
646
  function restoreFromHistory() {
646
647
  const state = APP.history[APP.historyIndex];
647
648
  if (state) {
648
- // TODO: Implement full state restoration
649
- console.log('Restore from history:', state);
649
+ // Clear current scene
650
+ APP.features.forEach((f) => {
651
+ if (f.mesh) removeFromScene(f.mesh);
652
+ });
653
+
654
+ // Restore features from history state
655
+ APP.features = [];
656
+ if (state.features && Array.isArray(state.features)) {
657
+ state.features.forEach((featureData) => {
658
+ try {
659
+ const primitive = createPrimitive(featureData.type, featureData.params);
660
+ addToScene(primitive.mesh);
661
+
662
+ const feature = {
663
+ id: featureData.id,
664
+ name: featureData.name,
665
+ type: featureData.type,
666
+ mesh: primitive.mesh,
667
+ params: featureData.params,
668
+ };
669
+
670
+ APP.features.push(feature);
671
+ addFeature(feature);
672
+ } catch (err) {
673
+ console.warn(`Failed to restore feature ${featureData.name}:`, err);
674
+ }
675
+ });
676
+ }
677
+
678
+ updateStatusBar(`Restored state from history (${APP.history.length - APP.historyIndex} steps remaining)`);
650
679
  }
651
680
  }
652
681
 
@@ -683,24 +712,65 @@ function pushHistory() {
683
712
  * Toggle grid visibility
684
713
  */
685
714
  function toggleGrid() {
686
- // TODO: Implement grid toggle in viewport
687
- console.log('Toggle grid');
715
+ const btn = document.getElementById('btn-grid');
716
+ const isCurrentlyVisible = btn ? !btn.classList.contains('active') : true;
717
+
718
+ // Call viewport function
719
+ vpToggleGrid(!isCurrentlyVisible);
720
+
721
+ // Update button state
722
+ if (btn) {
723
+ btn.classList.toggle('active');
724
+ }
725
+
726
+ updateStatusBar(isCurrentlyVisible ? 'Grid hidden' : 'Grid visible');
688
727
  }
689
728
 
690
729
  /**
691
730
  * Toggle wireframe mode
692
731
  */
693
732
  function toggleWireframe() {
694
- // TODO: Implement wireframe toggle in viewport
695
- console.log('Toggle wireframe');
733
+ const btn = document.getElementById('btn-wireframe');
734
+ const isCurrentlyWireframe = btn ? btn.classList.contains('active') : false;
735
+
736
+ // Call viewport function to toggle wireframe on all meshes
737
+ vpToggleWireframe(!isCurrentlyWireframe);
738
+
739
+ // Update button state
740
+ if (btn) {
741
+ btn.classList.toggle('active');
742
+ }
743
+
744
+ updateStatusBar(isCurrentlyWireframe ? 'Solid shading' : 'Wireframe mode');
696
745
  }
697
746
 
698
747
  /**
699
748
  * Fit all features in view
700
749
  */
701
750
  function fitAll() {
702
- // TODO: Implement fit-all camera animation
703
- console.log('Fit all');
751
+ if (APP.features.length === 0) {
752
+ updateStatusBar('Nothing to fit');
753
+ return;
754
+ }
755
+
756
+ // Create a temporary group of all features to fit camera
757
+ const group = new THREE.Group();
758
+ APP.features.forEach((f) => {
759
+ if (f.mesh) {
760
+ group.add(f.mesh);
761
+ }
762
+ });
763
+
764
+ // Create a bounding box to check if there's anything to show
765
+ const box = new THREE.Box3().setFromObject(group);
766
+ if (box.isEmpty()) {
767
+ updateStatusBar('No visible features to fit');
768
+ return;
769
+ }
770
+
771
+ // Fit camera to all features with padding
772
+ fitToObject(group, 1.3);
773
+ updateStatusBar('Fit all features');
704
774
  }
705
775
 
706
776
  /**
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Assembly Resolver — ES Module for cycleCAD
3
+ * Resolves Autodesk Inventor .iam (assembly) files into hierarchical trees
4
+ * Extracts component references, builds BOMs, categorizes parts
5
+ * Reuses OLE2 parsing pattern from inventor-parser.js
6
+ */
7
+
8
+ // ============================================================================
9
+ // OLE2 PARSER (reused pattern)
10
+ // ============================================================================
11
+
12
+ const OLE2_SIGNATURE = 0xD0CF11E0;
13
+ const SECTOR_SIZE = 512;
14
+ const DIR_ENTRY_SIZE = 128;
15
+
16
+ function parseOLE2(buffer) {
17
+ const view = new DataView(buffer.buffer || buffer);
18
+ const bufArr = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
19
+
20
+ const sig1 = view.getUint32(0, false);
21
+ if (sig1 !== 0xD0CF11E0) {
22
+ throw new Error('Invalid OLE2 signature');
23
+ }
24
+
25
+ const sectorSizePow = view.getUint16(30, true);
26
+ const sectorSize = 1 << sectorSizePow;
27
+ const totalFATSectors = view.getUint32(44, true);
28
+ const dirStartSector = view.getInt32(48, true);
29
+
30
+ const fat = [];
31
+ for (let i = 0; i < Math.min(totalFATSectors, 109); i++) {
32
+ const fatSectorID = view.getInt32(76 + i * 4, true);
33
+ if (fatSectorID < 0) break;
34
+ const offset = (fatSectorID + 1) * sectorSize;
35
+ if (offset + sectorSize > bufArr.length) break;
36
+ for (let j = 0; j < sectorSize / 4; j++) {
37
+ fat.push(view.getInt32(offset + j * 4, true));
38
+ }
39
+ }
40
+
41
+ function readChain(startSector, maxSize = Infinity) {
42
+ const chunks = [];
43
+ let sector = startSector;
44
+ let totalRead = 0;
45
+ let safety = 0;
46
+ while (sector >= 0 && sector < fat.length && safety < 50000 && totalRead < maxSize) {
47
+ const offset = (sector + 1) * sectorSize;
48
+ if (offset + sectorSize > bufArr.length) break;
49
+ chunks.push(bufArr.slice(offset, offset + sectorSize));
50
+ totalRead += sectorSize;
51
+ sector = fat[sector];
52
+ safety++;
53
+ }
54
+ const result = new Uint8Array(totalRead);
55
+ let pos = 0;
56
+ for (const c of chunks) { result.set(c, pos); pos += c.length; }
57
+ return result;
58
+ }
59
+
60
+ const dirData = readChain(dirStartSector, 200 * sectorSize);
61
+ const entries = [];
62
+ for (let i = 0; i < dirData.length; i += 128) {
63
+ const nameLen = dirData[i + 64] | (dirData[i + 65] << 8);
64
+ let name = '';
65
+ for (let c = 0; c < Math.min(nameLen, 64) - 2; c += 2) {
66
+ const ch = dirData[i + c] | (dirData[i + c + 1] << 8);
67
+ if (ch === 0) break;
68
+ name += String.fromCharCode(ch);
69
+ }
70
+ const type = dirData[i + 66];
71
+ const startSector = dirData[i + 116] | (dirData[i + 117] << 8) | (dirData[i + 118] << 16) | (dirData[i + 119] << 24);
72
+ const size = dirData[i + 120] | (dirData[i + 121] << 8) | (dirData[i + 122] << 16) | (dirData[i + 123] << 24);
73
+ if (type > 0 && name) entries.push({ name, type, startSector, size });
74
+ }
75
+
76
+ return {
77
+ sectorSize,
78
+ entries,
79
+ fat,
80
+ getStream(name) {
81
+ const entry = entries.find(e => e.name === name && e.type === 2);
82
+ if (!entry || entry.size <= 0) return null;
83
+ const raw = readChain(entry.startSector, entry.size + sectorSize);
84
+ return raw.slice(0, Math.min(raw.length, entry.size));
85
+ },
86
+ getAllStreams() {
87
+ return entries.filter(e => e.type === 2 && e.size > 0);
88
+ }
89
+ };
90
+ }
91
+
92
+ // ============================================================================
93
+ // ASSEMBLY REFERENCE EXTRACTOR
94
+ // ============================================================================
95
+
96
+ function extractReferences(ole2) {
97
+ const references = [];
98
+
99
+ // Try RSeDbTransactableRoutingData stream
100
+ let data = ole2.getStream('RSeDbTransactableRoutingData');
101
+ if (!data) {
102
+ // Fallback to first available stream
103
+ const allStreams = ole2.getAllStreams();
104
+ if (allStreams.length > 0) {
105
+ data = ole2.getStream(allStreams[0].name);
106
+ }
107
+ }
108
+
109
+ if (!data) return references;
110
+
111
+ // Decode as UTF-16LE to find component references
112
+ const utf16 = new TextDecoder('utf-16le', { fatal: false }).decode(data);
113
+ const ascii = new TextDecoder('ascii', { fatal: false }).decode(data);
114
+
115
+ // Scan for .ipt and .iam file references
116
+ const patterns = [
117
+ /[\x00-\x7F]*?([^\x00\x01-\x08\x0B\x0C\x0E-\x1F\/\\]+\.ipt)/gi,
118
+ /[\x00-\x7F]*?([^\x00\x01-\x08\x0B\x0C\x0E-\x1F\/\\]+\.iam)/gi,
119
+ ];
120
+
121
+ const seen = new Set();
122
+
123
+ // UTF-16LE scan
124
+ const utf16Lines = utf16.split(/[\n\r\x00]/);
125
+ for (const line of utf16Lines) {
126
+ const matches = line.match(/([^\/\\\\]+\.(ipt|iam))/gi) || [];
127
+ for (const match of matches) {
128
+ const normalized = match.toLowerCase();
129
+ if (!seen.has(normalized)) {
130
+ seen.add(normalized);
131
+ references.push(match);
132
+ }
133
+ }
134
+ }
135
+
136
+ // ASCII scan (case insensitive)
137
+ for (const pattern of patterns) {
138
+ let match;
139
+ while ((match = pattern.exec(ascii)) !== null) {
140
+ const normalized = match[1].toLowerCase();
141
+ if (!seen.has(normalized)) {
142
+ seen.add(normalized);
143
+ references.push(match[1]);
144
+ }
145
+ }
146
+ }
147
+
148
+ return references;
149
+ }
150
+
151
+ // ============================================================================
152
+ // PATH RESOLUTION
153
+ // ============================================================================
154
+
155
+ function resolvePath(relativePath, iamLocation, projectFiles, workspace) {
156
+ // Normalize path separators
157
+ const normalized = relativePath.replace(/\\/g, '/').toLowerCase();
158
+
159
+ // Try exact match in projectFiles
160
+ for (const file of projectFiles) {
161
+ if (file.path.toLowerCase().endsWith(normalized)) {
162
+ return file;
163
+ }
164
+ if (file.name.toLowerCase() === normalized) {
165
+ return file;
166
+ }
167
+ }
168
+
169
+ // Try relative to .iam directory
170
+ const iamDir = iamLocation.substring(0, iamLocation.lastIndexOf('/'));
171
+ const resolvedRelative = (iamDir + '/' + normalized).replace(/\/+/g, '/');
172
+ for (const file of projectFiles) {
173
+ if (file.path.toLowerCase() === resolvedRelative) {
174
+ return file;
175
+ }
176
+ }
177
+
178
+ // Try workspace root
179
+ if (workspace) {
180
+ const workspaceRelative = (workspace + '/' + normalized).replace(/\/+/g, '/');
181
+ for (const file of projectFiles) {
182
+ if (file.path.toLowerCase() === workspaceRelative) {
183
+ return file;
184
+ }
185
+ }
186
+ }
187
+
188
+ // Content Center check (standard parts)
189
+ if (normalized.includes('content center') || normalized.match(/^[a-z0-9_-]+\.(ipt|iam)$/)) {
190
+ return {
191
+ path: normalized,
192
+ name: normalized.split('/').pop(),
193
+ category: 'standard',
194
+ isStandard: true
195
+ };
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ // ============================================================================
202
+ // COMPONENT CATEGORIZATION
203
+ // ============================================================================
204
+
205
+ function categorizeComponent(filePath, componentName) {
206
+ const lower = filePath.toLowerCase();
207
+
208
+ if (lower.includes('zukaufteile')) return 'vendor';
209
+ if (lower.includes('content center') || lower.includes('standard')) return 'standard';
210
+ if (lower.includes('din') || lower.includes('iso')) return 'standard';
211
+ if (lower.match(/\/din|\/iso|^din|^iso/i)) return 'standard';
212
+
213
+ // Common vendor parts
214
+ if (/igus|interroll|weg|rittal|bosch|siemens|phoenix/i.test(filePath)) {
215
+ return 'vendor';
216
+ }
217
+
218
+ return 'custom';
219
+ }
220
+
221
+ // ============================================================================
222
+ // ASSEMBLY RESOLUTION
223
+ // ============================================================================
224
+
225
+ export function resolveAssembly(iamBuffer, projectFiles, iamPath = '', workspace = '') {
226
+ try {
227
+ const ole2 = parseOLE2(iamBuffer);
228
+ const references = extractReferences(ole2);
229
+
230
+ const assemblyName = iamPath.split('/').pop()?.replace(/\.iam$/i, '') || 'Assembly';
231
+
232
+ const tree = {
233
+ name: assemblyName,
234
+ path: iamPath,
235
+ type: 'assembly',
236
+ children: [],
237
+ components: []
238
+ };
239
+
240
+ const instanceMap = {};
241
+ const resolvedParts = [];
242
+
243
+ for (const ref of references) {
244
+ const resolved = resolvePath(ref, iamPath, projectFiles, workspace);
245
+ if (!resolved) continue;
246
+
247
+ const category = categorizeComponent(resolved.path, resolved.name);
248
+ const key = resolved.path.toLowerCase();
249
+
250
+ if (instanceMap[key]) {
251
+ instanceMap[key].quantity++;
252
+ } else {
253
+ instanceMap[key] = {
254
+ name: resolved.name,
255
+ path: resolved.path,
256
+ category,
257
+ quantity: 1,
258
+ isAssembly: ref.toLowerCase().endsWith('.iam'),
259
+ isStandard: resolved.isStandard || false
260
+ };
261
+ }
262
+ }
263
+
264
+ // Build component list
265
+ for (const [key, comp] of Object.entries(instanceMap)) {
266
+ resolvedParts.push(comp);
267
+ tree.components.push(comp);
268
+ }
269
+
270
+ tree.componentCount = resolvedParts.length;
271
+ tree.totalInstances = resolvedParts.reduce((sum, p) => sum + p.quantity, 0);
272
+
273
+ return tree;
274
+ } catch (err) {
275
+ console.error('Assembly resolution error:', err);
276
+ return {
277
+ name: 'Error',
278
+ error: err.message,
279
+ children: [],
280
+ components: []
281
+ };
282
+ }
283
+ }
284
+
285
+ // ============================================================================
286
+ // BOM GENERATION
287
+ // ============================================================================
288
+
289
+ export function generateBOM(assemblyTree) {
290
+ const bom = [];
291
+ let partNumber = 1;
292
+
293
+ if (!assemblyTree.components) return bom;
294
+
295
+ // Sort: custom, standard, vendor
296
+ const categoryOrder = { custom: 0, standard: 1, vendor: 2 };
297
+ const sorted = [...assemblyTree.components].sort((a, b) => {
298
+ const catA = categoryOrder[a.category] || 3;
299
+ const catB = categoryOrder[b.category] || 3;
300
+ return catA - catB;
301
+ });
302
+
303
+ for (const comp of sorted) {
304
+ bom.push({
305
+ partNumber,
306
+ name: comp.name,
307
+ quantity: comp.quantity,
308
+ category: comp.category,
309
+ filePath: comp.path,
310
+ isAssembly: comp.isAssembly,
311
+ isStandard: comp.isStandard
312
+ });
313
+ partNumber++;
314
+ }
315
+
316
+ return bom;
317
+ }
318
+
319
+ // ============================================================================
320
+ // DOM RENDERING
321
+ // ============================================================================
322
+
323
+ export function renderAssemblyTree(container, tree) {
324
+ if (!container) return;
325
+ container.innerHTML = '';
326
+
327
+ const treeEl = document.createElement('div');
328
+ treeEl.className = 'assembly-tree';
329
+ treeEl.style.cssText = `
330
+ font-family: monospace;
331
+ font-size: 12px;
332
+ color: #333;
333
+ line-height: 1.6;
334
+ `;
335
+
336
+ function renderNode(node, depth = 0) {
337
+ const indent = depth * 20;
338
+ const div = document.createElement('div');
339
+ div.style.marginLeft = indent + 'px';
340
+
341
+ const icon = node.type === 'assembly' ? '📦' : '📄';
342
+ const title = document.createElement('div');
343
+ title.style.cssText = 'font-weight: bold; cursor: pointer; padding: 4px;';
344
+ title.textContent = `${icon} ${node.name}`;
345
+ div.appendChild(title);
346
+
347
+ if (node.children && node.children.length > 0) {
348
+ for (const child of node.children) {
349
+ div.appendChild(renderNode(child, depth + 1));
350
+ }
351
+ }
352
+
353
+ return div;
354
+ }
355
+
356
+ treeEl.appendChild(renderNode(tree));
357
+ container.appendChild(treeEl);
358
+ }
359
+
360
+ // ============================================================================
361
+ // BOM EXPORT
362
+ // ============================================================================
363
+
364
+ export function exportBOMCSV(bom) {
365
+ if (!bom || bom.length === 0) {
366
+ console.warn('BOM is empty');
367
+ return;
368
+ }
369
+
370
+ // CSV header
371
+ const headers = ['Part #', 'Name', 'Qty', 'Category', 'File Path', 'Type'];
372
+ const rows = [headers.join(',')];
373
+
374
+ for (const item of bom) {
375
+ const type = item.isAssembly ? 'Assembly' : 'Part';
376
+ const row = [
377
+ item.partNumber,
378
+ `"${item.name.replace(/"/g, '""')}"`,
379
+ item.quantity,
380
+ item.category,
381
+ `"${item.filePath.replace(/"/g, '""')}"`,
382
+ type
383
+ ];
384
+ rows.push(row.join(','));
385
+ }
386
+
387
+ const csv = rows.join('\n');
388
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
389
+ const link = document.createElement('a');
390
+ link.setAttribute('href', URL.createObjectURL(blob));
391
+ link.setAttribute('download', `BOM_${new Date().toISOString().split('T')[0]}.csv`);
392
+ link.style.visibility = 'hidden';
393
+ document.body.appendChild(link);
394
+ link.click();
395
+ document.body.removeChild(link);
396
+ }
397
+
398
+ // ============================================================================
399
+ // BATCH RESOLUTION (multiple assemblies)
400
+ // ============================================================================
401
+
402
+ export function resolveAssemblyBatch(iamBuffers, projectFiles, workspace = '') {
403
+ const results = [];
404
+ for (const [path, buffer] of Object.entries(iamBuffers)) {
405
+ const tree = resolveAssembly(buffer, projectFiles, path, workspace);
406
+ results.push({ path, tree });
407
+ }
408
+ return results;
409
+ }
410
+
411
+ // ============================================================================
412
+ // UTILITIES
413
+ // ============================================================================
414
+
415
+ export function createComponentIndex(tree) {
416
+ const index = new Map();
417
+
418
+ function traverse(node, depth = 0) {
419
+ if (node.components) {
420
+ for (const comp of node.components) {
421
+ const key = comp.path.toLowerCase();
422
+ if (!index.has(key)) {
423
+ index.set(key, {
424
+ component: comp,
425
+ usageCount: 0,
426
+ instances: []
427
+ });
428
+ }
429
+ index.get(key).usageCount += comp.quantity;
430
+ index.get(key).instances.push({ assemblyPath: node.path, quantity: comp.quantity });
431
+ }
432
+ }
433
+ if (node.children) {
434
+ for (const child of node.children) {
435
+ traverse(child, depth + 1);
436
+ }
437
+ }
438
+ }
439
+
440
+ traverse(tree);
441
+ return index;
442
+ }
443
+
444
+ export function getMaterialEstimate(bom, materialDensities = {}) {
445
+ const densities = {
446
+ aluminum: 2.7,
447
+ steel: 7.85,
448
+ stainless: 7.75,
449
+ plastic: 1.05,
450
+ ...materialDensities
451
+ };
452
+
453
+ let totalWeight = 0;
454
+ for (const item of bom) {
455
+ const material = Object.keys(densities).find(m =>
456
+ item.name.toLowerCase().includes(m)
457
+ ) || 'steel';
458
+ const density = densities[material];
459
+ totalWeight += (item.quantity || 1) * (density || 1);
460
+ }
461
+
462
+ return {
463
+ totalWeight,
464
+ estimatedMaterial: 'steel',
465
+ unit: 'kg (estimated)'
466
+ };
467
+ }
468
+
469
+ export default {
470
+ resolveAssembly,
471
+ generateBOM,
472
+ renderAssemblyTree,
473
+ exportBOMCSV,
474
+ resolveAssemblyBatch,
475
+ createComponentIndex,
476
+ getMaterialEstimate
477
+ };