Resources Numerical accuracy Calculation policy

DWG and DXF linework into project units

Turns the lines, arcs, polylines, splines, ellipses and block references of a drawing into linework, feature lines and survey points in the project's units.

How the precision is kept

Coordinates stay 64-bit from the drawing model to the project. Curves are replaced by vertices at most 5° of turn apart, and splines by evenly spaced samples, so a curve's imported shape is a chain of chords. The unit scale is one 64-bit division of two decimal factors.

Calculations under this policy

15 calculations in 2 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.

cad-linework.js CivilCraft 0.8.1 64-bit 12 calculations Reviewed

Tests bin/test-civilcraft-cad.mjs, bin/test-civilcraft-arcs.mjs, bin/test-survey.mjs

Meters per drawing unit

Exact

The length of each $INSUNITS drawing unit in meters.

Each factor is the unit's defined value: 0.0254, 0.3048, 1609.344 and 0.9144 are exact by definition, and the US survey foot is written as the fraction 1200 / 3937.

  • cad-linework.js
  • lines 55 to 58
  • CivilCraft 0.8.1
const INSUNITS = {
  1: ['in', 0.0254], 2: ['ft', 0.3048], 3: ['mi', 1609.344], 4: ['mm', 0.001], 5: ['cm', 0.01], 6: ['m', 1], 7: ['km', 1000],
  8: ['microinch', 2.54e-8], 9: ['mil', 2.54e-5], 10: ['yd', 0.9144], 14: ['dm', 0.1], 21: ['ftUS', 1200 / 3937],
};

Drawing units to project units

64-bit

The factor every imported coordinate is multiplied by.

inch, yard, mile into feet: meters ÷ 0.3048; metric into feet: meters ÷ (1200 ÷ 3937); into meters: meters ÷ 1

The scale is one 64-bit division of two decimal factors, not a whole-number fraction. For inches, yards and miles into feet the quotient equals 1/12, 3 and 5280 exactly in 64-bit (checked for this review). A drawing in feet goes into a feet project at scale 1 whether its feet are international or US survey; the two differ by 2 parts per million and the import note says so. The scale shown in that note is rounded to 10 digits; the scale applied is not.

  • cad-linework.js
  • lines 69 to 80
  • CivilCraft 0.8.1
/** Drawing units → project units ('ft' = US survey feet, 'm'). */
export function unitScale(header, projectUnit = 'ft') {
  const u = cadUnits(header);
  const to = projectUnit === 'm' ? 'm' : 'ft';
  if (!u.metres) return { scale: 1, from: '', to, note: t('cad.units.unknown', { unit: t('cad.unit.' + to) }) };
  if (to === 'ft' && FEET.has(u.label)) return { scale: 1, from: u.label, to, note: u.label === 'ft' ? t('cad.units.feet') : null };
  if (to === 'm' && u.label === 'm') return { scale: 1, from: 'm', to, note: null };
  const projM = to === 'm' ? 1 : 1200 / 3937;
  // inches and other imperial lengths into a feet project: exact ratios to the international foot
  const scale = to === 'ft' && ['in', 'mi', 'yd', 'mil', 'microinch'].includes(u.label) ? u.metres / 0.3048 : u.metres / projM;
  return { scale, from: u.label, to, note: t('cad.units.scaled', { from: u.label, to: t('cad.unit.' + to), scale: +scale.toPrecision(10) }) };
}

Object coordinate system

64-bit

The rotation from an entity's own coordinate system to world coordinates, from its extrusion direction.

Az = N ÷ |N|; Ax = (Wy or Wz) × Az, normalized; Ay = Az × Ax

The extrusion vector is normalized with hypot. The switch between the two reference axes is at 1/64, the value the DXF reference gives. An extrusion within 1e-12 of +Z is treated as exactly +Z and no rotation is applied.

  • cad-linework.js
  • lines 94 to 106
  • CivilCraft 0.8.1
/** OCS → WCS for an extrusion direction (DXF reference, "Arbitrary axis algorithm"); null for the plain +Z. */
export function ocs(ext) {
  if (!ext || !Number.isFinite(ext.x) || !Number.isFinite(ext.y) || !Number.isFinite(ext.z)) return null;
  const L = Math.hypot(ext.x, ext.y, ext.z);
  if (!L) return null;
  const az = [ext.x / L, ext.y / L, ext.z / L];
  if (Math.abs(az[0]) < 1e-12 && Math.abs(az[1]) < 1e-12 && az[2] > 0) return null;
  const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
  let ax = Math.abs(az[0]) < 1 / 64 && Math.abs(az[1]) < 1 / 64 ? cross([0, 1, 0], az) : cross([0, 0, 1], az);
  const n = Math.hypot(...ax); ax = ax.map((v) => v / n);
  const ay = cross(az, ax);
  return [ax[0], ay[0], az[0], 0, ax[1], ay[1], az[1], 0, ax[2], ay[2], az[2], 0];
}

Composing transforms

64-bit

The product of two 3×4 affine transforms, used for nested block references.

Each entry is a sum of three products plus the translation, in 64-bit. Each block reference forms five of these products (six with an extrusion), and a reference nested 8 deep repeats that at every level before a vertex is transformed.

  • cad-linework.js
  • lines 82 to 90
  • CivilCraft 0.8.1
// ---------------------------------------------------------------- 3D affine transforms (row-major 3×4)
function mul(a, b) {   // a ∘ b
  const o = new Array(12);
  for (let r = 0; r < 3; r++) {
    for (let c = 0; c < 3; c++) o[r * 4 + c] = a[r * 4] * b[c] + a[r * 4 + 1] * b[4 + c] + a[r * 4 + 2] * b[8 + c];
    o[r * 4 + 3] = a[r * 4] * b[3] + a[r * 4 + 1] * b[7] + a[r * 4 + 2] * b[11] + a[r * 4 + 3];
  }
  return o;
}

Block reference placement

64-bit

The transform of one block reference: base point, scale, array spacing, rotation, insertion point and extrusion.

M = parent · OCS · T(insert) · R(rotation) · T(column, row) · S(scale) · T(−base)

Five matrix products are formed for every reference and every array cell, six when the reference has an extrusion. The array counts are clamped to 1 to 1000 with a bitwise OR on the count, which is a whole number; no coordinate is truncated.

  • cad-linework.js
  • lines 293 to 302
  • CivilCraft 0.8.1
const s = e.scale || { x: 1, y: 1, z: 1 }, r = e.rotation || 0, ins = e.insert || { x: 0, y: 0, z: 0 }, base = b.base || { x: 0, y: 0, z: 0 };
const cols = e.type === 'MINSERT' ? Math.max(1, Math.min(1000, e.cols | 0 || 1)) : 1, rows = e.type === 'MINSERT' ? Math.max(1, Math.min(1000, e.rows | 0 || 1)) : 1;
const c = Math.cos(r), sn = Math.sin(r);
for (let ci = 0; ci < cols; ci++) for (let ri = 0; ri < rows; ri++) {
  // block → (−base) → scale → (+ column/row spacing) → rotate → (+insert) → OCS
  const step = translate(ci * (e.colSpacing || 0), ri * (e.rowSpacing || 0), 0);
  const rot = [c, -sn, 0, 0, sn, c, 0, 0, 0, 0, 1, 0];
  const scl = [s.x ?? 1, 0, 0, 0, 0, s.y ?? 1, 0, 0, 0, 0, s.z ?? 1, 0];
  const M = mul(mul(withOcs(m, e.extrusion), translate(ins.x, ins.y, ins.z || 0)), mul(rot, mul(step, mul(scl, translate(-base.x, -base.y, -(base.z || 0))))));
  walk(b.entities || [], M, layer, depth + 1);

Arcs and circles as vertices

64-bit

Points along a circular arc, at most 5° apart.

n = max(2, ⌈sweep ÷ 5°⌉); p = c + r·(cos a, sin a)

Every vertex is on the circle to rounding; the chord between two vertices is not. Angles are start + sweep × i ÷ n, not a running sum, so the last vertex lands on the end angle.

  • cad-linework.js
  • lines 109 to 117
  • CivilCraft 0.8.1
// ---------------------------------------------------------------- curves → vertices
function arcPts(cx, cy, r, a0, a1, z = 0) {   // CCW from a0 to a1 (radians)
  let sweep = a1 - a0;
  while (sweep <= 0) sweep += 2 * Math.PI;
  const n = Math.max(2, Math.ceil(sweep / STEP));
  const out = [];
  for (let i = 0; i <= n; i++) { const a = a0 + (sweep * i) / n; out.push([cx + r * Math.cos(a), cy + r * Math.sin(a), z]); }
  return out;
}

Polyline bulges as vertices

64-bit

The arc between two polyline vertices that carry a bulge, as intermediate points.

θ = 4·atan(bulge); r = chord ÷ (2·sin(θ/2)); center = chord middle + r·cos(θ/2) along the normal

An arc of 5° or less adds no intermediate vertices, so points are only ever evaluated from a center that is at most about 11.5 chord lengths away. Elevation along the arc is interpolated linearly by step.

  • cad-linework.js
  • lines 118 to 144
  • CivilCraft 0.8.1
/** Vertices with DXF bulges (the arc starting at a vertex) → points; `closed` adds the closing segment's arc. */
function bulgedPts(vs, closed, zOf) {
  const out = [];
  const n = vs.length;
  for (let i = 0; i < n; i++) {
    const a = vs[i], z = zOf(a);
    out.push([a.x, a.y, z]);
    if (i === n - 1 && !closed) break;
    const b = vs[(i + 1) % n], bu = a.bulge || 0;
    if (!bu || !Number.isFinite(bu)) continue;
    const dx = b.x - a.x, dy = b.y - a.y, c = Math.hypot(dx, dy);
    if (!c) continue;
    const theta = 4 * Math.atan(bu);              // signed sweep
    const r = c / (2 * Math.sin(theta / 2));
    const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
    const h = r * Math.cos(theta / 2);            // centre distance from the chord's middle (signed with r)
    const cx = mx - (dy / c) * h, cy = my + (dx / c) * h;
    const a0 = Math.atan2(a.y - cy, a.x - cx);
    const steps = Math.max(1, Math.ceil(Math.abs(theta) / STEP));
    const zb = zOf(b);
    for (let k = 1; k < steps; k++) {
      const ang = a0 + (theta * k) / steps, R = Math.abs(r);
      out.push([cx + R * Math.cos(ang), cy + R * Math.sin(ang), z + ((zb - z) * k) / steps]);
    }
  }
  return out;
}

Splines as vertices

64-bit

Points along a B-spline, rational or not, sampled evenly in its parameter.

samples = min(4000, max(16, 32 × (control points − degree)))

De Boor's algorithm in homogeneous coordinates, then one division by the weight, which is not guarded against zero. Samples are even in the parameter, not in length or turn, so a tightly curved span gets no more points than a straight one.

  • cad-linework.js
  • lines 145 to 175
  • CivilCraft 0.8.1
/** A B-spline (optionally rational) by de Boor, sampled evenly in its parameter: ~32 vertices per control span. */
export function splinePts(sp) {
  const P = sp.control || [], p = sp.degree | 0, U = sp.knots || [];
  if (P.length > p && p >= 1 && U.length === P.length + p + 1) {
    const W = sp.weights && sp.weights.length === P.length ? sp.weights : null;
    const u0 = U[p], u1 = U[P.length];
    const n = Math.min(4000, Math.max(16, (P.length - p) * 32));
    const out = [];
    for (let i = 0; i <= n; i++) {
      const u = i === n ? u1 : u0 + ((u1 - u0) * i) / n;
      let k = p;
      while (k < P.length - 1 && u >= U[k + 1]) k++;
      const d = [];
      for (let j = 0; j <= p; j++) { const q = P[k - p + j], w = W ? W[k - p + j] : 1; d.push([q.x * w, q.y * w, (q.z || 0) * w, w]); }
      for (let r = 1; r <= p; r++) {
        for (let j = p; j >= r; j--) {
          const i0 = k - p + j, den = U[i0 + p - r + 1] - U[i0];
          const a = den ? (u - U[i0]) / den : 0;
          for (let c = 0; c < 4; c++) d[j][c] = (1 - a) * d[j - 1][c] + a * d[j][c];
        }
      }
      const q = d[p];
      out.push([q[0] / q[3], q[1] / q[3], q[2] / q[3]]);
    }
    return out;
  }
  const F = (sp.fit || []).map((q) => [q.x, q.y, q.z || 0]);
  if (F.length === 2) return F;
  if (F.length > 2) return throughFit(F);
  return P.length >= 2 ? P.map((q) => [q.x, q.y, q.z || 0]) : [];
}

Splines stored as fit points only

64-bit

A smooth curve through the fit points when the file gives no control points.

centripetal Catmull-Rom: knot step = √(distance between points); 8 vertices per span

The curve passes through every fit point, but between them it is a Catmull-Rom curve, not the spline the source program would draw from the same fit points. A repeated fit point takes a knot step of 1e-9 to avoid a division by zero.

  • cad-linework.js
  • lines 177 to 195
  • CivilCraft 0.8.1
/** A smooth curve through fit points (centripetal Catmull–Rom, 8 vertices a span) — for a spline stored only as fit
 *  points (DXF writers may leave out the control points). It passes every fit point, as the spline does. */
function throughFit(F) {
  const out = [F[0]];
  const P = [F[0].map((v, i) => 2 * v - F[1][i]), ...F, F.at(-1).map((v, i) => 2 * v - F.at(-2)[i])];
  const tj = (a, b) => Math.sqrt(Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])) || 1e-9;
  for (let i = 1; i < P.length - 2; i++) {
    const [p0, p1, p2, p3] = [P[i - 1], P[i], P[i + 1], P[i + 2]];
    const t1 = tj(p0, p1), t2 = t1 + tj(p1, p2), t3 = t2 + tj(p2, p3);
    for (let k = 1; k <= 8; k++) {
      const u = t1 + ((t2 - t1) * k) / 8;
      const L = (a, b, ta, tb) => a.map((v, j) => ((tb - u) * v + (u - ta) * b[j]) / (tb - ta));
      const A1 = L(p0, p1, 0, t1), A2 = L(p1, p2, t1, t2), A3 = L(p2, p3, t2, t3);
      const B1 = L(A1, A2, 0, t2), B2 = L(A2, A3, t1, t3);
      out.push(L(B1, B2, t1, t2));
    }
  }
  return out;
}

Ellipses as vertices

64-bit

Points along an ellipse or elliptical arc.

minor = ratio × (N × major); p = c + major·cos s + minor·sin s

Sampled every 5° of the ellipse parameter, at least 4 pieces. The parameter is not the turn angle, so on a flat ellipse the vertices are further apart in direction near the ends of the major axis.

  • cad-linework.js
  • lines 205 to 216
  • CivilCraft 0.8.1
case 'ELLIPSE': {
  const c = e.center, M = e.majorAxis, r = e.ratio;
  const N = ocs(e.extrusion) ? [e.extrusion.x, e.extrusion.y, e.extrusion.z] : [0, 0, 1];
  const L = Math.hypot(...N), n = N.map((v) => v / L);
  const mn = [n[1] * M.z - n[2] * M.y, n[2] * M.x - n[0] * M.z, n[0] * M.y - n[1] * M.x].map((v) => v * r);   // minor axis
  let s0 = e.startParam ?? 0, s1 = e.endParam ?? 2 * Math.PI;
  while (s1 <= s0) s1 += 2 * Math.PI;
  const full = Math.abs(s1 - s0 - 2 * Math.PI) < 1e-9;
  const k = Math.max(4, Math.ceil((s1 - s0) / STEP)), out = [];
  for (let i = 0; i <= k; i++) { const s = s0 + ((s1 - s0) * i) / k; out.push([c.x + M.x * Math.cos(s) + mn[0] * Math.sin(s), c.y + M.y * Math.cos(s) + mn[1] * Math.sin(s), (c.z || 0) + M.z * Math.cos(s) + mn[2] * Math.sin(s)]); }
  if (full) out.pop();
  return { parts: [out], closed: full, wcs: true };

One elevation per object, and flat objects

64-bit

The ELEV attribute of an imported object, and whether its vertices keep a z at all.

ELEV is set when every vertex z is within 1e-9 × max(1, |z|) of the first vertex, a tolerance scaled to the elevation. An object whose every |z| is below 1e-12 (absolute) is stored as 2D and its z is dropped.

  • cad-linework.js
  • lines 271 to 279
  • CivilCraft 0.8.1
const props = (layer, type, pts) => {
  const z0 = pts[0]?.[2];
  const same = pts.length && pts.every((p) => Math.abs(p[2] - z0) <= 1e-9 * Math.max(1, Math.abs(z0)));
  return { LAYER: layer, TYPE: type, ELEV: same && Number.isFinite(z0) && z0 !== 0 ? z0 : null, HANDLE: curHandle };
};
const shape = (pts) => {
  const flat = pts.every((p) => p[2] === 0 || Math.abs(p[2]) < 1e-12);
  return flat ? pts.map((p) => [p[0], p[1]]) : pts.map((p) => [p[0], p[1], p[2]]);
};

Feature lines read back from a drawing

64-bit

The vertices of a feature line written earlier by CivilCraft, scaled into project units.

x, y, z × scale

One multiplication per coordinate by the unit scale. Arcs are restored from the bulge values kept in the drawing's extended data, not refitted from the pieces.

  • cad-linework.js
  • lines 38 to 50
  • CivilCraft 0.8.1
/** CivilCraft feature lines written into this drawing (top-level model space only), in project units. */
export function cadFeatureLines(model, { projectUnit = 'ft', scale = null } = {}) {
  const ms = (model?.blocks || []).find((b) => String(b.name).toUpperCase() === '*MODEL_SPACE');
  const k = Number.isFinite(scale) && scale > 0 ? scale : unitScale(model?.header || {}, projectUnit).scale;
  const out = [];
  for (const e of ms?.entities || []) {
    if (!isFeatureLine(e) || e.invisible) continue;
    const vs = (e.vertices || []).filter((v) => !(v.flags & 16)).map((v) => ({ x: v.x * k, y: v.y * k, z: (v.z || 0) * k }));
    const f = featureFromCad(vs, !!e.closed, e.xdata, projectUnit);
    if (f) out.push(f);
  }
  return out;
}
block-points.js CivilCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-civilcraft-blocks.mjs

Survey point from a block's insertion point

64-bit

The world position of each block reference, and of each cell of an arrayed reference, in project units.

world = OCS · insert; array cell offset rotated by the reference's rotation; × unit scale

The insertion point is rotated by the extrusion's coordinate system and then multiplied once by the unit scale. Array offsets are column and row index times the spacing, not running sums.

  • block-points.js
  • lines 43 to 55
  • CivilCraft 0.8.1
const ins = e.insert || { x: 0, y: 0, z: 0 };
// the insertion point is in the reference's object coordinate system (its extrusion), as every 2D entity
const O = ocs(e.extrusion) || [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0];   // row-major 3 × 4; null = +Z
const ix = ins.x || 0, iy = ins.y || 0, iz = ins.z || 0;
const w = [O[0] * ix + O[1] * iy + O[2] * iz, O[4] * ix + O[5] * iy + O[6] * iz, O[8] * ix + O[9] * iy + O[10] * iz];
const attrs = {};
for (const a of e.attribs || []) { const tag = str(a.tag, 255).toUpperCase(); if (tag && !(tag in attrs)) attrs[tag] = str(a.value ?? a.text, 500); }
const cols = e.type === 'MINSERT' ? Math.max(1, Math.min(1000, e.cols | 0 || 1)) : 1, rows = e.type === 'MINSERT' ? Math.max(1, Math.min(1000, e.rows | 0 || 1)) : 1;
const r = e.rotation || 0, c = Math.cos(r), s = Math.sin(r);
for (let ci = 0; ci < cols; ci++) for (let ri = 0; ri < rows; ri++) {
  const dx = ci * (e.colSpacing || 0), dy = ri * (e.rowSpacing || 0);
  const ox = c * dx - s * dy, oy = s * dx + c * dy;   // MINSERT steps run along the reference's own turned axes (OCS)
  const p = [w[0] + O[0] * ox + O[1] * oy, w[1] + O[4] * ox + O[5] * oy, w[2] + O[8] * ox + O[9] * oy].map((v) => v * k);

Number from attribute text

64-bit

The elevation or number read from a block attribute such as EL 105.32.

Takes the first number in the text and reads a comma between digits as a decimal point, so 105,32 reads as 105.32 and 1,234.5 reads as 1.234. The text is parsed as written; nothing is rounded.

  • block-points.js
  • lines 78 to 84
  • CivilCraft 0.8.1
/** A number read from attribute text: "105.32", "105,32", "EL 105.32", "105.32'" — the first number in it. */
export function attrNumber(text) {
  const m = /[-+]?\d+(?:[.,]\d+)?(?:e[-+]?\d+)?/i.exec(String(text ?? ''));
  if (!m) return null;
  const v = Number(m[0].replace(',', '.'));
  return Number.isFinite(v) ? v : null;
}

Elevation kept for a block point

64-bit

Which elevation a point made from a block gets: the insertion z, an attribute, or none.

The value is copied, not rounded. An elevation of 1,000,000 or more in absolute value is discarded and the point is stored without one.

  • block-points.js
  • lines 104 to 109
  • CivilCraft 0.8.1
let zz = null;
if (z === 'insert') zz = r.z;
else if (zTag) { zz = attrNumber(r.attrs[zTag]); if (zz === null) skipped.noZ++; }
let d = desc === 'layer' ? r.layer : dTag ? str(r.attrs[dTag]) : r.block;
if (!d) d = str(r.block);
points.push({ n, x: r.x, y: r.y, z: Number.isFinite(zz) && Math.abs(zz) < 1e6 ? zz : null, d: str(d), blk: { name: str(r.block, 255), layer: str(r.layer, 255), handle: str(r.handle, 40) } });

In detail

Method
Unit scale from $INSUNITS. Object coordinate systems by the arbitrary axis algorithm (the code cites the DXF reference). Block references by composing 3×4 affine matrices. Bulge arcs from sweep = 4·atan(bulge). B-splines by de Boor's algorithm; a spline stored only as fit points becomes a centripetal Catmull-Rom curve through them.
Number types
64-bit numbers in plain arrays.
Tolerances
Curve vertices every 5° at most. One elevation per object when every z is within 1e-9 × max(1, |z|) of the first. An object is treated as flat when every |z| is below 1e-12 (absolute). Extrusion taken as +Z when |x| and |y| are below 1e-12. Full ellipse when the parameter span is within 1e-9 of 2π.
Limits
A chord's middle lies up to r × (1 − cos 2.5°), about 0.00095 r, inside an arc of radius r. Feet and US survey feet are never scaled into each other. Block nesting stops at 8 levels and the import at 5,000,000 vertices.

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