Resources Numerical accuracy Calculation policy

Move, align and distribute

Works out how far each wall, floor, column or placed item moves to line up with the others or to be spaced evenly, and where an item turned about a point ends up.

How the precision is kept

Boxes, targets and moves are 64-bit differences in plan millimeters. Even spacing uses one division for the gap and a running sum for the edges. When a wall type changes thickness, two walls that met at a corner are rejoined at the crossing of their new center lines. The shared model writer rounds the stored positions to 6 decimals of the file's unit.

Calculations under this policy

7 calculations in 3 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.

model-modify.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

Tests tools/bimcraft-test/modify-test.mjs

Boxes of the things that move

64-bit

The plan box of each wall, drawn floor, column and placed item.

wall corners = end ± n × t ÷ 2

A wall's box is its two ends moved half its thickness to each side; a curved wall is taken from its two ends, not from its arc. A column's corners are turned by its angle with one cosine and sine each; a placed item's box is its insertion point.

  • model-modify.js
  • lines 30 to 46
  • BimCraft 0.8.1
/** The walls' outline (centre line ± half the thickness at each end), floors' points, columns' corners, items' insertion point. */
export function movables(w, storey = null) {
  const out = [];
  for (const x of readWalls(w, storey)) {
    const L = Math.hypot(x.b[0] - x.a[0], x.b[1] - x.a[1]) || 1, n = [-(x.b[1] - x.a[1]) / L * x.t / 2, (x.b[0] - x.a[0]) / L * x.t / 2];
    out.push({ kind: 'wall', guid: x.guid, storey: x.storey, box: boxOfPts([[x.a[0] + n[0], x.a[1] + n[1]], [x.a[0] - n[0], x.a[1] - n[1]], [x.b[0] + n[0], x.b[1] + n[1]], [x.b[0] - n[0], x.b[1] - n[1]]]) });
  }
  for (const f of readDrawnFloors(w, storey)) out.push({ kind: 'floor', guid: f.guid, storey: f.storey, box: boxOfPts(f.points) });
  for (const c of readColumns(w, storey)) {
    const a = (c.angle || 0) * Math.PI / 180, hw = c.width / 2, hd = (c.shape === 'round' ? c.width : c.depth) / 2;
    const pts = c.shape === 'round' ? [[c.at[0] - hw, c.at[1] - hw], [c.at[0] + hw, c.at[1] + hw]]
      : [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map(([x, y]) => [c.at[0] + x * Math.cos(a) - y * Math.sin(a), c.at[1] + x * Math.sin(a) + y * Math.cos(a)]);
    out.push({ kind: 'column', guid: c.guid, storey: c.storey, box: boxOfPts(pts) });
  }
  for (const it of placedItems(w, storey)) out.push({ kind: 'item', guid: it.guid, storey: it.storey, box: { minX: it.x, minY: it.y, maxX: it.x, maxY: it.y } });
  return out;
}

Align

64-bit

The move that brings each element's chosen side or middle to a common line.

move = target − own; middle = (min + max) ÷ 2

The target is a minimum, maximum or mean of two box values and the move one subtraction; a move of 1e-9 mm or less is left out.

  • model-modify.js
  • lines 51 to 64
  • BimCraft 0.8.1
export function alignMoves(list, { side = 'left', to = 'selection', point = null } = {}) {
  const out = new Map();
  if (!list.length || !SIDES[side]) return out;
  const [ax, k] = SIDES[side];
  let target;
  if (to === 'point') { if (!point) return out; target = ax === 'x' ? point[0] : point[1]; }
  else if (to === 'first' || to === 'last') target = at((to === 'first' ? list[0] : list[list.length - 1]).box, ax, k);
  else {
    const all = { minX: Math.min(...list.map((e) => e.box.minX)), minY: Math.min(...list.map((e) => e.box.minY)), maxX: Math.max(...list.map((e) => e.box.maxX)), maxY: Math.max(...list.map((e) => e.box.maxY)) };
    target = at(all, ax, k);
  }
  for (const e of list) { const d = target - at(e.box, ax, k); if (Math.abs(d) > 1e-9) out.set(e.guid, ax === 'x' ? [d, 0] : [0, d]); }
  return out;
}

Distribute

64-bit

The moves that space elements evenly between the first and the last.

gap = (span − Σ sizes) ÷ (n − 1)

By centers, each target is c₀ + (c₁ − c₀) × i ÷ (n − 1), computed fresh for each element. By gaps, the next edge is a running sum of sizes and the gap, so the last element can come out a rounding error away from where it stands; a move of 1e-9 mm or less is left out.

  • model-modify.js
  • lines 66 to 81
  • BimCraft 0.8.1
export function distributeMoves(list, { axis = 'h', mode = 'gaps' } = {}) {
  const out = new Map();
  if (list.length < 3) return out;
  const lo = axis === 'h' ? 'minX' : 'minY', hi = axis === 'h' ? 'maxX' : 'maxY';
  const row = list.slice().sort((a, b) => (a.box[lo] + a.box[hi]) - (b.box[lo] + b.box[hi]));
  const first = row[0], last = row[row.length - 1], n = row.length;
  if (mode === 'centers') {
    const c0 = (first.box[lo] + first.box[hi]) / 2, c1 = (last.box[lo] + last.box[hi]) / 2;
    row.forEach((e, i) => { const d = c0 + (c1 - c0) * i / (n - 1) - (e.box[lo] + e.box[hi]) / 2; if (Math.abs(d) > 1e-9) out.set(e.guid, axis === 'h' ? [d, 0] : [0, d]); });
  } else {
    const span = last.box[hi] - first.box[lo], sizes = row.reduce((s, e) => s + (e.box[hi] - e.box[lo]), 0), gap = (span - sizes) / (n - 1);
    let edge = first.box[lo];
    for (const e of row) { const d = edge - e.box[lo]; if (Math.abs(d) > 1e-9) out.set(e.guid, axis === 'h' ? [d, 0] : [0, d]); edge += (e.box[hi] - e.box[lo]) + gap; }
  }
  return out;
}
model-move.js BimCraft 0.8.1 64-bit 2 calculations Reviewed

Tests tools/bimcraft-test/comforts-test.mjs

Where placed items stand

64-bit

The position and rotation of each placed library item in its story's plan.

rot = atan2(M[1], M[0]) × 180 ÷ π

Position is the translation of the item's matrix relative to its story, in millimeters; the rotation is atan2 of the matrix's X axis, in degrees.

  • model-move.js
  • lines 24 to 37
  • BimCraft 0.8.1
/** The library items placed in the model (or on one storey), where they stand in their storey's plan. */
export function placedItems(w, storey = null) {
  const out = [];
  for (const p of w.placements()) {
    if (storey !== null && p.storey !== storey) continue;
    const line = w.api.GetLine(w.mid, p.id, false);
    const lp = line?.ObjectPlacement?.value;
    if (!lp) continue;
    const rel = w.api.GetLine(w.mid, lp, false)?.PlacementRelTo?.value ?? null;
    const M = rel ? w.relMatrix(rel, lp) : scaleT(w.api.GetWorldTransformMatrix(w.mid, lp), w.unitMm);
    out.push({ id: p.id, guid: val(line.GlobalId), name: p.name, storey: p.storey, x: M[12], y: M[13], z: M[14], rot: Math.atan2(M[1], M[0]) / D2R, itemNumber: p.itemNumber, lp });
  }
  return out;
}

Moving and turning placed items

64-bit

The new position and rotation of an item moved by an amount or turned, on its own or about a shared point.

p′ = c + R(Δ) × (p − c); rot = ((rot mod 360) + 540) mod 360 − 180

A turn about a point rotates the insertion point with one cosine and sine. The rotation is brought into [−180, 180) and rounded to 6 decimals of a degree before it is written.

  • model-move.js
  • lines 44 to 66
  • BimCraft 0.8.1
/** The same inside a writer that is already running (model-modify.js). */
export function moveItemsIn(w, moves) {
  {
    const all = placedItems(w), ids = [];
    for (const m of moves) {
      const it = all.find((x) => x.guid === m.guid);
      if (!it) fail('comforts.err.noitem');
      let x = Number.isFinite(m.x) ? m.x : it.x + (Number(m.dx) || 0), y = Number.isFinite(m.y) ? m.y : it.y + (Number(m.dy) || 0);
      const drot = Number(m.drot) || 0;
      let rot = Number.isFinite(m.rot) ? m.rot : it.rot + drot;
      if (drot && Array.isArray(m.about)) {
        // turned about a shared point: the insertion point goes round it too
        const c = Math.cos(drot * D2R), s = Math.sin(drot * D2R), ox = x - m.about[0], oy = y - m.about[1];
        x = m.about[0] + ox * c - oy * s; y = m.about[1] + ox * s + oy * c;
      }
      if (![x, y, rot].every(Number.isFinite)) fail('comforts.err.noitem');
      rot = ((rot % 360) + 540) % 360 - 180;
      w.movePlacement(it.lp, x, y, it.z, Math.round(rot * 1e6) / 1e6);
      ids.push(it.id);
    }
    return { id: ids[0] ?? null, ids };
  }
}
model-ops.js BimCraft 0.8.1 64-bit 2 calculations Reviewed

Tests tools/bimcraft-test/modify-test.mjs

Rejoining wall corners after a type change

64-bit

The new corner point of two walls that met end to end, after their center lines moved.

s = ((B.a − A.a) × v) ÷ (u × v); p = A.a + s u

Ends within 1 mm of each other are one corner. The crossing parameter divides by the cross product of the two wall vectors, which is tested against 1e-9 times both lengths first. A crossing more than 4 times the thicker wall from the old corner is ignored.

  • model-ops.js
  • lines 95 to 132
  • BimCraft 0.8.1
// Intersect the final axes, after every instance has its new core offset. Intersecting
// against a neighbour's old axis during each update would leave an open L corner.
function rejoinAssemblyCorners(w, before, changed) {
  const changedSet = new Set(changed), after = new Map(readWalls(w).map(x => [x.guid, x]));
  const buckets = new Map(), nodes = [];
  for (const wall of before) for (const end of ['a', 'b']) {
    const p = wall[end], x = Math.floor(p[0]), y = Math.floor(p[1]);
    let node = null;
    for (let dx = -1; dx <= 1 && !node; dx++) for (let dy = -1; dy <= 1 && !node; dy++) {
      node = (buckets.get(`${wall.storey}:${x+dx}:${y+dy}`) || []).find(n => Math.hypot(n.p[0]-p[0],n.p[1]-p[1]) <= 1);
    }
    if (!node) {
      node = { p, ends: [] }; nodes.push(node);
      const key = `${wall.storey}:${x}:${y}`;
      if (!buckets.has(key)) buckets.set(key, []);
      buckets.get(key).push(node);
    }
    node.ends.push({ wall, end });
  }
  const edits = new Map();
  for (const node of nodes) {
    if (node.ends.length !== 2 || !node.ends.some(e => changedSet.has(e.wall.guid))) continue;
    const [a, b] = node.ends, A = after.get(a.wall.guid), B = after.get(b.wall.guid);
    if (!A || !B || A.bulge || B.bulge) continue;
    const u = [A.b[0]-A.a[0],A.b[1]-A.a[1]], v = [B.b[0]-B.a[0],B.b[1]-B.a[1]];
    const cross = u[0]*v[1]-u[1]*v[0];
    if (Math.abs(cross) < 1e-9 * A.len * B.len) continue;
    const s = ((B.a[0]-A.a[0])*v[1]-(B.a[1]-A.a[1])*v[0])/cross;
    const p = [A.a[0]+s*u[0],A.a[1]+s*u[1]];
    if (Math.hypot(p[0]-node.p[0],p[1]-node.p[1]) > 4*Math.max(A.t,B.t)) continue;
    for (const e of [a,b]) {
      if (!edits.has(e.wall.guid)) edits.set(e.wall.guid, {});
      edits.get(e.wall.guid)[e.end] = p;
    }
  }
  for (const [guid, ch] of edits) { updateWall(w, guid, ch, { carryJoined: false }); changedSet.add(guid); }
  return [...changedSet];
}

Doors and windows when a wall end slides

64-bit

How far a wall's start moved along its own line, so that what it hosts keeps its place.

shift = (B.a − A.a) · u_A; new offset = offset − shift

The wall is taken to be on the same line when the cross product of the old and new unit directions is under 1e-9 and they point the same way. The shift is a projection on the old direction; openings are refitted when it, the length or the thickness changed by more than 1e-6 mm.

  • model-ops.js
  • lines 229 to 245
  • BimCraft 0.8.1
/** Doors and windows of walls that moved or changed: same place in the world when a wall end slid along its line; made
 *  again for a new thickness; fitted into a shorter wall (or the change is refused: model.err.nofit). */
function afterWallMove(w, before, ops0, guids, rethick) {
  const after = readWalls(w);
  for (const g of guids) {
    const A = before.find((x) => x.guid === g), B = after.find((x) => x.guid === g);
    if (!A || !B) continue;
    const mine = ops0.filter((o) => o.wallGuid === g);
    if (!mine.length) continue;
    const uA = [(A.b[0] - A.a[0]) / A.len, (A.b[1] - A.a[1]) / A.len], uB = [(B.b[0] - B.a[0]) / B.len, (B.b[1] - B.a[1]) / B.len];
    const sameLine = Math.abs(uA[0] * uB[1] - uA[1] * uB[0]) < 1e-9 && uA[0] * uB[0] + uA[1] * uB[1] > 0;
    const shift = sameLine ? (B.a[0] - A.a[0]) * uA[0] + (B.a[1] - A.a[1]) * uA[1] : 0;
    const moved = Math.abs(shift) > 1e-6 || Math.abs(A.len - B.len) > 1e-6 || Math.abs(A.t - B.t) > 1e-6 || rethick;
    if (!moved) continue;
    for (const o of mine) updateOpening(w, o.guid, { offset: o.offset - shift });
  }
}

In detail

Method
Align: move = target − own side or middle. Distribute: equal center steps, or equal gaps = (span − Σ sizes) ÷ (n − 1). Rejoin: two-line intersection.
Number types
64-bit numbers in millimeters and degrees.
Tolerances
Moves under 1e-9 mm are skipped. Wall ends within 1 mm are one corner. Center lines are parallel when |u × v| < 1e-9 × both lengths. A rejoined corner more than 4 wall thicknesses from the old one is left alone. A wall slid along its own line when the cross product of unit directions is under 1e-9; openings are refitted when the shift, length or thickness changed by more than 1e-6 mm.
Checked by
tools/bimcraft-test/modify-test.mjs calls movables, alignMoves, distributeMoves and moveElements; tools/bimcraft-test/comforts-test.mjs calls placedItems and moveItems.

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.

Every policy that takes this approach