pcb-scene3d-viewer 1.1.21 → 1.1.31

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.
Files changed (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -3,6 +3,7 @@ import {
3
3
  CircuitJsonIndexer,
4
4
  CircuitJsonUnits
5
5
  } from 'circuitjson-toolkit'
6
+ import { PcbScene3dCircuitJsonModelTransform } from './PcbScene3dCircuitJsonModelTransform.mjs'
6
7
 
7
8
  const TOP_LAYER_ID = 1
8
9
  const BOTTOM_LAYER_ID = 32
@@ -12,6 +13,17 @@ const DEFAULT_BOARD_THICKNESS_MM = 1.6
12
13
  const DEFAULT_COMPONENT_HEIGHT_MIL = 60
13
14
  const RECTANGULAR_PAD_SHAPE = 2
14
15
  const CIRCULAR_PAD_SHAPE = 1
16
+ const CIRCLE_CUTOUT_POINTS = 32
17
+ const MODEL_URL_FIELDS = [
18
+ ['model_step_url', 'step'],
19
+ ['model_stp_url', 'step'],
20
+ ['model_wrl_url', 'wrl'],
21
+ ['model_vrml_url', 'wrl'],
22
+ ['model_glb_url', 'glb'],
23
+ ['model_gltf_url', 'gltf'],
24
+ ['model_stl_url', 'stl'],
25
+ ['model_obj_url', 'obj']
26
+ ]
15
27
 
16
28
  /**
17
29
  * Converts serialized CircuitJSON element arrays into the viewer render model.
@@ -46,16 +58,28 @@ export class PcbScene3dCircuitJsonAdapter {
46
58
  /**
47
59
  * Builds the internal render model used by the Three.js runtime.
48
60
  * @param {object[]} circuitJson Serialized CircuitJSON model.
61
+ * @param {{ modelUrlResolver?: (url: string, context: object) => string | object | null | undefined }} [options] Adapter options.
49
62
  * @returns {object}
50
63
  */
51
- static build(circuitJson) {
64
+ static build(circuitJson, options = {}) {
52
65
  CircuitJsonDocument.assertModel(circuitJson)
53
66
  const index = CircuitJsonIndexer.index(circuitJson)
54
67
  const board = PcbScene3dCircuitJsonAdapter.#buildBoard(index)
55
68
  const detail = PcbScene3dCircuitJsonAdapter.#buildDetail(index)
69
+ const externalPlacements =
70
+ PcbScene3dCircuitJsonAdapter.#buildExternalPlacements(
71
+ index,
72
+ board,
73
+ options
74
+ )
75
+ const externalModelByComponentId =
76
+ PcbScene3dCircuitJsonAdapter.#externalModelByComponentId(
77
+ externalPlacements
78
+ )
56
79
  const components = PcbScene3dCircuitJsonAdapter.#buildComponents(
57
80
  index,
58
- board
81
+ board,
82
+ externalModelByComponentId
59
83
  )
60
84
 
61
85
  return {
@@ -63,19 +87,22 @@ export class PcbScene3dCircuitJsonAdapter {
63
87
  coordinateSystem: 'circuitjson-mm',
64
88
  board,
65
89
  components,
66
- externalPlacements: [],
90
+ externalPlacements,
67
91
  boardAssemblyModel: null,
68
92
  detail
69
93
  }
70
94
  }
71
95
 
72
96
  /**
73
- * Builds the render-model board from a `pcb_board` element.
97
+ * Builds the render-model board from a `pcb_panel` or `pcb_board` element.
74
98
  * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
75
99
  * @returns {object}
76
100
  */
77
101
  static #buildBoard(index) {
78
- const boardElement = index.elementsByType.get('pcb_board')?.[0] || {}
102
+ const boardElement =
103
+ index.elementsByType.get('pcb_panel')?.[0] ||
104
+ index.elementsByType.get('pcb_board')?.[0] ||
105
+ {}
79
106
  const widthMil = CircuitJsonUnits.mmToMil(
80
107
  boardElement.width,
81
108
  DEFAULT_BOARD_WIDTH_MM
@@ -110,6 +137,7 @@ export class PcbScene3dCircuitJsonAdapter {
110
137
  centerX: center.x,
111
138
  centerY: center.y,
112
139
  segments,
140
+ cutouts: PcbScene3dCircuitJsonAdapter.#buildBoardCutouts(index),
113
141
  surfaceColor: null,
114
142
  edgeColor: null
115
143
  }
@@ -168,18 +196,129 @@ export class PcbScene3dCircuitJsonAdapter {
168
196
  })
169
197
  }
170
198
 
199
+ /**
200
+ * Builds explicit through-board cutout loops.
201
+ * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
202
+ * @returns {{ points: { x: number, y: number }[], sourceId?: string }[]}
203
+ */
204
+ static #buildBoardCutouts(index) {
205
+ return (index.elementsByType.get('pcb_cutout') || [])
206
+ .map((cutout) => PcbScene3dCircuitJsonAdapter.#buildCutout(cutout))
207
+ .filter(Boolean)
208
+ }
209
+
210
+ /**
211
+ * Builds one cutout loop.
212
+ * @param {object} cutout Cutout element.
213
+ * @returns {{ points: { x: number, y: number }[], sourceId?: string } | null}
214
+ */
215
+ static #buildCutout(cutout) {
216
+ const outline =
217
+ PcbScene3dCircuitJsonAdapter.#cutoutOutlinePoints(cutout)
218
+ if (outline.length < 3) {
219
+ return null
220
+ }
221
+
222
+ return {
223
+ points: outline,
224
+ sourceId: String(cutout?.pcb_cutout_id || cutout?.cutout_id || '')
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Resolves cutout outline points from polygon, rectangle, or circle fields.
230
+ * @param {object} cutout Cutout element.
231
+ * @returns {{ x: number, y: number }[]}
232
+ */
233
+ static #cutoutOutlinePoints(cutout) {
234
+ const points = PcbScene3dCircuitJsonAdapter.#array(
235
+ cutout?.points || cutout?.outline || cutout?.polygon || []
236
+ )
237
+ .map((point) => CircuitJsonUnits.pointMmToMil(point))
238
+ .filter((point) => Number.isFinite(point.x + point.y))
239
+ if (points.length >= 3) {
240
+ return points
241
+ }
242
+
243
+ const shape = String(cutout?.shape || '').toLowerCase()
244
+ if (shape.includes('circle') || Number(cutout?.radius || 0) > 0) {
245
+ return PcbScene3dCircuitJsonAdapter.#circleCutoutPoints(cutout)
246
+ }
247
+
248
+ return PcbScene3dCircuitJsonAdapter.#rectCutoutPoints(cutout)
249
+ }
250
+
251
+ /**
252
+ * Builds a rectangular cutout loop.
253
+ * @param {object} cutout Cutout element.
254
+ * @returns {{ x: number, y: number }[]}
255
+ */
256
+ static #rectCutoutPoints(cutout) {
257
+ const center = CircuitJsonUnits.pointMmToMil(
258
+ cutout?.center || {
259
+ x: cutout?.x,
260
+ y: cutout?.y
261
+ }
262
+ )
263
+ const width = CircuitJsonUnits.mmToMil(cutout?.width, 0)
264
+ const height = CircuitJsonUnits.mmToMil(cutout?.height, 0)
265
+ if (width <= 0 || height <= 0) {
266
+ return []
267
+ }
268
+
269
+ const halfWidth = width / 2
270
+ const halfHeight = height / 2
271
+ return [
272
+ { x: center.x - halfWidth, y: center.y - halfHeight },
273
+ { x: center.x + halfWidth, y: center.y - halfHeight },
274
+ { x: center.x + halfWidth, y: center.y + halfHeight },
275
+ { x: center.x - halfWidth, y: center.y + halfHeight }
276
+ ]
277
+ }
278
+
279
+ /**
280
+ * Builds a circular cutout loop.
281
+ * @param {object} cutout Cutout element.
282
+ * @returns {{ x: number, y: number }[]}
283
+ */
284
+ static #circleCutoutPoints(cutout) {
285
+ const center = CircuitJsonUnits.pointMmToMil(
286
+ cutout?.center || {
287
+ x: cutout?.x,
288
+ y: cutout?.y
289
+ }
290
+ )
291
+ const radius = CircuitJsonUnits.mmToMil(
292
+ cutout?.radius || Number(cutout?.diameter || 0) / 2,
293
+ 0
294
+ )
295
+ if (radius <= 0) {
296
+ return []
297
+ }
298
+
299
+ return Array.from({ length: CIRCLE_CUTOUT_POINTS }, (_entry, index) => {
300
+ const angle = (Math.PI * 2 * index) / CIRCLE_CUTOUT_POINTS
301
+ return {
302
+ x: center.x + Math.cos(angle) * radius,
303
+ y: center.y + Math.sin(angle) * radius
304
+ }
305
+ })
306
+ }
307
+
171
308
  /**
172
309
  * Builds component fallback bodies from `pcb_component` elements.
173
310
  * @param {{ elementsByType: Map<string, object[]>, sourceComponentById: Map<string, object> }} index CircuitJSON index.
174
311
  * @param {{ centerX: number, centerY: number, thicknessMil: number }} board Render board.
312
+ * @param {Map<string, object>} externalModelByComponentId External model metadata by PCB component ID.
175
313
  * @returns {object[]}
176
314
  */
177
- static #buildComponents(index, board) {
315
+ static #buildComponents(index, board, externalModelByComponentId) {
178
316
  return (index.elementsByType.get('pcb_component') || []).map(
179
317
  (component, componentIndex) => {
180
318
  const sourceComponent = index.sourceComponentById.get(
181
319
  String(component?.source_component_id || '')
182
320
  )
321
+ const componentId = String(component?.pcb_component_id || '')
183
322
  const center = CircuitJsonUnits.pointMmToMil(
184
323
  component?.center || {
185
324
  x: component?.x,
@@ -231,12 +370,266 @@ export class PcbScene3dCircuitJsonAdapter {
231
370
  height: Math.max(height, 10)
232
371
  }
233
372
  },
234
- externalModel: null
373
+ externalModel:
374
+ externalModelByComponentId.get(componentId) || null
235
375
  }
236
376
  }
237
377
  )
238
378
  }
239
379
 
380
+ /**
381
+ * Builds external component model placements from CAD metadata.
382
+ * @param {{ elementsByType: Map<string, object[]>, sourceComponentById: Map<string, object> }} index CircuitJSON index.
383
+ * @param {{ centerX: number, centerY: number, thicknessMil: number }} board Render board.
384
+ * @param {{ modelUrlResolver?: (url: string, context: object) => string | object | null | undefined }} options Adapter options.
385
+ * @returns {object[]}
386
+ */
387
+ static #buildExternalPlacements(index, board, options) {
388
+ const componentById = PcbScene3dCircuitJsonAdapter.#elementById(
389
+ index,
390
+ 'pcb_component',
391
+ 'pcb_component_id'
392
+ )
393
+ return (index.elementsByType.get('cad_component') || [])
394
+ .map((cadComponent, cadIndex) => {
395
+ const component = componentById.get(
396
+ String(cadComponent?.pcb_component_id || '')
397
+ )
398
+ return PcbScene3dCircuitJsonAdapter.#buildExternalPlacement(
399
+ cadComponent,
400
+ component,
401
+ index,
402
+ board,
403
+ options,
404
+ cadIndex
405
+ )
406
+ })
407
+ .filter(Boolean)
408
+ }
409
+
410
+ /**
411
+ * Builds one external model placement.
412
+ * @param {object} cadComponent CAD component element.
413
+ * @param {object | undefined} component PCB component element.
414
+ * @param {{ sourceComponentById: Map<string, object> }} index CircuitJSON index.
415
+ * @param {{ centerX: number, centerY: number, thicknessMil: number }} board Render board.
416
+ * @param {{ modelUrlResolver?: (url: string, context: object) => string | object | null | undefined }} options Adapter options.
417
+ * @param {number} cadIndex CAD component index.
418
+ * @returns {object | null}
419
+ */
420
+ static #buildExternalPlacement(
421
+ cadComponent,
422
+ component,
423
+ index,
424
+ board,
425
+ options,
426
+ cadIndex
427
+ ) {
428
+ const sourceComponent = index.sourceComponentById.get(
429
+ String(component?.source_component_id || '')
430
+ )
431
+ const externalModel = PcbScene3dCircuitJsonAdapter.#externalModel(
432
+ cadComponent,
433
+ component,
434
+ sourceComponent,
435
+ options,
436
+ cadIndex
437
+ )
438
+ if (!externalModel) {
439
+ return null
440
+ }
441
+
442
+ const mountSide = PcbScene3dCircuitJsonAdapter.#layerSide(
443
+ component?.layer || cadComponent?.layer
444
+ )
445
+ return {
446
+ designator: PcbScene3dCircuitJsonAdapter.#componentDesignator(
447
+ component || cadComponent,
448
+ sourceComponent,
449
+ cadIndex
450
+ ),
451
+ pcbComponentId: String(component?.pcb_component_id || ''),
452
+ mountSide,
453
+ rotationDeg: PcbScene3dCircuitJsonAdapter.#placementRotationDeg(
454
+ cadComponent,
455
+ component
456
+ ),
457
+ positionMil: PcbScene3dCircuitJsonAdapter.#placementPositionMil(
458
+ cadComponent,
459
+ component,
460
+ board,
461
+ mountSide
462
+ ),
463
+ modelTransform:
464
+ PcbScene3dCircuitJsonModelTransform.build(cadComponent),
465
+ ...PcbScene3dCircuitJsonModelTransform.displayMetadata(
466
+ cadComponent
467
+ ),
468
+ externalModel
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Builds an external model metadata object.
474
+ * @param {object} cadComponent CAD component element.
475
+ * @param {object | undefined} component PCB component element.
476
+ * @param {object | undefined} sourceComponent Source component element.
477
+ * @param {{ modelUrlResolver?: (url: string, context: object) => string | object | null | undefined }} options Adapter options.
478
+ * @param {number} cadIndex CAD component index.
479
+ * @returns {object | null}
480
+ */
481
+ static #externalModel(
482
+ cadComponent,
483
+ component,
484
+ sourceComponent,
485
+ options,
486
+ cadIndex
487
+ ) {
488
+ const match = MODEL_URL_FIELDS.find(([field]) =>
489
+ String(cadComponent?.[field] || '').trim()
490
+ )
491
+ if (!match) {
492
+ return null
493
+ }
494
+
495
+ const [field, format] = match
496
+ const sourceUrl = String(cadComponent?.[field] || '').trim()
497
+ const context = {
498
+ format,
499
+ field,
500
+ cadComponent,
501
+ component,
502
+ sourceComponent,
503
+ index: cadIndex
504
+ }
505
+
506
+ return {
507
+ format,
508
+ name: PcbScene3dCircuitJsonAdapter.#fileNameFromUrl(sourceUrl),
509
+ sourceUrl,
510
+ ...PcbScene3dCircuitJsonAdapter.#resolveModelUrl(
511
+ sourceUrl,
512
+ context,
513
+ options
514
+ )
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Applies optional caller-owned model URL resolution.
520
+ * @param {string} sourceUrl Source URL.
521
+ * @param {object} context Resolver context.
522
+ * @param {{ modelUrlResolver?: (url: string, context: object) => string | object | null | undefined }} options Adapter options.
523
+ * @returns {object}
524
+ */
525
+ static #resolveModelUrl(sourceUrl, context, options) {
526
+ const resolver = options?.modelUrlResolver
527
+ if (typeof resolver !== 'function') {
528
+ return {}
529
+ }
530
+
531
+ const resolved = resolver(sourceUrl, context)
532
+ if (typeof resolved === 'string') {
533
+ return { resolvedUrl: resolved }
534
+ }
535
+ if (resolved && typeof resolved === 'object') {
536
+ return { ...resolved }
537
+ }
538
+ return {}
539
+ }
540
+
541
+ /**
542
+ * Builds a component ID to external-model map.
543
+ * @param {object[]} placements External placements.
544
+ * @returns {Map<string, object>}
545
+ */
546
+ static #externalModelByComponentId(placements) {
547
+ const map = new Map()
548
+ placements.forEach((placement) => {
549
+ const componentId = String(placement?.pcbComponentId || '')
550
+ if (componentId && placement?.externalModel) {
551
+ map.set(componentId, placement.externalModel)
552
+ }
553
+ })
554
+ return map
555
+ }
556
+
557
+ /**
558
+ * Builds a map keyed by one element ID field.
559
+ * @param {{ elementsByType: Map<string, object[]> }} index CircuitJSON index.
560
+ * @param {string} type Element type.
561
+ * @param {string} idField ID field.
562
+ * @returns {Map<string, object>}
563
+ */
564
+ static #elementById(index, type, idField) {
565
+ const map = new Map()
566
+ ;(index.elementsByType.get(type) || []).forEach((element) => {
567
+ const id = String(element?.[idField] || '')
568
+ if (id) {
569
+ map.set(id, element)
570
+ }
571
+ })
572
+ return map
573
+ }
574
+
575
+ /**
576
+ * Resolves placement position in board-local mils.
577
+ * @param {object} cadComponent CAD component element.
578
+ * @param {object | undefined} component PCB component element.
579
+ * @param {{ centerX: number, centerY: number, thicknessMil: number }} board Render board.
580
+ * @param {'top' | 'bottom'} mountSide Mount side.
581
+ * @returns {{ x: number, y: number, z: number }}
582
+ */
583
+ static #placementPositionMil(cadComponent, component, board, mountSide) {
584
+ const point = CircuitJsonUnits.pointMmToMil(
585
+ cadComponent?.position ||
586
+ component?.center || {
587
+ x: component?.x,
588
+ y: component?.y
589
+ }
590
+ )
591
+ const fallbackZ =
592
+ mountSide === 'bottom'
593
+ ? -Number(board?.thicknessMil || 0) / 2
594
+ : Number(board?.thicknessMil || 0) / 2
595
+ const z = Number.isFinite(Number(cadComponent?.position?.z))
596
+ ? CircuitJsonUnits.mmToMil(cadComponent.position.z, 0)
597
+ : fallbackZ
598
+
599
+ return {
600
+ x: point.x - Number(board?.centerX || 0),
601
+ y: point.y - Number(board?.centerY || 0),
602
+ z
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Resolves placement rotation in board space.
608
+ * @param {object} cadComponent CAD component element.
609
+ * @param {object | undefined} component PCB component element.
610
+ * @returns {number}
611
+ */
612
+ static #placementRotationDeg(cadComponent, component) {
613
+ return Number(
614
+ cadComponent?.rotation?.z ??
615
+ cadComponent?.rotationDeg ??
616
+ cadComponent?.rotation ??
617
+ component?.rotation ??
618
+ component?.ccw_rotation ??
619
+ 0
620
+ )
621
+ }
622
+
623
+ /**
624
+ * Extracts a file name from a URL-like model reference.
625
+ * @param {string} sourceUrl Source URL.
626
+ * @returns {string}
627
+ */
628
+ static #fileNameFromUrl(sourceUrl) {
629
+ const cleanUrl = String(sourceUrl || '').split(/[?#]/u)[0]
630
+ return cleanUrl.split('/').filter(Boolean).pop() || 'model'
631
+ }
632
+
240
633
  /**
241
634
  * Resolves a display designator for one CircuitJSON component.
242
635
  * @param {object} component PCB component element.
@@ -542,4 +935,13 @@ export class PcbScene3dCircuitJsonAdapter {
542
935
  ? 'bottom'
543
936
  : 'top'
544
937
  }
938
+
939
+ /**
940
+ * Normalizes a value to an array.
941
+ * @param {unknown} value Candidate value.
942
+ * @returns {any[]}
943
+ */
944
+ static #array(value) {
945
+ return Array.isArray(value) ? value : []
946
+ }
545
947
  }