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.
Files changed (38) hide show
  1. package/AGENTS.md +2 -2
  2. package/docs/model-format.md +2 -3
  3. package/package.json +1 -1
  4. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +255 -1
  5. package/src/core/altium/AltiumPcbLibExporter.mjs +6 -1
  6. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
  7. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +14 -3
  8. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
  9. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  10. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  11. package/src/core/altium/SchematicTextParser.mjs +18 -253
  12. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  13. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  14. package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
  15. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  16. package/src/parser.mjs +1 -0
  17. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
  18. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +6 -2
  19. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
  20. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
  21. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +225 -17
  22. package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
  23. package/src/ui/PcbScene3dBuilder.mjs +470 -20
  24. package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
  25. package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
  26. package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
  27. package/src/ui/PcbScene3dPackages.mjs +31 -4
  28. package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
  29. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +899 -0
  30. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +471 -35
  31. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  32. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +897 -0
  33. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +555 -0
  34. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +438 -0
  35. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +897 -0
  36. package/src/ui/SchematicColorResolver.mjs +44 -0
  37. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  38. 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
- SchematicTextParser.#resolveSchematicTextMetadata(
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
- const footerTexts = records
271
- .filter((record) =>
272
- SchematicTextParser.isTitleBlockFooterRecord(
273
- record.fields,
274
- sheetWidth
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
- const recordType = ParserUtils.getField(fields, 'RECORD')
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