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 GitHubBefore & After (Representative Area)
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
- A:
New_Vision_Zero_Polygon_Test_For_Snapping
- B:
Current_Vision_Zero_Test_For_Snapping
- OUT_GDB:
New Vision Zero Polygons.gdb
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
- A (snapped & cleaned):
New_VZP_Test_Snapped_AtoB
- B (unchanged reference copy):
Current_VZP_Test_Ref_Copy
- Optional QA:
A_B_Overlap_DIAG
(what was trimmed; may be suffixed if locked)
3) Parameters (cheat-sheet)
Name | Purpose | Good Start | Notes |
---|---|---|---|
FORCE_WKID | Project target WKID | 2277 | Feet units for Austin area work. |
MAX_SNAP_FT | Cap for auto tolerance | 10.0 | Prevents big moves. |
NEAR_PCTILE | Percentile for A→B distances | 0.95 | Raise to 0.98 if needed. |
TEMP_DENSIFY_FT | Temporary densify on A only | None | Uses min(2.0, tol/3). |
POST_SIMPLIFY_FT | Vertex thinning after trim | 0.2 ft | Raise (0.3–0.5) to drop more points. |
MAKE_OVERLAP_DIAG | Save overlap polygons (QA) | True | Turn 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
- Open ArcGIS Pro → Python pane or Notebook.
- Paste the recommended script and update the three paths at the top.
- Run; refresh the GDB in Catalog; add outputs as needed.
7) Validation / QA
- Visual edge check with contrasting outlines.
- Overlap diagnostics (
A_B_Overlap_DIAG
) should be empty/minimal. - Optional: build a GDB topology and validate Must Not Overlap (Area) and Must Not Have Gaps (Area).
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.