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.
- package/README.md +4 -3
- package/docs/api.md +47 -8
- package/docs/model-format.md +30 -6
- package/package.json +2 -1
- package/spec/library-scope.md +2 -1
- package/src/core/altium/AltiumParser.mjs +17 -4
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +826 -0
- package/src/core/circuit-json/CircuitJsonModelAdapterPrimitives.mjs +354 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +83 -0
- package/src/core/netlist-query/CircuitTraversal.mjs +325 -0
- package/src/core/netlist-query/ComponentGrouping.mjs +355 -0
- package/src/core/netlist-query/LoadedDesignNetlistService.mjs +732 -0
- package/src/core/netlist-query/QueryNetlistBuilder.mjs +180 -0
- package/src/core/netlist-query/RegexPattern.mjs +89 -0
- package/src/netlist-query.mjs +12 -0
- package/src/parser.mjs +2 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +402 -0
- package/src/ui/PcbScene3dBuilder.mjs +16 -5
- package/src/ui/PcbScene3dDrillCutoutBuilder.mjs +26 -15
|
@@ -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
|
+
}
|