@tscircuit/copper-pour-solver 0.0.26 → 0.0.27

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.
Files changed (30) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.js +347 -205
  3. package/lib/index.ts +1 -0
  4. package/lib/solvers/CopperPourPipelineSolver.ts +20 -24
  5. package/lib/solvers/copper-pour/generate-brep.ts +35 -51
  6. package/lib/solvers/copper-pour/get-board-polygon.ts +10 -19
  7. package/lib/solvers/copper-pour/manifold-geometry-adapter.ts +159 -0
  8. package/lib/solvers/copper-pour/manifold-runtime.ts +51 -0
  9. package/lib/solvers/copper-pour/polygon-primitives.ts +57 -0
  10. package/lib/solvers/copper-pour/polygon-ring.ts +126 -0
  11. package/lib/solvers/copper-pour/process-obstacles.ts +48 -143
  12. package/package.json +4 -1
  13. package/tests/__snapshots__/2-layers-bottom.snap.svg +1 -1
  14. package/tests/__snapshots__/2-layers-top.snap.svg +1 -1
  15. package/tests/__snapshots__/board-edge-margin-2.snap.svg +1 -1
  16. package/tests/__snapshots__/board-edge-margin.snap.svg +1 -1
  17. package/tests/__snapshots__/hole-and-cutouts.snap.svg +1 -1
  18. package/tests/__snapshots__/larger-trace-margin.snap.svg +1 -1
  19. package/tests/__snapshots__/multiple-pours.snap.svg +1 -1
  20. package/tests/__snapshots__/pad-margin.snap.svg +1 -1
  21. package/tests/__snapshots__/polygon-board-2.snap.svg +1 -1
  22. package/tests/__snapshots__/polygon-board.snap.svg +1 -1
  23. package/tests/__snapshots__/smaller-trace-margin.snap.svg +1 -1
  24. package/tests/__snapshots__/stm32f746g-disco.test.tsbottom.snap.svg +1 -0
  25. package/tests/__snapshots__/stm32f746g-disco.test.tstop.snap.svg +1 -0
  26. package/tests/__snapshots__/via.snap.svg +1 -1
  27. package/tests/fixtures/preload.ts +3 -0
  28. package/tests/manifold-copper-pour-geometry.test.ts +194 -0
  29. package/tests/stm32f746g-disco.test.ts +16 -16
  30. package/lib/solvers/copper-pour/circle-to-polygon.ts +0 -15
@@ -1,10 +1,15 @@
1
1
  import { BasePipelineSolver } from "@tscircuit/solver-utils"
2
- import { getBoardPolygon } from "./copper-pour/get-board-polygon"
3
- import Flatten from "@flatten-js/core"
4
- import { processObstaclesForPour } from "./copper-pour/process-obstacles"
5
- import { generateBRep } from "./copper-pour/generate-brep"
6
2
  import type { BRepShape } from "circuit-json"
7
3
  import type { InputProblem, PipelineOutput } from "lib/types"
4
+ import { generateBRep } from "./copper-pour/generate-brep"
5
+ import { getBoardPolygon } from "./copper-pour/get-board-polygon"
6
+ import {
7
+ crossSectionToCopperPourIslands,
8
+ removeTinyIslands,
9
+ subtractBlockersFromPour,
10
+ } from "./copper-pour/manifold-geometry-adapter"
11
+ import { isManifoldGeometryInitialized } from "./copper-pour/manifold-runtime"
12
+ import { processObstaclesForPour } from "./copper-pour/process-obstacles"
8
13
 
9
14
  export class CopperPourPipelineSolver extends BasePipelineSolver<InputProblem> {
10
15
  pipelineDef = []
@@ -17,6 +22,12 @@ export class CopperPourPipelineSolver extends BasePipelineSolver<InputProblem> {
17
22
  }
18
23
 
19
24
  override getOutput(): PipelineOutput {
25
+ if (!isManifoldGeometryInitialized()) {
26
+ throw new Error(
27
+ "Manifold geometry has not been initialized. Call initializeManifoldGeometry() before solving copper pours.",
28
+ )
29
+ }
30
+
20
31
  const brep_shapes: BRepShape[] = []
21
32
 
22
33
  for (const region of this.input.regionsForPour) {
@@ -38,27 +49,12 @@ export class CopperPourPipelineSolver extends BasePipelineSolver<InputProblem> {
38
49
  region.outline,
39
50
  )
40
51
 
41
- let pourPolygons: Flatten.Polygon | Flatten.Polygon[] = boardPolygon
42
-
43
- for (const poly of polygonsToSubtract) {
44
- const currentPolys = Array.isArray(pourPolygons)
45
- ? pourPolygons
46
- : [pourPolygons]
47
- const nextPolys: Flatten.Polygon[] = []
48
- for (const p of currentPolys) {
49
- const result = Flatten.BooleanOperations.subtract(p, poly)
50
- if (result) {
51
- if (Array.isArray(result)) {
52
- nextPolys.push(...result.filter((r) => !r.isEmpty()))
53
- } else {
54
- if (!result.isEmpty()) nextPolys.push(result)
55
- }
56
- }
57
- }
58
- pourPolygons = nextPolys
59
- }
52
+ const finalPour = removeTinyIslands(
53
+ subtractBlockersFromPour(boardPolygon, polygonsToSubtract),
54
+ )
55
+ const pourIslands = crossSectionToCopperPourIslands(finalPour)
60
56
 
61
- const new_breps = generateBRep(pourPolygons)
57
+ const new_breps = generateBRep(pourIslands)
62
58
  brep_shapes.push(...new_breps)
63
59
  }
64
60
 
@@ -1,59 +1,43 @@
1
- import Flatten from "@flatten-js/core"
2
1
  import type { BRepShape } from "circuit-json"
2
+ import type { CopperPourIsland } from "./manifold-geometry-adapter"
3
+ import { signedArea, type PolygonRing } from "./polygon-ring"
4
+
5
+ const ensureAreaSign = (
6
+ ring: PolygonRing,
7
+ desiredSign: "positive" | "negative",
8
+ ) => {
9
+ const area = signedArea(ring)
10
+ const shouldReverse =
11
+ (desiredSign === "positive" && area < 0) ||
12
+ (desiredSign === "negative" && area > 0)
13
+ return shouldReverse ? [...ring].reverse() : ring
14
+ }
3
15
 
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
- })
16
+ const ringToVertices = (ring: PolygonRing) =>
17
+ ring.map((point) => ({
18
+ x: point.x,
19
+ y: point.y,
20
+ }))
18
21
 
19
- export const generateBRep = (
20
- pourPolygons: Flatten.Polygon | Flatten.Polygon[],
21
- ): BRepShape[] => {
22
+ export const generateBRep = (pourIslands: CopperPourIsland[]): BRepShape[] => {
22
23
  const brep_shapes: BRepShape[] = []
23
24
 
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
- }
25
+ for (const island of pourIslands) {
26
+ if (island.outerRing.length < 3) continue
27
+
28
+ // circuit-json BRep uses implicit closure. Keep the previous renderer-facing
29
+ // winding: outer rings have negative signed area, holes have positive area.
30
+ const outerRing = ensureAreaSign(island.outerRing, "negative")
31
+ const innerRings = island.innerRings
32
+ .filter((ring) => ring.length >= 3)
33
+ .map((ring) => ensureAreaSign(ring, "positive"))
34
+
35
+ brep_shapes.push({
36
+ outer_ring: { vertices: ringToVertices(outerRing) },
37
+ inner_rings: innerRings.map((ring) => ({
38
+ vertices: ringToVertices(ring),
39
+ })),
40
+ })
57
41
  }
58
42
 
59
43
  return brep_shapes
@@ -1,18 +1,11 @@
1
1
  import type { InputPourRegion } from "lib/types"
2
- import Flatten from "@flatten-js/core"
2
+ import { normalizeRing, type PolygonRing } from "./polygon-ring"
3
3
 
4
- export const getBoardPolygon = (region: InputPourRegion): Flatten.Polygon => {
4
+ export const getBoardPolygon = (region: InputPourRegion): PolygonRing => {
5
5
  const board_edge_margin = region.board_edge_margin ?? 0
6
6
 
7
7
  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
8
+ return normalizeRing(region.outline, "getBoardPolygon.outline")
16
9
  }
17
10
 
18
11
  const { bounds } = region
@@ -24,15 +17,13 @@ export const getBoardPolygon = (region: InputPourRegion): Flatten.Polygon => {
24
17
  }
25
18
 
26
19
  if (newBounds.minX >= newBounds.maxX || newBounds.minY >= newBounds.maxY) {
27
- return new Flatten.Polygon()
20
+ return []
28
21
  }
29
22
 
30
- return new Flatten.Polygon(
31
- new Flatten.Box(
32
- newBounds.minX,
33
- newBounds.minY,
34
- newBounds.maxX,
35
- newBounds.maxY,
36
- ).toPoints(),
37
- )
23
+ return [
24
+ { x: newBounds.minX, y: newBounds.minY },
25
+ { x: newBounds.maxX, y: newBounds.minY },
26
+ { x: newBounds.maxX, y: newBounds.maxY },
27
+ { x: newBounds.minX, y: newBounds.maxY },
28
+ ]
38
29
  }
@@ -0,0 +1,159 @@
1
+ import type { FillRule } from "manifold-3d"
2
+ import {
3
+ getCrossSection,
4
+ runManifoldOperation,
5
+ type CrossSection,
6
+ } from "./manifold-runtime"
7
+ import {
8
+ fromScaledManifoldPolygons,
9
+ MANIFOLD_GEOMETRY_SCALE,
10
+ normalizeRing,
11
+ signedArea,
12
+ toScaledManifoldPolygons,
13
+ type PolygonRing,
14
+ } from "./polygon-ring"
15
+
16
+ export const DEFAULT_MIN_ISLAND_AREA = 1e-8
17
+
18
+ export type CopperPourIsland = {
19
+ outerRing: PolygonRing
20
+ innerRings: PolygonRing[]
21
+ }
22
+
23
+ export const crossSectionFromPolygon = (
24
+ polygon: PolygonRing,
25
+ fillRule: FillRule = "Positive",
26
+ ): CrossSection => {
27
+ const scaledPolygons = toScaledManifoldPolygons(
28
+ [polygon],
29
+ "crossSectionFromPolygon",
30
+ )
31
+ const CrossSection = getCrossSection()
32
+ if (scaledPolygons.length === 0) {
33
+ return CrossSection.ofPolygons([])
34
+ }
35
+ return runManifoldOperation("crossSectionFromPolygon", scaledPolygons, () =>
36
+ CrossSection.ofPolygons(scaledPolygons, fillRule),
37
+ )
38
+ }
39
+
40
+ export const crossSectionFromPolygons = (
41
+ polygons: PolygonRing[],
42
+ fillRule: FillRule = "Positive",
43
+ ): CrossSection => {
44
+ const scaledPolygons = toScaledManifoldPolygons(
45
+ polygons,
46
+ "crossSectionFromPolygons",
47
+ )
48
+ const CrossSection = getCrossSection()
49
+ if (scaledPolygons.length === 0) {
50
+ return CrossSection.ofPolygons([])
51
+ }
52
+ return runManifoldOperation("crossSectionFromPolygons", scaledPolygons, () =>
53
+ CrossSection.ofPolygons(scaledPolygons, fillRule),
54
+ )
55
+ }
56
+
57
+ export const composeCrossSections = (
58
+ sections: CrossSection[],
59
+ ): CrossSection => {
60
+ const nonEmptySections = sections.filter((section) => !section.isEmpty())
61
+ const CrossSection = getCrossSection()
62
+ if (nonEmptySections.length === 0) {
63
+ return CrossSection.ofPolygons([])
64
+ }
65
+ return runManifoldOperation("composeCrossSections", [], () =>
66
+ CrossSection.compose(nonEmptySections),
67
+ )
68
+ }
69
+
70
+ export const offsetPolygon = (
71
+ polygon: PolygonRing,
72
+ margin: number,
73
+ joinType: "Square" | "Round" | "Miter" = "Miter",
74
+ ): PolygonRing[] => {
75
+ const scaledPolygons = toScaledManifoldPolygons([polygon], "offsetPolygon")
76
+ if (scaledPolygons.length === 0 || margin <= 0) {
77
+ return scaledPolygons.length === 0 ? [] : [normalizeRing(polygon)]
78
+ }
79
+
80
+ const scaledMargin = margin * MANIFOLD_GEOMETRY_SCALE
81
+ const CrossSection = getCrossSection()
82
+ const section = runManifoldOperation(
83
+ "offsetPolygon.input",
84
+ scaledPolygons,
85
+ () => CrossSection.ofPolygons(scaledPolygons, "Positive"),
86
+ )
87
+ const offset = runManifoldOperation(
88
+ "offsetPolygon.offset",
89
+ scaledPolygons,
90
+ () => section.offset(scaledMargin, joinType, 2, 32),
91
+ )
92
+ return fromScaledManifoldPolygons(offset.toPolygons())
93
+ }
94
+
95
+ export const subtractBlockersFromPour = (
96
+ pourPolygon: PolygonRing,
97
+ blockerPolygons: PolygonRing[],
98
+ ): CrossSection => {
99
+ const pourSection = crossSectionFromPolygon(pourPolygon)
100
+ const blockerSection = crossSectionFromPolygons(blockerPolygons)
101
+
102
+ if (pourSection.isEmpty() || blockerSection.isEmpty()) {
103
+ return pourSection
104
+ }
105
+
106
+ const operationPolygons = [
107
+ ...toScaledManifoldPolygons([pourPolygon], "subtractBlockersFromPour.pour"),
108
+ ...toScaledManifoldPolygons(
109
+ blockerPolygons,
110
+ "subtractBlockersFromPour.blockers",
111
+ ),
112
+ ]
113
+
114
+ return runManifoldOperation(
115
+ "subtractBlockersFromPour",
116
+ operationPolygons,
117
+ () => pourSection.subtract(blockerSection),
118
+ )
119
+ }
120
+
121
+ export const removeTinyIslands = (
122
+ section: CrossSection,
123
+ minArea = DEFAULT_MIN_ISLAND_AREA,
124
+ ): CrossSection => {
125
+ if (section.isEmpty()) return section
126
+
127
+ const minScaledArea =
128
+ minArea * MANIFOLD_GEOMETRY_SCALE * MANIFOLD_GEOMETRY_SCALE
129
+ const islands = section
130
+ .decompose()
131
+ .filter((island) => Math.abs(island.area()) >= minScaledArea)
132
+
133
+ return composeCrossSections(islands)
134
+ }
135
+
136
+ export const crossSectionToCopperPourIslands = (
137
+ section: CrossSection,
138
+ ): CopperPourIsland[] => {
139
+ const islands: CopperPourIsland[] = []
140
+
141
+ for (const island of section.decompose()) {
142
+ const rings = fromScaledManifoldPolygons(island.toPolygons())
143
+ if (rings.length === 0) continue
144
+
145
+ const outerRing = rings.reduce((largest, ring) =>
146
+ Math.abs(signedArea(ring)) > Math.abs(signedArea(largest))
147
+ ? ring
148
+ : largest,
149
+ )
150
+ const innerRings = rings.filter((ring) => ring !== outerRing)
151
+
152
+ islands.push({
153
+ outerRing,
154
+ innerRings,
155
+ })
156
+ }
157
+
158
+ return islands
159
+ }
@@ -0,0 +1,51 @@
1
+ import type {
2
+ CrossSection as CrossSectionType,
3
+ ManifoldToplevel,
4
+ } from "manifold-3d"
5
+ import {
6
+ getManifoldModule,
7
+ getManifoldModuleSync,
8
+ } from "manifold-3d/lib/wasm.js"
9
+ import { describeScaledPolygons, type ScaledPolygons } from "./polygon-ring"
10
+
11
+ let manifoldModulePromise: Promise<ManifoldToplevel> | null = null
12
+
13
+ export const initializeManifoldGeometry = async () => {
14
+ if (getManifoldModuleSync()) return
15
+ manifoldModulePromise ??= getManifoldModule().catch((error) => {
16
+ manifoldModulePromise = null
17
+ throw error
18
+ })
19
+ await manifoldModulePromise
20
+ }
21
+
22
+ export const isManifoldGeometryInitialized = () =>
23
+ Boolean(getManifoldModuleSync())
24
+
25
+ export const getCrossSection = () => {
26
+ const manifold = getManifoldModuleSync()
27
+ if (!manifold) {
28
+ throw new Error(
29
+ "Manifold geometry has not been initialized. Call initializeManifoldGeometry() before solving copper pours.",
30
+ )
31
+ }
32
+ return manifold.CrossSection
33
+ }
34
+
35
+ export const runManifoldOperation = <T>(
36
+ operation: string,
37
+ polygons: ScaledPolygons,
38
+ callback: () => T,
39
+ ): T => {
40
+ try {
41
+ return callback()
42
+ } catch (error) {
43
+ const details = describeScaledPolygons(polygons)
44
+ const message = error instanceof Error ? error.message : String(error)
45
+ throw new Error(
46
+ `${operation} failed: ${message}; details=${JSON.stringify(details)}`,
47
+ )
48
+ }
49
+ }
50
+
51
+ export type CrossSection = CrossSectionType
@@ -0,0 +1,57 @@
1
+ import type { Point } from "@tscircuit/math-utils"
2
+ import type { PolygonRing } from "./polygon-ring"
3
+
4
+ export const circleToPolygon = (
5
+ center: Point,
6
+ radius: number,
7
+ numSegments = 32,
8
+ ): PolygonRing => {
9
+ const points: PolygonRing = []
10
+ for (let i = 0; i < numSegments; i++) {
11
+ const angle = (i / numSegments) * 2 * Math.PI
12
+ points.push({
13
+ x: center.x + radius * Math.cos(angle),
14
+ y: center.y + radius * Math.sin(angle),
15
+ })
16
+ }
17
+ return points
18
+ }
19
+
20
+ export const boxToPolygon = (
21
+ minX: number,
22
+ minY: number,
23
+ maxX: number,
24
+ maxY: number,
25
+ ): PolygonRing => [
26
+ { x: minX, y: minY },
27
+ { x: maxX, y: minY },
28
+ { x: maxX, y: maxY },
29
+ { x: minX, y: maxY },
30
+ ]
31
+
32
+ export const segmentToPolygon = (
33
+ start: Point,
34
+ end: Point,
35
+ width: number,
36
+ ): PolygonRing => {
37
+ const segmentLength = Math.hypot(start.x - end.x, start.y - end.y)
38
+ if (segmentLength === 0) return []
39
+
40
+ const centerX = (start.x + end.x) / 2
41
+ const centerY = (start.y + end.y) / 2
42
+ const angle = Math.atan2(end.y - start.y, end.x - start.x)
43
+ const cosAngle = Math.cos(angle)
44
+ const sinAngle = Math.sin(angle)
45
+ const halfLength = segmentLength / 2
46
+ const halfWidth = width / 2
47
+
48
+ return [
49
+ { x: -halfLength, y: -halfWidth },
50
+ { x: halfLength, y: -halfWidth },
51
+ { x: halfLength, y: halfWidth },
52
+ { x: -halfLength, y: halfWidth },
53
+ ].map((point) => ({
54
+ x: centerX + point.x * cosAngle - point.y * sinAngle,
55
+ y: centerY + point.x * sinAngle + point.y * cosAngle,
56
+ }))
57
+ }
@@ -0,0 +1,126 @@
1
+ import type { Point } from "@tscircuit/math-utils"
2
+ import type { SimplePolygon } from "manifold-3d"
3
+
4
+ export const MANIFOLD_GEOMETRY_SCALE = 1_000_000
5
+
6
+ export type PolygonRing = Point[]
7
+ export type ScaledPolygons = SimplePolygon[]
8
+
9
+ export const signedArea = (ring: PolygonRing) => {
10
+ let area = 0
11
+ for (let i = 0; i < ring.length; i++) {
12
+ const current = ring[i]!
13
+ const next = ring[(i + 1) % ring.length]!
14
+ area += current.x * next.y - next.x * current.y
15
+ }
16
+ return area / 2
17
+ }
18
+
19
+ export const describeScaledPolygons = (polygons: ScaledPolygons) => {
20
+ let pointCount = 0
21
+ const bbox = {
22
+ minX: Number.POSITIVE_INFINITY,
23
+ minY: Number.POSITIVE_INFINITY,
24
+ maxX: Number.NEGATIVE_INFINITY,
25
+ maxY: Number.NEGATIVE_INFINITY,
26
+ }
27
+
28
+ for (const polygon of polygons) {
29
+ pointCount += polygon.length
30
+ for (const [x, y] of polygon) {
31
+ bbox.minX = Math.min(bbox.minX, x)
32
+ bbox.minY = Math.min(bbox.minY, y)
33
+ bbox.maxX = Math.max(bbox.maxX, x)
34
+ bbox.maxY = Math.max(bbox.maxY, y)
35
+ }
36
+ }
37
+
38
+ return {
39
+ polygonCount: polygons.length,
40
+ pointCount,
41
+ bbox: pointCount > 0 ? bbox : null,
42
+ scale: MANIFOLD_GEOMETRY_SCALE,
43
+ }
44
+ }
45
+
46
+ const assertFinitePoint = (point: Point, operation: string) => {
47
+ if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
48
+ throw new Error(
49
+ `${operation} received non-finite point (${point.x}, ${point.y})`,
50
+ )
51
+ }
52
+ }
53
+
54
+ const pointsEqual = (a: Point, b: Point) => a.x === b.x && a.y === b.y
55
+
56
+ export const normalizeRing = (
57
+ ring: PolygonRing,
58
+ operation = "normalizeRing",
59
+ ): PolygonRing => {
60
+ const normalized: PolygonRing = []
61
+
62
+ for (const point of ring) {
63
+ assertFinitePoint(point, operation)
64
+ const roundedPoint = {
65
+ x:
66
+ Math.round(point.x * MANIFOLD_GEOMETRY_SCALE) / MANIFOLD_GEOMETRY_SCALE,
67
+ y:
68
+ Math.round(point.y * MANIFOLD_GEOMETRY_SCALE) / MANIFOLD_GEOMETRY_SCALE,
69
+ }
70
+ const previous = normalized[normalized.length - 1]
71
+ if (!previous || !pointsEqual(previous, roundedPoint)) {
72
+ normalized.push(roundedPoint)
73
+ }
74
+ }
75
+
76
+ if (
77
+ normalized.length > 1 &&
78
+ pointsEqual(normalized[0]!, normalized[normalized.length - 1]!)
79
+ ) {
80
+ normalized.pop()
81
+ }
82
+
83
+ const uniquePoints = new Set(normalized.map((p) => `${p.x},${p.y}`))
84
+ if (uniquePoints.size < 3 || Math.abs(signedArea(normalized)) < 1e-18) {
85
+ return []
86
+ }
87
+
88
+ return normalized
89
+ }
90
+
91
+ export const toScaledManifoldPolygons = (
92
+ polygons: PolygonRing[],
93
+ operation = "toScaledManifoldPolygons",
94
+ ): ScaledPolygons => {
95
+ const scaledPolygons: ScaledPolygons = []
96
+
97
+ for (const polygon of polygons) {
98
+ const normalized = normalizeRing(polygon, operation)
99
+ if (normalized.length < 3) continue
100
+ const positiveRing =
101
+ signedArea(normalized) < 0 ? [...normalized].reverse() : normalized
102
+ scaledPolygons.push(
103
+ positiveRing.map((p) => [
104
+ Math.round(p.x * MANIFOLD_GEOMETRY_SCALE),
105
+ Math.round(p.y * MANIFOLD_GEOMETRY_SCALE),
106
+ ]),
107
+ )
108
+ }
109
+
110
+ return scaledPolygons
111
+ }
112
+
113
+ export const fromScaledManifoldPolygons = (
114
+ polygons: SimplePolygon[],
115
+ ): PolygonRing[] =>
116
+ polygons
117
+ .map((polygon) =>
118
+ normalizeRing(
119
+ polygon.map(([x, y]) => ({
120
+ x: x / MANIFOLD_GEOMETRY_SCALE,
121
+ y: y / MANIFOLD_GEOMETRY_SCALE,
122
+ })),
123
+ "fromScaledManifoldPolygons",
124
+ ),
125
+ )
126
+ .filter((polygon) => polygon.length >= 3)