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.
@@ -0,0 +1,579 @@
1
+ /**
2
+ * project-loader.js - ES Module for loading Inventor projects
3
+ *
4
+ * Handles parsing .ipj files, indexing project contents, and providing
5
+ * a folder-based file selection UI for Inventor project structures.
6
+ *
7
+ * Exports:
8
+ * - loadProject(directoryHandle)
9
+ * - parseIPJ(buffer)
10
+ * - indexFiles(entries)
11
+ * - getProjectStats()
12
+ * - getFileByPath(path)
13
+ * - showFolderPicker()
14
+ */
15
+
16
+ // ============================================================================
17
+ // FILE TYPE CONSTANTS
18
+ // ============================================================================
19
+
20
+ const INVENTOR_EXTENSIONS = {
21
+ IPT: '.ipt', // Part files
22
+ IAM: '.iam', // Assembly files
23
+ IPJ: '.ipj', // Project files
24
+ IDW: '.idw', // Drawing files
25
+ DWG: '.dwg', // AutoCAD drawing files
26
+ };
27
+
28
+ const FILE_CATEGORIES = {
29
+ PART: 'part',
30
+ ASSEMBLY: 'assembly',
31
+ PROJECT: 'project',
32
+ DRAWING: 'drawing',
33
+ OTHER: 'other',
34
+ };
35
+
36
+ const PART_CLASSIFICATIONS = {
37
+ CUSTOM: 'custom',
38
+ STANDARD: 'standard',
39
+ BUYOUT: 'buyout',
40
+ };
41
+
42
+ // ============================================================================
43
+ // PROJECT STATE
44
+ // ============================================================================
45
+
46
+ let projectState = {
47
+ name: '',
48
+ ipj: null,
49
+ files: new Map(),
50
+ tree: null,
51
+ stats: {
52
+ parts: 0,
53
+ assemblies: 0,
54
+ drawings: 0,
55
+ total: 0,
56
+ byClassification: {
57
+ custom: 0,
58
+ standard: 0,
59
+ buyout: 0,
60
+ },
61
+ },
62
+ };
63
+
64
+ // ============================================================================
65
+ // UTF-16 DECODING FOR IPJ FILES
66
+ // ============================================================================
67
+
68
+ /**
69
+ * Decode UTF-16 little-endian buffer to string
70
+ * @param {ArrayBuffer} buffer
71
+ * @returns {string}
72
+ */
73
+ function decodeUTF16LE(buffer) {
74
+ const view = new Uint16Array(buffer);
75
+ let str = '';
76
+ for (let i = 0; i < view.length; i++) {
77
+ str += String.fromCharCode(view[i]);
78
+ }
79
+ return str;
80
+ }
81
+
82
+ // ============================================================================
83
+ // IPJ FILE PARSING
84
+ // ============================================================================
85
+
86
+ /**
87
+ * Parse an Inventor Project (.ipj) file
88
+ * IPJ files are UTF-16 XML documents containing project configuration
89
+ *
90
+ * @param {ArrayBuffer} buffer - Raw file buffer
91
+ * @returns {Object} Parsed project info { workspace, contentCenter, options }
92
+ */
93
+ export function parseIPJ(buffer) {
94
+ try {
95
+ const xmlString = decodeUTF16LE(buffer);
96
+
97
+ // Extract workspace path from <Workspace>.\Workspaces\...\</Workspace>
98
+ const workspaceMatch = xmlString.match(/<Workspace>([^<]+)<\/Workspace>/i);
99
+ const workspace = workspaceMatch ? workspaceMatch[1].trim() : null;
100
+
101
+ // Extract Content Center path
102
+ const ccMatch = xmlString.match(/<ContentCenterPath>([^<]+)<\/ContentCenterPath>/i);
103
+ const contentCenter = ccMatch ? ccMatch[1].trim() : null;
104
+
105
+ // Extract project options
106
+ const options = {};
107
+ const oldVersionsMatch = xmlString.match(/<OldVersionsToKeep>(\d+)<\/OldVersionsToKeep>/i);
108
+ if (oldVersionsMatch) {
109
+ options.oldVersionsToKeep = parseInt(oldVersionsMatch[1], 10);
110
+ }
111
+
112
+ const uniqueFilenamesMatch = xmlString.match(/<UsingUniqueFilenames>(true|false)<\/UsingUniqueFilenames>/i);
113
+ if (uniqueFilenamesMatch) {
114
+ options.usingUniqueFilenames = uniqueFilenamesMatch[1].toLowerCase() === 'true';
115
+ }
116
+
117
+ return {
118
+ workspace,
119
+ contentCenter,
120
+ options,
121
+ };
122
+ } catch (error) {
123
+ console.error('Error parsing IPJ file:', error);
124
+ return { workspace: null, contentCenter: null, options: {} };
125
+ }
126
+ }
127
+
128
+ // ============================================================================
129
+ // FILE CLASSIFICATION
130
+ // ============================================================================
131
+
132
+ /**
133
+ * Classify a part file based on its path location
134
+ * @param {string} path - File path
135
+ * @returns {string} Classification: 'custom', 'standard', or 'buyout'
136
+ */
137
+ function classifyPart(path) {
138
+ const lowerPath = path.toLowerCase();
139
+
140
+ if (lowerPath.includes('content center') || lowerPath.includes('libraries')) {
141
+ return PART_CLASSIFICATIONS.STANDARD;
142
+ }
143
+ if (lowerPath.includes('zukaufteile') || lowerPath.includes('buyout')) {
144
+ return PART_CLASSIFICATIONS.BUYOUT;
145
+ }
146
+ return PART_CLASSIFICATIONS.CUSTOM;
147
+ }
148
+
149
+ /**
150
+ * Get file extension and categorize file type
151
+ * @param {string} filename
152
+ * @returns {Object} { ext, category }
153
+ */
154
+ function categorizeFile(filename) {
155
+ const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase();
156
+
157
+ let category = FILE_CATEGORIES.OTHER;
158
+ if (ext === INVENTOR_EXTENSIONS.IPT) category = FILE_CATEGORIES.PART;
159
+ else if (ext === INVENTOR_EXTENSIONS.IAM) category = FILE_CATEGORIES.ASSEMBLY;
160
+ else if (ext === INVENTOR_EXTENSIONS.IPJ) category = FILE_CATEGORIES.PROJECT;
161
+ else if (ext === INVENTOR_EXTENSIONS.IDW || ext === INVENTOR_EXTENSIONS.DWG) {
162
+ category = FILE_CATEGORIES.DRAWING;
163
+ }
164
+
165
+ return { ext, category };
166
+ }
167
+
168
+ // ============================================================================
169
+ // FILE TREE BUILDING
170
+ // ============================================================================
171
+
172
+ /**
173
+ * Build a hierarchical tree from flat file entries
174
+ * @param {Map} filesMap - Map of path to file metadata
175
+ * @returns {Object} Tree root node
176
+ */
177
+ function buildFileTree(filesMap) {
178
+ const root = {
179
+ name: 'Project Root',
180
+ type: 'folder',
181
+ path: '',
182
+ children: [],
183
+ };
184
+
185
+ const nodeMap = new Map();
186
+ nodeMap.set('', root);
187
+
188
+ // Sort paths for consistent ordering
189
+ const sortedPaths = Array.from(filesMap.keys()).sort();
190
+
191
+ for (const path of sortedPaths) {
192
+ const file = filesMap.get(path);
193
+ const parts = path.split('/').filter(Boolean);
194
+
195
+ let currentPath = '';
196
+ let currentParent = root;
197
+
198
+ // Navigate/create folder hierarchy
199
+ for (let i = 0; i < parts.length - 1; i++) {
200
+ const folderName = parts[i];
201
+ currentPath = (currentPath ? currentPath + '/' : '') + folderName;
202
+
203
+ if (!nodeMap.has(currentPath)) {
204
+ const folderNode = {
205
+ name: folderName,
206
+ type: 'folder',
207
+ path: currentPath,
208
+ children: [],
209
+ };
210
+ nodeMap.set(currentPath, folderNode);
211
+ currentParent.children.push(folderNode);
212
+ }
213
+ currentParent = nodeMap.get(currentPath);
214
+ }
215
+
216
+ // Add file node
217
+ const fileNode = {
218
+ name: file.name,
219
+ type: file.category,
220
+ path: path,
221
+ size: file.size,
222
+ classification: file.classification,
223
+ ext: file.ext,
224
+ };
225
+ currentParent.children.push(fileNode);
226
+ }
227
+
228
+ return root;
229
+ }
230
+
231
+ // ============================================================================
232
+ // FILE INDEXING
233
+ // ============================================================================
234
+
235
+ /**
236
+ * Index all files from FileSystemEntry objects (from drop/picker)
237
+ * Recursively traverses directory structure and builds file index
238
+ *
239
+ * @param {FileSystemEntry[]|DataTransferItemList} entries
240
+ * @returns {Promise<Map>} Map of path -> { name, ext, size, type, category, buffer, classification }
241
+ */
242
+ export async function indexFiles(entries) {
243
+ const filesMap = new Map();
244
+ const stack = [];
245
+
246
+ // Initialize with top-level entries
247
+ for (let i = 0; i < entries.length; i++) {
248
+ const entry = entries[i];
249
+ if (entry instanceof DataTransferItem) {
250
+ stack.push(entry.webkitGetAsEntry());
251
+ } else if (entry.isDirectory) {
252
+ stack.push(entry);
253
+ }
254
+ }
255
+
256
+ // Depth-first traversal
257
+ while (stack.length > 0) {
258
+ const entry = stack.pop();
259
+
260
+ if (entry.isDirectory) {
261
+ try {
262
+ const reader = entry.createReader();
263
+ const fileEntries = await new Promise((resolve, reject) => {
264
+ reader.readEntries(resolve, reject);
265
+ });
266
+
267
+ for (const fileEntry of fileEntries) {
268
+ stack.push(fileEntry);
269
+ }
270
+ } catch (error) {
271
+ console.warn(`Failed to read directory ${entry.fullPath}:`, error);
272
+ }
273
+ } else if (entry.isFile) {
274
+ try {
275
+ const file = await new Promise((resolve, reject) => {
276
+ entry.file(resolve, reject);
277
+ });
278
+
279
+ const { ext, category } = categorizeFile(file.name);
280
+ const classification = category === FILE_CATEGORIES.PART
281
+ ? classifyPart(entry.fullPath)
282
+ : null;
283
+
284
+ const buffer = await file.arrayBuffer();
285
+
286
+ filesMap.set(entry.fullPath, {
287
+ name: file.name,
288
+ ext,
289
+ size: file.size,
290
+ category,
291
+ classification,
292
+ buffer,
293
+ mimeType: file.type,
294
+ });
295
+ } catch (error) {
296
+ console.warn(`Failed to read file ${entry.fullPath}:`, error);
297
+ }
298
+ }
299
+ }
300
+
301
+ return filesMap;
302
+ }
303
+
304
+ /**
305
+ * Load an entire Inventor project from a directory handle
306
+ * @param {FileSystemDirectoryHandle} directoryHandle
307
+ * @returns {Promise<Object>} Project object with ipj, files, tree, stats
308
+ */
309
+ export async function loadProject(directoryHandle) {
310
+ projectState.name = directoryHandle.name;
311
+ projectState.files.clear();
312
+ projectState.stats = {
313
+ parts: 0,
314
+ assemblies: 0,
315
+ drawings: 0,
316
+ total: 0,
317
+ byClassification: { custom: 0, standard: 0, buyout: 0 },
318
+ };
319
+
320
+ // Recursively read all files
321
+ for await (const entry of directoryHandle.values()) {
322
+ await traverseDirectory(entry, '');
323
+ }
324
+
325
+ // Parse .ipj file if present
326
+ const ipjEntry = Array.from(projectState.files.values()).find(
327
+ (f) => f.ext === INVENTOR_EXTENSIONS.IPJ
328
+ );
329
+
330
+ if (ipjEntry && ipjEntry.buffer) {
331
+ projectState.ipj = parseIPJ(ipjEntry.buffer);
332
+ }
333
+
334
+ // Build file tree
335
+ projectState.tree = buildFileTree(projectState.files);
336
+
337
+ // Calculate stats
338
+ updateStats();
339
+
340
+ return {
341
+ name: projectState.name,
342
+ ipj: projectState.ipj,
343
+ files: projectState.files,
344
+ tree: projectState.tree,
345
+ stats: projectState.stats,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Recursively traverse directory structure
351
+ * @private
352
+ */
353
+ async function traverseDirectory(entry, basePath) {
354
+ if (entry.kind === 'directory') {
355
+ const nextPath = basePath ? `${basePath}/${entry.name}` : entry.name;
356
+
357
+ for await (const subEntry of entry.values()) {
358
+ await traverseDirectory(subEntry, nextPath);
359
+ }
360
+ } else if (entry.kind === 'file') {
361
+ const file = await entry.getFile();
362
+ const filePath = basePath ? `${basePath}/${file.name}` : file.name;
363
+
364
+ const { ext, category } = categorizeFile(file.name);
365
+ const classification = category === FILE_CATEGORIES.PART
366
+ ? classifyPart(filePath)
367
+ : null;
368
+
369
+ const buffer = await file.arrayBuffer();
370
+
371
+ projectState.files.set(filePath, {
372
+ name: file.name,
373
+ ext,
374
+ size: file.size,
375
+ category,
376
+ classification,
377
+ buffer,
378
+ mimeType: file.type,
379
+ });
380
+ }
381
+ }
382
+
383
+ // ============================================================================
384
+ // PROJECT STATISTICS
385
+ // ============================================================================
386
+
387
+ /**
388
+ * Update project statistics from current file index
389
+ * @private
390
+ */
391
+ function updateStats() {
392
+ projectState.stats = {
393
+ parts: 0,
394
+ assemblies: 0,
395
+ drawings: 0,
396
+ total: 0,
397
+ byClassification: { custom: 0, standard: 0, buyout: 0 },
398
+ };
399
+
400
+ for (const file of projectState.files.values()) {
401
+ projectState.stats.total++;
402
+
403
+ if (file.category === FILE_CATEGORIES.PART) {
404
+ projectState.stats.parts++;
405
+ if (file.classification) {
406
+ projectState.stats.byClassification[file.classification]++;
407
+ }
408
+ } else if (file.category === FILE_CATEGORIES.ASSEMBLY) {
409
+ projectState.stats.assemblies++;
410
+ } else if (file.category === FILE_CATEGORIES.DRAWING) {
411
+ projectState.stats.drawings++;
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Get current project statistics
418
+ * @returns {Object}
419
+ */
420
+ export function getProjectStats() {
421
+ return {
422
+ ...projectState.stats,
423
+ name: projectState.name,
424
+ };
425
+ }
426
+
427
+ // ============================================================================
428
+ // FILE RETRIEVAL
429
+ // ============================================================================
430
+
431
+ /**
432
+ * Retrieve a file's ArrayBuffer by path
433
+ * @param {string} path
434
+ * @returns {ArrayBuffer|null}
435
+ */
436
+ export function getFileByPath(path) {
437
+ const file = projectState.files.get(path);
438
+ return file ? file.buffer : null;
439
+ }
440
+
441
+ /**
442
+ * Get all files of a specific category
443
+ * @param {string} category - FILE_CATEGORIES.PART, etc.
444
+ * @returns {Array<{path, name, size, classification}>}
445
+ */
446
+ export function getFilesByCategory(category) {
447
+ return Array.from(projectState.files.entries())
448
+ .filter(([, file]) => file.category === category)
449
+ .map(([path, file]) => ({
450
+ path,
451
+ name: file.name,
452
+ size: file.size,
453
+ classification: file.classification,
454
+ }));
455
+ }
456
+
457
+ // ============================================================================
458
+ // FOLDER PICKER UI
459
+ // ============================================================================
460
+
461
+ /**
462
+ * Show folder picker dialog and load project
463
+ * Uses File System Access API with fallback to webkitdirectory input
464
+ *
465
+ * @returns {Promise<Object>} Loaded project object or null if cancelled
466
+ */
467
+ export async function showFolderPicker() {
468
+ try {
469
+ // Try modern File System Access API
470
+ if ('showDirectoryPicker' in window) {
471
+ const directoryHandle = await window.showDirectoryPicker();
472
+ return await loadProject(directoryHandle);
473
+ }
474
+
475
+ // Fallback to webkitdirectory input
476
+ const input = document.createElement('input');
477
+ input.type = 'file';
478
+ input.webkitdirectory = true;
479
+ input.multiple = true;
480
+
481
+ return new Promise((resolve) => {
482
+ input.addEventListener('change', async () => {
483
+ if (input.files.length === 0) {
484
+ resolve(null);
485
+ return;
486
+ }
487
+
488
+ const filesMap = new Map();
489
+ const commonRoot = findCommonRoot(input.files);
490
+
491
+ for (const file of input.files) {
492
+ const path = file.webkitRelativePath.replace(commonRoot, '').replace(/^\//, '');
493
+
494
+ if (!path) continue;
495
+
496
+ const { ext, category } = categorizeFile(file.name);
497
+ const classification = category === FILE_CATEGORIES.PART
498
+ ? classifyPart(path)
499
+ : null;
500
+
501
+ const buffer = await file.arrayBuffer();
502
+
503
+ filesMap.set(path, {
504
+ name: file.name,
505
+ ext,
506
+ size: file.size,
507
+ category,
508
+ classification,
509
+ buffer,
510
+ mimeType: file.type,
511
+ });
512
+ }
513
+
514
+ projectState.files = filesMap;
515
+ projectState.name = commonRoot.split('/').filter(Boolean)[0] || 'Project';
516
+ projectState.tree = buildFileTree(filesMap);
517
+ updateStats();
518
+
519
+ resolve({
520
+ name: projectState.name,
521
+ ipj: projectState.ipj,
522
+ files: projectState.files,
523
+ tree: projectState.tree,
524
+ stats: projectState.stats,
525
+ });
526
+ });
527
+
528
+ input.click();
529
+ });
530
+ } catch (error) {
531
+ if (error.name === 'AbortError') {
532
+ console.log('Folder picker cancelled by user');
533
+ return null;
534
+ }
535
+ console.error('Error showing folder picker:', error);
536
+ return null;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Find common root directory from webkitRelativePath entries
542
+ * @private
543
+ */
544
+ function findCommonRoot(files) {
545
+ if (files.length === 0) return '';
546
+
547
+ const paths = Array.from(files).map((f) => f.webkitRelativePath);
548
+ const firstPath = paths[0].split('/');
549
+
550
+ let commonDepth = 0;
551
+ for (let i = 0; i < firstPath.length; i++) {
552
+ if (paths.every((p) => p.split('/')[i] === firstPath[i])) {
553
+ commonDepth = i + 1;
554
+ } else {
555
+ break;
556
+ }
557
+ }
558
+
559
+ return firstPath.slice(0, commonDepth).join('/');
560
+ }
561
+
562
+ // ============================================================================
563
+ // EXPORT SUMMARY
564
+ // ============================================================================
565
+
566
+ /**
567
+ * Get current project state (mostly for debugging)
568
+ * @private
569
+ * @returns {Object}
570
+ */
571
+ export function getProjectState() {
572
+ return {
573
+ name: projectState.name,
574
+ ipj: projectState.ipj,
575
+ fileCount: projectState.files.size,
576
+ stats: projectState.stats,
577
+ tree: projectState.tree,
578
+ };
579
+ }