altium-toolkit 0.1.23 → 1.0.1

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.
@@ -0,0 +1,180 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ComponentGrouping } from './ComponentGrouping.mjs'
6
+
7
+ /**
8
+ * Builds compact query netlists from normalized toolkit document models.
9
+ */
10
+ export class QueryNetlistBuilder {
11
+ /**
12
+ * Builds the query netlist for one document model.
13
+ * @param {object} documentModel Document model.
14
+ * @returns {{ nets: object, components: object }}
15
+ */
16
+ static build(documentModel) {
17
+ const components =
18
+ QueryNetlistBuilder.#buildComponentDetails(documentModel)
19
+ const nets = {}
20
+
21
+ for (const net of documentModel?.schematic?.nets || []) {
22
+ const netName = String(net?.name || '').trim()
23
+ if (!netName) continue
24
+
25
+ for (const pin of net.pins || []) {
26
+ const refdes = QueryNetlistBuilder.#resolvePinRefdes(
27
+ pin,
28
+ components
29
+ )
30
+ const pinNumber = QueryNetlistBuilder.#resolvePinNumber(pin)
31
+ if (!refdes || !pinNumber) continue
32
+
33
+ nets[netName] ||= {}
34
+ const existing = nets[netName][refdes]
35
+ nets[netName][refdes] = QueryNetlistBuilder.#appendPin(
36
+ existing,
37
+ pinNumber
38
+ )
39
+ components[refdes] ||= { pins: {} }
40
+ components[refdes].pins[pinNumber] =
41
+ QueryNetlistBuilder.#pinEntry(pinNumber, pin?.name, netName)
42
+ }
43
+ }
44
+
45
+ return { nets, components }
46
+ }
47
+
48
+ /**
49
+ * Builds component metadata from schematic, PCB, and BOM records.
50
+ * @param {object} documentModel Document model.
51
+ * @returns {object}
52
+ */
53
+ static #buildComponentDetails(documentModel) {
54
+ const components = {}
55
+
56
+ for (const component of documentModel?.schematic?.components || []) {
57
+ const refdes = String(component?.designator || '').trim()
58
+ if (!refdes) continue
59
+ components[refdes] = {
60
+ ...components[refdes],
61
+ value: component.value || components[refdes]?.value,
62
+ description:
63
+ component.description || components[refdes]?.description,
64
+ comment: component.comment || components[refdes]?.comment,
65
+ ownerIndex:
66
+ component.ownerIndex || components[refdes]?.ownerIndex,
67
+ dns:
68
+ component.dns ||
69
+ component.excludeFromBom ||
70
+ components[refdes]?.dns,
71
+ excludeFromBom:
72
+ component.excludeFromBom ||
73
+ components[refdes]?.excludeFromBom,
74
+ pins: components[refdes]?.pins || {}
75
+ }
76
+ }
77
+
78
+ for (const component of documentModel?.pcb?.components || []) {
79
+ const refdes = String(component?.designator || '').trim()
80
+ if (!refdes) continue
81
+ components[refdes] = {
82
+ ...components[refdes],
83
+ description:
84
+ component.description ||
85
+ component.pattern ||
86
+ components[refdes]?.description,
87
+ value: component.value || components[refdes]?.value,
88
+ comment: component.comment || components[refdes]?.comment,
89
+ pins: components[refdes]?.pins || {}
90
+ }
91
+ }
92
+
93
+ for (const row of documentModel?.bom || []) {
94
+ for (const refdes of row.designators || []) {
95
+ const normalizedRefdes = String(refdes || '').trim()
96
+ if (!normalizedRefdes) continue
97
+ components[normalizedRefdes] = {
98
+ ...components[normalizedRefdes],
99
+ mpn: row.pattern || components[normalizedRefdes]?.mpn,
100
+ description:
101
+ row.source || components[normalizedRefdes]?.description,
102
+ value: row.value || components[normalizedRefdes]?.value,
103
+ pins: components[normalizedRefdes]?.pins || {}
104
+ }
105
+ }
106
+ }
107
+
108
+ return components
109
+ }
110
+
111
+ /**
112
+ * Resolves a pin's owning reference designator.
113
+ * @param {object} pin Net pin.
114
+ * @param {object} components Component details.
115
+ * @returns {string}
116
+ */
117
+ static #resolvePinRefdes(pin, components) {
118
+ const direct = String(
119
+ pin?.refdes ||
120
+ pin?.componentRefdes ||
121
+ pin?.componentDesignator ||
122
+ pin?.ownerDesignator ||
123
+ ''
124
+ ).trim()
125
+ if (direct) return direct
126
+
127
+ const ownerIndex = String(pin?.ownerIndex || '').trim()
128
+ if (!ownerIndex) return ''
129
+
130
+ return (
131
+ Object.entries(components).find(([, component]) => {
132
+ return String(component.ownerIndex || '') === ownerIndex
133
+ })?.[0] || ''
134
+ )
135
+ }
136
+
137
+ /**
138
+ * Resolves a pin number.
139
+ * @param {object} pin Net pin.
140
+ * @returns {string}
141
+ */
142
+ static #resolvePinNumber(pin) {
143
+ return String(
144
+ pin?.pinNumber || pin?.number || pin?.designator || ''
145
+ ).trim()
146
+ }
147
+
148
+ /**
149
+ * Appends a pin to a compact net connection value.
150
+ * @param {string | string[] | undefined} existing Existing pins.
151
+ * @param {string} pinNumber Pin number.
152
+ * @returns {string | string[]}
153
+ */
154
+ static #appendPin(existing, pinNumber) {
155
+ if (!existing) return pinNumber
156
+ const pins = Array.isArray(existing) ? existing : [existing]
157
+ if (!pins.includes(pinNumber)) {
158
+ pins.push(pinNumber)
159
+ }
160
+ return ComponentGrouping.compactArray(
161
+ pins.sort(ComponentGrouping.naturalSort)
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Builds a compact pin entry.
167
+ * @param {string} pinNumber Pin number.
168
+ * @param {string | undefined} pinName Pin name.
169
+ * @param {string} netName Net name.
170
+ * @returns {string | { name: string, net: string }}
171
+ */
172
+ static #pinEntry(pinNumber, pinName, netName) {
173
+ const normalizedName = String(pinName || '').trim()
174
+ if (normalizedName && normalizedName !== pinNumber) {
175
+ return { name: normalizedName, net: netName }
176
+ }
177
+
178
+ return netName
179
+ }
180
+ }
@@ -0,0 +1,89 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Regex helpers for netlist query tools.
7
+ */
8
+ export class RegexPattern {
9
+ /**
10
+ * Parses a user regex pattern into a JavaScript RegExp.
11
+ * @param {string} pattern User-provided pattern.
12
+ * @param {string} [flags] RegExp flags.
13
+ * @returns {{ regex: RegExp } | { error: string }}
14
+ */
15
+ static parse(pattern, flags = 'i') {
16
+ const normalizedPattern = RegexPattern.#normalizeInlineFlags(pattern)
17
+ const normalizedFlags = RegexPattern.#normalizeFlags(
18
+ flags,
19
+ normalizedPattern.forceIgnoreCase
20
+ )
21
+
22
+ try {
23
+ return {
24
+ regex: new RegExp(normalizedPattern.pattern, normalizedFlags)
25
+ }
26
+ } catch (error) {
27
+ return {
28
+ error:
29
+ 'Invalid regex pattern ' +
30
+ JSON.stringify(String(pattern || '')) +
31
+ ': ' +
32
+ (error instanceof Error ? error.message : String(error))
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Returns true when a pattern matches every candidate value.
39
+ * @param {string} pattern User-provided pattern.
40
+ * @param {string[]} candidates Candidate values.
41
+ * @returns {boolean}
42
+ */
43
+ static rejectsBroadMatch(pattern, candidates) {
44
+ const parsed = RegexPattern.parse(pattern)
45
+ if (parsed.error || !Array.isArray(candidates) || !candidates.length) {
46
+ return false
47
+ }
48
+
49
+ return candidates.every((candidate) => {
50
+ parsed.regex.lastIndex = 0
51
+ return parsed.regex.test(String(candidate || ''))
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Normalizes a leading `(?i)` flag into JavaScript RegExp flags.
57
+ * @param {string} pattern User-provided pattern.
58
+ * @returns {{ pattern: string, forceIgnoreCase: boolean }}
59
+ */
60
+ static #normalizeInlineFlags(pattern) {
61
+ const source = String(pattern || '')
62
+ if (source.startsWith('(?i)')) {
63
+ return {
64
+ pattern: source.slice(4),
65
+ forceIgnoreCase: true
66
+ }
67
+ }
68
+
69
+ return {
70
+ pattern: source,
71
+ forceIgnoreCase: false
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Adds the ignore-case flag when requested.
77
+ * @param {string} flags RegExp flags.
78
+ * @param {boolean} forceIgnoreCase Whether to force `i`.
79
+ * @returns {string}
80
+ */
81
+ static #normalizeFlags(flags, forceIgnoreCase) {
82
+ const uniqueFlags = new Set(String(flags || '').split(''))
83
+ if (forceIgnoreCase) {
84
+ uniqueFlags.add('i')
85
+ }
86
+
87
+ return [...uniqueFlags].join('')
88
+ }
89
+ }
@@ -0,0 +1,12 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ export { RegexPattern } from './core/netlist-query/RegexPattern.mjs'
6
+ export {
7
+ ComponentGrouping,
8
+ MPN_MISSING_NOTE
9
+ } from './core/netlist-query/ComponentGrouping.mjs'
10
+ export { CircuitTraversal } from './core/netlist-query/CircuitTraversal.mjs'
11
+ export { QueryNetlistBuilder } from './core/netlist-query/QueryNetlistBuilder.mjs'
12
+ export { LoadedDesignNetlistService } from './core/netlist-query/LoadedDesignNetlistService.mjs'
package/src/parser.mjs CHANGED
@@ -11,6 +11,8 @@ export { AltiumLayoutParser } from './core/altium/AltiumLayoutParser.mjs'
11
11
  export { AsciiRecordParser } from './core/altium/AsciiRecordParser.mjs'
12
12
  export { ParserUtils } from './core/altium/ParserUtils.mjs'
13
13
  export { NormalizedModelSchema } from './core/altium/NormalizedModelSchema.mjs'
14
+ export { CircuitJsonModelSchema } from './core/circuit-json/CircuitJsonModelSchema.mjs'
15
+ export { CircuitJsonModelAdapter } from './core/circuit-json/CircuitJsonModelAdapter.mjs'
14
16
  export { PcbBinaryPrimitiveParser } from './core/altium/PcbBinaryPrimitiveParser.mjs'
15
17
  export { PcbBoardRegionSemanticsParser } from './core/altium/PcbBoardRegionSemanticsParser.mjs'
16
18
  export { PcbComponentPrimitiveIndexer } from './core/altium/PcbComponentPrimitiveIndexer.mjs'
@@ -0,0 +1,402 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Refines Altium 3D board outlines when parser recovery emitted a rasterized
7
+ * stair-step contour even though the document still carries a smoother board
8
+ * region contour.
9
+ */
10
+ export class PcbScene3dBoardOutlineRefiner {
11
+ static #MIN_RASTERIZED_SEGMENTS = 16
12
+ static #MIN_SHORT_SEGMENT_RATIO = 0.35
13
+ static #MIN_AXIS_ALIGNED_RATIO = 0.9
14
+ static #SHORT_SEGMENT_MAX_MIL = 24
15
+ static #POINT_EPSILON_MIL = 0.25
16
+ static #AREA_RATIO_MIN = 0.75
17
+ static #AREA_RATIO_MAX = 1.25
18
+
19
+ /**
20
+ * Returns a scene description with a refined board outline when a better
21
+ * board-region contour is available.
22
+ * @param {object} sceneDescription Built scene description.
23
+ * @param {object} documentModel Source PCB document model.
24
+ * @returns {object}
25
+ */
26
+ static refine(sceneDescription, documentModel) {
27
+ const board = sceneDescription?.board
28
+ const segments = Array.isArray(board?.segments) ? board.segments : []
29
+
30
+ if (
31
+ !PcbScene3dBoardOutlineRefiner.#isRasterizedManhattanOutline(
32
+ segments
33
+ )
34
+ ) {
35
+ return sceneDescription
36
+ }
37
+
38
+ const currentOutline =
39
+ documentModel?.pcb?.boardOutline || sceneDescription?.board || {}
40
+ const candidate = PcbScene3dBoardOutlineRefiner.#selectCandidate(
41
+ documentModel?.pcb?.boardRegions,
42
+ currentOutline
43
+ )
44
+
45
+ if (!candidate) {
46
+ return sceneDescription
47
+ }
48
+
49
+ return {
50
+ ...sceneDescription,
51
+ board: {
52
+ ...board,
53
+ minX: candidate.minX,
54
+ minY: candidate.minY,
55
+ widthMil: candidate.widthMil,
56
+ heightMil: candidate.heightMil,
57
+ centerX: candidate.minX + candidate.widthMil / 2,
58
+ centerY: candidate.minY + candidate.heightMil / 2,
59
+ segments: candidate.segments
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Returns true when an outline looks like a raster-grid recovery of a
66
+ * curved board route instead of authored geometric segments.
67
+ * @param {Array<Record<string, number | string>>} segments Outline segments.
68
+ * @returns {boolean}
69
+ */
70
+ static #isRasterizedManhattanOutline(segments) {
71
+ if (
72
+ segments.length <
73
+ PcbScene3dBoardOutlineRefiner.#MIN_RASTERIZED_SEGMENTS
74
+ ) {
75
+ return false
76
+ }
77
+
78
+ if (segments.some((segment) => segment.type === 'arc')) {
79
+ return false
80
+ }
81
+
82
+ let axisAlignedCount = 0
83
+ let shortSegmentCount = 0
84
+
85
+ for (const segment of segments) {
86
+ const dx = Math.abs(
87
+ Number(segment.x2 || 0) - Number(segment.x1 || 0)
88
+ )
89
+ const dy = Math.abs(
90
+ Number(segment.y2 || 0) - Number(segment.y1 || 0)
91
+ )
92
+ const length = Math.hypot(dx, dy)
93
+
94
+ if (dx <= 0.001 || dy <= 0.001) {
95
+ axisAlignedCount += 1
96
+ }
97
+ if (
98
+ length <= PcbScene3dBoardOutlineRefiner.#SHORT_SEGMENT_MAX_MIL
99
+ ) {
100
+ shortSegmentCount += 1
101
+ }
102
+ }
103
+
104
+ return (
105
+ axisAlignedCount / segments.length >=
106
+ PcbScene3dBoardOutlineRefiner.#MIN_AXIS_ALIGNED_RATIO &&
107
+ shortSegmentCount / segments.length >=
108
+ PcbScene3dBoardOutlineRefiner.#MIN_SHORT_SEGMENT_RATIO
109
+ )
110
+ }
111
+
112
+ /**
113
+ * Chooses the best compatible board-region contour.
114
+ * @param {object[] | undefined} regions Candidate board regions.
115
+ * @param {object} currentOutline Current outline bounds and segments.
116
+ * @returns {object | null}
117
+ */
118
+ static #selectCandidate(regions, currentOutline) {
119
+ const currentBounds =
120
+ PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(currentOutline)
121
+ if (!currentBounds) {
122
+ return null
123
+ }
124
+
125
+ const currentArea = Math.abs(
126
+ PcbScene3dBoardOutlineRefiner.#computeAreaFromSegments(
127
+ currentOutline?.segments || []
128
+ )
129
+ )
130
+ const candidates = (Array.isArray(regions) ? regions : [])
131
+ .filter((region) =>
132
+ PcbScene3dBoardOutlineRefiner.#isBoardRegionCandidate(region)
133
+ )
134
+ .map((region) =>
135
+ PcbScene3dBoardOutlineRefiner.#buildOutlineFromPoints(
136
+ region.points
137
+ )
138
+ )
139
+ .filter(Boolean)
140
+ .filter((outline) =>
141
+ PcbScene3dBoardOutlineRefiner.#boundsAreCompatible(
142
+ currentBounds,
143
+ outline
144
+ )
145
+ )
146
+ .filter((outline) =>
147
+ PcbScene3dBoardOutlineRefiner.#areaIsCompatible(
148
+ currentArea,
149
+ outline
150
+ )
151
+ )
152
+ .map((outline) => ({
153
+ outline,
154
+ score: PcbScene3dBoardOutlineRefiner.#scoreBounds(
155
+ currentBounds,
156
+ outline
157
+ )
158
+ }))
159
+ .sort((left, right) => left.score - right.score)
160
+
161
+ return candidates[0]?.outline || null
162
+ }
163
+
164
+ /**
165
+ * Returns true when a region can represent the board body boundary.
166
+ * @param {object} region Source board region.
167
+ * @returns {boolean}
168
+ */
169
+ static #isBoardRegionCandidate(region) {
170
+ return (
171
+ Array.isArray(region?.points) &&
172
+ region.points.length >= 3 &&
173
+ (region?.objectKind === 'BoardRegion' ||
174
+ region?.isBoardCutout === true ||
175
+ Number.isInteger(region?.boardRegionIndex))
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Converts one point loop into outline bounds and line segments.
181
+ * @param {{ x?: number, y?: number }[] | undefined} points Source points.
182
+ * @returns {object | null}
183
+ */
184
+ static #buildOutlineFromPoints(points) {
185
+ const normalizedPoints =
186
+ PcbScene3dBoardOutlineRefiner.#normalizePointLoop(points)
187
+
188
+ if (normalizedPoints.length < 3) {
189
+ return null
190
+ }
191
+
192
+ let minX = Number.POSITIVE_INFINITY
193
+ let minY = Number.POSITIVE_INFINITY
194
+ let maxX = Number.NEGATIVE_INFINITY
195
+ let maxY = Number.NEGATIVE_INFINITY
196
+
197
+ for (const point of normalizedPoints) {
198
+ minX = Math.min(minX, point.x)
199
+ minY = Math.min(minY, point.y)
200
+ maxX = Math.max(maxX, point.x)
201
+ maxY = Math.max(maxY, point.y)
202
+ }
203
+
204
+ const segments = normalizedPoints.map((point, index) => {
205
+ const next = normalizedPoints[(index + 1) % normalizedPoints.length]
206
+
207
+ return {
208
+ type: 'line',
209
+ x1: point.x,
210
+ y1: point.y,
211
+ x2: next.x,
212
+ y2: next.y
213
+ }
214
+ })
215
+
216
+ return {
217
+ minX,
218
+ minY,
219
+ widthMil: maxX - minX,
220
+ heightMil: maxY - minY,
221
+ segments
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Normalizes finite points and drops duplicated adjacent points.
227
+ * @param {{ x?: number, y?: number }[] | undefined} points Source points.
228
+ * @returns {{ x: number, y: number }[]}
229
+ */
230
+ static #normalizePointLoop(points) {
231
+ const output = []
232
+
233
+ for (const point of Array.isArray(points) ? points : []) {
234
+ const nextPoint = {
235
+ x: Number(point?.x),
236
+ y: Number(point?.y)
237
+ }
238
+
239
+ if (
240
+ !Number.isFinite(nextPoint.x) ||
241
+ !Number.isFinite(nextPoint.y)
242
+ ) {
243
+ continue
244
+ }
245
+
246
+ if (
247
+ output.length &&
248
+ PcbScene3dBoardOutlineRefiner.#distanceBetween(
249
+ output[output.length - 1],
250
+ nextPoint
251
+ ) <= PcbScene3dBoardOutlineRefiner.#POINT_EPSILON_MIL
252
+ ) {
253
+ continue
254
+ }
255
+
256
+ output.push(nextPoint)
257
+ }
258
+
259
+ if (
260
+ output.length > 1 &&
261
+ PcbScene3dBoardOutlineRefiner.#distanceBetween(
262
+ output[0],
263
+ output[output.length - 1]
264
+ ) <= PcbScene3dBoardOutlineRefiner.#POINT_EPSILON_MIL
265
+ ) {
266
+ output.pop()
267
+ }
268
+
269
+ return output
270
+ }
271
+
272
+ /**
273
+ * Resolves bounds from an outline object.
274
+ * @param {object} outline Outline-like object.
275
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number } | null}
276
+ */
277
+ static #resolveOutlineBounds(outline) {
278
+ const minX = Number(outline?.minX)
279
+ const minY = Number(outline?.minY)
280
+ const widthMil = Number(outline?.widthMil)
281
+ const heightMil = Number(outline?.heightMil)
282
+
283
+ if (
284
+ !Number.isFinite(minX) ||
285
+ !Number.isFinite(minY) ||
286
+ !Number.isFinite(widthMil) ||
287
+ !Number.isFinite(heightMil) ||
288
+ widthMil <= 0 ||
289
+ heightMil <= 0
290
+ ) {
291
+ return null
292
+ }
293
+
294
+ return {
295
+ minX,
296
+ minY,
297
+ maxX: minX + widthMil,
298
+ maxY: minY + heightMil,
299
+ widthMil,
300
+ heightMil
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Returns true when a candidate's envelope matches the current board.
306
+ * @param {object} current Current outline bounds.
307
+ * @param {object} candidate Candidate outline bounds.
308
+ * @returns {boolean}
309
+ */
310
+ static #boundsAreCompatible(current, candidate) {
311
+ const tolerance = Math.max(
312
+ Math.max(current.widthMil, current.heightMil) * 0.06,
313
+ 40
314
+ )
315
+ const candidateBounds =
316
+ PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(candidate)
317
+
318
+ if (!candidateBounds) {
319
+ return false
320
+ }
321
+
322
+ return (
323
+ Math.abs(current.minX - candidateBounds.minX) <= tolerance &&
324
+ Math.abs(current.minY - candidateBounds.minY) <= tolerance &&
325
+ Math.abs(current.maxX - candidateBounds.maxX) <= tolerance &&
326
+ Math.abs(current.maxY - candidateBounds.maxY) <= tolerance
327
+ )
328
+ }
329
+
330
+ /**
331
+ * Returns true when the candidate area is close enough to the fallback.
332
+ * @param {number} currentArea Current outline area.
333
+ * @param {object} candidate Candidate outline.
334
+ * @returns {boolean}
335
+ */
336
+ static #areaIsCompatible(currentArea, candidate) {
337
+ if (!currentArea) {
338
+ return true
339
+ }
340
+
341
+ const candidateArea = Math.abs(
342
+ PcbScene3dBoardOutlineRefiner.#computeAreaFromSegments(
343
+ candidate?.segments || []
344
+ )
345
+ )
346
+ const ratio = candidateArea / currentArea
347
+
348
+ return (
349
+ ratio >= PcbScene3dBoardOutlineRefiner.#AREA_RATIO_MIN &&
350
+ ratio <= PcbScene3dBoardOutlineRefiner.#AREA_RATIO_MAX
351
+ )
352
+ }
353
+
354
+ /**
355
+ * Scores how closely candidate bounds match current bounds.
356
+ * @param {object} current Current outline bounds.
357
+ * @param {object} candidate Candidate outline bounds.
358
+ * @returns {number}
359
+ */
360
+ static #scoreBounds(current, candidate) {
361
+ const candidateBounds =
362
+ PcbScene3dBoardOutlineRefiner.#resolveOutlineBounds(candidate)
363
+
364
+ if (!candidateBounds) {
365
+ return Number.POSITIVE_INFINITY
366
+ }
367
+
368
+ return (
369
+ Math.abs(current.minX - candidateBounds.minX) +
370
+ Math.abs(current.minY - candidateBounds.minY) +
371
+ Math.abs(current.maxX - candidateBounds.maxX) +
372
+ Math.abs(current.maxY - candidateBounds.maxY)
373
+ )
374
+ }
375
+
376
+ /**
377
+ * Computes signed area from line segments.
378
+ * @param {Array<Record<string, number | string>>} segments Outline segments.
379
+ * @returns {number}
380
+ */
381
+ static #computeAreaFromSegments(segments) {
382
+ let area = 0
383
+
384
+ for (const segment of segments) {
385
+ area +=
386
+ Number(segment.x1 || 0) * Number(segment.y2 || 0) -
387
+ Number(segment.x2 || 0) * Number(segment.y1 || 0)
388
+ }
389
+
390
+ return area / 2
391
+ }
392
+
393
+ /**
394
+ * Measures the distance between two points.
395
+ * @param {{ x: number, y: number }} left First point.
396
+ * @param {{ x: number, y: number }} right Second point.
397
+ * @returns {number}
398
+ */
399
+ static #distanceBetween(left, right) {
400
+ return Math.hypot(right.x - left.x, right.y - left.y)
401
+ }
402
+ }