Computing a buildable envelope from setbacks and height limits
The zoning says front 25 feet, side 8 feet, rear 20 feet. The code says parcel.buffer(-25 * 0.3048). On a 60-foot-wide urban lot that removes 50 feet of width for setbacks that should have removed 16, so the computed footprint is roughly a third of what the ordinance allows — and every capacity figure derived from it is wrong in the conservative direction, which is the direction nobody questions. This guide computes the envelope properly: classify each edge, offset each by its own distance, and keep the intermediate geometry. It is the mechanical core of development capacity & buildable area analysis.
Diagnosis: how much the uniform buffer costs jump to heading
Measure it before defending it, because the error scales with lot shape rather than being a constant fraction.
FT = 0.3048
def uniform_vs_per_edge(lot, front_ft, side_ft, rear_ft, frontage_edges):
"""The same lot, both ways. Run this across a batch before deciding the
per-edge work is not worth it."""
uniform = lot.buffer(-front_ft * FT, join_style=2) # the naive version
per_edge = offset_per_edge(lot, front_ft, side_ft, rear_ft, frontage_edges)
return {
"lot_m2": round(lot.area, 1),
"uniform_m2": round(uniform.area, 1) if not uniform.is_empty else 0.0,
"per_edge_m2": round(per_edge.area, 1) if not per_edge.is_empty else 0.0,
"understated_by": (round(1 - uniform.area / per_edge.area, 3)
if per_edge.area else None),
}
Three patterns show up. On a square lot with similar setbacks the two agree closely, which is why the bug survives review. On a narrow deep lot the uniform buffer applies the front setback to both side edges and understates the envelope by half or more. On a shallow wide lot it can return empty, so the parcel is reported as having no buildable area at all — a result that looks like a data problem rather than a code problem.
Step-by-step implementation jump to heading
1. Classify the edges jump to heading
Front, side and rear are not in the data. They are inferred from adjacency to a street centreline, and the inference has to be explicit because it is the step that can be wrong.
from dataclasses import dataclass
from shapely.geometry import LineString, Polygon
STREET_BUFFER_M = 12.0 # how close an edge must be to a centreline to be frontage
@dataclass(frozen=True)
class EdgeClass:
index: int
kind: str # 'front' | 'side' | 'rear'
length_m: float
reason: str
def classify_edges(lot: Polygon, streets) -> list[EdgeClass]:
"""Frontage = adjacent to a street centreline. The edge opposite the longest
frontage is the rear; the remainder are sides. Every classification records the
reason, because a wrong front setback is the most consequential error here."""
coords = list(lot.exterior.coords)
edges = [LineString([coords[i], coords[i + 1]]) for i in range(len(coords) - 1)]
near_street = []
for i, e in enumerate(edges):
d = min((e.distance(s) for s in streets), default=float("inf"))
near_street.append(d <= STREET_BUFFER_M)
if not any(near_street):
# No mapped street adjacency. Do NOT silently pick an edge — the caller must
# know the classification is unavailable so it can apply the conservative rule
# and record that it did.
return []
front_idx = max((i for i, n in enumerate(near_street) if n),
key=lambda i: edges[i].length)
front_mid = edges[front_idx].interpolate(0.5, normalized=True)
rear_idx = max(range(len(edges)),
key=lambda i: edges[i].interpolate(0.5, normalized=True).distance(front_mid))
out = []
for i, e in enumerate(edges):
if i == front_idx:
kind, why = "front", "longest edge adjacent to a street centreline"
elif near_street[i]:
kind, why = "front", "also adjacent to a street (corner lot)"
elif i == rear_idx:
kind, why = "rear", "furthest edge from the frontage midpoint"
else:
kind, why = "side", "neither frontage nor opposite it"
out.append(EdgeClass(i, kind, round(e.length, 2), why))
return out
Note that a corner lot gets two front edges, which is what most ordinances actually require — both street-facing edges take the front setback. A classifier that forces exactly one front edge produces an envelope that is too large on precisely the lots where neighbours complain.
2. Offset each edge by its own distance jump to heading
The robust construction is an intersection of half-planes: for each edge, build the inward-offset half-plane and intersect them all. It handles concave lots, which a per-edge buffer union does not.
def offset_per_edge(lot, front_ft, side_ft, rear_ft, edge_classes) -> Polygon:
"""Intersect one inward half-plane per edge. More robust than buffering each
edge outward and subtracting, which produces artefacts at reflex corners."""
if not edge_classes:
raise NoFrontageDetermination(
"edges are unclassified; the caller must apply the conservative rule "
"and record that it did")
distances = {"front": front_ft * FT, "side": side_ft * FT, "rear": rear_ft * FT}
coords = list(lot.exterior.coords)
envelope = lot
for ec in edge_classes:
a, b = coords[ec.index], coords[ec.index + 1]
d = distances[ec.kind]
if d <= 0:
continue
envelope = envelope.intersection(_inward_halfplane(lot, a, b, d))
if envelope.is_empty:
return envelope
return envelope
def _inward_halfplane(lot, a, b, distance):
"""A large rectangle covering the lot, offset `distance` inward from edge a→b."""
import math
from shapely import affinity
from shapely.geometry import box
dx, dy = b[0] - a[0], b[1] - a[1]
length = math.hypot(dx, dy)
if length == 0:
return lot # degenerate edge: no constraint
ux, uy = dx / length, dy / length
nx, ny = -uy, ux # left normal
# Which side is inward? Test the lot's representative point.
rp = lot.representative_point()
if (rp.x - a[0]) * nx + (rp.y - a[1]) * ny < 0:
nx, ny = -nx, -ny
span = max(lot.bounds[2] - lot.bounds[0], lot.bounds[3] - lot.bounds[1]) * 2 + 10
plane = box(0, 0, span, span)
plane = affinity.rotate(plane, math.degrees(math.atan2(uy, ux)), origin=(0, 0))
plane = affinity.translate(plane, a[0] + nx * distance - 0, a[1] + ny * distance - 0)
return plane
3. Handle the no-frontage case explicitly jump to heading
def buildable_envelope(lot, std, streets):
classes = classify_edges(lot, streets)
notes = []
if not classes:
# Conservative fallback: the LARGEST setback on every edge. This understates
# capacity, which is the safe direction, and the note says so.
worst = max(std.numeric["front_setback_ft"], std.numeric["side_setback_ft"],
std.numeric["rear_setback_ft"])
envelope = lot.buffer(-worst * FT, join_style=2)
notes.append(f"no street adjacency mapped: {worst} ft applied to every edge — "
f"this is a floor, not an estimate")
else:
envelope = offset_per_edge(lot, std.numeric["front_setback_ft"],
std.numeric["side_setback_ft"],
std.numeric["rear_setback_ft"], classes)
fronts = sum(1 for c in classes if c.kind == "front")
if fronts > 1:
notes.append(f"corner lot: front setback applied to {fronts} edges")
return envelope, classes, notes
4. Derive the height and storey count last jump to heading
Height does not change the footprint; it changes what can be stacked on it, so it belongs after the envelope rather than mixed into it.
def stackable(envelope, std, storey_height_ft=11.0):
"""Storeys from the height limit, then floor area from the footprint. The storey
height is an ASSUMPTION and is returned so it travels with the number."""
storeys = int(std.numeric["height_ft"] // storey_height_ft)
return {
"footprint_m2": round(envelope.area, 1),
"storeys": storeys,
"floor_area_m2": round(envelope.area * storeys, 1),
"assumption": f"{storey_height_ft} ft per storey",
}
Verification & testing jump to heading
def test_uniform_buffer_understates_a_narrow_lot():
lot = box(0, 0, 18, 40) # 18 m wide, 40 m deep
classes = classify_edges(lot, [LineString([(-5, 0), (25, 0)])]) # street to the south
per_edge = offset_per_edge(lot, 25, 8, 20, classes)
uniform = lot.buffer(-25 * FT, join_style=2)
assert per_edge.area > uniform.area * 1.5
def test_corner_lot_gets_two_front_edges():
lot = box(0, 0, 30, 30)
streets = [LineString([(-5, 0), (35, 0)]), LineString([(0, -5), (0, 35)])]
classes = classify_edges(lot, streets)
assert sum(1 for c in classes if c.kind == "front") == 2
def test_no_frontage_is_refused_by_the_offsetter():
lot = box(0, 0, 30, 30)
with pytest.raises(NoFrontageDetermination):
offset_per_edge(lot, 25, 8, 20, [])
def test_no_frontage_fallback_is_conservative_and_recorded():
lot = box(0, 0, 30, 30)
env, classes, notes = buildable_envelope(lot, STD, streets=[])
assert not classes
assert any("floor, not an estimate" in n for n in notes)
assert env.area < offset_per_edge(lot, 25, 8, 20, CLASSES_WITH_STREET).area
def test_envelope_never_exceeds_the_lot():
for lot in (box(0, 0, 30, 30), FLAG_LOT, L_SHAPED_LOT):
env, _c, _n = buildable_envelope(lot, STD, STREETS)
assert env.area <= lot.area + 1e-9
The last test is the cheap invariant worth running across a whole batch: an envelope larger than its lot means an offset went outward, which is a sign error rather than a data problem.
Failure recovery jump to heading
Capacity figures computed with a uniform buffer. Every affected parcel understates capacity, and the error is largest on narrow lots. Recompute, then diff: the parcels whose figures move most are the ones any downstream analysis most likely mis-ranked.
An empty envelope on a lot that is obviously developable. Either the setbacks exceed the lot’s short dimension — which is real, and means the lot needs a variance — or the offset direction is inverted. Check one lot by hand: if the envelope is empty on a 40-metre-wide lot with 8-metre side setbacks, the direction is wrong.
Frontage classified on the wrong edge. Usually a street centreline layer that includes alleys, so the rear edge is classified as frontage and the setbacks are transposed. Filter the street layer by functional class before using it, and keep the classification reason so the mistake is visible in the record rather than only in the number.
Frequently asked questions jump to heading
Why not buffer each edge and subtract the union?
Because buffering a line segment produces a rounded or squared cap at each end, and the caps overlap into the neighbouring edge’s allowance, removing area the ordinance permits. Intersecting inward half-planes has no caps and behaves correctly at reflex corners, which an L-shaped or flag lot has by definition.
How do you decide which edge is the front without a street layer?
You do not, and pretending otherwise is the mistake. Without mapped street adjacency the honest move is to apply the largest setback to every edge, which understates capacity, and record that the figure is a floor rather than an estimate. Guessing the front edge produces a number that is wrong in an unknown direction.
Should a corner lot really take the front setback twice?
In most ordinances, yes — both street-facing edges are frontage, though some codes reduce the second one. This is a per-jurisdiction rule and it belongs in the standards rather than in the geometry code. Applying one front setback on a corner lot overstates the envelope on exactly the lots where the neighbours are most likely to object.
Does the height limit affect the footprint?
No, and keeping them separate is what makes the binding constraint legible. Height determines how many storeys stack on the footprint the setbacks and coverage allow, so it enters after the envelope is computed. Mixing them makes it impossible to say whether relaxing the setback or the height would help.
Related jump to heading
- Parent topic: Development Capacity & Buildable Area Analysis
- Section overview: Spatial Impact Analysis & Zoning Change Detection
- Handling irregular and flag lots in setback computation — the shapes where the offset splits the lot
- Overlay District Modeling — where the setback numbers come from
- CRS Alignment Strategies — every distance here needs a projected CRS in metres