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