Resources Numerical accuracy Calculation policy

Objects on a tilted or flipped plane mapped to the plan

Maps circles, arcs, polylines, text, block references and hatches that a file stores in their own coordinate system onto the drawing's plan.

How the precision is kept

The plane's axes come from the DXF arbitrary axis rule and give a 2 × 3 matrix. Circles, arcs and ellipses are mapped as conics in closed form: they stay circles or arcs when the map keeps shapes and become ellipses otherwise. Only a polyline's arc segments under a tilt are replaced by points.

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.

ocs.js CadCraft 0.8.2 64-bit 6 calculations Reviewed

Tests bin/test-cadcraft.mjs

Axes of an object's own coordinate system

64-bit

The X, Y and Z axes of the plane an object was drawn on, from its extrusion direction.

Z = N ÷ |N|; X = (Wy × Z or Wz × Z) ÷ length; Y = Z × X

N is normalized with Math.hypot, X is a cross product normalized again, and Y is the cross product of the two unit vectors without a further normalization. The 1/64 threshold is exact in binary.

  • ocs.js
  • lines 25 to 31
  • CadCraft 0.8.2
/** The OCS axes of an extrusion direction (Arbitrary Axis Algorithm), or null for a missing / zero one. */
export function ocsAxes(n) {
  const z = n && Number.isFinite(n.x) && Number.isFinite(n.y) && Number.isFinite(n.z) ? unit(n) : null;
  if (!z) return null;
  const x = unit(Math.abs(z.x) < 1 / 64 && Math.abs(z.y) < 1 / 64 ? cross({ x: 0, y: 1, z: 0 }, z) : cross({ x: 0, y: 0, z: 1 }, z));
  return { x, y: cross(z, x), z };
}

Is the plane the drawing's own

64-bit

Whether an extrusion direction is straight up, so that nothing needs mapping.

True when the unit direction's x and y are both under 1e-9 in size and z is positive. Such an object is left as it is, so a tilt below about 1e-9 rad is ignored.

  • ocs.js
  • lines 33 to 37
  • CadCraft 0.8.2
/** Is this extrusion the world's own +Z (nothing to map)? Missing = yes. */
export function isWorldZ(n) {
  const a = ocsAxes(n);
  return !a || (Math.abs(a.z.x) < 1e-9 && Math.abs(a.z.y) < 1e-9 && a.z.z > 0);
}

Plane to plan matrix

64-bit

The transform from an object's own (x, y) at its elevation to the plan.

M = [X.x, X.y, Y.x, Y.y, Z.x·elevation, Z.y·elevation]

The plan keeps only the x and y components of the two in-plane axes; the elevation moves the origin by the x and y of the plane's normal times the elevation.

  • ocs.js
  • lines 39 to 44
  • CadCraft 0.8.2
/** OCS (x, y) at elevation z → plan, as a geom.js affine; null when N is +Z. */
export function planMatrix(n, z = 0) {
  if (isWorldZ(n)) return null;
  const a = ocsAxes(n), e = Number.isFinite(z) ? z : 0;
  return [a.x.x, a.x.y, a.y.x, a.y.y, a.z.x * e, a.z.y * e];
}

Mapped circle, arc or ellipse

64-bit

The circle, arc or ellipse that has exactly the points of the original after the map.

t* = ½·atan2(2 u·v, |u|² − |v|²); U = u·cos t* + v·sin t*; V = −u·sin t* + v·cos t*; a = |U|, b = |V|

The principal axes are found in closed form from the two mapped radii. With b ÷ a within 1e-9 of 1 the result is a circle or arc of radius a; with b ÷ a under 1e-9 it is a polyline along the major axis at 64 points per turn; otherwise an ellipse whose parameters are shifted by t*. A mirror is detected from the sign of U × V and reverses the direction of travel.

  • ocs.js
  • lines 49 to 76
  • CadCraft 0.8.2
/**
 * c + u·cos t + v·sin t for t in [t0, t1] (u, v conjugate half-diameters: a circle, an arc or an ellipse after m's linear
 * part) → a CadCraft circle, arc or ellipse with the same points; a squashed one (seen edge on) → a polyline.
 */
function conic(base, c, u, v, t0, t1) {
  const full = t1 - t0 >= TAU - 1e-12;
  const uu = u.x * u.x + u.y * u.y, vv = v.x * v.x + v.y * v.y, uv = u.x * v.x + u.y * v.y;
  let ts = 0.5 * Math.atan2(2 * uv, uu - vv);   // the parameter of a principal axis
  let U = { x: u.x * Math.cos(ts) + v.x * Math.sin(ts), y: u.y * Math.cos(ts) + v.y * Math.sin(ts) };
  let V = { x: -u.x * Math.sin(ts) + v.x * Math.cos(ts), y: -u.y * Math.sin(ts) + v.y * Math.cos(ts) };
  if (Math.hypot(V.x, V.y) > Math.hypot(U.x, U.y)) { ts += Math.PI / 2; [U, V] = [V, { x: -U.x, y: -U.y }]; }
  const a = Math.hypot(U.x, U.y), b = Math.hypot(V.x, V.y);
  if (!(a > 0)) return null;
  const ccw = U.x * V.y - U.y * V.x >= 0;   // a mirror reverses the direction of travel
  const s0 = ccw ? t0 - ts : -(t1 - ts), s1 = ccw ? t1 - ts : -(t0 - ts);
  const ratio = b / a;
  if (ratio < 1e-9) {   // edge on: the points along the major axis
    const n = full ? 64 : Math.max(8, Math.ceil(64 * (t1 - t0) / TAU)), pts = [];
    for (let i = 0; i <= n; i++) { const t = s0 + (s1 - s0) * i / n; pts.push({ x: c.x + U.x * Math.cos(t), y: c.y + U.y * Math.cos(t), b: 0 }); }
    return { ...base, type: 'poly', closed: false, pts };
  }
  const rot = Math.atan2(U.y, U.x);
  if (Math.abs(ratio - 1) < 1e-9) {
    if (full) return { ...base, type: 'circle', cx: c.x, cy: c.y, r: a };
    return { ...base, type: 'arc', cx: c.x, cy: c.y, r: a, a0: normAngle(rot + s0), a1: normAngle(rot + s1) };
  }
  return { ...base, type: 'ellipse', cx: c.x, cy: c.y, mx: U.x, my: U.y, ratio, t0: full ? 0 : normAngle(s0), t1: full ? TAU : normAngle(s0) + (s1 - s0) };
}

Polyline arcs as points

64-bit

A polyline's arc segments as points, for a map that does not keep shapes.

k = max(4, ⌈64 × |sweep| ÷ 2π⌉); point = c + r·(cos a, sin a)

Each bulged segment is replaced by points on its arc at 64 per full turn, computed from the center and radius bulgeArc returns; the mapped polyline has no bulges. The straight pieces between the mapped points are not on the mapped curve.

  • ocs.js
  • lines 81 to 96
  • CadCraft 0.8.2
/** A polyline's points with every bulged segment as points along its arc (for a map that is not similar). */
function flatPoly(e) {
  const pts = e.pts, n = pts.length, out = [];
  const segs = e.closed ? n : n - 1;
  for (let i = 0; i < n; i++) {
    const p = pts[i];
    out.push({ ...p, b: 0 });
    if (i >= segs || !p.b) continue;
    const q = pts[(i + 1) % n], arc = bulgeArc(p, q, p.b);
    if (!arc) continue;
    const sweep = 4 * Math.atan(p.b), k = Math.max(4, Math.ceil(64 * Math.abs(sweep) / TAU));
    const a0 = Math.atan2(p.y - arc.cy, p.x - arc.cx);
    for (let j = 1; j < k; j++) { const a = a0 + sweep * j / k; out.push({ x: arc.cx + arc.r * Math.cos(a), y: arc.cy + arc.r * Math.sin(a), b: 0 }); }
  }
  return out;
}

Mapping each kind of object

64-bit

A drawing object in its own coordinate system put through the plane's matrix.

text: rotation = atan2(M·d); height × |M·d × M·up| ÷ |M·d|; block: scales and rotation read from the mapped axes

Points go through the matrix once. Text takes its direction and height from the mapped direction and up vectors and gets a width factor when the two scales differ by more than 1e-9. A block reference's rotation and scales are read off its mapped axes; a shear above 1e-9 of the scale makes a new block with the contents mapped.

  • ocs.js
  • lines 100 to 171
  • CadCraft 0.8.2
export function toPlan(e, m, opt = {}) {
  if (!m || !e) return e;
  const P = (x, y) => mApply(m, { x, y });
  const sim = mSimilar(m), det = mDet(m);
  switch (e.type) {
    case 'line': { const a = P(e.x1, e.y1), b = P(e.x2, e.y2); return { ...e, x1: a.x, y1: a.y, x2: b.x, y2: b.y }; }
    case 'point': { const a = P(e.x, e.y); return { ...e, x: a.x, y: a.y }; }
    case 'circle': return conic(rest(e, ['cx', 'cy', 'r']), P(e.cx, e.cy), lin(m, e.r, 0), lin(m, 0, e.r), 0, TAU);
    case 'arc': {
      let a1 = e.a1; while (a1 <= e.a0) a1 += TAU;
      return conic(rest(e, ['cx', 'cy', 'r', 'a0', 'a1']), P(e.cx, e.cy), lin(m, e.r, 0), lin(m, 0, e.r), e.a0, a1);
    }
    case 'ellipse': {
      const mi = { x: -e.my * e.ratio, y: e.mx * e.ratio };
      const full = e.t1 - e.t0 >= TAU - 1e-12;
      let t1 = e.t1; while (!full && t1 <= e.t0) t1 += TAU;
      return conic(rest(e, ['cx', 'cy', 'mx', 'my', 'ratio', 't0', 't1']), P(e.cx, e.cy), lin(m, e.mx, e.my), lin(m, mi.x, mi.y), full ? 0 : e.t0, full ? TAU : t1);
    }
    case 'poly': {
      const k = sim ? sim.s : Math.sqrt(Math.abs(det));
      const src = sim ? e.pts : flatPoly(e);
      const w = (v) => (Number.isFinite(v) ? v * k : v);
      return { ...e, ...(e.w ? { w: w(e.w) } : {}), pts: src.map((p) => ({ ...p, ...P(p.x, p.y), b: sim && det < 0 ? -(p.b || 0) : sim ? p.b || 0 : 0, ...(p.sw !== undefined ? { sw: w(p.sw) } : {}), ...(p.ew !== undefined ? { ew: w(p.ew) } : {}) })) };
    }
    case 'text': {
      const a = P(e.x, e.y), r = e.rot || 0;
      const d = lin(m, Math.cos(r), Math.sin(r)), up = lin(m, -Math.sin(r), Math.cos(r));
      const dl = Math.hypot(d.x, d.y);
      if (!(dl > 0)) return { ...e, x: a.x, y: a.y };
      let rot = Math.atan2(d.y, d.x);
      const hk = Math.abs(d.x * up.y - d.y * up.x) / dl;   // the height across the new direction
      // mirrored text (seen from below) stays readable, as a mirror leaves it (MIRRTEXT 0): turned round if it reads right to left
      if (det < 0 && Math.cos(rot) < -1e-9) rot += Math.PI;
      return { ...e, x: a.x, y: a.y, rot: normAngle(rot), h: e.h * hk, ...(hk > 0 && Math.abs(dl / hk - 1) > 1e-9 ? { wf: (e.wf || 1) * dl / hk } : {}), ...(e.ww ? { ww: e.ww * dl } : {}) };
    }
    case 'insert': {
      const blk = opt.blocks?.get?.(e.block);
      const a = P(e.x, e.y);
      const r = e.rot || 0, sx = e.sx ?? 1, sy = e.sy ?? 1;
      const c1 = lin(m, sx * Math.cos(r), sx * Math.sin(r)), c2 = lin(m, -sy * Math.sin(r), sy * Math.cos(r));
      let rot = Math.atan2(c1.y, c1.x), nsx = Math.hypot(c1.x, c1.y);
      let nsy = c2.x * -Math.sin(rot) + c2.y * Math.cos(rot);
      const shear = c2.x * Math.cos(rot) + c2.y * Math.sin(rot);
      if (Math.abs(shear) <= 1e-9 * Math.max(nsx, Math.abs(nsy), 1e-300)) {
        if (nsy < 0) { rot += Math.PI; nsx = -nsx; nsy = -nsy; }   // a mirror as AutoCAD keeps it: X scale negative
        return { ...e, x: a.x, y: a.y, rot: normAngle(rot), sx: nsx, sy: nsy };
      }
      // a shear no reference can hold: a new unnamed block with the contents mapped, placed as it is
      if (!blk || !opt.makeBlock || (opt.depth || 0) > 16) return { ...e, x: a.x, y: a.y, rot: normAngle(rot), sx: nsx, sy: nsy };
      const full = mMul(m, insertMatrix(e, blk));
      const ents = [];
      for (const c of blk.entities || []) { const x = toPlan(c, full, { ...opt, depth: (opt.depth || 0) + 1 }); if (x) ents.push(...[].concat(x)); }
      const name = opt.makeBlock(ents);
      const out = { ...rest(e, ['attrs', 'attloc', 'dyn', 'clip']), block: name, x: 0, y: 0, sx: 1, sy: 1, rot: 0 };
      return out;
    }
    case 'hatch': {
      const loops = e.loops.map((l) => { const o = []; for (let i = 0; i + 1 < l.length; i += 2) { const p = P(l[i], l[i + 1]); o.push(p.x, p.y); } return o; });
      const out = { ...e, loops };
      if (e.pangle !== undefined || !e.patlines) { const d = lin(m, Math.cos(e.pangle || 0), Math.sin(e.pangle || 0)); out.pangle = normAngle(Math.atan2(d.y, d.x)); if (!out.pangle) delete out.pangle; }
      if (e.patlines) out.patlines = e.patlines.map((l) => {
        // hatch.js: angle in degrees, base point, offset (dx along the line, dy across it), dashes along the line
        const r = l.angle * Math.PI / 180, dir = { x: Math.cos(r), y: Math.sin(r) }, nrm = { x: -dir.y, y: dir.x };
        const d = lin(m, dir.x, dir.y), s = Math.hypot(d.x, d.y) || 1, nd = { x: d.x / s, y: d.y / s };
        const off = lin(m, l.dx * dir.x + l.dy * nrm.x, l.dx * dir.y + l.dy * nrm.y), b = P(l.x, l.y);
        return { ...l, angle: Math.atan2(nd.y, nd.x) * 180 / Math.PI, x: b.x, y: b.y, dx: off.x * nd.x + off.y * nd.y, dy: -off.x * nd.y + off.y * nd.x, dashes: (l.dashes || []).map((v) => v * s) };
      });
      return out;
    }
    default: return e;
  }
}

In detail

Method
Arbitrary Axis Algorithm as the file header states it: Ax = Wy × N when |Nx| and |Ny| are both under 1/64, else Wz × N; Ay = N × Ax. Principal axes of the mapped conic from t* = ½·atan2(2 u·v, |u|² − |v|²).
Number types
64-bit numbers.
Tolerances
N counts as straight up or straight down when its unit x and y are under 1e-9. A conic is a circle when |b ÷ a − 1| < 1e-9 and edge-on when b ÷ a < 1e-9. A block reference is free of shear within 1e-9 of its scale. A full turn is 2π − 1e-12 or more.
Limits
A direction within 1e-9 of straight up is not mapped at all. Bulged polyline segments under a tilted plane become straight pieces at 64 per full turn.

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