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
« 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#
25"""Resolution of a ``PEX25DFile`` into a ``PEX25DScene``."""
27from __future__ import annotations
29from dataclasses import dataclass
30from fractions import Fraction
31from typing import *
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)
42FLOATING_NET = 'FLOATING'
45class ResolveError(Exception):
46 """The file could not be resolved; the report carries the reasons."""
49@dataclass
50class Profile:
51 """A declared object with a z extent, whatever kind of record made it."""
53 name: str
54 zlow: int
55 zhigh: int
56 is_conductive: bool
59@dataclass
60class ChainInfo:
61 """Where a dielectric sits in its WRAPS chain."""
63 depth: int
64 root: str
65 lateral: int # total offset from the root's surface, grid units
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
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]] = {}
83 # ---------------------------------------------------------- diagnostics
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)))
96 # ---------------------------------------------------------------- entry
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)
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)
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)
123 if self.errors:
124 raise ResolveError(f"{self.errors} error(s) while resolving the file")
125 return scene
127 # ------------------------------------------------------------- profiles
129 def collect_profiles(self) -> None:
130 """
131 Give every profile an absolute z extent.
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")
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)
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)
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]
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
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)
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}
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)
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)
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")
245 # ---------------------------------------------------------- dielectrics
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.
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]
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}
265 dielectric = self.dielectrics_by_name[name]
266 wrapped = dielectric.wraps
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
282 self.chains[name] = info
283 return info
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)
292 def declare_dielectric_names(self) -> None:
293 """
294 Dielectrics are in the same namespace as the conductive profiles.
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)
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)
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}
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))
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])
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
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
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
358 if dielectric.HasField('source'):
359 resolved.source.CopyFrom(dielectric.source)
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")
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]
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)
402 self.diagnose('PEX25D-E0214',
403 f"Dielectric '{dielectric.name}' has no kind",
404 source=dielectric)
405 return None
407 # ----------------------------------------------------------- conductors
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}
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)
425 terminals_by_conductor: Dict[str, List[Any]] = {}
426 for terminal in self.file.terminals:
427 terminals_by_conductor.setdefault(terminal.conductor, []).append(terminal)
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")
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)
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)
459 for terminal in terminals_by_conductor.get(conductor.name, []):
460 self.resolve_terminal(resolved, terminal, layers, by_layer, layer_kinds)
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.
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
482 kinds = pex25d_file_pb2().ShapeRecord
483 on_via = layer.kind == layer_kinds.RESOLVED_LAYER_KIND_VIA
484 region = terminal.region
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)
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
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)
541 # --------------------------------------------------------------- domain
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.
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
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)
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)
593 return tuple(bounds) if bounds else None
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')
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)
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
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
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
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)
635# ---------------------------------------------------------------- geometry
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
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)
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
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
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)
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)
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)
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)]
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
702def clip_polygon(polygon: Any, region: Any) -> Optional[List[Any]]:
703 """
704 Intersect a polygon, including holes, with a box on the grid.
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))
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
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``.
758 Derives absolute z extents, resolves ``CONNECTS`` / ``BETWEEN`` / ``WRAPS``,
759 flattens the wrap chain to a depth number and computes terminal
760 intersections.
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()