altium-toolkit 1.1.0 → 1.1.2

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.
@@ -21,6 +21,10 @@ export class SchematicImplementationParser {
21
21
  const components = SchematicImplementationParser.#componentRows(records)
22
22
  const listsByIndex =
23
23
  SchematicImplementationParser.#implementationLists(records)
24
+ const childrenByOwner =
25
+ SchematicImplementationParser.#implementationChildrenByOwner(
26
+ records
27
+ )
24
28
  const implementations = (records || [])
25
29
  .filter(
26
30
  (record) =>
@@ -32,9 +36,9 @@ export class SchematicImplementationParser {
32
36
  .map((record) =>
33
37
  SchematicImplementationParser.#implementation(
34
38
  record,
35
- records,
36
39
  components,
37
- listsByIndex
40
+ listsByIndex,
41
+ childrenByOwner
38
42
  )
39
43
  )
40
44
  .filter(Boolean)
@@ -137,12 +141,12 @@ export class SchematicImplementationParser {
137
141
  /**
138
142
  * Parses one implementation record.
139
143
  * @param {object} record Implementation record.
140
- * @param {object[]} records All schematic records.
141
144
  * @param {object[]} components Component rows.
142
145
  * @param {Map<string, object>} listsByIndex Implementation lists.
146
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
143
147
  * @returns {object | null}
144
148
  */
145
- static #implementation(record, records, components, listsByIndex) {
149
+ static #implementation(record, components, listsByIndex, childrenByOwner) {
146
150
  const indexInSheet =
147
151
  parseNumericField(record.fields, 'IndexInSheet') ??
148
152
  record.recordIndex ??
@@ -184,11 +188,11 @@ export class SchematicImplementationParser {
184
188
  record.fields
185
189
  ),
186
190
  mapDefiners: SchematicImplementationParser.#mapDefiners(
187
- records,
191
+ childrenByOwner,
188
192
  indexInSheet
189
193
  ),
190
194
  parameters: SchematicImplementationParser.#parameters(
191
- records,
195
+ childrenByOwner,
192
196
  indexInSheet
193
197
  )
194
198
  })
@@ -326,23 +330,17 @@ export class SchematicImplementationParser {
326
330
 
327
331
  /**
328
332
  * Parses map-definer child records for one implementation.
329
- * @param {object[]} records All schematic records.
333
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
330
334
  * @param {number} implementationIndex Implementation index.
331
335
  * @returns {object[]}
332
336
  */
333
- static #mapDefiners(records, implementationIndex) {
334
- return (records || [])
335
- .filter(
336
- (record) =>
337
- SchematicImplementationParser.#field(
338
- record.fields,
339
- 'RECORD'
340
- ) === '47' &&
341
- SchematicImplementationParser.#field(
342
- record.fields,
343
- 'OwnerIndex'
344
- ) === String(implementationIndex)
345
- )
337
+ static #mapDefiners(childrenByOwner, implementationIndex) {
338
+ return SchematicImplementationParser.#ownedImplementationChildren(
339
+ childrenByOwner,
340
+ implementationIndex
341
+ )
342
+ .filter((row) => row.recordType === '47')
343
+ .map((row) => row.record)
346
344
  .map((record) =>
347
345
  SchematicImplementationParser.#stripEmpty({
348
346
  recordKey: SchematicImplementationParser.#recordKey(record),
@@ -360,25 +358,17 @@ export class SchematicImplementationParser {
360
358
 
361
359
  /**
362
360
  * Parses implementation parameter child records.
363
- * @param {object[]} records All schematic records.
361
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
364
362
  * @param {number} implementationIndex Implementation index.
365
363
  * @returns {object[]}
366
364
  */
367
- static #parameters(records, implementationIndex) {
368
- return (records || [])
369
- .filter((record) => {
370
- const recordType = SchematicImplementationParser.#field(
371
- record.fields,
372
- 'RECORD'
373
- )
374
- return (
375
- (recordType === '48' || recordType === '41') &&
376
- SchematicImplementationParser.#field(
377
- record.fields,
378
- 'OwnerIndex'
379
- ) === String(implementationIndex)
380
- )
381
- })
365
+ static #parameters(childrenByOwner, implementationIndex) {
366
+ return SchematicImplementationParser.#ownedImplementationChildren(
367
+ childrenByOwner,
368
+ implementationIndex
369
+ )
370
+ .filter((row) => row.recordType === '48' || row.recordType === '41')
371
+ .map((row) => row.record)
382
372
  .map((record) =>
383
373
  SchematicImplementationParser.#stripEmpty({
384
374
  recordKey: SchematicImplementationParser.#recordKey(record),
@@ -396,6 +386,55 @@ export class SchematicImplementationParser {
396
386
  )
397
387
  }
398
388
 
389
+ /**
390
+ * Indexes implementation child rows by owner index once per parse.
391
+ * @param {object[]} records Schematic records.
392
+ * @returns {Map<string, { recordType: string, record: object }[]>}
393
+ */
394
+ static #implementationChildrenByOwner(records) {
395
+ const rowsByOwner = new Map()
396
+
397
+ for (const record of records || []) {
398
+ const recordType = SchematicImplementationParser.#field(
399
+ record.fields,
400
+ 'RECORD'
401
+ )
402
+ if (
403
+ recordType !== '47' &&
404
+ recordType !== '48' &&
405
+ recordType !== '41'
406
+ ) {
407
+ continue
408
+ }
409
+
410
+ const ownerIndex = SchematicImplementationParser.#field(
411
+ record.fields,
412
+ 'OwnerIndex'
413
+ )
414
+ if (!ownerIndex) {
415
+ continue
416
+ }
417
+
418
+ if (!rowsByOwner.has(ownerIndex)) {
419
+ rowsByOwner.set(ownerIndex, [])
420
+ }
421
+
422
+ rowsByOwner.get(ownerIndex).push({ recordType, record })
423
+ }
424
+
425
+ return rowsByOwner
426
+ }
427
+
428
+ /**
429
+ * Returns pre-indexed child rows for one implementation.
430
+ * @param {Map<string, { recordType: string, record: object }[]>} childrenByOwner Implementation child rows by owner index.
431
+ * @param {number} implementationIndex Implementation index.
432
+ * @returns {{ recordType: string, record: object }[]}
433
+ */
434
+ static #ownedImplementationChildren(childrenByOwner, implementationIndex) {
435
+ return childrenByOwner.get(String(implementationIndex)) || []
436
+ }
437
+
399
438
  /**
400
439
  * Parses indexed implementation designator fields from a map definer.
401
440
  * @param {Record<string, string | string[]>} fields Record fields.
@@ -4,8 +4,7 @@
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
6
 
7
- const { getDisplayText, getField, parseBoolean, parseNumericField, toColor } =
8
- ParserUtils
7
+ const { parseBoolean, parseNumericField, toColor } = ParserUtils
9
8
 
10
9
  /**
11
10
  * Builds deterministic read-only QA summaries for schematic documents.
@@ -25,12 +24,17 @@ export class SchematicQaReportBuilder {
25
24
  const lineWidths = SchematicQaReportBuilder.#lineWidths(records)
26
25
  const unresolvedParameters =
27
26
  SchematicQaReportBuilder.#unresolvedParameters(records)
27
+ const titleBlockResidue =
28
+ SchematicQaReportBuilder.#titleBlockResidue(records)
28
29
  const findings = [
29
30
  ...SchematicQaReportBuilder.#fontFindings(fonts),
30
31
  ...SchematicQaReportBuilder.#unresolvedFindings(
31
32
  unresolvedParameters
32
33
  ),
33
- ...SchematicQaReportBuilder.#titleBlockFindings(records)
34
+ ...SchematicQaReportBuilder.#titleBlockFindings(
35
+ records,
36
+ titleBlockResidue
37
+ )
34
38
  ]
35
39
 
36
40
  return {
@@ -47,8 +51,7 @@ export class SchematicQaReportBuilder {
47
51
  colors,
48
52
  lineWidths,
49
53
  unresolvedParameters,
50
- titleBlockResidue:
51
- SchematicQaReportBuilder.#titleBlockResidue(records),
54
+ titleBlockResidue,
52
55
  geometryFallbacks: SchematicQaReportBuilder.#geometryFallbacks(
53
56
  input?.texts
54
57
  ),
@@ -138,26 +141,15 @@ export class SchematicQaReportBuilder {
138
141
  * @returns {string[]}
139
142
  */
140
143
  static #unresolvedParameters(records) {
141
- const metadata = new Set(
142
- (records || [])
143
- .filter((record) => getField(record.fields, 'RECORD') === '41')
144
- .map((record) => getField(record.fields, 'Name'))
145
- .filter(Boolean)
146
- )
147
144
  const unresolved = []
148
145
 
149
146
  for (const record of records || []) {
150
- const text = getDisplayText(record.fields)
147
+ const text = SchematicQaReportBuilder.#displayText(record.fields)
151
148
  const match = text.match(/^=([A-Za-z_][\w.]*)$/)
152
149
  if (!match) {
153
150
  continue
154
151
  }
155
- if (
156
- !metadata.has(match[1]) ||
157
- text === getDisplayText(record.fields)
158
- ) {
159
- unresolved.push(match[1])
160
- }
152
+ unresolved.push(match[1])
161
153
  }
162
154
 
163
155
  return [...new Set(unresolved)].sort()
@@ -200,23 +192,25 @@ export class SchematicQaReportBuilder {
200
192
  /**
201
193
  * Builds title-block findings.
202
194
  * @param {object[]} records Schematic records.
195
+ * @param {object[]} titleBlockResidue Hidden title-block residue rows.
203
196
  * @returns {object[]}
204
197
  */
205
- static #titleBlockFindings(records) {
198
+ static #titleBlockFindings(records, titleBlockResidue) {
206
199
  const sheet = (records || []).find(
207
- (record) => getField(record.fields, 'RECORD') === '31'
200
+ (record) =>
201
+ SchematicQaReportBuilder.#field(record.fields, 'RECORD') ===
202
+ '31'
208
203
  )
209
204
  if (!sheet || parseBoolean(sheet.fields.TitleBlockOn)) {
210
205
  return []
211
206
  }
212
207
 
213
- const residue = SchematicQaReportBuilder.#titleBlockResidue(records)
214
- return residue.length
208
+ return titleBlockResidue.length
215
209
  ? [
216
210
  {
217
211
  code: 'schematic.title-block.hidden-residue',
218
212
  severity: 'info',
219
- count: residue.length,
213
+ count: titleBlockResidue.length,
220
214
  message:
221
215
  'Hidden title-block parameter records remain while the title block is disabled.'
222
216
  }
@@ -240,22 +234,32 @@ export class SchematicQaReportBuilder {
240
234
  'drawnby'
241
235
  ])
242
236
 
243
- return (records || [])
244
- .filter(
245
- (record) =>
246
- getField(record.fields, 'RECORD') === '41' &&
247
- parseBoolean(record.fields.IsHidden) &&
248
- titleBlockNames.has(
249
- getField(record.fields, 'Name')
250
- .replace(/\s+/g, '')
251
- .toLowerCase()
252
- )
253
- )
254
- .map((record) => ({
237
+ const residue = []
238
+
239
+ for (const record of records || []) {
240
+ const fields = record.fields || {}
241
+ if (
242
+ SchematicQaReportBuilder.#field(fields, 'RECORD') !== '41' ||
243
+ !parseBoolean(
244
+ SchematicQaReportBuilder.#rawField(fields, 'IsHidden')
245
+ )
246
+ ) {
247
+ continue
248
+ }
249
+
250
+ const name = SchematicQaReportBuilder.#field(fields, 'Name')
251
+ if (!titleBlockNames.has(name.replace(/\s+/g, '').toLowerCase())) {
252
+ continue
253
+ }
254
+
255
+ residue.push({
255
256
  recordKey: SchematicQaReportBuilder.#recordKey(record),
256
- name: getField(record.fields, 'Name'),
257
- value: getDisplayText(record.fields)
258
- }))
257
+ name,
258
+ value: SchematicQaReportBuilder.#displayText(fields)
259
+ })
260
+ }
261
+
262
+ return residue
259
263
  }
260
264
 
261
265
  /**
@@ -281,4 +285,77 @@ export class SchematicQaReportBuilder {
281
285
  static #recordKey(record) {
282
286
  return 'schematic-record-' + String(record?.recordIndex ?? 0)
283
287
  }
288
+
289
+ /**
290
+ * Reads one common field without invoking generic field-cache bookkeeping.
291
+ * @param {Record<string, string | string[]> | undefined} fields Record fields.
292
+ * @param {string} key Requested key.
293
+ * @returns {string}
294
+ */
295
+ static #field(fields, key) {
296
+ return SchematicQaReportBuilder.#pickFieldValue(
297
+ SchematicQaReportBuilder.#rawField(fields, key),
298
+ false
299
+ )
300
+ }
301
+
302
+ /**
303
+ * Returns the best schematic display text without shared field-cache overhead.
304
+ * @param {Record<string, string | string[]> | undefined} fields Record fields.
305
+ * @returns {string}
306
+ */
307
+ static #displayText(fields) {
308
+ return (
309
+ SchematicQaReportBuilder.#pickFieldValue(
310
+ SchematicQaReportBuilder.#rawField(fields, 'UTF8:Text'),
311
+ true
312
+ ) ||
313
+ SchematicQaReportBuilder.#pickFieldValue(
314
+ SchematicQaReportBuilder.#rawField(fields, 'Text'),
315
+ true
316
+ )
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Reads one raw field from parsed records or simple fixture objects.
322
+ * @param {Record<string, string | string[]> | undefined} fields Record fields.
323
+ * @param {string} key Requested key.
324
+ * @returns {string | string[] | undefined}
325
+ */
326
+ static #rawField(fields, key) {
327
+ if (!fields || typeof fields !== 'object') {
328
+ return undefined
329
+ }
330
+
331
+ const direct = fields[key]
332
+ if (direct !== undefined) {
333
+ return direct
334
+ }
335
+
336
+ const upperKey = key.toUpperCase()
337
+ return upperKey === key ? undefined : fields[upperKey]
338
+ }
339
+
340
+ /**
341
+ * Returns the last meaningful value from one field payload.
342
+ * @param {string | string[] | undefined} raw Raw field payload.
343
+ * @param {boolean} skipAsterisk Whether placeholder asterisks are ignored.
344
+ * @returns {string}
345
+ */
346
+ static #pickFieldValue(raw, skipAsterisk) {
347
+ if (!Array.isArray(raw)) {
348
+ const value = String(raw || '').trim()
349
+ return value && (!skipAsterisk || value !== '*') ? value : ''
350
+ }
351
+
352
+ for (let index = raw.length - 1; index >= 0; index -= 1) {
353
+ const value = String(raw[index] || '').trim()
354
+ if (value && (!skipAsterisk || value !== '*')) {
355
+ return value
356
+ }
357
+ }
358
+
359
+ return ''
360
+ }
284
361
  }
@@ -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
@@ -52,7 +52,22 @@ export class PcbInteractionIndex {
52
52
  * @returns {object[]}
53
53
  */
54
54
  static hitTest(documentModel, point, options = {}) {
55
- return PcbInteractionIndex.build(documentModel)
55
+ return PcbInteractionIndex.hitTestItems(
56
+ PcbInteractionIndex.build(documentModel),
57
+ point,
58
+ options
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Returns hit candidates from an already-built interaction item list.
64
+ * @param {object[]} items Built interaction items.
65
+ * @param {{ x?: unknown, y?: unknown }} point Hit-test point.
66
+ * @param {object} [options] Hit-test options.
67
+ * @returns {object[]}
68
+ */
69
+ static hitTestItems(items, point, options = {}) {
70
+ return (Array.isArray(items) ? items : [])
56
71
  .filter((item) =>
57
72
  PcbInteractionIndex.#isVisibleCandidate(item, options)
58
73
  )