Works out the buildable area of a lot from its setbacks, the height a sky-exposure plane allows, and the coverage and floor area ratio of the masses on the lot against the limits the person typed.
How the precision is kept
All arithmetic is 64-bit on plain point objects. Straight lot lines are used as they are; a curved lot line is replaced by chords that stay within 0.01 project units of the arc, so the lot area and every ratio built on it carry that chord error for curved lots. Corners of the envelope come from one line-line intersection each. Most tolerances are absolute values in project units.
Calculations under this policy
8 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
The lot as a counter-clockwise ring of points, each tagged with the lot line it starts.
chord angle = 2·acos(1 − 0.01 ÷ R); n = ⌈Δ ÷ chord angle⌉, 2 ≤ n ≤ 400
The acos argument is clamped to [0, 1]. The 0.01 sagitta is absolute in project units, not scaled to the lot, and the 400-chord cap can exceed it on a very large arc. Points on the arc come from the center, radius and Math.cos and Math.sin of the stepped angle.
zoning.js
lines 56 to 72
CivilCraft 0.8.1
/** The lot as a counter-clockwise ring of points; a point's `course` is the lot line that STARTS there. Arcs as chords. */
export function lotRing(parcel) {
const cs = courses(parcel.pts), ring = [];
cs.forEach((c, i) => {
ring.push({ x: c.from.x, y: c.from.y, course: i });
if (c.kind === 'curve') {
const a0 = Math.atan2(c.from.y - c.c.y, c.from.x - c.c.x), step = 2 * Math.acos(Math.max(0, Math.min(1, 1 - 0.01 / Math.max(c.R, 0.02)))) || Math.PI / 8;
const n = Math.max(2, Math.min(400, Math.ceil(c.delta / step)));
for (let k = 1; k < n; k++) { const a = a0 + c.turn * c.delta * (k / n); ring.push({ x: c.c.x + c.R * Math.cos(a), y: c.c.y + c.R * Math.sin(a), course: i }); }
}
});
if (signedArea(ring) < 0) { // keep the course tags: reverse so the point that starts a line now ends it
const r = ring.slice().reverse();
return r.map((p, k) => ({ x: p.x, y: p.y, course: r[(k + 1) % r.length].course }));
}
return ring;
}
Setback distance for each lot line
64-bit
Assigns the front, side or rear distance, or a typed distance, to every lot line, and picks the rear line when none is named.
rear = the line with −cos(dir − front dir) > 0.5 whose midpoint is farthest from the front line's midpoint
The distances are copied, not computed. The rear line is chosen by a cosine threshold of 0.5 and a midpoint distance from Math.hypot; for a curved line the midpoint used is the chord midpoint. The bitwise | 0 is applied to the line index only.
zoning.js
lines 79 to 100
CivilCraft 0.8.1
// ------------------------------------------------------------------ setbacks
export function courseSetbacks(parcel, zone) {
const cs = courses(parcel.pts), n = cs.length;
const mid = (c) => (c.kind === 'line' ? { x: (c.from.x + c.to.x) / 2, y: (c.from.y + c.to.y) / 2 } : { x: (c.from.x + c.to.x) / 2, y: (c.from.y + c.to.y) / 2 });
const f = Math.min(Math.max(0, zone.frontCourse | 0), n - 1);
let rear = zone.rearCourse;
if (!(Number.isInteger(rear) && rear >= 0 && rear < n && rear !== f)) { // the line most opposite the front one: facing the other way and farthest from it
const fm = mid(cs[f]), fd = cs[f].dir ?? cs[f].chordDir ?? 0;
let best = -Infinity; rear = null;
cs.forEach((c, i) => {
if (i === f) return;
const d = c.dir ?? c.chordDir ?? 0, anti = -Math.cos(d - fd), m = mid(c), far = Math.hypot(m.x - fm.x, m.y - fm.y);
const score = anti > 0.5 ? far : -Infinity;
if (score > best) { best = score; rear = i; }
});
}
return cs.map((_, i) => {
const typed = zone.setbacks?.[String(i)];
if (fin(typed)) return typed;
return i === f ? zone.front : i === rear ? zone.rear : zone.side;
});
}
Buildable envelope and its area
64-bit
The lot taken in from every line by that line's setback, with its area, or the reason it cannot be built.
area = |shoelace(envelope)|; lot area = |shoelace(lot ring)|
Distances count as uniform when they differ by less than 1e-12, absolute; then the offset is done by offsetFeature in featureline.js. The result is kept only if it does not self-cross, has positive area above 1e-9 and every vertex is inside the lot or within 1e-7 of its boundary; these three thresholds are absolute.
zoning.js
lines 102 to 120
CivilCraft 0.8.1
/** The lot taken in from every line by that line's distance, mitred; or why it cannot be. */
export function envelope(parcel, zone) {
const ring = lotRing(parcel), lotArea = Math.abs(signedArea(ring));
const dist = courseSetbacks(parcel, zone);
const per = ring.map((p) => dist[p.course] || 0); // the distance for the ring edge starting at each point
const base = { ring, courses: dist.length, distances: dist, lotArea };
if (per.every((d) => d === 0)) return { ...base, poly: ring.map((p) => ({ x: p.x, y: p.y })), area: lotArea, problem: null, uniform: true };
const uniform = per.every((d) => Math.abs(d - per[0]) < 1e-12);
let poly = null, problem = null;
if (uniform) { // the same distance all round is a plain offset: featureline.js does it (the lot's interior is to the left, so a negative distance)
const r = offsetFeature({ pts: ring.map((p) => ({ x: p.x, y: p.y, z: 0 })), closed: true }, -per[0]);
if (r.f) poly = r.f.pts.map((p) => ({ x: p.x, y: p.y })); else problem = r.problem || 'fold';
} else {
const r = mitreInset(ring, per);
if (r.poly) poly = r.poly; else problem = r.problem;
}
if (poly && !(simple(poly) && Math.abs(signedArea(poly)) > 1e-9 && signedArea(poly) > 0 && poly.every((p) => inside(p, ring) || onEdge(p, ring)))) { poly = null; problem = 'fold'; }
return { ...base, poly, area: poly ? Math.abs(signedArea(poly)) : 0, problem, uniform };
}
Mitred inset with a distance per line
64-bit
Moves each lot line in by its own distance and finds each new corner where two moved lines meet.
s = ((q − p) × u_q) ÷ (u_p × u_q); corner = p + s·u_p
Directions are normalized first, so the 1e-9 parallel test is on the sine of the angle between the lines. For nearly parallel lines above that threshold the division by a small cross product magnifies rounding in the corner position; a corner more than 1e7 along the line is refused. Edges shorter than 1e-12 are refused.
zoning.js
lines 122 to 146
CivilCraft 0.8.1
/** Per-edge mitre: edge i (ring[i] → ring[i+1]) moved in (to its left) by per[i]; corner i = where edges i−1 and i meet. */
export function mitreInset(ring, per) {
const n = ring.length, E = [];
for (let i = 0; i < n; i++) {
const a = ring[i], b = ring[(i + 1) % n], L = Math.hypot(b.x - a.x, b.y - a.y);
if (L < 1e-12) return { poly: null, problem: 'repeat' };
const ux = (b.x - a.x) / L, uy = (b.y - a.y) / L, nx = -uy, ny = ux;
E.push({ px: a.x + nx * per[i], py: a.y + ny * per[i], ux, uy, nx, ny }); // a point on the moved line and its direction
}
const out = [];
for (let i = 0; i < n; i++) {
const p = E[(i + n - 1) % n], q = E[i], cr = p.ux * q.uy - p.uy * q.ux;
if (Math.abs(cr) < 1e-9) { // collinear lines: a straight continuation (two different distances on one straight line step; take the later)
if (Math.abs((q.px - p.px) * p.nx + (q.py - p.py) * p.ny) > 1e-9) return { poly: null, problem: 'step' };
out.push({ x: q.px, y: q.py }); continue;
}
// p.p + s p.u = q.p + t q.u
const dx = q.px - p.px, dy = q.py - p.py, s = (dx * q.uy - dy * q.ux) / cr;
if (Math.abs(s) > 1e7) return { poly: null, problem: 'sharp' };
out.push({ x: p.px + s * p.ux, y: p.py + s * p.uy });
}
// every moved edge must keep its direction (a line that turned round has folded over)
for (let i = 0; i < n; i++) { const a = out[i], b = out[(i + 1) % n]; if ((b.x - a.x) * E[i].ux + (b.y - a.y) * E[i].uy <= 1e-12) return { poly: null, problem: 'fold' }; }
return { poly: out, problem: null };
}
Allowed height under a sky-exposure plane
64-bit
The lowest height the cap and the sky-exposure plane allow over a footprint.
s = ((p − a) × (b − a)) ÷ |b − a|; h = h₀ + rise × max(0, s); allowed = min over corners and lines
The distance in from a line is one cross product divided by the line length from Math.hypot. Lines shorter than 1e-12 are skipped. Only the footprint's corners are tested, which the file header justifies for a plane that rises inward.
zoning.js
lines 178 to 194
CivilCraft 0.8.1
/** Lowest height the zone allows over a footprint. */
export function allowedHeight(parcel, zone, pts) {
let height = zone.heightCap > 0 ? zone.heightCap : Infinity, by = zone.heightCap > 0 ? 'cap' : null, at = null;
if (zone.sky) {
const ring = lotRing(parcel), cs = courses(parcel.pts);
const f = Math.min(Math.max(0, zone.frontCourse | 0), cs.length - 1);
const lines = ringEdges(ring).filter((e) => zone.sky.from === 'all' || e.course === f);
for (const p of pts) for (const e of lines) {
const L = Math.hypot(e.b.x - e.a.x, e.b.y - e.a.y);
if (L < 1e-12) continue;
const s = ((p.x - e.a.x) * -(e.b.y - e.a.y) + (p.y - e.a.y) * (e.b.x - e.a.x)) / L; // distance in from the line (the interior is to the left)
const h = zone.sky.h0 + zone.sky.rise * Math.max(0, s);
if (h < height) { height = h; by = 'sky'; at = { x: p.x, y: p.y, course: e.course }; }
}
}
return { height, by, at };
}
Footprint area inside the envelope
64-bit
How much of a building footprint lies inside the buildable envelope.
area = Σ over envelope triangles of |shoelace(footprint clipped to the triangle)|
The triangulation comes from triangulate in massing.js. The pieces are summed in triangle order as absolute values. The setback check then subtracts this sum from the footprint area, two nearly equal numbers when the footprint is fully inside.
zoning.js
lines 197 to 207
CivilCraft 0.8.1
/** Area of a footprint inside the envelope: the envelope cut into triangles, the footprint clipped to each (Sutherland–Hodgman against a convex piece). */
export function insideArea(poly, pts) {
if (!poly || poly.length < 3) return 0;
const tri = triangulate(poly);
let sum = 0;
for (let i = 0; i < tri.length; i += 3) {
const T = [poly[tri[i]], poly[tri[i + 1]], poly[tri[i + 2]]];
sum += Math.abs(signedArea(clipConvex(pts, T)));
}
return sum;
}
Clip a footprint to one triangle
64-bit
The part of a polygon inside a convex, counter-clockwise piece.
side(p) = (b − a) × (p − a); crossing at u = s_p ÷ (s_p − s_q)
A point counts as inside when its side value is zero or more, with no tolerance. The crossing parameter divides by the difference of two side values with opposite signs, so the divisor is never zero.
zoning.js
lines 208 to 221
CivilCraft 0.8.1
function clipConvex(subject, clip) { // clip is counter-clockwise
let out = subject.map((p) => ({ x: p.x, y: p.y }));
for (let i = 0; i < clip.length && out.length; i++) {
const a = clip[i], b = clip[(i + 1) % clip.length], inn = [];
const side = (p) => (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
for (let k = 0; k < out.length; k++) {
const p = out[k], q = out[(k + 1) % out.length], sp = side(p), sq = side(q);
if (sp >= 0) inn.push(p);
if ((sp >= 0) !== (sq >= 0)) { const u = sp / (sp - sq); inn.push({ x: p.x + (q.x - p.x) * u, y: p.y + (q.y - p.y) * u }); }
}
out = inn;
}
return out;
}
Coverage, floor area ratio, setback and height checks
64-bit
Compares the masses on a lot with every limit typed for it and lists each result.
coverage = Σ footprint ÷ lot area; FAR = Σ floor area ÷ lot area; outside = footprint − inside area
Footprint and floor areas come from massArea and massGfa in massing.js and are summed in order. The setback test passes when the area outside is at most 1e-6 times the larger of 1 and the footprint area. Height, floor area ratio and coverage are compared with an absolute slack of 1e-9.
zoning.js
lines 223 to 245
CivilCraft 0.8.1
/** Every limit the person typed, against the masses on this lot. */
export function checkMasses(parcel, zone, masses) {
const env = envelope(parcel, zone), lotArea = env.lotArea, items = [];
const footprint = masses.reduce((s, m) => s + massArea(m), 0), gfa = masses.reduce((s, m) => s + massGfa(m), 0);
const coverage = lotArea ? footprint / lotArea : 0, far = lotArea ? gfa / lotArea : 0;
const any = zone.front || zone.side || zone.rear || Object.keys(zone.setbacks || {}).length;
if (any) {
for (const m of masses) {
if (!env.poly) { items.push({ key: 'setback', ok: false, limit: 0, value: massArea(m), mass: m.id, problem: env.problem }); continue; }
const out = massArea(m) - insideArea(env.poly, m.pts);
items.push({ key: 'setback', ok: out <= 1e-6 * Math.max(1, massArea(m)), limit: 0, value: Math.max(0, out), mass: m.id });
}
}
if (zone.heightCap > 0 || zone.sky) {
for (const m of masses) {
const a = allowedHeight(parcel, zone, m.pts), h = massHeight(m);
items.push({ key: 'height', ok: h <= a.height + 1e-9, limit: a.height, value: h, mass: m.id, by: a.by });
}
}
if (zone.far > 0) items.push({ key: 'far', ok: far <= zone.far + 1e-9, limit: zone.far, value: far });
if (zone.coverage > 0) items.push({ key: 'coverage', ok: coverage <= zone.coverage + 1e-9, limit: zone.coverage, value: coverage });
return { envelope: env, lotArea, coverage, far, items, ok: items.every((i) => i.ok) };
}
In detail
Method
Per-line mitred inset (each lot line moved in by its own distance, neighbors intersected); a uniform setback calls offsetFeature in featureline.js. Area inside the envelope: the envelope is triangulated and the footprint is clipped to each triangle by Sutherland-Hodgman, as the code comment says. Allowed height is the lowest of the cap and h₀ + rise × distance in from the line, over the footprint corners.
Number types
64-bit numbers in plain objects and arrays.
Tolerances
Chord sagitta 0.01 (absolute, project units), at most 400 chords per arc. Uniform setback test 1e-12 absolute. Parallel lines: cross product of unit directions below 1e-9. Offset step on collinear lines 1e-9 absolute. Corner farther than 1e7 along the line is refused as too sharp. Fold test 1e-12. Envelope vertex on the lot boundary within 1e-7. Setback pass when the area outside is at most 1e-6 × max(1, footprint area). Height, floor area ratio and coverage pass with 1e-9 absolute slack.
Limits
Arcs are chords, so a curved lot's area is slightly under or over the true area. The envelope is refused, with a reason, when the inset folds over itself. Lot area is a shoelace sum on full coordinates, without shifting to a local origin.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.