@tscircuit/copper-pour-solver 0.0.25 → 0.0.26
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/dist/index.js +169 -323
- package/lib/solvers/CopperPourPipelineSolver.ts +22 -35
- package/lib/solvers/copper-pour/circle-to-polygon.ts +15 -0
- package/lib/solvers/copper-pour/generate-brep.ts +52 -45
- package/lib/solvers/copper-pour/get-board-polygon.ts +19 -11
- package/lib/solvers/copper-pour/process-obstacles.ts +87 -65
- package/package.json +1 -4
- package/tests/__snapshots__/2-layers-bottom.snap.svg +1 -1
- package/tests/__snapshots__/2-layers-top.snap.svg +1 -1
- package/tests/__snapshots__/board-edge-margin-2.snap.svg +1 -1
- package/tests/__snapshots__/board-edge-margin.snap.svg +1 -1
- package/tests/__snapshots__/hole-and-cutouts.snap.svg +1 -1
- package/tests/__snapshots__/larger-trace-margin.snap.svg +1 -1
- package/tests/__snapshots__/multiple-pours.snap.svg +1 -1
- package/tests/__snapshots__/pad-margin.snap.svg +1 -1
- package/tests/__snapshots__/polygon-board-2.snap.svg +1 -1
- package/tests/__snapshots__/polygon-board.snap.svg +1 -1
- package/tests/__snapshots__/smaller-trace-margin.snap.svg +1 -1
- package/tests/__snapshots__/via.snap.svg +1 -1
- package/tests/stm32f746g-disco.test.ts +16 -16
- package/lib/solvers/copper-pour/manifold-geometry-adapter.ts +0 -306
- package/tests/__snapshots__/stm32f746g-disco.test.tsbottom.snap.svg +0 -1
- package/tests/__snapshots__/stm32f746g-disco.test.tstop.snap.svg +0 -1
- package/tests/manifold-copper-pour-geometry.test.ts +0 -194
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
import type { Point } from "@tscircuit/math-utils"
|
|
2
|
-
import type {
|
|
3
|
-
CrossSection as CrossSectionType,
|
|
4
|
-
FillRule,
|
|
5
|
-
SimplePolygon,
|
|
6
|
-
} from "manifold-3d"
|
|
7
|
-
|
|
8
|
-
const manifoldModule = await import("manifold-3d")
|
|
9
|
-
const manifoldFactory = manifoldModule.default as unknown as () => Promise<{
|
|
10
|
-
CrossSection: typeof CrossSectionType
|
|
11
|
-
setup: () => void
|
|
12
|
-
}>
|
|
13
|
-
const manifold = await manifoldFactory()
|
|
14
|
-
manifold.setup()
|
|
15
|
-
|
|
16
|
-
const { CrossSection } = manifold
|
|
17
|
-
|
|
18
|
-
export const MANIFOLD_GEOMETRY_SCALE = 1_000_000
|
|
19
|
-
export const DEFAULT_MIN_ISLAND_AREA = 1e-8
|
|
20
|
-
|
|
21
|
-
export type PolygonRing = Point[]
|
|
22
|
-
type ScaledPolygons = SimplePolygon[]
|
|
23
|
-
export type CopperPourIsland = {
|
|
24
|
-
outerRing: PolygonRing
|
|
25
|
-
innerRings: PolygonRing[]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const describePolygons = (polygons: ScaledPolygons) => {
|
|
29
|
-
let pointCount = 0
|
|
30
|
-
const bbox = {
|
|
31
|
-
minX: Number.POSITIVE_INFINITY,
|
|
32
|
-
minY: Number.POSITIVE_INFINITY,
|
|
33
|
-
maxX: Number.NEGATIVE_INFINITY,
|
|
34
|
-
maxY: Number.NEGATIVE_INFINITY,
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
for (const polygon of polygons) {
|
|
38
|
-
pointCount += polygon.length
|
|
39
|
-
for (const [x, y] of polygon) {
|
|
40
|
-
bbox.minX = Math.min(bbox.minX, x)
|
|
41
|
-
bbox.minY = Math.min(bbox.minY, y)
|
|
42
|
-
bbox.maxX = Math.max(bbox.maxX, x)
|
|
43
|
-
bbox.maxY = Math.max(bbox.maxY, y)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return {
|
|
48
|
-
polygonCount: polygons.length,
|
|
49
|
-
pointCount,
|
|
50
|
-
bbox: pointCount > 0 ? bbox : null,
|
|
51
|
-
scale: MANIFOLD_GEOMETRY_SCALE,
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const assertFinitePoint = (point: Point, operation: string) => {
|
|
56
|
-
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
57
|
-
throw new Error(
|
|
58
|
-
`${operation} received non-finite point (${point.x}, ${point.y})`,
|
|
59
|
-
)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const pointsEqual = (a: Point, b: Point) => a.x === b.x && a.y === b.y
|
|
64
|
-
|
|
65
|
-
const signedArea = (ring: PolygonRing) => {
|
|
66
|
-
let area = 0
|
|
67
|
-
for (let i = 0; i < ring.length; i++) {
|
|
68
|
-
const current = ring[i]!
|
|
69
|
-
const next = ring[(i + 1) % ring.length]!
|
|
70
|
-
area += current.x * next.y - next.x * current.y
|
|
71
|
-
}
|
|
72
|
-
return area / 2
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export const normalizeRing = (
|
|
76
|
-
ring: PolygonRing,
|
|
77
|
-
operation = "normalizeRing",
|
|
78
|
-
): PolygonRing => {
|
|
79
|
-
const normalized: PolygonRing = []
|
|
80
|
-
|
|
81
|
-
for (const point of ring) {
|
|
82
|
-
assertFinitePoint(point, operation)
|
|
83
|
-
const roundedPoint = {
|
|
84
|
-
x:
|
|
85
|
-
Math.round(point.x * MANIFOLD_GEOMETRY_SCALE) / MANIFOLD_GEOMETRY_SCALE,
|
|
86
|
-
y:
|
|
87
|
-
Math.round(point.y * MANIFOLD_GEOMETRY_SCALE) / MANIFOLD_GEOMETRY_SCALE,
|
|
88
|
-
}
|
|
89
|
-
const previous = normalized[normalized.length - 1]
|
|
90
|
-
if (!previous || !pointsEqual(previous, roundedPoint)) {
|
|
91
|
-
normalized.push(roundedPoint)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
normalized.length > 1 &&
|
|
97
|
-
pointsEqual(normalized[0]!, normalized[normalized.length - 1]!)
|
|
98
|
-
) {
|
|
99
|
-
normalized.pop()
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const uniquePoints = new Set(normalized.map((p) => `${p.x},${p.y}`))
|
|
103
|
-
if (uniquePoints.size < 3 || Math.abs(signedArea(normalized)) < 1e-18) {
|
|
104
|
-
return []
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return normalized
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Manifold owns all robust 2D clipping/offsetting for copper-pour geometry.
|
|
111
|
-
// This adapter keeps scaling, ring normalization, errors, and output grouping
|
|
112
|
-
// out of the solver so the rest of the repo stays independent of WASM details.
|
|
113
|
-
export const toScaledManifoldPolygons = (
|
|
114
|
-
polygons: PolygonRing[],
|
|
115
|
-
operation = "toScaledManifoldPolygons",
|
|
116
|
-
): ScaledPolygons => {
|
|
117
|
-
const scaledPolygons: ScaledPolygons = []
|
|
118
|
-
|
|
119
|
-
for (const polygon of polygons) {
|
|
120
|
-
const normalized = normalizeRing(polygon, operation)
|
|
121
|
-
if (normalized.length < 3) continue
|
|
122
|
-
const positiveRing =
|
|
123
|
-
signedArea(normalized) < 0 ? [...normalized].reverse() : normalized
|
|
124
|
-
scaledPolygons.push(
|
|
125
|
-
positiveRing.map((p) => [
|
|
126
|
-
Math.round(p.x * MANIFOLD_GEOMETRY_SCALE),
|
|
127
|
-
Math.round(p.y * MANIFOLD_GEOMETRY_SCALE),
|
|
128
|
-
]),
|
|
129
|
-
)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return scaledPolygons
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export const fromScaledManifoldPolygons = (
|
|
136
|
-
polygons: SimplePolygon[],
|
|
137
|
-
): PolygonRing[] =>
|
|
138
|
-
polygons
|
|
139
|
-
.map((polygon) =>
|
|
140
|
-
normalizeRing(
|
|
141
|
-
polygon.map(([x, y]) => ({
|
|
142
|
-
x: x / MANIFOLD_GEOMETRY_SCALE,
|
|
143
|
-
y: y / MANIFOLD_GEOMETRY_SCALE,
|
|
144
|
-
})),
|
|
145
|
-
"fromScaledManifoldPolygons",
|
|
146
|
-
),
|
|
147
|
-
)
|
|
148
|
-
.filter((polygon) => polygon.length >= 3)
|
|
149
|
-
|
|
150
|
-
const runManifoldOperation = <T>(
|
|
151
|
-
operation: string,
|
|
152
|
-
polygons: ScaledPolygons,
|
|
153
|
-
callback: () => T,
|
|
154
|
-
): T => {
|
|
155
|
-
try {
|
|
156
|
-
return callback()
|
|
157
|
-
} catch (error) {
|
|
158
|
-
const details = describePolygons(polygons)
|
|
159
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
160
|
-
throw new Error(
|
|
161
|
-
`${operation} failed: ${message}; details=${JSON.stringify(details)}`,
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export const crossSectionFromPolygon = (
|
|
167
|
-
polygon: PolygonRing,
|
|
168
|
-
fillRule: FillRule = "Positive",
|
|
169
|
-
): CrossSectionType => {
|
|
170
|
-
const scaledPolygons = toScaledManifoldPolygons(
|
|
171
|
-
[polygon],
|
|
172
|
-
"crossSectionFromPolygon",
|
|
173
|
-
)
|
|
174
|
-
if (scaledPolygons.length === 0) {
|
|
175
|
-
return CrossSection.ofPolygons([])
|
|
176
|
-
}
|
|
177
|
-
return runManifoldOperation("crossSectionFromPolygon", scaledPolygons, () =>
|
|
178
|
-
CrossSection.ofPolygons(scaledPolygons, fillRule),
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export const crossSectionFromPolygons = (
|
|
183
|
-
polygons: PolygonRing[],
|
|
184
|
-
fillRule: FillRule = "Positive",
|
|
185
|
-
): CrossSectionType => {
|
|
186
|
-
const scaledPolygons = toScaledManifoldPolygons(
|
|
187
|
-
polygons,
|
|
188
|
-
"crossSectionFromPolygons",
|
|
189
|
-
)
|
|
190
|
-
if (scaledPolygons.length === 0) {
|
|
191
|
-
return CrossSection.ofPolygons([])
|
|
192
|
-
}
|
|
193
|
-
return runManifoldOperation("crossSectionFromPolygons", scaledPolygons, () =>
|
|
194
|
-
CrossSection.ofPolygons(scaledPolygons, fillRule),
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export const composeCrossSections = (
|
|
199
|
-
sections: CrossSectionType[],
|
|
200
|
-
): CrossSectionType => {
|
|
201
|
-
const nonEmptySections = sections.filter((section) => !section.isEmpty())
|
|
202
|
-
if (nonEmptySections.length === 0) {
|
|
203
|
-
return CrossSection.ofPolygons([])
|
|
204
|
-
}
|
|
205
|
-
return runManifoldOperation("composeCrossSections", [], () =>
|
|
206
|
-
CrossSection.compose(nonEmptySections),
|
|
207
|
-
)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export const offsetPolygon = (
|
|
211
|
-
polygon: PolygonRing,
|
|
212
|
-
margin: number,
|
|
213
|
-
joinType: "Square" | "Round" | "Miter" = "Miter",
|
|
214
|
-
): PolygonRing[] => {
|
|
215
|
-
const scaledPolygons = toScaledManifoldPolygons([polygon], "offsetPolygon")
|
|
216
|
-
if (scaledPolygons.length === 0 || margin <= 0) {
|
|
217
|
-
return scaledPolygons.length === 0 ? [] : [normalizeRing(polygon)]
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const scaledMargin = margin * MANIFOLD_GEOMETRY_SCALE
|
|
221
|
-
const section = runManifoldOperation(
|
|
222
|
-
"offsetPolygon.input",
|
|
223
|
-
scaledPolygons,
|
|
224
|
-
() => CrossSection.ofPolygons(scaledPolygons, "Positive"),
|
|
225
|
-
)
|
|
226
|
-
const offset = runManifoldOperation(
|
|
227
|
-
"offsetPolygon.offset",
|
|
228
|
-
scaledPolygons,
|
|
229
|
-
() => section.offset(scaledMargin, joinType, 2, 32),
|
|
230
|
-
)
|
|
231
|
-
return fromScaledManifoldPolygons(offset.toPolygons())
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export const subtractBlockersFromPour = (
|
|
235
|
-
pourPolygon: PolygonRing,
|
|
236
|
-
blockerPolygons: PolygonRing[],
|
|
237
|
-
): CrossSectionType => {
|
|
238
|
-
const pourSection = crossSectionFromPolygon(pourPolygon)
|
|
239
|
-
const blockerSection = crossSectionFromPolygons(blockerPolygons)
|
|
240
|
-
|
|
241
|
-
if (pourSection.isEmpty() || blockerSection.isEmpty()) {
|
|
242
|
-
return pourSection
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const operationPolygons = [
|
|
246
|
-
...toScaledManifoldPolygons([pourPolygon], "subtractBlockersFromPour.pour"),
|
|
247
|
-
...toScaledManifoldPolygons(
|
|
248
|
-
blockerPolygons,
|
|
249
|
-
"subtractBlockersFromPour.blockers",
|
|
250
|
-
),
|
|
251
|
-
]
|
|
252
|
-
|
|
253
|
-
return runManifoldOperation(
|
|
254
|
-
"subtractBlockersFromPour",
|
|
255
|
-
operationPolygons,
|
|
256
|
-
() => pourSection.subtract(blockerSection),
|
|
257
|
-
)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
export const removeTinyIslands = (
|
|
261
|
-
section: CrossSectionType,
|
|
262
|
-
minArea = DEFAULT_MIN_ISLAND_AREA,
|
|
263
|
-
): CrossSectionType => {
|
|
264
|
-
if (section.isEmpty()) return section
|
|
265
|
-
|
|
266
|
-
const minScaledArea =
|
|
267
|
-
minArea * MANIFOLD_GEOMETRY_SCALE * MANIFOLD_GEOMETRY_SCALE
|
|
268
|
-
const islands = section
|
|
269
|
-
.decompose()
|
|
270
|
-
.filter((island) => Math.abs(island.area()) >= minScaledArea)
|
|
271
|
-
|
|
272
|
-
return composeCrossSections(islands)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export const crossSectionToCopperPourIslands = (
|
|
276
|
-
section: CrossSectionType,
|
|
277
|
-
): CopperPourIsland[] => {
|
|
278
|
-
const islands: CopperPourIsland[] = []
|
|
279
|
-
|
|
280
|
-
for (const island of section.decompose()) {
|
|
281
|
-
const rings = fromScaledManifoldPolygons(island.toPolygons())
|
|
282
|
-
if (rings.length === 0) continue
|
|
283
|
-
|
|
284
|
-
const outerRing = rings.reduce((largest, ring) =>
|
|
285
|
-
Math.abs(signedArea(ring)) > Math.abs(signedArea(largest))
|
|
286
|
-
? ring
|
|
287
|
-
: largest,
|
|
288
|
-
)
|
|
289
|
-
const innerRings = rings.filter((ring) => ring !== outerRing)
|
|
290
|
-
|
|
291
|
-
islands.push({
|
|
292
|
-
outerRing,
|
|
293
|
-
innerRings,
|
|
294
|
-
})
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return islands
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export const geometryDebugSummary = (
|
|
301
|
-
label: string,
|
|
302
|
-
polygons: PolygonRing[],
|
|
303
|
-
) => ({
|
|
304
|
-
label,
|
|
305
|
-
...describePolygons(toScaledManifoldPolygons(polygons, label)),
|
|
306
|
-
})
|