Resources Numerical accuracy Calculation policy

Massing options placed on a lot

Reads alternative building footprints, places each inside the buildable envelope, and writes the envelope back as a file in millimeters.

How the precision is kept

Footprints are converted from millimeters to project units in 64-bit. The position is found by a grid search (four quarter-turn rotations, five passes of a 7 × 7 grid, each a third of the last), so the placement is resolved to 1/243 of half the envelope's larger side, not to 64-bit accuracy. The envelope file rounds coordinates to 0.1 mm, the area to 0.01 m² and the angle to 0.1 degree.

Open item. Use the same foot for a feet project here as the IFC export does (it treats a feet project without a coordinate system as US survey feet; this file uses 0.3048).

Calculations under this policy

6 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.

massing-options.js CivilCraft 0.8.1 64-bit 6 calculations Reviewed

Tests bin/test-civilcraft-massopt.mjs

Footprint and floor area of an option

64-bit

The footprint area and gross floor area of each alternative, in square meters.

area (m²) = |A| ÷ 10⁶; gross = area × floors

The shoelace area in mm² is divided by the exact factor 1e6. Floors are rounded to a whole number and limited to 200; a floor height outside 1000…20000 mm skips the option.

  • massing-options.js
  • lines 34 to 39
  • CivilCraft 0.8.1
const a = Math.abs(signedArea(pts));
if (pts.length < 3 || a < 1 || !simple(pts)) { notes.push({ key: 'mo.note.skip', vars: { name: clean(m?.name ?? m?.key, 60) } }); continue; }
const floors = fin(m.floors) && m.floors >= 1 ? Math.min(200, Math.round(m.floors)) : 0;
const fh = fin(m.floorHeight) && m.floorHeight >= 1000 && m.floorHeight <= 20000 ? m.floorHeight : 0;
if (!floors || !fh) { notes.push({ key: 'mo.note.skip', vars: { name: clean(m?.name ?? m?.key, 60) } }); continue; }
options.push({ key: clean(m.key, 40) || 'o' + (options.length + 1), name: clean(m.name, 80) || 'Option ' + (options.length + 1), pts, floors, floorHeight: fh, use: clean(m.use, 60), areaM2: a / 1e6, gfaM2: (a / 1e6) * floors, longest: longestEdge(pts).ang * 180 / Math.PI });

Is the footprint a simple polygon

64-bit

Refuses a footprint whose edges cross.

Orientation signs from 64-bit cross products with no tolerance; the test is not an exact predicate, so edges that only just touch can be decided either way.

  • massing-options.js
  • lines 45 to 53
  • CivilCraft 0.8.1
/** True when no two non-neighbouring edges cross (a plain polygon). */
export function simple(pts) {
  const n = pts.length, X = (a, b, c, d) => {
    const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
    return o(a, b, c) !== o(a, b, d) && o(c, d, a) !== o(c, d, b);
  };
  for (let i = 0; i < n; i++) for (let j = i + 2; j < n; j++) { if (i === 0 && j === n - 1) continue; if (X(pts[i], pts[(i + 1) % n], pts[j], pts[(j + 1) % n])) return false; }
  return true;
}

Turning and moving a footprint

64-bit

Rotates a footprint about its centroid and moves it to a position.

x′ = t_x + (x − c_x)·cos a − (y − c_y)·sin a; y′ = t_y + (x − c_x)·sin a + (y − c_y)·cos a

The centroid is subtracted before the rotation, so the rotation works on small numbers; cos and sin are evaluated once per call.

  • massing-options.js
  • line 55
  • CivilCraft 0.8.1
const turn = (pts, c, ang, to) => { const co = Math.cos(ang), si = Math.sin(ang); return pts.map((p) => ({ x: to.x + (p.x - c.x) * co - (p.y - c.y) * si, y: to.y + (p.x - c.x) * si + (p.y - c.y) * co })); };

Placing an option in the envelope

64-bit

The position and rotation at which as much of the footprint as possible lies inside the buildable envelope.

k = 0.001 ÷ meters per project unit; inside = area(envelope ∩ footprint) ÷ area(footprint)

Four rotations a quarter turn apart, each searched on a 7 × 7 grid in five passes whose span shrinks by 3, so the final grid step is the starting span ÷ 243. Shares are compared with 1e-9. The inside share is clamped to at most 1. The intersection area comes from insideArea in zoning.js.

  • massing-options.js
  • lines 57 to 82
  • CivilCraft 0.8.1
export function fitOption(option, polygon, units = 'ft') {
  const k = 0.001 / (METRES_PER[units] || 0.3048);   // project units per mm
  const base = option.pts.map((p) => ({ x: p.x * k, y: p.y * k }));
  const c0 = centroid(base), area = Math.abs(signedArea(base));
  if (!polygon || polygon.length < 3) return { ok: false, error: 'mo.err.envelope' };
  const ec = centroid(polygon), edge = longestEdge(polygon), own = longestEdge(base);
  let lo = { x: Infinity, y: Infinity }, hi = { x: -Infinity, y: -Infinity };
  for (const p of polygon) { lo = { x: Math.min(lo.x, p.x), y: Math.min(lo.y, p.y) }; hi = { x: Math.max(hi.x, p.x), y: Math.max(hi.y, p.y) }; }
  let best = null;
  for (let q = 0; q < 4; q++) {
    const rot = edge.ang - own.ang + (q * Math.PI) / 2;
    let span = Math.max(hi.x - lo.x, hi.y - lo.y) / 2, at = { ...ec };
    let cur = null;
    for (let pass = 0; pass < 5; pass++, span /= 3) {
      for (let i = -3; i <= 3; i++) for (let j = -3; j <= 3; j++) {
        const to = { x: at.x + (i / 3) * span, y: at.y + (j / 3) * span };
        const pts = turn(base, c0, rot, to), inside = insideArea(polygon, pts) / area;
        const d = Math.hypot(to.x - ec.x, to.y - ec.y);
        if (!cur || inside > cur.inside + 1e-9 || (Math.abs(inside - cur.inside) <= 1e-9 && d < cur.d)) cur = { pts, inside, centre: to, rot, d };
      }
      at = cur.centre;
    }
    if (!best || cur.inside > best.inside + 1e-9 || (Math.abs(cur.inside - best.inside) <= 1e-9 && cur.d < best.d)) best = cur;
  }
  return { ok: true, pts: best.pts, centre: best.centre, rotation: ((best.rot * 180) / Math.PI + 360) % 360, inside: Math.min(1, best.inside), outside: Math.max(0, area * (1 - best.inside)) };
}

Option to a mass

64-bit

Makes a project mass from a placed option, with its floor height in project units.

floor height = mm × 0.001 ÷ meters per project unit

One multiplication. For a feet project the divisor is 0.3048 (METRES_PER.ft), the international foot.

  • massing-options.js
  • lines 84 to 87
  • CivilCraft 0.8.1
export function toMass(option, fit, { parcel = null, units = 'ft' } = {}) {
  const k = 0.001 / (METRES_PER[units] || 0.3048);
  return cleanMass({ name: option.name, pts: fit.pts, floors: option.floors, floorHeight: option.floorHeight * k, use: option.use, parcel }, units);
}

Envelope file

64-bit

Writes the buildable envelope in millimeters, turned so its longest edge lies along x.

mm = meters per project unit × 1000; value = round(v × 10) ÷ 10

Coordinates are rounded to 0.1 mm with Math.round(v × 10) ÷ 10, the area to 0.01 m² and the angle to 0.1 degree before they are written. The turned polygon and the project polygon are rounded separately, so one cannot be rebuilt exactly from the other and the rounded angle.

  • massing-options.js
  • lines 89 to 104
  • CivilCraft 0.8.1
export function envelopeFile(parcel, zone, units = 'ft') {
  const env = envelope(parcel, zone);
  if (!env.poly) return { ok: false, problem: env.problem || 'fold' };
  const mm = (METRES_PER[units] || 0.3048) * 1000;
  const e = longestEdge(env.poly), c = e.ang, co = Math.cos(-c), si = Math.sin(-c);
  const turned = env.poly.map((p) => ({ x: p.x * co - p.y * si, y: p.x * si + p.y * co }));
  const minx = Math.min(...turned.map((p) => p.x)), miny = Math.min(...turned.map((p) => p.y));
  const round = (v) => Math.round(v * 10) / 10;
  return { ok: true, area: env.area, file: {
    format: 'cadcraft-massing-envelope', version: 1, units: 'mm', parcel: clean(parcel.name || parcel.id, 80),
    note: 'polygon: the buildable area with its longest edge along +x and its lowest corner at 0,0 — pass it as `envelope` to BimCraft\'s massing_describe. project: the same polygon in the lot\'s own coordinates; angle is the turn between the two.',
    angle: round((c * 180) / Math.PI), areaM2: Math.round(env.area * (mm / 1000) ** 2 * 100) / 100,
    polygon: turned.map((p) => ({ x: round((p.x - minx) * mm), y: round((p.y - miny) * mm) })),
    project: env.poly.map((p) => ({ x: round(p.x * mm), y: round(p.y * mm) })),
  } };
}

In detail

Method
Grid search for the largest share of the footprint inside the envelope; ties go to the position nearest the envelope's centroid.
Number types
64-bit numbers.
Tolerances
Shares equal within 1e-9. A footprint with an area under 1 mm² is refused. Envelope file rounded to 0.1 mm, 0.01 m², 0.1°.
Limits
The search can miss a better position between grid points or at another rotation. A feet project is converted with 0.3048 m per foot (METRES_PER.ft in units.js).

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.

Every policy that takes this approach