@tscircuit/cli 0.1.2287 → 0.1.2288
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/cli/main.js +3950 -7
- package/dist/cli/python/flux.py +99 -0
- package/dist/cli/python/mesh.py +485 -0
- package/dist/cli/python/mesh_multilayer.py +376 -0
- package/dist/cli/python/requirements.txt +4 -0
- package/dist/cli/python/sample.py +352 -0
- package/dist/cli/python/surface_sample.py +88 -0
- package/dist/lib/index.js +10 -6
- package/package.json +11 -7
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Layered copper, dielectric, and plated-hole geometry for the Palace adapter.
|
|
2
|
+
This builds physical geometry only; Palace solves Maxwell's equations.
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
import gmsh
|
|
6
|
+
from shapely.geometry import Polygon, LineString
|
|
7
|
+
from shapely.ops import unary_union
|
|
8
|
+
from shapely import set_precision
|
|
9
|
+
from shapely.geometry import Point
|
|
10
|
+
from shapely.strtree import STRtree
|
|
11
|
+
from mesh import points, add_polygon, polygons
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def clean(shape):
|
|
15
|
+
return set_precision(shape, grid_size=1e-6)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def nonempty_polygons(shape):
|
|
19
|
+
if shape.is_empty:
|
|
20
|
+
return []
|
|
21
|
+
return [p for p in polygons(shape) if p.area > 1e-12]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def prism(options):
|
|
25
|
+
shape, z_min, z_max = [options[k] for k in ["shape", "zMin", "zMax"]]
|
|
26
|
+
return [entity for polygon in nonempty_polygons(clean(shape))
|
|
27
|
+
for entity in gmsh.model.occ.extrude([add_polygon(polygon, z_min)], 0, 0, z_max - z_min)
|
|
28
|
+
if entity[0] == 3]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_layer_copper(model, trace_caps="round"):
|
|
32
|
+
layered = model["multilayer"]
|
|
33
|
+
board = Polygon(points(model["geometry"]["boardOutline"]))
|
|
34
|
+
for cutout in layered["boardCutouts"]:
|
|
35
|
+
board = board.difference(Polygon(points(cutout)))
|
|
36
|
+
if not board.is_valid:
|
|
37
|
+
raise ValueError("Invalid board outline/cutout")
|
|
38
|
+
barrel_shapes = [Polygon(points(barrel["hole"])).buffer(barrel["platingThickness"], join_style="mitre")
|
|
39
|
+
for barrel in layered["barrels"]]
|
|
40
|
+
barrel_tree = STRtree(barrel_shapes)
|
|
41
|
+
for index, barrel in enumerate(layered["barrels"]):
|
|
42
|
+
outer = barrel_shapes[index]
|
|
43
|
+
for other_index in barrel_tree.query(outer, predicate="intersects"):
|
|
44
|
+
if other_index <= index:
|
|
45
|
+
continue
|
|
46
|
+
other = layered["barrels"][other_index]
|
|
47
|
+
if barrel["netId"] == other["netId"] or min(barrel["zMax"], other["zMax"]) <= max(barrel["zMin"], other["zMin"]):
|
|
48
|
+
continue
|
|
49
|
+
other_outer = barrel_shapes[other_index]
|
|
50
|
+
if outer.intersection(other_outer).area > 1e-10:
|
|
51
|
+
raise ValueError("Different-net plated barrels overlap")
|
|
52
|
+
copper = {}
|
|
53
|
+
for foil in layered["stackup"]["copperLayers"]:
|
|
54
|
+
groups = {}
|
|
55
|
+
for group in layered["copper"]:
|
|
56
|
+
if group["layer"] != foil["name"]:
|
|
57
|
+
continue
|
|
58
|
+
regions, planes = [], []
|
|
59
|
+
for region in group["regions"]:
|
|
60
|
+
shape = Polygon(points(region["outer"]), [points(h) for h in region["holes"]])
|
|
61
|
+
if not shape.is_valid:
|
|
62
|
+
raise ValueError(f"Invalid copper polygon on {foil['name']} for {group['netId']}")
|
|
63
|
+
(planes if region.get("isPlane") else regions).append(shape)
|
|
64
|
+
strips = [LineString([points([s["start"]])[0], points([s["end"]])[0]])
|
|
65
|
+
.buffer(s["width"] / 2, quad_segs=8, cap_style=trace_caps, join_style="round")
|
|
66
|
+
for s in group["segments"]]
|
|
67
|
+
plane = unary_union(planes)
|
|
68
|
+
if not plane.is_empty:
|
|
69
|
+
clearances = unary_union([Polygon(points(barrel["clearance"])) for barrel in layered["barrels"]
|
|
70
|
+
if barrel["zMin"] <= foil["zMin"] and barrel["zMax"] >= foil["zMax"]
|
|
71
|
+
and group["netId"] != barrel["netId"]])
|
|
72
|
+
plane = plane.difference(clearances)
|
|
73
|
+
groups[group["netId"]] = unary_union([*regions, *strips, plane])
|
|
74
|
+
for barrel in layered["barrels"]:
|
|
75
|
+
if barrel["zMin"] <= foil["zMin"] and barrel["zMax"] >= foil["zMax"]:
|
|
76
|
+
hole = Polygon(points(barrel["hole"]))
|
|
77
|
+
footprint = hole.buffer(barrel["platingThickness"], join_style="mitre").difference(hole)
|
|
78
|
+
if foil["name"] in barrel["layers"]:
|
|
79
|
+
footprint = unary_union([footprint, Polygon(points(barrel["pads"]))])
|
|
80
|
+
groups[barrel["netId"]] = unary_union([groups.get(barrel["netId"], Polygon()), footprint])
|
|
81
|
+
drilled = unary_union([Polygon(points(drill["hole"])) for drill in layered["drills"]
|
|
82
|
+
if drill["zMin"] <= foil["zMin"] and drill["zMax"] >= foil["zMax"]])
|
|
83
|
+
for net_id, shape in list(groups.items()):
|
|
84
|
+
shape = shape.difference(drilled)
|
|
85
|
+
if not shape.is_empty and not board.buffer(2e-6).covers(shape):
|
|
86
|
+
raise ValueError(f"Copper for {net_id} on {foil['name']} leaves the PCB")
|
|
87
|
+
groups[net_id] = shape.intersection(board)
|
|
88
|
+
copper[foil["name"]] = groups
|
|
89
|
+
return clean(board), {layer: {net: clean(shape) for net, shape in groups.items()} for layer, groups in copper.items()}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def copper_overlaps(copper):
|
|
93
|
+
"""Exact polygon intersections; the spatial index only filters candidates."""
|
|
94
|
+
overlaps = []
|
|
95
|
+
for layer, groups in copper.items():
|
|
96
|
+
names = sorted(groups)
|
|
97
|
+
shapes = [groups[name] for name in names]
|
|
98
|
+
tree = STRtree(shapes)
|
|
99
|
+
for index, shape in enumerate(shapes):
|
|
100
|
+
for other_index in sorted(tree.query(shape, predicate="intersects")):
|
|
101
|
+
if other_index <= index:
|
|
102
|
+
continue
|
|
103
|
+
overlap = shape.intersection(shapes[other_index])
|
|
104
|
+
if overlap.area > 1e-10:
|
|
105
|
+
overlaps.append({"layer": layer, "nets": [names[index], names[other_index]],
|
|
106
|
+
"areaMm2": overlap.area, "boundsMm": list(overlap.bounds)})
|
|
107
|
+
return overlaps
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def layer_copper(model):
|
|
111
|
+
board, copper = build_layer_copper(model)
|
|
112
|
+
overlaps = copper_overlaps(copper)
|
|
113
|
+
if overlaps:
|
|
114
|
+
first = overlaps[0]
|
|
115
|
+
raise ValueError(f"Different nets overlap on {first['layer']}: {', '.join(first['nets'])}; area={first['areaMm2']:.9g} mm², bounds={tuple(first['boundsMm'])}")
|
|
116
|
+
return board, copper
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def validate_reference_connectivity(model, copper):
|
|
121
|
+
layered = model["multilayer"]
|
|
122
|
+
reference_net = layered["referenceNetId"]
|
|
123
|
+
nodes = []
|
|
124
|
+
for foil in layered["stackup"]["copperLayers"]:
|
|
125
|
+
for shape in nonempty_polygons(copper[foil["name"]].get(reference_net, Polygon())):
|
|
126
|
+
nodes.append({"shape": shape, "zMin": foil["zMin"], "zMax": foil["zMax"], "layer": foil["name"]})
|
|
127
|
+
for barrel in layered["barrels"]:
|
|
128
|
+
if barrel["netId"] != reference_net:
|
|
129
|
+
continue
|
|
130
|
+
hole = Polygon(points(barrel["hole"]))
|
|
131
|
+
nodes.append({"shape": hole.buffer(barrel["platingThickness"], join_style="mitre").difference(hole),
|
|
132
|
+
"zMin": barrel["zMin"], "zMax": barrel["zMax"], "layer": None})
|
|
133
|
+
adjacency = [set() for _ in nodes]
|
|
134
|
+
for index, node in enumerate(nodes):
|
|
135
|
+
for other_index in range(index + 1, len(nodes)):
|
|
136
|
+
other = nodes[other_index]
|
|
137
|
+
if min(node["zMax"], other["zMax"]) < max(node["zMin"], other["zMin"]) - 1e-8:
|
|
138
|
+
continue
|
|
139
|
+
if node["shape"].buffer(2e-6).intersection(other["shape"]).area > 1e-10:
|
|
140
|
+
adjacency[index].add(other_index)
|
|
141
|
+
adjacency[other_index].add(index)
|
|
142
|
+
connected = None
|
|
143
|
+
for port in model["ports"]:
|
|
144
|
+
contact = Point(port["reference"]["x"], port["reference"]["y"])
|
|
145
|
+
candidates = [index for index, node in enumerate(nodes) if node["layer"] == port["referenceLayer"] and Polygon(node["shape"].exterior).buffer(2e-6).covers(contact)]
|
|
146
|
+
if len(candidates) != 1:
|
|
147
|
+
raise ValueError("Reference terminal must identify one physical copper region")
|
|
148
|
+
reached, pending = set(), candidates.copy()
|
|
149
|
+
while pending:
|
|
150
|
+
index = pending.pop()
|
|
151
|
+
if index in reached:
|
|
152
|
+
continue
|
|
153
|
+
reached.add(index)
|
|
154
|
+
pending.extend(adjacency[index] - reached)
|
|
155
|
+
if not any(nodes[index]["layer"] == layered["sampleLayer"] for index in reached):
|
|
156
|
+
raise ValueError("Reference pad is not physically connected to the sampled reference layer; supply real traces/vias")
|
|
157
|
+
if connected is not None and not reached.intersection(connected):
|
|
158
|
+
raise ValueError("Reference terminals are on disconnected copper islands")
|
|
159
|
+
connected = reached
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def port_surface(options):
|
|
163
|
+
model, port, neighbor, copper, board = [options[k] for k in ["model", "port", "neighbor", "copper", "board"]]
|
|
164
|
+
signal, reference = port["signal"], port["reference"]
|
|
165
|
+
dx, dy = reference["x"] - signal["x"], reference["y"] - signal["y"]
|
|
166
|
+
if abs(dx) + abs(dy) < 1e-9:
|
|
167
|
+
dx, dy = neighbor["x"] - signal["x"], neighbor["y"] - signal["y"]
|
|
168
|
+
length = (dx * dx + dy * dy) ** 0.5
|
|
169
|
+
if length < 1e-9:
|
|
170
|
+
raise ValueError("Port endpoint needs a nonzero neighboring route segment")
|
|
171
|
+
tx, ty = -dy / length * model["portWidth"] / 2, dx / length * model["portWidth"] / 2
|
|
172
|
+
corners = [(signal["x"] - tx, signal["y"] - ty, port["signalZ"]),
|
|
173
|
+
(signal["x"] + tx, signal["y"] + ty, port["signalZ"]),
|
|
174
|
+
(reference["x"] + tx, reference["y"] + ty, port["referenceZ"]),
|
|
175
|
+
(reference["x"] - tx, reference["y"] - ty, port["referenceZ"])]
|
|
176
|
+
signal_shapes = unary_union(list(copper[port["signalLayer"]].values()))
|
|
177
|
+
reference_shapes = copper[port["referenceLayer"]].get(model["multilayer"]["referenceNetId"], Polygon())
|
|
178
|
+
if not signal_shapes.buffer(2e-6).covers(LineString([c[:2] for c in corners[:2]])):
|
|
179
|
+
raise ValueError("Port aperture leaves signal copper")
|
|
180
|
+
coplanar = abs(port["signalZ"] - port["referenceZ"]) < 1e-9
|
|
181
|
+
reference_contact_shapes = unary_union([Polygon(p.exterior) for p in nonempty_polygons(reference_shapes)]) if coplanar else reference_shapes
|
|
182
|
+
if not reference_contact_shapes.buffer(2e-6).covers(LineString([c[:2] for c in corners[2:]])):
|
|
183
|
+
raise ValueError("Port aperture leaves reference copper; specify a physical reference pin")
|
|
184
|
+
if coplanar:
|
|
185
|
+
patch = Polygon([c[:2] for c in corners]).difference(unary_union([signal_shapes, reference_contact_shapes]))
|
|
186
|
+
if patch.geom_type != "Polygon" or patch.is_empty or len(patch.interiors):
|
|
187
|
+
raise ValueError("Coplanar port needs one clear dielectric gap")
|
|
188
|
+
signal_net = options["signalNetId"]
|
|
189
|
+
intended_signal = copper[port["signalLayer"]][signal_net]
|
|
190
|
+
if patch.boundary.intersection(intended_signal.boundary.buffer(2e-6)).length < model["portWidth"] * 0.9 or patch.boundary.intersection(reference_shapes.boundary.buffer(2e-6)).length < model["portWidth"] * 0.9:
|
|
191
|
+
raise ValueError(f"Coplanar port does not contact both intended conductors: signal={patch.boundary.intersection(intended_signal.boundary.buffer(2e-6)).length}, reference={patch.boundary.intersection(reference_shapes.boundary.buffer(2e-6)).length}, width={model['portWidth']}")
|
|
192
|
+
if not board.covers(patch):
|
|
193
|
+
raise ValueError("Port crosses board cutout/edge")
|
|
194
|
+
return add_polygon(patch, port["signalZ"])
|
|
195
|
+
validate_aperture({"model": model, "port": port, "corners": corners, "copper": copper, "board": board})
|
|
196
|
+
vertices = [gmsh.model.occ.addPoint(*c) for c in corners]
|
|
197
|
+
edges = [gmsh.model.occ.addLine(vertices[i], vertices[(i + 1) % 4]) for i in range(4)]
|
|
198
|
+
return (2, gmsh.model.occ.addPlaneSurface([gmsh.model.occ.addCurveLoop(edges)]))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def projected_aperture(options):
|
|
203
|
+
port, z_min, z_max, corners = [options[k] for k in ["port", "zMin", "zMax", "corners"]]
|
|
204
|
+
a = max(z_min, min(port["signalZ"], port["referenceZ"]))
|
|
205
|
+
b = min(z_max, max(port["signalZ"], port["referenceZ"]))
|
|
206
|
+
if b - a <= 1e-8:
|
|
207
|
+
return Polygon()
|
|
208
|
+
coordinates = []
|
|
209
|
+
for z in [a, b]:
|
|
210
|
+
fraction = (z - port["signalZ"]) / (port["referenceZ"] - port["signalZ"])
|
|
211
|
+
for source, target in [(corners[0], corners[3]), (corners[1], corners[2])]:
|
|
212
|
+
coordinates.append((source[0] + fraction * (target[0] - source[0]),
|
|
213
|
+
source[1] + fraction * (target[1] - source[1])))
|
|
214
|
+
from shapely.geometry import MultiPoint
|
|
215
|
+
return MultiPoint(coordinates).convex_hull
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def validate_aperture(options):
|
|
219
|
+
model, port, corners, copper, board = [options[k] for k in ["model", "port", "corners", "copper", "board"]]
|
|
220
|
+
from shapely.geometry import MultiPoint
|
|
221
|
+
footprint = MultiPoint([c[:2] for c in corners]).convex_hull
|
|
222
|
+
if not board.buffer(2e-6).covers(footprint):
|
|
223
|
+
raise ValueError("Port aperture crosses a physical PCB cutout/edge")
|
|
224
|
+
for foil in model["multilayer"]["stackup"]["copperLayers"]:
|
|
225
|
+
patch = projected_aperture({"port": port, "zMin": foil["zMin"], "zMax": foil["zMax"], "corners": corners})
|
|
226
|
+
copper_shape = unary_union(list(copper[foil["name"]].values()))
|
|
227
|
+
intersection = patch.intersection(copper_shape)
|
|
228
|
+
if intersection.area > 1e-10 or (patch.geom_type == "LineString" and intersection.length > 1e-7):
|
|
229
|
+
raise ValueError("Port aperture passes through intermediate copper; select closer explicit reference pins")
|
|
230
|
+
for barrel in model["multilayer"]["barrels"]:
|
|
231
|
+
patch = projected_aperture({"port": port, "zMin": barrel["zMin"], "zMax": barrel["zMax"], "corners": corners})
|
|
232
|
+
hole = Polygon(points(barrel["hole"]))
|
|
233
|
+
annulus = hole.buffer(barrel["platingThickness"], join_style="mitre").difference(hole)
|
|
234
|
+
intersection = patch.intersection(annulus)
|
|
235
|
+
if intersection.area > 1e-10 or (patch.geom_type == "LineString" and intersection.length > 1e-7):
|
|
236
|
+
raise ValueError("Port aperture passes through a plated barrel; select a clear terminal gap")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def generate_multilayer_mesh(model, destination):
|
|
240
|
+
layered = model["multilayer"]
|
|
241
|
+
board, planar_copper = layer_copper(model)
|
|
242
|
+
validate_reference_connectivity(model, planar_copper)
|
|
243
|
+
gmsh.initialize()
|
|
244
|
+
try:
|
|
245
|
+
gmsh.option.setNumber("Geometry.Tolerance", 1e-6)
|
|
246
|
+
gmsh.option.setNumber("Geometry.ToleranceBoolean", 1e-6)
|
|
247
|
+
gmsh.model.add("multilayer-return-current")
|
|
248
|
+
foil_layers = layered["stackup"]["copperLayers"]
|
|
249
|
+
bottom, top = foil_layers[-1], foil_layers[0]
|
|
250
|
+
copper_by_net = {}
|
|
251
|
+
for foil in foil_layers:
|
|
252
|
+
for net_id, shape in planar_copper[foil["name"]].items():
|
|
253
|
+
copper_by_net.setdefault(net_id, []).extend(prism({"shape": shape, "zMin": foil["zMin"], "zMax": foil["zMax"]}))
|
|
254
|
+
for barrel in layered["barrels"]:
|
|
255
|
+
hole = Polygon(points(barrel["hole"]))
|
|
256
|
+
outer = hole.buffer(barrel["platingThickness"], join_style="mitre")
|
|
257
|
+
pads = Polygon(points(barrel["pads"]))
|
|
258
|
+
if not pads.buffer(2e-6).covers(outer) or not board.covers(pads):
|
|
259
|
+
raise ValueError("Plated barrel does not fit its pad/board")
|
|
260
|
+
copper_by_net.setdefault(barrel["netId"], []).extend(prism({"shape": outer.difference(hole), "zMin": barrel["zMin"], "zMax": barrel["zMax"]}))
|
|
261
|
+
copper = []
|
|
262
|
+
for net_id, volumes in copper_by_net.items():
|
|
263
|
+
if len(volumes) > 1:
|
|
264
|
+
volumes, _ = gmsh.model.occ.fuse(volumes[:1], volumes[1:])
|
|
265
|
+
copper.extend(volumes)
|
|
266
|
+
if not copper:
|
|
267
|
+
raise ValueError("Empty copper model")
|
|
268
|
+
# Dielectrics occupy the resin around patterned inner foil too. The
|
|
269
|
+
# fragments occupied by metal are removed from dielectric attributes.
|
|
270
|
+
dielectric_volumes = []
|
|
271
|
+
dielectric_counts = []
|
|
272
|
+
for slab in layered["stackup"]["dielectrics"]:
|
|
273
|
+
z_min = slab["zMin"]
|
|
274
|
+
lower = next((foil for foil in foil_layers if abs(foil["zMax"] - z_min) < 1e-8), None)
|
|
275
|
+
if lower and lower["name"] != "bottom":
|
|
276
|
+
z_min = lower["zMin"]
|
|
277
|
+
shape = board
|
|
278
|
+
# Blind/buried holes remove only their physical depth. Split slabs at
|
|
279
|
+
# drill endpoints so unused layers remain intact.
|
|
280
|
+
boundaries = sorted({z_min, slab["zMax"], *[z for drill in layered["drills"] for z in [drill["zMin"], drill["zMax"]]]})
|
|
281
|
+
volumes = []
|
|
282
|
+
for a, b in zip(boundaries, boundaries[1:]):
|
|
283
|
+
if a < z_min - 1e-9 or b > slab["zMax"] + 1e-9:
|
|
284
|
+
continue
|
|
285
|
+
shape = board
|
|
286
|
+
for drill in layered["drills"]:
|
|
287
|
+
if drill["zMin"] <= a and drill["zMax"] >= b:
|
|
288
|
+
shape = shape.difference(Polygon(points(drill["hole"])))
|
|
289
|
+
volumes.extend(prism({"shape": shape, "zMin": a, "zMax": b}))
|
|
290
|
+
dielectric_volumes.extend(volumes)
|
|
291
|
+
dielectric_counts.append((slab["attribute"], len(volumes)))
|
|
292
|
+
min_x, min_y, max_x, max_y = board.bounds
|
|
293
|
+
padding = model["airPadding"]
|
|
294
|
+
air = (3, gmsh.model.occ.addBox(min_x - padding, min_y - padding, bottom["zMin"] - padding,
|
|
295
|
+
max_x - min_x + 2 * padding, max_y - min_y + 2 * padding,
|
|
296
|
+
top["zMax"] - bottom["zMin"] + 2 * padding))
|
|
297
|
+
legacy = []
|
|
298
|
+
for trace in model["geometry"]["signals"]:
|
|
299
|
+
wires = [p for p in trace["route"] if p["route_type"] == "wire"]
|
|
300
|
+
for endpoint, route in [(wires[0], wires[1:]), (wires[-1], list(reversed(wires[:-1])))]:
|
|
301
|
+
neighbor = next((p for p in route if abs(p["x"] - endpoint["x"]) + abs(p["y"] - endpoint["y"]) > 1e-9), None)
|
|
302
|
+
if not neighbor:
|
|
303
|
+
raise ValueError("Trace needs a nonzero segment for its terminal orientation")
|
|
304
|
+
legacy.append(neighbor)
|
|
305
|
+
port_surfaces = []
|
|
306
|
+
for index, port in enumerate(model["ports"]):
|
|
307
|
+
# Find signal owner by the endpoint's copper. Named PCB pin ownership
|
|
308
|
+
# was already validated by the TS model builder.
|
|
309
|
+
endpoint = port["signal"]
|
|
310
|
+
owners = [net for net, shape in planar_copper[port["signalLayer"]].items()
|
|
311
|
+
if shape.buffer(2e-6).covers(Point(endpoint["x"], endpoint["y"]))]
|
|
312
|
+
if len(owners) != 1 or owners[0] == layered["referenceNetId"]:
|
|
313
|
+
raise ValueError("Signal terminal must contact exactly one non-reference conductor")
|
|
314
|
+
surface = port_surface({"model": model, "port": port, "neighbor": legacy[index],
|
|
315
|
+
"copper": planar_copper, "board": board, "signalNetId": owners[0]})
|
|
316
|
+
port_surfaces.append(surface)
|
|
317
|
+
tools = [*dielectric_volumes, *copper, *port_surfaces]
|
|
318
|
+
_, mapping = gmsh.model.occ.fragment([air], tools)
|
|
319
|
+
gmsh.model.occ.synchronize()
|
|
320
|
+
copper_offset = 1 + len(dielectric_volumes)
|
|
321
|
+
copper_tags = {tag for desc in mapping[copper_offset:copper_offset + len(copper)] for dim, tag in desc if dim == 3}
|
|
322
|
+
gmsh.model.addPhysicalGroup(3, sorted(copper_tags), 3, "copper")
|
|
323
|
+
dielectric_tags = set()
|
|
324
|
+
offset = 1
|
|
325
|
+
for attribute, count in dielectric_counts:
|
|
326
|
+
tags = {tag for desc in mapping[offset:offset + count] for dim, tag in desc if dim == 3} - copper_tags
|
|
327
|
+
if not tags:
|
|
328
|
+
raise ValueError("Empty dielectric slab")
|
|
329
|
+
if tags.intersection(dielectric_tags):
|
|
330
|
+
raise ValueError("Overlapping dielectric slabs")
|
|
331
|
+
gmsh.model.addPhysicalGroup(3, sorted(tags), attribute, f"dielectric-{attribute}")
|
|
332
|
+
dielectric_tags.update(tags)
|
|
333
|
+
offset += count
|
|
334
|
+
air_tags = {tag for dim, tag in mapping[0] if dim == 3} - dielectric_tags - copper_tags
|
|
335
|
+
gmsh.model.addPhysicalGroup(3, sorted(air_tags), 1, "air")
|
|
336
|
+
port_tags = []
|
|
337
|
+
for index, desc in enumerate(mapping[copper_offset + len(copper):]):
|
|
338
|
+
tags = [tag for dim, tag in desc if dim == 2]
|
|
339
|
+
if not tags or set(tags).intersection(port_tags):
|
|
340
|
+
raise ValueError("Lumped ports overlap or have no surface")
|
|
341
|
+
gmsh.model.addPhysicalGroup(2, tags, 21 + index, f"port-{index + 1}")
|
|
342
|
+
port_tags.extend(tags)
|
|
343
|
+
outer = [tag for dim, tag in gmsh.model.getBoundary([(3, tag) for tag in sorted(air_tags | dielectric_tags | copper_tags)], combined=True, oriented=False) if dim == 2]
|
|
344
|
+
gmsh.model.addPhysicalGroup(2, outer, 13, "outer-air")
|
|
345
|
+
# Avoid tagging exposed drilled/board surfaces as absorbing boundaries.
|
|
346
|
+
# The combined boundary is the air enclosure, since voids are filled by air.
|
|
347
|
+
copper_surfaces = [tag for dim, tag in gmsh.model.getBoundary([(3, tag) for tag in copper_tags], combined=False, oriented=False) if dim == 2]
|
|
348
|
+
edges = sorted({tag for dim, tag in gmsh.model.getBoundary([(2, tag) for tag in [*copper_surfaces, *port_tags]], combined=False, oriented=False) if dim == 1})
|
|
349
|
+
distance = gmsh.model.mesh.field.add("Distance")
|
|
350
|
+
gmsh.model.mesh.field.setNumbers(distance, "CurvesList", edges)
|
|
351
|
+
gmsh.model.mesh.field.setNumber(distance, "Sampling", 50)
|
|
352
|
+
threshold = gmsh.model.mesh.field.add("Threshold")
|
|
353
|
+
for key, number in {"InField": distance, "SizeMin": min(model["meshSize"] / 3, model["portWidth"] / 2), "SizeMax": model["meshSize"], "DistMin": 0.05, "DistMax": 0.8}.items():
|
|
354
|
+
gmsh.model.mesh.field.setNumber(threshold, key, number)
|
|
355
|
+
gmsh.model.mesh.field.setAsBackgroundMesh(threshold)
|
|
356
|
+
for key, number in {"Mesh.MeshSizeExtendFromBoundary": 0, "Mesh.MeshSizeFromPoints": 0,
|
|
357
|
+
"Mesh.MeshSizeFromCurvature": 0, "Mesh.Algorithm3D": 1,
|
|
358
|
+
"General.NumThreads": 1, "Mesh.MshFileVersion": 2.2}.items():
|
|
359
|
+
gmsh.option.setNumber(key, number)
|
|
360
|
+
gmsh.model.mesh.generate(3)
|
|
361
|
+
gmsh.write(str(destination / "mesh.msh"))
|
|
362
|
+
tetrahedra = [int(tag) for tags in gmsh.model.mesh.getElements(3)[1] for tag in tags]
|
|
363
|
+
qualities = gmsh.model.mesh.getElementQualities(tetrahedra, "minSICN")
|
|
364
|
+
minimum_quality = float(min(qualities))
|
|
365
|
+
if not minimum_quality > 1e-8:
|
|
366
|
+
raise ValueError(f"Degenerate/inverted tetrahedra: min SICN={minimum_quality}")
|
|
367
|
+
summary = {"minimumSicn": minimum_quality, "geometryPrecisionMm": 1e-6, "nodes": len(gmsh.model.mesh.getNodes()[0]),
|
|
368
|
+
"tetrahedra": sum(len(tags) for tags in gmsh.model.mesh.getElements(3)[1]),
|
|
369
|
+
"copperVolumeMm3": sum(gmsh.model.occ.getMass(3, tag) for tag in copper_tags),
|
|
370
|
+
"dielectricAttributes": [a for a, _ in dielectric_counts],
|
|
371
|
+
"portAttributes": list(range(21, 21 + len(port_surfaces))),
|
|
372
|
+
"sampleLayer": layered["sampleLayer"]}
|
|
373
|
+
(destination / "mesh-summary.json").write_text(json.dumps(summary, indent=2) + "\n")
|
|
374
|
+
finally:
|
|
375
|
+
gmsh.finalize()
|
|
376
|
+
return len(model["ports"])
|