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

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# 

24 

25""" 

26Resolved PEX25D scene → disjoint 2.5D prisms, one body per net and per dielectric. 

27 

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. 

35 

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. 

40 

41Coordinates stay in integer grid units; :attr:`SceneSolids.grid_um` converts. 

42""" 

43 

44from __future__ import annotations 

45 

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 

50 

51import klayout.db as kdb 

52 

53from klayout_pex import pex25d 

54from klayout_pex.plugin_api.v1 import ExportError 

55 

56if TYPE_CHECKING: 

57 from klayout_pex_protobuf.kpex.pex25d.pex25d_scene_pb2 import PEX25DScene 

58 

59 

60class BodyKind(Enum): 

61 CONDUCTOR = 'conductor' 

62 DIELECTRIC = 'dielectric' 

63 

64 

65@dataclass(frozen=True) 

66class Body: 

67 """One net (or floating conductor, or the ground plane), or one dielectric.""" 

68 

69 name: str 

70 kind: BodyKind 

71 permittivity: Optional[float] = None 

72 """Relative permittivity; dielectrics only.""" 

73 

74 

75@dataclass 

76class Prism: 

77 """A region of one body extruded over [zlow, zhigh], in grid units.""" 

78 

79 body: Body 

80 region: kdb.Region 

81 zlow: int 

82 zhigh: int 

83 

84 

85@dataclass 

86class SceneSolids: 

87 """The domain split into disjoint prisms; their union is ``field`` × [zlow, zhigh].""" 

88 

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.""" 

95 

96 prisms: List[Prism] = dataclass_field(default_factory=list) 

97 

98 conductor_thickness: Optional[int] = None 

99 """The thinnest layer that carries conductor geometry, the ground plane aside.""" 

100 

101 def um(self, grid_units: int) -> float: 

102 return grid_units * self.grid_um 

103 

104 

105Claim = Tuple[kdb.Region, int, int] 

106 

107 

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] 

118 

119 

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() 

123 

124 

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)) 

133 

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]] = {} 

138 

139 # ---------------------------------------------------------------- field 

140 

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) 

158 

159 @property 

160 def field_region(self) -> kdb.Region: 

161 return kdb.Region(self.field) 

162 

163 # --------------------------------------------------------------- claims 

164 

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)])) 

172 

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)])) 

179 

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 

189 

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()] 

207 

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 

222 

223 # ------------------------------------------------------------ profiles 

224 

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] 

230 

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)] 

236 

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)] 

240 

241 dielectric = self.dielectrics.get(name) 

242 if dielectric is None: 

243 raise ExportError(f"'{name}' is not a profile of the scene") 

244 

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") 

253 

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 

267 

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 

275 

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() 

284 

285 # ----------------------------------------------------------- occupancy 

286 

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 

316 

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 

327 

328 

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()