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

427 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-17 19:08 +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 typing import * 

31 

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

33 source_ref) 

34from .protobuf import ( 

35 pex25d_dielectric_pb2, 

36 pex25d_file_pb2, 

37 pex25d_geometry_pb2, 

38 pex25d_scene_pb2, 

39) 

40 

41FLOATING_NET = 'FLOATING' 

42 

43 

44class ResolveError(Exception): 

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

46 

47 

48@dataclass 

49class Profile: 

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

51 

52 name: str 

53 zlow: int 

54 zhigh: int 

55 is_conductive: bool 

56 

57 

58@dataclass 

59class ChainInfo: 

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

61 

62 depth: int 

63 root: str 

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

65 

66 

67class Resolver: 

68 def __init__(self, 

69 pex25d_file: Any, 

70 report: Optional[DiagnosticsReport] = None, 

71 strict: bool = False): 

72 self.file = pex25d_file 

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

74 self.strict = strict 

75 self.errors = 0 

76 

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

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

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

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

81 

82 # ---------------------------------------------------------- diagnostics 

83 

84 def diagnose(self, 

85 code: str, 

86 message: str, 

87 tier: Tier = Tier.SEMANTIC, 

88 severity: Severity = Severity.ERROR, 

89 source: Any = None) -> None: 

90 if severity == Severity.ERROR: 

91 self.errors += 1 

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

93 message=message, source=source_ref(source))) 

94 

95 # ---------------------------------------------------------------- entry 

96 

97 def resolve(self) -> Any: 

98 scene = pex25d_scene_pb2().PEX25DScene() 

99 scene.format_version_major = self.file.format_version_major 

100 scene.format_version_minor = self.file.format_version_minor 

101 scene.format_version_suffix = self.file.format_version_suffix 

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

103 for meta in self.file.meta: 

104 scene.meta.add().CopyFrom(meta) 

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

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

107 

108 self.collect_profiles() 

109 self.resolve_ground_plane(scene) 

110 self.resolve_layers(scene) 

111 self.resolve_dielectrics(scene) 

112 self.resolve_conductors(scene) 

113 self.resolve_domain(scene) 

114 

115 if self.strict: 

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

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

118 from .validator import geometric_tier 

119 geometric_tier(self.report, self.file, 

120 scene=None if self.errors else scene) 

121 

122 if self.errors: 

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

124 return scene 

125 

126 # ------------------------------------------------------------- profiles 

127 

128 def collect_profiles(self) -> None: 

129 """ 

130 Give every profile an absolute z extent. 

131 

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

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

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

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

136 """ 

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

138 ground_plane = self.file.ground_plane 

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

140 ground_plane.zhigh, is_conductive=True), 

141 ground_plane) 

142 else: 

143 self.diagnose('PEX25D-E0210', 

144 "No GROUND_PLANE: PEX25D requires exactly one") 

145 

146 for metal in self.file.metals: 

147 if metal.zhigh <= metal.zlow: 

148 self.diagnose('PEX25D-E0211', 

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

150 source=metal) 

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

152 is_conductive=True), metal) 

153 

154 pending = list(self.file.vias) 

155 while pending: 

156 progressed = [] 

157 for via in pending: 

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

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

160 if below is None or above is None: 

161 continue 

162 if above.zlow < below.zhigh: 

163 self.diagnose( 

164 'PEX25D-E0212', 

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

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

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

168 source=via) 

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

170 is_conductive=True), via) 

171 progressed.append(via) 

172 

173 if not progressed: 

174 for via in pending: 

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

176 if endpoint not in self.profiles: 

177 self.diagnose( 

178 'PEX25D-E0201', 

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

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

181 source=via) 

182 return 

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

184 

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

186 if profile.name in self.profiles: 

187 self.diagnose('PEX25D-E0202', 

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

189 f"profile namespace", source=record) 

190 return 

191 self.profiles[profile.name] = profile 

192 

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

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

195 return 

196 ground_plane = self.file.ground_plane 

197 scene.ground_plane.name = ground_plane.name 

198 scene.ground_plane.zlow = ground_plane.zlow 

199 scene.ground_plane.zhigh = ground_plane.zhigh 

200 if ground_plane.HasField('source'): 

201 scene.ground_plane.source.CopyFrom(ground_plane.source) 

202 

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

204 kinds = pex25d_scene_pb2().ResolvedLayer 

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

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

207 

208 for metal in self.file.metals: 

209 layer = scene.layers.add() 

210 layer.name = metal.name 

211 layer.kind = kinds.RESOLVED_LAYER_KIND_METAL 

212 layer.zlow, layer.zhigh = metal.zlow, metal.zhigh 

213 if metal.name in metal_resistances: 

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

215 if metal.HasField('source'): 

216 layer.source.CopyFrom(metal.source) 

217 

218 for via in self.file.vias: 

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

220 if profile is None: 

221 continue 

222 layer = scene.layers.add() 

223 layer.name = via.name 

224 layer.kind = kinds.RESOLVED_LAYER_KIND_VIA 

225 layer.zlow, layer.zhigh = profile.zlow, profile.zhigh 

226 layer.connects_below = via.connects_below 

227 layer.connects_above = via.connects_above 

228 if via.name in via_resistances: 

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

230 if via.HasField('source'): 

231 layer.source.CopyFrom(via.source) 

232 

233 for name in metal_resistances: 

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

235 self.diagnose('PEX25D-E0220', 

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

237 f"METAL profile") 

238 for name in via_resistances: 

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

240 self.diagnose('PEX25D-E0221', 

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

242 f"VIA profile") 

243 

244 # ---------------------------------------------------------- dielectrics 

245 

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

247 """ 

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

249 

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

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

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

253 """ 

254 if name in self.chains: 

255 return self.chains[name] 

256 

257 seen = seen or set() 

258 if name in seen: 

259 self.diagnose('PEX25D-E0230', 

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

261 return None 

262 seen = seen | {name} 

263 

264 dielectric = self.dielectrics_by_name[name] 

265 wrapped = dielectric.wraps 

266 

267 if wrapped in self.profiles: 

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

269 elif wrapped in self.dielectrics_by_name: 

270 outer = self.chain_of(wrapped, seen) 

271 if outer is None: 

272 return None 

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

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

275 else: 

276 self.diagnose('PEX25D-E0201', 

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

278 f"declared profile", source=dielectric) 

279 return None 

280 

281 self.chains[name] = info 

282 return info 

283 

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

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

286 profile = self.profiles.get(name) 

287 if profile is not None: 

288 return profile.zlow, profile.zhigh 

289 return self.resolved_dielectric_z.get(name) 

290 

291 def declare_dielectric_names(self) -> None: 

292 """ 

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

294 

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

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

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

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

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

300 wrapping the wrong object several records later. 

301 """ 

302 seen: Set[str] = set() 

303 for dielectric in self.file.dielectrics: 

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

305 self.diagnose('PEX25D-E0202', 

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

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

308 seen.add(dielectric.name) 

309 

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

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

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

313 self.diagnose('PEX25D-E0202', 

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

315 f"once in the profile namespace", 

316 source=self.file.background) 

317 

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

319 kinds = pex25d_dielectric_pb2() 

320 self.declare_dielectric_names() 

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

322 

323 ordered = [] 

324 for dielectric in self.file.dielectrics: 

325 info = self.chain_of(dielectric.name) 

326 if info is not None: 

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

328 

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

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

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

332 

333 for _, dielectric, info in ordered: 

334 extent = self.resolve_dielectric_extent(dielectric, kinds) 

335 if extent is None: 

336 continue 

337 self.resolved_dielectric_z[dielectric.name] = extent 

338 

339 resolved = scene.dielectrics.add() 

340 resolved.name = dielectric.name 

341 resolved.kind = dielectric.kind 

342 resolved.permittivity = dielectric.permittivity 

343 resolved.wraps = dielectric.wraps 

344 resolved.wrap_depth = info.depth 

345 resolved.root = info.root 

346 resolved.zlow, resolved.zhigh = extent 

347 

348 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

349 conformal = dielectric.conformal 

350 resolved.thickness_over_wrapped = conformal.thickness_over_wrapped 

351 resolved.thickness_beside_wrapped = conformal.thickness_beside_wrapped 

352 resolved.thickness_on_field = conformal.thickness_on_field 

353 elif dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE: 

354 resolved.between_below = dielectric.simple.between_below 

355 resolved.between_above = dielectric.simple.between_above 

356 

357 if dielectric.HasField('source'): 

358 resolved.source.CopyFrom(dielectric.source) 

359 

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

361 scene.background.name = self.file.background.name 

362 scene.background.permittivity = self.file.background.permittivity 

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

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

365 else: 

366 self.diagnose('PEX25D-E0213', 

367 "No DIELECTRIC_BACKGROUND: PEX25D requires exactly one") 

368 

369 def resolve_dielectric_extent(self, 

370 dielectric: Any, 

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

372 if dielectric.kind == kinds.DIELECTRIC_KIND_SIMPLE: 

373 simple = dielectric.simple 

374 below = self.wrapped_extent(simple.between_below) 

375 above = self.wrapped_extent(simple.between_above) 

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

377 (simple.between_above, above)): 

378 if extent is None: 

379 self.diagnose('PEX25D-E0201', 

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

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

382 source=dielectric) 

383 if below is None or above is None: 

384 return None 

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

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

387 return below[0], above[0] 

388 

389 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

390 wrapped = self.wrapped_extent(dielectric.wraps) 

391 if wrapped is None: 

392 return None 

393 conformal = dielectric.conformal 

394 over = wrapped[1] + conformal.thickness_over_wrapped 

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

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

397 # does over the object — or higher. 

398 on_field = wrapped[0] + conformal.thickness_on_field 

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

400 

401 self.diagnose('PEX25D-E0214', 

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

403 source=dielectric) 

404 return None 

405 

406 # ----------------------------------------------------------- conductors 

407 

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

409 kinds = pex25d_file_pb2().ShapeRecord 

410 layer_kinds = pex25d_scene_pb2().ResolvedLayer 

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

412 

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

414 for shape in self.file.shapes: 

415 if shape.layer not in layers: 

416 self.diagnose('PEX25D-E0201', 

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

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

419 source=shape) 

420 continue 

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

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

423 

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

425 for terminal in self.file.terminals: 

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

427 

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

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

430 self.diagnose('PEX25D-E0201', 

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

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

433 self.diagnose('PEX25D-E0201', 

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

435 

436 for conductor in self.file.conductors: 

437 resolved = scene.conductors.add() 

438 resolved.name = conductor.name 

439 resolved.net = conductor.net 

440 resolved.floating = conductor.net == FLOATING_NET 

441 if conductor.HasField('source'): 

442 resolved.source.CopyFrom(conductor.source) 

443 

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

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

446 region = resolved.regions.add() 

447 region.layer = layer_name 

448 for shape in shapes: 

449 if shape.kind == kinds.SHAPE_KIND_BOX: 

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

451 elif shape.kind == kinds.SHAPE_KIND_POLYGON: 

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

453 else: 

454 self.diagnose('PEX25D-E0215', 

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

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

457 

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

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

460 

461 def resolve_terminal(self, 

462 conductor: Any, 

463 terminal: Any, 

464 layers: Dict[str, Any], 

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

466 layer_kinds: Any) -> None: 

467 """ 

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

469 

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

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

472 that no adapter repeats the boolean. 

473 """ 

474 layer = layers.get(terminal.layer) 

475 if layer is None: 

476 self.diagnose('PEX25D-E0201', 

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

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

479 return 

480 

481 kinds = pex25d_file_pb2().ShapeRecord 

482 on_via = layer.kind == layer_kinds.RESOLVED_LAYER_KIND_VIA 

483 region = terminal.region 

484 

485 boxes: List[Any] = [] 

486 polygons: List[Any] = [] 

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

488 if shape.kind == kinds.SHAPE_KIND_BOX: 

489 clipped = intersect_boxes(region, shape.box) 

490 if clipped is None: 

491 continue 

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

493 self.diagnose( 

494 'PEX25D-E0240', 

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

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

497 source=terminal) 

498 return 

499 boxes.append(clipped) 

500 elif shape.kind == kinds.SHAPE_KIND_POLYGON: 

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

502 polygons.append(shape.polygon) 

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

504 clipped = clip_manhattan_polygon(shape.polygon, region) 

505 if clipped is None: 

506 self.diagnose( 

507 'PEX25D-E0241', 

508 f"TERMINAL '{terminal.name}' partially covers a non-Manhattan " 

509 f"polygon on layer '{terminal.layer}'; clipping may leave " 

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

511 return 

512 if on_via and clipped: 

513 self.diagnose( 

514 'PEX25D-E0240', 

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

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

517 source=terminal) 

518 return 

519 boxes.extend(clipped) 

520 

521 if not boxes and not polygons: 

522 self.diagnose('PEX25D-E0242', 

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

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

525 return 

526 

527 resolved = conductor.terminals.add() 

528 resolved.name = terminal.name 

529 resolved.kind = terminal.kind 

530 resolved.layer = terminal.layer 

531 resolved.region.CopyFrom(region) 

532 for box in boxes: 

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

534 for polygon in polygons: 

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

536 resolved.zlow, resolved.zhigh = layer.zlow, layer.zhigh 

537 if terminal.HasField('source'): 

538 resolved.source.CopyFrom(terminal.source) 

539 

540 # --------------------------------------------------------------- domain 

541 

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

543 """ 

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

545 

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

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

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

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

550 laterally unbounded and contribute nothing. 

551 """ 

552 kinds = pex25d_dielectric_pb2() 

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

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

555 

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

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

558 # laterally unbounded and contribute nothing here. 

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

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

561 for dielectric in scene.dielectrics: 

562 if dielectric.kind != kinds.DIELECTRIC_KIND_CONFORMAL: 

563 continue 

564 if dielectric.thickness_on_field: 

565 continue 

566 if dielectric.root not in layers: 

567 continue 

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

569 if info is None: 

570 continue 

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

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

573 

574 for conductor in scene.conductors: 

575 for region in conductor.regions: 

576 layer = layers.get(region.layer) 

577 if layer is None: 

578 continue 

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

580 top = max([layer.zhigh] 

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

582 for box in region.boxes: 

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

584 box.lower_left.y - grow, 

585 box.upper_right.x + grow, 

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

587 for polygon in region.polygons: 

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

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

590 layer.zlow, top) 

591 

592 return tuple(bounds) if bounds else None 

593 

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

595 origins = pex25d_scene_pb2().ResolvedDomain 

596 bounds = self.geometry_bounds(scene) 

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

598 

599 if bounds is None and which is None: 

600 return 

601 if bounds is not None: 

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

603 

604 if which is None: 

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

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

607 # geometry_bounds is still useful to one that does. 

608 return 

609 

610 if which == 'domain_box': 

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

612 scene.domain.origin = origins.ORIGIN_DOMAIN_BOX 

613 return 

614 

615 margin = self.file.domain_margin 

616 if bounds is None: 

617 self.diagnose('PEX25D-E0250', 

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

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

620 return 

621 

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

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

624 # ground plane forms the lower boundary. 

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

626 else bounds[4] 

627 fill_box3d(scene.domain.box, 

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

629 lower_z, z1 + margin.z) 

630 scene.domain.origin = origins.ORIGIN_DOMAIN_MARGIN 

631 scene.domain.applied_margin.CopyFrom(margin) 

632 

633 

634# ---------------------------------------------------------------- geometry 

635 

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

637 kinds = pex25d_dielectric_pb2() 

638 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL: 

639 return dielectric.conformal.thickness_beside_wrapped 

640 return 0 

641 

642 

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

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

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

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

647 

648 

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

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

651 box = pex25d_geometry_pb2().Box2D() 

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

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

654 return box 

655 

656 

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

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

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

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

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

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

663 return None 

664 box = pex25d_geometry_pb2().Box2D() 

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

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

667 return box 

668 

669 

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

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

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

673 

674 

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

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

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

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

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

680 

681 

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

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

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

685 

686 

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

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

689 if bounds is None: 

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

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

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

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

694 

695 

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

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

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

699 

700 

701def clip_manhattan_polygon(polygon: Any, region: Any) -> Optional[List[Any]]: 

702 """Intersect an orthogonal polygon, including holes, with a box on the grid.""" 

703 rings = [polygon.outer, *polygon.holes] 

704 edges = [] 

705 for ring in rings: 

706 points = list(ring.points) 

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

708 if first.x != second.x and first.y != second.y: 

709 return None 

710 if first.y != second.y: 

711 edges.append((first.x, min(first.y, second.y), max(first.y, second.y))) 

712 

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

714 if bottom >= top or region.lower_left.x >= region.upper_right.x: 

715 return [] 

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

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

718 boxes = [] 

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

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

721 crossings = sorted(x for x, lo, hi in edges if 2 * lo < low + high < 2 * hi) 

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

723 left = max(left, region.lower_left.x) 

724 right = min(right, region.upper_right.x) 

725 if left < right: 

726 box = pex25d_geometry_pb2().Box2D() 

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

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

729 boxes.append(box) 

730 return boxes 

731 

732 

733def resolve(pex25d_file: Any, 

734 report: Optional[DiagnosticsReport] = None, 

735 strict: bool = False) -> Any: 

736 """ 

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

738 

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

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

741 intersections. 

742 

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

744 wellformedness, hole containment, conductor overlap. 

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

746 in ``report``. 

747 """ 

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