altium-toolkit 0.1.0
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 +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- package/src/workers/altium-parser.worker.mjs +29 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
getField,
|
|
9
|
+
parseBoolean,
|
|
10
|
+
parseNumericField,
|
|
11
|
+
parseNumericFieldWithFraction,
|
|
12
|
+
toColor
|
|
13
|
+
} = ParserUtils
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes schematic drawing primitives that are not plain line segments.
|
|
17
|
+
*/
|
|
18
|
+
export class SchematicPrimitiveParser {
|
|
19
|
+
/**
|
|
20
|
+
* Returns true when one record belongs to the rectangle primitive family.
|
|
21
|
+
* Some record-225 frames store only `Location`/`Corner` in printable runs.
|
|
22
|
+
* @param {Record<string, string | string[]>} fields
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
static isRectangleRecord(fields) {
|
|
26
|
+
const recordType = getField(fields, 'RECORD')
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
recordType === '14' ||
|
|
30
|
+
recordType === '225' ||
|
|
31
|
+
SchematicPrimitiveParser.isListedRectangleRecord(fields)
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns true when one point-listed primitive describes an axis-aligned
|
|
37
|
+
* rectangle instead of an arbitrary polyline.
|
|
38
|
+
* @param {Record<string, string | string[]>} fields
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
static isListedRectangleRecord(fields) {
|
|
42
|
+
const locationX = parseNumericField(fields, 'Location.X')
|
|
43
|
+
const locationY = parseNumericField(fields, 'Location.Y')
|
|
44
|
+
const cornerX = parseNumericField(fields, 'Corner.X')
|
|
45
|
+
const cornerY = parseNumericField(fields, 'Corner.Y')
|
|
46
|
+
const points = SchematicPrimitiveParser.#collectPolygonPoints(fields)
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
locationX === null ||
|
|
50
|
+
locationY === null ||
|
|
51
|
+
cornerX === null ||
|
|
52
|
+
cornerY === null ||
|
|
53
|
+
points.length !== 4
|
|
54
|
+
) {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const xs = [...new Set(points.map((point) => point.x))]
|
|
59
|
+
const ys = [...new Set(points.map((point) => point.y))]
|
|
60
|
+
|
|
61
|
+
if (xs.length !== 2 || ys.length !== 2) {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const minX = Math.min(...xs)
|
|
66
|
+
const maxX = Math.max(...xs)
|
|
67
|
+
const minY = Math.min(...ys)
|
|
68
|
+
const maxY = Math.max(...ys)
|
|
69
|
+
const corners = new Set([
|
|
70
|
+
minX + ':' + minY,
|
|
71
|
+
minX + ':' + maxY,
|
|
72
|
+
maxX + ':' + minY,
|
|
73
|
+
maxX + ':' + maxY
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
corners.has(locationX + ':' + locationY) &&
|
|
78
|
+
corners.has(cornerX + ':' + cornerY) &&
|
|
79
|
+
points.every((point) => corners.has(point.x + ':' + point.y))
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Normalizes record-7 polygon primitives into fill-capable polygons.
|
|
85
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
86
|
+
* @returns {{ points: { x: number, y: number }[], color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string }[]}
|
|
87
|
+
*/
|
|
88
|
+
static parseSchematicPolygons(records) {
|
|
89
|
+
return records
|
|
90
|
+
.map((record, index) => {
|
|
91
|
+
const points = SchematicPrimitiveParser.#collectPolygonPoints(
|
|
92
|
+
record.fields
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if (points.length < 2) {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
points,
|
|
101
|
+
color: toColor(record.fields.Color, '#a44a1b'),
|
|
102
|
+
fill: toColor(record.fields.AreaColor, '#ffe16f'),
|
|
103
|
+
isSolid: parseBoolean(record.fields.IsSolid),
|
|
104
|
+
transparent: parseBoolean(record.fields.Transparent),
|
|
105
|
+
lineWidth:
|
|
106
|
+
parseNumericField(record.fields, 'LineWidth') || 1,
|
|
107
|
+
renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
|
|
108
|
+
record.fields,
|
|
109
|
+
index
|
|
110
|
+
),
|
|
111
|
+
ownerIndex:
|
|
112
|
+
getField(record.fields, 'OwnerIndex') || undefined
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalizes record-14 body primitives into drawable rectangles.
|
|
120
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
121
|
+
* @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, ownerIndex?: string }[]}
|
|
122
|
+
*/
|
|
123
|
+
static parseSchematicRectangles(records) {
|
|
124
|
+
return records
|
|
125
|
+
.map((record, index) => {
|
|
126
|
+
const x1 = parseNumericField(record.fields, 'Location.X')
|
|
127
|
+
const y1 = parseNumericField(record.fields, 'Location.Y')
|
|
128
|
+
const x2 = parseNumericField(record.fields, 'Corner.X')
|
|
129
|
+
const y2 = parseNumericField(record.fields, 'Corner.Y')
|
|
130
|
+
const isRectangleRecord =
|
|
131
|
+
SchematicPrimitiveParser.isRectangleRecord(record.fields)
|
|
132
|
+
const isListedRectangle =
|
|
133
|
+
SchematicPrimitiveParser.isListedRectangleRecord(
|
|
134
|
+
record.fields
|
|
135
|
+
)
|
|
136
|
+
const usesFrameFallback =
|
|
137
|
+
SchematicPrimitiveParser.#shouldUseFrameFallback(
|
|
138
|
+
record.fields,
|
|
139
|
+
isListedRectangle
|
|
140
|
+
)
|
|
141
|
+
const recordType = getField(record.fields, 'RECORD')
|
|
142
|
+
|
|
143
|
+
if (x1 === null || y1 === null || x2 === null || y2 === null) {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
x: Math.min(x1, x2),
|
|
149
|
+
y: Math.min(y1, y2),
|
|
150
|
+
width: Math.abs(x2 - x1),
|
|
151
|
+
height: Math.abs(y2 - y1),
|
|
152
|
+
color: usesFrameFallback
|
|
153
|
+
? '#ff0000'
|
|
154
|
+
: toColor(
|
|
155
|
+
record.fields.Color,
|
|
156
|
+
recordType === '225' ? '#ff0000' : '#a44a1b'
|
|
157
|
+
),
|
|
158
|
+
fill: usesFrameFallback
|
|
159
|
+
? '#ffffff'
|
|
160
|
+
: toColor(
|
|
161
|
+
record.fields.AreaColor,
|
|
162
|
+
recordType === '225' ? '#ffffff' : '#ffe16f'
|
|
163
|
+
),
|
|
164
|
+
isSolid:
|
|
165
|
+
parseBoolean(record.fields.IsSolid) ||
|
|
166
|
+
usesFrameFallback ||
|
|
167
|
+
SchematicPrimitiveParser.#hasImplicitAreaFill(
|
|
168
|
+
record.fields,
|
|
169
|
+
isRectangleRecord
|
|
170
|
+
),
|
|
171
|
+
transparent: usesFrameFallback
|
|
172
|
+
? false
|
|
173
|
+
: parseBoolean(record.fields.Transparent),
|
|
174
|
+
lineWidth:
|
|
175
|
+
parseNumericField(record.fields, 'LineWidth') || 1,
|
|
176
|
+
lineStyle: usesFrameFallback
|
|
177
|
+
? 1
|
|
178
|
+
: parseNumericField(record.fields, 'LineStyle') || 0,
|
|
179
|
+
renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
|
|
180
|
+
record.fields,
|
|
181
|
+
index
|
|
182
|
+
),
|
|
183
|
+
ownerIndex:
|
|
184
|
+
getField(record.fields, 'OwnerIndex') || undefined
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Infers paint order for solid owner body rectangles whose printable
|
|
192
|
+
* record lost IndexInSheet. Those bodies should stay behind their owner's
|
|
193
|
+
* indexed contact/detail primitives rather than inheriting unrelated
|
|
194
|
+
* global rectangle-list offsets from elsewhere on the sheet.
|
|
195
|
+
* @param {{ fields: Record<string, string | string[]> }[]} rectangleRecords
|
|
196
|
+
* @param {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, renderOrder: number, ownerIndex?: string }[]} rectangles
|
|
197
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, renderOrder?: number, ownerIndex?: string }[]} lines
|
|
198
|
+
* @param {{ points: { x: number, y: number }[], renderOrder?: number, ownerIndex?: string }[]} polygons
|
|
199
|
+
* @param {{ x: number, y: number, radiusX: number, radiusY: number, renderOrder?: number, ownerIndex?: string }[]} ellipses
|
|
200
|
+
* @param {{ x: number, y: number, radius: number, radiusY?: number, renderOrder?: number, ownerIndex?: string }[]} arcs
|
|
201
|
+
* @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, lineStyle: number, renderOrder: number, ownerIndex?: string }[]}
|
|
202
|
+
*/
|
|
203
|
+
static inferMissingOwnerRectangleRenderOrders(
|
|
204
|
+
rectangleRecords,
|
|
205
|
+
rectangles,
|
|
206
|
+
lines,
|
|
207
|
+
polygons,
|
|
208
|
+
ellipses,
|
|
209
|
+
arcs
|
|
210
|
+
) {
|
|
211
|
+
const rectangleMetaQueues =
|
|
212
|
+
SchematicPrimitiveParser.#buildRectangleRecordMetaQueues(
|
|
213
|
+
rectangleRecords
|
|
214
|
+
)
|
|
215
|
+
const normalizedRectangles = rectangles.map((rectangle) => ({
|
|
216
|
+
rectangle,
|
|
217
|
+
hasExplicitOrder:
|
|
218
|
+
SchematicPrimitiveParser.#shiftRectangleMeta(
|
|
219
|
+
rectangleMetaQueues,
|
|
220
|
+
rectangle
|
|
221
|
+
)?.hasExplicitOrder || false
|
|
222
|
+
}))
|
|
223
|
+
const ownerGeometryItems =
|
|
224
|
+
SchematicPrimitiveParser.#buildOwnerGeometryItems(
|
|
225
|
+
normalizedRectangles,
|
|
226
|
+
lines,
|
|
227
|
+
polygons,
|
|
228
|
+
ellipses,
|
|
229
|
+
arcs
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return normalizedRectangles.map(({ rectangle, hasExplicitOrder }) => {
|
|
233
|
+
if (
|
|
234
|
+
hasExplicitOrder ||
|
|
235
|
+
!rectangle.ownerIndex ||
|
|
236
|
+
rectangle.isSolid !== true
|
|
237
|
+
) {
|
|
238
|
+
return rectangle
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const inferredRenderOrder =
|
|
242
|
+
SchematicPrimitiveParser.#inferMissingOwnerRectangleRenderOrder(
|
|
243
|
+
rectangle,
|
|
244
|
+
ownerGeometryItems.get(String(rectangle.ownerIndex)) || []
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if (inferredRenderOrder === null) {
|
|
248
|
+
return rectangle
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
...rectangle,
|
|
253
|
+
renderOrder: inferredRenderOrder
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Normalizes authored sheet overlay regions into rectangular overlays.
|
|
260
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
261
|
+
* @returns {{ x: number, y: number, width: number, height: number, color: string, fill: string, renderOrder: number }[]}
|
|
262
|
+
*/
|
|
263
|
+
static parseSchematicRegions(records) {
|
|
264
|
+
return records
|
|
265
|
+
.map((record, index) => {
|
|
266
|
+
const x1 = parseNumericField(record.fields, 'Location.X')
|
|
267
|
+
const y1 = parseNumericField(record.fields, 'Location.Y')
|
|
268
|
+
const x2 = parseNumericField(record.fields, 'Corner.X')
|
|
269
|
+
const y2 = parseNumericField(record.fields, 'Corner.Y')
|
|
270
|
+
|
|
271
|
+
if (x1 === null || y1 === null || x2 === null || y2 === null) {
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
x: Math.min(x1, x2),
|
|
277
|
+
y: Math.min(y1, y2),
|
|
278
|
+
width: Math.abs(x2 - x1),
|
|
279
|
+
height: Math.abs(y2 - y1),
|
|
280
|
+
color: toColor(record.fields.Color, '#ff0000'),
|
|
281
|
+
fill: toColor(record.fields.AreaColor, '#ffffcf'),
|
|
282
|
+
renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
|
|
283
|
+
record.fields,
|
|
284
|
+
index
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
.filter(Boolean)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Normalizes record-11/12 curve primitives into drawable arcs.
|
|
293
|
+
* Record 11 carries an optional secondary radius for ellipse segments.
|
|
294
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
295
|
+
* @returns {{ x: number, y: number, radius: number, radiusY?: number, startAngle: number, endAngle: number, color: string, width: number, ownerIndex?: string }[]}
|
|
296
|
+
*/
|
|
297
|
+
static parseSchematicArcs(records) {
|
|
298
|
+
return records
|
|
299
|
+
.map((record, index) => {
|
|
300
|
+
const x = parseNumericFieldWithFraction(
|
|
301
|
+
record.fields,
|
|
302
|
+
'Location.X'
|
|
303
|
+
)
|
|
304
|
+
const y = parseNumericFieldWithFraction(
|
|
305
|
+
record.fields,
|
|
306
|
+
'Location.Y'
|
|
307
|
+
)
|
|
308
|
+
const radius = parseNumericFieldWithFraction(
|
|
309
|
+
record.fields,
|
|
310
|
+
'Radius'
|
|
311
|
+
)
|
|
312
|
+
const radiusY = parseNumericFieldWithFraction(
|
|
313
|
+
record.fields,
|
|
314
|
+
'SecondaryRadius'
|
|
315
|
+
)
|
|
316
|
+
const startAngle = parseNumericField(
|
|
317
|
+
record.fields,
|
|
318
|
+
'StartAngle'
|
|
319
|
+
)
|
|
320
|
+
const endAngle = parseNumericField(record.fields, 'EndAngle')
|
|
321
|
+
const normalizedRadiusY = radiusY === null ? radius : radiusY
|
|
322
|
+
|
|
323
|
+
if (
|
|
324
|
+
x === null ||
|
|
325
|
+
y === null ||
|
|
326
|
+
radius === null ||
|
|
327
|
+
radius <= 0 ||
|
|
328
|
+
normalizedRadiusY === null ||
|
|
329
|
+
normalizedRadiusY <= 0
|
|
330
|
+
) {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
x,
|
|
336
|
+
y,
|
|
337
|
+
radius,
|
|
338
|
+
...(getField(record.fields, 'RECORD') === '11'
|
|
339
|
+
? { radiusY: normalizedRadiusY }
|
|
340
|
+
: {}),
|
|
341
|
+
startAngle: startAngle === null ? 0 : startAngle,
|
|
342
|
+
endAngle: endAngle === null ? 360 : endAngle,
|
|
343
|
+
color: toColor(record.fields.Color, '#a44a1b'),
|
|
344
|
+
width: parseNumericField(record.fields, 'LineWidth') || 1,
|
|
345
|
+
renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
|
|
346
|
+
record.fields,
|
|
347
|
+
index
|
|
348
|
+
),
|
|
349
|
+
ownerIndex:
|
|
350
|
+
getField(record.fields, 'OwnerIndex') || undefined
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
.filter(Boolean)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Normalizes record-8 ellipse primitives into drawable outlines.
|
|
358
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
359
|
+
* @returns {{ x: number, y: number, radiusX: number, radiusY: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string }[]}
|
|
360
|
+
*/
|
|
361
|
+
static parseSchematicEllipses(records) {
|
|
362
|
+
return records
|
|
363
|
+
.map((record, index) => {
|
|
364
|
+
const x = parseNumericFieldWithFraction(
|
|
365
|
+
record.fields,
|
|
366
|
+
'Location.X'
|
|
367
|
+
)
|
|
368
|
+
const y = parseNumericFieldWithFraction(
|
|
369
|
+
record.fields,
|
|
370
|
+
'Location.Y'
|
|
371
|
+
)
|
|
372
|
+
const radiusX = parseNumericFieldWithFraction(
|
|
373
|
+
record.fields,
|
|
374
|
+
'Radius'
|
|
375
|
+
)
|
|
376
|
+
const radiusY = parseNumericFieldWithFraction(
|
|
377
|
+
record.fields,
|
|
378
|
+
'SecondaryRadius'
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
if (
|
|
382
|
+
x === null ||
|
|
383
|
+
y === null ||
|
|
384
|
+
radiusX === null ||
|
|
385
|
+
radiusX <= 0 ||
|
|
386
|
+
radiusY === null ||
|
|
387
|
+
radiusY <= 0
|
|
388
|
+
) {
|
|
389
|
+
return null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
x,
|
|
394
|
+
y,
|
|
395
|
+
radiusX,
|
|
396
|
+
radiusY,
|
|
397
|
+
color: toColor(record.fields.Color, '#a44a1b'),
|
|
398
|
+
fill: toColor(record.fields.AreaColor, '#ffffff'),
|
|
399
|
+
isSolid: parseBoolean(record.fields.IsSolid),
|
|
400
|
+
transparent: parseBoolean(record.fields.Transparent),
|
|
401
|
+
lineWidth:
|
|
402
|
+
parseNumericField(record.fields, 'LineWidth') || 1,
|
|
403
|
+
renderOrder: SchematicPrimitiveParser.#resolveRenderOrder(
|
|
404
|
+
record.fields,
|
|
405
|
+
index
|
|
406
|
+
),
|
|
407
|
+
ownerIndex:
|
|
408
|
+
getField(record.fields, 'OwnerIndex') || undefined
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Resolves one stable render-order key from Altium sheet order metadata.
|
|
416
|
+
* @param {Record<string, string | string[]>} fields
|
|
417
|
+
* @param {number} fallbackOrder
|
|
418
|
+
* @returns {number}
|
|
419
|
+
*/
|
|
420
|
+
static #resolveRenderOrder(fields, fallbackOrder) {
|
|
421
|
+
const indexInSheet = parseNumericField(fields, 'IndexInSheet')
|
|
422
|
+
|
|
423
|
+
if (indexInSheet !== null) {
|
|
424
|
+
return indexInSheet
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return fallbackOrder
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Builds one stable geometry-key queue for rectangle source metadata.
|
|
432
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
433
|
+
* @returns {Map<string, { hasExplicitOrder: boolean }[]>}
|
|
434
|
+
*/
|
|
435
|
+
static #buildRectangleRecordMetaQueues(records) {
|
|
436
|
+
const queues = new Map()
|
|
437
|
+
|
|
438
|
+
for (const record of records) {
|
|
439
|
+
const x1 = parseNumericField(record.fields, 'Location.X')
|
|
440
|
+
const y1 = parseNumericField(record.fields, 'Location.Y')
|
|
441
|
+
const x2 = parseNumericField(record.fields, 'Corner.X')
|
|
442
|
+
const y2 = parseNumericField(record.fields, 'Corner.Y')
|
|
443
|
+
|
|
444
|
+
if (x1 === null || y1 === null || x2 === null || y2 === null) {
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const key = SchematicPrimitiveParser.#buildRectangleGeometryKey({
|
|
449
|
+
ownerIndex: getField(record.fields, 'OwnerIndex') || undefined,
|
|
450
|
+
x: Math.min(x1, x2),
|
|
451
|
+
y: Math.min(y1, y2),
|
|
452
|
+
width: Math.abs(x2 - x1),
|
|
453
|
+
height: Math.abs(y2 - y1)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
if (!queues.has(key)) {
|
|
457
|
+
queues.set(key, [])
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
queues.get(key).push({
|
|
461
|
+
hasExplicitOrder:
|
|
462
|
+
parseNumericField(record.fields, 'IndexInSheet') !== null
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return queues
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Consumes one rectangle source-metadata queue entry for a normalized body.
|
|
471
|
+
* @param {Map<string, { hasExplicitOrder: boolean }[]>} queues
|
|
472
|
+
* @param {{ ownerIndex?: string, x: number, y: number, width: number, height: number }} rectangle
|
|
473
|
+
* @returns {{ hasExplicitOrder: boolean } | null}
|
|
474
|
+
*/
|
|
475
|
+
static #shiftRectangleMeta(queues, rectangle) {
|
|
476
|
+
const key =
|
|
477
|
+
SchematicPrimitiveParser.#buildRectangleGeometryKey(rectangle)
|
|
478
|
+
const queue = queues.get(key)
|
|
479
|
+
|
|
480
|
+
if (!queue?.length) {
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return queue.shift() || null
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Builds one geometry key that stays stable across raw and normalized
|
|
489
|
+
* rectangle representations.
|
|
490
|
+
* @param {{ ownerIndex?: string, x: number, y: number, width: number, height: number }} rectangle
|
|
491
|
+
* @returns {string}
|
|
492
|
+
*/
|
|
493
|
+
static #buildRectangleGeometryKey(rectangle) {
|
|
494
|
+
return [
|
|
495
|
+
String(rectangle.ownerIndex || ''),
|
|
496
|
+
Number(rectangle.x),
|
|
497
|
+
Number(rectangle.y),
|
|
498
|
+
Number(rectangle.width),
|
|
499
|
+
Number(rectangle.height)
|
|
500
|
+
].join(':')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Collects owner-geometry bounds that can help infer one missing owner
|
|
505
|
+
* body render order.
|
|
506
|
+
* @param {{ rectangle: { ownerIndex?: string, x: number, y: number, width: number, height: number, renderOrder: number }, hasExplicitOrder: boolean }[]} rectangles
|
|
507
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, renderOrder?: number, ownerIndex?: string }[]} lines
|
|
508
|
+
* @param {{ points: { x: number, y: number }[], renderOrder?: number, ownerIndex?: string }[]} polygons
|
|
509
|
+
* @param {{ x: number, y: number, radiusX: number, radiusY: number, renderOrder?: number, ownerIndex?: string }[]} ellipses
|
|
510
|
+
* @param {{ x: number, y: number, radius: number, radiusY?: number, renderOrder?: number, ownerIndex?: string }[]} arcs
|
|
511
|
+
* @returns {Map<string, { renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }[]>}
|
|
512
|
+
*/
|
|
513
|
+
static #buildOwnerGeometryItems(
|
|
514
|
+
rectangles,
|
|
515
|
+
lines,
|
|
516
|
+
polygons,
|
|
517
|
+
ellipses,
|
|
518
|
+
arcs
|
|
519
|
+
) {
|
|
520
|
+
const ownerItems = new Map()
|
|
521
|
+
|
|
522
|
+
for (const { rectangle, hasExplicitOrder } of rectangles) {
|
|
523
|
+
if (!rectangle.ownerIndex || !hasExplicitOrder) {
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
|
|
528
|
+
ownerIndex: String(rectangle.ownerIndex),
|
|
529
|
+
renderOrder: Number(rectangle.renderOrder),
|
|
530
|
+
minX: rectangle.x,
|
|
531
|
+
maxX: rectangle.x + rectangle.width,
|
|
532
|
+
minY: rectangle.y,
|
|
533
|
+
maxY: rectangle.y + rectangle.height
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
if (!line.ownerIndex) {
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
|
|
543
|
+
ownerIndex: String(line.ownerIndex),
|
|
544
|
+
renderOrder: Number(line.renderOrder),
|
|
545
|
+
minX: Math.min(Number(line.x1), Number(line.x2)),
|
|
546
|
+
maxX: Math.max(Number(line.x1), Number(line.x2)),
|
|
547
|
+
minY: Math.min(Number(line.y1), Number(line.y2)),
|
|
548
|
+
maxY: Math.max(Number(line.y1), Number(line.y2))
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
for (const polygon of polygons) {
|
|
553
|
+
if (!polygon.ownerIndex || !polygon.points?.length) {
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const xs = polygon.points.map((point) => Number(point.x))
|
|
558
|
+
const ys = polygon.points.map((point) => Number(point.y))
|
|
559
|
+
|
|
560
|
+
SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
|
|
561
|
+
ownerIndex: String(polygon.ownerIndex),
|
|
562
|
+
renderOrder: Number(polygon.renderOrder),
|
|
563
|
+
minX: Math.min(...xs),
|
|
564
|
+
maxX: Math.max(...xs),
|
|
565
|
+
minY: Math.min(...ys),
|
|
566
|
+
maxY: Math.max(...ys)
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
for (const ellipse of ellipses) {
|
|
571
|
+
if (!ellipse.ownerIndex) {
|
|
572
|
+
continue
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
|
|
576
|
+
ownerIndex: String(ellipse.ownerIndex),
|
|
577
|
+
renderOrder: Number(ellipse.renderOrder),
|
|
578
|
+
minX: Number(ellipse.x) - Number(ellipse.radiusX),
|
|
579
|
+
maxX: Number(ellipse.x) + Number(ellipse.radiusX),
|
|
580
|
+
minY: Number(ellipse.y) - Number(ellipse.radiusY),
|
|
581
|
+
maxY: Number(ellipse.y) + Number(ellipse.radiusY)
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (const arc of arcs) {
|
|
586
|
+
if (!arc.ownerIndex) {
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const radiusY = Number(arc.radiusY || arc.radius)
|
|
591
|
+
|
|
592
|
+
SchematicPrimitiveParser.#pushOwnerGeometryItem(ownerItems, {
|
|
593
|
+
ownerIndex: String(arc.ownerIndex),
|
|
594
|
+
renderOrder: Number(arc.renderOrder),
|
|
595
|
+
minX: Number(arc.x) - Number(arc.radius),
|
|
596
|
+
maxX: Number(arc.x) + Number(arc.radius),
|
|
597
|
+
minY: Number(arc.y) - radiusY,
|
|
598
|
+
maxY: Number(arc.y) + radiusY
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return ownerItems
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Stores one owner-geometry candidate for missing-body order inference.
|
|
607
|
+
* @param {Map<string, { renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }[]>} ownerItems
|
|
608
|
+
* @param {{ ownerIndex: string, renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }} item
|
|
609
|
+
* @returns {void}
|
|
610
|
+
*/
|
|
611
|
+
static #pushOwnerGeometryItem(ownerItems, item) {
|
|
612
|
+
if (!Number.isFinite(item.renderOrder)) {
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!ownerItems.has(item.ownerIndex)) {
|
|
617
|
+
ownerItems.set(item.ownerIndex, [])
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
ownerItems.get(item.ownerIndex).push({
|
|
621
|
+
...item
|
|
622
|
+
})
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Infers one missing owner-body render order from contained indexed
|
|
627
|
+
* geometry. Missing-order bodies should sit just behind the earliest
|
|
628
|
+
* indexed sibling primitive contained inside the same owner body.
|
|
629
|
+
* @param {{ x: number, y: number, width: number, height: number, renderOrder: number }} rectangle
|
|
630
|
+
* @param {{ renderOrder: number, minX: number, maxX: number, minY: number, maxY: number }[]} ownerItems
|
|
631
|
+
* @returns {number | null}
|
|
632
|
+
*/
|
|
633
|
+
static #inferMissingOwnerRectangleRenderOrder(rectangle, ownerItems) {
|
|
634
|
+
const containedItems = ownerItems.filter(
|
|
635
|
+
(item) =>
|
|
636
|
+
item.minX >= rectangle.x &&
|
|
637
|
+
item.maxX <= rectangle.x + rectangle.width &&
|
|
638
|
+
item.minY >= rectangle.y &&
|
|
639
|
+
item.maxY <= rectangle.y + rectangle.height
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
if (!containedItems.length) {
|
|
643
|
+
return null
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const earliestContainedRenderOrder = Math.min(
|
|
647
|
+
...containedItems.map((item) => Number(item.renderOrder))
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
return Number.isFinite(earliestContainedRenderOrder)
|
|
651
|
+
? earliestContainedRenderOrder - 0.5
|
|
652
|
+
: null
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Returns true when one closed rectangle-like record carries a visible
|
|
657
|
+
* area color even without an explicit `IsSolid=T` flag.
|
|
658
|
+
* @param {Record<string, string | string[]>} fields
|
|
659
|
+
* @param {boolean} isRectangleRecord
|
|
660
|
+
* @returns {boolean}
|
|
661
|
+
*/
|
|
662
|
+
static #hasImplicitAreaFill(fields, isRectangleRecord) {
|
|
663
|
+
return (
|
|
664
|
+
isRectangleRecord &&
|
|
665
|
+
!parseBoolean(fields.Transparent) &&
|
|
666
|
+
getField(fields, 'AreaColor') !== ''
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Returns true when one record-225 frame lost its printable style fields
|
|
672
|
+
* and therefore needs the authored dashed white-box defaults restored.
|
|
673
|
+
* @param {Record<string, string | string[]>} fields
|
|
674
|
+
* @param {boolean} isListedRectangle
|
|
675
|
+
* @returns {boolean}
|
|
676
|
+
*/
|
|
677
|
+
static #shouldUseFrameFallback(fields, isListedRectangle) {
|
|
678
|
+
if (getField(fields, 'RECORD') !== '225' || isListedRectangle) {
|
|
679
|
+
return false
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
getField(fields, 'AreaColor') === '' ||
|
|
684
|
+
getField(fields, 'LineStyle') === '' ||
|
|
685
|
+
!/^-?\d+$/.test(getField(fields, 'Color'))
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Collects one record-7 polygon point list in source order.
|
|
691
|
+
* @param {Record<string, string | string[]>} fields
|
|
692
|
+
* @returns {{ x: number, y: number }[]}
|
|
693
|
+
*/
|
|
694
|
+
static #collectPolygonPoints(fields) {
|
|
695
|
+
const locationCount = parseNumericField(fields, 'LocationCount')
|
|
696
|
+
|
|
697
|
+
if (locationCount === null || locationCount < 2) {
|
|
698
|
+
return []
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const points = []
|
|
702
|
+
|
|
703
|
+
for (let index = 1; index <= locationCount; index += 1) {
|
|
704
|
+
const x = parseNumericField(fields, 'X' + index)
|
|
705
|
+
const y = parseNumericField(fields, 'Y' + index)
|
|
706
|
+
|
|
707
|
+
if (x === null || y === null) {
|
|
708
|
+
break
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
points.push({ x, y })
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return points
|
|
715
|
+
}
|
|
716
|
+
}
|