Builds the site's ground from survey points, grades building pads into it, works out the cut and fill volumes, drapes paving on it and writes the ground to LandXML.
How the precision is kept
The surface itself is tin.js. This file moves survey coordinates into model millimeters in 64-bit, grades pads by rings of sample points every 500 mm, and measures cut and fill by sampling the two surfaces on a 250 mm grid. The existing ground is stored in the model as whole millimeters, the terrain body with 3 decimals of the file's length unit, and LandXML with 4 decimals.
Open item. Store the existing ground points with decimals instead of whole millimeters, and the terrain body with 6 decimals instead of 3.
Calculations under this policy
17 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.
Converts survey points from their own unit to millimeters, moves them by the offset and measures heights from the datum.
x = x_s × unit − offset_x; z = (z_s − datum) × unit
The horizontal coordinates are multiplied by the unit first and the offset is subtracted after, so for state-plane sized coordinates a large product is subtracted from a nearly equal large offset. The height subtracts the datum before multiplying.
Finds the move that brings survey coordinates far from the origin onto the building.
c = (min + max) ÷ 2 × unit; offset = c − target when |c| > 2e6 mm
The middle of the points' bounding box, in millimeters. Coordinates whose middle is within 2000 m of the origin are not moved.
res-terrain.js
lines 112 to 119
BimCraft 0.8.1
/** An offset that brings far-off survey coordinates near the model (their middle onto `to`), or [0, 0] when they are near. */
export function autoOffset(points, unitMm, to = [0, 0]) {
if (!points.length) return [0, 0];
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of points) { 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 cx = (x0 + x1) / 2 * unitMm, cy = (y0 + y1) / 2 * unitMm;
return Math.hypot(cx, cy) > 2e6 ? [cx - to[0], cy - to[1]] : [0, 0];
}
Thinning a large point set
64-bit
Keeps the lowest point in each square cell of a grid.
cell index = (⌊x ÷ cell⌋, ⌊y ÷ cell⌋); keep min z
The kept points are original points, unchanged. Which points are dropped depends on where the grid lines fall, because the cells are counted from the coordinate origin.
res-terrain.js
lines 104 to 108
BimCraft 0.8.1
export function thinToGrid(points, cell) {
const m = new Map();
for (const p of points) { const k = Math.floor(p.x / cell) + ',' + Math.floor(p.y / cell); const q = m.get(k); if (!q || p.z < q.z) m.set(k, p); }
return [...m.values()];
}
Cell size when a ground has too many points
64-bit
Chooses the thinning cell so that about 20000 points remain.
cell = √(bounding box area ÷ 20000) × 1.2
Applies only above 20000 points and not to a surface that brings its own triangles. The factor 1.2 is a fixed allowance in the code.
Reads a LandXML surface's length unit and its points, which are written northing, easting, elevation.
x = E × k, y = N × k, z = Z × k; k = 0.01 for centimeters, 1000 for kilometers, 1 ÷ 12 for inches
Values are parsed with Number and multiplied by the unit factor once. Meters, millimeters, feet and US survey feet keep a factor of 1 and name their unit for the caller to convert. An unknown unit is read as feet or meters and a note says so.
Each corner is the intersection of two neighboring offset edges. When |cross| of the two unit directions is below 1e-9 the edge's own offset point is used. At a sharp corner the intersection moves far out; nothing limits it.
The finished ground height at a point: flat on each pad and sloping away from it to meet the existing ground.
z = max(z_pad − d ÷ slope, min(z_pad + d ÷ slope, z))
d is the distance to the pad polygon, zero inside it, from a point-in-polygon test and Math.hypot distances to its edges. The clamp is applied pad after pad in order.
res-terrain.js
lines 179 to 184
BimCraft 0.8.1
/** The finished grade at p over existing elevation e: each pad flattens to its z and slopes out at 1 / slope. */
export function gradeAt(p, e, pads) {
let z = e;
for (const pd of pads) { const d = distTo(p, pd.poly), r = d / pd.slope; z = Math.max(pd.z - r, Math.min(pd.z + r, z)); }
return z;
}
Sample rings round a pad
64-bit
Adds rings of graded points round each pad, out to where its slopes can meet the ground.
reach = max(|z_max − z_pad|, |z_pad − z_min|) × slope + 500; rings every 500 mm, points every 500 mm
The slope faces are represented by points 500 mm apart, so the line where a slope meets the existing ground is found to that spacing. Ring distances are found by adding 500 mm repeatedly. Ring points outside the existing surface are skipped.
res-terrain.js
lines 196 to 205
BimCraft 0.8.1
const reach = Math.max(Math.abs(s.maxZ - pd.z), Math.abs(pd.z - s.minZ)) * pd.slope + step;
for (let d = step; d <= reach; d += step) {
const ring = offsetOut(pd.poly, d), L = ring.reduce((a, q, i) => a + Math.hypot(ring[(i + 1) % ring.length][0] - q[0], ring[(i + 1) % ring.length][1] - q[1]), 0), n = Math.max(ring.length, Math.ceil(L / step));
for (let k = 0; k < n; k++) {
const tt = k / n * L;
let acc = 0, q = ring[0];
for (let i = 0; i < ring.length; i++) { const a = ring[i], b = ring[(i + 1) % ring.length], l = Math.hypot(b[0] - a[0], b[1] - a[1]); if (acc + l >= tt) { const f = (tt - acc) / (l || 1); q = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; break; } acc += l; }
const e = existing.elevationAt(q[0], q[1]);
if (e === null) continue;
out.push({ x: q[0], y: q[1], z: gradeAt(q, e, pads) });
Cut and fill volumes
64-bit
The volume of earth to cut and to fill between the existing and the finished ground, in cubic meters.
V = Σ (z_finished − z_existing) × cell² ÷ 1e9, cell = 250 mm, sampled at cell centers
A sum over grid cell centers, added in grid order; each cell counts whole with the height difference at its center. The cell positions are found by adding 250 mm repeatedly. Cells where either surface has no height are skipped.
res-terrain.js
lines 214 to 224
BimCraft 0.8.1
export function cutFill(existing, finished, { cell = 250 } = {}) {
const b = existing.stats().bounds;
let cut = 0, fill = 0;
for (let x = b.minX + cell / 2; x < b.maxX; x += cell) for (let y = b.minY + cell / 2; y < b.maxY; y += cell) {
const e = existing.elevationAt(x, y), f = finished.elevationAt(x, y);
if (e === null || f === null) continue;
const d = (f - e) * cell * cell;
if (d > 0) fill += d; else cut -= d;
}
return { cut: cut / 1e9, fill: fill / 1e9 };
}
Cut and fill written to the model
64-bit
Writes the terrain's cut and fill volumes and its lowest and highest elevation.
Cut, Fill = round(V, 3) m³
The volumes are stored rounded to 3 decimals of a cubic meter. With no pad both are written as 0 without sampling.
A pad's outline in site coordinates and its level: the walls' outline moved out by the margin, at the floor's height less the pad depth.
z_pad = level elevation − depth
The outline is offset in the level's plan and then moved to site coordinates through the level's 4×4 matrix. The pad level is the matrix's vertical translation minus the depth.
res-terrain.js
lines 287 to 299
BimCraft 0.8.1
/** A pad's outline (world mm) and level z: the walls' outline round its seed on its level, out by the margin. */
export function padShape(w, pd, levels = readLevels(w)) {
const lv = levels.find((l) => l.guid === pd.level);
if (!lv) return null;
const walls = readWalls(w, lv.id);
const L = planLoops([...wallOutlines(walls.map((x) => ({ key: x.guid, a: x.a, b: x.b, t: x.t, bulge: x.bulge }))).values()]);
const o = loopAt(L.outlines, pd.seed);
if (!o) return null;
const M = w.storeyWorld(lv.id);
const poly = offsetOut(o.poly, pd.margin).map(([x, y]) => { const q = apply4(M, x, y, 0); return [q[0], q[1]]; });
const outline = o.poly.map(([x, y]) => { const q = apply4(M, x, y, 0); return [q[0], q[1]]; });
return { poly, outline, z: M[14] - pd.depth, slope: pd.slope, storey: lv.id };
}
Existing ground points as stored
64-bit
Writes the existing ground's points into the model as text.
x, y, z = Math.round(value) mm
Every coordinate of the existing ground is rounded to a whole millimeter before it is stored, and the surface is rebuilt from these whole numbers each time the terrain is read.
Writes the finished ground as a closed triangulated body in the site's axes.
coordinate = round(value ÷ unit, 3)
Each vertex goes through the inverse of the site placement and is rounded to 3 decimals of the file's length unit: 1 mm in a file in meters. The helper is named r6 but rounds to 3 decimals.
Moves the existing ground up or down and corrects the stored survey datum.
z′ = z + raise; datum′ = datum − raise ÷ (mm per source unit)
The unit table used for the datum holds meters, feet and millimeters only. A terrain whose source unit is the US survey foot falls through to a factor of 1, so its datum is corrected as if the unit were millimeters.
Builds a driveway, walk or patio as a surface on the finished grade and writes its area and volume.
grid step = max(500, √(box area ÷ 2000)); GrossArea = plan area; GrossVolume = sloped area × thickness
The paving takes the grade at its corners and at grid points inside it, so it follows the ground to that grid spacing. Its body is the surface moved straight down by the thickness, but the stored volume multiplies the thickness by the sloped area, not the plan area.
res-terrain.js
lines 474 to 485
BimCraft 0.8.1
const step = Math.max(500, Math.sqrt((x1 - x0) * (y1 - y0) / 2000));
for (let x = x0 + step / 2; x < x1; x += step) for (let y = y0 + step / 2; y < y1; y += step) if (inPoly(x, y, world)) { const z = zAt(x, y); if (z !== null) pts.push({ x, y, z }); }
const r = buildSurface({ points: pts, breaklines: [], boundary: world.map(([x, y]) => ({ x, y })) });
if (!r.surface) return false;
const body = closedBody(w, r.surface, 0, 10, h.thickness);
w.styleItem(body, w.surfaceStyle(tr('res.style.paving.' + h.kind), h.kind === 'driveway' ? [0.62, 0.62, 0.6] : [0.78, 0.76, 0.72], 0));
// the slab sits in its level's storey: the body goes from world to that storey's axes
const site = siteOf(w);
void Mi; void site;
w.setShape(h.id, [w.rep('body', 'Body', 'Tessellation', [body])]);
const st = r.surface.stats();
w.upsertQto(h.id, 'Qto_SlabBaseQuantities', [['Width', 'length', h.thickness], ['GrossArea', 'area', st.area2d], ['NetArea', 'area', st.area2d], ['GrossVolume', 'volume', st.area3d * h.thickness]]);
LandXML export
64-bit
Writes the existing and finished ground as LandXML surfaces.
value = (mm × k).toFixed(4), k = 0.001 for meters, 1 ÷ 304.8 for feet
Every coordinate is written with 4 decimals of the export unit: 0.1 mm in meters, about 0.03 mm in feet. Points are written northing first.
Finished grade = existing elevation clamped to pad level ± distance ÷ slope. Cut and fill = Σ (finished − existing) × cell² over grid cell centers. Paving is a surface sampled from the finished grade on a grid of at least 500 mm.
Number types
64-bit numbers. Feet are 304.8 mm.
Tolerances
Pad offset corners use the edge's own offset point when |cross| of the neighboring unit directions is below 1e-9. More than 20000 points are thinned to the lowest point per grid cell. Coordinates whose middle is more than 2e6 mm from the origin are moved to the building.
Limits
Cut and fill depend on the 250 mm sampling grid and count each cell whole. The finished surface is triangulated again from the graded points, so its triangles need not match the existing surface's away from the pads. With several pads each is given an equal share of the total cut and fill.
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.