Skip to content

Commit cc45410

Browse files
shizumaatclaude
andcommitted
Off-source phantom: formation-time SOURCE CLIP for partial-coverage bands (Fix C)
CLASS: apron/junction faces that the global slice births 100% on source (pipeline._SLICE_SOURCE_CLIP) but that acquire off-source area through DOWNSTREAM pre-solve recuts (the apron route-proximity cut, the _enforce_runway_1to1_sharing frontage straightening). KCLT oscarpilote#278 is the user-visible in-sim phantom: an 8,253 m2 junction at 35% on source (the near-runway band the route-proximity cut carved off a real 18R-end apron; the 65% off-source remainder is RESA grass). Fixes A+B (5782ab2) took KCLT off-source 8->2; this closes the pre-solve partial-coverage residual. DESIGN: junction_repair.source_clip_partial_coverage_shapes, gated by config.SOURCE_CLIP_PARTIAL_COVERAGE (O4_SOURCE_CLIP, default ON). For each apron/junction whose on-source fraction < 0.5 -- judged by verification.check_source_adjacency (the SAME source union + on-source method the verify pass uses; ONE definition of "on source") -- clip the polygon to (source_pavement_union UNION runway_union).buffer(RUNWAY_REWRITE_CARVE_RUNWAY_ HALO_M) (the 1to1 carve's 3 m runway-frontage halo, so near-runway CONTACT survives). All shapely return types handled via junction_rules._polygonal_ parts (the 5782ab2 GeometryCollection-fallback family); kept pieces >= the 50 m2 junction sliver floor, largest replaces the shape, extras become sibling shapes. The off-source remainder is dropped (off source by construction -- re-minting RESA grass as groundside pavement would only relocate the phantom; coverage-safe, uncovers no real source). Runs at END of pre-solve geometry formation, immediately BEFORE _unify_airside_geometry, so the clipped edges are re-noded/welded/graded normally; node_altitudes are None here (pre-solve). The pass only ever CLIPS a shape that has a real on-source piece to keep -- it never nulls/drops a whole shape (whole-shape phantom removal stays with _drop_off_source_residue, which honours the route-proximity-cut exemption that protects the KCLT oscarpilote#255 rests-on-source-hole class). O4_SOURCE_CLIP=0 reverts byte-identically (pipeline block skipped; the added constant/function inert). GATES (36fe8da baseline; KCLT/CYXY builds carry benign node-ID renumbering nondeterminism -- verified GEOMETRICALLY via sorted coord+alt sets): * KCLT gate on vs off: OFF-SOURCE 2->1 (oscarpilote#278 clipped to a ~2.9k m2 near-100%-on-source junction; #763 383 m2@32% remains -- see below). overlap 3=3, epsilon_wedge 4=4, within 3=3, plane 0=0 (NO growth). * KCLT gate-off canonical identity: coord-set + alt-set IDENTICAL to a no-pass build (byte-diff only cosmetic node-ID renumbering, which two clean builds also show). * SPLP (fires -- bonus): clips a pre-existing 34,034 m2@19% phantom junction shred86#34, OFF-SOURCE 1->0, GRADE-NEUTRAL (within 12=12, runway_grade 2=2, plane 0=0). Fixes suite test_pavement_rests_on_source[SPLP] + test_compare_target_splp[SPLP] (the compare-target reference lacks shred86#34 -- confirms it is a genuine phantom). * CYXY / SPJC / HECA: fully INERT (on vs off coord+alt sets 0-diff); CYXY within 1, SPJC overlap 1/epsilon 1/plane 1, HECA within 0/epsilon 5/ overlap 4/off-source 0. * Satellites KJQF/KEXX/KSVH: no-op, off-source 0->0. * Full suite: clean 12 -> patched 10 failed (FIXES the 2 SPLP source tests, ADDS 0). Fast suite: SPLP source test fixed, 0 added. * Unit test tests/test_source_clip.py: 30%-on-source clip, remainder-drop, 60% untouched, gate-off inert. RESIDUAL -- KCLT #763 (383 m2@32%): NOT fixed here (correctly out of scope). Staged tracing proves it is a POST-SOLVE class, not formation-time: it is >=50% on source through the pre-solve unify, and flips off-source only AFTER the solve, when emit_surface_clearance_cuts carves the on-source portion of a post-solve enforce_conformance split (junction #775 697 m2@63% -> 383 m2@32% at the 18R runway-clearance band). A formation-time/pre-solve pass cannot reach it; the proper fix is clearance-side (do not leave an off-source pavement remnant when cutting) -- a follow-up increment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 06b84aa commit cc45410

4 files changed

Lines changed: 287 additions & 0 deletions

File tree

src/auto_patch/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,23 @@ def parallel_airports_worker_count(n_tasks: int) -> int:
10271027
# law, byte-identical to the pre-feature build.
10281028
ANISO_EDGES = _os.environ.get("O4_ANISO_EDGES", "1") == "1"
10291029

1030+
# FORMATION-TIME SOURCE CLIP (KCLT off-source phantom, Fix C). The global
1031+
# slice births every face 100 % on source, but DOWNSTREAM recuts (the
1032+
# route-proximity cut, frontage straightening) can sweep an apron / junction
1033+
# face off the real source pavement (apt.dat row-110 ∪ DSF ∪ runway) — KCLT
1034+
# junction #278 is 8.3 k m² at 35 % on source (a near-runway band the
1035+
# route-proximity cut carved off a real 18R-end apron; the 65 % off-source
1036+
# remainder is RESA grass). When ON, a formation-time pass clips every
1037+
# apron / junction shape whose on-source fraction < 0.5 back to the source
1038+
# union (∪ runway, buffered by the runway-frontage halo so contact survives)
1039+
# BEFORE the pre-solve node-unification, so the clipped edges are re-noded /
1040+
# welded / solved normally. The off-source remainder (grass, off-source by
1041+
# construction) is DROPPED — re-minting it as groundside pavement would just
1042+
# relocate the phantom onto a DEM-following surface. O4_SOURCE_CLIP=0 reverts
1043+
# byte-identically (the pass is inert — no shape is touched).
1044+
SOURCE_CLIP_PARTIAL_COVERAGE = (
1045+
_os.environ.get("O4_SOURCE_CLIP", "1") == "1")
1046+
10301047
# JUNCTION MESH CONSTRAINTS (user 2026-06-30). A JUNCTION is taxi-centerline
10311048
# fill: aircraft travel ALONG the spine through it, so the only grade paths that
10321049
# physically exist are the spine (longitudinal) and the triangle-mesh EDGES X-Plane

src/auto_patch/junction_repair.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"_clamp_junction_free_vertices",
7373
"_merge_sliver_junctions_into_neighbours",
7474
"_subdivide_violating_junctions",
75+
"source_clip_partial_coverage_shapes",
7576
]
7677

7778

@@ -1660,6 +1661,156 @@ def _drop_off_source_residue(
16601661
return len(to_drop)
16611662

16621663

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+
16631814
def _decompose_airside_holed_shapes(
16641815
layout: "PavementLayout",
16651816
icao: str = "",

src/auto_patch/pipeline.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5014,6 +5014,22 @@ def _adjacent_taxi_letter(_road_poly):
50145014
from .pavement.vertices import _round_turnback_corners
50155015
_round_turnback_corners(layout, icao)
50165016

5017+
# ── Formation-time SOURCE CLIP (KCLT off-source phantom, Fix C) ──
5018+
# An apron / junction face can acquire off-source area through
5019+
# DOWNSTREAM recuts (route-proximity cut, frontage straightening) even
5020+
# though the slice birthed it 100 % on source — KCLT #278 (8.3 k m² at
5021+
# 35 %) is the near-runway band carved off a real 18R-end apron. Clip
5022+
# every < 50 %-on-source apron / junction back to the source pavement
5023+
# (∪ runway halo) HERE — after merge_small_apron_fragments / groundside
5024+
# emit / full-width-corridor consolidation (pass 1) have settled the
5025+
# apron/junction set, and BEFORE _unify_airside_geometry below so the
5026+
# clipped edges are re-noded, welded, and graded like any other pre-
5027+
# solve geometry. Gate O4_SOURCE_CLIP → no-op (byte-identical) off.
5028+
if os.environ.get("O4_SOURCE_CLIP", "1") == "1":
5029+
from .junction_repair import source_clip_partial_coverage_shapes
5030+
source_clip_partial_coverage_shapes(layout, icao=icao)
5031+
_covp(layout, "post-source-clip")
5032+
50175033
# ── Airside node-unification (refactor Phases 6+7, PRE-solve) ──
50185034
# Weld + full conformance + final corner snaps, run HERE so the solver
50195035
# sees the FINAL node-set and grades every shared vertex to ONE

tests/test_source_clip.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Unit tests for the formation-time SOURCE-CLIP pass
2+
(``junction_repair.source_clip_partial_coverage_shapes``, Fix C).
3+
4+
Pure synthetic geometry — no X-Plane install, no airport build. A
5+
partial-coverage (< 50 % on source) apron/junction is clipped back to the
6+
recorded source pavement (∪ runway halo); a well-covered (≥ 50 %) shape is
7+
left untouched; and the gate (``config.SOURCE_CLIP_PARTIAL_COVERAGE``) is
8+
byte-inert when off.
9+
"""
10+
from __future__ import annotations
11+
12+
from shapely.geometry import Polygon
13+
14+
# Import cycle gotcha (junction_repair <-> elevation): import through
15+
# auto_patch.pipeline FIRST so the modules initialise in the right order
16+
# (src/auto_patch/CLAUDE.md).
17+
import auto_patch.pipeline # noqa: F401
18+
import auto_patch.config as config
19+
from auto_patch.junction_repair import source_clip_partial_coverage_shapes
20+
from auto_patch.layout import (
21+
PavementLayout, BuiltShape, ROLE_APRON, ROLE_JUNCTION,
22+
)
23+
24+
# Source pavement is the unit-scaled square x,y in [0, 100].
25+
_SOURCE = Polygon([(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])
26+
27+
28+
def _layout() -> PavementLayout:
29+
"""A 30 %-on-source junction, a 60 %-on-source apron, and a source union.
30+
31+
* junction spans x in [70, 170] (10 000 m²); 3 000 m² (30 %) is on source.
32+
* apron spans x in [40, 140] (10 000 m²); 6 000 m² (60 %) is on source.
33+
"""
34+
junction = BuiltShape(
35+
polygon=Polygon([(70.0, 0.0), (170.0, 0.0),
36+
(170.0, 100.0), (70.0, 100.0)]),
37+
role=ROLE_JUNCTION, ref="J")
38+
apron = BuiltShape(
39+
polygon=Polygon([(40.0, 0.0), (140.0, 0.0),
40+
(140.0, 100.0), (40.0, 100.0)]),
41+
role=ROLE_APRON, ref="A")
42+
layout = PavementLayout(icao="TEST", anchor=(35.22, -80.94),
43+
shapes=[junction, apron])
44+
layout.source_pavement_union = _SOURCE
45+
layout.runway_union = None
46+
return layout
47+
48+
49+
def test_partial_coverage_shape_is_clipped_to_source():
50+
layout = _layout()
51+
# A pre-existing (stale) node_altitudes list must be cleared by the clip.
52+
layout.shapes[0].node_altitudes = [10.0, 10.0, 10.0, 10.0, 10.0]
53+
54+
n = source_clip_partial_coverage_shapes(layout, icao="TEST")
55+
assert n == 1, "exactly the 30 %-on-source junction should clip"
56+
57+
# The junction is clipped back to (source ∪ runway) buffered by the
58+
# runway-frontage halo (3 m): x in [70, 103], ~3 300 m², and now mostly
59+
# on source.
60+
junction = layout.shapes[0]
61+
on = junction.polygon.intersection(_SOURCE).area
62+
frac = on / junction.polygon.area
63+
assert junction.polygon.area < 4000.0, junction.polygon.area
64+
assert frac > 0.85, f"clipped junction should be near-fully on source: {frac}"
65+
assert junction.polygon.bounds[2] <= 103.0 + 1e-6, junction.polygon.bounds
66+
# node_altitudes cleared (geometry changed; the solver reassigns).
67+
assert junction.node_altitudes is None
68+
69+
70+
def test_off_source_remainder_is_dropped_not_re_minted():
71+
layout = _layout()
72+
source_clip_partial_coverage_shapes(layout, icao="TEST")
73+
# The clipped-away off-source remainder (x in [103, 170], RESA-grass
74+
# analogue) must NOT survive as any shape — dropping it uncovers no real
75+
# source, so nothing covers a deep-off-source probe point.
76+
remainder_pt = (150.0, 50.0)
77+
from shapely.geometry import Point
78+
p = Point(*remainder_pt)
79+
assert not any(s.polygon is not None and s.polygon.contains(p)
80+
for s in layout.shapes), \
81+
"off-source remainder was re-minted instead of dropped"
82+
83+
84+
def test_well_covered_shape_is_untouched():
85+
layout = _layout()
86+
apron_before = layout.shapes[1].polygon
87+
source_clip_partial_coverage_shapes(layout, icao="TEST")
88+
# The 60 %-on-source apron is above the 0.5 threshold → not a candidate.
89+
apron_after = next(s for s in layout.shapes if s.ref == "A")
90+
assert apron_after.polygon.equals(apron_before), \
91+
"a >= 50 %-on-source shape must not be clipped"
92+
93+
94+
def test_gate_off_is_inert(monkeypatch):
95+
monkeypatch.setattr(config, "SOURCE_CLIP_PARTIAL_COVERAGE", False)
96+
layout = _layout()
97+
before = [s.polygon for s in layout.shapes]
98+
n = source_clip_partial_coverage_shapes(layout, icao="TEST")
99+
assert n == 0
100+
after = [s.polygon for s in layout.shapes]
101+
assert len(after) == len(before)
102+
assert all(a.equals(b) for a, b in zip(after, before)), \
103+
"gate-off must leave every shape untouched"

0 commit comments

Comments
 (0)