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,957 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PcbOutlineRasterizer } from './PcbOutlineRasterizer.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recovers a board-facing PCB outline from mechanical boundary tracks and
|
|
9
|
+
* normalizes PCB coordinates into SVG top-view space.
|
|
10
|
+
*/
|
|
11
|
+
export class PcbOutlineRecovery {
|
|
12
|
+
static #BOARD_ROUTE_CLOSURE_MIL = 40
|
|
13
|
+
|
|
14
|
+
static #MAX_BOARD_ROUTE_AREA_INCREASE_RATIO = 1.12
|
|
15
|
+
|
|
16
|
+
static #MIN_BOARD_ROUTE_COMPLEXITY_SEGMENTS = 8
|
|
17
|
+
|
|
18
|
+
static #MIN_BOARD_ROUTE_SIGNIFICANT_GAIN_RATIO = 1.002
|
|
19
|
+
|
|
20
|
+
static #MIN_COMPONENT_MARGIN_MIL = 120
|
|
21
|
+
|
|
22
|
+
static #MAX_DIRECT_RENDER_BOARD_ROUTE_SEGMENTS = 12
|
|
23
|
+
|
|
24
|
+
static #MAX_DIRECT_RENDER_ARC_SWEEP_DEGREES = 120
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Selects a recoverable board outline from mechanical track layers.
|
|
28
|
+
* @param {{ fallbackOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, components: { x: number, y: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[] }} options
|
|
29
|
+
* @returns {{ source: 'board-route' | 'fallback' | 'mechanical-track-layer', layerId: number | null, outline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> } }}
|
|
30
|
+
*/
|
|
31
|
+
static recoverOutline(options) {
|
|
32
|
+
const fallbackOutline = options?.fallbackOutline || {
|
|
33
|
+
minX: 0,
|
|
34
|
+
minY: 0,
|
|
35
|
+
widthMil: 0,
|
|
36
|
+
heightMil: 0,
|
|
37
|
+
segments: []
|
|
38
|
+
}
|
|
39
|
+
const componentBounds = PcbOutlineRecovery.#buildComponentBounds(
|
|
40
|
+
options?.components || []
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (!componentBounds) {
|
|
44
|
+
return {
|
|
45
|
+
source: 'fallback',
|
|
46
|
+
layerId: null,
|
|
47
|
+
outline: fallbackOutline
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const boardRouteOutline = PcbOutlineRecovery.#recoverBoardRouteOutline(
|
|
52
|
+
fallbackOutline,
|
|
53
|
+
options?.components || [],
|
|
54
|
+
componentBounds
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (boardRouteOutline) {
|
|
58
|
+
return {
|
|
59
|
+
source: 'board-route',
|
|
60
|
+
layerId: null,
|
|
61
|
+
outline: boardRouteOutline
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const boundaryLayer = PcbOutlineRecovery.#selectBoundaryLayer(
|
|
66
|
+
options?.tracks || [],
|
|
67
|
+
componentBounds
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if (!boundaryLayer) {
|
|
71
|
+
return {
|
|
72
|
+
source: 'fallback',
|
|
73
|
+
layerId: null,
|
|
74
|
+
outline: fallbackOutline
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const recoveredOutline =
|
|
79
|
+
PcbOutlineRecovery.#traceTrackOutline(
|
|
80
|
+
boundaryLayer.tracks,
|
|
81
|
+
options?.components || [],
|
|
82
|
+
componentBounds
|
|
83
|
+
) || PcbOutlineRecovery.#buildRectOutline(boundaryLayer.bounds)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
source: 'mechanical-track-layer',
|
|
87
|
+
layerId: boundaryLayer.layerId,
|
|
88
|
+
outline: recoveredOutline
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mirrors one normalized PCB model vertically so the SVG matches the
|
|
94
|
+
* authored top-view orientation.
|
|
95
|
+
* @param {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons?: { layer?: string, segments: Array<Record<string, number | string>> }[], fills?: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks?: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias?: { x: number, y: number, diameter: number, holeDiameter: number }[], pads?: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], components?: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }} pcb
|
|
96
|
+
* @returns {{ boardOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, polygons: { layer?: string, segments: Array<Record<string, number | string>> }[], fills: { x1: number, y1: number, x2: number, y2: number, layerCode?: number, layerId?: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode?: number, layerId?: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode?: number, layerId?: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, rotation?: number, holeRotation?: number | null }[], components: { designator: string, x: number, y: number, rotation: number, layer: string, pattern: string }[] }}
|
|
97
|
+
*/
|
|
98
|
+
static flipGeometryVertically(pcb) {
|
|
99
|
+
const outline = pcb?.boardOutline
|
|
100
|
+
const maxY =
|
|
101
|
+
Number(outline?.minY || 0) + Number(outline?.heightMil || 0)
|
|
102
|
+
const mirrorY = (value) =>
|
|
103
|
+
Number(outline?.minY || 0) + maxY - Number(value || 0)
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
...pcb,
|
|
107
|
+
boardOutline: {
|
|
108
|
+
...outline,
|
|
109
|
+
segments: (outline?.segments || []).map((segment) =>
|
|
110
|
+
PcbOutlineRecovery.#flipSegment(segment, mirrorY)
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
polygons: (pcb?.polygons || []).map((polygon) => ({
|
|
114
|
+
...polygon,
|
|
115
|
+
segments: (polygon.segments || []).map((segment) =>
|
|
116
|
+
PcbOutlineRecovery.#flipSegment(segment, mirrorY)
|
|
117
|
+
)
|
|
118
|
+
})),
|
|
119
|
+
fills: (pcb?.fills || []).map((fill) => {
|
|
120
|
+
const y1 = mirrorY(fill.y1)
|
|
121
|
+
const y2 = mirrorY(fill.y2)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
...fill,
|
|
125
|
+
y1: Math.min(y1, y2),
|
|
126
|
+
y2: Math.max(y1, y2)
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
tracks: (pcb?.tracks || []).map((track) => ({
|
|
130
|
+
...track,
|
|
131
|
+
y1: mirrorY(track.y1),
|
|
132
|
+
y2: mirrorY(track.y2)
|
|
133
|
+
})),
|
|
134
|
+
arcs: (pcb?.arcs || []).map((arc) => ({
|
|
135
|
+
...arc,
|
|
136
|
+
y: mirrorY(arc.y),
|
|
137
|
+
startAngle: PcbOutlineRecovery.#normalizeAngle(
|
|
138
|
+
360 - Number(arc.startAngle || 0)
|
|
139
|
+
),
|
|
140
|
+
endAngle: PcbOutlineRecovery.#normalizeAngle(
|
|
141
|
+
360 - Number(arc.endAngle || 0)
|
|
142
|
+
)
|
|
143
|
+
})),
|
|
144
|
+
vias: (pcb?.vias || []).map((via) => ({
|
|
145
|
+
...via,
|
|
146
|
+
y: mirrorY(via.y)
|
|
147
|
+
})),
|
|
148
|
+
pads: (pcb?.pads || []).map((pad) => ({
|
|
149
|
+
...pad,
|
|
150
|
+
y: mirrorY(pad.y),
|
|
151
|
+
rotation: PcbOutlineRecovery.#normalizeAngle(
|
|
152
|
+
360 - Number(pad.rotation || 0)
|
|
153
|
+
),
|
|
154
|
+
holeRotation:
|
|
155
|
+
pad?.holeRotation === null ||
|
|
156
|
+
pad?.holeRotation === undefined
|
|
157
|
+
? (pad?.holeRotation ?? null)
|
|
158
|
+
: PcbOutlineRecovery.#normalizeAngle(
|
|
159
|
+
360 - Number(pad.holeRotation || 0)
|
|
160
|
+
)
|
|
161
|
+
})),
|
|
162
|
+
components: (pcb?.components || []).map((component) => ({
|
|
163
|
+
...component,
|
|
164
|
+
y: mirrorY(component.y),
|
|
165
|
+
rotation: PcbOutlineRecovery.#normalizeAngle(
|
|
166
|
+
360 - Number(component.rotation || 0)
|
|
167
|
+
)
|
|
168
|
+
}))
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Chooses the smallest mechanical track layer that still encloses all
|
|
174
|
+
* placements with a practical board-edge margin.
|
|
175
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[]} tracks
|
|
176
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} componentBounds
|
|
177
|
+
* @returns {{ layerId: number, bounds: { minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number }, tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[] } | null}
|
|
178
|
+
*/
|
|
179
|
+
static #selectBoundaryLayer(tracks, componentBounds) {
|
|
180
|
+
const groupedTracks = new Map()
|
|
181
|
+
|
|
182
|
+
for (const track of tracks) {
|
|
183
|
+
const layerId = Number(track.layerId)
|
|
184
|
+
|
|
185
|
+
if (!Number.isInteger(layerId) || layerId < 57) {
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!groupedTracks.has(layerId)) {
|
|
190
|
+
groupedTracks.set(layerId, [])
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
groupedTracks.get(layerId).push(track)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const candidates = []
|
|
197
|
+
|
|
198
|
+
for (const [layerId, layerTracks] of groupedTracks.entries()) {
|
|
199
|
+
if (layerTracks.length < 4) {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const bounds = PcbOutlineRecovery.#buildTrackBounds(layerTracks)
|
|
204
|
+
if (!bounds) {
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const margins = {
|
|
209
|
+
left: componentBounds.minX - bounds.minX,
|
|
210
|
+
right: bounds.maxX - componentBounds.maxX,
|
|
211
|
+
top: componentBounds.minY - bounds.minY,
|
|
212
|
+
bottom: bounds.maxY - componentBounds.maxY
|
|
213
|
+
}
|
|
214
|
+
const minMargin = Math.min(
|
|
215
|
+
margins.left,
|
|
216
|
+
margins.right,
|
|
217
|
+
margins.top,
|
|
218
|
+
margins.bottom
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (minMargin < PcbOutlineRecovery.#MIN_COMPONENT_MARGIN_MIL) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
candidates.push({
|
|
226
|
+
layerId,
|
|
227
|
+
bounds,
|
|
228
|
+
tracks: layerTracks,
|
|
229
|
+
area: bounds.widthMil * bounds.heightMil
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
candidates.sort((left, right) => left.area - right.area)
|
|
234
|
+
|
|
235
|
+
return candidates[0] || null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Builds one track-bounds envelope.
|
|
240
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number }[]} tracks
|
|
241
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number } | null}
|
|
242
|
+
*/
|
|
243
|
+
static #buildTrackBounds(tracks) {
|
|
244
|
+
if (!tracks.length) {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let minX = Number.POSITIVE_INFINITY
|
|
249
|
+
let minY = Number.POSITIVE_INFINITY
|
|
250
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
251
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
252
|
+
|
|
253
|
+
for (const track of tracks) {
|
|
254
|
+
minX = Math.min(minX, track.x1, track.x2)
|
|
255
|
+
minY = Math.min(minY, track.y1, track.y2)
|
|
256
|
+
maxX = Math.max(maxX, track.x1, track.x2)
|
|
257
|
+
maxY = Math.max(maxY, track.y1, track.y2)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
minX,
|
|
262
|
+
minY,
|
|
263
|
+
maxX,
|
|
264
|
+
maxY,
|
|
265
|
+
widthMil: maxX - minX,
|
|
266
|
+
heightMil: maxY - minY
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Builds one placement bounds envelope.
|
|
272
|
+
* @param {{ x: number, y: number }[]} components
|
|
273
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number, centerX: number, centerY: number } | null}
|
|
274
|
+
*/
|
|
275
|
+
static #buildComponentBounds(components) {
|
|
276
|
+
if (!components.length) {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let minX = Number.POSITIVE_INFINITY
|
|
281
|
+
let minY = Number.POSITIVE_INFINITY
|
|
282
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
283
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
284
|
+
|
|
285
|
+
for (const component of components) {
|
|
286
|
+
minX = Math.min(minX, component.x)
|
|
287
|
+
minY = Math.min(minY, component.y)
|
|
288
|
+
maxX = Math.max(maxX, component.x)
|
|
289
|
+
maxY = Math.max(maxY, component.y)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
minX,
|
|
294
|
+
minY,
|
|
295
|
+
maxX,
|
|
296
|
+
maxY,
|
|
297
|
+
centerX: (minX + maxX) / 2,
|
|
298
|
+
centerY: (minY + maxY) / 2
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Returns true when one fallback route contour is complex enough to merit
|
|
304
|
+
* board-silhouette recovery instead of using its raw routed path directly.
|
|
305
|
+
* @param {{ segments?: Array<Record<string, number | string>> } | undefined} outline
|
|
306
|
+
* @returns {boolean}
|
|
307
|
+
*/
|
|
308
|
+
static #hasRecoverableBoardRouteComplexity(outline) {
|
|
309
|
+
const segments = outline?.segments || []
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
segments.length >=
|
|
313
|
+
PcbOutlineRecovery.#MIN_BOARD_ROUTE_COMPLEXITY_SEGMENTS ||
|
|
314
|
+
segments.some((segment) => segment.type === 'arc')
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Converts one authored route contour into a board-body silhouette by
|
|
320
|
+
* filling the enclosed region and closing small scallops caused by routed
|
|
321
|
+
* hole bites. When the closure gain is negligible the authored contour is
|
|
322
|
+
* preserved as-is.
|
|
323
|
+
* @param {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }} fallbackOutline
|
|
324
|
+
* @param {{ x: number, y: number }[]} components
|
|
325
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number }} componentBounds
|
|
326
|
+
* @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> } | null}
|
|
327
|
+
*/
|
|
328
|
+
static #recoverBoardRouteOutline(
|
|
329
|
+
fallbackOutline,
|
|
330
|
+
components,
|
|
331
|
+
componentBounds
|
|
332
|
+
) {
|
|
333
|
+
if (
|
|
334
|
+
!componentBounds ||
|
|
335
|
+
!PcbOutlineRecovery.#hasRecoverableBoardRouteComplexity(
|
|
336
|
+
fallbackOutline
|
|
337
|
+
)
|
|
338
|
+
) {
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const bounds = {
|
|
343
|
+
minX: Number(fallbackOutline.minX || 0),
|
|
344
|
+
minY: Number(fallbackOutline.minY || 0),
|
|
345
|
+
maxX:
|
|
346
|
+
Number(fallbackOutline.minX || 0) +
|
|
347
|
+
Number(fallbackOutline.widthMil || 0),
|
|
348
|
+
maxY:
|
|
349
|
+
Number(fallbackOutline.minY || 0) +
|
|
350
|
+
Number(fallbackOutline.heightMil || 0),
|
|
351
|
+
widthMil: Number(fallbackOutline.widthMil || 0),
|
|
352
|
+
heightMil: Number(fallbackOutline.heightMil || 0)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!bounds.widthMil || !bounds.heightMil) {
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
PcbOutlineRecovery.#isDirectlyRenderableBoardRoute(fallbackOutline)
|
|
361
|
+
) {
|
|
362
|
+
return fallbackOutline
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const resolutionMil =
|
|
366
|
+
PcbOutlineRasterizer.resolveSilhouetteResolution(bounds)
|
|
367
|
+
const closingPasses = Math.max(
|
|
368
|
+
Math.ceil(
|
|
369
|
+
PcbOutlineRecovery.#BOARD_ROUTE_CLOSURE_MIL / resolutionMil
|
|
370
|
+
),
|
|
371
|
+
4
|
|
372
|
+
)
|
|
373
|
+
const paddingCells = closingPasses + 8
|
|
374
|
+
const rasterWidth = Math.max(
|
|
375
|
+
Math.ceil(bounds.widthMil / resolutionMil) + paddingCells * 2 + 1,
|
|
376
|
+
32
|
|
377
|
+
)
|
|
378
|
+
const rasterHeight = Math.max(
|
|
379
|
+
Math.ceil(bounds.heightMil / resolutionMil) + paddingCells * 2 + 1,
|
|
380
|
+
32
|
|
381
|
+
)
|
|
382
|
+
const originX = bounds.minX - paddingCells * resolutionMil
|
|
383
|
+
const originY = bounds.minY - paddingCells * resolutionMil
|
|
384
|
+
const boundaryMask = PcbOutlineRasterizer.drawOutlineMask(
|
|
385
|
+
fallbackOutline.segments || [],
|
|
386
|
+
rasterWidth,
|
|
387
|
+
rasterHeight,
|
|
388
|
+
resolutionMil,
|
|
389
|
+
originX,
|
|
390
|
+
originY
|
|
391
|
+
)
|
|
392
|
+
const exteriorMask = PcbOutlineRasterizer.floodExterior(
|
|
393
|
+
boundaryMask,
|
|
394
|
+
rasterWidth,
|
|
395
|
+
rasterHeight
|
|
396
|
+
)
|
|
397
|
+
const solidMask = PcbOutlineRasterizer.buildSolidMask(
|
|
398
|
+
boundaryMask,
|
|
399
|
+
exteriorMask
|
|
400
|
+
)
|
|
401
|
+
const closedMask = PcbOutlineRasterizer.closeSolidMask(
|
|
402
|
+
solidMask,
|
|
403
|
+
rasterWidth,
|
|
404
|
+
rasterHeight,
|
|
405
|
+
closingPasses
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if (
|
|
409
|
+
!PcbOutlineRasterizer.maskContainsAllComponents(
|
|
410
|
+
closedMask,
|
|
411
|
+
rasterWidth,
|
|
412
|
+
rasterHeight,
|
|
413
|
+
components,
|
|
414
|
+
resolutionMil,
|
|
415
|
+
originX,
|
|
416
|
+
originY
|
|
417
|
+
)
|
|
418
|
+
) {
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const contourLoops = PcbOutlineRasterizer.traceInteriorLoops(
|
|
423
|
+
closedMask,
|
|
424
|
+
rasterWidth,
|
|
425
|
+
rasterHeight,
|
|
426
|
+
resolutionMil,
|
|
427
|
+
originX,
|
|
428
|
+
originY
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if (!contourLoops.length) {
|
|
432
|
+
return null
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const points = contourLoops.sort(
|
|
436
|
+
(left, right) =>
|
|
437
|
+
Math.abs(PcbOutlineRecovery.#computeLoopArea(right)) -
|
|
438
|
+
Math.abs(PcbOutlineRecovery.#computeLoopArea(left))
|
|
439
|
+
)[0]
|
|
440
|
+
const simplifiedPoints = PcbOutlineRecovery.#simplifyLoopPoints(points)
|
|
441
|
+
|
|
442
|
+
if (simplifiedPoints.length < 4) {
|
|
443
|
+
return null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const recoveredOutline =
|
|
447
|
+
PcbOutlineRecovery.#buildOutlineFromPoints(simplifiedPoints)
|
|
448
|
+
const rawArea = PcbOutlineRecovery.#computeOutlineArea(fallbackOutline)
|
|
449
|
+
const recoveredArea = Math.abs(
|
|
450
|
+
PcbOutlineRecovery.#computeLoopArea(simplifiedPoints)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if (!rawArea || recoveredArea < rawArea) {
|
|
454
|
+
return null
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const areaIncreaseRatio = recoveredArea / rawArea
|
|
458
|
+
|
|
459
|
+
if (
|
|
460
|
+
areaIncreaseRatio >
|
|
461
|
+
PcbOutlineRecovery.#MAX_BOARD_ROUTE_AREA_INCREASE_RATIO
|
|
462
|
+
) {
|
|
463
|
+
return null
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (
|
|
467
|
+
areaIncreaseRatio <
|
|
468
|
+
PcbOutlineRecovery.#MIN_BOARD_ROUTE_SIGNIFICANT_GAIN_RATIO
|
|
469
|
+
) {
|
|
470
|
+
return fallbackOutline
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return recoveredOutline
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Rasterizes one mechanical boundary layer and traces the filled region
|
|
478
|
+
* that encloses the placement centroid.
|
|
479
|
+
* @param {{ x1: number, y1: number, x2: number, y2: number, width: number }[]} tracks
|
|
480
|
+
* @param {{ x: number, y: number }[]} components
|
|
481
|
+
* @param {{ centerX: number, centerY: number }} componentBounds
|
|
482
|
+
* @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> } | null}
|
|
483
|
+
*/
|
|
484
|
+
static #traceTrackOutline(tracks, components, componentBounds) {
|
|
485
|
+
const bounds = PcbOutlineRecovery.#buildTrackBounds(tracks)
|
|
486
|
+
|
|
487
|
+
if (!bounds) {
|
|
488
|
+
return null
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const resolutionMil =
|
|
492
|
+
PcbOutlineRasterizer.resolveRasterResolution(bounds)
|
|
493
|
+
const paddingCells = 6
|
|
494
|
+
const rasterWidth = Math.max(
|
|
495
|
+
Math.ceil(bounds.widthMil / resolutionMil) + paddingCells * 2 + 1,
|
|
496
|
+
16
|
|
497
|
+
)
|
|
498
|
+
const rasterHeight = Math.max(
|
|
499
|
+
Math.ceil(bounds.heightMil / resolutionMil) + paddingCells * 2 + 1,
|
|
500
|
+
16
|
|
501
|
+
)
|
|
502
|
+
const originX = bounds.minX - paddingCells * resolutionMil
|
|
503
|
+
const originY = bounds.minY - paddingCells * resolutionMil
|
|
504
|
+
|
|
505
|
+
const componentCells = components
|
|
506
|
+
.map((component) =>
|
|
507
|
+
PcbOutlineRasterizer.coordinateToRasterCell(
|
|
508
|
+
component.x,
|
|
509
|
+
component.y,
|
|
510
|
+
resolutionMil,
|
|
511
|
+
originX,
|
|
512
|
+
originY,
|
|
513
|
+
rasterWidth,
|
|
514
|
+
rasterHeight
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
.filter(Boolean)
|
|
518
|
+
|
|
519
|
+
for (let dilationPasses = 0; dilationPasses <= 2; dilationPasses += 1) {
|
|
520
|
+
let boundaryMask = PcbOutlineRasterizer.drawBoundaryMask(
|
|
521
|
+
tracks,
|
|
522
|
+
rasterWidth,
|
|
523
|
+
rasterHeight,
|
|
524
|
+
resolutionMil,
|
|
525
|
+
originX,
|
|
526
|
+
originY
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
for (let pass = 0; pass < dilationPasses; pass += 1) {
|
|
530
|
+
boundaryMask = PcbOutlineRasterizer.dilateMask(
|
|
531
|
+
boundaryMask,
|
|
532
|
+
rasterWidth,
|
|
533
|
+
rasterHeight
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const exteriorMask = PcbOutlineRasterizer.floodExterior(
|
|
538
|
+
boundaryMask,
|
|
539
|
+
rasterWidth,
|
|
540
|
+
rasterHeight
|
|
541
|
+
)
|
|
542
|
+
const interiorMask = PcbOutlineRasterizer.recoverPlacementInterior(
|
|
543
|
+
boundaryMask,
|
|
544
|
+
exteriorMask,
|
|
545
|
+
rasterWidth,
|
|
546
|
+
rasterHeight,
|
|
547
|
+
componentCells,
|
|
548
|
+
componentBounds,
|
|
549
|
+
resolutionMil,
|
|
550
|
+
originX,
|
|
551
|
+
originY
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
if (!interiorMask) {
|
|
555
|
+
continue
|
|
556
|
+
}
|
|
557
|
+
const contourLoops = PcbOutlineRasterizer.traceInteriorLoops(
|
|
558
|
+
interiorMask,
|
|
559
|
+
rasterWidth,
|
|
560
|
+
rasterHeight,
|
|
561
|
+
resolutionMil,
|
|
562
|
+
originX,
|
|
563
|
+
originY
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
if (!contourLoops.length) {
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const points = contourLoops.sort(
|
|
571
|
+
(left, right) =>
|
|
572
|
+
Math.abs(PcbOutlineRecovery.#computeLoopArea(right)) -
|
|
573
|
+
Math.abs(PcbOutlineRecovery.#computeLoopArea(left))
|
|
574
|
+
)[0]
|
|
575
|
+
const simplifiedPoints =
|
|
576
|
+
PcbOutlineRecovery.#simplifyLoopPoints(points)
|
|
577
|
+
|
|
578
|
+
if (simplifiedPoints.length < 4) {
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return PcbOutlineRecovery.#buildOutlineFromPoints(simplifiedPoints)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return null
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Returns true when one authored board-route contour is already simple
|
|
590
|
+
* enough to render directly without silhouette recovery.
|
|
591
|
+
* @param {{ segments?: Array<Record<string, number | string>> } | undefined} outline
|
|
592
|
+
* @returns {boolean}
|
|
593
|
+
*/
|
|
594
|
+
static #isDirectlyRenderableBoardRoute(outline) {
|
|
595
|
+
const segments = outline?.segments || []
|
|
596
|
+
|
|
597
|
+
if (
|
|
598
|
+
!segments.length ||
|
|
599
|
+
segments.length >
|
|
600
|
+
PcbOutlineRecovery.#MAX_DIRECT_RENDER_BOARD_ROUTE_SEGMENTS
|
|
601
|
+
) {
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const arcSegments = segments.filter((segment) => segment.type === 'arc')
|
|
606
|
+
|
|
607
|
+
if (!arcSegments.length) {
|
|
608
|
+
return false
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (
|
|
612
|
+
arcSegments.some(
|
|
613
|
+
(segment) =>
|
|
614
|
+
PcbOutlineRecovery.#computeArcSweep(segment) >
|
|
615
|
+
PcbOutlineRecovery.#MAX_DIRECT_RENDER_ARC_SWEEP_DEGREES
|
|
616
|
+
)
|
|
617
|
+
) {
|
|
618
|
+
return false
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return PcbOutlineRecovery.#isClosedOutlinePath(segments)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Returns true when consecutive outline segments connect closely enough to
|
|
626
|
+
* form one closed authored contour.
|
|
627
|
+
* @param {Array<Record<string, number | string>>} segments
|
|
628
|
+
* @returns {boolean}
|
|
629
|
+
*/
|
|
630
|
+
static #isClosedOutlinePath(segments) {
|
|
631
|
+
if (!segments.length) {
|
|
632
|
+
return false
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
636
|
+
const current = segments[index]
|
|
637
|
+
const next = segments[(index + 1) % segments.length]
|
|
638
|
+
const deltaX = Number(current.x2 || 0) - Number(next.x1 || 0)
|
|
639
|
+
const deltaY = Number(current.y2 || 0) - Number(next.y1 || 0)
|
|
640
|
+
|
|
641
|
+
if (
|
|
642
|
+
Math.hypot(deltaX, deltaY) >
|
|
643
|
+
PcbOutlineRecovery.#BOARD_ROUTE_CLOSURE_MIL
|
|
644
|
+
) {
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return true
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Builds one rectangular fallback outline from bounds.
|
|
654
|
+
* @param {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number }} bounds
|
|
655
|
+
* @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }}
|
|
656
|
+
*/
|
|
657
|
+
static #buildRectOutline(bounds) {
|
|
658
|
+
return {
|
|
659
|
+
minX: bounds.minX,
|
|
660
|
+
minY: bounds.minY,
|
|
661
|
+
widthMil: bounds.widthMil,
|
|
662
|
+
heightMil: bounds.heightMil,
|
|
663
|
+
segments: [
|
|
664
|
+
{
|
|
665
|
+
type: 'line',
|
|
666
|
+
x1: bounds.minX,
|
|
667
|
+
y1: bounds.minY,
|
|
668
|
+
x2: bounds.maxX,
|
|
669
|
+
y2: bounds.minY
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
type: 'line',
|
|
673
|
+
x1: bounds.maxX,
|
|
674
|
+
y1: bounds.minY,
|
|
675
|
+
x2: bounds.maxX,
|
|
676
|
+
y2: bounds.maxY
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
type: 'line',
|
|
680
|
+
x1: bounds.maxX,
|
|
681
|
+
y1: bounds.maxY,
|
|
682
|
+
x2: bounds.minX,
|
|
683
|
+
y2: bounds.maxY
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
type: 'line',
|
|
687
|
+
x1: bounds.minX,
|
|
688
|
+
y1: bounds.maxY,
|
|
689
|
+
x2: bounds.minX,
|
|
690
|
+
y2: bounds.minY
|
|
691
|
+
}
|
|
692
|
+
]
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Builds one segment outline from traced loop points.
|
|
698
|
+
* @param {{ x: number, y: number }[]} points
|
|
699
|
+
* @returns {{ minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }}
|
|
700
|
+
*/
|
|
701
|
+
static #buildOutlineFromPoints(points) {
|
|
702
|
+
const segments = []
|
|
703
|
+
let minX = Number.POSITIVE_INFINITY
|
|
704
|
+
let minY = Number.POSITIVE_INFINITY
|
|
705
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
706
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
707
|
+
|
|
708
|
+
for (const point of points) {
|
|
709
|
+
minX = Math.min(minX, point.x)
|
|
710
|
+
minY = Math.min(minY, point.y)
|
|
711
|
+
maxX = Math.max(maxX, point.x)
|
|
712
|
+
maxY = Math.max(maxY, point.y)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
716
|
+
const current = points[index]
|
|
717
|
+
const next = points[(index + 1) % points.length]
|
|
718
|
+
|
|
719
|
+
segments.push({
|
|
720
|
+
type: 'line',
|
|
721
|
+
x1: current.x,
|
|
722
|
+
y1: current.y,
|
|
723
|
+
x2: next.x,
|
|
724
|
+
y2: next.y
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
minX,
|
|
730
|
+
minY,
|
|
731
|
+
widthMil: maxX - minX,
|
|
732
|
+
heightMil: maxY - minY,
|
|
733
|
+
segments
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Removes duplicate closure points and intermediate collinear corners.
|
|
739
|
+
* @param {{ x: number, y: number }[]} points
|
|
740
|
+
* @returns {{ x: number, y: number }[]}
|
|
741
|
+
*/
|
|
742
|
+
static #simplifyLoopPoints(points) {
|
|
743
|
+
const normalizedPoints = points.slice()
|
|
744
|
+
|
|
745
|
+
if (normalizedPoints.length > 1) {
|
|
746
|
+
const first = normalizedPoints[0]
|
|
747
|
+
const last = normalizedPoints[normalizedPoints.length - 1]
|
|
748
|
+
|
|
749
|
+
if (
|
|
750
|
+
Math.abs(first.x - last.x) < 1e-6 &&
|
|
751
|
+
Math.abs(first.y - last.y) < 1e-6
|
|
752
|
+
) {
|
|
753
|
+
normalizedPoints.pop()
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
let changed = true
|
|
758
|
+
|
|
759
|
+
while (changed && normalizedPoints.length > 3) {
|
|
760
|
+
changed = false
|
|
761
|
+
|
|
762
|
+
for (
|
|
763
|
+
let index = 0;
|
|
764
|
+
index < normalizedPoints.length && normalizedPoints.length > 3;
|
|
765
|
+
index += 1
|
|
766
|
+
) {
|
|
767
|
+
const previous =
|
|
768
|
+
normalizedPoints[
|
|
769
|
+
(index - 1 + normalizedPoints.length) %
|
|
770
|
+
normalizedPoints.length
|
|
771
|
+
]
|
|
772
|
+
const current = normalizedPoints[index]
|
|
773
|
+
const next =
|
|
774
|
+
normalizedPoints[(index + 1) % normalizedPoints.length]
|
|
775
|
+
|
|
776
|
+
if (
|
|
777
|
+
(Math.abs(previous.x - current.x) < 1e-6 &&
|
|
778
|
+
Math.abs(current.x - next.x) < 1e-6) ||
|
|
779
|
+
(Math.abs(previous.y - current.y) < 1e-6 &&
|
|
780
|
+
Math.abs(current.y - next.y) < 1e-6)
|
|
781
|
+
) {
|
|
782
|
+
normalizedPoints.splice(index, 1)
|
|
783
|
+
changed = true
|
|
784
|
+
break
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return normalizedPoints
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Computes the signed polygon area of one traced loop.
|
|
794
|
+
* @param {{ x: number, y: number }[]} points
|
|
795
|
+
* @returns {number}
|
|
796
|
+
*/
|
|
797
|
+
static #computeLoopArea(points) {
|
|
798
|
+
let area = 0
|
|
799
|
+
|
|
800
|
+
for (let index = 0; index < points.length; index += 1) {
|
|
801
|
+
const current = points[index]
|
|
802
|
+
const next = points[(index + 1) % points.length]
|
|
803
|
+
|
|
804
|
+
area += current.x * next.y - next.x * current.y
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return area / 2
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Computes one approximate routed outline area by sampling arc segments
|
|
812
|
+
* densely enough for board-route closure decisions.
|
|
813
|
+
* @param {{ segments?: Array<Record<string, number | string>> } | undefined} outline
|
|
814
|
+
* @returns {number}
|
|
815
|
+
*/
|
|
816
|
+
static #computeOutlineArea(outline) {
|
|
817
|
+
const segments = outline?.segments || []
|
|
818
|
+
|
|
819
|
+
if (!segments.length) {
|
|
820
|
+
return 0
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const points = []
|
|
824
|
+
|
|
825
|
+
for (const segment of segments) {
|
|
826
|
+
const sampledPoints =
|
|
827
|
+
PcbOutlineRecovery.#sampleSegmentPoints(segment)
|
|
828
|
+
|
|
829
|
+
if (!sampledPoints.length) {
|
|
830
|
+
continue
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (!points.length) {
|
|
834
|
+
points.push(sampledPoints[0])
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
points.push(...sampledPoints.slice(1))
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (points.length < 3) {
|
|
841
|
+
return 0
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return Math.abs(PcbOutlineRecovery.#computeLoopArea(points))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Samples one line or arc segment into polygon points for approximate area
|
|
849
|
+
* calculations.
|
|
850
|
+
* @param {Record<string, number | string>} segment
|
|
851
|
+
* @returns {{ x: number, y: number }[]}
|
|
852
|
+
*/
|
|
853
|
+
static #sampleSegmentPoints(segment) {
|
|
854
|
+
if (segment.type !== 'arc') {
|
|
855
|
+
return [
|
|
856
|
+
{
|
|
857
|
+
x: Number(segment.x1 || 0),
|
|
858
|
+
y: Number(segment.y1 || 0)
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
x: Number(segment.x2 || 0),
|
|
862
|
+
y: Number(segment.y2 || 0)
|
|
863
|
+
}
|
|
864
|
+
]
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const startAngle = Number(segment.startAngle || 0)
|
|
868
|
+
const endAngle = Number(segment.endAngle || 0)
|
|
869
|
+
let delta = endAngle - startAngle
|
|
870
|
+
|
|
871
|
+
if (Math.abs(delta) < 1e-6) {
|
|
872
|
+
delta = 360
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (delta < 0) {
|
|
876
|
+
delta += 360
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const steps = Math.max(Math.ceil(Math.abs(delta) / 10), 8)
|
|
880
|
+
const radius = Number(segment.radius) || 0
|
|
881
|
+
const centerX = Number(segment.cx || 0)
|
|
882
|
+
const centerY = Number(segment.cy || 0)
|
|
883
|
+
const points = []
|
|
884
|
+
|
|
885
|
+
for (let step = 0; step <= steps; step += 1) {
|
|
886
|
+
const angle =
|
|
887
|
+
((startAngle + delta * (step / steps)) * Math.PI) / 180
|
|
888
|
+
|
|
889
|
+
points.push({
|
|
890
|
+
x: centerX + radius * Math.cos(angle),
|
|
891
|
+
y: centerY + radius * Math.sin(angle)
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return points
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Computes one normalized positive arc sweep in degrees.
|
|
900
|
+
* @param {Record<string, number | string>} segment
|
|
901
|
+
* @returns {number}
|
|
902
|
+
*/
|
|
903
|
+
static #computeArcSweep(segment) {
|
|
904
|
+
const startAngle = Number(segment.startAngle || 0)
|
|
905
|
+
const endAngle = Number(segment.endAngle || 0)
|
|
906
|
+
let delta = endAngle - startAngle
|
|
907
|
+
|
|
908
|
+
if (Math.abs(delta) < 1e-6) {
|
|
909
|
+
delta = 360
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (delta < 0) {
|
|
913
|
+
delta += 360
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return delta
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Mirrors one outline or polygon segment across the board Y axis.
|
|
921
|
+
* @param {Record<string, number | string>} segment
|
|
922
|
+
* @param {(value: number) => number} mirrorY
|
|
923
|
+
* @returns {Record<string, number | string>}
|
|
924
|
+
*/
|
|
925
|
+
static #flipSegment(segment, mirrorY) {
|
|
926
|
+
if (segment.type !== 'arc') {
|
|
927
|
+
return {
|
|
928
|
+
...segment,
|
|
929
|
+
y1: mirrorY(Number(segment.y1 || 0)),
|
|
930
|
+
y2: mirrorY(Number(segment.y2 || 0))
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const startAngle = Number(segment.startAngle || 0)
|
|
935
|
+
const endAngle = Number(segment.endAngle || 0)
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
...segment,
|
|
939
|
+
y1: mirrorY(Number(segment.y1 || 0)),
|
|
940
|
+
y2: mirrorY(Number(segment.y2 || 0)),
|
|
941
|
+
cy: mirrorY(Number(segment.cy || 0)),
|
|
942
|
+
startAngle: PcbOutlineRecovery.#normalizeAngle(360 - startAngle),
|
|
943
|
+
endAngle: PcbOutlineRecovery.#normalizeAngle(360 - endAngle)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Normalizes one circular angle into the [0, 360) range.
|
|
949
|
+
* @param {number} angle
|
|
950
|
+
* @returns {number}
|
|
951
|
+
*/
|
|
952
|
+
static #normalizeAngle(angle) {
|
|
953
|
+
const normalized = Number(angle || 0) % 360
|
|
954
|
+
|
|
955
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
956
|
+
}
|
|
957
|
+
}
|