Resources Numerical accuracy Calculation policy

Move, rotate and mirror

Moves, rotates and mirrors selected members, footings and stairs, and keeps each section facing the right way.

How the precision is kept

The transform is a 3 × 3 matrix and an offset in 64-bit. Every coordinate it produces is then cleaned before it is stored: snapped to the whole number when within 0.000001 of one, otherwise rounded to six decimals. The cleaning removes the tiny residues of sine and cosine (a 90 degree turn lands on whole millimeters again) and limits stored coordinates to a millionth of a millimeter.

Calculations under this policy

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

transform.js DetailCraft 0.8.1 64-bit 10 calculations Reviewed

Tests bin/test-detailcraft.mjs

Rotation about a vertical line

64-bit

The matrix and offset that turn points about a point in plan.

A = [[c, −s], [s, c]]; t = center − A·center; c = cos(deg·π ÷ 180), s = sin(deg·π ÷ 180)

Math.cos and Math.sin of the angle in radians. For 90 degrees the cosine is about 6e-17 rather than 0; the cleaning step afterwards removes that from the stored coordinates.

  • transform.js
  • lines 39 to 45
  • DetailCraft 0.8.1
export function rotateXf(center, deg) {
  const a = Number(deg) * R, [cx, cy] = center.map(Number);
  if (!Number.isFinite(a) || !Number.isFinite(cx) || !Number.isFinite(cy)) throw new Error('bad-angle');
  const c = Math.cos(a), s = Math.sin(a);
  const A = [[c, -s, 0], [s, c, 0], [0, 0, 1]];
  return { kind: 'rotate', A, t: [cx - (c * cx - s * cy), cy - (s * cx + c * cy), 0], det: 1, deg: Number(deg) };
}

Mirror in a vertical plane

64-bit

The matrix and offset that reflect points across a line in plan.

A = 2·d·dᵀ − I; t = p₁ − A·p₁; d = (p₂ − p₁) ÷ |p₂ − p₁|

The line's direction is its difference divided by its length from Math.hypot; a line shorter than 1e-6 mm is refused. The line's angle is kept from Math.atan2 for turning footings and stairs.

  • transform.js
  • lines 47 to 56
  • DetailCraft 0.8.1
export function mirrorXf(p1, p2) {
  const [x1, y1] = p1.map(Number), [x2, y2] = p2.map(Number);
  const dx = x2 - x1, dy = y2 - y1, L = Math.hypot(dx, dy);
  if (!(L > 1e-6) || ![x1, y1].every(Number.isFinite)) throw new Error('bad-mirror');
  const ux = dx / L, uy = dy / L;
  // reflection across the line through p1 with direction (ux, uy): A = 2·d·dᵀ − I in plan, z kept
  const A = [[2 * ux * ux - 1, 2 * ux * uy, 0], [2 * ux * uy, 2 * uy * uy - 1, 0], [0, 0, 1]];
  const t = [x1 - (A[0][0] * x1 + A[0][1] * y1), y1 - (A[1][0] * x1 + A[1][1] * y1), 0];
  return { kind: 'mirror', A, t, det: -1, phi: Math.atan2(uy, ux) / R };
}

A point through the transform

64-bit

The new position of a point.

p′ = A·p + t

Three products and the offset added in a fixed order per coordinate.

  • transform.js
  • line 58
  • DetailCraft 0.8.1
export const applyPt = (xf, p) => [0, 1, 2].map((i) => xf.A[i][0] * p[0] + xf.A[i][1] * p[1] + xf.A[i][2] * p[2] + xf.t[i]);

Cleaning a stored coordinate

64-bit

The rounding applied to every coordinate and angle a transform stores.

|n − round(n)| < 10⁻⁶ → round(n); else round(n × 10⁶) ÷ 10⁶

The value kept is rounded to six decimals with Math.round. The tolerance is absolute, a millionth of a millimeter whatever the size of the coordinate.

  • transform.js
  • line 61
  • DetailCraft 0.8.1
const clean = (n) => (Math.abs(n - Math.round(n)) < 1e-6 ? Math.round(n) : Math.round(n * 1e6) / 1e6);

A member's new rotation

64-bit

The rotation that puts a member's section axis where the transform sent it.

rotation = atan2(−want·u₀, want·v₀) × 180 ÷ π, brought into 0 to 360

The frame is rebuilt from the cleaned end points with rotation 0, then one Math.atan2 of two dot products. The angle is cleaned to six decimals.

  • transform.js
  • lines 65 to 69
  • DetailCraft 0.8.1
/** The rotation (deg) whose frame on start → end has its v along `want`. */
function rotationFor(start, end, want) {
  const f0 = frame(start, end, 0);
  return norm360(Math.atan2(-dot(want, f0.u), dot(want, f0.v)) / R);
}

A member through the transform

64-bit

The new end points, rotation, position, holes and copes of a member.

End points go through the matrix and are cleaned. For a mirror the section is re-placed by its own symmetry, which flips the sign of flange hole offsets or swaps top and bottom exactly, without arithmetic on the values.

  • transform.js
  • lines 71 to 105
  • DetailCraft 0.8.1
/**
 * One member through xf: { start, end, rotation, position, holes, cuts, shift? }.
 * For a mirror the section is re-placed by its own symmetry (see the header); `kind` is the profile kind.
 */
export function transformMember(m, xf, kind) {
  const f = frame(m.start, m.end, m.rotation);
  let start = applyPt(xf, m.start), end = applyPt(xf, m.end);
  const U = applyDir(xf, f.u), V = applyDir(xf, f.v);
  let wantV = V, position = m.position, holes = m.holes.map((h) => ({ ...h })), cuts = m.cuts.map((c) => ({ ...c }));
  let note = null;
  if (xf.det < 0) {
    if (kind === 'C') {
      // channel: the image is the channel turned over about u — v' = −V, top and bottom swap
      wantV = neg(V);
      position = m.position === 'top' ? 'bottom' : m.position === 'bottom' ? 'top' : m.position;
      for (const h of holes) {
        if (h.face === 'web') h.y = -h.y;
        else if (h.face === 'top') h.face = 'bottom';
        else if (h.face === 'bottom') h.face = 'top';
      }
      for (const c of cuts) c.side = c.side === 'top' ? 'bottom' : 'top';
    } else if (kind === 'L') {
      // equal angle: symmetric about its diagonal — u' = V, v' = U; the line keeps the section where it was
      wantV = U;
      if (m.position !== 'middle') note = 'angle-offset';
    } else {
      // I-shapes, tubes, round tubes, plates, flats: symmetric about v — v' = V, u' = −U: flange and plate holes change side
      for (const h of holes) if (h.face !== 'web') h.y = -h.y;
    }
  }
  start = cleanPt(start); end = cleanPt(end);
  const rotation = rotationFor(start, end, wantV);
  for (const h of holes) h.y = clean(h.y);
  return { start, end, rotation, position, holes, cuts, note };
}

Mirrored angle with a top or bottom position

64-bit

Shifts a mirrored angle's line so the steel lands where the mirror image is.

d = A·v̂·voff − v̂′·voff; start, end += d

The shift is the difference of two offset vectors of equal length; the shifted points are cleaned again.

  • transform.js
  • lines 107 to 117
  • DetailCraft 0.8.1
/** An angle mirrored with a top/bottom position: its line moves so the steel lands where the image is. */
function angleShift(m, prof, out, xf) {
  const f = frame(m.start, m.end, m.rotation);
  const off = voffFor(m.position, prof.depth);
  const want = applyDir(xf, f.v).map((n) => n * off);           // where the section center went
  const nf = frame(out.start, out.end, out.rotation);
  const have = nf.v.map((n) => n * voffFor(m.position, prof.depth));
  const d = sub(want, have);
  out.start = cleanPt(out.start.map((n, i) => n + d[i]));
  out.end = cleanPt(out.end.map((n, i) => n + d[i]));
}

A footing through the transform

64-bit

The new position and rotation of a footing.

rotate: rot + deg; mirror: 2·φ − rot; both brought into 0 to 360

The footing's point goes through the matrix and is cleaned; its angle is changed by plain addition, with the mirror line's angle from Math.atan2.

  • transform.js
  • lines 139 to 143
  • DetailCraft 0.8.1
const p = cleanPt(applyPt(xf, [f.x, f.y, f.top]));
let rotation = Number(f.rotation) || 0;
if (xf.kind === 'rotate') rotation = norm360(rotation + xf.deg);
else if (xf.kind === 'mirror') rotation = norm360(2 * xf.phi - rotation);
tx.updateFooting(f.id, { x: p[0], y: p[1], top: p[2], rotation });

Center of a selection

64-bit

The plan middle of the selection, used as the point to rotate about.

((x_min + x_max) ÷ 2, (y_min + y_max) ÷ 2, z_min)

The middle of the bounding box of the selected points, cleaned to six decimals.

  • transform.js
  • lines 192 to 202
  • DetailCraft 0.8.1
/** The plan center of the selection's members and footings (for "rotate about the selection's middle"). */
export function selectionCenter(state, refs) {
  const ids = new Set(refs.map((r) => r.kind + ':' + r.id));
  let x0 = Infinity, y0 = Infinity, z0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  const take = (p) => { x0 = Math.min(x0, p[0]); y0 = Math.min(y0, p[1]); z0 = Math.min(z0, p[2]); x1 = Math.max(x1, p[0]); y1 = Math.max(y1, p[1]); };
  for (const m of state.members) if (ids.has('member:' + m.id)) { take(m.start); take(m.end); }
  for (const f of state.footings) if (ids.has('footing:' + f.id)) take([f.x, f.y, f.top]);
  for (const x of state.stairs || []) if (ids.has('stair:' + x.id)) for (const [a, b] of stairOutline(x)) { take(a); take(b); }
  if (!Number.isFinite(x0)) return null;
  return [clean((x0 + x1) / 2), clean((y0 + y1) / 2), clean(z0)];
}

Offset of a drag

64-bit

The offset a dragged part is moved by: onto a grid node, or in steps.

node − held point, or round(raw ÷ step) × step

The pointer's raw offset is never stored. What is stored is either the difference between a grid node and one of the held part's own points, or the raw offset rounded to a multiple of the step (10 mm by default), cleaned to six decimals. The choice of node is made in screen pixels.

  • transform.js
  • lines 206 to 228
  • DetailCraft 0.8.1
/**
 * raw: the pointer's offset in mm (already in the drag's plane); points: the held part's own points (member ends,
 * footing center); nodes: grid nodes [x, y, z]; project(p) → { x, y, behind } screen px; mask: which axes the drag moves
 * ([1, 1, 0] across a plan, [1, 0, 1] in an elevation, [0, 0, 1] up and down). Returns { offset, node, point }.
 */
export function snapOffset(raw, points, nodes, project, { mask = [1, 1, 1], px = 18, step = 10 } = {}) {
  let best = null;
  for (const p of points) {
    const q = [p[0] + raw[0], p[1] + raw[1], p[2] + raw[2]];
    const sq = project(q);
    if (sq.behind) continue;
    for (const n of nodes) {
      const d = [n[0] - p[0], n[1] - p[1], n[2] - p[2]];
      if (d.some((v, i) => !mask[i] && Math.abs(v) > 0.5)) continue;          // not reachable in this drag's plane
      const sn = project(n);
      if (sn.behind) continue;
      const k = Math.hypot(sn.x - sq.x, sn.y - sq.y);
      if (k <= px && (!best || k < best.k)) best = { k, d, n, p };
    }
  }
  if (best) return { offset: best.d.map((v, i) => (mask[i] ? clean(v) : 0)), node: best.n, point: best.p };
  return { offset: raw.map((v, i) => (mask[i] ? clean(Math.round(v / step) * step) : 0)), node: null, point: null };
}

In detail

Method
Rotation about a vertical line and reflection in a vertical plane as matrices; the member's new rotation from Math.atan2 of where its section axes went.
Number types
64-bit numbers, stored rounded to 6 decimals.
Tolerances
Clean: 1e-6 absolute. Mirror line at least 1e-6 mm long. Offsets limited to 1e6 mm.
Limits
Each transform rounds to a millionth of a millimeter, so a long series of rotations by odd angles can drift by that much per step.

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