@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.
@@ -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 faceToVertices = (face: Flatten.Face) =>
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 polygons = Array.isArray(pourPolygons) ? pourPolygons : [pourPolygons]
25
-
26
- for (const p of polygons) {
27
- const islands = p.splitToIslands()
28
-
29
- for (const island of islands) {
30
- if (island.isEmpty()) continue
31
-
32
- const faces = [...island.faces] as Flatten.Face[]
33
- const outer_face_ccw = faces.find(
34
- (f) => f.orientation() === Flatten.ORIENTATION.CCW,
35
- )
36
- const inner_faces_cw = faces.filter(
37
- (f) => f.orientation() === Flatten.ORIENTATION.CW,
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 Flatten from "@flatten-js/core"
2
+ import type { PolygonRing } from "./manifold-geometry-adapter"
3
+ import { normalizeRing } from "./manifold-geometry-adapter"
3
4
 
4
- export const getBoardPolygon = (region: InputPourRegion): Flatten.Polygon => {
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
- const polygon = new Flatten.Polygon(
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 new Flatten.Polygon()
21
+ return []
28
22
  }
29
23
 
30
- return new Flatten.Polygon(
31
- new Flatten.Box(
32
- newBounds.minX,
33
- newBounds.minY,
34
- newBounds.maxX,
35
- newBounds.maxY,
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 { circleToPolygon } from "./circle-to-polygon"
9
+ import {
10
+ normalizeRing,
11
+ offsetPolygon,
12
+ type PolygonRing,
13
+ } from "./manifold-geometry-adapter"
11
14
 
12
15
  interface ProcessedObstacles {
13
- polygonsToSubtract: Flatten.Polygon[]
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: Flatten.Polygon[] = []
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 boardPoly = new Flatten.Polygon(
46
- boardOutline.map((p) => Flatten.point(p.x, p.y)),
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 = new Flatten.Vector(p1, p2)
62
- const v2 = new Flatten.Vector(p2, p3)
63
- const crossProduct = v1.cross(v2)
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
- const circle = new Flatten.Circle(p2, board_edge_margin)
66
- polygonsToSubtract.push(circleToPolygon(circle))
93
+ polygonsToSubtract.push(circleToPolygon(p2, board_edge_margin))
67
94
 
68
95
  if (crossProduct < 0) {
69
- const box = new Flatten.Box(
70
- p2.x - board_edge_margin,
71
- p2.y - board_edge_margin,
72
- p2.x + board_edge_margin,
73
- p2.y + board_edge_margin,
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
- const circle = new Flatten.Circle(
134
- new Flatten.Point(pad.x, pad.y),
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
- const b = new Flatten.Box(
145
- bounds.minX - margin,
146
- bounds.minY - margin,
147
- bounds.maxX + margin,
148
- bounds.maxY + margin,
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 = new Flatten.Polygon(
170
- uniquePoints.map((p) => Flatten.point(p.x, p.y)),
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
- // Ensure polygon is CCW for consistent normal direction.
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
- const circle = new Flatten.Circle(
226
- new Flatten.Point(segment.x, segment.y),
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
  }