Handling Corner Lots with Two Front Setbacks
A corner lot abuts a street on two sides, and almost every zoning code responds by applying a front setback to both — which is the single most common reason an automated envelope is wrong. A pipeline that assumes one frontage per parcel gives a corner lot a side setback where a front setback belongs, and produces a buildable area substantially larger than the code allows. This guide classifies each boundary edge by what it abuts, applies the correct standard per edge, and assembles the envelope, including the case where the standards leave nothing buildable at all. It is the hardest case in dynamic setback buffer generation.
Prerequisites
Step-by-step
Step 1: Break the parcel boundary into edges
The parcel’s exterior ring is a single closed line, and setbacks are per edge, so the first job is to split it at its corners. Splitting on vertices is close enough for the fabric most jurisdictions publish, provided near-collinear vertices are merged first — otherwise a slightly kinked street frontage becomes eleven edges rather than one.
import numpy as np
from shapely.geometry import LineString
def boundary_edges(poly, collinear_tol_deg=8.0):
"""Split the exterior ring into edges, merging near-collinear runs."""
coords = list(poly.exterior.coords)
segs = [LineString([coords[i], coords[i + 1]]) for i in range(len(coords) - 1)]
def bearing(s):
(x0, y0), (x1, y1) = s.coords[0], s.coords[-1]
return np.degrees(np.arctan2(y1 - y0, x1 - x0)) % 180.0
merged, run = [], [segs[0]]
for s in segs[1:]:
if abs(bearing(s) - bearing(run[-1])) < collinear_tol_deg:
run.append(s)
else:
merged.append(run)
run = [s]
merged.append(run)
return [LineString([r[0].coords[0]] + [seg.coords[-1] for seg in r]) for r in merged]
Step 2: Classify each edge by what it abuts
An edge is a frontage if it runs along a street. The robust test is a buffered intersection against the right-of-way rather than a distance to a centreline, because centreline distance misclassifies edges on wide arterials and on parcels set back behind a verge.
import geopandas as gpd
def classify_edges(edges, row_layer, abut_tol=2.0, min_frontage=10.0):
"""Front edges abut the right-of-way over a meaningful length; the rest are not."""
row = row_layer.union_all()
out = []
for e in edges:
shared = e.buffer(abut_tol).intersection(row.boundary).length / 2.0
is_front = shared >= min_frontage
out.append({"geometry": e, "length": e.length,
"abutting_row": shared, "edge_class": "front" if is_front else "unknown"})
return gpd.GeoDataFrame(out, crs=row_layer.crs)
The min_frontage threshold exists because a corner parcel’s chamfered corner — the short diagonal edge many jurisdictions require at intersections — technically abuts both streets. Treating it as a third frontage is wrong, and a minimum length in the region of ten feet excludes it cleanly.
Step 3: Distinguish the primary frontage from the secondary
Where the code reduces the setback on the secondary frontage, something has to decide which is which. Codes vary; the three rules in practice are the narrower frontage, the frontage matching the address, and the frontage on the lower-classification street. Encode whichever the jurisdiction uses rather than assuming.
def assign_frontage_roles(front_edges, policy="narrowest", address_edge_id=None):
if policy == "narrowest":
primary = front_edges["length"].idxmin()
elif policy == "address":
primary = address_edge_id
elif policy == "street_class":
primary = front_edges["street_class_rank"].idxmin()
else:
raise ValueError(f"unknown frontage policy {policy}")
roles = {i: "front_secondary" for i in front_edges.index}
roles[primary] = "front_primary"
return roles
The narrowest-frontage rule is the usual default and matches how lots are conventionally addressed, but it is a default and not a law. Record which policy ran, alongside the verdict — it is exactly the kind of assumption that has to be visible when a determination is challenged.
Step 4: Classify the remaining edges as side or rear
With frontages known, the rest follows from geometry: the rear edge is the one most nearly opposite the primary frontage, and everything else is a side.
def classify_remaining(edges, roles):
primary = edges.loc[[i for i, r in roles.items() if r == "front_primary"][0], "geometry"]
px, py = primary.coords[0], primary.coords[-1]
pb = np.degrees(np.arctan2(py[1] - px[1], py[0] - px[0])) % 180.0
for i, row in edges.iterrows():
if i in roles:
continue
(x0, y0), (x1, y1) = row.geometry.coords[0], row.geometry.coords[-1]
b = np.degrees(np.arctan2(y1 - y0, x1 - x0)) % 180.0
parallel = min(abs(b - pb), 180 - abs(b - pb)) < 30.0
roles[i] = "rear" if parallel else "side"
return roles
A true corner lot often has no rear edge in the conventional sense — two frontages, one side and one rear, or on a triangular corner parcel two frontages and a single side. The classifier should be allowed to return that, rather than being forced to find a rear edge that does not exist.
Step 5: Buffer each edge inward by its own standard, and intersect
The envelope is what remains of the parcel after every edge’s setback is removed. Building it edge-by-edge — rather than by a single negative buffer of the whole parcel — is what allows different standards on different edges.
from shapely.ops import unary_union
STANDARDS = {"front_primary": 25.0, "front_secondary": 15.0, "side": 5.0, "rear": 20.0}
def buildable_envelope(poly, edges, roles, standards=STANDARDS):
"""Remove each edge's setback strip, then keep what is left of the parcel."""
strips = [edges.loc[i, "geometry"].buffer(standards[role], cap_style=2)
for i, role in roles.items()]
envelope = poly.difference(unary_union(strips))
return envelope
cap_style=2 — a flat cap — is important. The default round cap extends the setback strip past the end of its edge in a semicircle, which cuts an arc out of the envelope at every corner and shrinks the buildable area by an amount that grows with the setback. On a 25-foot front setback the difference is around 490 square feet at each corner, which is easily enough to change a verdict.
Step 6: Handle the envelope that collapses
On a narrow corner lot, two front setbacks plus a side and a rear can consume the entire parcel. That is a real outcome the code produces and the pipeline must report it as such rather than crashing or returning an empty geometry silently.
def envelope_result(envelope, parcel_area, min_area=200.0):
if envelope.is_empty or envelope.area < min_area:
return {"buildable": False, "envelope_area": float(envelope.area),
"reason": "setbacks consume the lot — likely a variance case"}
return {"buildable": True, "envelope_area": float(envelope.area),
"envelope_fraction": float(envelope.area / parcel_area)}
A collapsed envelope on a legally created lot is almost always the situation the reduced-secondary-frontage provision exists to address, and where no such provision exists it is a variance case — routed the way variance and exception handling describes, not reported as a violation.
Verification
Two properties should hold on every parcel, and one comparison catches the classic bug directly.
# The envelope is inside the parcel and no closer to any edge than its standard allows.
assert envelope.within(parcel.buffer(1e-9))
for i, role in roles.items():
d = envelope.distance(edges.loc[i, "geometry"])
assert d >= STANDARDS[role] - 1e-6, f"envelope violates the {role} setback"
# A corner lot must lose more area than the same lot treated as having one frontage.
single = parcel.difference(unary_union(
[edges.loc[i, "geometry"].buffer(STANDARDS["side"] if role.startswith("front_sec")
else STANDARDS[role], cap_style=2)
for i, role in roles.items()]))
assert envelope.area <= single.area, "second frontage did not reduce the envelope"
That last assertion is the regression test for the bug this whole guide exists to prevent. If a change ever makes a corner lot’s envelope equal to the single-frontage envelope, the second frontage has stopped being applied.
Common Pitfalls
- Round buffer caps. The default cap style removes an arc at each corner and understates the envelope by hundreds of square feet per corner.
- Treating the chamfer as a frontage. The short diagonal at an intersection abuts both streets; a minimum frontage length excludes it.
- Assuming every parcel has a rear edge. Triangular and wedge corner lots do not, and forcing the classifier to find one produces a nonsense setback on a side.
- Splitting edges on every vertex. A frontage digitised with a slight curve becomes many short edges, each of which then fails the minimum-frontage test and is misclassified as a side.
- Using centreline distance to detect frontage. It misses parcels behind a wide verge and falsely includes parcels near a wide arterial that they do not front on.
Frequently Asked Questions
What if the code does not mention corner lots at all?
Then both street-abutting edges take the front standard, which is the conservative reading and the one most jurisdictions administer. Record it as an assumption and confirm it — the reduced-secondary provision is common enough that its absence from the text is often an omission rather than a decision.
How should a through lot — street on front and rear — be treated?
Usually as two front setbacks and no rear, which the classifier above produces naturally since both edges abut the right-of-way. Some codes let the owner designate one as the rear; that is a per-parcel input rather than something geometry can decide.
Does the envelope need to account for height?
For the setback itself, no — but many codes step the envelope back further as height increases, and a few apply a sky-exposure plane from the front lot line. Those are envelope modifiers layered on top of this footprint, and they need a height model; see computing building height from lidar-derived surfaces.
Should the classified edges be stored?
Yes. The classification is the most contestable part of the result and recomputing it from a changed parcel fabric gives a different answer, so it belongs in the output with the verdict. It also makes the run reviewable: a planner can look at the edge classes for twenty parcels far faster than they can look at twenty envelopes.
How well does the collinear merge tolerance generalise?
Eight degrees works on most published fabrics and is worth checking against yours by counting edges per parcel: a well-digitised rectangular lot should yield four. If the median edge count is seven or eight, the tolerance is too tight for that fabric’s vertex density. If corner lots are coming out with three edges, it is too loose and a real corner is being merged away.
What about parcels that abut a private street or an access easement?
Whether a private way creates a frontage is a code question, not a geometry question, and codes differ. The mechanism is the same either way — include or exclude the private ways from the right-of-way layer used in classification, and record which was done. What is not acceptable is having the answer depend on whether the private ways happened to be in the layer someone downloaded.
Can this be vectorized across a county?
The edge classification vectorizes well — the buffered intersection against the right-of-way is one indexed pass. The per-edge buffering and difference is inherently per-parcel, but it is fast, and it parallelises cleanly by tile as described in chunking county-scale runs by spatial tile.
Related
Part of: Dynamic setback buffer generation
- Calculating variable setbacks based on street frontage in Python — the single-frontage case this extends.
- Variance and exception handling — where a collapsed envelope goes.
- Detecting setback encroachments with spatial joins — testing an existing structure against this envelope.
- Handling edge cases in parcel boundary alignment — the fabric quality edge classification needs.
- Computing building height from lidar-derived surfaces — the vertical half of the envelope.