pcb-scene3d-viewer 1.1.19 → 1.1.21

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,952 @@
1
+ import { PcbAssemblyExportCoordinateFrame } from './PcbAssemblyExportCoordinateFrame.mjs'
2
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
3
+ import { PcbAssemblyPolygonTriangulator } from './PcbAssemblyPolygonTriangulator.mjs'
4
+
5
+ const TESSELLATED_MESH_THRESHOLD = 512
6
+ const TESSELLATED_FACE_THRESHOLD = 5000
7
+ const TESSELLATED_VERTEX_THRESHOLD = 20000
8
+
9
+ /**
10
+ * Writes PCB assembly meshes as an ISO 10303 STEP file.
11
+ */
12
+ export class PcbAssemblyStepWriter {
13
+ /**
14
+ * Writes meshes to STEP text.
15
+ * @param {{ name?: string, meshes?: object[] }} assembly Assembly data.
16
+ * @returns {string}
17
+ */
18
+ static write(assembly = {}) {
19
+ const writer = new PcbAssemblyStepWriter(assembly?.name || 'assembly')
20
+ return writer.#write(PcbAssemblyStepWriter.#array(assembly.meshes))
21
+ }
22
+
23
+ /** @type {string} */
24
+ #name
25
+
26
+ /** @type {string[]} */
27
+ #entities
28
+
29
+ /** @type {number} */
30
+ #nextId
31
+
32
+ /**
33
+ * @param {string} name Assembly name.
34
+ */
35
+ constructor(name) {
36
+ this.#name = String(name || 'assembly')
37
+ this.#entities = []
38
+ this.#nextId = 1
39
+ }
40
+
41
+ /**
42
+ * Writes the full STEP file.
43
+ * @param {object[]} meshes Assembly meshes.
44
+ * @returns {string}
45
+ */
46
+ #write(meshes) {
47
+ const tessellated = PcbAssemblyStepWriter.#shouldUseTessellated(meshes)
48
+ const contextIds = this.#writeContext(tessellated)
49
+ const representationId = tessellated
50
+ ? this.#writeTessellatedRepresentation(meshes, contextIds)
51
+ : this.#writeBrepRepresentation(meshes, contextIds)
52
+ const shapeId = this.#add(
53
+ "PRODUCT_DEFINITION_SHAPE('','',#" +
54
+ contextIds.productDefinitionId +
55
+ ')'
56
+ )
57
+ this.#add(
58
+ 'SHAPE_DEFINITION_REPRESENTATION(#' +
59
+ shapeId +
60
+ ',#' +
61
+ representationId +
62
+ ')'
63
+ )
64
+
65
+ return (
66
+ PcbAssemblyStepWriter.#header(this.#name, tessellated) +
67
+ this.#entities.map((entity) => entity + ';').join('\n') +
68
+ '\nENDSEC;\nEND-ISO-10303-21;\n'
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Writes an advanced B-rep shape representation.
74
+ * @param {object[]} meshes Assembly meshes.
75
+ * @param {{ axisId: number, representationContextId: number }} contextIds STEP context ids.
76
+ * @returns {number}
77
+ */
78
+ #writeBrepRepresentation(meshes, contextIds) {
79
+ const solidIds = meshes
80
+ .map((mesh) => this.#writeMesh(mesh))
81
+ .filter((id) => id > 0)
82
+ const representationType = solidIds.length
83
+ ? 'ADVANCED_BREP_SHAPE_REPRESENTATION'
84
+ : 'SHAPE_REPRESENTATION'
85
+ const representationId = this.#add(
86
+ representationType +
87
+ "('" +
88
+ PcbAssemblyStepWriter.#escape(this.#name) +
89
+ "',(" +
90
+ PcbAssemblyStepWriter.#referenceList([
91
+ contextIds.axisId,
92
+ ...solidIds
93
+ ]) +
94
+ '),#' +
95
+ contextIds.representationContextId +
96
+ ')'
97
+ )
98
+
99
+ return representationId
100
+ }
101
+
102
+ /**
103
+ * Writes an AP242 tessellated shape representation for dense meshes.
104
+ * @param {object[]} meshes Assembly meshes.
105
+ * @param {{ axisId: number, representationContextId: number }} contextIds STEP context ids.
106
+ * @returns {number}
107
+ */
108
+ #writeTessellatedRepresentation(meshes, contextIds) {
109
+ const surfaceIds = meshes
110
+ .map((mesh) => this.#writeTessellatedSurfaceSet(mesh))
111
+ .filter((id) => id > 0)
112
+ const representationType = surfaceIds.length
113
+ ? 'TESSELLATED_SHAPE_REPRESENTATION'
114
+ : 'SHAPE_REPRESENTATION'
115
+
116
+ return this.#add(
117
+ representationType +
118
+ "('" +
119
+ PcbAssemblyStepWriter.#escape(this.#name) +
120
+ "',(" +
121
+ PcbAssemblyStepWriter.#referenceList([
122
+ contextIds.axisId,
123
+ ...surfaceIds
124
+ ]) +
125
+ '),#' +
126
+ contextIds.representationContextId +
127
+ ')'
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Writes product and geometric context entities.
133
+ * @param {boolean} tessellated Whether AP242 tessellated context should be emitted.
134
+ * @returns {{ axisId: number, representationContextId: number, productDefinitionId: number }}
135
+ */
136
+ #writeContext(tessellated) {
137
+ const appContext = tessellated
138
+ ? 'managed model based 3d engineering'
139
+ : 'automotive_design'
140
+ const protocol = tessellated
141
+ ? 'ap242_managed_model_based_3d_engineering'
142
+ : 'automotive_design'
143
+ const protocolYear = tessellated ? '2014' : '2000'
144
+ const appContextId = this.#add(
145
+ "APPLICATION_CONTEXT('" + appContext + "')"
146
+ )
147
+ this.#add(
148
+ "APPLICATION_PROTOCOL_DEFINITION('international standard','" +
149
+ protocol +
150
+ "'," +
151
+ protocolYear +
152
+ ',#' +
153
+ appContextId +
154
+ ')'
155
+ )
156
+ const productContextId = this.#add(
157
+ "PRODUCT_CONTEXT('',#" + appContextId + ",'mechanical')"
158
+ )
159
+ const productId = this.#add(
160
+ "PRODUCT('" +
161
+ PcbAssemblyStepWriter.#escape(this.#name) +
162
+ "','" +
163
+ PcbAssemblyStepWriter.#escape(this.#name) +
164
+ "','',(#" +
165
+ productContextId +
166
+ '))'
167
+ )
168
+ const formationId = this.#add(
169
+ "PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#" +
170
+ productId +
171
+ ',.NOT_KNOWN.)'
172
+ )
173
+ const definitionContextId = this.#add(
174
+ "PRODUCT_DEFINITION_CONTEXT('part definition',#" +
175
+ appContextId +
176
+ ",'design')"
177
+ )
178
+ const productDefinitionId = this.#add(
179
+ "PRODUCT_DEFINITION('design','',#" +
180
+ formationId +
181
+ ',#' +
182
+ definitionContextId +
183
+ ')'
184
+ )
185
+ const originId = this.#point([0, 0, 0])
186
+ const zDirectionId = this.#direction([0, 0, 1])
187
+ const xDirectionId = this.#direction([1, 0, 0])
188
+ const axisId = this.#add(
189
+ "AXIS2_PLACEMENT_3D('',#" +
190
+ originId +
191
+ ',#' +
192
+ zDirectionId +
193
+ ',#' +
194
+ xDirectionId +
195
+ ')'
196
+ )
197
+ const lengthUnitId = this.#add(
198
+ '(LENGTH_UNIT() NAMED_UNIT(*) SI_UNIT(.MILLI.,.METRE.))'
199
+ )
200
+ const angleUnitId = this.#add(
201
+ '(NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($,.RADIAN.))'
202
+ )
203
+ const solidAngleUnitId = this.#add(
204
+ '(NAMED_UNIT(*) SOLID_ANGLE_UNIT() SI_UNIT($,.STERADIAN.))'
205
+ )
206
+ const uncertaintyId = this.#add(
207
+ 'UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#' +
208
+ lengthUnitId +
209
+ ",'distance_accuracy_value','')"
210
+ )
211
+ const representationContextId = this.#add(
212
+ 'GEOMETRIC_REPRESENTATION_CONTEXT(3) GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#' +
213
+ uncertaintyId +
214
+ ')) GLOBAL_UNIT_ASSIGNED_CONTEXT((#' +
215
+ lengthUnitId +
216
+ ',#' +
217
+ angleUnitId +
218
+ ',#' +
219
+ solidAngleUnitId +
220
+ ")) REPRESENTATION_CONTEXT('','')"
221
+ )
222
+
223
+ return { axisId, representationContextId, productDefinitionId }
224
+ }
225
+
226
+ /**
227
+ * Writes one mesh as a surface-backed manifold solid B-rep.
228
+ * @param {{ name?: string, vertices?: number[][], faces?: number[][] }} mesh Mesh data.
229
+ * @returns {number}
230
+ */
231
+ #writeMesh(mesh) {
232
+ const vertices = PcbAssemblyStepWriter.#array(mesh?.vertices)
233
+ const faces = PcbAssemblyStepWriter.#array(mesh?.faces)
234
+ const context = this.#createMeshContext(vertices)
235
+ const faceIds = faces
236
+ .map((face) => this.#writeAdvancedFace(face, context))
237
+ .filter((id) => id > 0)
238
+
239
+ if (!faceIds.length) {
240
+ return 0
241
+ }
242
+
243
+ const shellId = this.#add(
244
+ "CLOSED_SHELL('',(" +
245
+ PcbAssemblyStepWriter.#referenceList(faceIds) +
246
+ '))'
247
+ )
248
+ const solidId = this.#add(
249
+ "MANIFOLD_SOLID_BREP('" +
250
+ PcbAssemblyStepWriter.#escape(
251
+ PcbAssemblyMeshUtils.safeName(mesh?.name || 'mesh')
252
+ ) +
253
+ "',#" +
254
+ shellId +
255
+ ')'
256
+ )
257
+ this.#writePresentationStyle(solidId, mesh?.color)
258
+ return solidId
259
+ }
260
+
261
+ /**
262
+ * Writes one mesh as an AP242 triangulated surface set.
263
+ * @param {{ name?: string, vertices?: number[][], faces?: number[][] }} mesh Mesh data.
264
+ * @returns {number}
265
+ */
266
+ #writeTessellatedSurfaceSet(mesh) {
267
+ const tessellation = PcbAssemblyStepWriter.#tessellateMesh(mesh)
268
+
269
+ if (!tessellation.triangles.length) {
270
+ return 0
271
+ }
272
+
273
+ const coordinatesId = this.#add(
274
+ "COORDINATES_LIST(''," +
275
+ tessellation.vertices.length +
276
+ ',(' +
277
+ PcbAssemblyStepWriter.#coordinateList(tessellation.vertices) +
278
+ '))'
279
+ )
280
+
281
+ const surfaceSetId = this.#add(
282
+ "TRIANGULATED_SURFACE_SET('" +
283
+ PcbAssemblyStepWriter.#escape(
284
+ PcbAssemblyMeshUtils.safeName(mesh?.name || 'mesh')
285
+ ) +
286
+ "',#" +
287
+ coordinatesId +
288
+ ',' +
289
+ tessellation.vertices.length +
290
+ ',(),(),(' +
291
+ PcbAssemblyStepWriter.#triangleList(tessellation.triangles) +
292
+ '))'
293
+ )
294
+ this.#writePresentationStyle(surfaceSetId, mesh?.color)
295
+ return surfaceSetId
296
+ }
297
+
298
+ /**
299
+ * Writes a STEP presentation style for one shape item.
300
+ * @param {number} itemId Styled representation item id.
301
+ * @param {number[] | undefined} color RGB color.
302
+ * @returns {void}
303
+ */
304
+ #writePresentationStyle(itemId, color) {
305
+ const rgb = PcbAssemblyStepWriter.#colorComponents(color)
306
+ const colorId = this.#add(
307
+ "COLOUR_RGB(''," +
308
+ rgb.map((value) => PcbAssemblyStepWriter.#formatNumber(value)) +
309
+ ')'
310
+ )
311
+ const fillColorId = this.#add(
312
+ "FILL_AREA_STYLE_COLOUR('',#" + colorId + ')'
313
+ )
314
+ const fillStyleId = this.#add(
315
+ "FILL_AREA_STYLE('',(#" + fillColorId + '))'
316
+ )
317
+ const surfaceFillId = this.#add(
318
+ 'SURFACE_STYLE_FILL_AREA(#' + fillStyleId + ')'
319
+ )
320
+ const sideStyleId = this.#add(
321
+ "SURFACE_SIDE_STYLE('',(#" + surfaceFillId + '))'
322
+ )
323
+ const surfaceStyleId = this.#add(
324
+ 'SURFACE_STYLE_USAGE(.BOTH.,#' + sideStyleId + ')'
325
+ )
326
+ const assignmentId = this.#add(
327
+ 'PRESENTATION_STYLE_ASSIGNMENT((#' + surfaceStyleId + '))'
328
+ )
329
+ this.#add("STYLED_ITEM('',(#" + assignmentId + '),#' + itemId + ')')
330
+ }
331
+
332
+ /**
333
+ * Creates per-mesh STEP topology caches.
334
+ * @param {number[][]} vertices Mesh vertices in mils.
335
+ * @returns {{ vertices: number[][], vertexRefs: Map<string, { key: string, pointId: number, vertexId: number, scaled: number[] }>, edges: Map<string, { id: number, startKey: string, endKey: string }> }}
336
+ */
337
+ #createMeshContext(vertices) {
338
+ return {
339
+ vertices,
340
+ vertexRefs: new Map(),
341
+ edges: new Map()
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Writes one polygon face as an advanced planar face.
347
+ * @param {number[]} face Face vertex indices.
348
+ * @param {{ vertices: number[][], vertexRefs: Map<string, { key: string, pointId: number, vertexId: number, scaled: number[] }>, edges: Map<string, { id: number, startKey: string, endKey: string }> }} context Mesh write context.
349
+ * @returns {number}
350
+ */
351
+ #writeAdvancedFace(face, context) {
352
+ const indexes = PcbAssemblyStepWriter.#normalizeFaceIndexes(
353
+ face,
354
+ context.vertices
355
+ )
356
+
357
+ if (indexes.length < 3) {
358
+ return 0
359
+ }
360
+
361
+ const frame = PcbAssemblyStepWriter.#faceFrame(
362
+ indexes,
363
+ context.vertices
364
+ )
365
+
366
+ if (!frame) {
367
+ return 0
368
+ }
369
+
370
+ const orientedEdgeIds = []
371
+ for (let index = 0; index < indexes.length; index += 1) {
372
+ const startIndex = indexes[index]
373
+ const endIndex = indexes[(index + 1) % indexes.length]
374
+ const edge = this.#edge(context, startIndex, endIndex)
375
+
376
+ if (!edge) {
377
+ return 0
378
+ }
379
+
380
+ orientedEdgeIds.push(
381
+ this.#add(
382
+ "ORIENTED_EDGE('',*,*,#" +
383
+ edge.id +
384
+ ',' +
385
+ PcbAssemblyStepWriter.#stepBoolean(edge.forward) +
386
+ ')'
387
+ )
388
+ )
389
+ }
390
+
391
+ const loopId = this.#add(
392
+ "EDGE_LOOP('',(" +
393
+ PcbAssemblyStepWriter.#referenceList(orientedEdgeIds) +
394
+ '))'
395
+ )
396
+ const boundId = this.#add("FACE_OUTER_BOUND('',#" + loopId + ',.T.)')
397
+ const normalDirectionId = this.#direction(frame.normal)
398
+ const xDirectionId = this.#direction(frame.xDirection)
399
+ const axisId = this.#add(
400
+ "AXIS2_PLACEMENT_3D('',#" +
401
+ this.#vertexRef(context, indexes[0]).pointId +
402
+ ',#' +
403
+ normalDirectionId +
404
+ ',#' +
405
+ xDirectionId +
406
+ ')'
407
+ )
408
+ const planeId = this.#add("PLANE('',#" + axisId + ')')
409
+ return this.#add(
410
+ "ADVANCED_FACE('',(#" + boundId + '),#' + planeId + ',.T.)'
411
+ )
412
+ }
413
+
414
+ /**
415
+ * Writes or reuses one vertex reference.
416
+ * @param {{ vertices: number[][], vertexRefs: Map<string, { key: string, pointId: number, vertexId: number, scaled: number[] }> }} context Mesh write context.
417
+ * @param {number} index Mesh vertex index.
418
+ * @returns {{ key: string, pointId: number, vertexId: number, scaled: number[] }}
419
+ */
420
+ #vertexRef(context, index) {
421
+ const vertex = context.vertices[index]
422
+ const key = PcbAssemblyStepWriter.#vertexKey(vertex)
423
+ const existing = context.vertexRefs.get(key)
424
+
425
+ if (existing) {
426
+ return existing
427
+ }
428
+
429
+ const scaled = PcbAssemblyStepWriter.#scaledVertex(vertex)
430
+ const pointId = this.#point(vertex)
431
+ const vertexId = this.#add("VERTEX_POINT('',#" + pointId + ')')
432
+ const ref = { key, pointId, vertexId, scaled }
433
+ context.vertexRefs.set(key, ref)
434
+ return ref
435
+ }
436
+
437
+ /**
438
+ * Writes or reuses one straight edge curve.
439
+ * @param {{ vertices: number[][], vertexRefs: Map<string, { key: string, pointId: number, vertexId: number, scaled: number[] }>, edges: Map<string, { id: number, startKey: string, endKey: string }> }} context Mesh write context.
440
+ * @param {number} startIndex Start vertex index.
441
+ * @param {number} endIndex End vertex index.
442
+ * @returns {{ id: number, forward: boolean } | null}
443
+ */
444
+ #edge(context, startIndex, endIndex) {
445
+ const start = this.#vertexRef(context, startIndex)
446
+ const end = this.#vertexRef(context, endIndex)
447
+
448
+ if (start.key === end.key) {
449
+ return null
450
+ }
451
+
452
+ const key =
453
+ start.key < end.key
454
+ ? start.key + '|' + end.key
455
+ : end.key + '|' + start.key
456
+ const existing = context.edges.get(key)
457
+
458
+ if (existing) {
459
+ return {
460
+ id: existing.id,
461
+ forward:
462
+ existing.startKey === start.key &&
463
+ existing.endKey === end.key
464
+ }
465
+ }
466
+
467
+ const lineId = this.#line(start.pointId, start.scaled, end.scaled)
468
+ const edgeId = this.#add(
469
+ "EDGE_CURVE('',#" +
470
+ start.vertexId +
471
+ ',#' +
472
+ end.vertexId +
473
+ ',#' +
474
+ lineId +
475
+ ',.T.)'
476
+ )
477
+ context.edges.set(key, {
478
+ id: edgeId,
479
+ startKey: start.key,
480
+ endKey: end.key
481
+ })
482
+
483
+ return { id: edgeId, forward: true }
484
+ }
485
+
486
+ /**
487
+ * Writes one unbounded line curve for an edge.
488
+ * @param {number} startPointId STEP point id at the start of the line.
489
+ * @param {number[]} start Start point in millimetres.
490
+ * @param {number[]} end End point in millimetres.
491
+ * @returns {number}
492
+ */
493
+ #line(startPointId, start, end) {
494
+ const delta = PcbAssemblyStepWriter.#subtract(end, start)
495
+ const length = PcbAssemblyStepWriter.#distance(delta)
496
+ const direction = PcbAssemblyStepWriter.#normalize(delta)
497
+ const directionId = this.#direction(direction)
498
+ const vectorId = this.#add(
499
+ "VECTOR('',#" +
500
+ directionId +
501
+ ',' +
502
+ PcbAssemblyStepWriter.#formatNumber(length) +
503
+ ')'
504
+ )
505
+
506
+ return this.#add("LINE('',#" + startPointId + ',#' + vectorId + ')')
507
+ }
508
+
509
+ /**
510
+ * Writes one Cartesian point.
511
+ * @param {number[]} vertex Coordinates in mils.
512
+ * @returns {number}
513
+ */
514
+ #point(vertex) {
515
+ const scaled = PcbAssemblyStepWriter.#scaledVertex(vertex)
516
+
517
+ return this.#add(
518
+ "CARTESIAN_POINT('',(" +
519
+ scaled
520
+ .map((value) => PcbAssemblyStepWriter.#formatNumber(value))
521
+ .join(',') +
522
+ '))'
523
+ )
524
+ }
525
+
526
+ /**
527
+ * Writes one direction.
528
+ * @param {number[]} direction Direction components.
529
+ * @returns {number}
530
+ */
531
+ #direction(direction) {
532
+ return this.#add(
533
+ "DIRECTION('',(" +
534
+ [
535
+ PcbAssemblyStepWriter.#formatNumber(direction[0] || 0),
536
+ PcbAssemblyStepWriter.#formatNumber(direction[1] || 0),
537
+ PcbAssemblyStepWriter.#formatNumber(direction[2] || 0)
538
+ ].join(',') +
539
+ '))'
540
+ )
541
+ }
542
+
543
+ /**
544
+ * Removes invalid, duplicate, and closing indices from one face.
545
+ * @param {unknown} face Face index list.
546
+ * @param {number[][]} vertices Mesh vertices.
547
+ * @returns {number[]}
548
+ */
549
+ static #normalizeFaceIndexes(face, vertices) {
550
+ const indexes = []
551
+
552
+ for (const candidate of PcbAssemblyStepWriter.#array(face)) {
553
+ const index = Number(candidate)
554
+ const lastIndex = indexes[indexes.length - 1]
555
+
556
+ if (
557
+ Number.isInteger(index) &&
558
+ index >= 0 &&
559
+ index < vertices.length &&
560
+ index !== lastIndex &&
561
+ PcbAssemblyStepWriter.#isFiniteVertex(vertices[index])
562
+ ) {
563
+ indexes.push(index)
564
+ }
565
+ }
566
+
567
+ if (indexes.length > 2 && indexes[0] === indexes[indexes.length - 1]) {
568
+ indexes.pop()
569
+ }
570
+
571
+ return indexes.length >= 3 ? indexes : []
572
+ }
573
+
574
+ /**
575
+ * Converts mesh polygon faces into compact one-based triangle indexes.
576
+ * @param {{ vertices?: number[][], faces?: number[][] }} mesh Mesh data.
577
+ * @returns {{ vertices: number[][], triangles: number[][] }}
578
+ */
579
+ static #tessellateMesh(mesh) {
580
+ const sourceVertices = PcbAssemblyStepWriter.#array(mesh?.vertices)
581
+ const coordinateIndexes = new Map()
582
+ const vertices = []
583
+ const triangles = []
584
+ const resolveIndex = (sourceIndex) => {
585
+ const existing = coordinateIndexes.get(sourceIndex)
586
+
587
+ if (existing) {
588
+ return existing
589
+ }
590
+
591
+ const coordinateIndex = vertices.length + 1
592
+ coordinateIndexes.set(sourceIndex, coordinateIndex)
593
+ vertices.push(sourceVertices[sourceIndex])
594
+ return coordinateIndex
595
+ }
596
+
597
+ for (const face of PcbAssemblyStepWriter.#array(mesh?.faces)) {
598
+ const indexes = PcbAssemblyStepWriter.#normalizeFaceIndexes(
599
+ face,
600
+ sourceVertices
601
+ )
602
+
603
+ for (const triangleIndexes of PcbAssemblyPolygonTriangulator.triangulateFace(
604
+ indexes,
605
+ sourceVertices
606
+ )) {
607
+ const triangle = triangleIndexes.map(
608
+ (index) => sourceVertices[index]
609
+ )
610
+
611
+ if (!PcbAssemblyStepWriter.#triangleHasArea(triangle)) {
612
+ continue
613
+ }
614
+
615
+ triangles.push(triangleIndexes.map(resolveIndex))
616
+ }
617
+ }
618
+
619
+ return { vertices, triangles }
620
+ }
621
+
622
+ /**
623
+ * Returns true when one triangle has non-zero area.
624
+ * @param {number[][]} triangle Triangle vertices in mils.
625
+ * @returns {boolean}
626
+ */
627
+ static #triangleHasArea(triangle) {
628
+ const points = triangle.map((vertex) =>
629
+ PcbAssemblyStepWriter.#scaledVertex(vertex)
630
+ )
631
+ const firstEdge = PcbAssemblyStepWriter.#subtract(points[1], points[0])
632
+ const secondEdge = PcbAssemblyStepWriter.#subtract(points[2], points[0])
633
+ return (
634
+ PcbAssemblyStepWriter.#distance(
635
+ PcbAssemblyStepWriter.#cross(firstEdge, secondEdge)
636
+ ) > 0.0000001
637
+ )
638
+ }
639
+
640
+ /**
641
+ * Resolves the planar frame for one face.
642
+ * @param {number[]} indexes Face vertex indices.
643
+ * @param {number[][]} vertices Mesh vertices in mils.
644
+ * @returns {{ normal: number[], xDirection: number[] } | null}
645
+ */
646
+ static #faceFrame(indexes, vertices) {
647
+ const points = indexes.map((index) =>
648
+ PcbAssemblyStepWriter.#scaledVertex(vertices[index])
649
+ )
650
+ const anchor = points[0]
651
+
652
+ for (let first = 1; first < points.length - 1; first += 1) {
653
+ const firstEdge = PcbAssemblyStepWriter.#subtract(
654
+ points[first],
655
+ anchor
656
+ )
657
+
658
+ if (PcbAssemblyStepWriter.#distance(firstEdge) <= 0.0000001) {
659
+ continue
660
+ }
661
+
662
+ for (let second = first + 1; second < points.length; second += 1) {
663
+ const secondEdge = PcbAssemblyStepWriter.#subtract(
664
+ points[second],
665
+ anchor
666
+ )
667
+ const normal = PcbAssemblyStepWriter.#normalize(
668
+ PcbAssemblyStepWriter.#cross(firstEdge, secondEdge)
669
+ )
670
+
671
+ if (PcbAssemblyStepWriter.#distance(normal) > 0) {
672
+ return {
673
+ normal,
674
+ xDirection: PcbAssemblyStepWriter.#normalize(firstEdge)
675
+ }
676
+ }
677
+ }
678
+ }
679
+
680
+ return null
681
+ }
682
+
683
+ /**
684
+ * Scales one mesh vertex from mils to millimetres.
685
+ * @param {number[]} vertex Vertex coordinates in mils.
686
+ * @returns {number[]}
687
+ */
688
+ static #scaledVertex(vertex) {
689
+ return PcbAssemblyExportCoordinateFrame.vertexMilToMm(vertex)
690
+ }
691
+
692
+ /**
693
+ * Builds a stable coordinate key for one vertex.
694
+ * @param {number[]} vertex Vertex coordinates in mils.
695
+ * @returns {string}
696
+ */
697
+ static #vertexKey(vertex) {
698
+ return PcbAssemblyStepWriter.#scaledVertex(vertex)
699
+ .map((value) => PcbAssemblyStepWriter.#formatNumber(value))
700
+ .join(',')
701
+ }
702
+
703
+ /**
704
+ * Formats AP242 coordinates for a tessellated surface set.
705
+ * @param {number[][]} vertices Mesh vertices in mils.
706
+ * @returns {string}
707
+ */
708
+ static #coordinateList(vertices) {
709
+ return vertices
710
+ .map(
711
+ (vertex) =>
712
+ '(' +
713
+ PcbAssemblyStepWriter.#scaledVertex(vertex)
714
+ .map((value) =>
715
+ PcbAssemblyStepWriter.#formatNumber(value)
716
+ )
717
+ .join(',') +
718
+ ')'
719
+ )
720
+ .join(',')
721
+ }
722
+
723
+ /**
724
+ * Formats AP242 triangle coordinate indexes.
725
+ * @param {number[][]} triangles One-based triangle coordinate indexes.
726
+ * @returns {string}
727
+ */
728
+ static #triangleList(triangles) {
729
+ return triangles
730
+ .map(
731
+ (triangle) =>
732
+ '(' +
733
+ triangle
734
+ .map((index) => String(Number(index || 0)))
735
+ .join(',') +
736
+ ')'
737
+ )
738
+ .join(',')
739
+ }
740
+
741
+ /**
742
+ * Returns clamped RGB components for one mesh color.
743
+ * @param {number[] | undefined} color Mesh RGB color.
744
+ * @returns {number[]}
745
+ */
746
+ static #colorComponents(color) {
747
+ const normalized = Array.isArray(color) ? color : [0.55, 0.56, 0.58]
748
+ return [0, 1, 2].map((index) =>
749
+ Math.max(Math.min(Number(normalized[index] || 0), 1), 0)
750
+ )
751
+ }
752
+
753
+ /**
754
+ * Tests whether a vertex has finite coordinates.
755
+ * @param {unknown} vertex Candidate vertex.
756
+ * @returns {boolean}
757
+ */
758
+ static #isFiniteVertex(vertex) {
759
+ return (
760
+ Array.isArray(vertex) &&
761
+ vertex.length >= 3 &&
762
+ Number.isFinite(Number(vertex[0])) &&
763
+ Number.isFinite(Number(vertex[1])) &&
764
+ Number.isFinite(Number(vertex[2]))
765
+ )
766
+ }
767
+
768
+ /**
769
+ * Subtracts two 3D vectors.
770
+ * @param {number[]} left Left vector.
771
+ * @param {number[]} right Right vector.
772
+ * @returns {number[]}
773
+ */
774
+ static #subtract(left, right) {
775
+ return [
776
+ Number(left?.[0] || 0) - Number(right?.[0] || 0),
777
+ Number(left?.[1] || 0) - Number(right?.[1] || 0),
778
+ Number(left?.[2] || 0) - Number(right?.[2] || 0)
779
+ ]
780
+ }
781
+
782
+ /**
783
+ * Computes one 3D cross product.
784
+ * @param {number[]} left Left vector.
785
+ * @param {number[]} right Right vector.
786
+ * @returns {number[]}
787
+ */
788
+ static #cross(left, right) {
789
+ return [
790
+ left[1] * right[2] - left[2] * right[1],
791
+ left[2] * right[0] - left[0] * right[2],
792
+ left[0] * right[1] - left[1] * right[0]
793
+ ]
794
+ }
795
+
796
+ /**
797
+ * Computes the length of one 3D vector.
798
+ * @param {number[]} vector Vector components.
799
+ * @returns {number}
800
+ */
801
+ static #distance(vector) {
802
+ return Math.sqrt(
803
+ Number(vector?.[0] || 0) ** 2 +
804
+ Number(vector?.[1] || 0) ** 2 +
805
+ Number(vector?.[2] || 0) ** 2
806
+ )
807
+ }
808
+
809
+ /**
810
+ * Normalizes one 3D vector.
811
+ * @param {number[]} vector Vector components.
812
+ * @returns {number[]}
813
+ */
814
+ static #normalize(vector) {
815
+ const length = PcbAssemblyStepWriter.#distance(vector)
816
+
817
+ if (length <= 0.0000001) {
818
+ return [0, 0, 0]
819
+ }
820
+
821
+ return [
822
+ Number(vector[0] || 0) / length,
823
+ Number(vector[1] || 0) / length,
824
+ Number(vector[2] || 0) / length
825
+ ]
826
+ }
827
+
828
+ /**
829
+ * Formats a STEP boolean literal.
830
+ * @param {boolean} value Boolean value.
831
+ * @returns {string}
832
+ */
833
+ static #stepBoolean(value) {
834
+ return value ? '.T.' : '.F.'
835
+ }
836
+
837
+ /**
838
+ * Returns true when dense geometry should use compact AP242 tessellation.
839
+ * @param {object[]} meshes Assembly meshes.
840
+ * @returns {boolean}
841
+ */
842
+ static #shouldUseTessellated(meshes) {
843
+ let faceCount = 0
844
+ let vertexCount = 0
845
+
846
+ for (const mesh of PcbAssemblyStepWriter.#array(meshes)) {
847
+ faceCount += PcbAssemblyStepWriter.#array(mesh?.faces).length
848
+ vertexCount += PcbAssemblyStepWriter.#array(mesh?.vertices).length
849
+ }
850
+
851
+ return (
852
+ meshes.length > TESSELLATED_MESH_THRESHOLD ||
853
+ faceCount > TESSELLATED_FACE_THRESHOLD ||
854
+ vertexCount > TESSELLATED_VERTEX_THRESHOLD
855
+ )
856
+ }
857
+
858
+ /**
859
+ * Adds one STEP entity and returns its numeric id.
860
+ * @param {string} body Entity body.
861
+ * @returns {number}
862
+ */
863
+ #add(body) {
864
+ const id = this.#nextId
865
+ this.#nextId += 1
866
+ this.#entities.push('#' + id + '=' + body)
867
+ return id
868
+ }
869
+
870
+ /**
871
+ * Builds the STEP header.
872
+ * @param {string} name Assembly name.
873
+ * @param {boolean} tessellated Whether AP242 tessellated schema should be used.
874
+ * @returns {string}
875
+ */
876
+ static #header(name, tessellated) {
877
+ const schema = tessellated
878
+ ? 'AP242_MANAGED_MODEL_BASED_3D_ENGINEERING_MIM_LF'
879
+ : 'AUTOMOTIVE_DESIGN_CC2'
880
+
881
+ return (
882
+ 'ISO-10303-21;\nHEADER;\n' +
883
+ "FILE_DESCRIPTION(('ECAD Forge PCB assembly export'),'2;1');\n" +
884
+ "FILE_NAME('" +
885
+ PcbAssemblyStepWriter.#escape(name) +
886
+ ".step','',('ECAD Forge'),('ECAD Forge'),'ECAD Forge','ECAD Forge','');\n" +
887
+ "FILE_SCHEMA(('" +
888
+ schema +
889
+ "'));\n" +
890
+ 'ENDSEC;\nDATA;\n'
891
+ )
892
+ }
893
+
894
+ /**
895
+ * Formats a STEP numeric literal.
896
+ * @param {number} value Numeric value.
897
+ * @returns {string}
898
+ */
899
+ static #formatNumber(value) {
900
+ const number = Number(value || 0)
901
+ return Math.abs(number) < 0.0000001
902
+ ? '0.'
903
+ : Number(number.toFixed(6)).toString()
904
+ }
905
+
906
+ /**
907
+ * Escapes STEP string content.
908
+ * @param {string} value Source value.
909
+ * @returns {string}
910
+ */
911
+ static #escape(value) {
912
+ return String(value || '').replaceAll("'", "''")
913
+ }
914
+
915
+ /**
916
+ * Formats STEP entity ids as a reference list.
917
+ * @param {unknown} value Candidate id list.
918
+ * @returns {string}
919
+ */
920
+ static #referenceList(value) {
921
+ const references = PcbAssemblyStepWriter.#array(value)
922
+ .map((id) => Number(id || 0))
923
+ .filter((id) => Number.isFinite(id) && id > 0)
924
+ .map((id) => '#' + id)
925
+
926
+ if (references.length <= 12) {
927
+ return references.join(',')
928
+ }
929
+
930
+ return (
931
+ '\n' +
932
+ references
933
+ .map(
934
+ (reference, index) =>
935
+ ' ' +
936
+ reference +
937
+ (index === references.length - 1 ? '' : ',')
938
+ )
939
+ .join('\n') +
940
+ '\n'
941
+ )
942
+ }
943
+
944
+ /**
945
+ * Normalizes a value to an array.
946
+ * @param {unknown} value Candidate value.
947
+ * @returns {any[]}
948
+ */
949
+ static #array(value) {
950
+ return Array.isArray(value) ? value : []
951
+ }
952
+ }