@tscircuit/copper-pour-solver 0.0.24 → 0.0.25
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 +323 -169
- package/lib/solvers/CopperPourPipelineSolver.ts +35 -22
- package/lib/solvers/copper-pour/generate-brep.ts +45 -52
- package/lib/solvers/copper-pour/get-board-polygon.ts +11 -19
- package/lib/solvers/copper-pour/manifold-geometry-adapter.ts +306 -0
- package/lib/solvers/copper-pour/process-obstacles.ts +65 -87
- package/package.json +4 -1
- 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__/stm32f746g-disco.test.tsbottom.snap.svg +1 -0
- package/tests/__snapshots__/stm32f746g-disco.test.tstop.snap.svg +1 -0
- package/tests/__snapshots__/via.snap.svg +1 -1
- package/tests/manifold-copper-pour-geometry.test.ts +194 -0
- package/tests/stm32f746g-disco.test.ts +16 -16
- package/lib/solvers/copper-pour/circle-to-polygon.ts +0 -15
|
@@ -1,59 +1,52 @@
|
|
|
1
|
-
import Flatten from "@flatten-js/core"
|
|
2
1
|
import type { BRepShape } from "circuit-json"
|
|
2
|
+
import type { CopperPourIsland, PolygonRing } from "./manifold-geometry-adapter"
|
|
3
|
+
|
|
4
|
+
const signedArea = (ring: PolygonRing) => {
|
|
5
|
+
let area = 0
|
|
6
|
+
for (let i = 0; i < ring.length; i++) {
|
|
7
|
+
const current = ring[i]!
|
|
8
|
+
const next = ring[(i + 1) % ring.length]!
|
|
9
|
+
area += current.x * next.y - next.x * current.y
|
|
10
|
+
}
|
|
11
|
+
return area / 2
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ensureAreaSign = (
|
|
15
|
+
ring: PolygonRing,
|
|
16
|
+
desiredSign: "positive" | "negative",
|
|
17
|
+
) => {
|
|
18
|
+
const area = signedArea(ring)
|
|
19
|
+
const shouldReverse =
|
|
20
|
+
(desiredSign === "positive" && area < 0) ||
|
|
21
|
+
(desiredSign === "negative" && area > 0)
|
|
22
|
+
return shouldReverse ? [...ring].reverse() : ring
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ringToVertices = (ring: PolygonRing) =>
|
|
26
|
+
ring.map((point) => ({
|
|
27
|
+
x: point.x,
|
|
28
|
+
y: point.y,
|
|
29
|
+
}))
|
|
3
30
|
|
|
4
|
-
const
|
|
5
|
-
face.edges.map((e) => {
|
|
6
|
-
const pt: { x: number; y: number; bulge?: number } = {
|
|
7
|
-
x: e.start.x,
|
|
8
|
-
y: e.start.y,
|
|
9
|
-
}
|
|
10
|
-
if (e.isArc) {
|
|
11
|
-
const bulge = Math.tan((e.shape as Flatten.Arc).sweep / 4)
|
|
12
|
-
if (Math.abs(bulge) > 1e-9) {
|
|
13
|
-
pt.bulge = bulge
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
return pt
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
export const generateBRep = (
|
|
20
|
-
pourPolygons: Flatten.Polygon | Flatten.Polygon[],
|
|
21
|
-
): BRepShape[] => {
|
|
31
|
+
export const generateBRep = (pourIslands: CopperPourIsland[]): BRepShape[] => {
|
|
22
32
|
const brep_shapes: BRepShape[] = []
|
|
23
33
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
if (!outer_face_ccw) continue
|
|
41
|
-
|
|
42
|
-
// BRep requires outer ring to be CW and inner rings to be CCW.
|
|
43
|
-
// Flatten-js provides outer face as CCW and inner faces as CW.
|
|
44
|
-
// We need to reverse them.
|
|
45
|
-
outer_face_ccw.reverse()
|
|
46
|
-
const outer_ring_vertices = faceToVertices(outer_face_ccw)
|
|
47
|
-
const inner_rings = inner_faces_cw.map((f) => {
|
|
48
|
-
f.reverse()
|
|
49
|
-
return { vertices: faceToVertices(f) }
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
brep_shapes.push({
|
|
53
|
-
outer_ring: { vertices: outer_ring_vertices },
|
|
54
|
-
inner_rings,
|
|
55
|
-
})
|
|
56
|
-
}
|
|
34
|
+
for (const island of pourIslands) {
|
|
35
|
+
if (island.outerRing.length < 3) continue
|
|
36
|
+
|
|
37
|
+
// circuit-json BRep uses implicit closure. Keep the previous renderer-facing
|
|
38
|
+
// winding: outer rings have negative signed area, holes have positive area.
|
|
39
|
+
const outerRing = ensureAreaSign(island.outerRing, "negative")
|
|
40
|
+
const innerRings = island.innerRings
|
|
41
|
+
.filter((ring) => ring.length >= 3)
|
|
42
|
+
.map((ring) => ensureAreaSign(ring, "positive"))
|
|
43
|
+
|
|
44
|
+
brep_shapes.push({
|
|
45
|
+
outer_ring: { vertices: ringToVertices(outerRing) },
|
|
46
|
+
inner_rings: innerRings.map((ring) => ({
|
|
47
|
+
vertices: ringToVertices(ring),
|
|
48
|
+
})),
|
|
49
|
+
})
|
|
57
50
|
}
|
|
58
51
|
|
|
59
52
|
return brep_shapes
|
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
import type { InputPourRegion } from "lib/types"
|
|
2
|
-
import
|
|
2
|
+
import type { PolygonRing } from "./manifold-geometry-adapter"
|
|
3
|
+
import { normalizeRing } from "./manifold-geometry-adapter"
|
|
3
4
|
|
|
4
|
-
export const getBoardPolygon = (region: InputPourRegion):
|
|
5
|
+
export const getBoardPolygon = (region: InputPourRegion): PolygonRing => {
|
|
5
6
|
const board_edge_margin = region.board_edge_margin ?? 0
|
|
6
7
|
|
|
7
8
|
if (region.outline && region.outline.length > 0) {
|
|
8
|
-
|
|
9
|
-
region.outline.map((p) => Flatten.point(p.x, p.y)),
|
|
10
|
-
)
|
|
11
|
-
// Ensure polygon is CCW for consistent boolean operations
|
|
12
|
-
if (polygon.orientation() === Flatten.ORIENTATION.CW) {
|
|
13
|
-
polygon.reverse()
|
|
14
|
-
}
|
|
15
|
-
return polygon
|
|
9
|
+
return normalizeRing(region.outline, "getBoardPolygon.outline")
|
|
16
10
|
}
|
|
17
11
|
|
|
18
12
|
const { bounds } = region
|
|
@@ -24,15 +18,13 @@ export const getBoardPolygon = (region: InputPourRegion): Flatten.Polygon => {
|
|
|
24
18
|
}
|
|
25
19
|
|
|
26
20
|
if (newBounds.minX >= newBounds.maxX || newBounds.minY >= newBounds.maxY) {
|
|
27
|
-
return
|
|
21
|
+
return []
|
|
28
22
|
}
|
|
29
23
|
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
).toPoints(),
|
|
37
|
-
)
|
|
24
|
+
return [
|
|
25
|
+
{ x: newBounds.minX, y: newBounds.minY },
|
|
26
|
+
{ x: newBounds.maxX, y: newBounds.minY },
|
|
27
|
+
{ x: newBounds.maxX, y: newBounds.maxY },
|
|
28
|
+
{ x: newBounds.minX, y: newBounds.maxY },
|
|
29
|
+
]
|
|
38
30
|
}
|
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
})
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import Flatten from "@flatten-js/core"
|
|
2
1
|
import type { Point } from "@tscircuit/math-utils"
|
|
3
2
|
import type {
|
|
4
3
|
InputCircularPad,
|
|
@@ -7,10 +6,14 @@ import type {
|
|
|
7
6
|
InputRectPad,
|
|
8
7
|
InputTracePad,
|
|
9
8
|
} from "lib/types"
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
normalizeRing,
|
|
11
|
+
offsetPolygon,
|
|
12
|
+
type PolygonRing,
|
|
13
|
+
} from "./manifold-geometry-adapter"
|
|
11
14
|
|
|
12
15
|
interface ProcessedObstacles {
|
|
13
|
-
polygonsToSubtract:
|
|
16
|
+
polygonsToSubtract: PolygonRing[]
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
const isRectPad = (pad: InputPad): pad is InputRectPad => pad.shape === "rect"
|
|
@@ -21,6 +24,34 @@ const isCircularPad = (pad: InputPad): pad is InputCircularPad =>
|
|
|
21
24
|
const isPolygonPad = (pad: InputPad): pad is InputPolygonPad =>
|
|
22
25
|
pad.shape === "polygon"
|
|
23
26
|
|
|
27
|
+
const circleToPolygon = (
|
|
28
|
+
center: Point,
|
|
29
|
+
radius: number,
|
|
30
|
+
numSegments = 32,
|
|
31
|
+
): PolygonRing => {
|
|
32
|
+
const points: PolygonRing = []
|
|
33
|
+
for (let i = 0; i < numSegments; i++) {
|
|
34
|
+
const angle = (i / numSegments) * 2 * Math.PI
|
|
35
|
+
points.push({
|
|
36
|
+
x: center.x + radius * Math.cos(angle),
|
|
37
|
+
y: center.y + radius * Math.sin(angle),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
return points
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const boxToPolygon = (
|
|
44
|
+
minX: number,
|
|
45
|
+
minY: number,
|
|
46
|
+
maxX: number,
|
|
47
|
+
maxY: number,
|
|
48
|
+
): PolygonRing => [
|
|
49
|
+
{ x: minX, y: minY },
|
|
50
|
+
{ x: maxX, y: minY },
|
|
51
|
+
{ x: maxX, y: maxY },
|
|
52
|
+
{ x: minX, y: maxY },
|
|
53
|
+
]
|
|
54
|
+
|
|
24
55
|
export const processObstaclesForPour = (
|
|
25
56
|
pads: InputPad[],
|
|
26
57
|
pourConnectivityKey: string,
|
|
@@ -32,7 +63,7 @@ export const processObstaclesForPour = (
|
|
|
32
63
|
},
|
|
33
64
|
boardOutline?: Point[],
|
|
34
65
|
): ProcessedObstacles => {
|
|
35
|
-
const polygonsToSubtract:
|
|
66
|
+
const polygonsToSubtract: PolygonRing[] = []
|
|
36
67
|
|
|
37
68
|
const { padMargin, traceMargin, board_edge_margin, cutoutMargin } = margins
|
|
38
69
|
|
|
@@ -42,13 +73,10 @@ export const processObstaclesForPour = (
|
|
|
42
73
|
board_edge_margin &&
|
|
43
74
|
board_edge_margin > 0
|
|
44
75
|
) {
|
|
45
|
-
const
|
|
46
|
-
boardOutline
|
|
76
|
+
const vertices = normalizeRing(
|
|
77
|
+
boardOutline,
|
|
78
|
+
"processObstacles.boardOutline",
|
|
47
79
|
)
|
|
48
|
-
if (boardPoly.area() < 0) {
|
|
49
|
-
boardPoly.reverse()
|
|
50
|
-
}
|
|
51
|
-
const vertices = boardPoly.vertices
|
|
52
80
|
|
|
53
81
|
// Add clearance shapes at vertices
|
|
54
82
|
for (let i = 0; i < vertices.length; i++) {
|
|
@@ -58,21 +86,21 @@ export const processObstaclesForPour = (
|
|
|
58
86
|
|
|
59
87
|
if (!p1 || !p2 || !p3) continue
|
|
60
88
|
|
|
61
|
-
const v1 =
|
|
62
|
-
const v2 =
|
|
63
|
-
const crossProduct = v1.
|
|
89
|
+
const v1 = { x: p2.x - p1.x, y: p2.y - p1.y }
|
|
90
|
+
const v2 = { x: p3.x - p2.x, y: p3.y - p2.y }
|
|
91
|
+
const crossProduct = v1.x * v2.y - v1.y * v2.x
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
polygonsToSubtract.push(circleToPolygon(circle))
|
|
93
|
+
polygonsToSubtract.push(circleToPolygon(p2, board_edge_margin))
|
|
67
94
|
|
|
68
95
|
if (crossProduct < 0) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
polygonsToSubtract.push(
|
|
97
|
+
boxToPolygon(
|
|
98
|
+
p2.x - board_edge_margin,
|
|
99
|
+
p2.y - board_edge_margin,
|
|
100
|
+
p2.x + board_edge_margin,
|
|
101
|
+
p2.y + board_edge_margin,
|
|
102
|
+
),
|
|
74
103
|
)
|
|
75
|
-
polygonsToSubtract.push(new Flatten.Polygon(box.toPoints()))
|
|
76
104
|
}
|
|
77
105
|
}
|
|
78
106
|
|
|
@@ -111,9 +139,7 @@ export const processObstaclesForPour = (
|
|
|
111
139
|
y: centerY + p.x * sinAngle + p.y * cosAngle,
|
|
112
140
|
}))
|
|
113
141
|
|
|
114
|
-
polygonsToSubtract.push(
|
|
115
|
-
new Flatten.Polygon(rotatedCorners.map((p) => Flatten.point(p.x, p.y))),
|
|
116
|
-
)
|
|
142
|
+
polygonsToSubtract.push(rotatedCorners)
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
|
|
@@ -130,24 +156,23 @@ export const processObstaclesForPour = (
|
|
|
130
156
|
|
|
131
157
|
if (isCircularPad(pad)) {
|
|
132
158
|
const margin = isHoleOrCutout ? (cutoutMargin ?? 0) : padMargin
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
pad.radius + margin,
|
|
159
|
+
polygonsToSubtract.push(
|
|
160
|
+
circleToPolygon({ x: pad.x, y: pad.y }, pad.radius + margin),
|
|
136
161
|
)
|
|
137
|
-
polygonsToSubtract.push(circleToPolygon(circle))
|
|
138
162
|
continue
|
|
139
163
|
}
|
|
140
164
|
|
|
141
165
|
if (isRectPad(pad)) {
|
|
142
166
|
const margin = isHoleOrCutout ? (cutoutMargin ?? 0) : padMargin
|
|
143
167
|
const { bounds } = pad
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
polygonsToSubtract.push(
|
|
169
|
+
boxToPolygon(
|
|
170
|
+
bounds.minX - margin,
|
|
171
|
+
bounds.minY - margin,
|
|
172
|
+
bounds.maxX + margin,
|
|
173
|
+
bounds.maxY + margin,
|
|
174
|
+
),
|
|
149
175
|
)
|
|
150
|
-
polygonsToSubtract.push(new Flatten.Polygon(b.toPoints()))
|
|
151
176
|
continue
|
|
152
177
|
}
|
|
153
178
|
|
|
@@ -166,67 +191,24 @@ export const processObstaclesForPour = (
|
|
|
166
191
|
|
|
167
192
|
if (uniquePoints.length < 3) continue
|
|
168
193
|
|
|
169
|
-
const polygon =
|
|
170
|
-
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
if (Math.abs(polygon.area()) < 1e-9) continue
|
|
194
|
+
const polygon = normalizeRing(uniquePoints, "processObstacles.polygonPad")
|
|
195
|
+
if (polygon.length < 3) continue
|
|
174
196
|
|
|
175
197
|
if (margin <= 0) {
|
|
176
198
|
polygonsToSubtract.push(polygon)
|
|
177
199
|
continue
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
|
|
181
|
-
// In flatten-js, CCW corresponds to a negative area.
|
|
182
|
-
if (polygon.area() > 0) {
|
|
183
|
-
polygon.reverse()
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const offsetLines: Flatten.Line[] = []
|
|
187
|
-
const polygonVertices = polygon.vertices
|
|
188
|
-
for (let i = 0; i < polygonVertices.length; i++) {
|
|
189
|
-
const p1 = polygonVertices[i]!
|
|
190
|
-
const p2 = polygonVertices[(i + 1) % polygonVertices.length]!
|
|
191
|
-
|
|
192
|
-
const segment = Flatten.segment(p1, p2)
|
|
193
|
-
|
|
194
|
-
if (segment.length === 0) continue
|
|
195
|
-
|
|
196
|
-
const line = Flatten.line(segment.start, segment.end)
|
|
197
|
-
|
|
198
|
-
// For a CCW polygon, the normal (rotated +90deg, i.e. "left") points inward.
|
|
199
|
-
// We must translate outward, so we use a negative margin.
|
|
200
|
-
const norm = line.norm
|
|
201
|
-
const offsetLine = line.translate(norm.multiply(-margin))
|
|
202
|
-
offsetLines.push(offsetLine)
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const newPolygonPoints: Flatten.Point[] = []
|
|
206
|
-
for (let i = 0; i < offsetLines.length; i++) {
|
|
207
|
-
const line1 = offsetLines[i]!
|
|
208
|
-
const line2 = offsetLines[(i + 1) % offsetLines.length]!
|
|
209
|
-
|
|
210
|
-
const ip = line1.intersect(line2)
|
|
211
|
-
if (ip.length > 0) {
|
|
212
|
-
newPolygonPoints.push(ip[0]!)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (newPolygonPoints.length >= 3) {
|
|
217
|
-
polygonsToSubtract.push(new Flatten.Polygon(newPolygonPoints))
|
|
218
|
-
}
|
|
202
|
+
polygonsToSubtract.push(...offsetPolygon(polygon, margin))
|
|
219
203
|
continue
|
|
220
204
|
}
|
|
221
205
|
|
|
222
206
|
if (isTracePad(pad)) {
|
|
223
207
|
// Add circles for each vertex
|
|
224
208
|
for (const segment of pad.segments) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
pad.width / 2 + traceMargin,
|
|
209
|
+
polygonsToSubtract.push(
|
|
210
|
+
circleToPolygon(segment, pad.width / 2 + traceMargin),
|
|
228
211
|
)
|
|
229
|
-
polygonsToSubtract.push(circleToPolygon(circle))
|
|
230
212
|
}
|
|
231
213
|
|
|
232
214
|
// Add rectangles for each segment
|
|
@@ -265,11 +247,7 @@ export const processObstaclesForPour = (
|
|
|
265
247
|
y: centerY + p.x * sinAngle + p.y * cosAngle,
|
|
266
248
|
}))
|
|
267
249
|
|
|
268
|
-
polygonsToSubtract.push(
|
|
269
|
-
new Flatten.Polygon(
|
|
270
|
-
rotatedCorners.map((p) => Flatten.point(p.x, p.y)),
|
|
271
|
-
),
|
|
272
|
-
)
|
|
250
|
+
polygonsToSubtract.push(rotatedCorners)
|
|
273
251
|
}
|
|
274
252
|
}
|
|
275
253
|
}
|