Planting: bed and hedge fills, planted areas and their quantities
Fills beds, hedges and planted areas with plants at a spacing, counts each species, and totals areas, soil and mulch volumes and edging lengths.
How the precision is kept
64-bit numbers throughout. A project foot is the US survey foot, written as the exact fraction 1200 ÷ 3937 m. Plant positions come from a grid stepped across the bed, kept when a point-in-polygon test and an edge distance of half a spacing less 1e-9 pass. Species counts are whole numbers from a largest-remainder split. Planted-area quantities use the clipped region from areas.js, and the values handed to TakeoffCraft are rounded to 2 decimals.
Open item. Hand TakeoffCraft the unrounded areas, volumes and lengths and leave rounding to the display.
Calculations under this policy
11 calculations in 2 source files. 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 length of one project unit in meters, used by every landscape calculation that works in meters.
1 ft = 1200 ÷ 3937 m (US survey foot); 1 m = 1
The US survey foot is written as the fraction 1200 ÷ 3937, its exact definition, and evaluated once in 64-bit. It differs from the international foot of 0.3048 m by 2 parts in a million.
landscape/planting.js
lines 55 to 56
CivilCraft 0.8.1
const FT_M = 1200 / 3937; // a US survey foot in metres (CivilCraft's feet)
export const unitM = (units) => (units === 'm' ? 1 : FT_M);
Bed area
64-bit
The plan area of a bed outline.
A = |Σ (xⱼ + xᵢ)(yⱼ − yᵢ)| ÷ 2
The trapezoid form of the shoelace sum on the coordinates as given, in vertex order. Coordinates are not shifted to a local origin, so for a small bed far from the origin the terms are large and nearly cancel.
landscape/planting.js
lines 123 to 124
CivilCraft 0.8.1
// ---- geometry ----
function area(pts) { let a = 0; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) a += (pts[j].x + pts[i].x) * (pts[j].y - pts[i].y); return Math.abs(a) / 2; }
Whole plant counts from percentages
64-bit
Splits a number of plants among species so the counts add up to exactly that number.
count = ⌊pct ÷ Σpct × n⌋, then the leftover goes to the largest remainders
Counts are integers and always sum to n. The remainders that decide who gets a leftover plant are 64-bit fractions; ties go to the earlier species.
landscape/planting.js
lines 146 to 154
CivilCraft 0.8.1
/** Whole counts from percentages that add up to exactly n (largest remainder). */
export function shares(mix, n) {
const tot = mix.reduce((a, m) => a + m.pct, 0) || 1;
const raw = mix.map((m) => (m.pct / tot) * n), out = raw.map(Math.floor);
let left = n - out.reduce((a, b) => a + b, 0);
const order = raw.map((r, i) => [r - Math.floor(r), i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
for (let k = 0; left > 0; k = (k + 1) % order.length, left--) out[order[k][1]]++;
return out;
}
Spacing from the plant list
64-bit
The spacing a bed or hedge is filled at when none is typed: the species' spacings weighted by their share.
s = Σ (pct × spacing) ÷ Σ pct
A weighted mean in meters over the species the list knows. A hedge with an unknown species falls back to 0.4 m and a mix with none known to 0.5 m.
landscape/planting.js
lines 174 to 182
CivilCraft 0.8.1
/** The spacing the plant list gives (m): the current list, or `edition`'s where a species changed since. */
export function listSpacingM(it, index, edition = PLANT_LIST) {
const old = edition < PLANT_LIST ? LIST_CHANGES[edition] || {} : {};
const of = (key) => { if (old[key] != null) return old[key]; const sp = index.get(key); return sp ? spacingOf(sp, it.pattern) : null; }; // LA.6: a density gives the spacing for the bed's pattern
if (!it.mix) return of(it.key) ?? 0.4;
let w = 0, s = 0;
for (const m of it.mix) { const v = of(m.key); if (v != null) { w += m.pct; s += m.pct * v; } }
return w > 0 && s > 0 ? s / w : 0.5;
}
Plants in a bed
64-bit
The position of every plant in a bed at the given spacing and pattern, and the count per species.
grid step s, row pitch s (square) or s·√3 ÷ 2 (triangular); keep when inside and at least s ÷ 2 − 1e-9 from the edge
Grid coordinates are built by adding the step repeatedly from the bed's bounding box, in a frame rotated about the first corner. A place right at half a spacing from an edge is kept or dropped by the 1e-9 slack, which is absolute. The fill stops at 50000 plants.
landscape/planting.js
lines 202 to 238
CivilCraft 0.8.1
/** `keep(x, y)` (0.8 LA.5): a place the fill may use (a planted area clipped around paving); species shares are counted after it. */
export function bedFill(bed, index, units, keep = null) {
const um = unitM(units), sM = bedSpacingM(bed, index), s = sM / um;
const pts = bed.pts, A = area(pts);
const out = [];
let capped = false;
if (s > 0 && pts.length >= 3) {
const a = (bed.angle * Math.PI) / 180, ca = Math.cos(a), sa = Math.sin(a);
// the bed in the grid's frame (rotated by −angle about its first point)
const o = pts[0];
const loc = pts.map((p) => ({ x: (p.x - o.x) * ca + (p.y - o.y) * sa, y: -(p.x - o.x) * sa + (p.y - o.y) * ca }));
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of loc) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y); }
const tri = bed.pattern !== 'square', dy = tri ? (s * Math.sqrt(3)) / 2 : s, edge = s / 2 - 1e-9;
let row = 0;
for (let y = y0 + s / 2; y <= y1 && !capped; y += dy, row++) {
const shift = tri && row % 2 ? s / 2 : 0;
for (let x = x0 + s / 2 + shift; x <= x1; x += s) {
if (!inside(x, y, loc) || edgeDist(x, y, loc) < edge) continue;
const wx = o.x + x * ca - y * sa, wy = o.y + x * sa + y * ca;
if (keep && !keep(wx, wy, s)) continue;
if (out.length >= MAX_FILL) { capped = true; break; }
out.push({ x: wx, y: wy });
}
}
if (!out.length) { // a bed smaller than one spacing still gets one plant, in its middle when that is inside
const cx = pts.reduce((v, p) => v + p.x, 0) / pts.length, cy = pts.reduce((v, p) => v + p.y, 0) / pts.length;
if (inside(cx, cy, pts) && (!keep || keep(cx, cy, 0))) out.push({ x: cx, y: cy });
}
}
// species by share, at places in a seeded random order (the same bed always fills the same way)
const n = out.length, counts = {}, cnt = shares(bed.mix, n), order = out.map((_, i) => i), r = rng(bed.seed);
for (let i = n - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
let k = 0;
bed.mix.forEach((m, mi) => { for (let c = 0; c < cnt[mi]; c++) { const q = out[order[k++]]; q.key = m.key; q.mi = mi; } counts[m.key] = (counts[m.key] || 0) + cnt[mi]; });
return { pts: out, counts, mixCounts: cnt, spacing: s, area: A, capped };
}
Plants along a hedge
64-bit
The number and positions of plants along a hedge line in one or two rows.
n = ⌊length ÷ s + 1e-9⌋ + 1; first plant at (length − (n − 1)·s) ÷ 2
The length is the sum of Math.hypot segment lengths. The 1e-9 added before the floor keeps a length that is a whole number of spacings from losing a plant to rounding. Positions are start + k × s, computed by multiplication.
landscape/planting.js
lines 240 to 267
CivilCraft 0.8.1
export function hedgeFill(hedge, index, units) {
const sp = index.get(hedge.key), um = unitM(units);
const s = usedSpacingM(hedge, index) / um, gap = hedge.rowGap / um; // 0.7.1: its own, as saved, else the list's
const P = hedge.pts, out = [];
let length = 0, capped = false;
for (let i = 0; i + 1 < P.length; i++) length += Math.hypot(P[i + 1].x - P[i].x, P[i + 1].y - P[i].y);
if (!(s > 0) || length <= 0) return { pts: out, length, spacing: s, capped };
const along = (d) => { // point and unit direction at distance d along the line
let acc = 0;
for (let i = 0; i + 1 < P.length; i++) {
const L = Math.hypot(P[i + 1].x - P[i].x, P[i + 1].y - P[i].y);
if (d <= acc + L || i === P.length - 2) { const f = L ? (d - acc) / L : 0, ux = L ? (P[i + 1].x - P[i].x) / L : 1, uy = L ? (P[i + 1].y - P[i].y) / L : 0; return { x: P[i].x + f * (P[i + 1].x - P[i].x), y: P[i].y + f * (P[i + 1].y - P[i].y), ux, uy }; }
acc += L;
}
return null;
};
// one row centred along the line (the ends get the same margin); a second row sits beside it, between the first row's plants
const n = Math.min(MAX_FILL, Math.max(1, Math.floor(length / s + 1e-9) + 1)), start = (length - (n - 1) * s) / 2;
if (n === MAX_FILL) capped = true;
const rows = hedge.rows === 2 ? [[-gap / 2, 0, n], [gap / 2, s / 2, Math.min(n - 1, MAX_FILL - n)]] : [[0, 0, n]];
for (const [off, shift, count] of rows) {
for (let k = 0; k < count; k++) {
const q = along(start + shift + k * s);
out.push({ x: q.x - q.uy * off, y: q.y + q.ux * off, key: hedge.key });
}
}
return { pts: out, length, spacing: s, capped };
}
Offset of an open line
64-bit
A line moved sideways by a distance with mitred corners, used for hedge and path edges, obstacle footprints and IFC bodies.
corner = p + m × d ÷ max(0.25, m·n₁), m = normalized sum of the two edge normals
The mitre divisor is floored at 0.25, so at corners sharper than that the offset point is nearer than the true mitre and the offset distance is not kept. A zero-length segment gets a divisor of 1 in place of its length.
landscape/planting.js
lines 423 to 436
CivilCraft 0.8.1
/** An open line offset sideways (left = +), mitred. */
export function offsetLine(pts, d) {
const n = pts.length, out = [];
const nrm = (a, b) => { const L = Math.hypot(b.x - a.x, b.y - a.y) || 1; return { x: -(b.y - a.y) / L, y: (b.x - a.x) / L }; };
for (let i = 0; i < n; i++) {
const n0 = i > 0 ? nrm(pts[i - 1], pts[i]) : null, n1 = i < n - 1 ? nrm(pts[i], pts[i + 1]) : null;
let m = n0 && n1 ? { x: n0.x + n1.x, y: n0.y + n1.y } : n0 || n1;
const L = Math.hypot(m.x, m.y) || 1;
m = { x: m.x / L, y: m.y / L };
const cos = n0 && n1 ? Math.max(0.25, m.x * n1.x + m.y * n1.y) : 1;
out.push({ x: pts[i].x + (m.x * d) / cos, y: pts[i].y + (m.y * d) / cos });
}
return out;
}
The topsoil and mulch depths a new planted area starts with, in project units.
feet = round(meters ÷ 0.3048 × 100) ÷ 100
The defaults are typical depths in meters; for a feet project they are divided by 0.3048 and rounded to 2 decimals. They are starting values that the person can change.
landscape/planted.js
lines 35 to 40
CivilCraft 0.8.1
/** Labelled typical build-ups (a designer sets the real ones), in project units. */
export function areaDefaults(kind, units) {
const m = units === 'm', L = (mv) => (m ? mv : Math.round((mv / 0.3048) * 100) / 100);
const d = { lawn: [0.15, 0, ''], bed: [0.3, 0.075, 'steel'], meadow: [0.1, 0, ''], groundcover: [0.2, 0.05, ''] }[kind] || [0.3, 0.075, ''];
return { topsoil: L(d[0]), mulch: L(d[1]), membrane: false, edging: d[2] };
}
Plants in a clipped planted area
64-bit
The plants of a planted area after paving, walls and steps are cut out of it.
keep when inside what is left and at least s ÷ 2 − 1e-9 from a cut edge
Uses the bed fill with an extra test against the clipped region. The distance to a cut edge is the smallest point-to-segment distance over the cut pieces, each from one clamped projection and Math.hypot.
landscape/planted.js
lines 89 to 93
CivilCraft 0.8.1
export function areaFill(a, ls, index, units, region = areaRegion(a, ls)) {
if (!a.mix.length) return { pts: [], counts: {}, mixCounts: [], spacing: 0, area: region.area, capped: false };
// a place is kept when it is in what is left and at least half a spacing from a cut edge (the outline's margin is bedFill's own)
return bedFill(asBed(a), index, units, (x, y, s) => region.inside(x, y) && (!region.cutLength || cutDist(region, x, y) >= s / 2 - 1e-9));
}
Planted area quantities
64-bit
The net area, perimeter, edging length, topsoil and mulch volumes and plant count of a planted area.
topsoil = net area × topsoil depth; mulch = net area × mulch depth; edging = outline length left after clipping
Area and lengths come from clipRegion in areas.js; each volume is one multiplication. These unrounded values are the ones the IFC quantity set receives.
Every quantity is rounded to 2 decimals before it is handed over. The 27 is exact. A small nonzero quantity is listed as 0, because the test for listing it uses the unrounded value.
landscape/planted.js
lines 125 to 137
CivilCraft 0.8.1
export function areaQuantityItems(ls, index, units) {
const m = units === 'm', A = m ? 'm²' : 'SF', L = m ? 'm' : 'LF', V = m ? 'm³' : 'CY', vPer = m ? 1 : 27, r2 = (v) => Math.round(v * 100) / 100; // i18n-ignore: TakeoffCraft's unit symbols
const out = [];
for (const a of ls?.areas || []) {
const q = areaQuantities(a, ls, index, units), k = 'area:' + a.id;
out.push({ key: k + ':area', name: t('ls.a.q.area', { name: a.name }), unit: A, qty: r2(q.area) });
if (q.topsoil) out.push({ key: k + ':topsoil', name: t('ls.a.q.topsoil', { name: a.name }), unit: V, qty: r2(q.topsoil / vPer) });
if (q.mulch) out.push({ key: k + ':mulch', name: t('ls.a.q.mulch', { name: a.name }), unit: V, qty: r2(q.mulch / vPer) });
if (q.membrane) out.push({ key: k + ':membrane', name: t('ls.a.q.membrane', { name: a.name }), unit: A, qty: r2(q.membrane) });
if (q.edging) out.push({ key: k + ':edging', name: t('ls.a.q.edging', { name: a.name, kind: t('ls.a.e.' + a.buildup.edging) }), unit: L, qty: r2(q.edging) });
}
return out;
}
In detail
Method
Square or triangular grid (row pitch s·√3 ÷ 2) in a frame turned by the bed's angle. Hedge: n = ⌊length ÷ s⌋ + 1 plants centered on the line. Species by largest remainder, as the code comment says. Offsets are mitred with the mitre length capped.
Number types
64-bit numbers; integer counts; a 32-bit integer generator only for the order in which species take places.
Tolerances
Edge margin s ÷ 2 − 1e-9; hedge count ⌊length ÷ s + 1e-9⌋ + 1; a kept spacing counts as stale when it differs from the list's by more than 1e-9. All absolute.
Limits
A fill stops at 50000 plants and is flagged as capped. offsetLine limits a mitre to 4 times the offset, so at a very sharp corner the offset line is closer than the full distance. Default build-up depths in feet are meters ÷ 0.3048 rounded to 2 decimals.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.