Resources Numerical accuracy Calculation policy

3D drawing and editing tools

Turns clicks and typed values into the arcs, rectangles, offsets, moves, rotations, scales, guides and dimensions that are stored in the 3D model.

How the precision is kept

Each tool works in 64-bit world coordinates in meters and converts to the open group's own coordinates with the group's inverse matrix just before the kernel is called. Typed values replace the pointer-derived value without passing through the readout. Angles near a multiple of 15 degrees are replaced by that multiple on purpose; the snap windows are fixed angles.

Calculations under this policy

18 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/tools.js CadCraft 0.8.2 64-bit 18 calculations Reviewed

Tests bin/test-modelcraft.mjs

Two-point arc from its bulge

64-bit

The radius, center and sweep of an arc through two points that stands off the chord by a given bulge.

R = (h² + (c ÷ 2)²) ÷ (2|h|); center = midpoint + side × sign(h) × (|h| − R); sweep = 4 × atan(2|h| ÷ c)

A bulge of 1e-6 m or less gives no arc. For a shallow bulge R is much larger than the chord, the center lies about R away, and the start direction is the normalized difference of two points that far apart.

  • m3d/tools.js
  • lines 600 to 610
  • CadCraft 0.8.2
  const c = dist(this.p0, this.p1), m = mul(add(this.p0, this.p1), 0.5);
  if (Math.abs(h) <= EPS) return null;
  const u = norm(sub(this.p1, this.p0)), w = this.side;
  const R = (h * h + (c / 2) ** 2) / (2 * Math.abs(h));
  const ctr = add(m, mul(w, Math.sign(h) * (Math.abs(h) - R)));
  const sweep = 4 * Math.atan(2 * Math.abs(h) / c);
  const u0 = norm(sub(this.p0, ctr));
  const n = norm(cross(u0, norm(sub(add(m, mul(w, h)), ctr))));
  void u;
  return arcPts(ctr, len(n) ? n : this.plane.n, u0, R, sweep, this.segs);
}

Sweep of an arc drawn from its center

64-bit

The angle from the arc's start direction to the pointer, counter-clockwise about the plane normal.

a = atan2((u × w)·n, u·w), plus 2π when a ≤ 0; snapped to k × π ÷ 12 when within 0.03 rad

atan2 of a cross and a dot product keeps its digits at every angle. An angle within 0.03 rad of a multiple of 15 degrees is replaced by that multiple, so the stored sweep is then exactly k × π ÷ 12 in 64-bit.

  • m3d/tools.js
  • lines 685 to 689
  • CadCraft 0.8.2
let a = Math.atan2(dot(cross(this.u, w), this.n), dot(this.u, w));
if (a <= 0) a += 2 * Math.PI;
const step = Math.PI / 12;
if (Math.abs(a - Math.round(a / step) * step) < 0.03) a = Math.round(a / step) * step;   // 15° steps snap
this.sweep = a;

Rectangle width and height

64-bit

The two sides of a rectangle from its first corner to the pointer, along the plane's two axes.

w = (q − p₀)·u; h = (q − p₀)·v

Two dot products of the same difference vector with the plane's two axes. The values are passed on unrounded; the value box formats copies of them.

  • m3d/tools.js
  • lines 421 to 423
  • CadCraft 0.8.2
const w = dot(sub(q, this.p0), this.u), h = dot(sub(q, this.p0), this.v);
this.wh = [w, h];
const pts = rectPts(this.p0, this.u, this.v, w, h);

Distance from a point to an outline segment

64-bit

How far the pointer is from one side of the outline being offset, in the outline's plane.

t = clamp(((p − a)·(b − a)) ÷ |b − a|², 0, 1); distance = |p − a − t(b − a)|

One division, guarded for a zero-length side, then Math.hypot. The result becomes the offset distance when the pointer sets it.

  • m3d/tools.js
  • lines 905 to 906
  • CadCraft 0.8.2
/** Distance from 2D point p to segment ab. */
const segD2 = (p, a, b) => { const dx = b[0] - a[0], dy = b[1] - a[1], L = dx * dx + dy * dy; const t = L ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L)) : 0; return Math.hypot(p[0] - a[0] - dx * t, p[1] - a[1] - dy * t); };

Offset distance and side

64-bit

The signed distance an outline is offset by: the least distance to any side, positive into a face.

d = ± min over sides of distance(p, side); inside by an even-odd crossing count

The minimum is taken over all sides in order, and the sign comes from the inside test (for a face) or from the side of the nearest segment (for a chain of edges). The value is not rounded before it is used.

  • m3d/tools.js
  • lines 971 to 982
  • CadCraft 0.8.2
  let m = Infinity;
  for (const L of this.loops2) for (let i = 0; i < L.length; i++) m = Math.min(m, segD2(p, L[i], L[(i + 1) % L.length]));
  const inside = inLoop2(p, this.loops2[0]) && !this.loops2.slice(1).some((L) => inLoop2(p, L));
  d = inside ? m : -m;
} else {
  const P = this.base.pts.map((q) => to2(this.fr, q));
  let m = Infinity, side = 1;
  for (let i = 0; i + 1 < P.length + (this.base.closed ? 1 : 0); i++) {
    const a = P[i], b = P[(i + 1) % P.length], dd = segD2(p, a, b);
    if (dd < m) { m = dd; side = Math.sign((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])) || 1; }
  }
  d = side * m;

Point inside an outline

64-bit

Whether the pointer lies inside a face's outline, which decides if an offset goes inward or outward.

count the sides whose ends straddle the point's height and cross to its right; inside when the count is odd

One division per straddling side and no tolerance, so a point exactly on the outline can fall either way. The division is by the side's height difference, which the straddle test has already shown to be non-zero.

  • m3d/tools.js
  • line 907
  • CadCraft 0.8.2
const inLoop2 = (p, L) => { let c = false; for (let i = 0, j = L.length - 1; i < L.length; j = i++) { const a = L[i], b = L[j]; if ((a[1] > p[1]) !== (b[1] > p[1]) && p[0] < ((b[0] - a[0]) * (p[1] - a[1])) / (b[1] - a[1]) + a[0]) c = !c; } return c; };

Push/pull distance

64-bit

The distance a face is pushed, in the open group's own scale, and the distance reported back.

kernel distance = d × k; reported d = returned distance ÷ k; k = |normal taken into the group's axes|

The world distance is multiplied by the group's scale along the face normal and the kernel's answer is divided by the same number. With collision on, the distance is replaced by the length of the move that is allowed.

  • m3d/tools.js
  • lines 1115 to 1121
  • CadCraft 0.8.2
if (this.coll && Math.abs(d) > EPS) {
  const r = this.coll.clamp(mul(this.n, d));
  if (r.hit) { d = Math.sign(d) * len(r.delta); this.stopped = true; }
}
this.d = d;
const r = this.h.model.preview((m) => pushPull(m.context(this.h.path).def, this.fid, d * k), this.scope());
if (r && r.ok) this.d = r.distance / k;

Move and copy

64-bit

The translation applied to the selection, in the open group's own coordinates.

local delta = M⁻¹ × delta (direction only); transform = T(local delta)

The world move is taken through the group's inverse matrix as a direction and applied as one translation matrix.

  • m3d/tools.js
  • lines 1271 to 1275
  • CadCraft 0.8.2
const ld = T.localDir(delta);
this.result = this.h.model.preview((m) => {
  const def = m.context(this.h.path).def;
  const s = copy ? copySel(def, sel) : sel;
  transformSel(def, s, translation(ld));

Rows and rings of copies

64-bit

The transform of each copy when a copy is repeated (3x) or divided (/3).

copy k: T(k × delta) or R(k × angle); divided: k ÷ n of the delta or angle

Every copy is made from the original with its own multiple of the move or angle. Nothing is accumulated from one copy to the next, so the last copy carries one rounding, not n.

  • m3d/tools.js
  • lines 1341 to 1349
  • CadCraft 0.8.2
const step = (f) => (rot ? rotation(T.localDir(rot.n), rot.a * f, T.local(rot.c)) : translation(T.localDir(mul(delta, f))));
if (n < 1 || n > 500) return false;
this.h.model.undo();
const made = this.h.model.change(v.kind === 'array' ? 'Copy ×' + n : 'Copy /' + n, (m) => {
  const def = m.context(this.h.path).def;
  const out = [];
  for (let k = 1; k <= n; k++) {
    const s = copySel(def, src);
    transformSel(def, s, step(v.kind === 'array' ? k : k / n));

Rotation angle

64-bit

The angle the selection is turned by, from the reference direction to the pointer.

a = atan2((ref × v)·n, ref·v); snapped to k × π ÷ 12 when within 2.5°

The pointer angle comes from atan2 of a cross and a dot product, which keeps its digits at every angle. An angle within 2.5 degrees of a multiple of 15 degrees is replaced by that multiple, and the unrounded angle is what is applied.

  • m3d/tools.js
  • lines 1401 to 1404
  • CadCraft 0.8.2
let a = Math.atan2(dot(cross(this.ref, v), this.n), dot(this.ref, v));
const step = Math.PI / 12, snapped = Math.round(a / step) * step;
if (Math.abs(a - snapped) < 2.5 * Math.PI / 180) a = snapped;
this.apply(a);

Uniform scale factor

64-bit

The factor the selection is scaled by about a fixed point, from a reference point to the pointer.

f = max(0.001, ((p − base)·u) ÷ (u·u)), u = ref − base

One projection and one division by the squared length of the reference vector. The factor is clamped at 0.001 from below and applied unrounded.

  • m3d/tools.js
  • lines 1598 to 1600
  • CadCraft 0.8.2
const u = sub(this.ref, this.base);
const f = Math.max(0.001, dot(sub(r.p, this.base), u) / dot(u, u));
this.apply(f);

Stretch by a grip

64-bit

The matrix that scales the selection by separate factors along the group's three axes about an anchor.

M = T(a) × diag(fx, fy, fz) × T(−a)

Two 4×4 products. The translation column comes out as a − f × a, which loses digits when the anchor is far from the group's origin and the factor is close to 1. The value box rounds to 4 decimals; the matrix does not.

  • m3d/tools.js
  • lines 1559 to 1569
  • CadCraft 0.8.2
/** Factors per axis (the open group's red, green, blue) about the anchor, previewed. */
applyAxes(fs) {
  const sel = this.h.sel.filter((s) => s.t !== 'g' && s.t !== 'd' && s.t !== 's');
  const a = this.gripAnchor;
  const S = [fs[0], 0, 0, 0, 0, fs[1], 0, 0, 0, 0, fs[2], 0, 0, 0, 0, 1];
  const m4 = mmul(translation(a), mmul(S, translation(mul(a, -1))));
  this.fs = fs;
  this.h.model.preview((m) => transformSel(m.context(this.h.path).def, sel, m4), this.scope());
  this.h.changed();
  this.h.vcb(t('m3d.vcb.scale'), fs.map((x) => String(+x.toFixed(4))).join(', '));
}

Guide line offset from an edge

64-bit

The perpendicular offset of a new guide line from the edge or guide it was measured from.

w = w₀ − dir × (w₀·dir), w₀ = pointer − start

The part of the pointer vector along the direction is subtracted. The formula assumes the direction has length 1.

  • m3d/tools.js
  • lines 1842 to 1843
  • CadCraft 0.8.2
const w0 = sub(r.p, this.p0);
this.w = sub(w0, mul(this.dir, dot(w0, this.dir)));

Guide direction at an angle

64-bit

The direction of a guide line at a given angle from the protractor's zero direction.

d = normalize(u × cos a + (n × u) × sin a)

One cosine and one sine of the angle, combined with the zero direction and its perpendicular in the plane, then a normalization.

  • m3d/tools.js
  • line 1949
  • CadCraft 0.8.2
dirAt(a) { const v = cross(this.n, this.u); return norm(add(mul(this.u, Math.cos(a)), mul(v, Math.sin(a)))); }

Dimension line offset

64-bit

Where the dimension line sits: the pointer's offset square to the measured line.

off = q − ab × (q·ab), q = pointer on the plane − first point, ab = unit direction of the measured line

The pointer is put on a plane through the first point, and the component along the measured line is subtracted from its vector. The offset is stored unrounded.

  • m3d/tools.js
  • lines 2079 to 2080
  • CadCraft 0.8.2
const ray = this.h.cam.ray(ev.x, ev.y), g = rayPlane(ray.o, ray.d, planeFrom(n, this.pa));
if (g) { const q = sub(g.p, this.pa); this.off = sub(q, mul(ab, dot(q, ab))); }

Terrain brush weights

64-bit

How much each terrain point under the round brush is raised or lowered.

w = 0.5 × (1 + cos(π × d ÷ R)) for d < R; new z = z + w × Δz

The distance is horizontal (Math.hypot of x and y) in the group's own units; the radius is scaled by the group's scale along its x axis. Only points that belong to triangles alone are moved.

  • m3d/tools.js
  • lines 2190 to 2204
  • CadCraft 0.8.2
/** The vertices the brush moves around local point p, with their weights and heights now. */
brush(p) {
  const def = this.T.ctx().def, R = this.radius * (len(this.T.localDir([1, 0, 0])) || 1);   // the brush in the group's own units
  const tri = new Set();
  for (const f of def.faces.values()) if (f.loops.length === 1 && f.loops[0].length === 3) for (const v of f.loops[0]) tri.add(v);
  const notTri = new Set();
  for (const f of def.faces.values()) if (!(f.loops.length === 1 && f.loops[0].length === 3)) for (const l of f.loops) for (const v of l) notTri.add(v);
  const out = [];
  for (const [v, q] of def.verts) {
    const d = Math.hypot(q[0] - p[0], q[1] - p[1]);
    if (d >= R || notTri.has(v) || !tri.has(v)) continue;
    out.push({ v, w: 0.5 * (1 + Math.cos((Math.PI * d) / R)), z: q[2] });
  }
  return out;
}

Selection bounding box

64-bit

The box around the selection in world coordinates; its middle is the center for turning and scaling from the keyboard.

min and max per axis over the transformed points; center = (min + max) × 0.5

Each point is transformed by the group's matrix and compared; nothing is accumulated. A placed group contributes only the two corners of its own transformed box.

  • m3d/tools.js
  • lines 172 to 191
  • CadCraft 0.8.2
/** The selection's bounding box {min, max} in world coordinates, or null. */
selBox() {
  const m = this.host.model, c = this.ctx(), def = c.def;
  let bb = null;
  const put = (p) => {
    if (!bb) bb = { min: p.slice(), max: p.slice() };
    for (let i = 0; i < 3; i++) { bb.min[i] = Math.min(bb.min[i], p[i]); bb.max[i] = Math.max(bb.max[i], p[i]); }
  };
  for (const s of this.host.sel) {
    if (s.t === 'e') { const e = def.edges.get(s.id); if (e) { put(apply(c.m, def.verts.get(e.a))); put(apply(c.m, def.verts.get(e.b))); } }
    else if (s.t === 'f') { const f = def.faces.get(s.id); if (f) for (const l of f.loops) for (const v of l) put(apply(c.m, def.verts.get(v))); }
    else if (s.t === 'i') {
      const inst = def.insts.get(s.id), d = inst && m.def(inst.def);
      const ib = d && m.bbox(d, mmul(c.m, inst.m));
      if (ib) { put(ib.min); put(ib.max); }
    }
  }
  for (const s of this.host.sel) if (s.t === 'g') { const g = m.guides.get(s.id); if (g) put(g.p); }
  return bb;
}

Frame of a placed component

64-bit

The three axis directions a component gets when it is laid on a face.

z = face normal toward the eye; x = red axis less its part along z (or blue × z); y = z × x

x is normalized and y is a cross product of two unit vectors. The fallback to blue × z is taken when the projected red axis is shorter than 1e-6 or the face is more than about 26 degrees from level (|z·blue| < 0.9).

  • m3d/tools.js
  • lines 2379 to 2389
  • CadCraft 0.8.2
/** The world frame the copy gets at snap r: [x, y, z] directions. */
frameAt(r) {
  const A = this.T.axes();
  if (!this.align || !r.face?.n) return [A.x, A.y, A.z];
  let z = norm(r.face.n);
  if (dot(z, sub(this.h.cam.eye, r.p)) < 0) z = mul(z, -1);
  let x = sub(A.x, mul(z, dot(A.x, z)));
  if (len(x) < 1e-6 || Math.abs(dot(z, A.z)) < 0.9) { x = cross(A.z, z); if (len(x) < 1e-6) x = A.x; }
  x = norm(x);
  return [x, cross(z, x), z];
}

In detail

Method
Closed forms: arc radius and center from chord and bulge, angles from atan2 of a cross and a dot product, scale factors from a projection, offsets from the pointer's distance to the outline.
Number types
64-bit numbers. The pointer position is in CSS pixels and enters only through the camera ray.
Tolerances
EPS = 1e-6 m is the smallest length, bulge, offset or move a tool accepts. Angle snaps: 0.03 rad for an arc from its center, 2.5 degrees for rotate, 0.035 rad for the protractor. A rotation below 1e-9 rad and a scale within 1e-9 of 1 are dropped. A scale factor is not allowed below 0.001.
Limits
A shallow two-point arc is built from a distant center, so its points carry the cancellation of center plus radius. Values shown in the value box are rounded; the stored values are not.

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