Resources Numerical accuracy Calculation policy

3D solids, faces and units in a drawing

Builds boxes, cylinders, wedges and extrusions from 2D outlines, reports their volume, and carries the 3D model between drawing units and meters.

How the precision is kept

The 3D model is kept in meters, and drawing units are converted with the defined factors (0.0254 m per inch, 0.3048 m per foot). Round shapes are polygons: a circle is outlined by 48 points, arcs are cut at steps of 7.5° at most and ellipses in 96 steps. Areas and volumes of round solids are therefore those of the polygons.

Calculations under this policy

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

model3d.js CadCraft 0.8.2 64-bit 9 calculations Reviewed

Tests bin/test-cadcraft.mjs, bin/test-cadcraft-3d.mjs

Meters per drawing unit

Exact

The table that converts drawing units to the 3D model's meters.

inch 0.0254; foot 0.3048; yard 0.9144; mile 1609.344; no unit 0.001

The factors are the defined values, none of them rounded; each is the nearest 64-bit number to that decimal. A drawing without units counts as millimeters.

  • model3d.js
  • lines 48 to 52
  • CadCraft 0.8.2
/** Meters per drawing unit, by `doc.meta.units` (the DXF $INSUNITS names). Unitless = millimeters. */
export const UNIT_M = {
  '': 0.001, inches: 0.0254, feet: 0.3048, miles: 1609.344, millimeters: 0.001, centimeters: 0.01, meters: 1,
  kilometers: 1000, microinches: 2.54e-8, mils: 2.54e-5, yards: 0.9144,
};

Outline of a closed 2D object

64-bit

The ring of points a solid is extruded from, and its area.

circle: 48 points at radius r; arc: steps of at most 7.5°; area = ½ |Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)|

A circle becomes 48 points on it and a full ellipse 96 steps, so the outline is a polygon inside the curve. Points closer than 1e-9 to the one before are skipped. The area is the shoelace sum over raw drawing coordinates, which loses digits for a shape far from the origin; a sum under 1e-12 in size is refused as having no area.

  • model3d.js
  • lines 366 to 403
  • CadCraft 0.8.2
/** A closed 2D object's boundary as a ring of [x, y] (drawing units), or {error} saying why it is not one. */
export function outline2d(e) {
  if (!e) return { error: t('model3d.nothing_there') };
  let ring = [];
  const push = (x, y) => { const l = ring[ring.length - 1]; if (!l || Math.hypot(l[0] - x, l[1] - y) > 1e-9) ring.push([x, y]); };
  if (e.type === 'circle') {
    for (let i = 0; i < PER_TURN; i++) push(e.cx + e.r * Math.cos((i / PER_TURN) * TAU), e.cy + e.r * Math.sin((i / PER_TURN) * TAU));
  } else if (e.type === 'ellipse') {
    if (!(Math.abs(e.t1 - e.t0) >= TAU - 1e-6 || (e.t0 === 0 && e.t1 === 0))) return { error: t('model3d.elliptical_arc_not_closed') };
    for (const p of ellipsePoints({ ...e, t0: 0, t1: TAU }, 96)) push(p.x, p.y);
  } else if (e.type === 'poly') {
    const pts = e.pts || [];
    const closed = e.closed || (pts.length > 2 && Math.hypot(pts[0].x - pts[pts.length - 1].x, pts[0].y - pts[pts.length - 1].y) < 1e-9);
    if (!closed) return { error: t('model3d.polyline_open_close_c') };
    for (const p of prims({ ...e, closed: true }, null)) {
      if (p.k === 'L') { push(p.x1, p.y1); push(p.x2, p.y2); continue; }
      if (p.k !== 'A') continue;
      // an arc segment runs from its start to its end, either way round: follow the polyline's direction
      const sweep = p.a1 - p.a0, n = Math.max(2, Math.ceil(Math.abs(sweep) / (TAU / PER_TURN)));
      const s = { x: p.cx + p.r * Math.cos(p.a0), y: p.cy + p.r * Math.sin(p.a0) };
      const last = ring[ring.length - 1];
      const fwd = !last || Math.hypot(last[0] - s.x, last[1] - s.y) < 1e-6 * Math.max(1, p.r);
      for (let i = 0; i <= n; i++) { const a = fwd ? p.a0 + (sweep * i) / n : p.a1 - (sweep * i) / n; push(p.cx + p.r * Math.cos(a), p.cy + p.r * Math.sin(a)); }
    }
  } else if (e.type === 'hatch' && e.loops?.length === 1) {
    const l = e.loops[0];
    for (let i = 0; i + 1 < l.length; i += 2) push(l[i], l[i + 1]);
  } else {
    const what = { line: t('model3d.a_line'), arc: t('model3d.an_arc'), text: t('model3d.a_text'), point: t('model3d.a_point'), insert: t('model3d.block_reference'), dim: t('model3d.a_dimension'), viewport: t('model3d.a_viewport') }[e.type] || t('model3d.this_object');
    return { error: t('model3d.not_closed', { what }) };
  }
  if (ring.length > 1 && Math.hypot(ring[0][0] - ring[ring.length - 1][0], ring[0][1] - ring[ring.length - 1][1]) < 1e-9) ring.pop();
  if (ring.length < 3) return { error: t('model3d.shape_has_no_area') };
  let a = 0;
  for (let i = 0; i < ring.length; i++) { const p = ring[i], q = ring[(i + 1) % ring.length]; a += p[0] * q[1] - q[0] * p[1]; }
  if (Math.abs(a) < 1e-12) return { error: t('model3d.shape_has_no_area') };
  return { pts: ring, area: Math.abs(a) / 2 };
}

Extruded solid

64-bit

A solid made by pushing a ring up or down by a height.

local ring = ring − (min x, min y); placed at (min x, min y, z₀)

The ring is shifted so its smallest x and y are 0 before the solid is built, and the solid is placed by a translation back; the subtraction and the later addition are each rounded once. A height of 1e-6 or less is refused.

  • model3d.js
  • lines 405 to 417
  • CadCraft 0.8.2
/** One solid: a ring (meters, [x, y]) on the plane z = z0, pushed up by h (down when negative). → instance id. */
export function makePrism(model, ring, z0, h, name = 'Extrusion') {
  if (!(Math.abs(h) > 1e-6)) throw new Error(t('model3d.height_must_not_zero'));
  let ox = Infinity, oy = Infinity;
  for (const [x, y] of ring) { ox = Math.min(ox, x); oy = Math.min(oy, y); }
  const d = model.newDef('group', model.uniqueName(name));
  addPolygon(d, ring.map(([x, y]) => [x - ox, y - oy, 0]), [0, 0, 1]);
  const fids = [...d.faces.keys()];
  if (fids.length !== 1) { model.defs.delete(d.id); throw new Error(fids.length ? t('model3d.outline_crosses_itself_draw') : t('model3d.outline_has_no_area')); }
  const r = pushPull(d, fids[0], h);
  if (!r.ok) { model.defs.delete(d.id); throw new Error(t('model3d.outline_could_not_extruded')); }
  return model.place(model.root, d.id, translation([ox, oy, z0]));
}

Cylinder

64-bit

A cylinder from a center, radius and height.

The solid is a prism on the polygon that ModelCraft's ngonPts returns for the radius, with 48 sides by default; it is not a true cylinder. A radius of 1e-6 or less is refused.

  • model3d.js
  • lines 424 to 428
  • CadCraft 0.8.2
/** A cylinder (48 sides, soft side edges). */
export function makeCylinder(model, c, r, z0, h, sides = PER_TURN) {
  if (!(r > 1e-6)) throw new Error(t('model3d.radius_must_more_than'));
  return makePrism(model, ngonPts([c[0], c[1], 0], [0, 0, 1], [1, 0, 0], r, sides).map((p) => [p[0], p[1]]), z0, h, 'Cylinder');
}

Volume of each solid

64-bit

The volume listed for every closed solid.

volume = |V of the definition| × |det of the placement's 3 × 3 part|

The definition's volume comes from ModelCraft's solidInfo and is multiplied by the absolute determinant of the placement, written out as a cofactor expansion in 64-bit numbers. A solid that is not closed gets no volume.

  • model3d.js
  • lines 448 to 459
  • CadCraft 0.8.2
/** The solids (and loose groups) at the root: name, closed or not, volume (m³) and world box. */
export function solidsOf(model) {
  const out = [];
  for (const [iid, inst] of model.root.insts) {
    const d = model.def(inst.def);
    if (!d) continue;
    const info = solidInfo(d);
    const det = Math.abs(inst.m[0] * (inst.m[5] * inst.m[10] - inst.m[9] * inst.m[6]) - inst.m[4] * (inst.m[1] * inst.m[10] - inst.m[9] * inst.m[2]) + inst.m[8] * (inst.m[1] * inst.m[6] - inst.m[5] * inst.m[2]));
    out.push({ iid, name: d.name, closed: info.closed, volume: info.closed ? Math.abs(info.volume) * det : null, faces: info.faces, bbox: model.bbox(d, inst.m) });
  }
  return out;
}

Changing the drawing's units

64-bit

Scales the 3D model so it stays the same size on the drawing when the units change.

ratio = m per new unit ÷ m per old unit; every vertex and placement offset × ratio

The ratio is one division of two table factors. Vertices are multiplied in place, so changing units and changing back multiplies by two rounded ratios and need not restore the coordinates bit for bit. A ratio within 1e-12 of 1 is not applied.

  • model3d.js
  • lines 461 to 490
  • CadCraft 0.8.2
/**
 * Change the drawing's units. Without solids it is a setting (not undone, as before). With solids, the 3D model is
 * scaled by the ratio of meters per unit so it stays on the drawing, and the units and the scaling are one undo step.
 * → {scaled: bool, ratio}
 */
export function setUnits(doc, units) {
  const before = doc.meta.units || '', after = units || '';
  const ratio = mPerUnit(after) / mPerUnit(before);
  const m = doc.model3d;
  if (before === after) return { scaled: false, ratio: 1 };
  if (!m || m.empty || Math.abs(ratio - 1) < 1e-12) {
    doc.meta.units = after; doc.version++; doc.emit();
    m?.syncUnits();
    return { scaled: false, ratio };
  }
  doc.change('UNITS', () => {
    doc.pending.ops.push({ units: true, before, after });
    doc.meta.units = after;
    m.change(t('model3d.drawing_units'), (mm) => {
      // every vertex and every placement's offset × ratio (placements stay rigid; a uniform scale commutes with them)
      for (const d of mm.defs.values()) {
        for (const p of d.verts.values()) { p[0] *= ratio; p[1] *= ratio; p[2] *= ratio; }
        for (const inst of d.insts.values()) { inst.m[12] *= ratio; inst.m[13] *= ratio; inst.m[14] *= ratio; }
      }
      for (const g of mm.guides.values()) g.p = g.p.map((x) => x * ratio);
    });
  });
  m.syncUnits();
  return { scaled: true, ratio };
}

3D faces written to a file

64-bit

The model's faces as 3D faces in drawing units for DWG and DXF.

file point = placed vertex in meters ÷ k, k = meters per drawing unit

Each corner is the placement applied to the vertex, then divided by k component by component. Triangles and convex quads are written as they are; other faces are written as triangles.

  • model3d.js
  • lines 304 to 341
  • CadCraft 0.8.2
/**
 * What a file needs for the 3D model, or null when there is none: the faces as 3DFACEs (drawing units; a face that is a
 * convex triangle or quad goes as itself, others as triangles with their inner edges invisible) and the JSON cut into
 * CADCRAFT xdata strings.
 */
export function forFile3d(doc) {
  const m = doc?.model3d;
  if (!m || m.empty) return null;
  const k = m.k;
  const faces = [];
  m.walk((def, wm) => {
    for (const f of def.faces.values()) {
      const W = (v) => { const p = apply(wm, def.verts.get(v)); return [p[0] / k, p[1] / k, p[2] / k]; };
      const l0 = f.loops[0];
      if (f.loops.length === 1 && (l0.length === 3 || (l0.length === 4 && convexQuad(l0.map((v) => def.verts.get(v)))))) {
        const pts = l0.map(W);
        if (pts.length === 3) pts.push(pts[2].slice());
        faces.push({ pts, invis: 0 });
        continue;
      }
      const along = new Set();
      for (const l of f.loops) for (let i = 0; i < l.length; i++) { const a = l[i], b = l[(i + 1) % l.length]; along.add(a + '|' + b); along.add(b + '|' + a); }
      for (const [a, b, c] of faceTris(def, f)) {
        const vis = (p, q) => along.has(p + '|' + q);
        const invis = (vis(a, b) ? 0 : 1) | (vis(b, c) ? 0 : 2) | (vis(c, a) ? 0 : 8);
        const C = W(c);
        faces.push({ pts: [W(a), W(b), C, C.slice()], invis });
      }
    }
  });
  const text = JSON.stringify(m.toJSON()).replace(/[^\x21-\x7e]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
  const points = [];
  for (let i = 0; i < text.length; i += 230) points.push(['CC3D:' + (points.length + 1) + ':' + text.slice(i, i + 230)]);
  // the 3D view as it was (0.4 C14): the open 3D tab's, else the one the file brought
  const view = cleanView(m.viewState?.() || m.savedView);
  if (view) points.push(['CCCAM:' + JSON.stringify(view)]);
  return { layer: M3D_LAYER, faces, points, text };
}

3D faces inside blocks

64-bit

Places the 3D faces a block holds once for every reference of that block.

(x′, y′) = M·(x, y); z′ = z

x and y go through the reference's 2 × 3 matrix, multiplied down through nested references. z is copied unchanged, so a reference's elevation and Z scale are not applied to the faces.

  • model3d.js
  • lines 205 to 207
  • CadCraft 0.8.2
const mm = mul2(m, insertMatrix2(e, b));
for (const f of own.get(b.name) || []) out.push({ ...f, layer: f.layer === '0' ? e.layer || '0' : f.layer, pts: f.pts.map((q) => [mm[0] * q[0] + mm[2] * q[1] + mm[4], mm[1] * q[0] + mm[3] * q[1] + mm[5], q[2] || 0]) });
walk(b.entities, mm, depth + 1, out);

3D faces read from a file

64-bit

A file's loose 3D faces brought into the model in meters.

corner in meters = file corner × k

Each coordinate is multiplied by k, the meters per drawing unit. A corner within 1e-9 of the one before in all three coordinates is dropped, and a face needs at least 3 corners left.

  • model3d.js
  • lines 147 to 151
  • CadCraft 0.8.2
  const q = [p[0] * k, p[1] * k, p[2] * k];
  if (!pts.length || pts[pts.length - 1].some((x, i) => Math.abs(x - q[i]) > 1e-9)) pts.push(q);
}
if (pts.length > 1 && pts[0].every((x, i) => Math.abs(x - pts[pts.length - 1][i]) <= 1e-9)) pts.pop();
if (pts.length >= 3) polys.push(pts);

In detail

Method
Outline as a ring of points, extruded by ModelCraft's push and pull; volume from ModelCraft's solidInfo times the placement's determinant.
Number types
64-bit numbers.
Tolerances
Heights, radii and base sizes must exceed 1e-6. Ring points closer than 1e-9 are merged. A unit change whose ratio is within 1e-12 of 1 does not scale the model. Faces read from a file drop a corner within 1e-9 of the one before.
Limits
A circle outlined by 48 points on it encloses about 0.29% less area than the circle. 3D faces inside blocks are placed with the reference's move, rotation and X and Y scale only; their z is copied unchanged.

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