@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.
@@ -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
- })