|
72 | 72 | "_clamp_junction_free_vertices", |
73 | 73 | "_merge_sliver_junctions_into_neighbours", |
74 | 74 | "_subdivide_violating_junctions", |
| 75 | + "source_clip_partial_coverage_shapes", |
75 | 76 | ] |
76 | 77 |
|
77 | 78 |
|
@@ -1660,6 +1661,156 @@ def _drop_off_source_residue( |
1660 | 1661 | return len(to_drop) |
1661 | 1662 |
|
1662 | 1663 |
|
| 1664 | +# The junction sliver floor (MIN_JUNCTION_AREA, documented in junction_rules |
| 1665 | +# next to WIDEN_MAX_ABANDONED_PAVEMENT_M2): a clipped piece smaller than this |
| 1666 | +# has no mesh-scale presence and is dropped, exactly as the residue passes |
| 1667 | +# discard sub-floor apron / junction fragments. |
| 1668 | +SOURCE_CLIP_MIN_PIECE_AREA_M2 = 50.0 |
| 1669 | + |
| 1670 | + |
| 1671 | +def source_clip_partial_coverage_shapes( |
| 1672 | + layout: "PavementLayout", |
| 1673 | + icao: str = "", |
| 1674 | + min_on_source_frac: float = 0.5, |
| 1675 | + ) -> int: |
| 1676 | + """Formation-time SOURCE CLIP for partial-coverage apron / junction shapes. |
| 1677 | +
|
| 1678 | + The global slice births every face 100 % on source |
| 1679 | + (``pipeline._SLICE_SOURCE_CLIP``), but DOWNSTREAM recuts — the apron |
| 1680 | + route-proximity cut, the ``_enforce_runway_1to1_sharing`` frontage |
| 1681 | + straightening — can leave an apron / junction whose polygon is mostly OFF |
| 1682 | + the real source pavement (apt.dat row-110 ∪ DSF ∪ runway). KCLT junction |
| 1683 | + #278 is 8.3 k m² at 35 % on source (the near-runway band carved off a real |
| 1684 | + 18R-end apron; the 65 % off-source remainder is RESA grass); #763 is a |
| 1685 | + 383 m² frontage split piece at 32 %. |
| 1686 | +
|
| 1687 | + For each apron / junction shape whose on-source fraction < |
| 1688 | + ``min_on_source_frac`` (judged by ``verification.check_source_adjacency`` — |
| 1689 | + the SAME source union and on-source method the verify pass uses, so there |
| 1690 | + is ONE definition of "on source"), clip the polygon to the source union |
| 1691 | + (∪ runway) buffered by ``RUNWAY_REWRITE_CARVE_RUNWAY_HALO_M`` (the 1to1 |
| 1692 | + carve's runway-frontage halo — a small margin so the shape's near-runway |
| 1693 | + contact strip survives). The largest clipped piece replaces the shape's |
| 1694 | + polygon; other pieces ≥ ``SOURCE_CLIP_MIN_PIECE_AREA_M2`` become their own |
| 1695 | + shapes of the same role. |
| 1696 | +
|
| 1697 | + Never touches runway / runway_crossing / groundside / boundary / clearance |
| 1698 | + / service shapes (apron / junction only). The clip only removes area from |
| 1699 | + the ORIGINAL polygon, and aprons / junctions are already cut away from the |
| 1700 | + runway upstream, so the runway-inclusive clip target can never introduce a |
| 1701 | + runway overlap — it only preserves frontage CONTACT. |
| 1702 | +
|
| 1703 | + ORDERING CONSTRAINT: this runs PRE-solve (before the per-surface elevation |
| 1704 | + assignment), immediately before ``_unify_airside_geometry``, so the clipped |
| 1705 | + edges are re-noded / welded / graded normally. Apron / junction shapes |
| 1706 | + carry no solved ``node_altitudes`` at this point; a clip changes the vertex |
| 1707 | + count, so any (unexpected) pre-existing per-vertex list cannot be realigned |
| 1708 | + and is cleared — the solver reassigns it. |
| 1709 | +
|
| 1710 | + REMAINDER TRADEOFF: the clipped-away off-source remainder is off source BY |
| 1711 | + CONSTRUCTION (it failed the source test), so it is DROPPED rather than |
| 1712 | + handed to groundside DEM-follow — re-minting RESA grass as groundside |
| 1713 | + pavement would merely relocate the phantom onto a DEM-following surface, |
| 1714 | + and dropping it uncovers no real source (coverage-safe: the dual |
| 1715 | + ``check_source_coverage`` invariant only guards INTERIOR source gaps). |
| 1716 | +
|
| 1717 | + Gated by ``config.SOURCE_CLIP_PARTIAL_COVERAGE`` (O4_SOURCE_CLIP, default |
| 1718 | + ON); OFF is byte-identical (the pass returns 0 without touching a shape). |
| 1719 | + Returns the number of shapes clipped. |
| 1720 | + """ |
| 1721 | + from .config import SOURCE_CLIP_PARTIAL_COVERAGE |
| 1722 | + if not SOURCE_CLIP_PARTIAL_COVERAGE: |
| 1723 | + return 0 |
| 1724 | + src = getattr(layout, "source_pavement_union", None) |
| 1725 | + if src is None or src.is_empty: |
| 1726 | + return 0 |
| 1727 | + rwy = getattr(layout, "runway_union", None) |
| 1728 | + src_union = src |
| 1729 | + if rwy is not None and not rwy.is_empty: |
| 1730 | + try: |
| 1731 | + src_union = src.union(rwy) |
| 1732 | + except _GEOM_EXC: |
| 1733 | + pass |
| 1734 | + from .junction_rules import ( |
| 1735 | + RUNWAY_REWRITE_CARVE_RUNWAY_HALO_M, _polygonal_parts) |
| 1736 | + try: |
| 1737 | + clip_target = src_union.buffer(RUNWAY_REWRITE_CARVE_RUNWAY_HALO_M) |
| 1738 | + except _GEOM_EXC: |
| 1739 | + return 0 |
| 1740 | + if clip_target is None or clip_target.is_empty: |
| 1741 | + return 0 |
| 1742 | + # Reuse the verifier's on-source method EXACTLY (one definition of |
| 1743 | + # "on source"); filter its below-threshold list to apron / junction. |
| 1744 | + from .verification import check_source_adjacency |
| 1745 | + try: |
| 1746 | + candidates = check_source_adjacency(layout, min_on_source_frac) |
| 1747 | + except _GEOM_EXC: |
| 1748 | + return 0 |
| 1749 | + idxs = [idx for (idx, _a, _f, _l) in candidates |
| 1750 | + if 0 <= idx < len(layout.shapes) |
| 1751 | + and layout.shapes[idx].role in (ROLE_APRON, ROLE_JUNCTION)] |
| 1752 | + if not idxs: |
| 1753 | + return 0 |
| 1754 | + new_shapes: list[BuiltShape] = [] |
| 1755 | + n_clipped = 0 |
| 1756 | + for i in idxs: |
| 1757 | + s = layout.shapes[i] |
| 1758 | + poly = s.polygon |
| 1759 | + if poly is None or poly.is_empty: |
| 1760 | + continue |
| 1761 | + try: |
| 1762 | + clipped = poly.intersection(clip_target) |
| 1763 | + except _GEOM_EXC: |
| 1764 | + continue |
| 1765 | + clipped = _polygonal_parts(clipped) |
| 1766 | + if clipped is None or clipped.is_empty: |
| 1767 | + # No source under this shape at all — leave it to the near-zero |
| 1768 | + # branch of _drop_off_source_residue; never null a shape here. |
| 1769 | + continue |
| 1770 | + if clipped.geom_type == "Polygon": |
| 1771 | + raw_pieces = [clipped] |
| 1772 | + elif clipped.geom_type == "MultiPolygon": |
| 1773 | + raw_pieces = list(clipped.geoms) |
| 1774 | + else: |
| 1775 | + raw_pieces = [] |
| 1776 | + pieces = sorted( |
| 1777 | + (p for p in raw_pieces |
| 1778 | + if p.geom_type == "Polygon" and not p.is_empty and p.is_valid |
| 1779 | + and p.area >= SOURCE_CLIP_MIN_PIECE_AREA_M2), |
| 1780 | + key=lambda p: p.area, reverse=True) |
| 1781 | + if not pieces: |
| 1782 | + # The whole on-source portion is below the sliver floor. Do NOT |
| 1783 | + # drop the shape here — whole-shape phantom removal is |
| 1784 | + # ``_drop_off_source_residue``'s job, which HONOURS the |
| 1785 | + # route-proximity-cut exemption that protects a real-pavement |
| 1786 | + # parent from the KCLT #255 rests-on-source hole. This pass only |
| 1787 | + # ever CLIPS a shape that has a genuine on-source piece to keep; |
| 1788 | + # it never nulls a shape (so gate-off equivalence holds trivially |
| 1789 | + # for every non-clipped shape). |
| 1790 | + continue |
| 1791 | + s.polygon = pieces[0] |
| 1792 | + s.node_altitudes = None # solver reassigns (geometry changed) |
| 1793 | + for extra in pieces[1:]: |
| 1794 | + new_shapes.append(BuiltShape( |
| 1795 | + polygon=extra, role=s.role, ref=s.ref, |
| 1796 | + source_axis=s.source_axis, is_bridge=s.is_bridge, |
| 1797 | + from_route_proximity_cut=getattr( |
| 1798 | + s, "from_route_proximity_cut", False))) |
| 1799 | + n_clipped += 1 |
| 1800 | + if new_shapes: |
| 1801 | + layout.shapes.extend(new_shapes) |
| 1802 | + if n_clipped: |
| 1803 | + try: |
| 1804 | + UI.vprint(1, |
| 1805 | + f" [pav-builder] {icao}: source-clipped {n_clipped} " |
| 1806 | + f"partial-coverage apron/junction shape(s) to the source " |
| 1807 | + f"pavement (< {min_on_source_frac*100:.0f}% on source; " |
| 1808 | + f"off-source remainder dropped).") |
| 1809 | + except _GEOM_EXC: |
| 1810 | + pass |
| 1811 | + return n_clipped |
| 1812 | + |
| 1813 | + |
1663 | 1814 | def _decompose_airside_holed_shapes( |
1664 | 1815 | layout: "PavementLayout", |
1665 | 1816 | icao: str = "", |
|
0 commit comments