altium-toolkit 1.1.33 → 1.1.36
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/AGENTS.md +2 -2
- package/docs/model-format.md +2 -3
- package/package.json +1 -1
- package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
- package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
- package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
- package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
- package/src/core/altium/PrintableTextDecoder.mjs +20 -1
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
- package/src/core/altium/SchematicTextParser.mjs +18 -253
- package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
- package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
- package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/parser.mjs +1 -0
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +225 -17
- package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
- package/src/ui/PcbScene3dBuilder.mjs +470 -20
- package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
- package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
- package/src/ui/PcbScene3dPackages.mjs +31 -4
- package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +899 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +471 -35
- package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
- package/src/ui/PcbScene3dStaticBodyRecovery.mjs +897 -0
- package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +555 -0
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +438 -0
- package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +897 -0
- package/src/ui/SchematicColorResolver.mjs +44 -0
- package/src/ui/SchematicLineColorResolver.mjs +1 -1
- package/src/ui/SchematicShapeRenderer.mjs +13 -13
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
6
|
|
|
7
7
|
const { getField, parseBoolean, parseNumericField } = ParserUtils
|
|
8
|
+
const WIRED_PART_MIN_SCORE = 2
|
|
9
|
+
const WIRED_PART_DOMINANCE_RATIO = 2
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Resolves which multipart symbol section is visible for one schematic owner.
|
|
@@ -21,6 +23,10 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
21
23
|
const partBounds = new Map()
|
|
22
24
|
const ownerBounds = new Map()
|
|
23
25
|
const directOwnerIndexesByRecord = new WeakMap()
|
|
26
|
+
const ownerlessConnectionPoints =
|
|
27
|
+
SchematicMultipartOwnerMatcher.#collectOwnerlessConnectionPoints(
|
|
28
|
+
records
|
|
29
|
+
)
|
|
24
30
|
|
|
25
31
|
for (const record of records) {
|
|
26
32
|
const ownerIndex = getField(record.fields, 'OwnerIndex')
|
|
@@ -92,11 +98,6 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
92
98
|
parseNumericField(record.fields, 'CurrentPartId') || ''
|
|
93
99
|
)
|
|
94
100
|
const partCount = parseNumericField(record.fields, 'PartCount') || 0
|
|
95
|
-
|
|
96
|
-
if (!currentPartId || partCount <= 1) {
|
|
97
|
-
continue
|
|
98
|
-
}
|
|
99
|
-
|
|
100
101
|
const directOwnerIndex =
|
|
101
102
|
SchematicMultipartOwnerMatcher.#findSerializedOwnerIndex(
|
|
102
103
|
records,
|
|
@@ -107,6 +108,11 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
107
108
|
continue
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
if (!currentPartId || partCount <= 1) {
|
|
112
|
+
directOwnerIndexesByRecord.set(record, directOwnerIndex)
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
|
|
110
116
|
directOwnerIndexesByRecord.set(record, directOwnerIndex)
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -120,13 +126,21 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
120
126
|
const x = parseNumericField(record.fields, 'Location.X')
|
|
121
127
|
const y = parseNumericField(record.fields, 'Location.Y')
|
|
122
128
|
const isMirrored = parseBoolean(record.fields.IsMirrored)
|
|
129
|
+
const directOwnerIndex = directOwnerIndexesByRecord.get(record)
|
|
123
130
|
|
|
124
131
|
if (!currentPartId || partCount <= 1 || x === null || y === null) {
|
|
132
|
+
const inferredPartId =
|
|
133
|
+
SchematicMultipartOwnerMatcher.#inferWiredMultipartOwnerPart(
|
|
134
|
+
records,
|
|
135
|
+
directOwnerIndex,
|
|
136
|
+
ownerlessConnectionPoints
|
|
137
|
+
)
|
|
138
|
+
if (directOwnerIndex && inferredPartId) {
|
|
139
|
+
activeOwnerParts.set(directOwnerIndex, inferredPartId)
|
|
140
|
+
}
|
|
125
141
|
continue
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
const directOwnerIndex = directOwnerIndexesByRecord.get(record)
|
|
129
|
-
|
|
130
144
|
if (directOwnerIndex) {
|
|
131
145
|
activeOwnerParts.set(directOwnerIndex, currentPartId)
|
|
132
146
|
continue
|
|
@@ -253,6 +267,243 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
253
267
|
)
|
|
254
268
|
}
|
|
255
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Infers the visible part for malformed multipart owners from routed pin
|
|
272
|
+
* endpoint evidence. This only applies when one part clearly owns the
|
|
273
|
+
* external wire connections, avoiding guesses for ambiguous overlaps.
|
|
274
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
275
|
+
* @param {string | undefined} ownerIndex
|
|
276
|
+
* @param {{ x: number, y: number }[]} ownerlessConnectionPoints
|
|
277
|
+
* @returns {string}
|
|
278
|
+
*/
|
|
279
|
+
static #inferWiredMultipartOwnerPart(
|
|
280
|
+
records,
|
|
281
|
+
ownerIndex,
|
|
282
|
+
ownerlessConnectionPoints
|
|
283
|
+
) {
|
|
284
|
+
const normalizedOwnerIndex = String(ownerIndex || '').trim()
|
|
285
|
+
if (!normalizedOwnerIndex || !ownerlessConnectionPoints.length) {
|
|
286
|
+
return ''
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const scores =
|
|
290
|
+
SchematicMultipartOwnerMatcher.#scoreWiredMultipartOwnerParts(
|
|
291
|
+
records,
|
|
292
|
+
normalizedOwnerIndex,
|
|
293
|
+
ownerlessConnectionPoints
|
|
294
|
+
)
|
|
295
|
+
if (scores.size < 2) {
|
|
296
|
+
return ''
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const rankedScores = [...scores.entries()].sort((left, right) => {
|
|
300
|
+
if (left[1] !== right[1]) {
|
|
301
|
+
return right[1] - left[1]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return Number(left[0]) - Number(right[0])
|
|
305
|
+
})
|
|
306
|
+
const [bestPartId, bestScore] = rankedScores[0] || ['', 0]
|
|
307
|
+
const secondScore = rankedScores[1]?.[1] || 0
|
|
308
|
+
|
|
309
|
+
if (bestScore < WIRED_PART_MIN_SCORE) {
|
|
310
|
+
return ''
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (
|
|
314
|
+
secondScore > 0 &&
|
|
315
|
+
bestScore < secondScore * WIRED_PART_DOMINANCE_RATIO
|
|
316
|
+
) {
|
|
317
|
+
return ''
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return bestPartId
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Scores owner parts by the number of pin endpoints touching ownerless
|
|
325
|
+
* wire endpoints or vertices.
|
|
326
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
327
|
+
* @param {string} ownerIndex
|
|
328
|
+
* @param {{ x: number, y: number }[]} ownerlessConnectionPoints
|
|
329
|
+
* @returns {Map<string, number>}
|
|
330
|
+
*/
|
|
331
|
+
static #scoreWiredMultipartOwnerParts(
|
|
332
|
+
records,
|
|
333
|
+
ownerIndex,
|
|
334
|
+
ownerlessConnectionPoints
|
|
335
|
+
) {
|
|
336
|
+
const scores = new Map()
|
|
337
|
+
|
|
338
|
+
for (const record of records || []) {
|
|
339
|
+
if (
|
|
340
|
+
getField(record.fields, 'RECORD') !== '2' ||
|
|
341
|
+
getField(record.fields, 'OwnerIndex') !== ownerIndex
|
|
342
|
+
) {
|
|
343
|
+
continue
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const ownerPartId = getField(record.fields, 'OwnerPartId')
|
|
347
|
+
if (!ownerPartId || ownerPartId === '-1') {
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!scores.has(ownerPartId)) {
|
|
352
|
+
scores.set(ownerPartId, 0)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const endpoint =
|
|
356
|
+
SchematicMultipartOwnerMatcher.#resolvePinConnectionPoint(
|
|
357
|
+
record.fields
|
|
358
|
+
)
|
|
359
|
+
if (
|
|
360
|
+
endpoint &&
|
|
361
|
+
SchematicMultipartOwnerMatcher.#pointTouchesAnyConnectionPoint(
|
|
362
|
+
endpoint,
|
|
363
|
+
ownerlessConnectionPoints
|
|
364
|
+
)
|
|
365
|
+
) {
|
|
366
|
+
scores.set(ownerPartId, scores.get(ownerPartId) + 1)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return scores
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Collects ownerless schematic wire endpoints and vertices.
|
|
375
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
376
|
+
* @returns {{ x: number, y: number }[]}
|
|
377
|
+
*/
|
|
378
|
+
static #collectOwnerlessConnectionPoints(records) {
|
|
379
|
+
const points = []
|
|
380
|
+
|
|
381
|
+
for (const record of records || []) {
|
|
382
|
+
if (getField(record.fields, 'OwnerIndex')) {
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const recordType = getField(record.fields, 'RECORD')
|
|
387
|
+
if (
|
|
388
|
+
recordType === '6' ||
|
|
389
|
+
recordType === '26' ||
|
|
390
|
+
recordType === '27'
|
|
391
|
+
) {
|
|
392
|
+
points.push(
|
|
393
|
+
...SchematicMultipartOwnerMatcher.#collectSchematicWirePoints(
|
|
394
|
+
record.fields
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (recordType !== '13') {
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const locationX = parseNumericField(record.fields, 'Location.X')
|
|
405
|
+
const locationY = parseNumericField(record.fields, 'Location.Y')
|
|
406
|
+
const cornerX = parseNumericField(record.fields, 'Corner.X')
|
|
407
|
+
const cornerY = parseNumericField(record.fields, 'Corner.Y')
|
|
408
|
+
|
|
409
|
+
if (locationX !== null && locationY !== null) {
|
|
410
|
+
points.push({ x: locationX, y: locationY })
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (cornerX !== null && cornerY !== null) {
|
|
414
|
+
points.push({ x: cornerX, y: cornerY })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return points
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Collects polyline vertices while preserving omitted unchanged axes.
|
|
423
|
+
* @param {Record<string, string | string[]>} fields
|
|
424
|
+
* @returns {{ x: number, y: number }[]}
|
|
425
|
+
*/
|
|
426
|
+
static #collectSchematicWirePoints(fields) {
|
|
427
|
+
const locationCount = parseNumericField(fields, 'LocationCount')
|
|
428
|
+
const points = []
|
|
429
|
+
let previousX = null
|
|
430
|
+
let previousY = null
|
|
431
|
+
|
|
432
|
+
if (locationCount === null || locationCount < 2) {
|
|
433
|
+
return points
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for (let index = 1; index <= locationCount; index += 1) {
|
|
437
|
+
const x = parseNumericField(fields, 'X' + index)
|
|
438
|
+
const y = parseNumericField(fields, 'Y' + index)
|
|
439
|
+
|
|
440
|
+
if (x === null && y === null) {
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const pointX = x === null ? previousX : x
|
|
445
|
+
const pointY = y === null ? previousY : y
|
|
446
|
+
|
|
447
|
+
if (pointX === null || pointY === null) {
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
points.push({ x: pointX, y: pointY })
|
|
452
|
+
previousX = pointX
|
|
453
|
+
previousY = pointY
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return points
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Resolves the external route endpoint for one raw pin record.
|
|
461
|
+
* @param {Record<string, string | string[]>} fields
|
|
462
|
+
* @returns {{ x: number, y: number } | null}
|
|
463
|
+
*/
|
|
464
|
+
static #resolvePinConnectionPoint(fields) {
|
|
465
|
+
const x = parseNumericField(fields, 'Location.X')
|
|
466
|
+
const y = parseNumericField(fields, 'Location.Y')
|
|
467
|
+
const length = parseNumericField(fields, 'PinLength')
|
|
468
|
+
const orientation =
|
|
469
|
+
SchematicMultipartOwnerMatcher.#inferPinConnectionOrientation(
|
|
470
|
+
parseNumericField(fields, 'PinConglomerate')
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if (x === null || y === null || length === null || !orientation) {
|
|
474
|
+
return null
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
switch (orientation) {
|
|
478
|
+
case 'right':
|
|
479
|
+
return { x: x + length, y }
|
|
480
|
+
case 'left':
|
|
481
|
+
return { x: x - length, y }
|
|
482
|
+
case 'top':
|
|
483
|
+
return { x, y: y + length }
|
|
484
|
+
case 'bottom':
|
|
485
|
+
return { x, y: y - length }
|
|
486
|
+
default:
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Returns true when one point matches any known connection point.
|
|
493
|
+
* @param {{ x: number, y: number }} point
|
|
494
|
+
* @param {{ x: number, y: number }[]} connectionPoints
|
|
495
|
+
* @returns {boolean}
|
|
496
|
+
*/
|
|
497
|
+
static #pointTouchesAnyConnectionPoint(point, connectionPoints) {
|
|
498
|
+
const tolerance = 0.01
|
|
499
|
+
|
|
500
|
+
return connectionPoints.some(
|
|
501
|
+
(connectionPoint) =>
|
|
502
|
+
Math.abs(connectionPoint.x - point.x) <= tolerance &&
|
|
503
|
+
Math.abs(connectionPoint.y - point.y) <= tolerance
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
256
507
|
/**
|
|
257
508
|
* Returns true when one schematic record belongs to the selected visible
|
|
258
509
|
* part for a multipart owner.
|
|
@@ -563,4 +814,34 @@ export class SchematicMultipartOwnerMatcher {
|
|
|
563
814
|
return null
|
|
564
815
|
}
|
|
565
816
|
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Maps raw pin conglomerates into external endpoint orientations.
|
|
820
|
+
* @param {number | null} conglomerate
|
|
821
|
+
* @returns {'left' | 'right' | 'top' | 'bottom' | null}
|
|
822
|
+
*/
|
|
823
|
+
static #inferPinConnectionOrientation(conglomerate) {
|
|
824
|
+
switch (conglomerate) {
|
|
825
|
+
case 34:
|
|
826
|
+
case 42:
|
|
827
|
+
case 50:
|
|
828
|
+
case 58:
|
|
829
|
+
return 'left'
|
|
830
|
+
case 32:
|
|
831
|
+
case 40:
|
|
832
|
+
case 48:
|
|
833
|
+
case 56:
|
|
834
|
+
return 'right'
|
|
835
|
+
case 33:
|
|
836
|
+
case 49:
|
|
837
|
+
case 57:
|
|
838
|
+
return 'top'
|
|
839
|
+
case 35:
|
|
840
|
+
case 51:
|
|
841
|
+
case 59:
|
|
842
|
+
return 'bottom'
|
|
843
|
+
default:
|
|
844
|
+
return null
|
|
845
|
+
}
|
|
846
|
+
}
|
|
566
847
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
4
|
|
|
5
5
|
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
import { SchematicTitleBlockParser } from './SchematicTitleBlockParser.mjs'
|
|
6
7
|
import { SchematicTextRunParser } from './SchematicTextRunParser.mjs'
|
|
7
8
|
import { SchematicTextOrientationResolver } from './SchematicTextOrientationResolver.mjs'
|
|
8
9
|
|
|
@@ -152,13 +153,19 @@ export class SchematicTextParser {
|
|
|
152
153
|
const rawText = ParserUtils.getDisplayText(fields)
|
|
153
154
|
const recordType = ParserUtils.getField(fields, 'RECORD')
|
|
154
155
|
const ownerIndex = ParserUtils.getField(fields, 'OwnerIndex')
|
|
156
|
+
const textMetadata = SchematicTextParser.isTitleBlockFooterRecord(
|
|
157
|
+
fields,
|
|
158
|
+
sheet.width
|
|
159
|
+
)
|
|
160
|
+
? metadata
|
|
161
|
+
: SchematicTextParser.#resolveSchematicTextMetadata(
|
|
162
|
+
ownerIndex,
|
|
163
|
+
metadata,
|
|
164
|
+
ownerMetadata
|
|
165
|
+
)
|
|
155
166
|
const text = SchematicTextParser.#resolveSchematicTemplateText(
|
|
156
167
|
rawText,
|
|
157
|
-
|
|
158
|
-
ownerIndex,
|
|
159
|
-
metadata,
|
|
160
|
-
ownerMetadata
|
|
161
|
-
)
|
|
168
|
+
textMetadata
|
|
162
169
|
)
|
|
163
170
|
const textRuns = SchematicTextRunParser.parseOptionalOverlineRuns(text)
|
|
164
171
|
|
|
@@ -267,70 +274,12 @@ export class SchematicTextParser {
|
|
|
267
274
|
* @returns {{ title: string, revision: string, documentNumber: string, sheetNumber: string, sheetTotal: string, date: string, drawnBy: string, footerHints: Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>> }}
|
|
268
275
|
*/
|
|
269
276
|
static extractSchematicTitleBlock(records, metadata, sheetWidth, fonts) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
)
|
|
276
|
-
)
|
|
277
|
-
.map((record) =>
|
|
278
|
-
SchematicTextParser.#normalizeTitleBlockFooterRecord(
|
|
279
|
-
record.fields,
|
|
280
|
-
fonts
|
|
281
|
-
)
|
|
282
|
-
)
|
|
283
|
-
.filter(Boolean)
|
|
284
|
-
.sort((left, right) => right.y - left.y || left.x - right.x)
|
|
285
|
-
const footerHints =
|
|
286
|
-
SchematicTextParser.#collectSchematicTitleBlockFooterHints(
|
|
287
|
-
footerTexts
|
|
288
|
-
)
|
|
289
|
-
const numericFooterTexts = footerTexts.filter((record) =>
|
|
290
|
-
/^\d+$/.test(record.text)
|
|
277
|
+
return SchematicTitleBlockParser.extract(
|
|
278
|
+
records,
|
|
279
|
+
metadata,
|
|
280
|
+
sheetWidth,
|
|
281
|
+
fonts
|
|
291
282
|
)
|
|
292
|
-
const footerDrawnBy =
|
|
293
|
-
SchematicTextParser.#extractSchematicTitleBlockFooterDrawnBy(
|
|
294
|
-
footerTexts,
|
|
295
|
-
metadata
|
|
296
|
-
)
|
|
297
|
-
|
|
298
|
-
return {
|
|
299
|
-
title:
|
|
300
|
-
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
301
|
-
footerHints.title?.text,
|
|
302
|
-
metadata
|
|
303
|
-
) || SchematicTextParser.#cleanMetadataValue(metadata.title),
|
|
304
|
-
revision:
|
|
305
|
-
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
306
|
-
footerHints.revision?.text,
|
|
307
|
-
metadata
|
|
308
|
-
) || SchematicTextParser.#cleanMetadataValue(metadata.revision),
|
|
309
|
-
documentNumber: SchematicTextParser.#cleanMetadataValue(
|
|
310
|
-
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
311
|
-
footerHints.documentNumber?.text,
|
|
312
|
-
metadata
|
|
313
|
-
) || metadata.documentnumber
|
|
314
|
-
),
|
|
315
|
-
sheetNumber:
|
|
316
|
-
footerHints.sheetNumber?.text ||
|
|
317
|
-
numericFooterTexts[0]?.text ||
|
|
318
|
-
SchematicTextParser.#cleanMetadataValue(metadata.sheetnumber),
|
|
319
|
-
sheetTotal:
|
|
320
|
-
footerHints.sheetTotal?.text ||
|
|
321
|
-
numericFooterTexts[1]?.text ||
|
|
322
|
-
SchematicTextParser.#cleanMetadataValue(metadata.sheettotal),
|
|
323
|
-
date: SchematicTextParser.#cleanMetadataValue(
|
|
324
|
-
metadata.currentdate || metadata.date
|
|
325
|
-
),
|
|
326
|
-
drawnBy:
|
|
327
|
-
SchematicTextParser.#cleanMetadataValue(metadata.drawnby) ||
|
|
328
|
-
footerDrawnBy,
|
|
329
|
-
footerHints:
|
|
330
|
-
SchematicTextParser.#stripSchematicTitleBlockHintText(
|
|
331
|
-
footerHints
|
|
332
|
-
)
|
|
333
|
-
}
|
|
334
283
|
}
|
|
335
284
|
|
|
336
285
|
/**
|
|
@@ -340,182 +289,7 @@ export class SchematicTextParser {
|
|
|
340
289
|
* @returns {boolean}
|
|
341
290
|
*/
|
|
342
291
|
static isTitleBlockFooterRecord(fields, sheetWidth) {
|
|
343
|
-
|
|
344
|
-
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
345
|
-
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
346
|
-
|
|
347
|
-
return (
|
|
348
|
-
recordType === '4' &&
|
|
349
|
-
x !== null &&
|
|
350
|
-
y !== null &&
|
|
351
|
-
x >= sheetWidth * 0.55 &&
|
|
352
|
-
y <= 100
|
|
353
|
-
)
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Normalizes one visible footer text record into a title-block layout hint.
|
|
358
|
-
* @param {Record<string, string | string[]>} fields
|
|
359
|
-
* @param {Record<string, { size: number, family: string, bold: boolean, italic?: boolean, rotation: number }>} fonts
|
|
360
|
-
* @returns {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string } | null}
|
|
361
|
-
*/
|
|
362
|
-
static #normalizeTitleBlockFooterRecord(fields, fonts) {
|
|
363
|
-
const text = ParserUtils.getDisplayText(fields)
|
|
364
|
-
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
365
|
-
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
366
|
-
|
|
367
|
-
if (!text || x === null || y === null) {
|
|
368
|
-
return null
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const font =
|
|
372
|
-
fonts[ParserUtils.getField(fields, 'FontID')] ||
|
|
373
|
-
SchematicTextParser.#defaultSchematicFont()
|
|
374
|
-
|
|
375
|
-
return {
|
|
376
|
-
text,
|
|
377
|
-
x,
|
|
378
|
-
y,
|
|
379
|
-
color: SchematicTextParser.#resolveSchematicTextColor(
|
|
380
|
-
fields,
|
|
381
|
-
ParserUtils.getField(fields, 'RECORD')
|
|
382
|
-
),
|
|
383
|
-
fontSize: font.size,
|
|
384
|
-
fontFamily: font.family,
|
|
385
|
-
fontWeight: font.bold ? 700 : 400,
|
|
386
|
-
...(font.italic ? { fontStyle: 'italic' } : {})
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Maps visible footer rows onto title-block fields.
|
|
392
|
-
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]} footerTexts
|
|
393
|
-
* @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>>}
|
|
394
|
-
*/
|
|
395
|
-
static #collectSchematicTitleBlockFooterHints(footerTexts) {
|
|
396
|
-
const rows = SchematicTextParser.#groupTitleBlockFooterRows(footerTexts)
|
|
397
|
-
const topRow = rows[0] || []
|
|
398
|
-
const middleRow = rows.length > 2 ? rows[1] || [] : []
|
|
399
|
-
const sheetRow =
|
|
400
|
-
[...rows]
|
|
401
|
-
.reverse()
|
|
402
|
-
.find(
|
|
403
|
-
(row) =>
|
|
404
|
-
row.filter((record) => /^\d+$/.test(record.text))
|
|
405
|
-
.length >= 2
|
|
406
|
-
) || []
|
|
407
|
-
const numericSheetRow = sheetRow.filter((record) =>
|
|
408
|
-
/^\d+$/.test(record.text)
|
|
409
|
-
)
|
|
410
|
-
const topRowHasVisibleTitleText = topRow.some(
|
|
411
|
-
(record) => /^\d+$/.test(record.text) === false
|
|
412
|
-
)
|
|
413
|
-
const hints = {}
|
|
414
|
-
|
|
415
|
-
if (topRow.length && topRowHasVisibleTitleText) {
|
|
416
|
-
hints.title = topRow[0]
|
|
417
|
-
|
|
418
|
-
if (topRow.length > 1) {
|
|
419
|
-
hints.documentNumber = topRow.at(-1)
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (middleRow.length) {
|
|
424
|
-
hints.revision = middleRow.at(-1)
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
if (numericSheetRow.length) {
|
|
428
|
-
hints.sheetNumber = numericSheetRow[0]
|
|
429
|
-
hints.sheetTotal = numericSheetRow.at(-1)
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return hints
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Groups footer texts by their shared baseline row.
|
|
437
|
-
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]} footerTexts
|
|
438
|
-
* @returns {Array<{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]>}
|
|
439
|
-
*/
|
|
440
|
-
static #groupTitleBlockFooterRows(footerTexts) {
|
|
441
|
-
const tolerance = 8
|
|
442
|
-
const rows = []
|
|
443
|
-
|
|
444
|
-
for (const record of footerTexts) {
|
|
445
|
-
const currentRow = rows.at(-1)
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
currentRow &&
|
|
449
|
-
Math.abs(currentRow[0].y - record.y) <= tolerance
|
|
450
|
-
) {
|
|
451
|
-
currentRow.push(record)
|
|
452
|
-
currentRow.sort((left, right) => left.x - right.x)
|
|
453
|
-
continue
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
rows.push([record])
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return rows
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Resolves one visible footer placeholder against hidden sheet metadata.
|
|
464
|
-
* @param {string | undefined} text
|
|
465
|
-
* @param {Record<string, string>} metadata
|
|
466
|
-
* @returns {string}
|
|
467
|
-
*/
|
|
468
|
-
static #resolveTitleBlockFooterValue(text, metadata) {
|
|
469
|
-
const resolved = SchematicTextParser.#resolveSchematicTemplateText(
|
|
470
|
-
text,
|
|
471
|
-
metadata
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
if (String(resolved || '').startsWith('=')) {
|
|
475
|
-
return ''
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
return SchematicTextParser.#cleanMetadataValue(resolved)
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Extracts a visible footer `Drawn By` value from the bottom-most footer
|
|
483
|
-
* row when hidden metadata does not provide one.
|
|
484
|
-
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }[]} footerTexts
|
|
485
|
-
* @param {Record<string, string>} metadata
|
|
486
|
-
* @returns {string}
|
|
487
|
-
*/
|
|
488
|
-
static #extractSchematicTitleBlockFooterDrawnBy(footerTexts, metadata) {
|
|
489
|
-
const bottomRow =
|
|
490
|
-
SchematicTextParser.#groupTitleBlockFooterRows(footerTexts).at(
|
|
491
|
-
-1
|
|
492
|
-
) || []
|
|
493
|
-
const candidates = bottomRow
|
|
494
|
-
.map((record) =>
|
|
495
|
-
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
496
|
-
record.text,
|
|
497
|
-
metadata
|
|
498
|
-
)
|
|
499
|
-
)
|
|
500
|
-
.filter(
|
|
501
|
-
(value) => value && /^\d+$/.test(String(value).trim()) === false
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
return candidates.at(-1) || ''
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Removes the non-rendered text payload from stored footer hints.
|
|
509
|
-
* @param {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>>} footerHints
|
|
510
|
-
* @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string }>>}
|
|
511
|
-
*/
|
|
512
|
-
static #stripSchematicTitleBlockHintText(footerHints) {
|
|
513
|
-
return Object.fromEntries(
|
|
514
|
-
Object.entries(footerHints).map(([key, value]) => {
|
|
515
|
-
const { text: _text, ...hint } = value
|
|
516
|
-
return [key, hint]
|
|
517
|
-
})
|
|
518
|
-
)
|
|
292
|
+
return SchematicTitleBlockParser.isFooterRecord(fields, sheetWidth)
|
|
519
293
|
}
|
|
520
294
|
|
|
521
295
|
/**
|
|
@@ -853,15 +627,6 @@ export class SchematicTextParser {
|
|
|
853
627
|
return Number(size || 10)
|
|
854
628
|
}
|
|
855
629
|
|
|
856
|
-
/**
|
|
857
|
-
* Normalizes placeholder metadata values.
|
|
858
|
-
* @param {string | undefined} value
|
|
859
|
-
* @returns {string}
|
|
860
|
-
*/
|
|
861
|
-
static #cleanMetadataValue(value) {
|
|
862
|
-
return value && value !== '*' ? value : ''
|
|
863
|
-
}
|
|
864
|
-
|
|
865
630
|
/**
|
|
866
631
|
* Adds note box metadata to one decoded schematic note record.
|
|
867
632
|
* @param {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, fontStyle?: string, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end' }} textRecord
|