pcb-scene3d-viewer 1.2.1 → 1.3.0

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 (34) hide show
  1. package/README.md +16 -1
  2. package/docs/api.md +26 -0
  3. package/docs/circuitjson.md +21 -8
  4. package/docs/release-notes-v1.2.2.md +33 -0
  5. package/docs/release-notes-v1.3.0.md +38 -0
  6. package/package.json +4 -2
  7. package/src/CircuitJsonCadModelAssetResolver.mjs +24 -10
  8. package/src/PcbAssemblyGltfModelMeshParser.mjs +3 -1
  9. package/src/PcbAssemblyModelMeshLoader.mjs +1 -1
  10. package/src/PcbAssemblyTextModelMeshParser.mjs +3 -1
  11. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -11
  12. package/src/PcbScene3dBoardMaterialPalette.mjs +12 -0
  13. package/src/PcbScene3dCircuitJsonAdapter.mjs +34 -86
  14. package/src/PcbScene3dCircuitJsonCopperPourBuilder.mjs +6 -1
  15. package/src/PcbScene3dCircuitJsonCopperTextBuilder.mjs +385 -0
  16. package/src/PcbScene3dCircuitJsonDocumentationArtworkBuilder.mjs +123 -22
  17. package/src/PcbScene3dCircuitJsonGeometry.mjs +12 -0
  18. package/src/PcbScene3dCircuitJsonPadCorner.mjs +72 -0
  19. package/src/PcbScene3dCircuitJsonSilkscreenBuilder.mjs +140 -25
  20. package/src/PcbScene3dCircuitJsonSilkscreenDetailBuilder.mjs +21 -0
  21. package/src/PcbScene3dCircuitJsonSourceLayer.mjs +124 -0
  22. package/src/PcbScene3dCircuitJsonTraceRouteBuilder.mjs +56 -17
  23. package/src/PcbScene3dCopperDetailFilter.mjs +5 -1
  24. package/src/PcbScene3dCopperFactory.mjs +23 -4
  25. package/src/PcbScene3dCopperTextFactory.mjs +105 -20
  26. package/src/PcbScene3dExternalModels.mjs +26 -0
  27. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +16 -1
  28. package/src/PcbScene3dModelContent.mjs +32 -1
  29. package/src/PcbScene3dRuntimeBoardMeshes.mjs +2 -4
  30. package/src/PcbScene3dSilkscreenCopperCutoutBuilder.mjs +588 -0
  31. package/src/PcbScene3dStepLoader.mjs +2 -1
  32. package/src/PcbScene3dStrokeCutoutBuilder.mjs +98 -0
  33. package/src/PcbScene3dViaFactory.mjs +51 -5
  34. package/src/PcbScene3dViaLayerSpan.mjs +126 -0
package/README.md CHANGED
@@ -22,9 +22,15 @@ factories, model loading, component picking, view presets, archive export,
22
22
  GLTF/GLB assembly writing, and optional DOM shell can be reused by other
23
23
  browser-based ECAD tools.
24
24
 
25
+ Version 1.3.0 renders the CircuitJSON 1.2 fidelity contract directly: exact
26
+ oval and rounded pads, filled and mirrored silkscreen, copper text and cutouts,
27
+ surface-aware blind/buried vias, collision-safe model assets, and a consistent
28
+ light FR-4 substrate edge. See the
29
+ [1.3.0 release notes](docs/release-notes-v1.3.0.md).
30
+
25
31
  ## CircuitJSON 1.1 convergence
26
32
 
27
- Version 1.2.1 accepts the common document and prepared-context shapes returned
33
+ Version 1.2.2 accepts the common document and prepared-context shapes returned
28
34
  by CircuitJSON, Gerber, Altium, and KiCad Toolkit 1.1-compatible APIs. The
29
35
  adapter requests the shared `elements` index once and reuses it across repeated
30
36
  scene builds. `PcbScene3dCircuitJsonAdapter.prepare()` exposes that proof-aware
@@ -47,6 +53,13 @@ Legal rectangular and square CircuitJSON drill apertures retain their exact
47
53
  width, height, and board-space rotation through substrate, pad, and assembly
48
54
  export meshes.
49
55
 
56
+ Canonical documents retain their exact `source.format` as the scene
57
+ `sourceFormat`; raw element arrays continue to use `circuitjson`. Routed traces
58
+ and copper pours with no authored solder-mask coverage value remain covered,
59
+ while `covered_with_solder_mask: false` keeps an explicit opening exposed.
60
+ Standard vias likewise default to tented and honor `is_tented: false` as an
61
+ explicit opening.
62
+
50
63
  The live runtime loads STEP/STP, WRL/VRML, STL, OBJ, GLTF/GLB, and 3MF from
51
64
  canonical text/bytes or browser files. Referenced GLTF buffers, OBJ material
52
65
  libraries, and WRL textures are attached from matching document/session assets
@@ -123,6 +136,8 @@ const controller = new PcbScene3dController(viewportNode, document)
123
136
 
124
137
  - [API](docs/api.md)
125
138
  - [CircuitJSON usage](docs/circuitjson.md)
139
+ - [1.2.2 release notes](docs/release-notes-v1.2.2.md)
140
+ - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
126
141
  - [1.2.1 release notes](docs/release-notes-v1.2.1.md)
127
142
  - [1.2.0 release notes](docs/release-notes-v1.2.0.md)
128
143
  - [Model format](docs/model-format.md)
package/docs/api.md CHANGED
@@ -68,6 +68,24 @@ controller only and are forwarded to the runtime through
68
68
 
69
69
  ## CircuitJSON Input
70
70
 
71
+ ### `PcbScene3dBoardMaterialPalette`
72
+
73
+ The `pcb-scene3d-viewer/scene3d` subpath exports the shared board-material
74
+ resolver used by both runtime meshes and assembly export.
75
+
76
+ - `resolveSurfaceColor(board, options?)` returns the authored or fallback
77
+ solder-mask face color.
78
+ - `resolveBoardSurfaceColor(board, options?)` returns the display-darkened
79
+ solder-mask face color.
80
+ - `resolveEdgeColor(board)` returns an authored `edgeColor` or the light FR-4
81
+ substrate fallback `0xc9ca78`.
82
+ - `isGeneratedSurfaceVisible(options?)` and
83
+ `isGeneratedBodyVisible(options?)` expose the generated-board visibility
84
+ decision.
85
+
86
+ Runtime and exported board edges call the same resolver, so a missing edge
87
+ color cannot produce different substrate materials in the two paths.
88
+
71
89
  ### `PcbScene3dCircuitJsonAdapter`
72
90
 
73
91
  Converts a common CircuitJSON `DocumentResult`, prepared
@@ -108,6 +126,14 @@ Methods:
108
126
  and courtyard artwork as silkscreen detail. `showPcbPaste: true` renders
109
127
  direct solder-paste artwork as a separate top/bottom overlay.
110
128
 
129
+ For canonical document and prepared-context inputs, the returned scene's
130
+ `sourceFormat` is the exact canonical `source.format`. Dense element arrays,
131
+ which do not carry source metadata, retain the `circuitjson` fallback. Routed
132
+ traces and copper pours default to covered when their coverage property is
133
+ omitted; an explicit `covered_with_solder_mask: false` remains an exposed
134
+ opening. Standard vias default to tented and honor `is_tented: false` as an
135
+ explicit opening.
136
+
111
137
  `PcbScene3dController` and `PcbScene3dRuntime` call this adapter automatically
112
138
  when they receive direct CircuitJSON input. See
113
139
  [CircuitJSON usage](circuitjson.md) for supported elements, units, and examples.
@@ -14,6 +14,10 @@ KiCad, Gerber, or other format-specific scene builder.
14
14
  Prepared contexts are the fastest repeated-render path because validation and
15
15
  the adapter's `elements` index are built at most once.
16
16
 
17
+ Canonical document envelopes and prepared contexts retain `source.format` as
18
+ the normalized scene's `sourceFormat`. Dense element arrays have no canonical
19
+ source metadata and therefore use `circuitjson`.
20
+
17
21
  ## Direct Controller Input
18
22
 
19
23
  Pass any accepted CircuitJSON shape as the `documentModel`. The controller
@@ -403,13 +407,15 @@ expected capsule outline:
403
407
 
404
408
  SMT pads, plated holes, and vias honor `is_covered_with_solder_mask` and
405
409
  `covered_with_solder_mask` when present. `true` keeps the copper under solder
406
- mask, while `false` exposes the copper on the applicable board side. For vias,
407
- covered values map to tenting metadata on both sides.
410
+ mask, while `false` exposes the copper on the applicable board side. Standard
411
+ vias also honor `is_tented`; omitted via tenting defaults to covered on both
412
+ sides, while `is_tented: false` retains an explicit opening.
408
413
 
409
414
  Copper pours can use rectangular, polygon, or B-Rep geometry. Rectangular pours
410
415
  support `rotation` or `ccw_rotation`; polygon and B-Rep point coordinates are
411
- converted from millimeters to mils. `covered_with_solder_mask: true` renders the
412
- zone under solder mask, while false or omitted values expose the copper:
416
+ converted from millimeters to mils. Omitted or true
417
+ `covered_with_solder_mask` renders the zone under solder mask, while an
418
+ explicit false value exposes the copper:
413
419
 
414
420
  ```js
415
421
  {
@@ -424,10 +430,12 @@ zone under solder mask, while false or omitted values expose the copper:
424
430
  }
425
431
  ```
426
432
 
427
- Silkscreen text uses `anchor_position` when available, falling back to `x` and
428
- `y`. Common `anchor_alignment` values such as `center`, `bottom_left`, or
429
- `top_right` are normalized into horizontal and vertical alignment metadata for
430
- stroke text rendering.
433
+ Silkscreen and copper text use `anchor_position` when available, falling back
434
+ to `x` and `y`. Independent `font_width` and `font_height`, `stroke_width`,
435
+ `source_anchor_alignment`, source layer/type provenance, mirroring, and hidden
436
+ state are honored when present. Filled silkscreen shapes retain their fill and
437
+ drill/copper cutouts, and bottom-side artwork is mirrored around its authored
438
+ anchor instead of being repositioned as top-side text.
431
439
 
432
440
  Component model metadata can be supplied on `cad_component` elements with
433
441
  `model_3mf_url`, `model_step_url`, `model_wrl_url`, `model_glb_url`,
@@ -450,6 +458,11 @@ one track segment:
450
458
  }
451
459
  ```
452
460
 
461
+ Trace-level or route-entry `covered_with_solder_mask` values control the
462
+ rendered opening. Omitted values default to covered; an explicit false value
463
+ remains exposed. A route-entry value takes precedence over its trace-level
464
+ value.
465
+
453
466
  Route entries with `route_type: 'via'` produce via primitives when their
454
467
  `from_layer`, `to_layer`, or `layer` touches the top or bottom surface. Adjacent
455
468
  surface wire segments remain visible through those vias. Inner-only vias and
@@ -0,0 +1,33 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 André Fiedler
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # PCB Scene3D Viewer 1.2.2
7
+
8
+ Version 1.2.2 preserves canonical source identity and aligns solder-mask
9
+ coverage with the converged CircuitJSON contract.
10
+
11
+ ## API and behavior changes
12
+
13
+ - Canonical document and prepared-context inputs now retain their exact
14
+ `source.format` as the rendered scene's `sourceFormat`. Dense CircuitJSON
15
+ arrays continue to use the `circuitjson` fallback because they carry no
16
+ canonical source metadata.
17
+ - Routed traces and copper pours now default omitted
18
+ `covered_with_solder_mask` values to covered. Explicit false values remain
19
+ exposed through the solder mask.
20
+ - Standard vias now honor canonical `is_tented` metadata. Omitted values
21
+ default to tented, while `is_tented: false` remains exposed.
22
+ - Covered canonical copper follows the existing solder-mask material palette,
23
+ including tracks, pours, and via annuli.
24
+
25
+ Consumers that used the generic `circuitjson` marker for canonical documents
26
+ must now handle the retained source identity such as `gerber`, `altium`, or
27
+ `kicad`.
28
+
29
+ ## Dependencies and validation
30
+
31
+ - Requires `circuitjson-toolkit ^1.1.2` and Node.js 20 or newer.
32
+ - The full test suite and both owned performance benchmarks cover the canonical
33
+ source, coverage, material, exact-geometry, and prepared-context paths.
@@ -0,0 +1,38 @@
1
+ # pcb-scene3d-viewer 1.3.0
2
+
3
+ This minor release consumes CircuitJSON Toolkit 1.2 directly and restores exact
4
+ PCB surface fidelity without source-format or host-app adapters.
5
+
6
+ ## Rendering and API changes
7
+
8
+ - Oval, rounded-rectangle, pill, polygon, and independently rotated drilled
9
+ pads keep their authored copper and drill geometry.
10
+ - Silkscreen strokes, fills, text dimensions, anchors, mirroring, source-layer
11
+ mapping, and surface cutouts render from canonical CircuitJSON fields.
12
+ - Copper text participates in the same solder-mask and cutout pipeline as other
13
+ copper detail.
14
+ - Blind and buried vias render only on the board surfaces reached by their
15
+ explicit layer span.
16
+ - `PcbScene3dBoardMaterialPalette.resolveEdgeColor(board)` is now public on the
17
+ `scene3d` subpath. Runtime and exported board edges share its authored-color
18
+ lookup and light FR-4 fallback (`0xc9ca78`).
19
+
20
+ ## Models and diagnostics
21
+
22
+ - Canonical and session CAD assets preserve exact case-sensitive project-path
23
+ aliases. Case-insensitive fallback is accepted only when unique, preventing
24
+ same-name and same-stem collisions across folders.
25
+ - Missing-model diagnostics are deferred until resolution has actually failed;
26
+ retryable or externally supplied assets are not reported as unavailable
27
+ prematurely.
28
+ - STEP, WRL, GLTF, and text/binary model payload handling avoids redundant
29
+ copies and retains the existing opt-in network policy.
30
+
31
+ ## Compatibility and performance
32
+
33
+ - The runtime baseline is `circuitjson-toolkit@^1.2.0`; the existing
34
+ `@sunbox/occt-import-js@^0.0.28` WASM package remains compatible and does not
35
+ require a coordinated rebuild.
36
+ - Existing scene descriptions, document envelopes, controller/runtime
37
+ parameters, and return shapes remain accepted. The palette method and
38
+ CircuitJSON fields above are additive.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -35,6 +35,8 @@
35
35
  "docs/circuitjson.md",
36
36
  "docs/release-notes-v1.2.0.md",
37
37
  "docs/release-notes-v1.2.1.md",
38
+ "docs/release-notes-v1.2.2.md",
39
+ "docs/release-notes-v1.3.0.md",
38
40
  "docs/model-format.md",
39
41
  "docs/testing.md",
40
42
  "spec",
@@ -56,7 +58,7 @@
56
58
  },
57
59
  "dependencies": {
58
60
  "@sunbox/occt-import-js": "^0.0.28",
59
- "circuitjson-toolkit": "^1.1.0",
61
+ "circuitjson-toolkit": "^1.2.0",
60
62
  "earcut": "3.0.2",
61
63
  "fflate": "^0.8.2",
62
64
  "polygon-clipping": "^0.15.7",
@@ -352,7 +352,7 @@ export class CircuitJsonCadModelAssetResolver {
352
352
  * Finds the earliest indexed asset matching one CAD model URL.
353
353
  * @param {string} url Model URL.
354
354
  * @param {object} context Resolver context.
355
- * @param {Map<string, object>} assetIndex Asset alias index.
355
+ * @param {{ exact: Map<string, object | symbol>, folded: Map<string, object | symbol> }} assetIndex Asset alias index.
356
356
  * @returns {object | null}
357
357
  */
358
358
  static #matchingIndexedEntry(url, context, assetIndex) {
@@ -363,7 +363,11 @@ export class CircuitJsonCadModelAssetResolver {
363
363
  let match = null
364
364
  for (const candidate of candidates) {
365
365
  const indexed = assetIndex.exact.get(candidate)
366
- if (indexed && (!match || indexed.index < match.index)) {
366
+ if (
367
+ indexed &&
368
+ indexed !== AMBIGUOUS_ASSET &&
369
+ (!match || indexed.index < match.index)
370
+ ) {
367
371
  match = indexed
368
372
  }
369
373
  }
@@ -554,12 +558,13 @@ export class CircuitJsonCadModelAssetResolver {
554
558
 
555
559
  /**
556
560
  * Resolves an exact alias or one unambiguous case-insensitive fallback.
557
- * @param {{ exact: Map<string, object>, folded: Map<string, object | symbol> }} index Asset index.
561
+ * @param {{ exact: Map<string, object | symbol>, folded: Map<string, object | symbol> }} index Asset index.
558
562
  * @param {string} key Exact normalized key.
559
563
  * @returns {object | null} Indexed asset entry.
560
564
  */
561
565
  static #indexedAsset(index, key) {
562
566
  const exact = index.exact.get(key)
567
+ if (exact === AMBIGUOUS_ASSET) return null
563
568
  if (exact) return exact
564
569
  const folded = index.folded.get(key.toLowerCase())
565
570
  return folded && folded !== AMBIGUOUS_ASSET ? folded : null
@@ -606,10 +611,10 @@ export class CircuitJsonCadModelAssetResolver {
606
611
  }
607
612
 
608
613
  /**
609
- * Builds one descriptor-safe, first-match-preserving asset alias index.
614
+ * Builds one descriptor-safe asset alias index with collision rejection.
610
615
  * @param {object[]} assets Asset rows.
611
616
  * @param {boolean} [canonical] Whether rows use the canonical toolkit asset contract.
612
- * @returns {{ exact: Map<string, object>, folded: Map<string, object | symbol> }}
617
+ * @returns {{ exact: Map<string, object | symbol>, folded: Map<string, object | symbol> }}
613
618
  */
614
619
  static #assetIndex(assets, canonical = false) {
615
620
  const exact = new Map()
@@ -625,7 +630,12 @@ export class CircuitJsonCadModelAssetResolver {
625
630
  for (const alias of CircuitJsonCadModelAssetResolver.#assetAliases(
626
631
  asset
627
632
  )) {
628
- if (!exact.has(alias)) exact.set(alias, entry)
633
+ const exactPrevious = exact.get(alias)
634
+ if (!exactPrevious) {
635
+ exact.set(alias, entry)
636
+ } else if (exactPrevious !== entry) {
637
+ exact.set(alias, AMBIGUOUS_ASSET)
638
+ }
629
639
  const foldedAlias = alias.toLowerCase()
630
640
  const previous = folded.get(foldedAlias)
631
641
  if (!previous) {
@@ -719,6 +729,9 @@ export class CircuitJsonCadModelAssetResolver {
719
729
  asset,
720
730
  'source'
721
731
  )
732
+ const explicitAliases = CircuitJsonCadModelAssetResolver.#denseArray(
733
+ CircuitJsonCadModelAssetResolver.#ownData(asset, 'aliases')
734
+ ).filter((value) => typeof value === 'string')
722
735
  return new Set(
723
736
  [
724
737
  CircuitJsonCadModelAssetResolver.#ownData(
@@ -742,7 +755,8 @@ export class CircuitJsonCadModelAssetResolver {
742
755
  'relativePath'
743
756
  ),
744
757
  CircuitJsonCadModelAssetResolver.#ownData(source, 'url'),
745
- CircuitJsonCadModelAssetResolver.#ownData(source, 'uri')
758
+ CircuitJsonCadModelAssetResolver.#ownData(source, 'uri'),
759
+ ...explicitAliases
746
760
  ]
747
761
  .map((value) => CircuitJsonCadModelAssetResolver.#key(value))
748
762
  .filter(Boolean)
@@ -866,10 +880,10 @@ export class CircuitJsonCadModelAssetResolver {
866
880
  */
867
881
  static #key(value) {
868
882
  if (typeof value !== 'string' && typeof value !== 'number') return ''
869
- return String(value || '')
883
+ const key = String(value || '')
870
884
  .trim()
871
885
  .split(/[?#]/u)[0]
872
- .replace(/^https?:\/\/[^/]+\//iu, '')
873
- .replace(/^\/+/u, '')
886
+ .replaceAll('\\', '/')
887
+ return /^https?:\/\//iu.test(key) ? key : key.replace(/^\/+/u, '')
874
888
  }
875
889
  }
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
2
+
1
3
  const MM_TO_MIL = 1000 / 25.4
2
4
  const GLB_MAGIC = 0x46546c67
3
5
  const GLB_VERSION = 2
@@ -887,7 +889,7 @@ export class PcbAssemblyGltfModelMeshParser {
887
889
  return bytes
888
890
  }
889
891
 
890
- throw new Error(label + ' model content is not available.')
892
+ throw PcbScene3dModelContent.unavailableError(label)
891
893
  }
892
894
 
893
895
  /**
@@ -334,7 +334,7 @@ export class PcbAssemblyModelMeshLoader {
334
334
  if (bytes) return new TextDecoder().decode(bytes)
335
335
  }
336
336
 
337
- throw new Error('WRL model content is not available.')
337
+ throw PcbScene3dModelContent.unavailableError('WRL')
338
338
  }
339
339
 
340
340
  /**
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
2
+
1
3
  const MM_TO_MIL = 1000 / 25.4
2
4
 
3
5
  /**
@@ -548,7 +550,7 @@ export class PcbAssemblyTextModelMeshParser {
548
550
  return bytes
549
551
  }
550
552
 
551
- throw new Error(label + ' model content is not available.')
553
+ throw PcbScene3dModelContent.unavailableError(label)
552
554
  }
553
555
 
554
556
  /**
@@ -35,8 +35,7 @@ export class PcbScene3dBoardAssemblyPresentation {
35
35
  board,
36
36
  options
37
37
  )
38
- const edgeColor =
39
- PcbScene3dBoardAssemblyPresentation.#resolveEdgeColor(board)
38
+ const edgeColor = PcbScene3dBoardMaterialPalette.resolveEdgeColor(board)
40
39
  const importedSurfaceColor =
41
40
  PcbScene3dBoardAssemblyPresentation.#resolveImportedSurfaceColor(
42
41
  meshRecords
@@ -426,15 +425,6 @@ export class PcbScene3dBoardAssemblyPresentation {
426
425
  })
427
426
  }
428
427
 
429
- /**
430
- * Resolves the app-level substrate edge color for board assembly meshes.
431
- * @param {{ edgeColor?: number } | null | undefined} board Board dimensions.
432
- * @returns {number}
433
- */
434
- static #resolveEdgeColor(board) {
435
- return Number.isInteger(board?.edgeColor) ? board.edgeColor : 0xc9ca78
436
- }
437
-
438
428
  /**
439
429
  * Applies display treatment to board substrate material.
440
430
  * @param {any | any[]} material Material or material list.
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export class PcbScene3dBoardMaterialPalette {
5
5
  static #DEFAULT_SURFACE_COLOR = 0x2a5f27
6
+ static #DEFAULT_EDGE_COLOR = 0xc9ca78
6
7
  static #BOARD_SURFACE_DARKEN_RATIO = 0.88
7
8
 
8
9
  /**
@@ -39,6 +40,17 @@ export class PcbScene3dBoardMaterialPalette {
39
40
  )
40
41
  }
41
42
 
43
+ /**
44
+ * Resolves the visible substrate-core color for board edge faces.
45
+ * @param {{ edgeColor?: number } | null | undefined} board Board metadata.
46
+ * @returns {number}
47
+ */
48
+ static resolveEdgeColor(board) {
49
+ return Number.isInteger(board?.edgeColor)
50
+ ? board.edgeColor
51
+ : PcbScene3dBoardMaterialPalette.#DEFAULT_EDGE_COLOR
52
+ }
53
+
42
54
  /**
43
55
  * Returns true when the source format supplies display-stable board colors.
44
56
  * @param {{ sourceFormat?: string }} options Scene options.
@@ -7,7 +7,8 @@ import { PcbScene3dCircuitJsonGeometry } from './PcbScene3dCircuitJsonGeometry.m
7
7
  import { PcbScene3dCircuitJsonDrillDetail } from './PcbScene3dCircuitJsonDrillDetail.mjs'
8
8
  import { PcbScene3dCircuitJsonModelTransform } from './PcbScene3dCircuitJsonModelTransform.mjs'
9
9
  import { PcbScene3dCircuitJsonCopperPourBuilder } from './PcbScene3dCircuitJsonCopperPourBuilder.mjs'
10
- import { PcbScene3dCircuitJsonSilkscreenBuilder } from './PcbScene3dCircuitJsonSilkscreenBuilder.mjs'
10
+ import { PcbScene3dCircuitJsonCopperTextBuilder } from './PcbScene3dCircuitJsonCopperTextBuilder.mjs'
11
+ import { PcbScene3dCircuitJsonSilkscreenDetailBuilder } from './PcbScene3dCircuitJsonSilkscreenDetailBuilder.mjs'
11
12
  import { PcbScene3dFootprintBodyBuilder } from './PcbScene3dFootprintBodyBuilder.mjs'
12
13
  import { PcbScene3dCircuitJsonLayer } from './PcbScene3dCircuitJsonLayer.mjs'
13
14
  import { PcbScene3dCircuitJsonInput } from './PcbScene3dCircuitJsonInput.mjs'
@@ -18,6 +19,7 @@ import { PcbScene3dCircuitJsonSolderPasteBuilder } from './PcbScene3dCircuitJson
18
19
  import { CircuitJsonCadModelAssetResolver } from './CircuitJsonCadModelAssetResolver.mjs'
19
20
  import { PcbScene3dDescriptorSafeRecord } from './PcbScene3dDescriptorSafeRecord.mjs'
20
21
  import { PcbScene3dCircuitJsonModelAsset } from './PcbScene3dCircuitJsonModelAsset.mjs'
22
+ import { PcbScene3dCircuitJsonPadCorner } from './PcbScene3dCircuitJsonPadCorner.mjs'
21
23
 
22
24
  const DEFAULT_COMPONENT_HEIGHT_MIL = 60
23
25
  const RECTANGULAR_PAD_SHAPE = 2
@@ -131,7 +133,7 @@ export class PcbScene3dCircuitJsonAdapter {
131
133
  )
132
134
 
133
135
  return {
134
- sourceFormat: 'circuitjson',
136
+ sourceFormat: context.source?.format || 'circuitjson',
135
137
  coordinateSystem: 'circuitjson-mm',
136
138
  board,
137
139
  components,
@@ -682,27 +684,34 @@ export class PcbScene3dCircuitJsonAdapter {
682
684
  index,
683
685
  options
684
686
  )
687
+ const pads = [
688
+ ...PcbScene3dCircuitJsonAdapter.#buildSmtPads(index),
689
+ ...PcbScene3dCircuitJsonAdapter.#buildPlatedHoles(index),
690
+ ...PcbScene3dCircuitJsonAdapter.#buildNonPlatedHoles(index)
691
+ ]
692
+ const vias = PcbScene3dCircuitJsonTraceRouteBuilder.buildVias(index)
693
+ const tracks = [
694
+ ...PcbScene3dCircuitJsonTraceRouteBuilder.buildTracks(index),
695
+ ...PcbScene3dCircuitJsonThermalSpokeBuilder.build(index)
696
+ ]
697
+ const polygons = PcbScene3dCircuitJsonCopperPourBuilder.build(index)
698
+ const copperTexts = PcbScene3dCircuitJsonCopperTextBuilder.build(index)
699
+ const silkscreen = PcbScene3dCircuitJsonSilkscreenDetailBuilder.build(
700
+ index,
701
+ { pads, vias, tracks, polygons, copperTexts },
702
+ options
703
+ )
685
704
 
686
705
  return {
687
- pads: [
688
- ...PcbScene3dCircuitJsonAdapter.#buildSmtPads(index),
689
- ...PcbScene3dCircuitJsonAdapter.#buildPlatedHoles(index),
690
- ...PcbScene3dCircuitJsonAdapter.#buildNonPlatedHoles(index)
691
- ],
692
- tracks: [
693
- ...PcbScene3dCircuitJsonTraceRouteBuilder.buildTracks(index),
694
- ...PcbScene3dCircuitJsonThermalSpokeBuilder.build(index)
695
- ],
706
+ pads,
707
+ tracks,
696
708
  arcs: [],
697
709
  fills: [],
698
- vias: PcbScene3dCircuitJsonTraceRouteBuilder.buildVias(index),
699
- polygons: PcbScene3dCircuitJsonCopperPourBuilder.build(index),
700
- copperTexts: [],
710
+ vias,
711
+ polygons,
712
+ copperTexts,
701
713
  embeddedFonts: [],
702
- silkscreen: PcbScene3dCircuitJsonSilkscreenBuilder.build(
703
- index,
704
- options
705
- ),
714
+ silkscreen,
706
715
  ...(paste ? { paste } : {}),
707
716
  drillQuality: PcbScene3dCircuitJsonGeometry.normalizeDrillQuality(
708
717
  options?.boardDrillQuality
@@ -745,11 +754,7 @@ export class PcbScene3dCircuitJsonAdapter {
745
754
  holeDiameter: 0,
746
755
  hasTopSolderMaskOpening: !isBottom && exposesCopper,
747
756
  hasBottomSolderMaskOpening: isBottom && exposesCopper,
748
- ...PcbScene3dCircuitJsonAdapter.#roundedPadMetadata(
749
- pad,
750
- size,
751
- isBottom
752
- )
757
+ ...PcbScene3dCircuitJsonPadCorner.metadata(pad, size, isBottom)
753
758
  }
754
759
  return padDetail
755
760
  })
@@ -768,7 +773,7 @@ export class PcbScene3dCircuitJsonAdapter {
768
773
  y: Number(hole?.y || 0)
769
774
  })
770
775
  const size =
771
- PcbScene3dCircuitJsonAdapter.#platedHoleOuterSize(geometry)
776
+ PcbScene3dCircuitJsonGeometry.platedHoleOuterSize(geometry)
772
777
  const center = CircuitJsonUnits.pointMmToMil({
773
778
  x: hole?.x,
774
779
  y: hole?.y
@@ -796,6 +801,11 @@ export class PcbScene3dCircuitJsonAdapter {
796
801
  sizeMidY: size.height,
797
802
  sizeBottomX: size.width,
798
803
  sizeBottomY: size.height,
804
+ ...PcbScene3dCircuitJsonPadCorner.metadata(
805
+ geometry,
806
+ size,
807
+ null
808
+ ),
799
809
  ...PcbScene3dCircuitJsonDrillDetail.fields(drill),
800
810
  holeOffsetX: drill.center.x - center.x,
801
811
  holeOffsetY: drill.center.y - center.y,
@@ -852,68 +862,6 @@ export class PcbScene3dCircuitJsonAdapter {
852
862
  height: CircuitJsonUnits.mmToMil(pad?.height, 1)
853
863
  }
854
864
  }
855
-
856
- /**
857
- * Builds rounded-rectangle metadata for pill-shaped SMT pads.
858
- * @param {object} pad Pad element.
859
- * @param {{ width: number, height: number }} size Pad copper size.
860
- * @param {boolean} isBottom True when the pad is on the bottom side.
861
- * @returns {object}
862
- */
863
- static #roundedPadMetadata(pad, size, isBottom) {
864
- if (!String(pad?.shape || '').endsWith('pill')) {
865
- return {}
866
- }
867
-
868
- const cornerRadius =
869
- PcbScene3dCircuitJsonAdapter.#roundedPadCornerRadiusPercent(
870
- pad,
871
- size
872
- )
873
- return {
874
- hasRoundedRect: true,
875
- roundedRectShapeTop: isBottom ? null : RECTANGULAR_PAD_SHAPE,
876
- roundedRectShapeBottom: isBottom ? RECTANGULAR_PAD_SHAPE : null,
877
- cornerRadiusTop: isBottom ? null : cornerRadius,
878
- cornerRadiusBottom: isBottom ? cornerRadius : null
879
- }
880
- }
881
-
882
- /**
883
- * Resolves a pill-pad corner radius as a percent of the shortest side.
884
- * @param {object} pad Pad element.
885
- * @param {{ width: number, height: number }} size Pad copper size.
886
- * @returns {number}
887
- */
888
- static #roundedPadCornerRadiusPercent(pad, size) {
889
- const width = CircuitJsonUnits.optionalLength(pad?.width)
890
- const height = CircuitJsonUnits.optionalLength(pad?.height)
891
- const radius = CircuitJsonUnits.optionalLength(pad?.radius)
892
- if (width > 0 && height > 0 && radius > 0) {
893
- return Math.min((radius / Math.min(width, height)) * 100, 50)
894
- }
895
-
896
- const shortestSide = Math.min(Number(size.width), Number(size.height))
897
- const radiusMil = CircuitJsonUnits.mmToMil(pad?.radius, 0)
898
- if (shortestSide > 0 && radiusMil > 0) {
899
- return Math.min((radiusMil / shortestSide) * 100, 50)
900
- }
901
-
902
- return 50
903
- }
904
-
905
- /**
906
- * Resolves plated-hole copper size from CircuitJSON fields.
907
- * @param {object} geometry Shared drilled-primitive geometry.
908
- * @returns {{ width: number, height: number }}
909
- */
910
- static #platedHoleOuterSize(geometry) {
911
- return {
912
- width: CircuitJsonUnits.mmToMil(geometry?.width, 1),
913
- height: CircuitJsonUnits.mmToMil(geometry?.height, 1)
914
- }
915
- }
916
-
917
865
  /**
918
866
  * Resolves the viewer pad shape code.
919
867
  * @param {object} pad CircuitJSON pad element.
@@ -257,7 +257,12 @@ export class PcbScene3dCircuitJsonCopperPourBuilder {
257
257
  */
258
258
  static #isCoveredWithMask(pour) {
259
259
  const value = pour?.covered_with_solder_mask
260
- return value === true || String(value).toLowerCase() === 'true'
260
+ if (typeof value === 'boolean') return value
261
+ if (value === undefined || value === null || value === '') return true
262
+
263
+ const text = String(value).trim().toLowerCase()
264
+ if (text === 'false') return false
265
+ return true
261
266
  }
262
267
 
263
268
  /**