Coverage for plugins/klayout-pex-plugin-gmsh/src/klayout_pex_plugins/gmsh/solids.py: 94%
193 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 13:14 +0000
1#
2# --------------------------------------------------------------------------------
3# SPDX-FileCopyrightText: 2026 Martin Jan Köhler and Harald Pretl
4# Johannes Kepler University, Institute for Integrated Circuits.
5#
6# This file is part of klayout-pex-plugin-gmsh
7# (see https://github.com/iic-jku/klayout-pex-plugin-gmsh).
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <http://www.gnu.org/licenses/>.
21# SPDX-License-Identifier: GPL-3.0-or-later
22# --------------------------------------------------------------------------------
23#
25"""
26Resolved PEX25D scene → disjoint 2.5D prisms, one body per net and per dielectric.
28Every PEX25D solid is a 2D region extruded over a z range, so the whole scene is a
29set of prisms. Where several solids claim a point, the occupancy rule decides:
30conductors and the ground plane first, then the dielectrics by ascending wrap
31depth (the order a resolved scene lists them in), the background last. Slicing
32the domain at every z where a solid starts or ends makes that rule a sequence of
332D booleans per slab. The result covers the domain exactly once, which is what a
34volume mesher needs.
36Conformal films grow incrementally along their WRAPS chain, as the format
37specifies: a film's solid is the solid it wraps, grown sideways and upward, plus
38a slab over the field where the wrapped solid is absent. A film that wraps a film
39covering the field therefore lies on top of it there.
41Coordinates stay in integer grid units; :attr:`SceneSolids.grid_um` converts.
42"""
44from __future__ import annotations
46from dataclasses import dataclass, field as dataclass_field
47from enum import Enum
48from fractions import Fraction
49from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
51import klayout.db as kdb
53from klayout_pex import pex25d
54from klayout_pex.plugin_api.v1 import ExportError
56if TYPE_CHECKING:
57 from klayout_pex_protobuf.kpex.pex25d.pex25d_scene_pb2 import PEX25DScene
60class BodyKind(Enum):
61 CONDUCTOR = 'conductor'
62 DIELECTRIC = 'dielectric'
65@dataclass(frozen=True)
66class Body:
67 """One net (or floating conductor, or the ground plane), or one dielectric."""
69 name: str
70 kind: BodyKind
71 permittivity: Optional[float] = None
72 """Relative permittivity; dielectrics only."""
75@dataclass
76class Prism:
77 """A region of one body extruded over [zlow, zhigh], in grid units."""
79 body: Body
80 region: kdb.Region
81 zlow: int
82 zhigh: int
85@dataclass
86class SceneSolids:
87 """The domain split into disjoint prisms; their union is ``field`` × [zlow, zhigh]."""
89 grid_um: float
90 field: kdb.Box
91 zlow: int
92 zhigh: int
93 bodies: List[Body]
94 """Conductors first, then dielectrics in occupancy order, the background last."""
96 prisms: List[Prism] = dataclass_field(default_factory=list)
98 conductor_thickness: Optional[int] = None
99 """The thinnest layer that carries conductor geometry, the ground plane aside."""
101 def um(self, grid_units: int) -> float:
102 return grid_units * self.grid_um
105Claim = Tuple[kdb.Region, int, int]
108def length_unit_um(units) -> Fraction:
109 unit = pex25d.proto.units.Units
110 factors = {
111 unit.LENGTH_UNIT_UM: Fraction(1),
112 unit.LENGTH_UNIT_NM: Fraction(1, 1000),
113 unit.LENGTH_UNIT_M: Fraction(1_000_000),
114 }
115 if units.length not in factors:
116 raise ExportError("The scene declares no length unit")
117 return factors[units.length]
120def scene_solids(scene: PEX25DScene, field_margin_um: float) -> SceneSolids:
121 """Split the domain of ``scene`` into disjoint prisms by the occupancy rule."""
122 return _SolidsBuilder(scene, field_margin_um).build()
125class _SolidsBuilder:
126 def __init__(self, scene: PEX25DScene, field_margin_um: float):
127 self.scene = scene
128 if not scene.units.grid_denominator:
129 raise ExportError("The scene declares no grid")
130 grid = Fraction(scene.units.grid_numerator, scene.units.grid_denominator)
131 self.grid_um = float(grid * length_unit_um(scene.units))
132 self.field_margin = int(round(field_margin_um / self.grid_um))
134 self.layers = {layer.name: layer for layer in scene.layers}
135 self.dielectrics = {d.name: d for d in scene.dielectrics}
136 self.field = self._field_box()
137 self._solids: Dict[str, List[Claim]] = {}
139 # ---------------------------------------------------------------- field
141 def _field_box(self) -> kdb.Box:
142 """
143 The lateral domain: the scene's domain box, or the finite geometry grown by
144 the field margin, as the FasterCap exporter does.
145 """
146 domain = self.scene.domain
147 if self.scene.HasField('domain') and domain.origin != domain.ORIGIN_UNSPECIFIED:
148 box = domain.box
149 return kdb.Box(box.lower_left.x, box.lower_left.y,
150 box.upper_right.x, box.upper_right.y)
151 bounds = domain.geometry_bounds
152 box = kdb.Box(bounds.lower_left.x, bounds.lower_left.y,
153 bounds.upper_right.x, bounds.upper_right.y)
154 if box.empty() or box.width() == 0 or box.height() == 0:
155 raise ExportError("The scene has neither a domain nor finite geometry, so "
156 "there is nothing to bound the unbounded materials with")
157 return box.enlarged(self.field_margin, self.field_margin)
159 @property
160 def field_region(self) -> kdb.Region:
161 return kdb.Region(self.field)
163 # --------------------------------------------------------------- claims
165 def build(self) -> SceneSolids:
166 claims: List[Tuple[Body, List[Claim]]] = []
167 claims.extend(self._conductor_claims())
168 for dielectric in self.scene.dielectrics:
169 body = Body(dielectric.name, BodyKind.DIELECTRIC, dielectric.permittivity)
170 claims.append((body, [(region & self.field_region, zlow, zhigh)
171 for region, zlow, zhigh in self._solid(dielectric.name)]))
173 zlow, zhigh = self._z_extent(claims)
174 if not self.scene.HasField('background'):
175 raise ExportError("The scene declares no background dielectric")
176 background = self.scene.background
177 claims.append((Body(background.name, BodyKind.DIELECTRIC, background.permittivity),
178 [(self.field_region, zlow, zhigh)]))
180 solids = SceneSolids(grid_um=self.grid_um, field=self.field, zlow=zlow, zhigh=zhigh,
181 bodies=[body for body, _ in claims])
182 thicknesses = [self.layers[conductor_region.layer].zhigh
183 - self.layers[conductor_region.layer].zlow
184 for conductor in self.scene.conductors
185 for conductor_region in conductor.regions]
186 solids.conductor_thickness = min(thicknesses, default=None)
187 solids.prisms = self._disjoint_prisms(claims, zlow, zhigh)
188 return solids
190 def _conductor_claims(self) -> List[Tuple[Body, List[Claim]]]:
191 by_name: Dict[str, List[Claim]] = {}
192 if self.scene.HasField('ground_plane'):
193 plane = self.scene.ground_plane
194 by_name[plane.name] = [(self.field_region, plane.zlow, plane.zhigh)]
195 for conductor in self.scene.conductors:
196 # A FLOATING conductor belongs to no net and is its own body.
197 name = conductor.name if conductor.floating else conductor.net
198 for conductor_region in conductor.regions:
199 layer = self.layers.get(conductor_region.layer)
200 if layer is None:
201 raise ExportError(f"Conductor '{conductor.name}' has geometry on "
202 f"'{conductor_region.layer}', which the scene "
203 f"does not declare")
204 by_name.setdefault(name, []).append(
205 (region_of(conductor_region), layer.zlow, layer.zhigh))
206 return [(Body(name, BodyKind.CONDUCTOR), claims) for name, claims in by_name.items()]
208 def _z_extent(self, claims: List[Tuple[Body, List[Claim]]]) -> Tuple[int, int]:
209 """
210 The whole stack, extended upward to the domain's top, or by the field margin
211 when the scene has no domain, so that the background lies above the stack.
212 """
213 zs = [z for _, body_claims in claims for _, zlow, zhigh in body_claims
214 for z in (zlow, zhigh)]
215 if not zs:
216 raise ExportError("The scene has no solids")
217 zlow, zhigh = min(zs), max(zs)
218 domain = self.scene.domain
219 if self.scene.HasField('domain') and domain.origin != domain.ORIGIN_UNSPECIFIED:
220 return zlow, max(zhigh, domain.box.upper_right.z)
221 return zlow, zhigh + self.field_margin
223 # ------------------------------------------------------------ profiles
225 def _solid(self, name: str) -> List[Claim]:
226 """The solid of a profile as overlapping prisms, before occupancy."""
227 if name not in self._solids:
228 self._solids[name] = self._compute_solid(name)
229 return self._solids[name]
231 def _compute_solid(self, name: str) -> List[Claim]:
232 if name in self.layers:
233 layer = self.layers[name]
234 shapes = self._shapes_of_layer(name)
235 return [] if shapes.is_empty() else [(shapes, layer.zlow, layer.zhigh)]
237 plane = self.scene.ground_plane
238 if self.scene.HasField('ground_plane') and name == plane.name:
239 return [(self.field_region, plane.zlow, plane.zhigh)]
241 dielectric = self.dielectrics.get(name)
242 if dielectric is None:
243 raise ExportError(f"'{name}' is not a profile of the scene")
245 kinds = pex25d.proto.dielectric
246 if dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE:
247 # A band spans the whole plane; the films it encloses win by depth.
248 if dielectric.zhigh <= dielectric.zlow:
249 return []
250 return [(self.field_region, dielectric.zlow, dielectric.zhigh)]
251 if dielectric.kind != kinds.DIELECTRIC_KIND_CONFORMAL:
252 raise ExportError(f"Dielectric '{name}' has no kind")
254 wrapped = self._solid(dielectric.wraps)
255 lateral = dielectric.thickness_beside_wrapped
256 solid = [(region.sized(lateral) if lateral else region, zlow,
257 zhigh + dielectric.thickness_over_wrapped)
258 for region, zlow, zhigh in wrapped]
259 if dielectric.thickness_on_field > 0:
260 footprint = kdb.Region()
261 for region, _, _ in wrapped:
262 footprint += region
263 bottom = self._bottom(dielectric.wraps)
264 solid.append((self.field_region - footprint, bottom,
265 bottom + dielectric.thickness_on_field))
266 return solid
268 def _bottom(self, name: str) -> int:
269 """The bottom face of a profile, which a film on the field is measured from."""
270 if name in self.layers:
271 return self.layers[name].zlow
272 if name in self.dielectrics:
273 return self.dielectrics[name].zlow
274 return self.scene.ground_plane.zlow
276 def _shapes_of_layer(self, layer_name: str) -> kdb.Region:
277 """Every conductor's geometry on one layer, unioned across nets."""
278 region = kdb.Region()
279 for conductor in self.scene.conductors:
280 for conductor_region in conductor.regions:
281 if conductor_region.layer == layer_name:
282 region += region_of(conductor_region)
283 return region.merged()
285 # ----------------------------------------------------------- occupancy
287 def _disjoint_prisms(self, claims: List[Tuple[Body, List[Claim]]],
288 zlow: int, zhigh: int) -> List[Prism]:
289 """
290 Apply the occupancy rule slab by slab, then merge each body's slabs back
291 into taller prisms wherever its region does not change.
292 """
293 zs = sorted({zlow, zhigh} | {z for _, body_claims in claims
294 for _, z0, z1 in body_claims if z0 < z1
295 for z in (z0, z1) if zlow < z < zhigh})
296 open_runs: Dict[Body, Prism] = {}
297 prisms: List[Prism] = []
298 for z0, z1 in zip(zs, zs[1:]):
299 claimed = kdb.Region()
300 conductors = kdb.Region()
301 for body, body_claims in claims:
302 region = kdb.Region()
303 for claim_region, claim_zlow, claim_zhigh in body_claims:
304 if claim_zlow <= z0 and claim_zhigh >= z1:
305 region += claim_region
306 region.merge()
307 if body.kind == BodyKind.CONDUCTOR:
308 short = region & conductors
309 if not short.is_empty():
310 raise ExportError(
311 f"Conductor '{body.name}' overlaps another net "
312 f"at z {z0}…{z1} (grid units)")
313 conductors += region
314 region -= claimed
315 claimed += region
317 run = open_runs.get(body)
318 if run is not None and run.zhigh == z0 and (run.region ^ region).is_empty():
319 run.zhigh = z1
320 continue
321 if run is not None:
322 prisms.append(open_runs.pop(body))
323 if not region.is_empty():
324 open_runs[body] = Prism(body, region, z0, z1)
325 prisms.extend(open_runs.values())
326 return prisms
329def region_of(conductor_region) -> kdb.Region:
330 """Build a region from one conductor's shapes on one layer, in grid units."""
331 region = kdb.Region()
332 for box in conductor_region.boxes:
333 region.insert(kdb.Box(box.lower_left.x, box.lower_left.y,
334 box.upper_right.x, box.upper_right.y))
335 for polygon in conductor_region.polygons:
336 shape = kdb.Polygon([kdb.Point(p.x, p.y) for p in polygon.outer.points])
337 for hole in polygon.holes:
338 shape.insert_hole([kdb.Point(p.x, p.y) for p in hole.points])
339 region.insert(shape)
340 return region.merged()