Prices each element from its quantities, adds the cost up per scheduled task, spreads it over the task's days and totals the cash flow.
How the precision is kept
Money is kept as whole cents: each priced line is rounded to a cent once, and every sum after that is a sum of whole numbers, which 64-bit numbers hold exactly up to 2⁵³. Quantities priced per cubic yard or cubic foot use factors written to 9 significant digits. A task's cost per day is whole cents by the largest-remainder rule.
Open item. Write the cubic yard and cubic foot factors exactly, as 0.764554857984 and 0.028316846592.
Calculations under this policy
13 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.
1 yd³ = 0.764554858 m³; 1 ft³ = 0.0283168466 m³; 1 ft² = 0.09290304 m²; 1 ft = 0.3048 m
The square foot (0.09290304) and the foot (0.3048) are exact. The cubic yard and the cubic foot are written to 9 significant digits; the exact values are 0.764554857984 and 0.028316846592, a relative difference of about 2 in 10¹¹ and 3 in 10¹⁰.
Dollars times 100 is a binary product, so an amount that sits on half a cent in decimal can round either way: 1.005 × 100 is 100.49999999999999 in 64-bit and rounds to 100. After this step money is a whole number.
cost5d.js
line 37
BimCraft 0.8.1
const cents = (d) => Math.round(d * 100);
Whole cents to dollars
64-bit
The amount reported, in dollars.
dollars = round(cents) ÷ 100
One division of a whole number by 100; the result is the 64-bit number nearest that amount.
cost5d.js
line 38
BimCraft 0.8.1
const money = (c) => Math.round(c) / 100;
Quantity in the price's unit
64-bit
An element's volume, area, length, weight or count in the unit its price is per.
qty = quantity ÷ unit factor; weight = volume × density
The quantity in m, m² or m³ is divided by the row's unit factor in 64-bit. A price per kilogram uses the part's mass when it has one, else its volume times the row's density.
The material and labor cost of every element, and the total.
line = round(qty × (1 + waste) × material rate × 100) + round(qty × labor rate × 100) cents
Material and labor are each rounded to a whole cent per material part, then added as whole numbers. An element's cost and the total are sums of those whole cents, so they add up exactly; the rounding happens once per line.
Adds up the cost of a task's elements, sharing an element that is in several tasks.
task cents = round(Σ element cents ÷ n), n = the tasks that hold the element
An element in several tasks is divided evenly in 64-bit and the task's sum is rounded to a whole cent once. The rounded tasks need not add back to the elements' cents: the scheduled figure can be off by up to half a cent per task.
cost5d.js
lines 158 to 172
BimCraft 0.8.1
/** Each matched task's cost: the cost of its elements, an element in n tasks counted 1/n in each. `byKey` Map(key → dollars) with key = keyBase + id. */
export function taskCosts(matched, byKey) {
const n = new Map();
for (const t of matched) for (const k of t.keys || []) n.set(k, (n.get(k) || 0) + 1);
const tasks = [];
const inTask = new Set();
for (const t of matched) {
let c = 0;
for (const k of t.keys || []) { const v = byKey.get(k); if (v) { c += cents(v) / n.get(k); inTask.add(k); } }
tasks.push({ name: t.name, stage: t.stage || '', package: t.package || '', start: t.start, finish: t.finish, keys: t.keys || [], cents: Math.round(c), cost: money(Math.round(c)) });
}
let un = 0;
for (const [k, v] of byKey) if (!inTask.has(k)) un += cents(v);
return { tasks, scheduled: money(tasks.reduce((s, t) => s + t.cents, 0)), unscheduled: money(un) };
}
Spending profile of a task
64-bit
The weight each day of a task gets: even, front-loaded, back-loaded or bell-shaped.
even 1; front D − i; back i + 1; bell sin(π (i + 0.5) ÷ D)
Whole numbers except the bell profile, which uses Math.sin. Only the ratios matter: the weights are divided by their sum when the cost is spread.
cost5d.js
lines 174 to 178
BimCraft 0.8.1
// ------------------------------------------------------------------------------------------------ spreading over days
const weightsFor = (D, profile) => {
if (D <= 1) return [1];
return Array.from({ length: D }, (_, i) => (profile === 'front' ? D - i : profile === 'back' ? i + 1 : profile === 'bell' ? Math.sin(Math.PI * (i + 0.5) / D) : 1));
};
A task's cost per day
64-bit
Divides a task's cost over its days in whole cents.
dayᵢ = floor(cents × wᵢ ÷ W), then one more cent to the days with the largest remainders
The shares are floored to whole cents, and the cents left over, a whole number found by subtraction, go one each to the days with the largest remainders. The days therefore add up to the task's cents exactly, whatever the profile.
cost5d.js
lines 179 to 186
BimCraft 0.8.1
/** A task's cents over Start..Finish (inclusive days): whole cents by the largest-remainder rule, so the days add up to the task exactly. */
export function spread(task, profile = 'even') {
const D = task.finish - task.start + 1, w = weightsFor(D, PROFILES.includes(profile) ? profile : 'even'), W = w.reduce((s, x) => s + x, 0);
const raw = w.map((x) => task.cents * x / W), base = raw.map(Math.floor);
let left = task.cents - base.reduce((s, x) => s + x, 0);
raw.map((x, i) => [x - Math.floor(x), i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]).forEach(([, i]) => { if (left > 0) { base[i]++; left--; } });
return base.map((c, i) => ({ day: task.start + i, cents: c }));
}
Cash flow curve
64-bit
The cost per day, week or month and the running total.
period cost = Σ day cents; cumulative = running sum
Day cents are whole numbers, added per day, per period and cumulatively as whole numbers, and divided by 100 only for the reported points. Weeks start on Monday, found from the day number.
cost5d.js
lines 190 to 204
BimCraft 0.8.1
export function cashFlow(tasks, { profile = 'even', per = 'day' } = {}) {
const days = new Map();
for (const t of tasks) for (const s of spread(t, profile)) days.set(s.day, (days.get(s.day) || 0) + s.cents);
if (!days.size) return { points: [], total: 0, first: null, last: null };
const first = Math.min(...days.keys()), last = Math.max(...days.keys());
const key = (d) => (per === 'week' ? monday(d) : per === 'month' ? monthOf(d) : d);
const groups = new Map();
for (let d = first; d <= last; d++) {
const k = key(d), g = groups.get(k) || { from: d, to: d, c: 0 };
g.to = d; g.c += days.get(d) || 0; groups.set(k, g);
}
let cum = 0;
const points = [...groups.values()].map((g) => { cum += g.c; return { label: per === 'month' ? monthOf(g.from) : dayText(per === 'week' ? monday(g.from) : g.from), from: g.from, to: g.to, cost: money(g.c), cum: money(cum) }; });
return { points, total: money(cum), first, last };
}
Planned, built and earned value on a day
64-bit
What the schedule plans to have spent by a day, what is built, running and remaining, and the earned value from typed progress.
Planned, built, running and remaining are sums of whole cents. Earned value multiplies by the typed progress in 64-bit and is rounded to a cent once, at the end.
cost5d.js
lines 210 to 219
BimCraft 0.8.1
/** On `day`: what the schedule plans to have spent, what is built (tasks finished), running (tasks in progress), the rest; and earned value when the person types progress. */
export function valueAt(tasks, day, { profile = 'even', progress = null } = {}) {
let planned = 0, built = 0, running = 0, remaining = 0, earned = 0;
for (const t of tasks) {
for (const s of spread(t, profile)) if (s.day <= day) planned += s.cents;
if (day > t.finish) built += t.cents; else if (day >= t.start) running += t.cents; else remaining += t.cents;
if (progress) earned += t.cents * Math.max(0, Math.min(1, Number(progress[t.name]) || 0));
}
return { planned: money(planned), built: money(built), running: money(running), remaining: money(remaining), ...(progress ? { earned: money(Math.round(earned)) } : {}) };
}
Grand total
64-bit
Runs the whole calculation and states the total cost.
grand = scheduled + unscheduled
The grand total is rebuilt from the scheduled and unscheduled figures in whole cents, not taken from the priced total, so it carries the per-task rounding of the scheduled part.
Turns TakeoffCraft's stored rates into prices per unit.
rate = stored whole number ÷ 10000
TakeoffCraft's rates are whole numbers of ten-thousandths; each is divided by 10000 once in 64-bit. The waste allowance is converted the same way.
cost5d.js
lines 105 to 127
BimCraft 0.8.1
/**
* TakeoffCraft's own rates → price rows. Reads a takeoff project (`{ format: 'takeoffcraft-project', items }`) or its `items` object: each item with a
* material or labour rate (integers in ten-thousandths of a dollar; waste in ten-thousandths of one) becomes a row whose keyword is the item's name.
* Those names rarely match a model's materials: edit the keywords (the mapping) before pricing. linear → lf, area → sf, count → item (TakeoffCraft's feet).
*/
export function fromTakeoff(json) {
const src = typeof json === 'string' ? (() => { try { return JSON.parse(json); } catch { return null; } })() : json;
const items = src && typeof src === 'object' ? (src.items && typeof src.items === 'object' ? src.items : src) : null;
if (!items || Array.isArray(items)) return { rows: [], skipped: [{ name: '', code: 'format' }] };
const rows = [], skipped = [], seen = new Set();
for (const [key, it] of Object.entries(items).slice(0, LIMIT.rows)) {
const name = String(it?.name ?? key);
const unit = { linear: 'lf', area: 'sf', count: 'item', fixed: 'item' }[it?.type];
const mat = Number(it?.mat) / 10000, lab = Number(it?.lab) / 10000, waste = (Number(it?.waste) || 0) / 10000;
if (!unit || !(mat > 0 || lab > 0)) { skipped.push({ name, code: 'norate' }); continue; }
let id = slug(name), n = 2;
while (seen.has(id)) id = slug(name) + '-' + n++;
const c = cleanPriceRow({ id, match: [name], unit, mat: mat || 0, lab: lab || 0, waste, source: 'TakeoffCraft rates' });
if (!c.row) { skipped.push({ name, code: c.errors[0] }); continue; }
seen.add(id); rows.push(c.row);
}
return { rows, skipped };
}
Costs in the CSV export
64-bit
The amounts written to the exported table.
Amounts are written with toFixed(2). They are whole cents divided by 100, so the two decimals reproduce them.
cost5d.js
lines 229 to 235
BimCraft 0.8.1
export function costCsv(res, currency = '') {
const lines = ['stage,package,task,start,finish,cost' + (cleanCurrency(currency) ? ' (' + cleanCurrency(currency) + ')' : '')];
for (const t of res.tasks) lines.push([cell(t.stage), cell(t.package), cell(t.name), dayText(t.start), dayText(t.finish), t.cost.toFixed(2)].join(','));
lines.push(['', '', 'scheduled', '', '', res.scheduled.toFixed(2)].join(','), ['', '', 'not in any task', '', '', res.unscheduled.toFixed(2)].join(','), '', 'period,cost,cumulative');
for (const p of res.flow.points) lines.push([p.label, p.cost.toFixed(2), p.cum.toFixed(2)].join(','));
return lines.join('\r\n') + '\r\n';
}
In detail
Method
cost = qty × (1 + waste) × material rate + qty × labor rate per material part; an element in n tasks counts 1 ÷ n in each; a task's cents are spread over its days by weights (even, front, back, bell) with the largest-remainder rule.
Number types
64-bit numbers; money as whole cents held in 64-bit numbers.
Limits
The cubic yard and cubic foot factors are rounded: 0.764554858 for 0.764554857984 and 0.0283168466 for 0.028316846592. Each task's share of split elements is rounded to a cent, so scheduled plus unscheduled can differ from the priced total by up to half a cent per task.
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.