Resources Numerical accuracy Calculation policy

Groups, placements and the model file

How coordinates are carried when geometry is grouped, placed, exploded or turned into a mesh, and which numbers the model file holds.

How the precision is kept

A group keeps its vertices in its own coordinates and is placed by a 4×4 matrix of 64-bit numbers; a nested placement's world matrix is the product of the matrices above it. The file holds vertices and matrices as plain JSON numbers, which JavaScript writes with enough digits to read back the same 64-bit value. Numbers read from a file must be finite and below 1e7 in size.

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.

m3d/model.js CadCraft 0.8.2 64-bit 10 calculations Reviewed

Tests bin/test-cadcraft-3d.mjs

World matrix of a nested placement

64-bit

The matrix that takes a group's own coordinates to the model's, through every group it sits in.

M_world = M₁ · M₂ · … · Mₖ

One 4×4 product per level, at most 32 levels deep. Each product rounds, so a deeply nested placement carries the rounding of every level above it.

  • m3d/model.js
  • lines 2588 to 2595
  • CadCraft 0.8.2
walk(cb, def = this.root, m = ident(), path = [], depth = 0, color = null) {
  if (depth > 32) return;
  if (cb(def, m, path, color) === false) return;
  for (const [iid, inst] of def.insts) {
    const d = this.defs.get(inst.def);
    if (d) this.walk(cb, d, mmul(m, inst.m), [...path, iid], depth + 1, inst.color || color);
  }
}

Editing context of an open group

64-bit

The world matrix of the group being edited and its inverse, used to take picked points into the group.

M = M₁ · … · Mₖ; M⁻¹ by cofactors

The inverse is computed once from the product (invert in vec.js, which returns the identity when the determinant is exactly zero and has no other conditioning check). Points drawn inside the group are world points times this inverse.

  • m3d/model.js
  • lines 2672 to 2683
  • CadCraft 0.8.2
/** The definition and world matrix at the end of a path of instance ids (the editing context). */
context(path = []) {
  let def = this.root, m = ident();
  for (const iid of path) {
    const inst = def.insts.get(iid);
    if (!inst) return null;
    m = mmul(m, inst.m);
    def = this.defs.get(inst.def);
    if (!def) return null;
  }
  return { def, m, inv: invert(m) };
}

Making a group

64-bit

Moves the selected geometry into a new group whose origin is the lowest corner of its box.

p_group = p − origin; placement = translation(origin)

Each coordinate is re-based by one subtraction and the group is placed by a pure translation. p − origin + origin does not always return p to the last bit.

  • m3d/model.js
  • lines 2707 to 2710
  • CadCraft 0.8.2
const origin = bb ? bb.min.slice() : [0, 0, 0];
const nd = this.newDef(kind, name);
const vmap = new Map();
const cv = (v) => { if (!vmap.has(v)) vmap.set(v, vAdd(nd, sub(def.verts.get(v), origin))); return vmap.get(v); };

Exploding a group

64-bit

Puts a group's geometry back into its parent at the group's place.

p_parent = M · p

Each vertex is multiplied by the placement matrix; a result within EPS (1e-6 m, absolute) of a vertex already in the parent becomes that vertex and takes its coordinates. Placements inside the group get the product of the two matrices.

  • m3d/model.js
  • lines 2738 to 2751
  • CadCraft 0.8.2
const m = inst.m, mirror = det3(m) < 0;
const vmap = new Map(), ix = vertexIndex(def);
const cv = (v) => { if (!vmap.has(v)) vmap.set(v, ix.at(apply(m, src.verts.get(v)))); return vmap.get(v); };
const rc = curveRemapper(def);
for (const e of src.edges.values()) { const id = eAdd(def, cv(e.a), cv(e.b), e.soft, rc(e.curve), e.hidden); ix.edgeAdded(id); out.push({ t: 'e', id }); }
ix.stop();   // faces may add edges of their own: a corner only a face uses is found the plain way
for (const [cid, sp] of src.splines) def.splines.set(rc(cid), { ...sp, pts: sp.pts.map((p) => apply(m, p)) });
for (const f of src.faces.values()) {
  let loops = f.loops.map((l) => l.map(cv));
  if (mirror) loops = loops.map((l) => l.reverse());
  out.push({ t: 'f', id: fAdd(def, loops, f.color, undefined, f.hidden) });
}
for (const child of src.insts.values()) { const id = nid(def); def.insts.set(id, { ...child, m: mmul(m, child.m) }); out.push({ t: 'i', id }); }
cleanup(def);

Flattening a group into a mesh

64-bit

Gathers a group and everything placed in it into one set of points before it becomes a mesh.

key = round(x × 1e8), round(y × 1e8), round(z × 1e8)

Points are moved by their placement matrix and welded on a grid of 1e-8 m: points whose rounded keys are equal become one, at the first one's coordinates. Two points closer than that but on either side of a grid line stay separate.

  • m3d/model.js
  • lines 172 to 192
  • CadCraft 0.8.2
/** Add src's own faces and edges, moved by m, to dst (points welded at 0.01 µm; faces without paint take `paint`). */
function copyInto(dst, src, m, paint = null) {
  const grid = dst._grid || (dst._grid = new Map());
  const vmap = new Map(), mirror = det3(m) < 0;
  const cv = (v) => {
    let id = vmap.get(v);
    if (id !== undefined) return id;
    const p = apply(m, src.verts.get(v)), k = p.map((x) => Math.round(x * 1e8)).join(',');
    id = grid.get(k);
    if (id === undefined) { id = vAdd(dst, p); grid.set(k, id); }
    vmap.set(v, id);
    return id;
  };
  for (const e of src.edges.values()) eAdd(dst, cv(e.a), cv(e.b), e.soft, undefined, e.hidden);
  for (const f of src.faces.values()) {
    let loops = f.loops.map((l) => l.map(cv));
    if (mirror) loops = loops.map((l) => l.slice().reverse());
    if (loops.some((l) => new Set(l).size !== l.length)) continue;
    fAdd(dst, loops, f.color || paint || undefined, undefined, f.hidden);
  }
}

Numbers written to the model file

64-bit

The vertices, placement matrices and curve points as they go into the file.

Coordinates and matrices are put into the file object as the 64-bit numbers they are; nothing is rounded or formatted here.

  • m3d/model.js
  • lines 3325 to 3336
  • CadCraft 0.8.2
function defToJSON(d) {
  if (d.mesh) return { ...meshHead(d), mesh: d.mesh.toJSON() };
  return {
    id: d.id, kind: d.kind, name: d.name, next: d.next, ...(d.starter ? { starter: d.starter } : {}), ...(d.dyn ? { dyn: { src: d.dyn.src, size: d.dyn.size.slice() } } : {}),
    ...(isStacked(d) ? { mods: { src: d.mods.src, ops: structuredClone(d.mods.ops) } } : {}),
    verts: [...d.verts].map(([id, p]) => [id, p[0], p[1], p[2]]),
    edges: [...d.edges].map(([id, e]) => (e.hidden ? [id, e.a, e.b, e.soft ? 1 : 0, e.curve || 0, 1] : e.curve ? [id, e.a, e.b, e.soft ? 1 : 0, e.curve] : e.soft ? [id, e.a, e.b, 1] : [id, e.a, e.b])),
    faces: [...d.faces].map(([id, f]) => { const o = { id, loops: f.loops }; if (f.color) o.color = f.color; if (f.hidden) o.hidden = true; return o; }),
    insts: [...d.insts].map(([id, i]) => { const o = { id, def: i.def, m: i.m }; if (i.name) o.name = i.name; if (i.color) o.color = i.color; if (i.hidden) o.hidden = true; if (i.tag) o.tag = i.tag; if (i.rule) o.rule = { ...i.rule }; return o; }),
    ...(d.splines.size ? { splines: [...d.splines].map(([id, sp]) => ({ id, pts: sp.pts, closed: !!sp.closed, segs: sp.segs })) } : {}),
  };
}

Vertices read from a file

64-bit

The check each vertex passes when a file is opened.

finite and |x| < 1e7

A vertex must be an id and three finite numbers below 1e7 in size (isNum); it is stored as read. A file with a coordinate of 10 000 km or more is refused.

  • m3d/model.js
  • lines 3389 to 3393
  • CadCraft 0.8.2
for (const v of Array.isArray(j.verts) ? j.verts : []) {
  if (!Array.isArray(v) || v.length !== 4 || !intId(v[0]) || !isPt(v.slice(1))) fail(t('m3d.model.a_vertex_is_wrong'));
  d.verts.set(v[0], [v[1], v[2], v[3]]);
  max = Math.max(max, v[0]);
}

Level elevations

64-bit

The list of named levels and their heights.

Elevations are kept as given. One within 1e-6 m of an elevation already in the list is dropped, and sizes above 1e6 m are refused.

  • m3d/model.js
  • lines 3279 to 3288
  • CadCraft 0.8.2
/** Storeys from a file or a dialog (0.4 M12): named, finite elevations, sorted, no two at one elevation. */
export function cleanStoreys(list) {
  const out = [];
  for (const s of Array.isArray(list) ? list.slice(0, 200) : []) {
    const name = cleanText(s?.name, 80), e = Number(s?.elevation);
    if (!name || !Number.isFinite(e) || Math.abs(e) > 1e6 || out.some((o) => Math.abs(o.elevation - e) < 1e-6)) continue;
    out.push({ name, elevation: e });
  }
  return out.sort((a, b) => a.elevation - b.elevation);
}

Light values as stored

64-bit

The brightness, cone angle, size and position kept for a lamp used in rendered pictures.

lumens = round(clamp(x, 1, 200000)); angle = round(clamp(x, 5, 170)); size clamped to 0.02…20 m

Lumens and the cone angle are rounded to whole numbers with Math.round when a light is stored, so a typed 62.5° is kept as 63°. Position and size stay 64-bit; the direction is normalized.

  • m3d/model.js
  • lines 3260 to 3270
  • CadCraft 0.8.2
export function cleanLight(o) {
  if (!o || typeof o !== 'object' || !LIGHT_KINDS.includes(o.kind) || !isPt(o.p)) return null;
  const clamp = (x, lo, hi, d) => (isNum(x) ? Math.min(hi, Math.max(lo, x)) : d);
  let dir = isPt(o.dir) && len(o.dir) > 1e-9 ? norm(o.dir) : [0, 0, -1];
  const size = Array.isArray(o.size) && o.size.length === 2 ? [clamp(o.size[0], 0.02, 20, 0.6), clamp(o.size[1], 0.02, 20, 0.6)] : (o.kind === 'area' ? [0.6, 0.6] : [0.06, 0.06]);
  return {
    kind: o.kind, p: o.p.slice(), dir, color: isColor(o.color) ? o.color.toLowerCase() : '#ffe7c4',
    lumens: Math.round(clamp(o.lumens, 1, 200000, 800)), angle: Math.round(clamp(o.angle, 5, 170, 60)), size,
    on: o.on !== false, name: cleanText(o.name, 80),
  };
}

Drawing axes

64-bit

The frame set when the drawing axes are placed by three picks.

X = x ÷ |x|; Y = (y − X (y · X)) normalized; Z = X × Y

One Gram–Schmidt step makes green square to red; the pick is refused when what is left of y is shorter than 1e-9. Z is the cross product of two unit vectors and is not normalized again.

  • m3d/model.js
  • lines 2186 to 2194
  • CadCraft 0.8.2
/** Put the drawing axes at origin o, red along x, green toward y (made square to red); blue = red × green. */
setAxes(o, x, y) {
  const X = norm(x), Y0 = sub(y, mul(X, dot(y, X)));
  if (!len(X) || len(Y0) < 1e-9) throw new Error(t('m3d.axes.bad'));
  const Y = norm(Y0), Z = cross(X, Y);
  const m = [X[0], X[1], X[2], 0, Y[0], Y[1], Y[2], 0, Z[0], Z[1], Z[2], 0, o[0], o[1], o[2], 1];
  const same = m.every((v, i) => Math.abs(v - ident()[i]) < 1e-12);
  this.axesM = same ? null : m;
}

In detail

Method
Matrix products for nested placements; cofactor inverse (invert in vec.js); translation to the box's lowest corner when a group is made.
Number types
64-bit numbers; matrices as 16 numbers in column-major order.
Tolerances
Exploding welds at EPS = 1e-6 m. Flattening into a mesh welds on a 1e-8 m grid. Level elevations closer than 1e-6 m count as one. Drawing axes from a file must be unit and square to within 1e-6.
Limits
Making a group subtracts its origin from every vertex and exploding adds it back, so the round trip can differ from the original in the last bit. Coordinates of 1e7 m or more are refused when a file is read.

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