@tscircuit/copper-pour-solver 0.0.26 → 0.0.28
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.d.ts +3 -1
- package/dist/index.js +347 -205
- package/lib/index.ts +1 -0
- package/lib/solvers/CopperPourPipelineSolver.ts +20 -24
- package/lib/solvers/copper-pour/generate-brep.ts +35 -51
- package/lib/solvers/copper-pour/get-board-polygon.ts +10 -19
- package/lib/solvers/copper-pour/manifold-geometry-adapter.ts +159 -0
- package/lib/solvers/copper-pour/manifold-runtime.ts +51 -0
- package/lib/solvers/copper-pour/polygon-primitives.ts +57 -0
- package/lib/solvers/copper-pour/polygon-ring.ts +126 -0
- package/lib/solvers/copper-pour/process-obstacles.ts +48 -143
- 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/fixtures/preload.ts +3 -0
- 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,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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
|
2
|
+
import { normalizeRing, type PolygonRing } from "./polygon-ring"
|
|
3
3
|
|
|
4
|
-
export const getBoardPolygon = (region: InputPourRegion):
|
|
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
|
-
|
|
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
|
|
20
|
+
return []
|
|
28
21
|
}
|
|
29
22
|
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|