Coverage for klayout_pex/pex25d/validator.py: 96%
445 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"""
26The reference PEX25D validator.
28The point of this module is *other people's* writers: another group should be
29able to check its output and get stable ``PEX25D-Ennnn`` codes back, without
30depending on kpex internals or on the wording of a message. So it reports
31everything it finds rather than stopping at the first problem, and it never
32raises for a merely invalid file — that is what the diagnostics are for.
34Three tiers, matching ``kpex.pex25d.Diagnostic.Tier``:
36``TIER_SYNTAX``
37 the reader's business, and already reported by the time a message exists.
38``TIER_SEMANTIC``
39 name resolution, acyclic WRAPS, depth ties, and the rules below. Most of it
40 comes from running the resolver, which has to answer the same questions to
41 do its job; duplicating that here would let the two drift apart.
42``TIER_GEOMETRIC``
43 ring and box wellformedness, hole containment, conductor overlap. Only
44 under ``--strict``, because it is the expensive tier.
45"""
47from __future__ import annotations
49from dataclasses import dataclass, field
50from fractions import Fraction
51from typing import *
53from .diagnostics import (Diagnostic, DiagnosticsReport, Severity, Tier,
54 source_ref)
55from .protobuf import pex25d_dielectric_pb2, pex25d_file_pb2, pex25d_scene_pb2
57FLOATING_NET = 'FLOATING'
59# One overlapping pair per shape pair would drown the report on a layout that
60# has the problem systematically; the count is still exact.
61MAX_OVERLAP_REPORTS = 50
64class Validator:
65 def __init__(self, report: DiagnosticsReport, strict: bool = False):
66 self.report = report
67 self.strict = strict
69 def diagnose(self,
70 code: str,
71 message: str,
72 tier: Tier = Tier.SEMANTIC,
73 severity: Severity = Severity.ERROR,
74 source: Any = None) -> None:
75 self.report.add(Diagnostic(code=code, severity=severity, tier=tier,
76 message=message, source=source_ref(source)))
78 # ----------------------------------------------------------------- entry
80 def validate(self, message: Any, scene: Any = None) -> None:
81 if is_scene(message):
82 self.validate_scene(message)
83 else:
84 self.validate_file(message, scene=scene)
86 def validate_file(self, pex25d_file: Any, scene: Any = None) -> None:
87 from .resolver import ResolveError, Resolver
89 # The resolver answers every name-resolution question already, and
90 # records the same coded diagnostics. Run it for its findings; a file
91 # too broken to resolve still gets the checks that do not need one.
92 # A caller that has resolved the file already passes the scene in, so
93 # that neither the work nor the diagnostics happen twice.
94 if scene is None:
95 try:
96 # Not strict: the geometric tier below is the same code the
97 # resolver would delegate to, and would report everything twice.
98 scene = Resolver(pex25d_file, report=self.report).resolve()
99 except ResolveError:
100 pass
102 self.check_units(pex25d_file)
103 self.check_names(pex25d_file)
104 self.check_wrap_anchoring(pex25d_file)
105 self.check_conductors(pex25d_file)
106 self.check_resistance_records(pex25d_file)
107 self.check_resistance_coverage(pex25d_file)
109 if scene is not None:
110 self.check_depth_ties(scene)
112 if self.strict:
113 # Wellformedness on the file, where a shape still carries the
114 # SourceRef of the record it came from; overlap on the scene,
115 # which is the only form that knows the z extent of a layer.
116 self.check_shapes(shapes_of_file(pex25d_file))
117 if scene is not None:
118 self.check_conductor_overlap(scene)
120 def validate_scene(self, scene: Any) -> None:
121 self.check_units(scene)
122 self.check_scene_layers(scene)
123 self.check_scene_references(scene)
124 self.check_scene_order(scene)
125 self.check_depth_ties(scene)
126 self.check_scene_terminals(scene)
128 if self.strict:
129 self.check_shapes(shapes_of_scene(scene))
130 self.check_conductor_overlap(scene)
132 # -------------------------------------------------------------- semantic
134 def check_units(self, message: Any) -> None:
135 units = message.units
136 if not units.grid_denominator:
137 self.diagnose('PEX25D-E0106', "No UNITS: the file states no grid")
138 return
140 if not units.source_dbu_denominator:
141 return
143 # GRID must divide source_dbu exactly when both are present: a writer
144 # for which it does not cannot place its own geometry on the grid.
145 # Divide-round-compare, never a modulo — the same rule as coordinates.
146 grid = Fraction(units.grid_numerator, units.grid_denominator)
147 source_dbu = Fraction(units.source_dbu_numerator, units.source_dbu_denominator)
148 quotient = source_dbu / grid
149 if abs(quotient - round(quotient)) > Fraction(1, 10 ** 6):
150 self.diagnose('PEX25D-E0262',
151 f"GRID ({grid}) does not divide the source DBU "
152 f"({source_dbu}) exactly, so the writer's own geometry "
153 f"cannot be on the grid")
155 def check_wrap_anchoring(self, pex25d_file: Any) -> None:
156 """
157 A simple dielectric must wrap the OUTERMOST film anchored on its
158 ``between_below`` object.
160 Wrapping an inner link would give the fill the same depth as a film it
161 contains, which is exactly the tie that has no rule to break it.
162 """
163 kinds = pex25d_dielectric_pb2()
165 anchored: Dict[str, List[str]] = {}
166 for dielectric in pex25d_file.dielectrics:
167 if dielectric.kind == kinds.DIELECTRIC_KIND_CONFORMAL:
168 anchored.setdefault(dielectric.wraps, []).append(dielectric.name)
170 def outermost(root: str) -> str:
171 name, seen = root, {root}
172 while True:
173 films = anchored.get(name, [])
174 if len(films) != 1 or films[0] in seen:
175 return name
176 name = films[0]
177 seen.add(name)
179 for dielectric in pex25d_file.dielectrics:
180 if dielectric.kind != kinds.DIELECTRIC_KIND_SIMPLE:
181 continue
182 below = dielectric.simple.between_below
183 if not below:
184 continue
185 expected = outermost(below)
186 if dielectric.wraps and dielectric.wraps != expected:
187 self.diagnose(
188 'PEX25D-E0260',
189 f"DIELECTRIC_SIMPLE '{dielectric.name}' WRAPS "
190 f"'{dielectric.wraps}', but the outermost profile anchored on "
191 f"'{below}' is '{expected}'; wrapping an inner link ties its "
192 f"depth with a film it contains",
193 source=dielectric)
195 def check_conductors(self, pex25d_file: Any) -> None:
196 with_geometry = {shape.conductor for shape in pex25d_file.shapes}
198 for conductor in pex25d_file.conductors:
199 if conductor.name not in with_geometry:
200 self.diagnose('PEX25D-W0264',
201 f"CONDUCTOR '{conductor.name}' has no geometry",
202 severity=Severity.WARNING, source=conductor)
204 floating = {c.name for c in pex25d_file.conductors if c.net == FLOATING_NET}
205 for terminal in pex25d_file.terminals:
206 if terminal.conductor in floating:
207 # A floating body is at its own unknown potential and by
208 # construction has no port.
209 self.diagnose('PEX25D-E0263',
210 f"TERMINAL '{terminal.name}' is on FLOATING conductor "
211 f"'{terminal.conductor}', which has no port",
212 source=terminal)
214 def check_resistance_coverage(self, pex25d_file: Any) -> None:
215 """
216 Once a file states any resistance, a profile carrying geometry without
217 one is worth reporting: an adapter asked for resistance must refuse such
218 a scene rather than solve it with a perfect conductor in it. An ANCHOR
219 profile — declared only to give a via its zlow — carries no geometry and
220 is exempt.
221 """
222 stated = {r.metal for r in pex25d_file.metal_resistances} \
223 | {r.via for r in pex25d_file.via_resistances}
224 if not stated:
225 return
227 with_geometry = {shape.layer for shape in pex25d_file.shapes}
228 for layer in sorted(with_geometry - stated):
229 self.diagnose('PEX25D-W0265',
230 f"Profile '{layer}' carries geometry but the file states "
231 f"no resistance for it, while stating one for others",
232 severity=Severity.WARNING)
234 def check_depth_ties(self, scene: Any) -> None:
235 """
236 Two films at the same wrap depth on the same root overlap by
237 construction, and at equal depth there is no rule to break the tie.
238 """
239 kinds = pex25d_dielectric_pb2()
240 seen: Dict[Tuple[str, int], str] = {}
241 for dielectric in scene.dielectrics:
242 if dielectric.kind != kinds.DIELECTRIC_KIND_CONFORMAL:
243 continue
244 key = (dielectric.root, dielectric.wrap_depth)
245 if key in seen:
246 self.diagnose(
247 'PEX25D-E0261',
248 f"'{dielectric.name}' and '{seen[key]}' are both at wrap depth "
249 f"{dielectric.wrap_depth} on '{dielectric.root}'; at equal depth "
250 f"there is no rule to decide which is visible",
251 source=dielectric)
252 else:
253 seen[key] = dielectric.name
255 def check_names(self, pex25d_file: Any) -> None:
256 """Conductor shortnames and terminal names are each unique."""
257 seen: Set[str] = set()
258 for conductor in pex25d_file.conductors:
259 if conductor.name in seen:
260 self.diagnose('PEX25D-E0266',
261 f"CONDUCTOR '{conductor.name}' is declared more "
262 f"than once", source=conductor)
263 seen.add(conductor.name)
265 seen = set()
266 for terminal in pex25d_file.terminals:
267 if terminal.name in seen:
268 self.diagnose('PEX25D-E0267',
269 f"TERMINAL '{terminal.name}' is declared more "
270 f"than once", source=terminal)
271 seen.add(terminal.name)
273 def check_resistance_records(self, pex25d_file: Any) -> None:
274 has_temperature = pex25d_file.HasField('resistance_temperature')
276 for records, attribute, keyword in (
277 (pex25d_file.metal_resistances, 'metal', 'METAL'),
278 (pex25d_file.via_resistances, 'via', 'VIA')):
279 seen: Set[str] = set()
280 for record in records:
281 name = getattr(record, attribute)
282 if name in seen:
283 self.diagnose('PEX25D-E0268',
284 f"More than one RESISTANCE {keyword} record "
285 f"for '{name}'", source=record)
286 seen.add(name)
288 if not has_temperature and (record.tc.tc1 or record.tc.tc2):
289 # TC1/TC2 move a value away from a reference temperature.
290 # Without RESISTANCE TEMPERATURE there is nothing to move
291 # from, so a consumer can only ignore them.
292 self.diagnose('PEX25D-W0269',
293 f"RESISTANCE {keyword} '{name}' states "
294 f"temperature coefficients, but the file has "
295 f"no RESISTANCE TEMPERATURE to refer them to",
296 severity=Severity.WARNING, source=record)
298 # ----------------------------------------------------------------- scene
300 def check_scene_layers(self, scene: Any) -> None:
301 layer_kinds = pex25d_scene_pb2().ResolvedLayer
303 for layer in scene.layers:
304 metal = layer.kind == layer_kinds.RESOLVED_LAYER_KIND_METAL
305 via = layer.kind == layer_kinds.RESOLVED_LAYER_KIND_VIA
307 if not metal and not via:
308 self.diagnose('PEX25D-E0270',
309 f"Layer '{layer.name}' has no kind",
310 source=layer)
311 continue
313 # A METAL is a solid and needs a height; a VIA between two abutting
314 # layers legitimately has none.
315 if layer.zhigh < layer.zlow or (metal and layer.zhigh == layer.zlow):
316 self.diagnose('PEX25D-E0271',
317 f"Layer '{layer.name}' has z extent "
318 f"[{layer.zlow}, {layer.zhigh}]",
319 source=layer)
321 if via and not (layer.connects_below and layer.connects_above):
322 self.diagnose('PEX25D-E0272',
323 f"VIA layer '{layer.name}' does not carry its "
324 f"resolved CONNECTS endpoints", source=layer)
326 arm = layer.WhichOneof('resistance')
327 expected = 'metal_resistance' if metal else 'via_resistance'
328 if arm is not None and arm != expected:
329 self.diagnose('PEX25D-E0273',
330 f"Layer '{layer.name}' is a "
331 f"{'METAL' if metal else 'VIA'} but carries a "
332 f"{arm.split('_')[0].upper()} resistance",
333 source=layer)
335 def check_scene_references(self, scene: Any) -> None:
336 """
337 Every name a resolved message states must be in the scene.
339 The resolver cannot produce a dangling one; a scene from somewhere else
340 can, and a consumer that trusts the resolved form will crash on it
341 rather than diagnose it.
342 """
343 profiles = {layer.name for layer in scene.layers}
344 profiles |= {d.name for d in scene.dielectrics}
345 if scene.HasField('ground_plane'):
346 profiles.add(scene.ground_plane.name)
348 for dielectric in scene.dielectrics:
349 for name, clause in ((dielectric.wraps, 'WRAPS'),
350 (dielectric.root, 'root'),
351 (dielectric.between_below, 'BETWEEN'),
352 (dielectric.between_above, 'BETWEEN')):
353 if name and name not in profiles:
354 self.diagnose('PEX25D-E0275',
355 f"Dielectric '{dielectric.name}' names "
356 f"'{name}' as its {clause}, which is not a "
357 f"profile of this scene", source=dielectric)
359 layers = {layer.name for layer in scene.layers}
360 for conductor in scene.conductors:
361 for region in conductor.regions:
362 if region.layer not in layers:
363 self.diagnose('PEX25D-E0279',
364 f"Conductor '{conductor.name}' has geometry "
365 f"on '{region.layer}', which is not a layer "
366 f"of this scene", source=conductor)
368 def check_scene_order(self, scene: Any) -> None:
369 """
370 Dielectrics come in ascending ``wrap_depth``.
372 The order is what lets a consumer walk the list once and stop at the
373 first dielectric claiming the point it is testing, which is the
374 occupancy rule. Out of order, that walk silently returns the wrong
375 material.
376 """
377 previous = 0
378 for dielectric in scene.dielectrics:
379 if dielectric.wrap_depth < previous:
380 self.diagnose('PEX25D-E0274',
381 f"Dielectric '{dielectric.name}' is at wrap depth "
382 f"{dielectric.wrap_depth} after one at depth "
383 f"{previous}; the list must ascend",
384 source=dielectric)
385 return
386 previous = dielectric.wrap_depth
388 def check_scene_terminals(self, scene: Any) -> None:
389 layers = {layer.name for layer in scene.layers}
390 seen: Set[str] = set()
392 for conductor in scene.conductors:
393 regions = {region.layer for region in conductor.regions}
394 for terminal in conductor.terminals:
395 if terminal.name in seen:
396 self.diagnose('PEX25D-E0267',
397 f"TERMINAL '{terminal.name}' appears more "
398 f"than once", source=terminal)
399 seen.add(terminal.name)
401 if conductor.floating:
402 self.diagnose('PEX25D-E0263',
403 f"TERMINAL '{terminal.name}' is on FLOATING "
404 f"conductor '{conductor.name}', which has no "
405 f"port", source=terminal)
407 if terminal.layer not in layers:
408 self.diagnose('PEX25D-E0276',
409 f"TERMINAL '{terminal.name}' names layer "
410 f"'{terminal.layer}', which is not a layer of "
411 f"this scene", source=terminal)
412 elif terminal.layer not in regions:
413 self.diagnose('PEX25D-E0276',
414 f"TERMINAL '{terminal.name}' is on layer "
415 f"'{terminal.layer}', where conductor "
416 f"'{conductor.name}' has no geometry",
417 source=terminal)
419 # The node is the resolved intersection, and the resolver
420 # rejects an empty one; an empty node here means the scene was
421 # written by something that did not do the boolean.
422 if not terminal.boxes and not terminal.polygons:
423 self.diagnose('PEX25D-E0277',
424 f"TERMINAL '{terminal.name}' has an empty "
425 f"node: nothing was intersected",
426 source=terminal)
428 # ------------------------------------------------------------- geometric
430 def check_shapes(self, shapes: Iterable[Shape]) -> None:
431 for shape in shapes:
432 if shape.is_box:
433 self.check_box(shape)
434 else:
435 self.check_polygon(shape)
437 def check_box(self, shape: Shape) -> None:
438 box = shape.geometry
439 if box.lower_left.x >= box.upper_right.x or \
440 box.lower_left.y >= box.upper_right.y:
441 self.geometric('PEX25D-E0308',
442 f"{shape} is empty: LL ({box.lower_left.x}, "
443 f"{box.lower_left.y}) is not below and left of UR "
444 f"({box.upper_right.x}, {box.upper_right.y})",
445 shape.source)
447 def check_polygon(self, shape: Shape) -> None:
448 polygon = shape.geometry
449 outer = self.check_ring(shape, polygon.outer, 'OUTER')
450 holes = [self.check_ring(shape, hole, f"HOLE {i + 1}")
451 for i, hole in enumerate(polygon.holes)]
452 if outer is None or any(hole is None for hole in holes):
453 return
455 for i, hole in enumerate(holes):
456 if not ring_strictly_inside(hole, outer):
457 self.geometric('PEX25D-E0306',
458 f"{shape}: HOLE {i + 1} is not strictly inside "
459 f"OUTER", shape.source)
461 for i in range(len(holes)):
462 for j in range(i + 1, len(holes)):
463 if rings_meet(holes[i], holes[j]):
464 self.geometric('PEX25D-E0307',
465 f"{shape}: HOLE {i + 1} and HOLE {j + 1} "
466 f"touch or overlap; an island inside a hole "
467 f"is a POLYGON record of its own", shape.source)
469 def check_ring(self, shape: Shape, ring: Any, what: str) -> Optional[Ring]:
470 """One ring, or ``None`` once it is too broken for the checks after it."""
471 points: Ring = [(p.x, p.y) for p in ring.points]
473 if len(points) < 3:
474 self.geometric('PEX25D-E0301',
475 f"{shape}: {what} has {len(points)} vertices",
476 shape.source)
477 return None
479 if points[0] == points[-1]:
480 self.geometric('PEX25D-E0302',
481 f"{shape}: {what} repeats its first vertex "
482 f"({points[0][0]}, {points[0][1]}) at the end; a ring "
483 f"is closed implicitly", shape.source)
484 return None
486 for i in range(len(points) - 1):
487 if points[i] == points[i + 1]:
488 self.geometric('PEX25D-E0303',
489 f"{shape}: {what} repeats the vertex "
490 f"({points[i][0]}, {points[i][1]})", shape.source)
491 return None
493 if ring_is_collinear(points):
494 self.geometric('PEX25D-E0304',
495 f"{shape}: {what} encloses no area, every vertex "
496 f"lying on one line", shape.source)
497 return None
499 if not ring_is_simple(points):
500 self.geometric('PEX25D-E0305',
501 f"{shape}: {what} intersects itself", shape.source)
502 return None
504 return points
506 def check_conductor_overlap(self, scene: Any) -> None:
507 """
508 Two conductors may not share a point.
510 Shapes are swept in x and filtered on y and z before anything exact
511 runs, because the interesting comparison — different conductors, same
512 height, same place — is a vanishing fraction of the pairs. Touching is
513 not overlap: abutting shapes of different conductors are legal, and
514 only a shared area is reported.
515 """
516 layers = {layer.name: layer for layer in scene.layers}
518 items: List[Item] = []
519 for conductor in scene.conductors:
520 for region in conductor.regions:
521 layer = layers.get(region.layer)
522 if layer is None:
523 continue
524 for box in region.boxes:
525 items.append(item_of_box(conductor, region.layer, layer, box))
526 for polygon in region.polygons:
527 item = item_of_polygon(conductor, region.layer, layer, polygon)
528 if item is not None:
529 items.append(item)
531 items.sort(key=lambda item: item.xmin)
533 active: List[Item] = []
534 overlaps = 0
535 for item in items:
536 active = [other for other in active if other.xmax >= item.xmin]
537 for other in active:
538 if other.conductor == item.conductor:
539 continue
540 if other.xmax <= item.xmin or item.xmax <= other.xmin:
541 continue
542 if other.ymax <= item.ymin or item.ymax <= other.ymin:
543 continue
544 if other.zhigh <= item.zlow or item.zhigh <= other.zlow:
545 continue
546 if not items_overlap(other, item):
547 continue
549 overlaps += 1
550 if overlaps <= MAX_OVERLAP_REPORTS:
551 self.geometric(
552 'PEX25D-E0309',
553 f"Conductors '{other.conductor}' (on '{other.layer}') "
554 f"and '{item.conductor}' (on '{item.layer}') overlap "
555 f"near ({max(other.xmin, item.xmin)}, "
556 f"{max(other.ymin, item.ymin)})", item.source)
557 active.append(item)
559 if overlaps > MAX_OVERLAP_REPORTS:
560 self.diagnose('PEX25D-N0002',
561 f"{overlaps - MAX_OVERLAP_REPORTS} further conductor "
562 f"overlaps were found and not listed",
563 tier=Tier.GEOMETRIC, severity=Severity.NOTE)
565 def geometric(self, code: str, message: str, source: Any = None) -> None:
566 self.diagnose(code, message, tier=Tier.GEOMETRIC, source=source)
569# -----------------------------------------------------------------------------
570# Shapes
571# -----------------------------------------------------------------------------
573Ring = List[Tuple[int, int]]
576@dataclass(frozen=True)
577class Shape:
578 """A drawn shape with what the geometric tier needs to talk about it."""
580 conductor: str
581 layer: str
582 is_box: bool
583 geometry: Any # Box2D or Polygon2D
584 source: Any = None # the record it came from, for its SourceRef
586 def __str__(self) -> str:
587 kind = 'BOX' if self.is_box else 'POLYGON'
588 return f"{kind} on conductor '{self.conductor}', layer '{self.layer}'"
591def shapes_of_file(pex25d_file: Any) -> Iterator[Shape]:
592 kinds = pex25d_file_pb2().ShapeRecord
593 for shape in pex25d_file.shapes:
594 if shape.kind == kinds.SHAPE_KIND_BOX:
595 yield Shape(shape.conductor, shape.layer, True, shape.box, shape)
596 elif shape.kind == kinds.SHAPE_KIND_POLYGON:
597 yield Shape(shape.conductor, shape.layer, False, shape.polygon, shape)
600def shapes_of_scene(scene: Any) -> Iterator[Shape]:
601 for conductor in scene.conductors:
602 for region in conductor.regions:
603 for box in region.boxes:
604 yield Shape(conductor.name, region.layer, True, box, conductor)
605 for polygon in region.polygons:
606 yield Shape(conductor.name, region.layer, False, polygon, conductor)
609# -----------------------------------------------------------------------------
610# Integer geometry
611#
612# All of it is exact: coordinates are grid units, every predicate below is a
613# comparison of integer cross and dot products, and the one place a midpoint is
614# needed works in doubled coordinates rather than in floats. A conformance
615# validator that answered "these two conductors overlap" from rounded
616# arithmetic would be worse than none.
617# -----------------------------------------------------------------------------
619def cross3(o: Tuple[int, int], a: Tuple[int, int], b: Tuple[int, int]) -> int:
620 return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
623def dot3(o: Tuple[int, int], a: Tuple[int, int], b: Tuple[int, int]) -> int:
624 return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1])
627def edges(ring: Ring) -> Iterator[Tuple[Tuple[int, int], Tuple[int, int]]]:
628 for i in range(len(ring)):
629 yield ring[i], ring[(i + 1) % len(ring)]
632def on_segment(a1: Tuple[int, int], a2: Tuple[int, int],
633 p: Tuple[int, int]) -> bool:
634 """Whether ``p``, already known to be collinear with a1-a2, is on it."""
635 return min(a1[0], a2[0]) <= p[0] <= max(a1[0], a2[0]) and \
636 min(a1[1], a2[1]) <= p[1] <= max(a1[1], a2[1])
639def segments_meet(a1: Tuple[int, int], a2: Tuple[int, int],
640 b1: Tuple[int, int], b2: Tuple[int, int]) -> bool:
641 """Any common point at all, touching and collinear overlap included."""
642 d1 = cross3(a1, a2, b1)
643 d2 = cross3(a1, a2, b2)
644 d3 = cross3(b1, b2, a1)
645 d4 = cross3(b1, b2, a2)
646 if d1 != 0 and d2 != 0 and d3 != 0 and d4 != 0:
647 return (d1 > 0) != (d2 > 0) and (d3 > 0) != (d4 > 0)
648 return (d1 == 0 and on_segment(a1, a2, b1)) or \
649 (d2 == 0 and on_segment(a1, a2, b2)) or \
650 (d3 == 0 and on_segment(b1, b2, a1)) or \
651 (d4 == 0 and on_segment(b1, b2, a2))
654def ring_is_simple(ring: Ring) -> bool:
655 edge_list = list(edges(ring))
656 n = len(edge_list)
657 for i in range(n):
658 a1, a2 = edge_list[i]
659 for j in range(i + 1, n):
660 b1, b2 = edge_list[j]
661 if j == i + 1 or (i == 0 and j == n - 1):
662 # Consecutive edges share one endpoint legitimately. What they
663 # may not do is fold back along each other.
664 shared, before, after = (a2, a1, b2) if j == i + 1 \
665 else (a1, a2, b1)
666 if cross3(shared, before, after) == 0 and \
667 dot3(shared, before, after) > 0:
668 return False
669 elif segments_meet(a1, a2, b1, b2):
670 return False
671 return True
674def ring_is_collinear(ring: Ring) -> bool:
675 origin = ring[0]
676 direction = next((p for p in ring[1:] if p != origin), None)
677 if direction is None:
678 return True
679 return all(cross3(origin, direction, p) == 0 for p in ring)
682def point_in_ring(x: Any, y: Any, ring: Ring) -> int:
683 """
684 Where a point lies relative to a ring: 1 inside, 0 on it, -1 outside.
686 The ring is integer; the point may be a Fraction, and every comparison
687 below stays exact either way.
688 """
689 inside = False
690 for (x1, y1), (x2, y2) in edges(ring):
691 if (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) == 0 and \
692 min(x1, x2) <= x <= max(x1, x2) and \
693 min(y1, y2) <= y <= max(y1, y2):
694 return 0
695 if (y1 > y) != (y2 > y):
696 # Sign of (crossing_x - x), scaled by (y2 - y1).
697 side = (x1 - x) * (y2 - y1) + (y - y1) * (x2 - x1)
698 if (side > 0) == (y2 > y1):
699 inside = not inside
700 return 1 if inside else -1
703@dataclass(frozen=True)
704class Polygon:
705 """A resolved polygon: one outer ring and its holes, as integer rings."""
707 outer: Ring
708 holes: List[Ring] = field(default_factory=list)
710 def rings(self) -> Iterator[Ring]:
711 yield self.outer
712 yield from self.holes
714 def all_edges(self) -> Iterator[Tuple[Tuple[int, int], Tuple[int, int]]]:
715 for ring in self.rings():
716 yield from edges(ring)
719def point_in_polygon(x: Any, y: Any, polygon: Polygon) -> int:
720 where = point_in_ring(x, y, polygon.outer)
721 if where <= 0:
722 return where
723 for hole in polygon.holes:
724 in_hole = point_in_ring(x, y, hole)
725 if in_hole == 0:
726 return 0
727 if in_hole > 0:
728 return -1
729 return 1
732def ring_strictly_inside(inner: Ring, outer: Ring) -> bool:
733 if any(point_in_ring(x, y, outer) != 1 for x, y in inner):
734 return False
735 # Every vertex inside is not enough on a non-convex outer ring: an edge can
736 # still leave it and come back.
737 return not any(segments_meet(a1, a2, b1, b2)
738 for a1, a2 in edges(inner)
739 for b1, b2 in edges(outer))
742def rings_meet(first: Ring, second: Ring) -> bool:
743 """Whether two rings touch, cross, or one contains the other."""
744 if any(segments_meet(a1, a2, b1, b2)
745 for a1, a2 in edges(first) for b1, b2 in edges(second)):
746 return True
747 return point_in_ring(first[0][0], first[0][1], second) == 1 or \
748 point_in_ring(second[0][0], second[0][1], first) == 1
751def crossing_x(a1: Tuple[int, int], a2: Tuple[int, int],
752 b1: Tuple[int, int], b2: Tuple[int, int]) -> Optional[Fraction]:
753 """Where two segments meet in a single point, or ``None``."""
754 denominator = (a2[0] - a1[0]) * (b2[1] - b1[1]) - \
755 (a2[1] - a1[1]) * (b2[0] - b1[0])
756 if denominator == 0:
757 return None # parallel: collinear overlap ends at a vertex anyway
758 t = Fraction((b1[0] - a1[0]) * (b2[1] - b1[1]) -
759 (b1[1] - a1[1]) * (b2[0] - b1[0]), denominator)
760 u = Fraction((b1[0] - a1[0]) * (a2[1] - a1[1]) -
761 (b1[1] - a1[1]) * (a2[0] - a1[0]), denominator)
762 if not (0 <= t <= 1 and 0 <= u <= 1):
763 return None
764 return a1[0] + t * (a2[0] - a1[0])
767def polygons_overlap(first: Polygon, second: Polygon) -> bool:
768 """
769 Whether two polygons share AREA. A shared edge or corner does not count.
771 A vertical decomposition, which is the part of the answer that sampling
772 vertices and edge midpoints cannot give: where two outlines run along each
773 other for a stretch, every vertex and every midpoint of one lies ON the
774 other rather than inside it, and a sampling test reports no overlap for
775 shapes that plainly have one.
777 So cut the strip the two bounding boxes share at every x where either
778 outline has a vertex or the two outlines meet. Within one slab no boundary
779 turns, so a shared area covers a whole cell of the decomposition and the
780 midpoint of that cell is strictly inside both. Every coordinate is exact.
781 """
782 lo = max(min(x for x, _ in first.outer), min(x for x, _ in second.outer))
783 hi = min(max(x for x, _ in first.outer), max(x for x, _ in second.outer))
784 if lo >= hi:
785 return False
787 cuts = {Fraction(lo), Fraction(hi)}
788 for polygon in (first, second):
789 for ring in polygon.rings():
790 cuts.update(Fraction(x) for x, _ in ring if lo < x < hi)
791 for a1, a2 in first.all_edges():
792 for b1, b2 in second.all_edges():
793 x = crossing_x(a1, a2, b1, b2)
794 if x is not None and lo < x < hi:
795 cuts.add(x)
797 columns = sorted(cuts)
798 segments = list(first.all_edges()) + list(second.all_edges())
799 for left, right in zip(columns, columns[1:]):
800 x = (left + right) / 2
802 heights = set()
803 for (x1, y1), (x2, y2) in segments:
804 if min(x1, x2) < x < max(x1, x2):
805 heights.add(y1 + Fraction((x - x1) * (y2 - y1), x2 - x1))
806 levels = sorted(heights)
808 for low, high in zip(levels, levels[1:]):
809 y = (low + high) / 2
810 if point_in_polygon(x, y, first) == 1 and \
811 point_in_polygon(x, y, second) == 1:
812 return True
814 return False
817# -----------------------------------------------------------------------------
818# Overlap sweep
819# -----------------------------------------------------------------------------
821@dataclass(frozen=True)
822class Item:
823 """One shape with the bounds the sweep filters on."""
825 conductor: str
826 layer: str
827 source: Any
828 xmin: int
829 ymin: int
830 xmax: int
831 ymax: int
832 zlow: int
833 zhigh: int
834 polygon: Optional[Polygon] = None # None for a box: its bounds ARE its area
837def item_of_box(conductor: Any, layer_name: str, layer: Any, box: Any) -> Item:
838 return Item(conductor.name, layer_name, conductor,
839 box.lower_left.x, box.lower_left.y,
840 box.upper_right.x, box.upper_right.y,
841 layer.zlow, layer.zhigh)
844def item_of_polygon(conductor: Any,
845 layer_name: str,
846 layer: Any,
847 polygon: Any) -> Optional[Item]:
848 outer: Ring = [(p.x, p.y) for p in polygon.outer.points]
849 if len(outer) < 3:
850 return None # already reported by the wellformedness checks
851 holes = [[(p.x, p.y) for p in hole.points] for hole in polygon.holes]
852 xs = [x for x, _ in outer]
853 ys = [y for _, y in outer]
854 return Item(conductor.name, layer_name, conductor,
855 min(xs), min(ys), max(xs), max(ys),
856 layer.zlow, layer.zhigh,
857 Polygon(outer, [hole for hole in holes if len(hole) >= 3]))
860def items_overlap(first: Item, second: Item) -> bool:
861 if first.polygon is None and second.polygon is None:
862 # Both boxes, and the sweep already compared both intervals.
863 return True
864 return polygons_overlap(polygon_of(first), polygon_of(second))
867def polygon_of(item: Item) -> Polygon:
868 if item.polygon is not None:
869 return item.polygon
870 return Polygon([(item.xmin, item.ymin), (item.xmax, item.ymin),
871 (item.xmax, item.ymax), (item.xmin, item.ymax)])
874# -----------------------------------------------------------------------------
876def is_scene(message: Any) -> bool:
877 return message.DESCRIPTOR.name == 'PEX25DScene'
880def geometric_tier(report: DiagnosticsReport,
881 pex25d_file: Any = None,
882 scene: Any = None) -> None:
883 """
884 The geometric tier, for callers that already hold one or both forms.
886 Wellformedness runs on the file when there is one, because only there does a
887 shape still carry the SourceRef of the record it came from. Conductor
888 overlap runs on the scene, the only form that knows how high a layer is.
889 """
890 validator = Validator(report, strict=True)
891 if pex25d_file is not None:
892 validator.check_shapes(shapes_of_file(pex25d_file))
893 elif scene is not None:
894 validator.check_shapes(shapes_of_scene(scene))
895 if scene is not None:
896 validator.check_conductor_overlap(scene)
899def validate(message: Any,
900 report: Optional[DiagnosticsReport] = None,
901 strict: bool = False,
902 scene: Any = None) -> DiagnosticsReport:
903 """
904 Check a ``PEX25DFile`` or ``PEX25DScene`` and return what was found.
906 Never raises for an invalid message: the diagnostics are the answer.
908 :param strict: additionally run the geometric tier — ring and box
909 wellformedness, hole containment, conductor overlap.
910 :param scene: for a ``PEX25DFile``, the scene it resolves to, when the
911 caller has resolved it already. Saves resolving twice, and keeps the
912 resolver's diagnostics from being recorded twice. Ignored for a scene.
913 """
914 report = report if report is not None else DiagnosticsReport()
915 Validator(report, strict=strict).validate(message, scene=scene)
916 return report