cyclecad 0.1.4 → 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.
@@ -0,0 +1,1173 @@
1
+ /**
2
+ * DXF Export Module for cycleCAD
3
+ *
4
+ * Exports 2D sketches and 3D model projections to AutoCAD-compatible DXF files.
5
+ * Supports ASCII DXF R12/R14 format for maximum compatibility.
6
+ *
7
+ * Usage:
8
+ * const dxfContent = exportSketchToDXF(sketchEntities, { units: 'mm', filename: 'part.dxf' });
9
+ * downloadDXF(dxfContent, 'part.dxf');
10
+ *
11
+ * const dxfContent = exportProjectionToDXF(mesh, 'front', { hiddenLines: true });
12
+ * downloadDXF(dxfContent, 'projection.dxf');
13
+ */
14
+
15
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
16
+
17
+ // ============================================================================
18
+ // DXF Constants & Utilities
19
+ // ============================================================================
20
+
21
+ const DXF_HEADER = {
22
+ VERSION: 'AC1012', // AutoCAD R13 (most compatible)
23
+ INSUNITS: 4, // 4 = millimeters
24
+ EXTMIN: { x: 0, y: 0, z: 0 },
25
+ EXTMAX: { x: 100, y: 100, z: 0 }
26
+ };
27
+
28
+ const DXF_LAYERS = {
29
+ OUTLINE: { name: 'OUTLINE', color: 7, linetype: 'CONTINUOUS' }, // White
30
+ HIDDEN: { name: 'HIDDEN', color: 2, linetype: 'HIDDEN' }, // Yellow/dashed
31
+ CENTER: { name: 'CENTER', color: 1, linetype: 'CENTER' }, // Red/dash-dot
32
+ DIMENSION: { name: 'DIMENSION', color: 3, linetype: 'CONTINUOUS' }, // Green
33
+ BORDER: { name: 'BORDER', color: 7, linetype: 'CONTINUOUS' }, // White
34
+ MODEL: { name: '0', color: 7, linetype: 'CONTINUOUS' } // Default layer
35
+ };
36
+
37
+ const DXF_COLORS = {
38
+ WHITE: 7,
39
+ YELLOW: 2,
40
+ RED: 1,
41
+ GREEN: 3,
42
+ BLUE: 5,
43
+ MAGENTA: 6,
44
+ CYAN: 4
45
+ };
46
+
47
+ // ============================================================================
48
+ // DXF Section Builders
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Build DXF HEADER section
53
+ * @param {Object} extents - { min: {x,y,z}, max: {x,y,z} }
54
+ * @returns {string} DXF header text
55
+ */
56
+ function buildHeaderSection(extents) {
57
+ const lines = [];
58
+ lines.push(' 0');
59
+ lines.push('SECTION');
60
+ lines.push(' 2');
61
+ lines.push('HEADER');
62
+
63
+ // DXF version
64
+ lines.push(' 9');
65
+ lines.push('$ACADVER');
66
+ lines.push(' 1');
67
+ lines.push('AC1014'); // R14 format
68
+
69
+ // Drawing extents
70
+ lines.push(' 9');
71
+ lines.push('$EXTMIN');
72
+ lines.push(' 10');
73
+ lines.push(formatNumber(extents.min.x));
74
+ lines.push(' 20');
75
+ lines.push(formatNumber(extents.min.y));
76
+
77
+ lines.push(' 9');
78
+ lines.push('$EXTMAX');
79
+ lines.push(' 10');
80
+ lines.push(formatNumber(extents.max.x));
81
+ lines.push(' 20');
82
+ lines.push(formatNumber(extents.max.y));
83
+
84
+ // Units in millimeters
85
+ lines.push(' 9');
86
+ lines.push('$INSUNITS');
87
+ lines.push(' 70');
88
+ lines.push('4');
89
+
90
+ // Default text height
91
+ lines.push(' 9');
92
+ lines.push('$TEXTSIZE');
93
+ lines.push(' 40');
94
+ lines.push('2.5');
95
+
96
+ lines.push(' 0');
97
+ lines.push('ENDSEC');
98
+
99
+ return lines.join('\n');
100
+ }
101
+
102
+ /**
103
+ * Build DXF TABLES section (layers, line types, text styles)
104
+ * @returns {string} DXF tables text
105
+ */
106
+ function buildTablesSection() {
107
+ const lines = [];
108
+ lines.push(' 0');
109
+ lines.push('SECTION');
110
+ lines.push(' 2');
111
+ lines.push('TABLES');
112
+
113
+ // VPORT table (required)
114
+ lines.push(' 0');
115
+ lines.push('TABLE');
116
+ lines.push(' 2');
117
+ lines.push('VPORT');
118
+ lines.push(' 70');
119
+ lines.push('1');
120
+ lines.push(' 0');
121
+ lines.push('VPORT');
122
+ lines.push(' 2');
123
+ lines.push('*ACTIVE');
124
+ lines.push(' 70');
125
+ lines.push('0');
126
+ lines.push(' 10');
127
+ lines.push('0.0');
128
+ lines.push(' 20');
129
+ lines.push('0.0');
130
+ lines.push(' 11');
131
+ lines.push('1.0');
132
+ lines.push(' 21');
133
+ lines.push('1.0');
134
+ lines.push(' 0');
135
+ lines.push('ENDTAB');
136
+
137
+ // LTYPE table (line types)
138
+ lines.push(' 0');
139
+ lines.push('TABLE');
140
+ lines.push(' 2');
141
+ lines.push('LTYPE');
142
+ lines.push(' 70');
143
+ lines.push('4');
144
+
145
+ // CONTINUOUS
146
+ lines.push(' 0');
147
+ lines.push('LTYPE');
148
+ lines.push(' 2');
149
+ lines.push('CONTINUOUS');
150
+ lines.push(' 70');
151
+ lines.push('0');
152
+ lines.push(' 3');
153
+ lines.push('Solid line');
154
+ lines.push(' 72');
155
+ lines.push('0');
156
+ lines.push(' 73');
157
+ lines.push('0');
158
+ lines.push(' 40');
159
+ lines.push('0.0');
160
+
161
+ // HIDDEN (dashed)
162
+ lines.push(' 0');
163
+ lines.push('LTYPE');
164
+ lines.push(' 2');
165
+ lines.push('HIDDEN');
166
+ lines.push(' 70');
167
+ lines.push('0');
168
+ lines.push(' 3');
169
+ lines.push('Hidden line');
170
+ lines.push(' 72');
171
+ lines.push('1');
172
+ lines.push(' 73');
173
+ lines.push('1');
174
+ lines.push(' 40');
175
+ lines.push('9.525');
176
+ lines.push(' 49');
177
+ lines.push('4.7625');
178
+ lines.push(' 49');
179
+ lines.push('-4.7625');
180
+
181
+ // CENTER (dash-dot)
182
+ lines.push(' 0');
183
+ lines.push('LTYPE');
184
+ lines.push(' 2');
185
+ lines.push('CENTER');
186
+ lines.push(' 70');
187
+ lines.push('0');
188
+ lines.push(' 3');
189
+ lines.push('Center line');
190
+ lines.push(' 72');
191
+ lines.push('1');
192
+ lines.push(' 73');
193
+ lines.push('2');
194
+ lines.push(' 40');
195
+ lines.push('20.0');
196
+ lines.push(' 49');
197
+ lines.push('12.5');
198
+ lines.push(' 49');
199
+ lines.push('-2.5');
200
+ lines.push(' 49');
201
+ lines.push('2.5');
202
+ lines.push(' 49');
203
+ lines.push('-2.5');
204
+
205
+ lines.push(' 0');
206
+ lines.push('ENDTAB');
207
+
208
+ // LAYER table
209
+ lines.push(' 0');
210
+ lines.push('TABLE');
211
+ lines.push(' 2');
212
+ lines.push('LAYER');
213
+ lines.push(' 70');
214
+ lines.push('6');
215
+
216
+ Object.values(DXF_LAYERS).forEach(layer => {
217
+ lines.push(' 0');
218
+ lines.push('LAYER');
219
+ lines.push(' 2');
220
+ lines.push(layer.name);
221
+ lines.push(' 70');
222
+ lines.push('0');
223
+ lines.push(' 62');
224
+ lines.push(layer.color.toString());
225
+ lines.push(' 6');
226
+ lines.push(layer.linetype);
227
+ });
228
+
229
+ lines.push(' 0');
230
+ lines.push('ENDTAB');
231
+
232
+ // STYLE table
233
+ lines.push(' 0');
234
+ lines.push('TABLE');
235
+ lines.push(' 2');
236
+ lines.push('STYLE');
237
+ lines.push(' 70');
238
+ lines.push('1');
239
+ lines.push(' 0');
240
+ lines.push('STYLE');
241
+ lines.push(' 2');
242
+ lines.push('STANDARD');
243
+ lines.push(' 70');
244
+ lines.push('0');
245
+ lines.push(' 40');
246
+ lines.push('0.0');
247
+ lines.push(' 41');
248
+ lines.push('1.0');
249
+ lines.push(' 50');
250
+ lines.push('0.0');
251
+ lines.push(' 71');
252
+ lines.push('0');
253
+ lines.push(' 3');
254
+ lines.push('txt');
255
+ lines.push(' 0');
256
+ lines.push('ENDTAB');
257
+
258
+ lines.push(' 0');
259
+ lines.push('ENDSEC');
260
+
261
+ return lines.join('\n');
262
+ }
263
+
264
+ /**
265
+ * Build DXF BLOCKS section
266
+ * @returns {string} DXF blocks text
267
+ */
268
+ function buildBlocksSection() {
269
+ const lines = [];
270
+ lines.push(' 0');
271
+ lines.push('SECTION');
272
+ lines.push(' 2');
273
+ lines.push('BLOCKS');
274
+
275
+ // Default block
276
+ lines.push(' 0');
277
+ lines.push('BLOCK');
278
+ lines.push(' 8');
279
+ lines.push('0');
280
+ lines.push(' 2');
281
+ lines.push('*MODEL_SPACE');
282
+ lines.push(' 70');
283
+ lines.push('0');
284
+ lines.push(' 10');
285
+ lines.push('0.0');
286
+ lines.push(' 20');
287
+ lines.push('0.0');
288
+ lines.push(' 0');
289
+ lines.push('ENDBLK');
290
+
291
+ lines.push(' 0');
292
+ lines.push('ENDSEC');
293
+
294
+ return lines.join('\n');
295
+ }
296
+
297
+ // ============================================================================
298
+ // Core Export Functions
299
+ // ============================================================================
300
+
301
+ /**
302
+ * Export 2D sketch entities to DXF format
303
+ *
304
+ * @param {Array<Object>} entities - Sketch entities
305
+ * Each entity: { type: 'line'|'rectangle'|'circle'|'arc'|'polyline', points: [{x,y}...], dimensions: {} }
306
+ * @param {Object} options - Export options
307
+ * @param {string} options.units - Unit system ('mm', 'in', 'cm') - default 'mm'
308
+ * @param {boolean} options.layers - Create separate layers - default true
309
+ * @param {boolean} options.dimensions - Export dimension annotations - default true
310
+ * @param {string} options.filename - Output filename - default 'sketch.dxf'
311
+ * @returns {string} DXF file content (ASCII)
312
+ */
313
+ export function exportSketchToDXF(entities, options = {}) {
314
+ const opts = {
315
+ units: 'mm',
316
+ layers: true,
317
+ dimensions: true,
318
+ filename: 'sketch.dxf',
319
+ ...options
320
+ };
321
+
322
+ // Calculate extents
323
+ const extents = calculateExtents(entities);
324
+
325
+ // Build sections
326
+ const header = buildHeaderSection(extents);
327
+ const tables = buildTablesSection();
328
+ const blocks = buildBlocksSection();
329
+
330
+ // Build ENTITIES section
331
+ const entitiesLines = [];
332
+ entitiesLines.push(' 0');
333
+ entitiesLines.push('SECTION');
334
+ entitiesLines.push(' 2');
335
+ entitiesLines.push('ENTITIES');
336
+
337
+ // Convert sketch entities to DXF entities
338
+ entities.forEach((entity, idx) => {
339
+ const layer = opts.layers ? `ENTITY_${idx}` : DXF_LAYERS.OUTLINE.name;
340
+
341
+ switch (entity.type.toLowerCase()) {
342
+ case 'line':
343
+ entitiesLines.push(...createDXFLine(entity.points[0], entity.points[1], layer));
344
+ break;
345
+ case 'rectangle':
346
+ entitiesLines.push(...createDXFRectangle(entity.points, layer));
347
+ break;
348
+ case 'circle':
349
+ entitiesLines.push(...createDXFCircle(entity.points[0], entity.dimensions?.radius || 10, layer));
350
+ break;
351
+ case 'arc':
352
+ entitiesLines.push(...createDXFArc(entity.points[0], entity.dimensions?.radius || 10,
353
+ entity.dimensions?.startAngle || 0, entity.dimensions?.endAngle || 180, layer));
354
+ break;
355
+ case 'polyline':
356
+ entitiesLines.push(...createDXFPolyline(entity.points, layer));
357
+ break;
358
+ }
359
+ });
360
+
361
+ // Add dimension annotations if requested
362
+ if (opts.dimensions && entities.some(e => e.dimensions)) {
363
+ entities.forEach((entity, idx) => {
364
+ if (entity.dimensions?.label) {
365
+ const midpoint = calculateMidpoint(entity.points);
366
+ entitiesLines.push(...createDXFText(midpoint, entity.dimensions.label, 2.5, DXF_LAYERS.DIMENSION.name));
367
+ }
368
+ });
369
+ }
370
+
371
+ entitiesLines.push(' 0');
372
+ entitiesLines.push('ENDSEC');
373
+
374
+ // Build EOF
375
+ const eof = [' 0', 'EOF'];
376
+
377
+ return [header, tables, blocks, entitiesLines.join('\n'), eof.join('\n')].join('\n');
378
+ }
379
+
380
+ /**
381
+ * Export 3D mesh projection to DXF format
382
+ *
383
+ * @param {THREE.Mesh|THREE.Group} mesh - Geometry to project
384
+ * @param {string} view - Projection view: 'front'|'top'|'right'|'back'|'left'|'bottom'|'iso'
385
+ * @param {Object} options - Export options
386
+ * @param {boolean} options.hiddenLines - Separate hidden lines to layer - default true
387
+ * @param {number} options.scale - Scaling factor - default 1.0
388
+ * @param {boolean} options.layers - Use separate layers - default true
389
+ * @returns {string} DXF file content
390
+ */
391
+ export function exportProjectionToDXF(mesh, view = 'front', options = {}) {
392
+ const opts = {
393
+ hiddenLines: true,
394
+ scale: 1.0,
395
+ layers: true,
396
+ ...options
397
+ };
398
+
399
+ // Get projection matrix for view
400
+ const projMatrix = getProjectionMatrix(view);
401
+
402
+ // Extract visible and hidden edges
403
+ const visibleEdges = extractProjectedEdges(mesh, projMatrix, opts.scale, true);
404
+ const hiddenEdges = opts.hiddenLines ? extractProjectedEdges(mesh, projMatrix, opts.scale, false) : [];
405
+
406
+ // Calculate extents
407
+ const allEdges = [...visibleEdges, ...hiddenEdges];
408
+ const extents = calculateExtentsFromEdges(allEdges);
409
+
410
+ // Build sections
411
+ const header = buildHeaderSection(extents);
412
+ const tables = buildTablesSection();
413
+ const blocks = buildBlocksSection();
414
+
415
+ // Build ENTITIES section
416
+ const entitiesLines = [];
417
+ entitiesLines.push(' 0');
418
+ entitiesLines.push('SECTION');
419
+ entitiesLines.push(' 2');
420
+ entitiesLines.push('ENTITIES');
421
+
422
+ // Add visible edges
423
+ visibleEdges.forEach(edge => {
424
+ entitiesLines.push(...createDXFLine(edge.start, edge.end, DXF_LAYERS.OUTLINE.name));
425
+ });
426
+
427
+ // Add hidden edges on separate layer
428
+ if (opts.hiddenLines) {
429
+ hiddenEdges.forEach(edge => {
430
+ entitiesLines.push(...createDXFLine(edge.start, edge.end, DXF_LAYERS.HIDDEN.name));
431
+ });
432
+ }
433
+
434
+ entitiesLines.push(' 0');
435
+ entitiesLines.push('ENDSEC');
436
+
437
+ const eof = [' 0', 'EOF'];
438
+
439
+ return [header, tables, blocks, entitiesLines.join('\n'), eof.join('\n')].join('\n');
440
+ }
441
+
442
+ /**
443
+ * Export standard engineering multi-view drawing
444
+ * Creates front, top, right views in 3rd-angle projection layout
445
+ *
446
+ * @param {THREE.Mesh|THREE.Group} mesh - Geometry to project
447
+ * @param {Object} options - Export options
448
+ * @param {number} options.scale - View scale - default 1.0
449
+ * @param {number} options.spacing - Space between views (mm) - default 50
450
+ * @param {boolean} options.border - Draw border - default true
451
+ * @param {boolean} options.titleBlock - Draw title block - default true
452
+ * @param {string} options.title - Drawing title - default 'Part'
453
+ * @param {string} options.partNumber - Part number - default ''
454
+ * @param {string} options.material - Material description - default ''
455
+ * @param {string} options.author - Author name - default ''
456
+ * @returns {string} DXF file content
457
+ */
458
+ export function exportMultiViewDXF(mesh, options = {}) {
459
+ const opts = {
460
+ scale: 1.0,
461
+ spacing: 50,
462
+ border: true,
463
+ titleBlock: true,
464
+ title: 'Part',
465
+ partNumber: '',
466
+ material: '',
467
+ author: '',
468
+ ...options
469
+ };
470
+
471
+ // Extract projections
472
+ const frontProj = extractProjectedEdges(mesh, getProjectionMatrix('front'), opts.scale, true);
473
+ const topProj = extractProjectedEdges(mesh, getProjectionMatrix('top'), opts.scale, true);
474
+ const rightProj = extractProjectedEdges(mesh, getProjectionMatrix('right'), opts.scale, true);
475
+
476
+ // Calculate view extents
477
+ const frontExt = calculateExtentsFromEdges(frontProj);
478
+ const topExt = calculateExtentsFromEdges(topProj);
479
+ const rightExt = calculateExtentsFromEdges(rightProj);
480
+
481
+ // Layout views (3rd angle projection)
482
+ const layout = {
483
+ front: { x: 10, y: 10, width: frontExt.max.x - frontExt.min.x, height: frontExt.max.y - frontExt.min.y },
484
+ top: { x: 10, y: 10 + frontExt.max.y - frontExt.min.y + opts.spacing, width: topExt.max.x - topExt.min.x, height: topExt.max.y - topExt.min.y },
485
+ right: { x: 10 + frontExt.max.x - frontExt.min.x + opts.spacing, y: 10, width: rightExt.max.x - rightExt.min.x, height: rightExt.max.y - rightExt.min.y }
486
+ };
487
+
488
+ // Calculate total extents for border
489
+ const totalWidth = layout.front.width + layout.right.width + opts.spacing + 30;
490
+ const totalHeight = layout.front.height + layout.top.height + opts.spacing + (opts.titleBlock ? 60 : 30);
491
+ const overallExtents = { min: { x: 0, y: 0 }, max: { x: totalWidth, y: totalHeight } };
492
+
493
+ // Build DXF
494
+ const header = buildHeaderSection(overallExtents);
495
+ const tables = buildTablesSection();
496
+ const blocks = buildBlocksSection();
497
+
498
+ const entitiesLines = [];
499
+ entitiesLines.push(' 0');
500
+ entitiesLines.push('SECTION');
501
+ entitiesLines.push(' 2');
502
+ entitiesLines.push('ENTITIES');
503
+
504
+ // Draw border
505
+ if (opts.border) {
506
+ entitiesLines.push(...createDXFRectangle(
507
+ [
508
+ { x: 5, y: 5 },
509
+ { x: totalWidth - 5, y: totalHeight - 5 }
510
+ ],
511
+ DXF_LAYERS.BORDER.name
512
+ ));
513
+ }
514
+
515
+ // Draw title block
516
+ if (opts.titleBlock) {
517
+ const titleY = 20;
518
+ entitiesLines.push(...createDXFText({ x: totalWidth - 100, y: titleY }, opts.title, 3.5, DXF_LAYERS.BORDER.name));
519
+ if (opts.partNumber) {
520
+ entitiesLines.push(...createDXFText({ x: totalWidth - 100, y: titleY - 8 }, `Part: ${opts.partNumber}`, 2, DXF_LAYERS.BORDER.name));
521
+ }
522
+ if (opts.material) {
523
+ entitiesLines.push(...createDXFText({ x: totalWidth - 100, y: titleY - 14 }, `Material: ${opts.material}`, 2, DXF_LAYERS.BORDER.name));
524
+ }
525
+ if (opts.author) {
526
+ entitiesLines.push(...createDXFText({ x: totalWidth - 100, y: titleY - 20 }, `By: ${opts.author}`, 2, DXF_LAYERS.BORDER.name));
527
+ }
528
+ }
529
+
530
+ // Draw front view
531
+ entitiesLines.push(...createDXFText({ x: layout.front.x, y: layout.front.y - 5 }, 'FRONT', 2.5, DXF_LAYERS.DIMENSION.name));
532
+ frontProj.forEach(edge => {
533
+ entitiesLines.push(...createDXFLine(
534
+ { x: edge.start.x + layout.front.x, y: edge.start.y + layout.front.y },
535
+ { x: edge.end.x + layout.front.x, y: edge.end.y + layout.front.y },
536
+ DXF_LAYERS.OUTLINE.name
537
+ ));
538
+ });
539
+
540
+ // Draw top view
541
+ entitiesLines.push(...createDXFText({ x: layout.top.x, y: layout.top.y - 5 }, 'TOP', 2.5, DXF_LAYERS.DIMENSION.name));
542
+ topProj.forEach(edge => {
543
+ entitiesLines.push(...createDXFLine(
544
+ { x: edge.start.x + layout.top.x, y: edge.start.y + layout.top.y },
545
+ { x: edge.end.x + layout.top.x, y: edge.end.y + layout.top.y },
546
+ DXF_LAYERS.OUTLINE.name
547
+ ));
548
+ });
549
+
550
+ // Draw right view
551
+ entitiesLines.push(...createDXFText({ x: layout.right.x, y: layout.right.y - 5 }, 'RIGHT', 2.5, DXF_LAYERS.DIMENSION.name));
552
+ rightProj.forEach(edge => {
553
+ entitiesLines.push(...createDXFLine(
554
+ { x: edge.start.x + layout.right.x, y: edge.start.y + layout.right.y },
555
+ { x: edge.end.x + layout.right.x, y: edge.end.y + layout.right.y },
556
+ DXF_LAYERS.OUTLINE.name
557
+ ));
558
+ });
559
+
560
+ entitiesLines.push(' 0');
561
+ entitiesLines.push('ENDSEC');
562
+
563
+ const eof = [' 0', 'EOF'];
564
+
565
+ return [header, tables, blocks, entitiesLines.join('\n'), eof.join('\n')].join('\n');
566
+ }
567
+
568
+ /**
569
+ * Export 3D geometry as wireframe to DXF
570
+ *
571
+ * @param {THREE.Mesh|THREE.Group} mesh - Geometry to export
572
+ * @param {Object} options - Export options
573
+ * @param {boolean} options.faces - Export faces as 3DFACE - default true
574
+ * @param {boolean} options.edges - Export edges as 3DLINE - default true
575
+ * @param {string} options.layer - Layer name - default 'MODEL'
576
+ * @returns {string} DXF file content
577
+ */
578
+ export function export3DDXF(mesh, options = {}) {
579
+ const opts = {
580
+ faces: true,
581
+ edges: true,
582
+ layer: DXF_LAYERS.MODEL.name,
583
+ ...options
584
+ };
585
+
586
+ // Extract all unique vertices and faces
587
+ const vertices = [];
588
+ const faces = [];
589
+
590
+ extractGeometry(mesh, vertices, faces);
591
+
592
+ // Calculate extents in 3D
593
+ const extents = { min: { x: 0, y: 0, z: 0 }, max: { x: 100, y: 100, z: 100 } };
594
+ if (vertices.length > 0) {
595
+ extents.min = { x: vertices[0].x, y: vertices[0].y, z: vertices[0].z };
596
+ extents.max = { ...extents.min };
597
+ vertices.forEach(v => {
598
+ extents.min.x = Math.min(extents.min.x, v.x);
599
+ extents.min.y = Math.min(extents.min.y, v.y);
600
+ extents.min.z = Math.min(extents.min.z, v.z);
601
+ extents.max.x = Math.max(extents.max.x, v.x);
602
+ extents.max.y = Math.max(extents.max.y, v.y);
603
+ extents.max.z = Math.max(extents.max.z, v.z);
604
+ });
605
+ }
606
+
607
+ // Build DXF
608
+ const header = buildHeaderSection(extents);
609
+ const tables = buildTablesSection();
610
+ const blocks = buildBlocksSection();
611
+
612
+ const entitiesLines = [];
613
+ entitiesLines.push(' 0');
614
+ entitiesLines.push('SECTION');
615
+ entitiesLines.push(' 2');
616
+ entitiesLines.push('ENTITIES');
617
+
618
+ // Export 3D faces
619
+ if (opts.faces) {
620
+ faces.forEach(face => {
621
+ entitiesLines.push(...createDXF3DFace(face, opts.layer));
622
+ });
623
+ }
624
+
625
+ // Export edges
626
+ if (opts.edges) {
627
+ const edges = extractEdges(faces);
628
+ edges.forEach(edge => {
629
+ entitiesLines.push(...createDXF3DLine(edge.start, edge.end, opts.layer));
630
+ });
631
+ }
632
+
633
+ entitiesLines.push(' 0');
634
+ entitiesLines.push('ENDSEC');
635
+
636
+ const eof = [' 0', 'EOF'];
637
+
638
+ return [header, tables, blocks, entitiesLines.join('\n'), eof.join('\n')].join('\n');
639
+ }
640
+
641
+ // ============================================================================
642
+ // DXF Entity Creators
643
+ // ============================================================================
644
+
645
+ /**
646
+ * Create DXF LINE entity
647
+ * @private
648
+ */
649
+ function createDXFLine(p1, p2, layer = '0') {
650
+ return [
651
+ ' 0', 'LINE',
652
+ ' 8', layer,
653
+ ' 10', formatNumber(p1.x),
654
+ ' 20', formatNumber(p1.y),
655
+ ' 30', '0.0',
656
+ ' 11', formatNumber(p2.x),
657
+ ' 21', formatNumber(p2.y),
658
+ ' 31', '0.0'
659
+ ];
660
+ }
661
+
662
+ /**
663
+ * Create DXF CIRCLE entity
664
+ * @private
665
+ */
666
+ function createDXFCircle(center, radius, layer = '0') {
667
+ return [
668
+ ' 0', 'CIRCLE',
669
+ ' 8', layer,
670
+ ' 10', formatNumber(center.x),
671
+ ' 20', formatNumber(center.y),
672
+ ' 30', '0.0',
673
+ ' 40', formatNumber(radius)
674
+ ];
675
+ }
676
+
677
+ /**
678
+ * Create DXF ARC entity
679
+ * @private
680
+ */
681
+ function createDXFArc(center, radius, startAngle, endAngle, layer = '0') {
682
+ return [
683
+ ' 0', 'ARC',
684
+ ' 8', layer,
685
+ ' 10', formatNumber(center.x),
686
+ ' 20', formatNumber(center.y),
687
+ ' 30', '0.0',
688
+ ' 40', formatNumber(radius),
689
+ ' 50', formatNumber(startAngle),
690
+ ' 51', formatNumber(endAngle)
691
+ ];
692
+ }
693
+
694
+ /**
695
+ * Create DXF LWPOLYLINE entity
696
+ * @private
697
+ */
698
+ function createDXFPolyline(points, layer = '0') {
699
+ const lines = [
700
+ ' 0', 'LWPOLYLINE',
701
+ ' 8', layer,
702
+ ' 70', '0',
703
+ ' 90', points.length.toString()
704
+ ];
705
+
706
+ points.forEach(p => {
707
+ lines.push(' 10');
708
+ lines.push(formatNumber(p.x));
709
+ lines.push(' 20');
710
+ lines.push(formatNumber(p.y));
711
+ });
712
+
713
+ return lines;
714
+ }
715
+
716
+ /**
717
+ * Create DXF TEXT entity
718
+ * @private
719
+ */
720
+ function createDXFText(position, text, height = 2.5, layer = '0') {
721
+ return [
722
+ ' 0', 'TEXT',
723
+ ' 8', layer,
724
+ ' 10', formatNumber(position.x),
725
+ ' 20', formatNumber(position.y),
726
+ ' 30', '0.0',
727
+ ' 40', formatNumber(height),
728
+ ' 1', text,
729
+ ' 7', 'STANDARD',
730
+ ' 50', '0.0',
731
+ ' 72', '0',
732
+ ' 11', formatNumber(position.x),
733
+ ' 21', formatNumber(position.y),
734
+ ' 31', '0.0'
735
+ ];
736
+ }
737
+
738
+ /**
739
+ * Create DXF RECTANGLE (as LWPOLYLINE)
740
+ * @private
741
+ */
742
+ function createDXFRectangle(points, layer = '0') {
743
+ const p1 = points[0];
744
+ const p2 = points[1];
745
+
746
+ const corners = [
747
+ { x: p1.x, y: p1.y },
748
+ { x: p2.x, y: p1.y },
749
+ { x: p2.x, y: p2.y },
750
+ { x: p1.x, y: p2.y },
751
+ { x: p1.x, y: p1.y } // Close loop
752
+ ];
753
+
754
+ return createDXFPolyline(corners, layer);
755
+ }
756
+
757
+ /**
758
+ * Create DXF 3DFACE entity
759
+ * @private
760
+ */
761
+ function createDXF3DFace(faceVertices, layer = '0') {
762
+ const lines = [
763
+ ' 0', '3DFACE',
764
+ ' 8', layer
765
+ ];
766
+
767
+ // Support triangles and quads
768
+ const vCount = Math.min(faceVertices.length, 4);
769
+ for (let i = 0; i < vCount; i++) {
770
+ const v = faceVertices[i];
771
+ lines.push(` 1${i}`);
772
+ lines.push(formatNumber(v.x));
773
+ lines.push(` 2${i}`);
774
+ lines.push(formatNumber(v.y));
775
+ lines.push(` 3${i}`);
776
+ lines.push(formatNumber(v.z));
777
+ }
778
+
779
+ return lines;
780
+ }
781
+
782
+ /**
783
+ * Create DXF 3DLINE entity
784
+ * @private
785
+ */
786
+ function createDXF3DLine(p1, p2, layer = '0') {
787
+ return [
788
+ ' 0', 'LINE',
789
+ ' 8', layer,
790
+ ' 10', formatNumber(p1.x),
791
+ ' 20', formatNumber(p1.y),
792
+ ' 30', formatNumber(p1.z),
793
+ ' 11', formatNumber(p2.x),
794
+ ' 21', formatNumber(p2.y),
795
+ ' 31', formatNumber(p2.z)
796
+ ];
797
+ }
798
+
799
+ // ============================================================================
800
+ // Geometry Processing
801
+ // ============================================================================
802
+
803
+ /**
804
+ * Extract visible projected edges from mesh
805
+ * @private
806
+ */
807
+ function extractProjectedEdges(mesh, projMatrix, scale = 1.0, isVisible = true) {
808
+ const edges = [];
809
+ const frustum = new THREE.Frustum();
810
+ const cameraMatrix = new THREE.Matrix4();
811
+ cameraMatrix.multiplyMatrices(projMatrix, mesh.matrixWorld);
812
+ frustum.setFromProjectionMatrix(cameraMatrix);
813
+
814
+ const tempMesh = mesh instanceof THREE.Group ? mesh : mesh;
815
+
816
+ tempMesh.traverse(child => {
817
+ if (child instanceof THREE.Mesh && child.geometry) {
818
+ const geometry = child.geometry;
819
+
820
+ if (!geometry.attributes.position) return;
821
+
822
+ const positions = geometry.attributes.position.array;
823
+ const indices = geometry.index ? geometry.index.array : null;
824
+
825
+ // Extract edges from geometry
826
+ const extractedEdges = indices ?
827
+ extractEdgesFromIndices(positions, indices) :
828
+ extractEdgesFromVertices(positions);
829
+
830
+ extractedEdges.forEach(edge => {
831
+ const v1 = new THREE.Vector3(edge.start.x * scale, edge.start.y * scale, 0);
832
+ const v2 = new THREE.Vector3(edge.end.x * scale, edge.end.y * scale, 0);
833
+ edges.push({ start: v1, end: v2 });
834
+ });
835
+ }
836
+ });
837
+
838
+ return edges;
839
+ }
840
+
841
+ /**
842
+ * Extract edges from indexed geometry
843
+ * @private
844
+ */
845
+ function extractEdgesFromIndices(positions, indices) {
846
+ const edges = [];
847
+ const edgeSet = new Set();
848
+
849
+ for (let i = 0; i < indices.length; i += 3) {
850
+ const i0 = indices[i] * 3;
851
+ const i1 = indices[i + 1] * 3;
852
+ const i2 = indices[i + 2] * 3;
853
+
854
+ const p0 = { x: positions[i0], y: positions[i0 + 1] };
855
+ const p1 = { x: positions[i1], y: positions[i1 + 1] };
856
+ const p2 = { x: positions[i2], y: positions[i2 + 1] };
857
+
858
+ addEdgeToSet(edgeSet, p0, p1, edges);
859
+ addEdgeToSet(edgeSet, p1, p2, edges);
860
+ addEdgeToSet(edgeSet, p2, p0, edges);
861
+ }
862
+
863
+ return edges;
864
+ }
865
+
866
+ /**
867
+ * Extract edges from non-indexed geometry
868
+ * @private
869
+ */
870
+ function extractEdgesFromVertices(positions) {
871
+ const edges = [];
872
+
873
+ for (let i = 0; i < positions.length; i += 9) {
874
+ const p0 = { x: positions[i], y: positions[i + 1] };
875
+ const p1 = { x: positions[i + 3], y: positions[i + 4] };
876
+ const p2 = { x: positions[i + 6], y: positions[i + 7] };
877
+
878
+ edges.push({ start: p0, end: p1 });
879
+ edges.push({ start: p1, end: p2 });
880
+ edges.push({ start: p2, end: p0 });
881
+ }
882
+
883
+ return edges;
884
+ }
885
+
886
+ /**
887
+ * Add edge to set, avoiding duplicates
888
+ * @private
889
+ */
890
+ function addEdgeToSet(edgeSet, p1, p2, edges) {
891
+ const key = [
892
+ Math.round(p1.x * 1000),
893
+ Math.round(p1.y * 1000),
894
+ Math.round(p2.x * 1000),
895
+ Math.round(p2.y * 1000)
896
+ ].join(',');
897
+
898
+ if (!edgeSet.has(key)) {
899
+ edgeSet.add(key);
900
+ edges.push({ start: p1, end: p2 });
901
+ }
902
+ }
903
+
904
+ /**
905
+ * Extract geometry from mesh/group
906
+ * @private
907
+ */
908
+ function extractGeometry(mesh, vertices, faces) {
909
+ let vertexOffset = vertices.length;
910
+
911
+ mesh.traverse(child => {
912
+ if (child instanceof THREE.Mesh && child.geometry) {
913
+ const geometry = child.geometry;
914
+
915
+ if (!geometry.attributes.position) return;
916
+
917
+ const positions = geometry.attributes.position.array;
918
+
919
+ // Add vertices
920
+ for (let i = 0; i < positions.length; i += 3) {
921
+ vertices.push({
922
+ x: positions[i],
923
+ y: positions[i + 1],
924
+ z: positions[i + 2]
925
+ });
926
+ }
927
+
928
+ // Add faces
929
+ const indices = geometry.index ? geometry.index.array : null;
930
+
931
+ if (indices) {
932
+ for (let i = 0; i < indices.length; i += 3) {
933
+ faces.push([
934
+ vertexOffset + indices[i],
935
+ vertexOffset + indices[i + 1],
936
+ vertexOffset + indices[i + 2]
937
+ ]);
938
+ }
939
+ } else {
940
+ for (let i = 0; i < positions.length / 3; i += 3) {
941
+ faces.push([
942
+ vertexOffset + i,
943
+ vertexOffset + i + 1,
944
+ vertexOffset + i + 2
945
+ ]);
946
+ }
947
+ }
948
+
949
+ vertexOffset = vertices.length;
950
+ }
951
+ });
952
+ }
953
+
954
+ /**
955
+ * Extract unique edges from faces
956
+ * @private
957
+ */
958
+ function extractEdges(faces) {
959
+ const edgeSet = new Set();
960
+ const edges = [];
961
+
962
+ faces.forEach(face => {
963
+ for (let i = 0; i < face.length; i++) {
964
+ const v1 = face[i];
965
+ const v2 = face[(i + 1) % face.length];
966
+
967
+ const key = [Math.min(v1, v2), Math.max(v1, v2)].join(',');
968
+
969
+ if (!edgeSet.has(key)) {
970
+ edgeSet.add(key);
971
+ edges.push({ v1, v2 });
972
+ }
973
+ }
974
+ });
975
+
976
+ return edges;
977
+ }
978
+
979
+ /**
980
+ * Get projection matrix for standard view
981
+ * @private
982
+ */
983
+ function getProjectionMatrix(view) {
984
+ const matrix = new THREE.Matrix4();
985
+
986
+ switch (view.toLowerCase()) {
987
+ case 'front':
988
+ // Looking at Z=0 plane from positive Z
989
+ matrix.set(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
990
+ break;
991
+ case 'top':
992
+ // Looking at XY plane from positive Z (top-down)
993
+ matrix.set(1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1);
994
+ break;
995
+ case 'right':
996
+ // Looking at YZ plane from positive X (right side)
997
+ matrix.set(0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1);
998
+ break;
999
+ case 'back':
1000
+ // Looking at Z=0 plane from negative Z
1001
+ matrix.set(-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1);
1002
+ break;
1003
+ case 'left':
1004
+ // Looking at YZ plane from negative X
1005
+ matrix.set(0, 0, -1, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1);
1006
+ break;
1007
+ case 'bottom':
1008
+ // Looking at XY plane from negative Z
1009
+ matrix.set(1, 0, 0, 0, 0, 0, -1, 0, 0, -1, 0, 0, 0, 0, 0, 1);
1010
+ break;
1011
+ case 'iso':
1012
+ // Isometric view
1013
+ matrix.set(0.866, -0.5, 0, 0, 0.433, 0.75, -0.5, 0, 0.5, 0.433, 0.75, 0, 0, 0, 0, 1);
1014
+ break;
1015
+ default:
1016
+ matrix.identity();
1017
+ }
1018
+
1019
+ return matrix;
1020
+ }
1021
+
1022
+ // ============================================================================
1023
+ // Utility Functions
1024
+ // ============================================================================
1025
+
1026
+ /**
1027
+ * Calculate extents from 2D sketch entities
1028
+ * @private
1029
+ */
1030
+ function calculateExtents(entities) {
1031
+ let minX = 0, minY = 0, maxX = 100, maxY = 100;
1032
+
1033
+ entities.forEach(entity => {
1034
+ entity.points?.forEach(p => {
1035
+ minX = Math.min(minX, p.x);
1036
+ minY = Math.min(minY, p.y);
1037
+ maxX = Math.max(maxX, p.x);
1038
+ maxY = Math.max(maxY, p.y);
1039
+ });
1040
+ });
1041
+
1042
+ return {
1043
+ min: { x: minX - 10, y: minY - 10 },
1044
+ max: { x: maxX + 10, y: maxY + 10 }
1045
+ };
1046
+ }
1047
+
1048
+ /**
1049
+ * Calculate extents from projected edges
1050
+ * @private
1051
+ */
1052
+ function calculateExtentsFromEdges(edges) {
1053
+ let minX = 0, minY = 0, maxX = 100, maxY = 100;
1054
+
1055
+ if (edges.length === 0) {
1056
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } };
1057
+ }
1058
+
1059
+ minX = edges[0].start.x;
1060
+ minY = edges[0].start.y;
1061
+ maxX = edges[0].start.x;
1062
+ maxY = edges[0].start.y;
1063
+
1064
+ edges.forEach(edge => {
1065
+ minX = Math.min(minX, edge.start.x, edge.end.x);
1066
+ minY = Math.min(minY, edge.start.y, edge.end.y);
1067
+ maxX = Math.max(maxX, edge.start.x, edge.end.x);
1068
+ maxY = Math.max(maxY, edge.start.y, edge.end.y);
1069
+ });
1070
+
1071
+ const padding = 10;
1072
+ return {
1073
+ min: { x: minX - padding, y: minY - padding },
1074
+ max: { x: maxX + padding, y: maxY + padding }
1075
+ };
1076
+ }
1077
+
1078
+ /**
1079
+ * Calculate midpoint of points
1080
+ * @private
1081
+ */
1082
+ function calculateMidpoint(points) {
1083
+ if (!points || points.length === 0) return { x: 0, y: 0 };
1084
+
1085
+ const sum = points.reduce((acc, p) => ({
1086
+ x: acc.x + p.x,
1087
+ y: acc.y + p.y
1088
+ }), { x: 0, y: 0 });
1089
+
1090
+ return {
1091
+ x: sum.x / points.length,
1092
+ y: sum.y / points.length
1093
+ };
1094
+ }
1095
+
1096
+ /**
1097
+ * Format number for DXF (6 decimal places)
1098
+ * @private
1099
+ */
1100
+ function formatNumber(num) {
1101
+ return Number(num).toFixed(6);
1102
+ }
1103
+
1104
+ // ============================================================================
1105
+ // File Download & Export
1106
+ // ============================================================================
1107
+
1108
+ /**
1109
+ * Trigger browser download of DXF file
1110
+ *
1111
+ * @param {string} content - DXF file content
1112
+ * @param {string} filename - Output filename (default 'export.dxf')
1113
+ */
1114
+ export function downloadDXF(content, filename = 'export.dxf') {
1115
+ const blob = new Blob([content], { type: 'application/vnd.dxf' });
1116
+ const url = URL.createObjectURL(blob);
1117
+ const link = document.createElement('a');
1118
+ link.href = url;
1119
+ link.download = filename.endsWith('.dxf') ? filename : `${filename}.dxf`;
1120
+ document.body.appendChild(link);
1121
+ link.click();
1122
+ document.body.removeChild(link);
1123
+ URL.revokeObjectURL(url);
1124
+ }
1125
+
1126
+ /**
1127
+ * Convert DXF sections to string
1128
+ * Utility for building custom DXF files
1129
+ *
1130
+ * @param {Object} sections - Object with section names as keys: { header: '...', tables: '...', ... }
1131
+ * @returns {string} Complete DXF content
1132
+ */
1133
+ export function dxfToString(sections) {
1134
+ return Object.values(sections).join('\n');
1135
+ }
1136
+
1137
+ // ============================================================================
1138
+ // Export Summary
1139
+ // ============================================================================
1140
+
1141
+ /**
1142
+ * Generate a text summary of what will be exported
1143
+ * Useful for UI feedback before export
1144
+ *
1145
+ * @param {Array|THREE.Mesh} data - Entities array or mesh
1146
+ * @param {string} type - Export type: 'sketch'|'projection'|'multiview'|'3d'
1147
+ * @returns {string} Human-readable summary
1148
+ */
1149
+ export function generateExportSummary(data, type = 'sketch') {
1150
+ let summary = '';
1151
+
1152
+ switch (type) {
1153
+ case 'sketch':
1154
+ summary = `Exporting ${data.length} sketch entities`;
1155
+ const lines = data.filter(e => e.type === 'line').length;
1156
+ const circles = data.filter(e => e.type === 'circle').length;
1157
+ if (lines > 0) summary += `\n- ${lines} lines`;
1158
+ if (circles > 0) summary += `\n- ${circles} circles`;
1159
+ break;
1160
+ case 'projection':
1161
+ summary = 'Exporting 2D projection view';
1162
+ break;
1163
+ case 'multiview':
1164
+ summary = 'Exporting multi-view engineering drawing\n- Front, Top, Right views\n- Title block included';
1165
+ break;
1166
+ case '3d':
1167
+ summary = 'Exporting 3D wireframe';
1168
+ break;
1169
+ }
1170
+
1171
+ summary += '\n\nFormat: AutoCAD R14 (ASCII DXF)\nUnits: millimeters';
1172
+ return summary;
1173
+ }