altium-toolkit 1.0.10 → 1.1.1

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 (64) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +4 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +132 -1
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +155 -5
  61. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  62. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  63. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  64. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -8,6 +8,68 @@ import { CircuitJsonModelAdapterPcbElements } from './CircuitJsonModelAdapterPcb
8
8
 
9
9
  const Primitives = CircuitJsonModelAdapterPrimitives
10
10
  const PcbElements = CircuitJsonModelAdapterPcbElements
11
+ const ALTIUM_TOOLKIT_SIDECARS = [
12
+ {
13
+ type: 'altium_toolkit_pcb_layer_stack',
14
+ paths: [['pcb', 'layerStackReadModel']],
15
+ schema: 'altium-toolkit.pcb.layer-stack.a1'
16
+ },
17
+ {
18
+ type: 'altium_toolkit_pcb_rigid_flex_topology',
19
+ paths: [['pcb', 'rigidFlexTopology']],
20
+ schema: 'altium-toolkit.pcb.rigid-flex-topology.a1'
21
+ },
22
+ {
23
+ type: 'altium_toolkit_pcb_review_metadata',
24
+ paths: [['pcb', 'reviewMetadata']],
25
+ schema: 'altium-toolkit.pcb.review-metadata.a1'
26
+ },
27
+ {
28
+ type: 'altium_toolkit_pcb_placed_footprint_extraction',
29
+ paths: [['pcb', 'footprintExtractionManifest']],
30
+ schema: 'altium-toolkit.pcb.placed-footprint-extraction.a1'
31
+ },
32
+ {
33
+ type: 'altium_toolkit_pcblib_parity',
34
+ paths: [['pcbLibrary', 'parityReport']],
35
+ schema: 'altium-toolkit.pcblib.parity.a1'
36
+ },
37
+ {
38
+ type: 'altium_toolkit_project_outjob_digest',
39
+ paths: [['project', 'outJobDigest']],
40
+ schema: 'altium-toolkit.project.outjob-digest.a1'
41
+ },
42
+ {
43
+ type: 'altium_toolkit_project_document_graph',
44
+ paths: [['project', 'documentGraph'], ['documentGraph']],
45
+ schema: 'altium-toolkit.project.document-graph.a1'
46
+ },
47
+ {
48
+ type: 'altium_toolkit_project_bom_pnp_reconciliation',
49
+ paths: [['reconciliation']],
50
+ schema: 'altium-toolkit.project.bom-pnp-reconciliation.a1'
51
+ },
52
+ {
53
+ type: 'altium_toolkit_draftsman_image_payloads',
54
+ paths: [['draftsman', 'imagePayloads']],
55
+ schema: 'altium-toolkit.draftsman.image-payloads.a1'
56
+ },
57
+ {
58
+ type: 'altium_toolkit_draftsman_board_view_metadata',
59
+ paths: [['draftsman', 'boardViewMetadata']],
60
+ schema: 'altium-toolkit.draftsman.board-view-cache.a1'
61
+ },
62
+ {
63
+ type: 'altium_toolkit_contract_gate',
64
+ paths: [['contractGate']],
65
+ schema: 'altium-toolkit.contract-gate.a1'
66
+ },
67
+ {
68
+ type: 'altium_toolkit_host_capabilities',
69
+ paths: [['hostCapabilities']],
70
+ schema: 'altium-toolkit.host-capabilities.a1'
71
+ }
72
+ ]
11
73
 
12
74
  /**
13
75
  * Converts between legacy renderer models and Circuit JSON element arrays.
@@ -55,6 +117,7 @@ export class CircuitJsonModelAdapter {
55
117
  }
56
118
 
57
119
  CircuitJsonModelAdapter.#appendBom(circuitJson, model, idScope)
120
+ CircuitJsonModelAdapter.#appendSidecars(circuitJson, model, idScope)
58
121
  CircuitJsonModelAdapter.#attachCompatibility(circuitJson, model)
59
122
 
60
123
  return CircuitJsonModelSchema.attach(circuitJson)
@@ -651,6 +714,107 @@ export class CircuitJsonModelAdapter {
651
714
  }
652
715
  }
653
716
 
717
+ /**
718
+ * Appends serialized Altium Toolkit sidecar elements.
719
+ * @param {object[]} circuitJson
720
+ * @param {Record<string, unknown>} model
721
+ * @param {string} idScope
722
+ * @returns {void}
723
+ */
724
+ static #appendSidecars(circuitJson, model, idScope) {
725
+ for (const descriptor of ALTIUM_TOOLKIT_SIDECARS) {
726
+ const payload = CircuitJsonModelAdapter.#sidecarPayload(
727
+ model,
728
+ descriptor.paths
729
+ )
730
+ if (!payload) continue
731
+
732
+ circuitJson.push(
733
+ CircuitJsonModelAdapter.#sidecarElement(
734
+ descriptor,
735
+ payload,
736
+ model,
737
+ idScope
738
+ )
739
+ )
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Builds one custom Altium Toolkit Circuit JSON sidecar element.
745
+ * @param {{ type: string, schema: string }} descriptor
746
+ * @param {Record<string, unknown>} payload
747
+ * @param {Record<string, unknown>} model
748
+ * @param {string} idScope
749
+ * @returns {object}
750
+ */
751
+ static #sidecarElement(descriptor, payload, model, idScope) {
752
+ return {
753
+ type: descriptor.type,
754
+ altium_toolkit_sidecar_id: Primitives.id(idScope, [
755
+ 'altium_toolkit_sidecar',
756
+ descriptor.type
757
+ ]),
758
+ source_document: CircuitJsonModelAdapter.#sourceDocument(model),
759
+ schema: Primitives.string(payload.schema, descriptor.schema),
760
+ payload
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Returns the first available sidecar payload from candidate model paths.
766
+ * @param {Record<string, unknown>} model
767
+ * @param {string[][]} paths
768
+ * @returns {Record<string, unknown> | null}
769
+ */
770
+ static #sidecarPayload(model, paths) {
771
+ for (const path of paths) {
772
+ const payload = CircuitJsonModelAdapter.#valueAtPath(model, path)
773
+ if (CircuitJsonModelAdapter.#isSidecarPayload(payload)) {
774
+ return payload
775
+ }
776
+ }
777
+ return null
778
+ }
779
+
780
+ /**
781
+ * Returns a nested object value.
782
+ * @param {Record<string, unknown>} value
783
+ * @param {string[]} path
784
+ * @returns {unknown}
785
+ */
786
+ static #valueAtPath(value, path) {
787
+ return path.reduce(
788
+ (current, key) =>
789
+ current && typeof current === 'object'
790
+ ? current[key]
791
+ : undefined,
792
+ value
793
+ )
794
+ }
795
+
796
+ /**
797
+ * Returns true when a value can be serialized as a sidecar payload.
798
+ * @param {unknown} value
799
+ * @returns {value is Record<string, unknown>}
800
+ */
801
+ static #isSidecarPayload(value) {
802
+ return !!value && typeof value === 'object' && !Array.isArray(value)
803
+ }
804
+
805
+ /**
806
+ * Builds the source document identity for sidecar elements.
807
+ * @param {Record<string, unknown>} model
808
+ * @returns {{ kind: string, file_type: string, file_name: string }}
809
+ */
810
+ static #sourceDocument(model) {
811
+ return {
812
+ kind: Primitives.string(model.kind, 'document'),
813
+ file_type: Primitives.string(model.fileType, ''),
814
+ file_name: Primitives.string(model.fileName, '')
815
+ }
816
+ }
817
+
654
818
  /**
655
819
  * Appends one schematic line or trace.
656
820
  * @param {object[]} circuitJson
package/src/parser.mjs CHANGED
@@ -9,12 +9,16 @@ export { OleDirectoryEntry } from './core/ole/OleDirectoryEntry.mjs'
9
9
  export { AltiumParser } from './core/altium/AltiumParser.mjs'
10
10
  export { AltiumLayoutParser } from './core/altium/AltiumLayoutParser.mjs'
11
11
  export { AsciiRecordParser } from './core/altium/AsciiRecordParser.mjs'
12
+ export { HostCapabilityDiagnosticsBuilder } from './core/altium/HostCapabilityDiagnosticsBuilder.mjs'
12
13
  export { IntLibModelParser } from './core/altium/IntLibModelParser.mjs'
13
14
  export { IntLibStreamExtractor } from './core/altium/IntLibStreamExtractor.mjs'
14
15
  export { EmbeddedFileInventoryBuilder } from './core/altium/EmbeddedFileInventoryBuilder.mjs'
15
16
  export { CiArtifactBundleBuilder } from './core/altium/CiArtifactBundleBuilder.mjs'
17
+ export { ContractGateReportBuilder } from './core/altium/ContractGateReportBuilder.mjs'
18
+ export { DraftsmanBoardViewMetadataBuilder } from './core/altium/DraftsmanBoardViewMetadataBuilder.mjs'
16
19
  export { DraftsmanDigestParser } from './core/altium/DraftsmanDigestParser.mjs'
17
20
  export { LibraryRenderManifestBuilder } from './core/altium/LibraryRenderManifestBuilder.mjs'
21
+ export { LibraryQaReportBuilder } from './core/altium/LibraryQaReportBuilder.mjs'
18
22
  export { LibrarySearchIndex } from './core/altium/LibrarySearchIndex.mjs'
19
23
  export { ParserUtils } from './core/altium/ParserUtils.mjs'
20
24
  export { ParserCompatibilityFuzzer } from './core/altium/ParserCompatibilityFuzzer.mjs'
@@ -27,7 +31,9 @@ export { ProjectVariantViewBuilder } from './core/altium/ProjectVariantViewBuild
27
31
  export { CircuitJsonModelSchema } from './core/circuit-json/CircuitJsonModelSchema.mjs'
28
32
  export { CircuitJsonModelAdapter } from './core/circuit-json/CircuitJsonModelAdapter.mjs'
29
33
  export { PcbBinaryPrimitiveParser } from './core/altium/PcbBinaryPrimitiveParser.mjs'
34
+ export { PcbBomProfileBuilder } from './core/altium/PcbBomProfileBuilder.mjs'
30
35
  export { PcbBoardRegionSemanticsParser } from './core/altium/PcbBoardRegionSemanticsParser.mjs'
36
+ export { PcbComponentKindPolicy } from './core/altium/PcbComponentKindPolicy.mjs'
31
37
  export { PcbComponentPrimitiveIndexer } from './core/altium/PcbComponentPrimitiveIndexer.mjs'
32
38
  export { PcbCustomPadShapeParser } from './core/altium/PcbCustomPadShapeParser.mjs'
33
39
  export { PcbDimensionParser } from './core/altium/PcbDimensionParser.mjs'
@@ -36,7 +42,11 @@ export { PcbEmbeddedModelExtractor } from './core/altium/PcbEmbeddedModelExtract
36
42
  export { PcbExtendedPrimitiveInformationParser } from './core/altium/PcbExtendedPrimitiveInformationParser.mjs'
37
43
  export { PcbFontMetricsParser } from './core/altium/PcbFontMetricsParser.mjs'
38
44
  export { PcbLayerIdCodec } from './core/altium/PcbLayerIdCodec.mjs'
45
+ export { PcbLayerStackFidelityReportBuilder } from './core/altium/PcbLayerStackFidelityReportBuilder.mjs'
46
+ export { PcbLayerStackInterchangeParser } from './core/altium/PcbLayerStackInterchangeParser.mjs'
47
+ export { PcbLayerStackQueryHelper } from './core/altium/PcbLayerStackQueryHelper.mjs'
39
48
  export { PcbLibModelParser } from './core/altium/PcbLibModelParser.mjs'
49
+ export { PcbLibParityReportBuilder } from './core/altium/PcbLibParityReportBuilder.mjs'
40
50
  export { PcbLibStreamExtractor } from './core/altium/PcbLibStreamExtractor.mjs'
41
51
  export { PcbMechanicalLayerPairParser } from './core/altium/PcbMechanicalLayerPairParser.mjs'
42
52
  export { PcbModelParser } from './core/altium/PcbModelParser.mjs'
@@ -44,8 +54,11 @@ export { PcbOutlineRasterizer } from './core/altium/PcbOutlineRasterizer.mjs'
44
54
  export { PcbOutlineRecovery } from './core/altium/PcbOutlineRecovery.mjs'
45
55
  export { PcbOwnershipGraphBuilder } from './core/altium/PcbOwnershipGraphBuilder.mjs'
46
56
  export { PcbPadStackParser } from './core/altium/PcbPadStackParser.mjs'
57
+ export { PcbPlacedFootprintManifestBuilder } from './core/altium/PcbPlacedFootprintManifestBuilder.mjs'
47
58
  export { PcbPickPlacePositionResolver } from './core/altium/PcbPickPlacePositionResolver.mjs'
59
+ export { PcbPolygonRecordParser } from './core/altium/PcbPolygonRecordParser.mjs'
48
60
  export { PcbRawRecordRegistry } from './core/altium/PcbRawRecordRegistry.mjs'
61
+ export { PcbReviewMetadataBuilder } from './core/altium/PcbReviewMetadataBuilder.mjs'
49
62
  export { PcbRouteAnalysisBuilder } from './core/altium/PcbRouteAnalysisBuilder.mjs'
50
63
  export { PcbRuleParser } from './core/altium/PcbRuleParser.mjs'
51
64
  export { PcbSidecarRecordParser } from './core/altium/PcbSidecarRecordParser.mjs'
@@ -57,6 +70,8 @@ export { PcbViaStackParser } from './core/altium/PcbViaStackParser.mjs'
57
70
  export { PcbViaStructureParser } from './core/altium/PcbViaStructureParser.mjs'
58
71
  export { PrintableTextDecoder } from './core/altium/PrintableTextDecoder.mjs'
59
72
  export { PrjPcbModelParser } from './core/altium/PrjPcbModelParser.mjs'
73
+ export { PrjScrModelParser } from './core/altium/PrjScrModelParser.mjs'
74
+ export { ProjectBomPnpReconciliationBuilder } from './core/altium/ProjectBomPnpReconciliationBuilder.mjs'
60
75
  export { SchematicMultipartOwnerMatcher } from './core/altium/SchematicMultipartOwnerMatcher.mjs'
61
76
  export { SchematicCrossSheetConnectorParser } from './core/altium/SchematicCrossSheetConnectorParser.mjs'
62
77
  export { SchematicHarnessParser } from './core/altium/SchematicHarnessParser.mjs'
@@ -14,6 +14,8 @@ export class PcbEdgeFacingGlyphNormalizer {
14
14
  static #EDGE_GLYPH_CENTER_TOLERANCE = 1.5
15
15
  static #EDGE_GLYPH_PROXIMITY_RATIO = 0.2
16
16
  static #MARKER_PROXIMITY_MULTIPLIER = 3
17
+ static #SPATIAL_INDEX_MIN_CELL_SIZE = 32
18
+ static #SPATIAL_INDEX_MAX_CELLS_PER_ITEM = 256
17
19
 
18
20
  /**
19
21
  * Normalizes repeated edge-facing documentation glyphs so their opening
@@ -107,6 +109,8 @@ export class PcbEdgeFacingGlyphNormalizer {
107
109
  )
108
110
  ]
109
111
  const visited = new Array(items.length).fill(false)
112
+ const spatialIndex =
113
+ PcbEdgeFacingGlyphNormalizer.#buildSpatialIndex(items)
110
114
  const groups = []
111
115
 
112
116
  for (let index = 0; index < items.length; index += 1) {
@@ -137,11 +141,10 @@ export class PcbEdgeFacingGlyphNormalizer {
137
141
  arcIndexes.push(currentItem.arcIndex)
138
142
  }
139
143
 
140
- for (
141
- let nextIndex = 0;
142
- nextIndex < items.length;
143
- nextIndex += 1
144
- ) {
144
+ for (const nextIndex of PcbEdgeFacingGlyphNormalizer.#collectCandidateIndexes(
145
+ currentItem.bounds,
146
+ spatialIndex
147
+ )) {
145
148
  if (visited[nextIndex]) {
146
149
  continue
147
150
  }
@@ -171,6 +174,153 @@ export class PcbEdgeFacingGlyphNormalizer {
171
174
  return groups
172
175
  }
173
176
 
177
+ /**
178
+ * Builds a spatial lookup for candidate bounds intersections.
179
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} items
180
+ * @returns {{ cellSize: number, cells: Map<string, number[]>, overflowIndexes: number[], marks: Uint32Array, mark: number }}
181
+ */
182
+ static #buildSpatialIndex(items) {
183
+ const cellSize =
184
+ PcbEdgeFacingGlyphNormalizer.#resolveSpatialCellSize(items)
185
+ const cells = new Map()
186
+ const overflowIndexes = []
187
+
188
+ items.forEach((item, index) => {
189
+ const range = PcbEdgeFacingGlyphNormalizer.#resolveCellRange(
190
+ item.bounds,
191
+ cellSize
192
+ )
193
+ const cellCount =
194
+ (range.maxX - range.minX + 1) * (range.maxY - range.minY + 1)
195
+
196
+ if (
197
+ cellCount >
198
+ PcbEdgeFacingGlyphNormalizer.#SPATIAL_INDEX_MAX_CELLS_PER_ITEM
199
+ ) {
200
+ overflowIndexes.push(index)
201
+ return
202
+ }
203
+
204
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
205
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
206
+ const key = PcbEdgeFacingGlyphNormalizer.#cellKey(
207
+ cellX,
208
+ cellY
209
+ )
210
+ const bucket = cells.get(key)
211
+
212
+ if (bucket) {
213
+ bucket.push(index)
214
+ } else {
215
+ cells.set(key, [index])
216
+ }
217
+ }
218
+ }
219
+ })
220
+
221
+ return {
222
+ cellSize,
223
+ cells,
224
+ overflowIndexes,
225
+ marks: new Uint32Array(items.length),
226
+ mark: 0
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Collects unique candidate item indexes that could intersect one bounds box.
232
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
233
+ * @param {{ cellSize: number, cells: Map<string, number[]>, overflowIndexes: number[], marks: Uint32Array, mark: number }} spatialIndex
234
+ * @returns {number[]}
235
+ */
236
+ static #collectCandidateIndexes(bounds, spatialIndex) {
237
+ const candidates = []
238
+ const range = PcbEdgeFacingGlyphNormalizer.#resolveCellRange(
239
+ bounds,
240
+ spatialIndex.cellSize
241
+ )
242
+
243
+ spatialIndex.mark += 1
244
+ if (spatialIndex.mark >= 0xffffffff) {
245
+ spatialIndex.marks.fill(0)
246
+ spatialIndex.mark = 1
247
+ }
248
+
249
+ const appendCandidate = (index) => {
250
+ if (spatialIndex.marks[index] === spatialIndex.mark) {
251
+ return
252
+ }
253
+
254
+ spatialIndex.marks[index] = spatialIndex.mark
255
+ candidates.push(index)
256
+ }
257
+
258
+ for (let cellX = range.minX; cellX <= range.maxX; cellX += 1) {
259
+ for (let cellY = range.minY; cellY <= range.maxY; cellY += 1) {
260
+ const bucket = spatialIndex.cells.get(
261
+ PcbEdgeFacingGlyphNormalizer.#cellKey(cellX, cellY)
262
+ )
263
+
264
+ if (bucket) {
265
+ bucket.forEach(appendCandidate)
266
+ }
267
+ }
268
+ }
269
+
270
+ spatialIndex.overflowIndexes.forEach(appendCandidate)
271
+ return candidates
272
+ }
273
+
274
+ /**
275
+ * Resolves a spatial cell size from typical primitive bounds spans.
276
+ * @param {{ bounds: { minX: number, maxX: number, minY: number, maxY: number } }[]} items
277
+ * @returns {number}
278
+ */
279
+ static #resolveSpatialCellSize(items) {
280
+ const spans = items
281
+ .map((item) =>
282
+ Math.max(
283
+ Number(item.bounds.maxX) - Number(item.bounds.minX),
284
+ Number(item.bounds.maxY) - Number(item.bounds.minY),
285
+ 0
286
+ )
287
+ )
288
+ .filter((span) => Number.isFinite(span))
289
+ .sort((left, right) => left - right)
290
+ const medianSpan = spans[Math.floor(spans.length / 2)] || 0
291
+
292
+ return Math.max(
293
+ medianSpan * 4,
294
+ PcbEdgeFacingGlyphNormalizer.#EDGE_GLYPH_CONNECTION_TOLERANCE * 8,
295
+ PcbEdgeFacingGlyphNormalizer.#SPATIAL_INDEX_MIN_CELL_SIZE
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Resolves the inclusive grid-cell range for one bounds box.
301
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds
302
+ * @param {number} cellSize
303
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
304
+ */
305
+ static #resolveCellRange(bounds, cellSize) {
306
+ return {
307
+ minX: Math.floor(Number(bounds.minX) / cellSize),
308
+ maxX: Math.floor(Number(bounds.maxX) / cellSize),
309
+ minY: Math.floor(Number(bounds.minY) / cellSize),
310
+ maxY: Math.floor(Number(bounds.maxY) / cellSize)
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Builds one deterministic spatial index key.
316
+ * @param {number} cellX
317
+ * @param {number} cellY
318
+ * @returns {string}
319
+ */
320
+ static #cellKey(cellX, cellY) {
321
+ return `${cellX}:${cellY}`
322
+ }
323
+
174
324
  /**
175
325
  * Resolves whether one connected screw glyph needs its semicircular head
176
326
  * moved onto the same side as the screw tip while keeping the authored
@@ -141,9 +141,21 @@ export class PcbFootprintPrimitiveSelector {
141
141
  * @returns {boolean}
142
142
  */
143
143
  static #includesLayerName(layerName, needle) {
144
+ return PcbFootprintPrimitiveSelector.#compactLayerName(
145
+ layerName
146
+ ).includes(PcbFootprintPrimitiveSelector.#compactLayerName(needle))
147
+ }
148
+
149
+ /**
150
+ * Normalizes layer labels so compact names such as TopOverlay match
151
+ * spaced Altium labels such as Top Overlay.
152
+ * @param {string} layerName
153
+ * @returns {string}
154
+ */
155
+ static #compactLayerName(layerName) {
144
156
  return String(layerName || '')
145
157
  .trim()
146
158
  .toUpperCase()
147
- .includes(needle)
159
+ .replace(/[^A-Z0-9]/g, '')
148
160
  }
149
161
  }
@@ -986,16 +986,38 @@ export class PcbScene3dBuilder {
986
986
  return new Set(
987
987
  (Array.isArray(primitiveLayers) ? primitiveLayers : [])
988
988
  .filter((layer) =>
989
- String(layer?.name || '')
990
- .trim()
991
- .toUpperCase()
992
- .includes(needle)
989
+ PcbScene3dBuilder.#includesLayerName(layer?.name, needle)
993
990
  )
994
991
  .map((layer) => Number(layer.layerId))
995
992
  .filter((layerId) => Number.isInteger(layerId))
996
993
  )
997
994
  }
998
995
 
996
+ /**
997
+ * Returns true when one layer name matches a spaced or compact target.
998
+ * @param {string} layerName
999
+ * @param {string} needle
1000
+ * @returns {boolean}
1001
+ */
1002
+ static #includesLayerName(layerName, needle) {
1003
+ return PcbScene3dBuilder.#compactLayerName(layerName).includes(
1004
+ PcbScene3dBuilder.#compactLayerName(needle)
1005
+ )
1006
+ }
1007
+
1008
+ /**
1009
+ * Normalizes layer labels so compact names such as TopOverlay match
1010
+ * spaced Altium labels such as Top Overlay.
1011
+ * @param {string} layerName
1012
+ * @returns {string}
1013
+ */
1014
+ static #compactLayerName(layerName) {
1015
+ return String(layerName || '')
1016
+ .trim()
1017
+ .toUpperCase()
1018
+ .replace(/[^A-Z0-9]/g, '')
1019
+ }
1020
+
999
1021
  /**
1000
1022
  * Normalizes one Altium overlay text into the runtime stroke-text shape.
1001
1023
  * @param {{ text?: string, value?: string, x?: number, y?: number, height?: number, strokeWidth?: number, rotation?: number, mirrored?: boolean | number | string, isMirrored?: boolean | number | string, mirrorFlag?: boolean | number | string, Mirrored?: boolean | number | string, IsMirrored?: boolean | number | string, MirrorFlag?: boolean | number | string, layerId?: number }} text