@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.
@@ -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"])
@@ -0,0 +1,4 @@
1
+ gmsh==4.13.1
2
+ numpy==2.3.5
3
+ shapely==2.1.2
4
+ vtk==9.3.1