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.
- package/package.json +1 -1
- package/src/core/altium/NaturalStringComparator.mjs +175 -0
- package/src/core/altium/ParserUtils.mjs +83 -14
- package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +104 -33
- package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +26 -4
- package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +28 -5
- package/src/core/altium/PcbOwnershipGraphBuilder.mjs +1 -3
- package/src/core/altium/PcbReviewMetadataBuilder.mjs +59 -28
- package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +66 -29
- package/src/core/altium/PcbRouteAnalysisBuilder.mjs +50 -16
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +36 -11
- package/src/core/altium/SchematicImplementationParser.mjs +75 -36
- package/src/core/altium/SchematicQaReportBuilder.mjs +115 -38
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +155 -5
- package/src/ui/PcbInteractionIndex.mjs +16 -1
|
@@ -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,
|
|
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
|
-
|
|
191
|
+
childrenByOwner,
|
|
188
192
|
indexInSheet
|
|
189
193
|
),
|
|
190
194
|
parameters: SchematicImplementationParser.#parameters(
|
|
191
|
-
|
|
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[]}
|
|
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(
|
|
334
|
-
return (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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[]}
|
|
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(
|
|
368
|
-
return (
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
257
|
-
value:
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
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
|
)
|