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
« 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#
25"""Resolution of a ``PEX25DFile`` into a ``PEX25DScene``."""
27from __future__ import annotations
29from dataclasses import dataclass
30from typing import *
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)
41FLOATING_NET = 'FLOATING'
44class ResolveError(Exception):
45 """The file could not be resolved; the report carries the reasons."""
48@dataclass
49class Profile:
50 """A declared object with a z extent, whatever kind of record made it."""
52 name: str
53 zlow: int
54 zhigh: int
55 is_conductive: bool
58@dataclass
59class ChainInfo:
60 """Where a dielectric sits in its WRAPS chain."""
62 depth: int
63 root: str
64 lateral: int # total offset from the root's surface, grid units
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
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]] = {}
82 # ---------------------------------------------------------- diagnostics
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)))
95 # ---------------------------------------------------------------- entry
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)
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)
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)
122 if self.errors:
123 raise ResolveError(f"{self.errors} error(s) while resolving the file")
124 return scene
126 # ------------------------------------------------------------- profiles
128 def collect_profiles(self) -> None:
129 """
130 Give every profile an absolute z extent.
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")
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)
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)
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]
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
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)
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}
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)
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)
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")
244 # ---------------------------------------------------------- dielectrics
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.
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]
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}
264 dielectric = self.dielectrics_by_name[name]
265 wrapped = dielectric.wraps
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
281 self.chains[name] = info
282 return info
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)
291 def declare_dielectric_names(self) -> None:
292 """
293 Dielectrics are in the same namespace as the conductive profiles.
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)
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)
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}
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))
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])
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
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
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
357 if dielectric.HasField('source'):
358 resolved.source.CopyFrom(dielectric.source)
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")
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]
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)
401 self.diagnose('PEX25D-E0214',
402 f"Dielectric '{dielectric.name}' has no kind",
403 source=dielectric)
404 return None
406 # ----------------------------------------------------------- conductors
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}
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)
424 terminals_by_conductor: Dict[str, List[Any]] = {}
425 for terminal in self.file.terminals:
426 terminals_by_conductor.setdefault(terminal.conductor, []).append(terminal)
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")
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)
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)
458 for terminal in terminals_by_conductor.get(conductor.name, []):
459 self.resolve_terminal(resolved, terminal, layers, by_layer, layer_kinds)
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.
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
481 kinds = pex25d_file_pb2().ShapeRecord
482 on_via = layer.kind == layer_kinds.RESOLVED_LAYER_KIND_VIA
483 region = terminal.region
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)
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
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)
540 # --------------------------------------------------------------- domain
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.
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
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)
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)
592 return tuple(bounds) if bounds else None
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')
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)
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
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
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
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)
634# ---------------------------------------------------------------- geometry
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
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)
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
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
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)
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)
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)
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)]
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
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)))
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
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``.
739 Derives absolute z extents, resolves ``CONNECTS`` / ``BETWEEN`` / ``WRAPS``,
740 flattens the wrap chain to a depth number and computes terminal
741 intersections.
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()