Vision Zero Polygons — Snap-Align & Clean Merge Prep (ArcGIS Pro / ArcPy)

Complete guide to aligning two polygon datasets along shared street boundaries using ArcGIS Pro and ArcPy. Includes working scripts, parameters, QA, troubleshooting — with before/after figures.

Edge Snapper with ArcPy on GitHub

Before & After (Representative Area)

Before: unmatching borders
Picture 1 — Before running the script: Unmatching borders are obvious (purple vs. orange).
After: snapped/trimmed
Picture 2 — After running the script: A (green) is snapped/trimmed to B (orange); edges coincide cleanly.

1) Goal & Approach

Objective. Make A share B’s edge wherever they should touch. A moves; B remains unchanged. Remove overshoot slivers and keep vertex counts minimal.

Method. Use Snap (EDGE + VERTEX) with a data-driven tolerance, tiny temporary densify on A to follow curvature, POINT_REMOVE to thin vertices, then erase overlaps (A ∩ B) from A.

2) Inputs, Outputs, & Requirements

Inputs

CRS: EPSG:2277 (NAD_1983_StatePlane_Texas_Central_FIPS_4203_Feet) so tolerances are in feet. Tools project on the fly if needed.

Primary outputs

3) Parameters (cheat-sheet)

NamePurposeGood StartNotes
FORCE_WKIDProject target WKID2277Feet units for Austin area work.
MAX_SNAP_FTCap for auto tolerance10.0Prevents big moves.
NEAR_PCTILEPercentile for A→B distances0.95Raise to 0.98 if needed.
TEMP_DENSIFY_FTTemporary densify on A onlyNoneUses min(2.0, tol/3).
POST_SIMPLIFY_FTVertex thinning after trim0.2 ftRaise (0.3–0.5) to drop more points.
MAKE_OVERLAP_DIAGSave overlap polygons (QA)TrueTurn off once confident.

4) “First Functioning Attempt” (Baseline)

Auto-tolerance SNAP A→B + tiny temp densify on A + POINT_REMOVE. B unchanged; no overshoot trimming.

# ArcGIS Pro / ArcPy 3.x
# Baseline: Auto-tolerance SNAP A -> B, tiny temp densify A, then POINT_REMOVE thin.
import arcpy, os, math, traceback

# ---------- YOUR PATHS ----------
FC_A = r"G:\ATD\ACTIVE TRANS\Vision Zero\GIS\New Vision Zero Polygons_2025\New Vision Zero Polygons.gdb\New_Vision_Zero_Polygon_Test_For_Snapping"
FC_B = r"G:\ATD\ACTIVE TRANS\Vision Zero\GIS\New Vision Zero Polygons_2025\New Vision Zero Polygons.gdb\Current_Vision_Zero_Test_For_Snapping"
OUT_GDB = r"G:\ATD\ACTIVE TRANS\Vision Zero\GIS\New Vision Zero Polygons_2025\New Vision Zero Polygons.gdb"

A_OUT = os.path.join(OUT_GDB, "New_VZP_Test_Snapped_AtoB")
B_OUT = os.path.join(OUT_GDB, "Current_VZP_Test_Ref_Copy")

# ---------- SETTINGS (feet; WKID 2277 feet) ----------
FORCE_WKID         = 2277
REPAIR_GEOMETRY    = True
MAX_SNAP_FT        = 8.0
NEAR_PCTILE        = 0.95
TEMP_DENSIFY_FT    = None      # if None -> min(2.0, tol/3)
POST_SIMPLIFY_FT   = 0.2

def msg(s):
    try: arcpy.AddMessage(s)
    except: pass
    print(s)

def prj_or_copy(src, out_path, target_sr):
    if arcpy.Exists(out_path): arcpy.management.Delete(out_path)
    if target_sr and arcpy.Describe(src).spatialReference.factoryCode != target_sr.factoryCode:
        arcpy.management.Project(src, out_path, target_sr)
    else:
        arcpy.management.CopyFeatures(src, out_path)
    return out_path

def pctile(vals, p):
    if not vals: return None
    vals = sorted(vals)
    k = max(0, min(len(vals)-1, int(round((len(vals)-1)*p))))
    return vals[k]

def main():
    arcpy.env.workspace = OUT_GDB
    arcpy.env.overwriteOutput = True

    sr_target = arcpy.SpatialReference(FORCE_WKID) if FORCE_WKID else None

    msg("Preparing inputs…")
    A_proj = prj_or_copy(FC_A, os.path.join(OUT_GDB, "A_proj"), sr_target)
    B_proj = prj_or_copy(FC_B, os.path.join(OUT_GDB, "B_proj"), sr_target)
    if REPAIR_GEOMETRY:
        arcpy.management.RepairGeometry(A_proj, "DELETE_NULL")
        arcpy.management.RepairGeometry(B_proj, "DELETE_NULL")

    B_lines = os.path.join(OUT_GDB, "B_boundary_lines")
    if arcpy.Exists(B_lines): arcpy.management.Delete(B_lines)
    arcpy.management.PolygonToLine(B_proj, B_lines, "IGNORE_NEIGHBORS")

    msg("Measuring A→B vertex distances (NEAR)…")
    A_verts = os.path.join(OUT_GDB, "A_vertices")
    if arcpy.Exists(A_verts): arcpy.management.Delete(A_verts)
    arcpy.management.FeatureVerticesToPoints(A_proj, A_verts, "ALL")
    arcpy.analysis.Near(A_verts, B_lines)
    dists = [r[0] for r in arcpy.da.SearchCursor(A_verts, ["NEAR_DIST"]) if r[0] is not None]
    tol = min(MAX_SNAP_FT, max(0.5, pctile(dists, NEAR_PCTILE)))
    msg(f"Auto snap tolerance (p{int(NEAR_PCTILE*100)}): {tol:.2f} ft (cap {MAX_SNAP_FT} ft)")

    if arcpy.Exists(A_OUT): arcpy.management.Delete(A_OUT)
    if arcpy.Exists(B_OUT): arcpy.management.Delete(B_OUT)
    arcpy.management.CopyFeatures(A_proj, A_OUT)
    arcpy.management.CopyFeatures(B_proj, B_OUT)

    d_int = TEMP_DENSIFY_FT if TEMP_DENSIFY_FT is not None else min(2.0, tol/3.0)
    if d_int > 0:
        msg(f"Temporary densify A @ ~{d_int:.2f} ft …")
        arcpy.edit.Densify(A_OUT, "DISTANCE", d_int)

    msg(f"Snapping A → B (EDGE + VERTEX) @ {tol:.2f} ft …")
    arcpy.edit.Snap(A_OUT, [[B_OUT, "EDGE", tol], [B_OUT, "VERTEX", tol]])

    if POST_SIMPLIFY_FT and POST_SIMPLIFY_FT > 0:
        A_simplified = A_OUT + "_simp"
        if arcpy.Exists(A_simplified): arcpy.management.Delete(A_simplified)
        msg(f"Simplifying (POINT_REMOVE) @ {POST_SIMPLIFY_FT:.2f} ft …")
        arcpy.cartography.SimplifyPolygon(
            in_features=A_OUT,
            out_feature_class=A_simplified,
            algorithm="POINT_REMOVE",
            tolerance=f"{POST_SIMPLIFY_FT} Feet",
            minimum_area=None,
            error_option="RESOLVE_ERRORS",
            collapsed_point_option="NO_KEEP"
        )
        arcpy.management.Delete(A_OUT)
        arcpy.management.Rename(A_simplified, os.path.basename(A_OUT))

    msg("Done.")
    msg(f"A (snapped) → {A_OUT}")
    msg(f"B (reference copy) → {B_OUT}")

if __name__ == "__main__":
    try:
        main()
    except arcpy.ExecuteError:
        msg("ArcPy tool error:")
        msg(arcpy.GetMessages(2))
        raise
    except Exception as e:
        msg("Python exception:")
        msg(str(e))
        msg(traceback.format_exc())
        raise

5) Current Recommended Script (Lock-Safe, Overshoot Trim)

Adds lock-safe temp handling, trims overlaps (A ∩ B) from A after snapping, keeps the same auto-tolerance and minimal vertex strategy.

# ArcGIS Pro / ArcPy 3.x
# v2.2: A -> B snap (auto tolerance) + overshoot trim; resilient to locks & in_memory issues
import arcpy, os, uuid, traceback

# ---------- YOUR PATHS ----------
FC_A    = r"G:\ATD\ACTIVE TRANS\Vision Zero\GIS\New Vision Zero Polygons_2025\New Vision Zero Polygons.gdb\New_Vision_Zero_Polygon_Test_For_Snapping"
FC_B    = r"G:\ATD\ACTIVE TRANS\Vision Zero\GIS\New Vision Zero Polygons_2025\New Vision Zero Polygons.gdb\Current_Vision_Zero_Test_For_Snapping"
OUT_GDB = r"G:\ATD\ACTIVE TRANS\Vision Zero\GIS\New Vision Zero Polygons_2025\New Vision Zero Polygons.gdb"

A_OUT_NAME        = "New_VZP_Test_Snapped_AtoB"
B_OUT_NAME        = "Current_VZP_Test_Ref_Copy"
OVERLAP_DIAG_BAS  = "A_B_Overlap_DIAG"

# ---------- SETTINGS (feet; WKID 2277) ----------
FORCE_WKID        = 2277       # set None to keep A's CRS
REPAIR_GEOMETRY   = True
MAX_SNAP_FT       = 10.0
NEAR_PCTILE       = 0.95       # 95th pct of A->B vertex dists = tolerance
TEMP_DENSIFY_FT   = None       # if None -> min(2.0, tol/3)  (A only, temporary)
POST_SIMPLIFY_FT  = 0.2        # light vertex thinning after trim
MAKE_OVERLAP_DIAG = True

# ---------- ENV ----------
arcpy.env.overwriteOutput = True
arcpy.env.addOutputsToMap = False  # avoid creating fresh locks

# ---------- HELPERS ----------
def msg(s):
    try: arcpy.AddMessage(s)
    except: pass
    print(s)

def ws_chain():
    """Preferred temp workspaces, in order: in_memory -> scratchGDB -> OUT_GDB."""
    chain = []
    try:
        chain.append("in_memory")
    except Exception:
        pass
    if arcpy.env.scratchGDB:
        chain.append(arcpy.env.scratchGDB)
    chain.append(OUT_GDB)
    return chain

def unique_out(base_name, ws):
    """Return a unique path (never delete existing), to avoid lock errors."""
    cand = os.path.join(ws, base_name) if ws != "in_memory" else base_name
    if not arcpy.Exists(cand):
        return cand
    import uuid
    uid = uuid.uuid4().hex[:6]
    return os.path.join(ws, f"{base_name}_{uid}") if ws != "in_memory" else f"{base_name}_{uid}"

def project_or_copy_fresh(src, target_sr):
    """
    Project/copy to the first workspace that succeeds.
    Returns the CREATED dataset path (from GP result), not just the intended path.
    """
    base = os.path.basename(src) + "_proj"
    for ws in ws_chain():
        try:
            out = unique_out(base, ws)
            if target_sr and arcpy.Describe(src).spatialReference.factoryCode != target_sr.factoryCode:
                res = arcpy.management.Project(src, out, target_sr)
            else:
                res = arcpy.management.CopyFeatures(src, out)
            created = res[0]
            if arcpy.Exists(created):
                return created
        except Exception:
            continue
    raise RuntimeError("Could not create a projected/copy workspace dataset for: " + src)

def ensure_output(base_name):
    """Pick a writable output path in OUT_GDB (never delete; auto-suffix if locked)."""
    path = os.path.join(OUT_GDB, base_name)
    if arcpy.Exists(path):
        try:
            arcpy.management.Delete(path)
            return path
        except Exception:
            import uuid
            uid = uuid.uuid4().hex[:6]
            newp = os.path.join(OUT_GDB, f"{base_name}_{uid}")
            print(f"⚠️  {base_name} is locked; writing to {os.path.basename(newp)} instead.")
            return newp
    return path

def pctile(vals, p):
    vals = sorted(vals)
    if not vals: return None
    k = max(0, min(len(vals)-1, int(round((len(vals)-1)*p))))
    return vals[k]

def erase_safe(in_fc, erase_fc, out_fc):
    """Erase with fallbacks; writes to out_fc."""
    try:
        arcpy.analysis.Erase(in_fc, erase_fc, out_fc)
        return
    except Exception:
        try:
            arcpy.analysis.PairwiseErase(in_fc, erase_fc, out_fc)
            return
        except Exception:
            # Identity fallback
            ws = ws_chain()[0]
            tmp = unique_out("id_tmp", ws)
            arcpy.analysis.Identity(in_fc, erase_fc, tmp, "NO_RELATIONSHIPS")
            fld = next((f.name for f in arcpy.ListFields(tmp) if f.name.upper().startswith("FID_")), None)
            if not fld:
                raise RuntimeError("Identity fallback failed: FID_* field not found.")
            where = f"{arcpy.AddFieldDelimiters(tmp, fld)} = -1"
            arcpy.management.MakeFeatureLayer(tmp, "id_lyr", where)
            arcpy.management.CopyFeatures("id_lyr", out_fc)

# ---------- MAIN ----------
def main():
    try:
        # 0) Prepare / project to a safe temp WS
        target_sr = arcpy.SpatialReference(FORCE_WKID) if FORCE_WKID else None
        print("Preparing inputs…")
        A_proj = project_or_copy_fresh(FC_A, target_sr)
        B_proj = project_or_copy_fresh(FC_B, target_sr)

        if REPAIR_GEOMETRY:
            arcpy.management.RepairGeometry(A_proj, "DELETE_NULL")
            arcpy.management.RepairGeometry(B_proj, "DELETE_NULL")

        # 1) Build B boundary (temp WS)
        ws = ws_chain()[0]
        B_lines = unique_out("B_boundary_lines", ws)
        arcpy.management.PolygonToLine(B_proj, B_lines, "IGNORE_NEIGHBORS")

        # 2) Auto-pick snap tolerance from A vertex distances to B boundary
        print("Measuring A→B vertex distances (NEAR)…")
        A_verts = unique_out("A_vertices", ws)
        arcpy.management.FeatureVerticesToPoints(A_proj, A_verts, "ALL")
        arcpy.analysis.Near(A_verts, B_lines)
        dists = [r[0] for r in arcpy.da.SearchCursor(A_verts, ["NEAR_DIST"]) if r[0] is not None]
        if not dists:
            raise RuntimeError("No NEAR distances computed; check geometry.")
        tol = min(MAX_SNAP_FT, max(0.5, pctile(dists, NEAR_PCTILE)))
        print(f"Auto snap tolerance (p{int(NEAR_PCTILE*100)}): {tol:.2f} ft (cap {MAX_SNAP_FT} ft)")

        # 3) Create FINAL outputs (handle locks gracefully)
        A_OUT = ensure_output(A_OUT_NAME)
        B_OUT = ensure_output(B_OUT_NAME)
        arcpy.management.CopyFeatures(A_proj, A_OUT)
        arcpy.management.CopyFeatures(B_proj, B_OUT)

        # 4) TEMP densify A (tiny) + SNAP A→B
        d_int = TEMP_DENSIFY_FT if TEMP_DENSIFY_FT is not None else min(2.0, tol/3.0)
        if d_int > 0:
            print(f"Temporary densify A @ ~{d_int:.2f} ft …")
            arcpy.edit.Densify(A_OUT, "DISTANCE", d_int)

        print(f"Snapping A → B (EDGE + VERTEX) @ {tol:.2f} ft …")
        arcpy.edit.Snap(A_OUT, [[B_OUT, "EDGE", tol], [B_OUT, "VERTEX", tol]])

        # 5) Overshoot trim (remove A ∩ B from A)
        print("Trimming A by B to remove overshoot slivers…")
        A_trim = unique_out("A_trim", ws)
        erase_safe(A_OUT, B_OUT, A_trim)
        arcpy.management.Delete(A_OUT)
        arcpy.management.CopyFeatures(A_trim, A_OUT)

        # 6) Light vertex thinning (POINT_REMOVE)
        if POST_SIMPLIFY_FT and POST_SIMPLIFY_FT > 0:
            A_simp = unique_out("A_simp", ws)
            print(f"Simplifying (POINT_REMOVE) @ {POST_SIMPLIFY_FT:.2f} ft …")
            arcpy.cartography.SimplifyPolygon(
                in_features=A_OUT,
                out_feature_class=A_simp,
                algorithm="POINT_REMOVE",
                tolerance=f"{POST_SIMPLIFY_FT} Feet",
                minimum_area=None,
                error_option="RESOLVE_ERRORS",
                collapsed_point_option="NO_KEEP"
            )
            arcpy.management.Delete(A_OUT)
            arcpy.management.CopyFeatures(A_simp, A_OUT)

        print("Done.")
        print(f"A (snapped & overshoot-trimmed) → {A_OUT}")
        print(f"B (reference copy) → {B_OUT}")

    except arcpy.ExecuteError:
        print("ArcPy tool error:")
        print(arcpy.GetMessages(2))
        raise
    except Exception as e:
        print("Python exception:")
        print(str(e))
        raise

if __name__ == "__main__":
    main()

6) How to Run

  1. Open ArcGIS Pro → Python pane or Notebook.
  2. Paste the recommended script and update the three paths at the top.
  3. Run; refresh the GDB in Catalog; add outputs as needed.

7) Validation / QA

8) Troubleshooting

Locks / “table is being edited”. Close attribute tables, stop edit sessions, remove layers from the TOC. v2.2 avoids deleting locked outputs and will use suffixed names.

Nothing moved. Raise MAX_SNAP_FT (12–15) or NEAR_PCTILE (0.98). Ensure no selection limits features.

Too many vertices. Increase POST_SIMPLIFY_FT to 0.3–0.5 ft. TEMP_DENSIFY_FT stays tiny.

9) Optional Variants

Strict rebuild from lines: PolygonToLine → tiny Densify → Integrate (lines) → FeatureToPolygon. Use when snap+trim isn’t sufficient; requires attribute reconciliation.

Optional Integrate cleanup: A light Integrate (≤ snap tol) after snapping can help in some cases; keep optional due to potential locks.

10) Changelog & Customization

Baseline → v2 (overshoot trim) → v2.1 (lock avoidance) → v2.2 (robust temp handling). Customize FORCE_WKID, tolerances, and output names to your environment.

© Your organization. Adapt as needed for internal use.