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/CLAUDE.md +233 -0
- package/DUO-MANIFEST-README.md +233 -0
- package/MASTERPLAN.md +182 -0
- package/app/duo-manifest-demo.html +337 -0
- package/app/duo-manifest.json +7375 -0
- package/app/index.html +1167 -23
- package/app/js/app.js +79 -9
- package/app/js/assembly-resolver.js +477 -0
- package/app/js/operations.js +501 -112
- package/app/js/project-browser.js +741 -0
- package/app/js/project-loader.js +579 -0
- package/app/js/rebuild-guide.js +743 -0
- package/app/js/viewport.js +24 -0
- package/package.json +2 -2
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
|
-
//
|
|
649
|
-
|
|
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
|
-
|
|
687
|
-
|
|
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
|
-
|
|
695
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
+
};
|