diff --git a/fypa/altium/extract.py b/fypa/altium/extract.py index 189d487..14c70f3 100644 --- a/fypa/altium/extract.py +++ b/fypa/altium/extract.py @@ -14,6 +14,11 @@ module-level sentinel `NO_NET = -1` for unassigned. `NO_POLYGON = 65535` is the sentinel returned by altium_monkey on tracks that are not part of a polygon outline. +- A primitive owned by a polygon pour inherits that polygon's net when it + carries none of its own — Altium keeps the net on the `Polygons6` record for + poured copper (regions for a solid fill, tracks/arcs for a hatched one). + Tracks and arcs also record whether that parent pour is hatched, because a + hatched pour's perimeter is real copper rather than boundary artwork. Public entry: :func:`extract_project`. """ @@ -85,6 +90,11 @@ class RawTrack: is_polygon_outline: bool component_index: int # -1 if not part of a component is_keepout: bool + # True when the parent polygon pour is *not* solid-filled (any hatch + # style, or "outlines only"). A hatched pour's copper IS its tracks — + # including the perimeter — so `is_polygon_outline` must not exclude + # them the way it does for a solid pour. See `_polygon_lookup`. + polygon_hatched: bool = False @dataclass(frozen=True, slots=True) @@ -97,12 +107,14 @@ class RawArc: layer_id: int net_index: int is_keepout: bool - # An arc that forms part of a polygon-pour *outline* (flags1 & 0x02) is - # boundary artwork, not copper — the poured copper is the region/fill. Like - # is_polygon_outline tracks, these must be excluded from the copper geometry - # or a rounded-corner pour gains a spurious band of copper along its outline. + # An arc that forms part of a *solid* polygon-pour outline (flags1 & 0x02) + # is boundary artwork, not copper — the poured copper is the region/fill. + # Like is_polygon_outline tracks, these must be excluded from the copper + # geometry or a rounded-corner pour gains a spurious band of copper along + # its outline. A hatched pour is the exception: see `polygon_hatched`. is_polygon_outline: bool = False polygon_index: int = NO_POLYGON + polygon_hatched: bool = False @dataclass(frozen=True, slots=True) @@ -533,6 +545,69 @@ def _component_index(raw) -> int: return -1 if raw is None else int(raw) +# HATCHSTYLE values Altium writes for a *solid* pour, lower-cased. Everything +# else ("45degree", "90degree", "horizontal", "vertical", "none") means the +# pour's copper is drawn as tracks/arcs rather than poured as regions. +_SOLID_HATCH_STYLES: frozenset[str] = frozenset({"solid", ""}) + + +def _polygon_net_of(poly) -> int | None: + """One polygon's net index, or ``None`` when it carries no net. + + Deliberately does *not* trust ``polygon.net`` alone: altium_monkey parses a + missing ``NET`` field as ``int(record.get('NET', 0))``, so a net-less + polygon is indistinguishable from one genuinely on net index 0. Inheriting + that would silently attach pour copper to whichever net happens to sit at + index 0 — 27 of Corvette's 134 polygons carry no ``NET`` field at all, and + were landing on ``PWR_I2C.SDA``. The raw record is consulted so "absent" + stays absent; a polygon built programmatically has no raw record, and there + ``poly.net`` is all we have. + """ + raw = getattr(poly, "_raw_record", None) or {} + if raw and not str(raw.get("NET") or "").strip(): + return None + try: + value = int(poly.net) + except (AttributeError, TypeError, ValueError): + return None + return None if value < 0 else value + + +def _polygon_is_hatched(poly) -> bool: + """True when a pour is not solid-filled, so its copper is tracks and arcs. + + Note the two distinct "None"s: a *missing* ``hatch_style`` attribute + (Python ``None``) means solid — that is altium_monkey's own default — + whereas the *string* ``'None'`` is Altium's "outlines only" fill, whose + copper really is just the perimeter tracks. + """ + style = str(getattr(poly, "hatch_style", None) or "Solid").strip() + return style.lower() not in _SOLID_HATCH_STYLES + + +def _polygon_lookup(pcb): + """Return ``(net_of, hatched_of)`` resolvers mapping a primitive's + ``polygon_index`` to facts about its parent ``Polygons6`` record. + + Both are resolved once per polygon up front — a board has a hundred or so + pours but tens of thousands of primitives asking about them. + """ + polygons = list(getattr(pcb, "polygons", None) or ()) + nets = [_polygon_net_of(p) for p in polygons] + hatched = [_polygon_is_hatched(p) for p in polygons] + + # 65535 is the documented "no polygon" sentinel; split-plane and + # board-outline tracks carry 65534. Both land outside the record list, so + # one range check covers every sentinel Altium writes. + def net_of(idx: int) -> int | None: + return nets[idx] if 0 <= idx < len(nets) else None + + def hatched_of(idx: int) -> bool: + return hatched[idx] if 0 <= idx < len(hatched) else False + + return net_of, hatched_of + + def _pt_from_mils(x_mils: float, y_mils: float, ox_mm: float = 0.0, oy_mm: float = 0.0) -> Pt2D: return Pt2D(mils_to_mm(x_mils) - ox_mm, mils_to_mm(y_mils) - oy_mm) @@ -547,25 +622,47 @@ def _pad_height_mm(pad) -> float: def _extract_tracks(pcb, ox_mm: float, oy_mm: float) -> tuple[RawTrack, ...]: + """Extract ``Tracks6`` records, inheriting the parent polygon's net. + + A hatched (or outlines-only) pour renders its copper as tracks, and Altium + leaves those tracks' own ``net_index`` unlinked (0xFFFF) because the net + assignment lives on the parent ``Polygons6`` record — the same split + :func:`_extract_regions` already handles for solid pours. Without this the + hatch lines arrive as NO_NET and drop out of the per-net pipeline. + """ + poly_net, poly_hatched = _polygon_lookup(pcb) out: list[RawTrack] = [] for t in pcb.tracks: + raw_net = t.net_index + poly_idx = int(t.polygon_index) + if raw_net is None: + raw_net = poly_net(poly_idx) out.append(RawTrack( a=_pt_from_mils(t.start_x_mils, t.start_y_mils, ox_mm, oy_mm), b=_pt_from_mils(t.end_x_mils, t.end_y_mils, ox_mm, oy_mm), width_mm=mils_to_mm(t.width_mils), layer_id=int(t.layer), - net_index=_net_index(t.net_index), - polygon_index=int(t.polygon_index), + net_index=_net_index(raw_net), + polygon_index=poly_idx, is_polygon_outline=bool(t.is_polygon_outline), component_index=_component_index(t.component_index), is_keepout=bool(t.is_keepout), + polygon_hatched=poly_hatched(poly_idx), )) return tuple(out) def _extract_arcs(pcb, ox_mm: float, oy_mm: float) -> tuple[RawArc, ...]: + """Extract ``Arcs6`` records, inheriting the parent polygon's net exactly + as :func:`_extract_tracks` does — a hatched pour's rounded corners and + curved perimeter arrive as polygon-owned arcs with no net of their own.""" + poly_net, poly_hatched = _polygon_lookup(pcb) out: list[RawArc] = [] for a in pcb.arcs: + raw_net = a.net_index + poly_idx = int(getattr(a, "polygon_index", NO_POLYGON)) + if raw_net is None: + raw_net = poly_net(poly_idx) out.append(RawArc( center=_pt_from_mils(a.center_x_mils, a.center_y_mils, ox_mm, oy_mm), radius_mm=mils_to_mm(a.radius_mils), @@ -573,10 +670,11 @@ def _extract_arcs(pcb, ox_mm: float, oy_mm: float) -> tuple[RawArc, ...]: end_angle_deg=float(a.end_angle), width_mm=mils_to_mm(a.width_mils), layer_id=int(a.layer), - net_index=_net_index(a.net_index), + net_index=_net_index(raw_net), is_keepout=bool(a.is_keepout), is_polygon_outline=bool(getattr(a, "is_polygon_outline", False)), - polygon_index=int(getattr(a, "polygon_index", NO_POLYGON)), + polygon_index=poly_idx, + polygon_hatched=poly_hatched(poly_idx), )) return tuple(out) @@ -821,16 +919,7 @@ def _extract_regions(pcb, ox_mm: float, oy_mm: float) -> tuple[RawRegion, ...]: Polygons6 record. Without this inheritance, the largest copper pours on the board come out unassigned — wreaking havoc on per-net-aware FEM. """ - polygons = list(pcb.polygons) - - def _polygon_net(idx: int): - # polygon_index == 65535 → sentinel for "not part of a polygon". - if idx < 0 or idx >= len(polygons): - return None - try: - return polygons[idx].net - except (AttributeError, IndexError): - return None + _polygon_net, _ = _polygon_lookup(pcb) out: list[RawRegion] = [] for r in pcb.regions: @@ -904,15 +993,7 @@ def _extract_shape_based_regions(pcb, ox_mm: float, oy_mm: float, shape_based = getattr(pcb, "shapebased_regions", None) if not shape_based: return () - polygons = list(pcb.polygons) - - def _polygon_net(idx: int): - if idx < 0 or idx >= len(polygons): - return None - try: - return polygons[idx].net - except (AttributeError, IndexError): - return None + _polygon_net, _ = _polygon_lookup(pcb) out: list[RawShapeBasedRegion] = [] for r in shape_based: diff --git a/fypa/altium_geometry.py b/fypa/altium_geometry.py index c748e22..ea69d75 100644 --- a/fypa/altium_geometry.py +++ b/fypa/altium_geometry.py @@ -14,8 +14,11 @@ Geometry rules -------------- -* **Tracks** with ``is_keepout`` or ``is_polygon_outline`` are skipped; the - remaining tracks are buffered LineStrings of half-width with round caps. +* **Tracks** with ``is_keepout`` are skipped, as are ``is_polygon_outline`` + tracks belonging to a *solid* pour (boundary artwork over the region fill). + A hatched or outlines-only pour keeps its perimeter — there the tracks are + the copper. The remaining tracks are buffered LineStrings of half-width + with round caps. Tracks on layer id ``MULTI_LAYER_PAD_LAYER_ID`` (74) with an assigned net appear on every enabled **signal** copper layer; internal planes are excluded. Unassigned (``NO_NET``) ones are omitted. @@ -692,14 +695,28 @@ def _distribute_to_layers( add_fn(prim_layer_id, net_index, geom) +def _pour_outline_is_artwork(prim: RawTrack | RawArc) -> bool: + """True when a polygon-pour outline primitive is display-only boundary + artwork rather than copper. + + For a *solid* pour it is: the poured copper lives in the region fill, and + including the outline would give a rounded-corner pour a spurious band of + copper along its border. A hatched (or outlines-only) pour is the opposite + case — its copper *is* tracks and arcs, and the perimeter Altium flags as + the polygon outline is the pour's outer conductor. Excluding it dropped + the border of every hatched pour from the mesh (GitHub issue #41). + """ + return prim.is_polygon_outline and not prim.polygon_hatched + + def _track_is_copper(t: RawTrack, plane_layer_ids: set[int]) -> bool: - return (not t.is_keepout and not t.is_polygon_outline and t.width_mm > 0 - and t.layer_id not in plane_layer_ids) + return (not t.is_keepout and not _pour_outline_is_artwork(t) + and t.width_mm > 0 and t.layer_id not in plane_layer_ids) def _arc_is_copper(a: RawArc, plane_layer_ids: set[int]) -> bool: - return (not a.is_keepout and not a.is_polygon_outline and a.width_mm > 0 - and a.layer_id not in plane_layer_ids) + return (not a.is_keepout and not _pour_outline_is_artwork(a) + and a.width_mm > 0 and a.layer_id not in plane_layer_ids) def _region_is_copper( @@ -1563,7 +1580,7 @@ def _add(layer_id: int, net_index: int, geom): _distribute_to_layers(t.layer_id, t.net_index, poly, enabled_layers, _add, plane_layer_ids) - # Arcs: same vectorised-buffer trick. Exclude polygon-pour *outline* arcs + # Arcs: same vectorised-buffer trick. Exclude solid-pour *outline* arcs # (boundary artwork, not copper) exactly as the track filter above does. valid_arcs = [a for a in proj.arcs if _arc_is_copper(a, plane_layer_ids)] arc_polys = _batch_buffer_arcs(valid_arcs) diff --git a/fypa/editor_directives.py b/fypa/editor_directives.py index 49706bf..d83ed0e 100644 --- a/fypa/editor_directives.py +++ b/fypa/editor_directives.py @@ -521,6 +521,7 @@ def apply_copper_names(loaded, copper_names) -> list[str]: from fypa.altium_geometry import ( _arc_polygon, _fill_polygon, + _pour_outline_is_artwork, _region_polygon, _shape_based_region_polygon, _track_polygon, @@ -603,7 +604,7 @@ def _take(prim, poly): for t in extracted.tracks: if (t.layer_id in enabled_set and t.net_index == NO_NET - and not t.is_keepout and not t.is_polygon_outline + and not t.is_keepout and not _pour_outline_is_artwork(t) and t.width_mm > 0): try: _take(t, _track_polygon(t)) diff --git a/tests/test_hatched_polygon_pour.py b/tests/test_hatched_polygon_pour.py new file mode 100644 index 0000000..a5ac293 --- /dev/null +++ b/tests/test_hatched_polygon_pour.py @@ -0,0 +1,303 @@ +"""Hatched polygon pours import as copper on the parent polygon's net. + +GitHub issue #41. Altium realises a *solid* pour as `Regions6` fill with the +perimeter kept as `is_polygon_outline` boundary artwork, and FYPA rightly drops +that artwork (otherwise a rounded-corner pour gains a spurious band of copper +along its border). A **hatched** — or "outlines only" — pour is realised the +other way round: its copper *is* `Tracks6`/`Arcs6`, perimeter included, and the +net assignment lives on the `Polygons6` record rather than on each primitive. + +Applying the solid-pour rules to it produced exactly the two symptoms reported: + +1. the outer perimeter vanished, because it carries the polygon-outline flag; +2. the surviving hatch lines landed on no net, because only the region + extractors inherited the parent polygon's net. + +A third hazard is pinned here too: altium_monkey parses a missing `NET` field +as ``int(record.get('NET', 0))``, so a net-less polygon is indistinguishable +from one genuinely on net index 0. Inheriting that blindly attaches pour copper +to whichever net happens to sit first in `Nets6` — 27 of the 134 polygons in +the Corvette example carry no `NET` field at all. +""" +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from fypa.altium.extract import ( + NO_NET, + NO_POLYGON, + ExtractedProject, + Pt2D, + RawArc, + RawNet, + RawStackupLayer, + RawTrack, + _extract_arcs, + _extract_tracks, +) +from fypa.altium_geometry import ( + _arc_is_copper, + _track_is_copper, + build_net_layer_shapes, +) + +TOP = 1 +BOTTOM = 32 + + +# -------------------------------------------------------------------------- +# altium_monkey stand-ins. `_extract_tracks` / `_extract_arcs` only ever touch +# `pcb.tracks`, `pcb.arcs` and `pcb.polygons`, so a duck-typed PcbDoc pins the +# extraction contract without needing a hatched .PcbDoc on disk (every design +# in ExampleDesigns/ is 100 % HATCHSTYLE=Solid). +# -------------------------------------------------------------------------- +def _mk_polygon(net_field: str | None, hatch_style: str | None): + """One `Polygons6` record. ``net_field`` is the raw record's NET string — + ``None`` means the field is absent, which is how Altium writes a net-less + polygon and how the Corvette example stores 27 of its pours.""" + return SimpleNamespace( + net=int(net_field) if net_field not in (None, "") else 0, + hatch_style=hatch_style, + _raw_record={"NET": net_field, "HATCHSTYLE": hatch_style}, + ) + + +def _mk_track(*, net_index=None, polygon_index=NO_POLYGON, is_outline=False): + return SimpleNamespace( + start_x_mils=0.0, start_y_mils=0.0, + end_x_mils=100.0, end_y_mils=0.0, + width_mils=10.0, layer=TOP, + net_index=net_index, polygon_index=polygon_index, + is_polygon_outline=is_outline, component_index=None, is_keepout=False, + ) + + +def _mk_arc(*, net_index=None, polygon_index=NO_POLYGON, is_outline=False): + return SimpleNamespace( + center_x_mils=0.0, center_y_mils=0.0, radius_mils=50.0, + start_angle=0.0, end_angle=90.0, width_mils=10.0, layer=TOP, + net_index=net_index, polygon_index=polygon_index, + is_polygon_outline=is_outline, is_keepout=False, + ) + + +def _mk_pcb(polygons, tracks=(), arcs=()): + return SimpleNamespace(polygons=list(polygons), + tracks=list(tracks), arcs=list(arcs)) + + +# -------------------------------------------------------------------------- +# Extraction: net inheritance + hatched-pour classification +# -------------------------------------------------------------------------- +def test_hatched_pour_tracks_inherit_polygon_net(): + """Both the perimeter and the hatch lines of a hatched pour are unlinked in + the file (net_index 0xFFFF → None); each must take the polygon's net.""" + pcb = _mk_pcb( + polygons=[_mk_polygon("7", "45Degree")], + tracks=[_mk_track(polygon_index=0, is_outline=True), # perimeter + _mk_track(polygon_index=0)], # hatch line + ) + perimeter, hatch = _extract_tracks(pcb, 0.0, 0.0) + + assert perimeter.net_index == 7, "hatched pour perimeter lost its net" + assert hatch.net_index == 7, "hatch line lost its net" + assert perimeter.polygon_hatched and hatch.polygon_hatched + + +def test_hatched_pour_arcs_inherit_polygon_net(): + """A hatched pour's curved perimeter arrives as polygon-owned arcs with no + net of their own — same inheritance as the tracks.""" + pcb = _mk_pcb(polygons=[_mk_polygon("7", "45Degree")], + arcs=[_mk_arc(polygon_index=0, is_outline=True)]) + (arc,) = _extract_arcs(pcb, 0.0, 0.0) + + assert arc.net_index == 7 + assert arc.polygon_hatched + + +def test_primitive_net_wins_over_parent_polygon(): + """Inheritance only fills a gap — a primitive that carries its own net + (a routed track crossing a pour, say) keeps it.""" + pcb = _mk_pcb(polygons=[_mk_polygon("7", "45Degree")], + tracks=[_mk_track(net_index=3, polygon_index=0)]) + (track,) = _extract_tracks(pcb, 0.0, 0.0) + + assert track.net_index == 3 + + +def test_netless_polygon_does_not_donate_phantom_net_zero(): + """A polygon whose record has no NET field must leave its primitives + unassigned rather than pulling them onto net index 0.""" + pcb = _mk_pcb(polygons=[_mk_polygon(None, "45Degree")], + tracks=[_mk_track(polygon_index=0)], + arcs=[_mk_arc(polygon_index=0)]) + (track,) = _extract_tracks(pcb, 0.0, 0.0) + (arc,) = _extract_arcs(pcb, 0.0, 0.0) + + assert track.net_index == NO_NET + assert arc.net_index == NO_NET + + +def test_polygon_on_net_zero_still_inherits(): + """The flip side: an explicit ``NET=0`` is a real net index, not a + sentinel, and must still be inherited.""" + pcb = _mk_pcb(polygons=[_mk_polygon("0", "45Degree")], + tracks=[_mk_track(polygon_index=0)]) + (track,) = _extract_tracks(pcb, 0.0, 0.0) + + assert track.net_index == 0 + + +def test_polygon_index_sentinels_resolve_to_no_net(): + """65535 is the documented "no polygon" sentinel; split-plane and + board-outline tracks carry 65534. Neither may index the polygon list.""" + pcb = _mk_pcb(polygons=[_mk_polygon("7", "45Degree")], + tracks=[_mk_track(polygon_index=NO_POLYGON), + _mk_track(polygon_index=65534)]) + no_polygon, split_plane = _extract_tracks(pcb, 0.0, 0.0) + + assert no_polygon.net_index == NO_NET and not no_polygon.polygon_hatched + assert split_plane.net_index == NO_NET and not split_plane.polygon_hatched + + +def test_fill_style_classification(): + """Every Altium HATCHSTYLE, plus the two distinct "None"s: a *missing* + hatch_style attribute is altium_monkey's own default and means solid, + whereas the *string* 'None' is Altium's "outlines only" fill, whose copper + really is just the perimeter tracks.""" + styles = ["Solid", "45Degree", "90Degree", "Horizontal", "Vertical", + "None", None] + pcb = _mk_pcb( + polygons=[_mk_polygon("7", s) for s in styles], + tracks=[_mk_track(polygon_index=i) for i in range(len(styles))], + ) + hatched = [t.polygon_hatched for t in _extract_tracks(pcb, 0.0, 0.0)] + + assert hatched == [False, True, True, True, True, True, False], ( + f"fill-style classification wrong for {styles}: {hatched}") + + +# -------------------------------------------------------------------------- +# Geometry: which outline primitives count as copper +# -------------------------------------------------------------------------- +def _outline_track(*, hatched: bool, net_index: int = 0) -> RawTrack: + return RawTrack( + a=Pt2D(0.0, 0.0), b=Pt2D(10.0, 0.0), width_mm=0.5, + layer_id=TOP, net_index=net_index, polygon_index=0, + is_polygon_outline=True, component_index=-1, is_keepout=False, + polygon_hatched=hatched, + ) + + +def _outline_arc(*, hatched: bool, net_index: int = 0) -> RawArc: + return RawArc( + center=Pt2D(0.0, 0.0), radius_mm=5.0, + start_angle_deg=0.0, end_angle_deg=90.0, width_mm=0.5, + layer_id=TOP, net_index=net_index, is_keepout=False, + is_polygon_outline=True, polygon_index=0, polygon_hatched=hatched, + ) + + +def test_solid_pour_outline_stays_artwork(): + """Unchanged behaviour: a solid pour's outline is boundary artwork over the + region fill and must stay out of the copper geometry.""" + assert not _track_is_copper(_outline_track(hatched=False), set()) + assert not _arc_is_copper(_outline_arc(hatched=False), set()) + + +def test_hatched_pour_outline_is_copper(): + """The fix: a hatched pour's perimeter is the pour's outer conductor.""" + assert _track_is_copper(_outline_track(hatched=True), set()) + assert _arc_is_copper(_outline_arc(hatched=True), set()) + + +def test_hatched_pour_outline_still_obeys_the_other_exclusions(): + """Being hatched only lifts the polygon-outline exclusion — keepout, zero + width and plane layers still disqualify a primitive.""" + import dataclasses + + hatched = _outline_track(hatched=True) + assert not _track_is_copper(dataclasses.replace(hatched, is_keepout=True), set()) + assert not _track_is_copper(dataclasses.replace(hatched, width_mm=0.0), set()) + assert not _track_is_copper(hatched, {TOP}) + + +# -------------------------------------------------------------------------- +# Geometry: the perimeter reaches the per-net copper shapes +# -------------------------------------------------------------------------- +def _stackup() -> tuple[RawStackupLayer, ...]: + return ( + RawStackupLayer( + layer_id=TOP, name="Top", copper_thickness_mm=0.035, + dielectric_thickness_mm=0.2, next_layer_id=BOTTOM, + is_plane=False, plane_net_name=None, mech_enabled=True, + ), + RawStackupLayer( + layer_id=BOTTOM, name="Bottom", copper_thickness_mm=0.035, + dielectric_thickness_mm=0.0, next_layer_id=0, + is_plane=False, plane_net_name=None, mech_enabled=True, + ), + ) + + +def _proj(**overrides) -> ExtractedProject: + base = { + "prjpcb_path": Path("t.PrjPcb"), + "pcbdoc_path": Path("t.PcbDoc"), + "tracks": (), "arcs": (), "vias": (), "pads": (), "regions": (), + "shape_based_regions": (), "fills": (), + "pcb_components": (), "nets": (RawNet("GND"), RawNet("+5V")), + "stackup": _stackup(), "sch_components": (), "compiled_netlist": None, + } + base.update(overrides) + return ExtractedProject(**base) + + +def _hatch_line(y_mm: float, net_index: int) -> RawTrack: + return RawTrack( + a=Pt2D(0.0, y_mm), b=Pt2D(10.0, y_mm), width_mm=0.25, + layer_id=TOP, net_index=net_index, polygon_index=0, + is_polygon_outline=False, component_index=-1, is_keepout=False, + polygon_hatched=True, + ) + + +def test_hatched_pour_perimeter_reaches_the_net_copper_shape(): + """The whole pour — perimeter plus hatch lines — unions into the +5V + bucket on Top. Before the fix the perimeter was missing from that shape and + the hatch lines sat in the NO_NET bucket instead.""" + plus5v = 1 + perimeter = _outline_track(hatched=True, net_index=plus5v) + hatch = _hatch_line(2.0, plus5v) + proj = _proj(tracks=(perimeter, hatch)) + + shapes = build_net_layer_shapes(proj, [TOP, BOTTOM]) + + assert (TOP, plus5v) in shapes, "hatched pour produced no +5V copper on Top" + pour = shapes[(TOP, plus5v)] + assert (TOP, NO_NET) not in shapes, "pour copper leaked into the NO_NET bucket" + + # The perimeter runs along y=0 and the hatch line along y=2; both must be + # inside the unioned shape, and its bbox must span them. + assert pour.intersects(_shapely_point(5.0, 0.0)), "perimeter missing" + assert pour.intersects(_shapely_point(5.0, 2.0)), "hatch line missing" + miny, maxy = pour.bounds[1], pour.bounds[3] + assert miny < 0.0 and maxy > 2.0 + + +def test_solid_pour_outline_absent_from_net_copper_shape(): + """Control: the same geometry marked as a solid pour contributes nothing — + its copper would come from the Regions6 fill instead.""" + plus5v = 1 + proj = _proj(tracks=(_outline_track(hatched=False, net_index=plus5v),)) + + shapes = build_net_layer_shapes(proj, [TOP, BOTTOM]) + + assert not shapes.get((TOP, plus5v)), ( + "solid-pour outline artwork must not become copper") + + +def _shapely_point(x: float, y: float): + import shapely.geometry + return shapely.geometry.Point(x, y)