Coverage for klayout_pex/fastercap/fastercap_model_generator.py: 96%

690 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-06 13:14 +0000

1# 

2# -------------------------------------------------------------------------------- 

3# SPDX-FileCopyrightText: 2024-2025 Martin Jan Köhler and Harald Pretl 

4# Johannes Kepler University, Institute for Integrated Circuits. 

5# 

6# This file is part of KPEX  

7# (see https://github.com/iic-jku/klayout-pex). 

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# A class providing a service for building FastCap2 or FasterCap models 

26# 

27# This class is used the following way: 

28# 

29# 1) Create a FasterCapModelBuilder object 

30# Specify the default k value which is the k assumed for "empty space". 

31# You can also specify a maximum area and the "b" parameter for the 

32# triangulation. The b parameter corresponds to the minimum angle 

33# and should be <=1 (b=sin(min_angle)*2). 

34# I.e. b=1 -> min_angle=30 deg, b=0.5 -> min_angle~14.5 deg. 

35# 

36# 2) Add material definitions for the dielectrics 

37# Each material definition consists of a k value and 

38# a material name. 

39# 

40# 3) Add layers in the 2.5d view fashion 

41# Each layer is a sheet in 3d space that is extruded in vertical 

42# direction with the given start and stop z (or height) 

43# The layer must be a DRC::Layer or RBA::Region object. 

44# 

45# Layers can be added in two ways: 

46# 

47# * As conductors: specify the net name 

48# 

49# * As dielectric layer: specify the material name 

50# 

51# The layers can intersect. The package resolves intersections 

52# based on priority: conductors first, dielectrics according to 

53# their position in the "materials" definition (first entries have 

54# higher prio) 

55# 

56# 4) Generate a 3d model using "generate" 

57# This method returns an object you can use to generate STL files 

58# or FastCap files. 

59 

60 

61from __future__ import annotations 

62 

63import base64 

64from collections import defaultdict 

65import hashlib 

66import os 

67from typing import * 

68from dataclasses import dataclass 

69from functools import reduce 

70import math 

71 

72import klayout.db as kdb 

73 

74from ..log import ( 

75 debug, 

76 info, 

77 warning, 

78 error, 

79 subproc 

80) 

81 

82 

83@dataclass 

84class FasterCapModelBuilder: 

85 dbu: float 

86 """Database unit""" 

87 

88 k_void: float 

89 """Default dielectric of 'empty space'""" 

90 

91 delaunay_amax: float 

92 """Maximum area parameter for the Delaunay triangulation""" 

93 

94 delaunay_b: float 

95 """ 

96 The delaunay_b parameter for the Delaunay triangulation  

97 corresponds to the minimum angle 

98 and should be <=1 (b=sin(min_angle)*2). 

99 I.e. b=1 -> min_angle=30 deg, b=0.5 -> min_angle~14.5 deg. 

100 """ 

101 

102 def __init__(self, 

103 dbu: float, 

104 k_void: float, 

105 delaunay_amax: float = 0.0, 

106 delaunay_b: float = 1.0, 

107 ): 

108 self.dbu = dbu 

109 self.k_void = k_void 

110 self.delaunay_amax = delaunay_amax 

111 self.delaunay_b = delaunay_b 

112 

113 self.materials: Dict[str, float] = {} 

114 self.net_names: List[str] = [] 

115 self.group_by_conductor: Dict[str, str] = {} 

116 

117 # layer, zstart, zstop 

118 self.clayers: Dict[str, List[Tuple[kdb.Region, float, float]]] = {} 

119 self.dlayers: Dict[str, List[Tuple[kdb.Region, float, float]]] = {} 

120 

121 info(f"DBU: {'%.12g' % self.dbu}") 

122 info(f"Delaunay b: {'%.12g' % self.delaunay_b}") 

123 info(f"Delaunay area_max: {'%.12g' % self.delaunay_amax}") 

124 

125 def add_material(self, name: str, k: float): 

126 self.materials[name] = k 

127 

128 def add_dielectric(self, 

129 material_name: str, 

130 layer: kdb.Region, 

131 z: float, 

132 height: float): 

133 if hasattr(layer, 'data'): 

134 layer = layer.data 

135 self._add_layer(name=material_name, layer=layer, is_dielectric=True, z=z, height=height) 

136 

137 def add_conductor(self, 

138 net_name: str, 

139 layer: kdb.Region, 

140 z: float, 

141 height: float, 

142 group_name: Optional[str] = None): 

143 """ 

144 :param net_name: the name of the conductor 

145 :param group_name: of the conductors that touch, as they are parts of one net, which have no surfaces 

146 between them (see FasterCapModelGenerator), by default the conductor is on its own 

147 """ 

148 if hasattr(layer, 'data'): 

149 layer = layer.data 

150 self.group_by_conductor[net_name] = group_name or net_name 

151 self._add_layer(name=net_name, layer=layer, is_dielectric=False, z=z, height=height) 

152 

153 def _norm2z(self, z: float) -> float: 

154 return z * self.dbu 

155 

156 def _z2norm(self, z: float) -> float: 

157 return math.floor(z / self.dbu + 1e-6) 

158 

159 def _add_layer(self, 

160 name: str, 

161 layer: kdb.Region, 

162 z: float, 

163 height: float, 

164 is_dielectric: bool): 

165 if is_dielectric and name not in self.materials: 

166 raise ValueError(f"Unknown material {name} - did you use 'add_material'?") 

167 

168 zstart: float = z 

169 zstop: float = zstart + height 

170 

171 if is_dielectric: 

172 if name not in self.dlayers: 

173 self.dlayers[name] = [] 

174 self.dlayers[name].append((layer, self._z2norm(zstart), self._z2norm(zstop))) 

175 else: 

176 if name not in self.clayers: 

177 self.clayers[name] = [] 

178 self.clayers[name].append((layer, self._z2norm(zstart), self._z2norm(zstop))) 

179 

180 def generate(self) -> Optional[FasterCapModelGenerator]: 

181 z: List[float] = [] 

182 for ll in (self.dlayers, self.clayers): 

183 for k, v in ll.items(): 

184 for l in v: 

185 z.extend((l[1], l[2])) 

186 z = sorted([*{*z}]) # sort & uniq 

187 if len(z) == 0: 

188 return None 

189 

190 gen = FasterCapModelGenerator(dbu=self.dbu, 

191 k_void=self.k_void, 

192 delaunay_amax=self.delaunay_amax, 

193 delaunay_b=self.delaunay_b, 

194 materials=self.materials, 

195 net_names=list(self.clayers.keys()), 

196 group_by_conductor=self.group_by_conductor) 

197 for zcurr in z: 

198 gen.next_z(self._norm2z(zcurr)) 

199 

200 for nn, v in self.clayers.items(): 

201 for l in v: 

202 if l[1] <= zcurr < l[2]: 

203 gen.add_in(name=f"+{nn}", layer=l[0]) 

204 if l[1] < zcurr <= l[2]: 

205 gen.add_out(name=f"+{nn}", layer=l[0]) 

206 for mn, v in self.dlayers.items(): 

207 for l in v: 

208 if l[1] <= zcurr < l[2]: 

209 gen.add_in(name=f"-{mn}", layer=l[0]) 

210 if l[1] < zcurr <= l[2]: 

211 gen.add_out(name=f"-{mn}", layer=l[0]) 

212 

213 gen.finish_z() 

214 

215 gen.finalize() 

216 return gen 

217 

218 

219@dataclass(frozen=True) 

220class HDielKey: 

221 outside: Optional[str] 

222 inside: Optional[str] 

223 

224 def __str__(self) -> str: 

225 return f"{self.outside or 'void'} <-> {self.inside or 'void'}" 

226 

227 @property 

228 def topic(self) -> str: 

229 return 'dielectric' 

230 

231 def reversed(self) -> HDielKey: 

232 return HDielKey(self.inside, self.outside) 

233 

234 

235@dataclass(frozen=True) 

236class HCondKey: 

237 net_name: str 

238 outside: Optional[str] 

239 

240 def __str__(self) -> str: 

241 return f"{self.outside or 'void'} <-> {self.net_name}" 

242 

243 @property 

244 def topic(self) -> str: 

245 return 'conductor' 

246 

247 

248@dataclass(frozen=True) 

249class VKey: 

250 kk: HDielKey | HCondKey 

251 p0: kdb.DPoint 

252 de: kdb.DVector 

253 

254 

255@dataclass(frozen=True) 

256class Point: 

257 x: float 

258 y: float 

259 z: float 

260 

261 def __sub__(self, other: Point) -> Point: 

262 return Point(self.x - other.x, self.y - other.y, self.z - other.z) 

263 

264 def sq_length(self) -> float: 

265 return self.x**2 + self.y**2 + self.z**2 

266 

267 def to_fastcap(self) -> str: 

268 return '%.12g %.12g %.12g' % (self.x, self.y, self.z) 

269 

270 

271def vector_product(a: Point, b: Point) -> Point: 

272 vp = Point( 

273 a.y * b.z - a.z * b.y, 

274 a.z * b.x - a.x * b.z, 

275 a.x * b.y - a.y * b.x 

276 ) 

277 return vp 

278 

279 

280def dot_product(a: Point, b: Point) -> float: 

281 dp = a.x * b.x + a.y * b.y + a.z * b.z 

282 return dp 

283 

284 

285@dataclass(frozen=True) 

286class Triangle: 

287 p0: Point 

288 p1: Point 

289 p2: Point 

290 

291 def reversed(self) -> Triangle: 

292 return Triangle(self.p2, self.p1, self.p0) 

293 

294 def outside_reference_point(self) -> Point: 

295 v1 = self.p1 - self.p0 

296 v2 = self.p2 - self.p0 

297 vp = Point(v1.y * v2.z - v1.z * v2.y, 

298 -v1.x * v2.z + v1.z * v2.x, 

299 v1.x * v2.y - v1.y * v2.x) 

300 vp_abs = math.sqrt(vp.x ** 2 + vp.y ** 2 + vp.z ** 2) 

301 rp = Point(self.p0.x + vp.x / vp_abs, 

302 self.p0.y + vp.y / vp_abs, 

303 self.p0.z + vp.z / vp_abs) 

304 return rp 

305 

306 def to_fastcap(self) -> str: 

307 return ' '.join([p.to_fastcap() for p in (self.p0, self.p1, self.p2)]) 

308 

309 def __len__(self): 

310 return 3 

311 

312 def __getitem__(self, i) -> Point: 

313 match i: 

314 case 0: return self.p0 

315 case 1: return self.p1 

316 case 2: return self.p2 

317 case _: raise IndexError("list index out of range") 

318 

319 

320@dataclass(frozen=True) 

321class Edge: 

322 p0: Point 

323 p1: Point 

324 

325 def vector_of_edge(self) -> Point: 

326 return Point( 

327 self.p1.x - self.p0.x, 

328 self.p1.y - self.p0.y, 

329 self.p1.z - self.p0.z 

330 ) 

331 

332 def reversed(self) -> Edge: 

333 return Edge(self.p1, self.p0) 

334 

335 

336@dataclass 

337class FasterCapModelGenerator: 

338 dbu: float 

339 """Database unit""" 

340 

341 k_void: float 

342 """Default dielectric of 'empty space'""" 

343 

344 delaunay_amax: float 

345 """Maximum area parameter for the Delaunay triangulation""" 

346 

347 delaunay_b: float 

348 """ 

349 The delaunay_b parameter for the Delaunay triangulation  

350 corresponds to the minimum angle 

351 and should be <=1 (b=sin(min_angle)*2). 

352 I.e. b=1 -> min_angle=30 deg, b=0.5 -> min_angle~14.5 deg. 

353 """ 

354 

355 materials: Dict[str, float] 

356 """Maps material name to dielectric k""" 

357 

358 net_names: List[str] 

359 

360 group_by_conductor: Dict[str, str] 

361 """The conductors that are parts of a net by the name of the net, the others are on their own""" 

362 

363 def __init__(self, 

364 dbu: float, 

365 k_void: float, 

366 delaunay_amax: float, 

367 delaunay_b: float, 

368 materials: Dict[str, float], 

369 net_names: List[str], 

370 group_by_conductor: Optional[Dict[str, str]] = None): 

371 self.k_void = k_void 

372 self.delaunay_amax = delaunay_amax 

373 self.delaunay_b = delaunay_b 

374 self.dbu = dbu 

375 self.materials = materials 

376 self.net_names = net_names 

377 self.group_by_conductor = group_by_conductor or {} 

378 

379 # NOTE: without surfaces between the conductors of a group, whose coincident panels would be singular. 

380 # The charge they'd have is the one between the parts of a net, which no netlist has 

381 conductors_by_group: Dict[str, List[str]] = defaultdict(list) 

382 for conductor_name, group_name in self.group_by_conductor.items(): 

383 conductors_by_group[group_name].append(conductor_name) 

384 self.conductors_by_group = conductors_by_group 

385 

386 self.z: Optional[float] = None 

387 self.zz: Optional[float] = None 

388 self.layers_in: Dict[str, kdb.Region] = {} 

389 self.layers_out: Dict[str, kdb.Region] = {} 

390 self.state: Dict[str, kdb.Region] = {} 

391 self.current: Dict[str, List[kdb.Region]] = {} 

392 self.diel_data: Dict[HDielKey, List[Triangle]] = {} 

393 self.diel_vdata: Dict[VKey, kdb.Region] = {} 

394 self.cond_data: Dict[HCondKey, List[Triangle]] = {} 

395 self.cond_vdata: Dict[VKey, kdb.Region] = {} 

396 

397 def reset(self): 

398 self.layers_in = {} 

399 self.layers_out = {} 

400 

401 def other_conductors_of_group(self, conductor_name: str) -> List[str]: 

402 group_name = self.group_by_conductor.get(conductor_name, conductor_name) 

403 return [name for name in self.conductors_by_group.get(group_name, ()) if name != conductor_name] 

404 

405 def add_in(self, name: str, layer: kdb.Region): 

406 debug(f"add_in: {name} -> {layer}") 

407 if name not in self.layers_in: 

408 # NOTE: Calling kdb.Region([]) with the empty list enforces, that a flat layer is created. 

409 # The following "+=" packs the polynomials into this flat layer and 

410 # does not copy the hierarchical layer from the LayoutToNetlist database. 

411 self.layers_in[name] = kdb.Region([]) 

412 self.layers_in[name] += layer 

413 

414 def add_out(self, name: str, layer: kdb.Region): 

415 debug(f"add_out: {name} -> {layer}") 

416 if name not in self.layers_out: 

417 # NOTE: Calling kdb.Region([]) with the empty list enforces, that a flat layer is created. 

418 # The following "+=" packs the polynomials into this flat layer and 

419 # does not copy the hierarchical layer from the LayoutToNetlist database. 

420 self.layers_out[name] = kdb.Region([]) 

421 self.layers_out[name] += layer 

422 

423 def finish_z(self): 

424 debug(f"Finishing layer z={self.z}") 

425 

426 din: Dict[str, kdb.Region] = {} 

427 dout: Dict[str, kdb.Region] = {} 

428 all_in = kdb.Region() 

429 all_out = kdb.Region() 

430 all = kdb.Region() 

431 all_cin: Optional[kdb.Region] = None 

432 all_cout: Optional[kdb.Region] = None 

433 

434 for names, prefix in ((self.net_names, '+'), (self.materials.keys(), '-')): 

435 for nn in names: 

436 mk = prefix + nn 

437 

438 # compute merged events 

439 if mk not in self.current: 

440 self.current[mk] = [] 

441 current_before = self.current[mk][0].dup() if len(self.current[mk]) >= 1 else kdb.Region() 

442 lin, lout, current = self._merge_events(pyra=self.current[mk], 

443 lin=self.layers_in.get(mk, None), 

444 lout=self.layers_out.get(mk, None)) 

445 debug(f"Merged events & status for {mk}:") 

446 debug(f" in = {lin}") 

447 debug(f" out = {lout}") 

448 debug(f" state = {current}") 

449 

450 if mk not in self.state: 

451 self.state[mk] = kdb.Region() 

452 

453 # legalize in and out events 

454 lin_org = lin.dup() 

455 lout_org = lout.dup() 

456 lout &= self.state[mk] 

457 lin -= all 

458 lout += current & all_in 

459 lin += current_before & all_out 

460 lin -= lout_org 

461 lout -= lin_org 

462 

463 # tracks the legalized horizontal cuts 

464 self.state[mk] += lin 

465 self.state[mk] -= lout 

466 

467 din[mk] = lin 

468 dout[mk] = lout 

469 

470 debug(f"Legalized events & status for '{mk}':") 

471 debug(f" in = {din[mk]}") 

472 debug(f" out = {dout[mk]}") 

473 debug(f" state = {self.state[mk]}") 

474 

475 all_in += lin 

476 all_out += lout 

477 all += self.state[mk] 

478 

479 if prefix == '+': 

480 all_cin = all_in.dup() 

481 all_cout = all_out.dup() 

482 

483 debug(f"All conductor region in: {all_cin}") 

484 debug(f"All conductor region out: {all_cout}") 

485 

486 # check whether states are separated 

487 a = reduce(lambda x, y: x+y, self.state.values()) 

488 for k, s in self.state.items(): 

489 r: kdb.Region = s - a 

490 if not r.is_empty(): 

491 error(f"State region of {k} ({s}) is not contained entirely " 

492 f"in remaining all state region ({a}) - this means there is an overlap") 

493 a -= s 

494 

495 # Now we have legalized the in and out events 

496 for mni in self.materials.keys(): 

497 lin = din.get(f"-{mni}", None) 

498 if lin: 

499 lin = lin.dup() 

500 lin -= all_cout # handled with the conductor 

501 for mno in self.materials.keys(): 

502 lout = dout.get(f"-{mno}", None) 

503 if lout: 

504 d: kdb.Region = lout & lin 

505 if not d.is_empty(): 

506 self.generate_hdiel(below=mno, above=mni, layer=d) 

507 lin -= lout 

508 if not lin.is_empty(): 

509 self.generate_hdiel(below=None, above=mni, layer=lin) 

510 

511 for mno in self.materials.keys(): 

512 lout = dout.get(f"-{mno}", None) 

513 if lout: 

514 lout = lout.dup() 

515 lout -= all_cin # handled with the conductor 

516 for mni in self.materials.keys(): 

517 lin = din.get(f"-{mni}", None) 

518 if lin: 

519 lout -= lin 

520 if not lout.is_empty(): 

521 self.generate_hdiel(below=mno, above=None, layer=lout) 

522 

523 for nn in self.net_names: 

524 lin = din.get(f"+{nn}", None) 

525 if lin: 

526 lin = lin.dup() 

527 for other in self.other_conductors_of_group(nn): 

528 lout = dout.get(f"+{other}", None) 

529 if lout: 

530 lin -= lout 

531 for mno in self.materials.keys(): 

532 lout = dout.get(f"-{mno}", None) 

533 if lout: 

534 d = lout & lin 

535 if not d.is_empty(): 

536 self.generate_hcond_in(net_name=nn, below=mno, layer=d) 

537 lin -= lout 

538 if not lin.is_empty(): 

539 self.generate_hcond_in(net_name=nn, below=None, layer=lin) 

540 

541 for nn in self.net_names: 

542 lout = dout.get(f"+{nn}", None) 

543 if lout: 

544 lout = lout.dup() 

545 lout -= all_cin # handled with the conductor 

546 for mni in self.materials.keys(): 

547 lin = din.get(f"-{mni}", None) 

548 if lin: 

549 d = lout & lin 

550 if not d.is_empty(): 

551 self.generate_hcond_out(net_name=nn, above=mni, layer=d) 

552 lout -= lin 

553 if not lout.is_empty(): 

554 self.generate_hcond_out(net_name=nn, above=None, layer=lout) 

555 

556 def next_z(self, z: float): 

557 debug(f"Next layer {z}") 

558 

559 self.reset() 

560 

561 if self.z is None: 

562 self.z = z 

563 return 

564 

565 self.zz = z 

566 

567 all_cond = kdb.Region() 

568 for nn in self.net_names: 

569 mk = f"+{nn}" 

570 if mk in self.state: 

571 all_cond += self.state[mk] 

572 all_cond = all_cond.edges() 

573 

574 for i, mni in enumerate(self.materials): 

575 linside = self.state.get(f"-{mni}", None) 

576 if linside: 

577 linside = linside.edges() 

578 linside -= all_cond # handled with the conductor 

579 for o, mno in enumerate(self.materials): 

580 if i != o: 

581 loutside = self.state.get(f"-{mno}", None) 

582 if loutside: 

583 loutside = loutside.edges() 

584 if o > i: 

585 d = loutside & linside 

586 for e in d: 

587 # NOTE: we need to swap points as we started from "outside" 

588 self.generate_vdiel(outside=mno, inside=mni, edge=e.swapped_points()) 

589 linside -= loutside 

590 

591 for e in linside: 

592 self.generate_vdiel(outside=None, inside=mni, edge=e) 

593 

594 for nn in self.net_names: 

595 mk = f"+{nn}" 

596 linside = self.state.get(mk, None) 

597 if linside: 

598 linside = linside.edges() 

599 for other in self.other_conductors_of_group(nn): 

600 other_state = self.state.get(f"+{other}", None) 

601 if other_state: 

602 linside -= other_state.edges() 

603 for mno in self.materials: 

604 loutside = self.state.get(f"-{mno}", None) 

605 if loutside: 

606 loutside = loutside.edges() 

607 d = loutside & linside 

608 for e in d: 

609 # NOTE: we need to swap points as we started from "outside" 

610 self.generate_vcond(net_name=nn, outside=mno, edge=e.swapped_points()) 

611 linside -= loutside 

612 for e in linside: 

613 self.generate_vcond(net_name=nn, outside=None, edge=e) 

614 

615 self.z = z 

616 

617 def generate_hdiel(self, 

618 below: Optional[str], 

619 above: Optional[str], 

620 layer: kdb.Region): 

621 k = HDielKey(below, above) 

622 debug(f"Generating horizontal dielectric surface {k} as {layer}") 

623 if k not in self.diel_data: 

624 self.diel_data[k] = [] 

625 data = self.diel_data[k] 

626 

627 for t in layer.delaunay(self.delaunay_amax / self.dbu ** 2, self.delaunay_b): 

628 # NOTE: normal is facing downwards (to "below") 

629 pl = list(map(lambda pt: Point(pt.x * self.dbu, pt.y * self.dbu, self.z), 

630 t.each_point_hull())) 

631 tri = Triangle(*pl) 

632 data.append(tri) 

633 debug(f" {tri}") 

634 

635 def generate_v_surface(self, 

636 kk: HDielKey | HCondKey, 

637 edge: kdb.Edge) -> Tuple[VKey, kdb.Box]: 

638 debug(f"Generating vertical {kk.topic} surface {kk} with edge {edge}") 

639 

640 el = math.sqrt(edge.sq_length()) 

641 de = kdb.DVector(edge.d().x / el, edge.d().y / el) 

642 ne = kdb.DVector(edge.d().y / el, -edge.d().x / el) 

643 p0 = ne * ne.sprod(kdb.DPoint(edge.p1) - kdb.DPoint()) + kdb.DPoint() 

644 x1 = (edge.p1 - p0).sprod(de) 

645 x2 = (edge.p2 - p0).sprod(de) 

646 

647 key = VKey(kk, p0, de) 

648 surface = kdb.Box(x1, 

649 math.floor(self.z / self.dbu + 0.5), 

650 x2, 

651 math.floor(self.zz / self.dbu + 0.5)) 

652 return key, surface 

653 

654 def generate_vdiel(self, 

655 outside: Optional[str], 

656 inside: Optional[str], 

657 edge: kdb.Edge): 

658 if edge.is_degenerate(): 

659 return 

660 

661 key, surface = self.generate_v_surface(HDielKey(outside, inside), edge) 

662 if key not in self.diel_vdata: 

663 self.diel_vdata[key] = kdb.Region() 

664 

665 self.diel_vdata[key].insert(surface) 

666 

667 def generate_hcond_in(self, 

668 net_name: str, 

669 below: Optional[str], 

670 layer: kdb.Region): 

671 k = HCondKey(net_name, below) 

672 debug(f"Generating horizontal bottom conductor surface {k} as {layer}") 

673 

674 if k not in self.cond_data: 

675 self.cond_data[k] = [] 

676 data = self.cond_data[k] 

677 

678 for t in layer.delaunay(self.delaunay_amax / self.dbu ** 2, self.delaunay_b): 

679 # NOTE: normal is facing downwards (to "below") 

680 pl = list(map(lambda pt: Point(pt.x * self.dbu, pt.y * self.dbu, self.z), 

681 t.each_point_hull())) 

682 tri = Triangle(*pl) 

683 data.append(tri) 

684 debug(f" {tri}") 

685 

686 def generate_hcond_out(self, 

687 net_name: str, 

688 above: Optional[str], 

689 layer: kdb.Region): 

690 k = HCondKey(net_name, above) 

691 debug(f"Generating horizontal top conductor surface {k} as {layer}") 

692 

693 if k not in self.cond_data: 

694 self.cond_data[k] = [] 

695 data = self.cond_data[k] 

696 

697 for t in layer.delaunay(self.delaunay_amax / self.dbu ** 2, self.delaunay_b): 

698 # NOTE: normal is facing downwards (into conductor) 

699 pl = list(map(lambda pt: Point(pt.x * self.dbu, pt.y * self.dbu, self.z), 

700 t.each_point_hull())) 

701 tri = Triangle(*pl) 

702 # now it is facing outside (to "above") 

703 tri = tri.reversed() 

704 data.append(tri) 

705 debug(f" {tri}") 

706 

707 def generate_vcond(self, 

708 net_name: str, 

709 outside: Optional[str], 

710 edge: kdb.Edge): 

711 if edge.is_degenerate(): 

712 return 

713 

714 key, surface = self.generate_v_surface(HCondKey(net_name, outside), edge) 

715 if key not in self.cond_vdata: 

716 self.cond_vdata[key] = kdb.Region() 

717 

718 self.cond_vdata[key].insert(surface) 

719 

720 def triangulate(self, p0: kdb.DPoint, de: kdb.DVector, region: kdb.Region, data: List[Triangle]): 

721 def convert_point(pt: kdb.Point) -> Point: 

722 pxy = (p0 + de * pt.x) * self.dbu 

723 pz = pt.y * self.dbu 

724 return Point(pxy.x, pxy.y, pz) 

725 

726 for t in region.delaunay(self.delaunay_amax / self.dbu ** 2, self.delaunay_b): 

727 # NOTE: normal is facing outwards (to "left") 

728 pl = list(map(convert_point, t.each_point_hull())) 

729 tri = Triangle(*pl) 

730 # now it is facing outside (to "above") 

731 data.append(tri) 

732 debug(f" {tri}") 

733 

734 def finalize(self): 

735 for k, r in self.diel_vdata.items(): 

736 debug(f"Finishing vertical dielectric plane {k.kk} at {k.p0}/{k.de}") 

737 

738 if k.kk not in self.diel_data: 

739 self.diel_data[k.kk] = [] 

740 data = self.diel_data[k.kk] 

741 

742 self.triangulate(p0=k.p0, de=k.de, region=r, data=data) 

743 

744 for k, r in self.cond_vdata.items(): 

745 debug(f"Finishing vertical conductor plane {k.kk} at {k.p0} / {k.de}") 

746 

747 if k.kk not in self.cond_data: 

748 self.cond_data[k.kk] = [] 

749 data = self.cond_data[k.kk] 

750 

751 self.triangulate(p0=k.p0, de=k.de, region=r, data=data) 

752 

753 dk: Dict[HDielKey, List[Triangle]] = {} 

754 

755 for k in self.diel_data.keys(): 

756 kk = k.reversed() 

757 if kk not in dk: 

758 dk[k] = [] 

759 else: 

760 debug(f"Combining dielectric surfaces {kk} with reverse") 

761 

762 for k, v in self.diel_data.items(): 

763 kk = k.reversed() 

764 if kk in dk: 

765 dk[kk] += list(map(lambda t: t.reversed(), v)) 

766 else: 

767 dk[k] += v 

768 

769 self.diel_data = dk 

770 

771 def write_fastcap(self, output_dir_path: str, prefix: str) -> List[str]: 

772 """Return the paths written, with the primary list file first.""" 

773 max_filename_length: Optional[int] = None 

774 try: 

775 max_filename_length = os.pathconf(output_dir_path, 'PC_NAME_MAX') 

776 except AttributeError: 

777 pass # NOTE: windows does not support the os.pathconf attribute 

778 

779 lst_fn = os.path.join(output_dir_path, f"{prefix}.lst") 

780 written: List[str] = [] 

781 file_num = 0 

782 lst_file: List[str] = [f"* k_void={'%.12g' % self.k_void}"] 

783 

784 for k, data in self.diel_data.items(): 

785 if len(data) == 0: 

786 continue 

787 

788 file_num += 1 

789 

790 k_outside = self.materials[k.outside] if k.outside else self.k_void 

791 k_inside = self.materials[k.inside] if k.inside else self.k_void 

792 

793 # lst_file.append(f"* Dielectric interface: outside={outside}, inside={inside}") 

794 

795 fn = f"{prefix}{file_num}_outside={k.outside or '(void)'}_inside={k.inside or '(void)'}.geo" 

796 output_path = os.path.join(output_dir_path, fn) 

797 self._write_fastercap_geo(output_path=output_path, 

798 data=data, 

799 cond_name=None, 

800 cond_number=file_num, 

801 rename_conductor=False) 

802 written.append(output_path) 

803 

804 # NOTE: for now, we compute the reference points for each triangle 

805 # This is a FasterCap feature, reference point in the *.geo file (end of each T line) 

806 rp_s = "0 0 0" 

807 lst_file.append(f"D {fn} {'%.12g' % k_outside} {'%.12g' % k_inside} 0 0 0 {rp_s}") 

808 

809 # 

810 # Feedback from FastFieldSolvers: 

811 # 

812 # - using the '+' trailing statements (conductor collation), 

813 # only the same conductor should be collated 

814 # 

815 # - renaming different conductor numbers ('N' rule line) is not allowed (currently a bug) 

816 # - Example: 1->VDD (1.geo) and 2->VDD (2.geo) is not possible 

817 # - Both conductor *.geo files should have the same number 

818 # - only the last conductor *.geo file should contain the 'N' rule 

819 # 

820 # - reference points 

821 # 

822 cond_data_grouped_by_net = defaultdict(list) 

823 for k, data in self.cond_data.items(): 

824 if len(data) == 0: 

825 continue 

826 cond_data_grouped_by_net[k.net_name].append((k.outside, data)) 

827 

828 cond_num = file_num 

829 

830 for nn, cond_list in cond_data_grouped_by_net.items(): 

831 cond_num += 1 

832 last_cond_index = len(cond_list) - 1 

833 for idx, (outside, data) in enumerate(cond_list): 

834 file_num += 1 

835 k_outside = self.materials[outside] if outside else self.k_void 

836 

837 outside = outside or '(void)' 

838 # lst_file.append(f"* Conductor interface: outside={outside}, net={nn}") 

839 fn = f"{prefix}{file_num}_outside={outside}_net={nn}.geo" 

840 if max_filename_length is not None and len(fn) > max_filename_length: 

841 warning(f"Unusual long net name detected: {nn}") 

842 d = hashlib.md5(nn.encode('utf-8')).digest() 

843 h = base64.urlsafe_b64encode(d).decode('utf-8').rstrip('=') 

844 remaining_len = len(f"{prefix}_{file_num}_outside={outside}_net=.geo") 

845 short_nn = nn[0: (max_filename_length - remaining_len - len(h) - 1)] + f"_{h}" 

846 fn = f"{prefix}{file_num}_outside={outside}_net={short_nn}.geo" 

847 output_path = os.path.join(output_dir_path, fn) 

848 self._write_fastercap_geo(output_path=output_path, 

849 data=data, 

850 cond_number=cond_num, 

851 cond_name=nn, 

852 rename_conductor=(idx == last_cond_index)) 

853 written.append(output_path) 

854 collation_operator = '' if idx == last_cond_index else ' +' 

855 lst_file.append(f"C {fn} {'%.12g' % k_outside} 0 0 0{collation_operator}") 

856 

857 subproc(lst_fn) 

858 with open(lst_fn, 'w', encoding='utf-8') as f: 

859 f.write('\n'.join(lst_file)) 

860 f.write('\n') 

861 

862 return [lst_fn, *written] 

863 

864 @staticmethod 

865 def _write_fastercap_geo(output_path: str, 

866 data: List[Triangle], 

867 cond_number: int, 

868 cond_name: Optional[str], 

869 rename_conductor: bool): 

870 subproc(output_path) 

871 with open(output_path, 'w', encoding='utf-8') as f: 

872 f.write(f"0 GEO File\n") 

873 for t in data: 

874 f.write(f"T {cond_number}") 

875 f.write(' ' + t.to_fastcap()) 

876 

877 # compute a reference point "outside" 

878 rp = t.outside_reference_point() 

879 rp_s = rp.to_fastcap() 

880 

881 f.write(f" {rp_s}\n") 

882 if cond_name and rename_conductor: 

883 f.write(f"N {cond_number} {cond_name}\n") 

884 

885 def check(self): 

886 info("Checking …") 

887 errors = 0 

888 

889 for mn in self.materials.keys(): 

890 tris = self._collect_diel_tris(mn) 

891 info(f"Material {mn} -> {len(tris)} triangles") 

892 errors += self._check_tris(f"Material '{mn}'", tris) 

893 

894 # NOTE: the surface of a net is closed, not the ones of its parts 

895 for group_name in dict.fromkeys(self.group_by_conductor.get(nn, nn) for nn in self.net_names): 

896 tris = [t for nn in self.net_names if self.group_by_conductor.get(nn, nn) == group_name 

897 for t in self._collect_cond_tris(nn)] 

898 info(f"Net '{group_name}' -> {len(tris)} triangles") 

899 errors += self._check_tris(f"Net '{group_name}'", tris) 

900 

901 if errors == 0: 

902 info(" No errors found") 

903 else: 

904 info(f" {errors} error{'s' if errors >= 2 else ''} found") 

905 

906 def _check_tris(self, msg: str, triangles: List[Triangle]) -> int: 

907 errors = 0 

908 

909 edge_set: Set[Edge] = set() 

910 edges = self._normed_edges(triangles) 

911 

912 for e in edges: 

913 if e in edge_set: 

914 error(f"{msg}: duplicate edge {self._edge2s(e)}") 

915 errors += 1 

916 else: 

917 edge_set.add(e) 

918 

919 self._split_edges(edge_set) 

920 

921 for e in edge_set: 

922 if e.reversed() not in edge_set: 

923 error(f"{msg}: edge {self._edge2s(e)} not connected with reverse edge (open surface)") 

924 errors += 1 

925 

926 return errors 

927 

928 def _normed_edges(self, triangles: List[Triangle]) -> List[Edge]: 

929 edges = [] 

930 

931 def normed_dbu(p: Point): 

932 return Point(*tuple(map(lambda c: math.floor(c / self.dbu + 0.5), 

933 (p.x, p.y, p.z)))) 

934 

935 for t in triangles: 

936 for i in range(0, 3): 

937 p1 = normed_dbu(t[i]) 

938 p2 = normed_dbu(t[(i + 1) % 3]) 

939 edges.append(Edge(p1, p2)) 

940 

941 return edges 

942 

943 def _point2s(self, p: Point) -> str: 

944 return f"(%.12g, %.12g, %.12g)" % (p.x * self.dbu, p.y * self.dbu, p.z * self.dbu) 

945 

946 def _edge2s(self, e: Edge) -> str: 

947 return f"{self._point2s(e.p0)}-{self._point2s(e.p1)}" 

948 

949 @staticmethod 

950 def _is_antiparallel(a: Point, 

951 b: Point) -> bool: 

952 vp = vector_product(a, b) 

953 if abs(vp.sq_length()) > 0.5: # we got normalized! 

954 return False 

955 

956 sp = dot_product(a, b) 

957 return sp < 0 

958 

959 def _split_edges(self, edges: set[Edge]): 

960 edges_by_p2: DefaultDict[Point, List[Edge]] = defaultdict(list) 

961 edges_by_p1: DefaultDict[Point, List[Edge]] = defaultdict(list) 

962 for e in edges: 

963 edges_by_p2[e.p1].append(e) 

964 edges_by_p1[e.p0].append(e) 

965 

966 i = 0 

967 while True: 

968 i += 1 

969 subst: DefaultDict[Edge, List[Edge]] = defaultdict(list) 

970 

971 for e in edges: 

972 ee = edges_by_p2.get(e.p0, []) 

973 for eee in ee: 

974 ve = e.vector_of_edge() 

975 veee = eee.vector_of_edge() 

976 if self._is_antiparallel(ve, veee) and \ 

977 (veee.sq_length() < ve.sq_length() - 0.5): 

978 # There is a shorter edge antiparallel -> 

979 # this means we need to insert a split point into e 

980 subst[e] += [Edge(e.p0, eee.p0), Edge(eee.p0, e.p1)] 

981 

982 for e in edges: 

983 ee = edges_by_p1.get(e.p1, []) 

984 for eee in ee: 

985 ve = e.vector_of_edge() 

986 veee = eee.vector_of_edge() 

987 if self._is_antiparallel(ve, veee) and \ 

988 (veee.sq_length() < ve.sq_length() - 0.5): 

989 # There is a shorter edge antiparallel -> 

990 # this means we need to insert a split point into e 

991 subst[e] += [Edge(e.p0, eee.p1), Edge(eee.p1, e.p1)] 

992 

993 if len(subst) == 0: 

994 break 

995 

996 for e, replacement in subst.items(): 

997 edges_by_p1[e.p0].remove(e) 

998 edges_by_p2[e.p1].remove(e) 

999 edges.remove(e) 

1000 for r in replacement: 

1001 edges.add(r) 

1002 edges_by_p1[r.p0].append(r) 

1003 edges_by_p2[r.p1].append(r) 

1004 

1005 def dump_stl(self, output_dir_path: str, prefix: str) -> List[str]: 

1006 """Return STL paths written; empty solids produce no file.""" 

1007 written: List[str] = [] 

1008 for mn in self.materials.keys(): 

1009 tris = self._collect_diel_tris(mn) 

1010 output_path = os.path.join(output_dir_path, f"{prefix}diel_{mn}.stl") 

1011 if self._write_as_stl(output_path, tris): 

1012 written.append(output_path) 

1013 

1014 for nn in self.net_names: 

1015 tris = self._collect_cond_tris(nn) 

1016 output_path = os.path.join(output_dir_path, f"{prefix}cond_{nn}.stl") 

1017 if self._write_as_stl(output_path, tris): 

1018 written.append(output_path) 

1019 

1020 return written 

1021 

1022 @staticmethod 

1023 def _write_as_stl(file_name: str, 

1024 tris: List[Triangle]) -> bool: 

1025 if len(tris) == 0: 

1026 return False 

1027 

1028 subproc(file_name) 

1029 with open(file_name, 'w', encoding='utf-8') as f: 

1030 f.write("solid stl\n") 

1031 for t in tris: 

1032 f.write(" facet normal 0 0 0\n") 

1033 f.write(" outer loop\n") 

1034 t = t.reversed() 

1035 for p in (t.p0, t.p1, t.p2): 

1036 f.write(f" vertex {p.to_fastcap()}\n") 

1037 f.write(" endloop\n") 

1038 f.write(" endfacet\n") 

1039 f.write("endsolid stl\n") 

1040 return True 

1041 

1042 @staticmethod 

1043 def _merge_events(pyra: List[Optional[kdb.Region]], 

1044 lin: Optional[kdb.Region], 

1045 lout: Optional[kdb.Region]) -> Tuple[kdb.Region, kdb.Region, kdb.Region]: 

1046 lin = lin.dup() if lin else kdb.Region() 

1047 lout = lout.dup() if lout else kdb.Region() 

1048 past = pyra[0].dup() if len(pyra) >= 1 else kdb.Region() 

1049 

1050 for i in range(0, len(pyra)): 

1051 ii = len(pyra) - i 

1052 added: kdb.Region = lin & pyra[ii - 1] 

1053 if not added.is_empty(): 

1054 if ii >= len(pyra): 

1055 pyra.append(kdb.Region()) 

1056 assert len(pyra) == ii + 1 

1057 pyra[ii] += added 

1058 lin -= added 

1059 

1060 if len(pyra) == 0: 

1061 pyra.append(kdb.Region()) 

1062 pyra[0] += lin 

1063 

1064 for i in range(0, len(pyra)): 

1065 ii = len(pyra) - i 

1066 removed: kdb.Region = lout & pyra[ii - 1] 

1067 if not removed.is_empty(): 

1068 pyra[ii - 1] -= removed 

1069 lout -= removed 

1070 

1071 # compute merged events 

1072 lin = pyra[0] - past 

1073 lout = past - pyra[0] 

1074 return lin, lout, pyra[0] 

1075 

1076 def _collect_diel_tris(self, material_name: str) -> List[Triangle]: 

1077 tris = [] 

1078 

1079 for k, v in self.diel_data.items(): 

1080 if material_name == k.outside: 

1081 tris += v 

1082 elif material_name == k.inside: 

1083 tris += [t.reversed() for t in v] 

1084 

1085 for k, v in self.cond_data.items(): 

1086 if material_name == k.outside: 

1087 tris += v 

1088 

1089 return tris 

1090 

1091 def _collect_cond_tris(self, net_name: str) -> List[Triangle]: 

1092 tris = [] 

1093 for k, v in self.cond_data.items(): 

1094 if k.net_name == net_name: 

1095 tris += [t.reversed() for t in v] 

1096 return tris