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.

Buildable footprint by lot width: per-edge against a uniform buffer Line chart of buildable footprint in square metres against lot width from 12 to 60 metres, on a 40 metre deep lot with a 25 foot front setback, 8 foot sides and a 20 foot rear. The per-edge result rises steadily from about 190 square metres at 12 metres wide to about 1 040 at 60 metres. The uniform buffer at the front setback returns zero for lots under about 16 metres wide and stays well below the per-edge line throughout, because it applies the front setback to the side edges too. Buildable footprint by lot width: per-edge against a uniform buffer 0 500 1000 1500 2000 12 m 15 m 18 m 24 m 30 m 40 m 60 m Lot width (40 m deep) Buildable footprint (m²) per-edge offsets uniform buffer at the front setback
Computed from the offsets in the code above. The uniform buffer returns zero on lots under about 16 m wide — reporting genuinely developable urban parcels as having no capacity — and understates the rest by applying a 25 ft front setback to edges the ordinance gives 8 ft.

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.

How each edge is classified, and what happens if the classifier is wrong A four-row by three-column matrix of edge classifications. Rows are the longest edge adjacent to a street, a second street-adjacent edge on a corner lot, the edge furthest from the frontage, and the remaining edges. Columns are the classification, the setback applied, and the consequence of misclassifying it. Classifying a rear edge as frontage transposes a 20 foot setback with a 25 foot one; classifying a side edge as frontage costs 17 feet of width on each side. How each edge is classified, and what happens if the classifier is wrong classified as setback applied if misclassified Longest street-adjacent edge front 25 ft transposes front and rear Second street-adjacent edge (corner) front 25 ft overstates the envelope if missed Furthest edge from the frontage rear 20 ft small error — 5 ft Everything else side 8 ft costs 17 ft per side if read as front as intended minor material
The consequence column is why the classifier records a reason for every edge. Mistaking a side edge for frontage costs 17 ft of width on each side — the single largest error available in this calculation, and it happens whenever an alley is in the street centreline layer.
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.

Three envelope failures and how to tell them apart A three-row by three-column matrix of envelope failures. Rows are capacity computed with a uniform buffer, an empty envelope on an obviously developable lot, and frontage classified on the wrong edge. Columns are the symptom, how to distinguish the cause, and the fix. An empty envelope on a 40 metre wide lot with 8 foot side setbacks indicates an inverted offset direction rather than a genuinely unbuildable parcel. Three envelope failures and how to tell them apart symptom how to tell fix Uniform buffer used capacity uniformly low compare per-edge on a batch recompute, diff, re-derive Empty on a developable lot zero on a wide lot check one lot by hand fix the normal direction Frontage on the wrong edge front and rear transposed alleys in the street layer filter by functional class straightforward systematic materially wrong
The middle row is the one worth checking by hand: if a 40 m wide lot with 8 ft side setbacks comes back empty, the offset direction is inverted — no lot that wide is consumed by those setbacks. One manual check settles it in a minute.

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.