Coverage for klayout_pex/pex25d/resolver.py: 95%

438 statements  

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

1# 

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

3# SPDX-FileCopyrightText: 2024-2026 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"""Resolution of a ``PEX25DFile`` into a ``PEX25DScene``.""" 

26 

27from __future__ import annotations 

28 

29from dataclasses import dataclass 

30from fractions import Fraction 

31from typing import * 

32 

33from .diagnostics import (Diagnostic, DiagnosticsReport, Severity, Tier, 

34 source_ref) 

35from .protobuf import ( 

36 pex25d_dielectric_pb2, 

37 pex25d_file_pb2, 

38 pex25d_geometry_pb2, 

39 pex25d_scene_pb2, 

40) 

41 

42FLOATING_NET = 'FLOATING' 

43 

44 

45class ResolveError(Exception): 

46 """The file could not be resolved; the report carries the reasons.""" 

47 

48 

49@dataclass 

50class Profile: 

51 """A declared object with a z extent, whatever kind of record made it.""" 

52 

53 name: str 

54 zlow: int 

55 zhigh: int 

56 is_conductive: bool 

57 

58 

59@dataclass 

60class ChainInfo: 

61 """Where a dielectric sits in its WRAPS chain.""" 

62 

63 depth: int 

64 root: str 

65 lateral: int # total offset from the root's surface, grid units 

66 

67 

68class Resolver: 

69 def __init__(self, 

70 pex25d_file: Any, 

71 report: Optional[DiagnosticsReport] = None, 

72 strict: bool = False): 

73 self.file = pex25d_file 

74 self.report = report if report is not None else DiagnosticsReport() 

75 self.strict = strict 

76 self.errors = 0 

77 

78 self.profiles: Dict[str, Profile] = {} 

79 self.dielectrics_by_name: Dict[str, Any] = {} 

80 self.chains: Dict[str, ChainInfo] = {} 

81 self.resolved_dielectric_z: Dict[str, Tuple[int, int]] = {} 

82 

83 # ---------------------------------------------------------- diagnostics 

84 

85 def diagnose(self, 

86 code: str, 

87 message: str, 

88 tier: Tier = Tier.SEMANTIC, 

89 severity: Severity = Severity.ERROR, 

90 source: Any = None) -> None: 

91 if severity == Severity.ERROR: 

92 self.errors += 1 

93 self.report.add(Diagnostic(code=code, severity=severity, tier=tier, 

94 message=message, source=source_ref(source))) 

95 

96 # ---------------------------------------------------------------- entry 

97 

98 def resolve(self) -> Any: 

99 scene = pex25d_scene_pb2().PEX25DScene() 

100 scene.format_version_major = self.file.format_version_major 

101 scene.format_version_minor = self.file.format_version_minor 

102 scene.format_version_suffix = self.file.format_version_suffix 

103 scene.units.CopyFrom(self.file.units) 

104 for entry in self.file.metadata: 

105 scene.metadata.add().CopyFrom(entry) 

106 if self.file.HasField('resistance_temperature'): 

107 scene.resistance_temperature.CopyFrom(self.file.resistance_temperature) 

108 

109 self.collect_profiles() 

110 self.resolve_ground_plane(scene) 

111 self.resolve_layers(scene) 

112 self.resolve_dielectrics(scene) 

113 self.resolve_conductors(scene) 

114 self.resolve_domain(scene) 

115 

116 if self.strict: 

117 # The geometric tier lives in the validator; running it from here 

118 # keeps `resolve --strict` and `validate --strict` the same checks. 

119 from .validator import geometric_tier 

120 geometric_tier(self.report, self.file, 

121 scene=None if self.errors else scene) 

122 

123 if self.errors: 

124 raise ResolveError(f"{self.errors} error(s) while resolving the file") 

125 return scene 

126 

127 # ------------------------------------------------------------- profiles 

128 

129 def collect_profiles(self) -> None: 

130 """ 

131 Give every profile an absolute z extent. 

132 

133 Metals and the ground plane carry theirs; a via derives its own from 

134 what it CONNECTS, which is why a change in metal position or thickness 

135 propagates without any other edit. A via may connect to another via, so 

136 this runs to a fixpoint rather than in one pass. 

137 """ 

138 if self.file.HasField('ground_plane'): 

139 ground_plane = self.file.ground_plane 

140 self.declare(Profile(ground_plane.name, ground_plane.zlow, 

141 ground_plane.zhigh, is_conductive=True), 

142 ground_plane) 

143 else: 

144 self.diagnose('PEX25D-E0210', 

145 "No GROUND_PLANE: PEX25D requires exactly one") 

146 

147 for metal in self.file.metals: 

148 if metal.zhigh <= metal.zlow: 

149 self.diagnose('PEX25D-E0211', 

150 f"METAL '{metal.name}': zlow must be less than zhigh", 

151 source=metal) 

152 self.declare(Profile(metal.name, metal.zlow, metal.zhigh, 

153 is_conductive=True), metal) 

154 

155 pending = list(self.file.vias) 

156 while pending: 

157 progressed = [] 

158 for via in pending: 

159 below = self.profiles.get(via.connects_below) 

160 above = self.profiles.get(via.connects_above) 

161 if below is None or above is None: 

162 continue 

163 if above.zlow < below.zhigh: 

164 self.diagnose( 

165 'PEX25D-E0212', 

166 f"VIA '{via.name}' CONNECTS '{via.connects_below}' " 

167 f"'{via.connects_above}': the endpoints are in the wrong " 

168 f"order, or they overlap in z", 

169 source=via) 

170 self.declare(Profile(via.name, below.zhigh, above.zlow, 

171 is_conductive=True), via) 

172 progressed.append(via) 

173 

174 if not progressed: 

175 for via in pending: 

176 for endpoint in (via.connects_below, via.connects_above): 

177 if endpoint not in self.profiles: 

178 self.diagnose( 

179 'PEX25D-E0201', 

180 f"VIA '{via.name}' CONNECTS an unknown or " 

181 f"unresolvable profile '{endpoint}'", 

182 source=via) 

183 return 

184 pending = [via for via in pending if via not in progressed] 

185 

186 def declare(self, profile: Profile, record: Any) -> None: 

187 if profile.name in self.profiles: 

188 self.diagnose('PEX25D-E0202', 

189 f"'{profile.name}' is declared more than once in the " 

190 f"profile namespace", source=record) 

191 return 

192 self.profiles[profile.name] = profile 

193 

194 def resolve_ground_plane(self, scene: Any) -> None: 

195 if not self.file.HasField('ground_plane'): 

196 return 

197 ground_plane = self.file.ground_plane 

198 scene.ground_plane.name = ground_plane.name 

199 scene.ground_plane.zlow = ground_plane.zlow 

200 scene.ground_plane.zhigh = ground_plane.zhigh 

201 if ground_plane.HasField('source'): 

202 scene.ground_plane.source.CopyFrom(ground_plane.source) 

203 

204 def resolve_layers(self, scene: Any) -> None: 

205 kinds = pex25d_scene_pb2().ResolvedLayer 

206 metal_resistances = {r.metal: r for r in self.file.metal_resistances} 

207 via_resistances = {r.via: r for r in self.file.via_resistances} 

208 

209 for metal in self.file.metals: 

210 layer = scene.layers.add() 

211 layer.name = metal.name 

212 layer.kind = kinds.RESOLVED_LAYER_KIND_METAL 

213 layer.zlow, layer.zhigh = metal.zlow, metal.zhigh 

214 if metal.name in metal_resistances: 

215 layer.metal_resistance.CopyFrom(metal_resistances[metal.name]) 

216 if metal.HasField('source'): 

217 layer.source.CopyFrom(metal.source) 

218 

219 for via in self.file.vias: 

220 profile = self.profiles.get(via.name) 

221 if profile is None: 

222 continue 

223 layer = scene.layers.add() 

224 layer.name = via.name 

225 layer.kind = kinds.RESOLVED_LAYER_KIND_VIA 

226 layer.zlow, layer.zhigh = profile.zlow, profile.zhigh 

227 layer.connects_below = via.connects_below 

228 layer.connects_above = via.connects_above 

229 if via.name in via_resistances: 

230 layer.via_resistance.CopyFrom(via_resistances[via.name]) 

231 if via.HasField('source'): 

232 layer.source.CopyFrom(via.source) 

233 

234 for name in metal_resistances: 

235 if name not in {m.name for m in self.file.metals}: 

236 self.diagnose('PEX25D-E0220', 

237 f"RESISTANCE METAL names '{name}', which is not a " 

238 f"METAL profile") 

239 for name in via_resistances: 

240 if name not in {v.name for v in self.file.vias}: 

241 self.diagnose('PEX25D-E0221', 

242 f"RESISTANCE VIA names '{name}', which is not a " 

243 f"VIA profile") 

244 

245 # ---------------------------------------------------------- dielectrics 

246 

247 def chain_of(self, name: str, seen: Optional[Set[str]] = None) -> Optional[ChainInfo]: 

248 """ 

249 Depth, root and cumulative lateral offset of a dielectric's WRAPS chain. 

250 

251 Depth 1 is a film directly on a conductor or the ground plane; a film on 

252 that film is depth 2. Occupancy goes to the smallest depth, so this is 

253 what decides which material is visible where two claim a point. 

254 """ 

255 if name in self.chains: 

256 return self.chains[name] 

257 

258 seen = seen or set() 

259 if name in seen: 

260 self.diagnose('PEX25D-E0230', 

261 f"WRAPS chain through '{name}' is cyclic") 

262 return None 

263 seen = seen | {name} 

264 

265 dielectric = self.dielectrics_by_name[name] 

266 wrapped = dielectric.wraps 

267 

268 if wrapped in self.profiles: 

269 info = ChainInfo(depth=1, root=wrapped, lateral=lateral_of(dielectric)) 

270 elif wrapped in self.dielectrics_by_name: 

271 outer = self.chain_of(wrapped, seen) 

272 if outer is None: 

273 return None 

274 info = ChainInfo(depth=outer.depth + 1, root=outer.root, 

275 lateral=outer.lateral + lateral_of(dielectric)) 

276 else: 

277 self.diagnose('PEX25D-E0201', 

278 f"Dielectric '{name}' WRAPS '{wrapped}', which is not a " 

279 f"declared profile", source=dielectric) 

280 return None 

281 

282 self.chains[name] = info 

283 return info 

284 

285 def wrapped_extent(self, name: str) -> Optional[Tuple[int, int]]: 

286 """The z extent of whatever a dielectric is built on.""" 

287 profile = self.profiles.get(name) 

288 if profile is not None: 

289 return profile.zlow, profile.zhigh 

290 return self.resolved_dielectric_z.get(name) 

291 

292 def declare_dielectric_names(self) -> None: 

293 """ 

294 Dielectrics are in the same namespace as the conductive profiles. 

295 

296 They cannot go through :meth:`declare` — a dielectric has no z until it 

297 is resolved, and resolving it needs the conductive profiles first — so 

298 the namespace is closed here instead. Without this a repeated 

299 DIELECTRIC name is silent: the later declaration simply replaces the 

300 earlier one in the lookup, and what shows up is some unrelated film 

301 wrapping the wrong object several records later. 

302 """ 

303 seen: Set[str] = set() 

304 for dielectric in self.file.dielectrics: 

305 if dielectric.name in self.profiles or dielectric.name in seen: 

306 self.diagnose('PEX25D-E0202', 

307 f"'{dielectric.name}' is declared more than once " 

308 f"in the profile namespace", source=dielectric) 

309 seen.add(dielectric.name) 

310 

311 if self.file.HasField('background') and \ 

312 (self.file.background.name in self.profiles or 

313 self.file.background.name in seen): 

314 self.diagnose('PEX25D-E0202', 

315 f"'{self.file.background.name}' is declared more than " 

316 f"once in the profile namespace", 

317 source=self.file.background) 

318 

319 def resolve_dielectrics(self, scene: Any) -> None: 

320 kinds = pex25d_dielectric_pb2() 

321 self.declare_dielectric_names() 

322 self.dielectrics_by_name = {d.name: d for d in self.file.dielectrics} 

323 

324 ordered = [] 

325 for dielectric in self.file.dielectrics: 

326 info = self.chain_of(dielectric.name) 

327 if info is not None: 

328 ordered.append((info.depth, dielectric, info)) 

329 

330 # Ascending wrap_depth, so a consumer walking the list sees the 

331 # occupancy winner first at any point it tests. Ties keep file order. 

332 ordered.sort(key=lambda entry: entry[0]) 

333 

334 for _, dielectric, info in ordered: 

335 extent = self.resolve_dielectric_extent(dielectric, kinds) 

336 if extent is None: 

337 continue 

338 self.resolved_dielectric_z[dielectric.name] = extent 

339 

340 resolved = scene.dielectrics.add() 

341 resolved.name = dielectric.name 

342 resolved.kind = dielectric.kind 

343 resolved.permittivity = dielectric.permittivity 

344 resolved.wraps = dielectric.wraps 

345 resolved.wrap_depth = info.depth 

346 resolved.root = info.root 

347 resolved.zlow, resolved.zhigh = extent 

348 

349 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

350 conformal = dielectric.conformal 

351 resolved.thickness_over_wrapped = conformal.thickness_over_wrapped 

352 resolved.thickness_beside_wrapped = conformal.thickness_beside_wrapped 

353 resolved.thickness_on_field = conformal.thickness_on_field 

354 elif dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE: 

355 resolved.between_below = dielectric.simple.between_below 

356 resolved.between_above = dielectric.simple.between_above 

357 

358 if dielectric.HasField('source'): 

359 resolved.source.CopyFrom(dielectric.source) 

360 

361 if self.file.HasField('background'): 

362 scene.background.name = self.file.background.name 

363 scene.background.permittivity = self.file.background.permittivity 

364 if self.file.background.HasField('source'): 

365 scene.background.source.CopyFrom(self.file.background.source) 

366 else: 

367 self.diagnose('PEX25D-E0213', 

368 "No DIELECTRIC_BACKGROUND: PEX25D requires exactly one") 

369 

370 def resolve_dielectric_extent(self, 

371 dielectric: Any, 

372 kinds: Any) -> Optional[Tuple[int, int]]: 

373 if dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE: 

374 simple = dielectric.simple 

375 below = self.wrapped_extent(simple.between_below) 

376 above = self.wrapped_extent(simple.between_above) 

377 for name, extent in ((simple.between_below, below), 

378 (simple.between_above, above)): 

379 if extent is None: 

380 self.diagnose('PEX25D-E0201', 

381 f"DIELECTRIC_SIMPLE '{dielectric.name}' BETWEEN " 

382 f"names '{name}', which is not a declared profile", 

383 source=dielectric) 

384 if below is None or above is None: 

385 return None 

386 # Bottom of <below> to bottom of <above>, so the band always joins 

387 # the neighbouring levels whatever the films inside it reach. 

388 return below[0], above[0] 

389 

390 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

391 wrapped = self.wrapped_extent(dielectric.wraps) 

392 if wrapped is None: 

393 return None 

394 conformal = dielectric.conformal 

395 over = wrapped[1] + conformal.thickness_over_wrapped 

396 # THICKNESS_ON_FIELD is measured up from the BOTTOM face of the 

397 # wrapped object, so on the field the film may reach lower than it 

398 # does over the object — or higher. 

399 on_field = wrapped[0] + conformal.thickness_on_field 

400 return wrapped[0], max(over, on_field) 

401 

402 self.diagnose('PEX25D-E0214', 

403 f"Dielectric '{dielectric.name}' has no kind", 

404 source=dielectric) 

405 return None 

406 

407 # ----------------------------------------------------------- conductors 

408 

409 def resolve_conductors(self, scene: Any) -> None: 

410 kinds = pex25d_file_pb2().ShapeRecord 

411 layer_kinds = pex25d_scene_pb2().ResolvedLayer 

412 layers = {layer.name: layer for layer in scene.layers} 

413 

414 shapes_by_conductor: Dict[str, Dict[str, List[Any]]] = {} 

415 for shape in self.file.shapes: 

416 if shape.layer not in layers: 

417 self.diagnose('PEX25D-E0201', 

418 f"Shape on conductor '{shape.conductor}' names LAYER " 

419 f"'{shape.layer}', which is not a METAL or VIA profile", 

420 source=shape) 

421 continue 

422 shapes_by_conductor.setdefault(shape.conductor, {}) \ 

423 .setdefault(shape.layer, []).append(shape) 

424 

425 terminals_by_conductor: Dict[str, List[Any]] = {} 

426 for terminal in self.file.terminals: 

427 terminals_by_conductor.setdefault(terminal.conductor, []).append(terminal) 

428 

429 declared = {conductor.name for conductor in self.file.conductors} 

430 for name in sorted(set(shapes_by_conductor) - declared): 

431 self.diagnose('PEX25D-E0201', 

432 f"Shapes name conductor '{name}', which is not declared") 

433 for name in sorted(set(terminals_by_conductor) - declared): 

434 self.diagnose('PEX25D-E0201', 

435 f"A TERMINAL names conductor '{name}', which is not declared") 

436 

437 for conductor in self.file.conductors: 

438 resolved = scene.conductors.add() 

439 resolved.name = conductor.name 

440 resolved.net = conductor.net 

441 resolved.floating = conductor.net == FLOATING_NET 

442 if conductor.HasField('source'): 

443 resolved.source.CopyFrom(conductor.source) 

444 

445 by_layer = shapes_by_conductor.get(conductor.name, {}) 

446 for layer_name, shapes in by_layer.items(): 

447 region = resolved.regions.add() 

448 region.layer = layer_name 

449 for shape in shapes: 

450 if shape.kind == kinds.SHAPE_KIND_BOX: 

451 region.boxes.add().CopyFrom(shape.box) 

452 elif shape.kind == kinds.SHAPE_KIND_POLYGON: 

453 region.polygons.add().CopyFrom(shape.polygon) 

454 else: 

455 self.diagnose('PEX25D-E0215', 

456 f"Shape on conductor '{conductor.name}', layer " 

457 f"'{layer_name}' has no kind", source=shape) 

458 

459 for terminal in terminals_by_conductor.get(conductor.name, []): 

460 self.resolve_terminal(resolved, terminal, layers, by_layer, layer_kinds) 

461 

462 def resolve_terminal(self, 

463 conductor: Any, 

464 terminal: Any, 

465 layers: Dict[str, Any], 

466 shapes_by_layer: Dict[str, List[Any]], 

467 layer_kinds: Any) -> None: 

468 """ 

469 Intersect the terminal's marker region with the conductor's geometry. 

470 

471 The region is a marker, not geometry: it may span the gaps between the 

472 shapes it selects. The node is the intersection, computed once here so 

473 that no adapter repeats the boolean. 

474 """ 

475 layer = layers.get(terminal.layer) 

476 if layer is None: 

477 self.diagnose('PEX25D-E0201', 

478 f"TERMINAL '{terminal.name}' names LAYER '{terminal.layer}', " 

479 f"which is not a METAL or VIA profile", source=terminal) 

480 return 

481 

482 kinds = pex25d_file_pb2().ShapeRecord 

483 on_via = layer.kind == layer_kinds.RESOLVED_LAYER_KIND_VIA 

484 region = terminal.region 

485 

486 boxes: List[Any] = [] 

487 polygons: List[Any] = [] 

488 for shape in shapes_by_layer.get(terminal.layer, []): 

489 if shape.kind == kinds.SHAPE_KIND_BOX: 

490 clipped = intersect_boxes(region, shape.box) 

491 if clipped is None: 

492 continue 

493 if on_via and not same_box(clipped, shape.box): 

494 self.diagnose( 

495 'PEX25D-E0240', 

496 f"TERMINAL '{terminal.name}' clips a via cut on layer " 

497 f"'{terminal.layer}' rather than covering it whole", 

498 source=terminal) 

499 return 

500 boxes.append(clipped) 

501 elif shape.kind == kinds.SHAPE_KIND_POLYGON: 

502 if box_contains(region, polygon_bounds(shape.polygon)): 

503 polygons.append(shape.polygon) 

504 elif boxes_overlap(region, polygon_bounds(shape.polygon)): 

505 clipped = clip_polygon(shape.polygon, region) 

506 if clipped is None: 

507 self.diagnose( 

508 'PEX25D-E0241', 

509 f"TERMINAL '{terminal.name}' is crossed by a non-Manhattan edge " 

510 f"of a polygon on layer '{terminal.layer}'; clipping would leave " 

511 f"the coordinate grid", source=terminal) 

512 return 

513 if on_via and clipped: 

514 self.diagnose( 

515 'PEX25D-E0240', 

516 f"TERMINAL '{terminal.name}' clips a via cut on layer " 

517 f"'{terminal.layer}' rather than covering it whole", 

518 source=terminal) 

519 return 

520 boxes.extend(clipped) 

521 

522 if not boxes and not polygons: 

523 self.diagnose('PEX25D-E0242', 

524 f"TERMINAL '{terminal.name}' selects nothing on layer " 

525 f"'{terminal.layer}'", source=terminal) 

526 return 

527 

528 resolved = conductor.terminals.add() 

529 resolved.name = terminal.name 

530 resolved.kind = terminal.kind 

531 resolved.layer = terminal.layer 

532 resolved.region.CopyFrom(region) 

533 for box in boxes: 

534 resolved.boxes.add().CopyFrom(box) 

535 for polygon in polygons: 

536 resolved.polygons.add().CopyFrom(polygon) 

537 resolved.zlow, resolved.zhigh = layer.zlow, layer.zhigh 

538 if terminal.HasField('source'): 

539 resolved.source.CopyFrom(terminal.source) 

540 

541 # --------------------------------------------------------------- domain 

542 

543 def geometry_bounds(self, scene: Any) -> Optional[Tuple[int, int, int, int, int, int]]: 

544 """ 

545 Bounding box of all finite, non-ground-plane geometry. 

546 

547 "Finite" means bounded in XY: conductor shapes, and conformal films with 

548 `thickness_on_field` zero, which reach past their conductor laterally by 

549 the accumulated `thickness_beside_wrapped` of their chain. Simple bands, 

550 films that cover the field, the background and the ground plane are 

551 laterally unbounded and contribute nothing. 

552 """ 

553 kinds = pex25d_dielectric_pb2() 

554 layers = {layer.name: layer for layer in scene.layers} 

555 bounds: Optional[List[int]] = None 

556 

557 # Only a conformal film that does NOT cover the field is finite. A 

558 # simple band, and a conformal with a non-zero thickness_on_field, are 

559 # laterally unbounded and contribute nothing here. 

560 finite_films: Dict[str, List[Any]] = {} 

561 reach: Dict[str, int] = {} 

562 for dielectric in scene.dielectrics: 

563 if dielectric.kind != kinds.DIELECTRIC_KIND_CONFORMAL: 

564 continue 

565 if dielectric.thickness_on_field: 

566 continue 

567 if dielectric.root not in layers: 

568 continue 

569 info = self.chains.get(dielectric.name) 

570 if info is None: 

571 continue 

572 finite_films.setdefault(dielectric.root, []).append(dielectric) 

573 reach[dielectric.root] = max(reach.get(dielectric.root, 0), info.lateral) 

574 

575 for conductor in scene.conductors: 

576 for region in conductor.regions: 

577 layer = layers.get(region.layer) 

578 if layer is None: 

579 continue 

580 grow = reach.get(region.layer, 0) 

581 top = max([layer.zhigh] 

582 + [f.zhigh for f in finite_films.get(region.layer, [])]) 

583 for box in region.boxes: 

584 bounds = extend(bounds, box.lower_left.x - grow, 

585 box.lower_left.y - grow, 

586 box.upper_right.x + grow, 

587 box.upper_right.y + grow, layer.zlow, top) 

588 for polygon in region.polygons: 

589 x0, y0, x1, y1 = polygon_bounds_tuple(polygon) 

590 bounds = extend(bounds, x0 - grow, y0 - grow, x1 + grow, y1 + grow, 

591 layer.zlow, top) 

592 

593 return tuple(bounds) if bounds else None 

594 

595 def resolve_domain(self, scene: Any) -> None: 

596 origins = pex25d_scene_pb2().ResolvedDomain 

597 bounds = self.geometry_bounds(scene) 

598 which = self.file.WhichOneof('domain') 

599 

600 if bounds is None and which is None: 

601 return 

602 if bounds is not None: 

603 fill_box3d(scene.domain.geometry_bounds, *bounds) 

604 

605 if which is None: 

606 # The resolver never invents a domain: it cannot know what a given 

607 # solver wants, and an adapter is free to place its own boundary. 

608 # geometry_bounds is still useful to one that does. 

609 return 

610 

611 if which == 'domain_box': 

612 scene.domain.box.CopyFrom(self.file.domain_box.box) 

613 scene.domain.origin = origins.ORIGIN_DOMAIN_BOX 

614 return 

615 

616 margin = self.file.domain_margin 

617 if bounds is None: 

618 self.diagnose('PEX25D-E0250', 

619 "DOMAIN_MARGIN was given but the file has no finite " 

620 "geometry to apply it to", severity=Severity.WARNING) 

621 return 

622 

623 x0, y0, x1, y1, _, z1 = bounds 

624 # X/Y expand in both directions; Z expands only upward, because the 

625 # ground plane forms the lower boundary. 

626 lower_z = scene.ground_plane.zhigh if scene.HasField('ground_plane') \ 

627 else bounds[4] 

628 fill_box3d(scene.domain.box, 

629 x0 - margin.x, y0 - margin.y, x1 + margin.x, y1 + margin.y, 

630 lower_z, z1 + margin.z) 

631 scene.domain.origin = origins.ORIGIN_DOMAIN_MARGIN 

632 scene.domain.applied_margin.CopyFrom(margin) 

633 

634 

635# ---------------------------------------------------------------- geometry 

636 

637def lateral_of(dielectric: Any) -> int: 

638 kinds = pex25d_dielectric_pb2() 

639 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

640 return dielectric.conformal.thickness_beside_wrapped 

641 return 0 

642 

643 

644def polygon_bounds_tuple(polygon: Any) -> Tuple[int, int, int, int]: 

645 xs = [p.x for p in polygon.outer.points] 

646 ys = [p.y for p in polygon.outer.points] 

647 return min(xs), min(ys), max(xs), max(ys) 

648 

649 

650def polygon_bounds(polygon: Any) -> Any: 

651 x0, y0, x1, y1 = polygon_bounds_tuple(polygon) 

652 box = pex25d_geometry_pb2().Box2D() 

653 box.lower_left.x, box.lower_left.y = x0, y0 

654 box.upper_right.x, box.upper_right.y = x1, y1 

655 return box 

656 

657 

658def intersect_boxes(a: Any, b: Any) -> Optional[Any]: 

659 x0 = max(a.lower_left.x, b.lower_left.x) 

660 y0 = max(a.lower_left.y, b.lower_left.y) 

661 x1 = min(a.upper_right.x, b.upper_right.x) 

662 y1 = min(a.upper_right.y, b.upper_right.y) 

663 if x0 >= x1 or y0 >= y1: 

664 return None 

665 box = pex25d_geometry_pb2().Box2D() 

666 box.lower_left.x, box.lower_left.y = x0, y0 

667 box.upper_right.x, box.upper_right.y = x1, y1 

668 return box 

669 

670 

671def same_box(a: Any, b: Any) -> bool: 

672 return (a.lower_left.x, a.lower_left.y, a.upper_right.x, a.upper_right.y) == \ 

673 (b.lower_left.x, b.lower_left.y, b.upper_right.x, b.upper_right.y) 

674 

675 

676def box_contains(outer: Any, inner: Any) -> bool: 

677 return (outer.lower_left.x <= inner.lower_left.x 

678 and outer.lower_left.y <= inner.lower_left.y 

679 and outer.upper_right.x >= inner.upper_right.x 

680 and outer.upper_right.y >= inner.upper_right.y) 

681 

682 

683def boxes_overlap(a: Any, b: Any) -> bool: 

684 return (a.lower_left.x < b.upper_right.x and b.lower_left.x < a.upper_right.x 

685 and a.lower_left.y < b.upper_right.y and b.lower_left.y < a.upper_right.y) 

686 

687 

688def extend(bounds: Optional[List[int]], 

689 x0: int, y0: int, x1: int, y1: int, z0: int, z1: int) -> List[int]: 

690 if bounds is None: 

691 return [x0, y0, x1, y1, z0, z1] 

692 return [min(bounds[0], x0), min(bounds[1], y0), 

693 max(bounds[2], x1), max(bounds[3], y1), 

694 min(bounds[4], z0), max(bounds[5], z1)] 

695 

696 

697def fill_box3d(box: Any, x0: int, y0: int, x1: int, y1: int, z0: int, z1: int) -> None: 

698 box.lower_left.x, box.lower_left.y, box.lower_left.z = x0, y0, z0 

699 box.upper_right.x, box.upper_right.y, box.upper_right.z = x1, y1, z1 

700 

701 

702def clip_polygon(polygon: Any, region: Any) -> Optional[List[Any]]: 

703 """ 

704 Intersect a polygon, including holes, with a box on the grid. 

705 

706 :return: the boxes of the intersection, or None if a non-Manhattan edge passes through the box, 

707 so that the intersection would leave the grid 

708 """ 

709 edges = [] # NOTE: all edges but the horizontal ones, as (x, y) of their lower and upper end 

710 for ring in [polygon.outer, *polygon.holes]: 

711 points = list(ring.points) 

712 for first, second in zip(points, points[1:] + points[:1]): 

713 if first.y != second.y: 

714 lower, upper = sorted((first, second), key=lambda point: point.y) 

715 edges.append((lower.x, lower.y, upper.x, upper.y)) 

716 

717 left_end, right_end = region.lower_left.x, region.upper_right.x 

718 bottom, top = region.lower_left.y, region.upper_right.y 

719 if bottom >= top or left_end >= right_end: 

720 return [] 

721 levels = sorted({bottom, top} | {y for _, lo, _, hi in edges 

722 for y in (lo, hi) if bottom < y < top}) 

723 boxes = [] 

724 for low, high in zip(levels, levels[1:]): 

725 # NOTE: clamping the crossings of the scanline to the box keeps their order and pairs, 

726 # so of a non-Manhattan edge beside the box only its side counts 

727 crossings = [] 

728 for x_lo, lo, x_hi, hi in edges: 

729 # Twice the midpoint keeps the scanline exact even between adjacent grid rows. 

730 if not 2 * lo < low + high < 2 * hi: 

731 continue 

732 if x_lo == x_hi: 

733 crossings.append(min(max(x_lo, left_end), right_end)) 

734 continue 

735 xs = [x_lo + Fraction((y - lo) * (x_hi - x_lo), hi - lo) for y in (low, high)] 

736 if max(xs) <= left_end: 

737 crossings.append(left_end) 

738 elif min(xs) >= right_end: 

739 crossings.append(right_end) 

740 else: 

741 return None 

742 crossings.sort() 

743 for left, right in zip(crossings[::2], crossings[1::2]): 

744 if left < right: 

745 box = pex25d_geometry_pb2().Box2D() 

746 box.lower_left.x, box.lower_left.y = left, low 

747 box.upper_right.x, box.upper_right.y = right, high 

748 boxes.append(box) 

749 return boxes 

750 

751 

752def resolve(pex25d_file: Any, 

753 report: Optional[DiagnosticsReport] = None, 

754 strict: bool = False) -> Any: 

755 """ 

756 Resolve a ``kpex.pex25d.PEX25DFile`` into a ``kpex.pex25d.PEX25DScene``. 

757 

758 Derives absolute z extents, resolves ``CONNECTS`` / ``BETWEEN`` / ``WRAPS``, 

759 flattens the wrap chain to a depth number and computes terminal 

760 intersections. 

761 

762 :param strict: additionally run the geometric tier — ring and box 

763 wellformedness, hole containment, conductor overlap. 

764 :raises ResolveError: when the file could not be resolved; the reasons are 

765 in ``report``. 

766 """ 

767 return Resolver(pex25d_file, report=report, strict=strict).resolve()