Resources Numerical accuracy Calculation policy

Measuring and mass properties in 3D

Reports lengths, radii, areas, distances and angles of picked points, edges, faces and bodies, and the mass, center of mass and inertia of a part or assembly.

How the precision is kept

Point-to-point distances, angles, circle fits and the combination of mass properties are closed-form 64-bit arithmetic. The fallback paths that work on the tessellation (facet area, minimum distance over facets, mass properties by the divergence theorem) read build.mesh.positions, which both kernels fill as a Float32Array, so those results start from 32-bit coordinates and are for the facets, not the true surfaces. Exact values come from the OpenCascade worker when it is available (measure-brep.js).

Open item. Give the tessellation fallbacks 64-bit vertex positions, so facet areas, facet distances and mesh mass properties are not computed from 32-bit coordinates.

Calculations under this policy

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

measure.js PartCraft 0.8.1 32-bit in part 10 calculations Reviewed

Tests tools/partcraft-test/test-assembly-tools.mjs

Circle of an edge

64-bit

Center, radius and axis of a circular edge from its two ends and its middle point.

c = A + ((n × ab) |ac|² + (ac × n) |ab|²) ÷ (2 |n|²), n = ab × ac; full circle: c = (a + middle) ÷ 2, r = |a − middle| ÷ 2

The circumcenter formula divides by 2|n|², which is small for a short arc whose three points are nearly in line; points with |n|² under 1e-24 are refused, an absolute limit that does not scale with the edge. A full circle's normal is taken from the edge's drawn segments, which are 32-bit values.

  • measure.js
  • lines 34 to 63
  • PartCraft 0.8.1
/** Centre, radius and plane normal of a circle edge of an exact regen (edges[i]: a, b, at; mesh segs give the normal). */
export function circleOf(e, build = null, index = -1) {
  const full = V.len(V.sub(e.a, e.b)) < 1e-9 * Math.max(1, V.len(e.a));
  let c;
  if (full) c = V.mul(V.add(e.a, e.at), 0.5);
  else {
    // circumcentre of a, at, b
    const A = e.a, B = e.at, C = e.b;
    const ab = V.sub(B, A), ac = V.sub(C, A), n = V.cross(ab, ac), nn = V.dot(n, n);
    if (nn < 1e-24) return null;
    c = V.add(A, V.mul(V.add(V.mul(V.cross(n, ab), V.dot(ac, ac)), V.mul(V.cross(ac, n), V.dot(ab, ab))), 1 / (2 * nn)));
  }
  const r = full ? V.len(V.sub(e.a, e.at)) / 2 : V.len(V.sub(e.a, c));
  let n = full ? null : V.norm(V.cross(V.sub(e.a, c), V.sub(e.b, c)));
  if (full || V.len(n) < 0.5) {
    // a third point off the a–at diameter from OCCT's polyline of this edge
    n = null;
    const segs = build?.mesh?.edgeSegs, se = build?.mesh?.segEdge;
    if (segs && se) {
      let best = 0;
      for (let k = 0; k < se.length; k++) {
        if (se[k] !== index) continue;
        const q = [segs[k * 6], segs[k * 6 + 1], segs[k * 6 + 2]];
        const x = V.cross(V.sub(e.a, c), V.sub(q, c));
        if (V.len(x) > best) { best = V.len(x); n = V.norm(x); }
      }
    }
  }
  return { c, r, n };
}

Area of a picked group of facets

32-bit in part

The area and normal of a picked face when no face table is available.

area = Σ |(b − a) × (c − a)| ÷ 2; plane if |Σ cross| > 0.999 × 2 × area

The triangle corners are read from the 32-bit mesh positions and the areas added in 64-bit numbers. The result is marked as not exact and the panel words it as an area of facets.

  • measure.js
  • lines 176 to 180
  • PartCraft 0.8.1
const tris = faceTris(build, pick);
let area = 0, nsum = [0, 0, 0];
for (const t of tris) { const [a, b, c] = triPts(build.mesh, t); const x = V.cross(V.sub(b, a), V.sub(c, a)); area += V.len(x) / 2; nsum = V.add(nsum, x); }
Object.assign(out, { type: 'facets', area, exact: false, p: W(M, pick.local) });
if (V.len(nsum) > 0.999 * 2 * area) { out.type = 'plane'; out.n = Wd(M, nsum); out.o = W(M, pick.local); }

Closest point on a triangle

64-bit

The point of a facet nearest to a given point, for the fallback distance.

Region tests on products of dot products in 64-bit numbers with no tolerance; the interior case divides by va + vb + vc, which is zero only for a triangle with no area and is not guarded.

  • measure.js
  • lines 210 to 227
  • PartCraft 0.8.1
export function pointTri(p, [a, b, c]) {
  // closest point on a triangle (Ericson, Real-Time Collision Detection 5.1.5)
  const ab = V.sub(b, a), ac = V.sub(c, a), ap = V.sub(p, a);
  const d1 = V.dot(ab, ap), d2 = V.dot(ac, ap);
  if (d1 <= 0 && d2 <= 0) return a;
  const bp = V.sub(p, b), d3 = V.dot(ab, bp), d4 = V.dot(ac, bp);
  if (d3 >= 0 && d4 <= d3) return b;
  const vc = d1 * d4 - d3 * d2;
  if (vc <= 0 && d1 >= 0 && d3 <= 0) return V.add(a, V.mul(ab, d1 / (d1 - d3)));
  const cp = V.sub(p, c), d5 = V.dot(ab, cp), d6 = V.dot(ac, cp);
  if (d6 >= 0 && d5 <= d6) return c;
  const vb = d5 * d2 - d1 * d6;
  if (vb <= 0 && d2 >= 0 && d6 <= 0) return V.add(a, V.mul(ac, d2 / (d2 - d6)));
  const va = d3 * d6 - d5 * d4;
  if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) return V.add(b, V.mul(V.sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))));
  const den = 1 / (va + vb + vc);
  return V.add(a, V.add(V.mul(ab, vb * den), V.mul(ac, vc * den)));
}

Closest points of two segments

64-bit

The nearest pair of points on two mesh edges, for the fallback distance.

s = (b·f − c·e) ÷ (a·e − b²), t = (b·s + f) ÷ e, clamped to 0…1

A segment counts as a point when its squared length is at most 1e-18, and two segments as parallel when a·e − b² is at most 1e-18; both limits are absolute. For nearly parallel segments the denominator loses digits by cancellation.

  • measure.js
  • lines 229 to 248
  • PartCraft 0.8.1
export function segSeg([p1, q1], [p2, q2]) {
  // closest points of two segments (Ericson 5.1.9)
  const d1 = V.sub(q1, p1), d2 = V.sub(q2, p2), r = V.sub(p1, p2);
  const a = V.dot(d1, d1), e = V.dot(d2, d2), f = V.dot(d2, r);
  let s, t;
  const clamp = (x) => Math.max(0, Math.min(1, x));
  if (a <= 1e-18 && e <= 1e-18) return [p1, p2];
  if (a <= 1e-18) { s = 0; t = clamp(f / e); }
  else {
    const c = V.dot(d1, r);
    if (e <= 1e-18) { t = 0; s = clamp(-c / a); }
    else {
      const b = V.dot(d1, d2), den = a * e - b * b;
      s = den > 1e-18 ? clamp((b * f - c * e) / den) : 0;
      t = (b * s + f) / e;
      if (t < 0) { t = 0; s = clamp(-c / a); } else if (t > 1) { t = 1; s = clamp((b - c) / a); }
    }
  }
  return [V.add(p1, V.mul(d1, s)), V.add(p2, V.mul(d2, t))];
}

Minimum distance over the facets

32-bit in part

The smallest distance between two picked entities when the exact kernel's distance is not available.

min over point–point, point–triangle, point–segment and segment–segment pairs

The points, segments and triangles are built from the 32-bit mesh positions and edge segments (geometryOf). The segment-to-segment pass is skipped when there are more than 4,000,000 pairs, so a closest approach between two edges can then be missed. The result is labeled as a distance between facets.

  • measure.js
  • lines 250 to 261
  • PartCraft 0.8.1
/** Minimum distance between two tessellated pick geometries (exact for the facets, when they do not cross). */
export function meshDistance(A, B) {
  let best = { distance: Infinity, p1: null, p2: null };
  const take = (p, q) => { const d = V.len(V.sub(p, q)); if (d < best.distance) best = { distance: d, p1: p, p2: q }; };
  for (const p of A.pts) for (const q of B.pts) take(p, q);
  for (const p of A.pts) for (const t of B.tris) take(p, pointTri(p, t));
  for (const q of B.pts) for (const t of A.tris) take(pointTri(q, t), q);
  for (const p of A.pts) for (const s of B.segs) take(p, segSeg([p, p], s)[1]);
  for (const q of B.pts) for (const s of A.segs) take(segSeg(s, [q, q])[0], q);
  if (A.segs.length * B.segs.length <= 4e6) for (const s of A.segs) for (const u of B.segs) { const [p, q] = segSeg(s, u); take(p, q); }
  return best;
}

Distance, angle and gaps between two picks

64-bit

Point-to-point distance, the angle between planes, lines or axes, the gap between parallel planes and the distance between centers or parallel axes.

angle = acos(clamp(d₁·d₂)) in degrees; plane gap = |(o₂ − o₁)·n|; axis gap = |w − d (w·d)|

The acos argument is clamped to −1…1. Near 0° and 180° acos of a dot product resolves angles only to about the square root of the rounding in the dot product. Planes and axes count as parallel within 1e-9 of |cos| = 1.

  • measure.js
  • lines 281 to 306
  • PartCraft 0.8.1
export function pair(A, B, ia, ib, exactDistance = null) {
  const out = {};
  if (isPointPick(A) && isPointPick(B)) Object.assign(out, { distance: V.len(V.sub(ib.p, ia.p)), p1: ia.p, p2: ib.p, method: 'exact' });
  else if (exactDistance) Object.assign(out, { distance: exactDistance.distance, p1: exactDistance.p1, p2: exactDistance.p2, method: 'exact', inside: exactDistance.inside });
  else if (A.geometry && B.geometry) {
    const d = meshDistance(A.geometry, B.geometry);
    Object.assign(out, d, { method: 'facets' });
  }
  if (out.p1) out.delta = V.sub(out.p2, out.p1);
  const da = dirOf(ia), db = dirOf(ib);
  if (da && db) {
    const c = V.dot(da.d, db.d);
    if (da.kind === 'plane' && db.kind === 'plane') out.angle = deg(acosC(c));
    else if (da.kind === 'line' && db.kind === 'line') out.angle = deg(acosC(Math.abs(c)));
    else out.angle = 90 - deg(acosC(Math.abs(c)));
  }
  // parallel planes: their gap; circles/holes: centre to centre (and axis to axis when parallel)
  if (da?.kind === 'plane' && db?.kind === 'plane' && Math.abs(Math.abs(V.dot(da.d, db.d)) - 1) < 1e-9) out.planeGap = Math.abs(V.dot(V.sub(ib.o, ia.o), da.d));
  if (ia.centre && ib.centre) out.centre = V.len(V.sub(ib.centre, ia.centre));
  const la = centreLine(ia), lb = centreLine(ib);
  if (la && lb && Math.abs(Math.abs(V.dot(la.d, lb.d)) - 1) < 1e-9) {
    const w = V.sub(lb.o, la.o);
    out.axisGap = V.len(V.sub(w, V.mul(la.d, V.dot(w, la.d))));
  }
  return out;
}

Volume, center of mass and inertia from the mesh

32-bit in part

Mass properties of a closed triangle mesh at density 1.

V = Σ a·(b × c) ÷ 6; ∫xxᵀ dV = Σ V_t ÷ 20 × (Σ pᵢpᵢᵀ + (Σpᵢ)(Σpᵢ)ᵀ); about the center: C − V g gᵀ

The vertices are read from the 32-bit mesh positions. Tetrahedra are measured from the model origin and the second moments are moved to the center by subtracting V × g × g, so a part far from the origin loses digits in both steps. The division by the volume is not guarded for an empty or open mesh.

  • measure.js
  • lines 309 to 331
  • PartCraft 0.8.1
/** Divergence theorem over a closed triangle mesh (part coordinates). */
export function meshMass(mesh) {
  const P = mesh.positions, I = mesh.indices;
  let vol = 0, area = 0;
  const s1 = [0, 0, 0];
  const C = [0, 0, 0, 0, 0, 0];   // ∫ xx yy zz xy yz xz
  for (let t = 0; t < I.length; t += 3) {
    const a = [P[I[t] * 3], P[I[t] * 3 + 1], P[I[t] * 3 + 2]], b = [P[I[t + 1] * 3], P[I[t + 1] * 3 + 1], P[I[t + 1] * 3 + 2]], c = [P[I[t + 2] * 3], P[I[t + 2] * 3 + 1], P[I[t + 2] * 3 + 2]];
    const v = V.dot(a, V.cross(b, c)) / 6;
    area += V.len(V.cross(V.sub(b, a), V.sub(c, a))) / 2;
    vol += v;
    const s = V.add(V.add(a, b), c);
    for (let k = 0; k < 3; k++) s1[k] += v * s[k] / 4;
    const q = (i, j) => v / 20 * (a[i] * a[j] + b[i] * b[j] + c[i] * c[j] + s[i] * s[j]);
    C[0] += q(0, 0); C[1] += q(1, 1); C[2] += q(2, 2); C[3] += q(0, 1); C[4] += q(1, 2); C[5] += q(0, 2);
  }
  const g = V.mul(s1, 1 / vol);
  // second moments about the centre, then the inertia tensor (density 1)
  const cxx = C[0] - vol * g[0] * g[0], cyy = C[1] - vol * g[1] * g[1], czz = C[2] - vol * g[2] * g[2];
  const cxy = C[3] - vol * g[0] * g[1], cyz = C[4] - vol * g[1] * g[2], cxz = C[5] - vol * g[0] * g[2];
  const tensor = [[cyy + czz, -cxy, -cxz], [-cxy, cxx + czz, -cyz], [-cxz, -cyz, cxx + cyy]];
  return { volume: vol, area, centre: g, tensor, exact: false };
}

Mass properties of a placed part

64-bit

Mass in grams and the inertia tensor in world axes for one placed part.

I_world = (density ÷ 1000) × R I Rᵀ; center_world = M × center

The rotation is the upper 3 × 3 of the placement matrix and is applied as two 3 × 3 products in 64-bit numbers. The mass itself comes from massOf in bom.js, which this review did not cover.

  • measure.js
  • lines 333 to 342
  • PartCraft 0.8.1
/** Mass-weighted properties in world axes: { mass (g), volume, area, centre, tensor (g·mm²) }. */
export function placeMass(props, matrix, density) {
  const M = matrix || IDENT;
  const R = [[M[0], M[4], M[8]], [M[1], M[5], M[9]], [M[2], M[6], M[10]]];
  const k = density / 1000;
  const T = props.tensor;
  const RT = [0, 1, 2].map((i) => [0, 1, 2].map((j) => R[i][0] * T[0][j] + R[i][1] * T[1][j] + R[i][2] * T[2][j]));
  const tensor = [0, 1, 2].map((i) => [0, 1, 2].map((j) => k * (RT[i][0] * R[j][0] + RT[i][1] * R[j][1] + RT[i][2] * R[j][2])));
  return { mass: massOf(props.volume, density), volume: props.volume, area: props.area, centre: V.applyMat(M, props.centre), tensor, exact: !!props.exact };
}

Mass properties of several parts together

64-bit

Total mass, combined center of mass and inertia tensor of an assembly.

center = Σ mᵢ cᵢ ÷ Σ mᵢ; I = Σ (Iᵢ + mᵢ (|dᵢ|² E − dᵢ dᵢᵀ)), dᵢ = cᵢ − center

Sums in list order in 64-bit numbers. A total mass of zero is replaced by 1 in the division for the center.

  • measure.js
  • lines 344 to 353
  • PartCraft 0.8.1
export function combineMass(list) {
  const mass = list.reduce((s, x) => s + x.mass, 0);
  const centre = [0, 1, 2].map((k) => list.reduce((s, x) => s + x.mass * x.centre[k], 0) / (mass || 1));
  const tensor = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
  for (const x of list) {
    const d = V.sub(x.centre, centre), dd = V.dot(d, d);
    for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) tensor[i][j] += x.tensor[i][j] + x.mass * ((i === j ? dd : 0) - d[i] * d[j]);
  }
  return { mass, volume: list.reduce((s, x) => s + x.volume, 0), area: list.reduce((s, x) => s + x.area, 0), centre, tensor, exact: list.every((x) => x.exact) };
}

Principal moments and axes

64-bit

The eigenvalues and eigenvectors of the inertia tensor.

cyclic Jacobi rotations: t = sign(θ) ÷ (|θ| + √(θ² + 1)), θ = (a_qq − a_pp) ÷ (2 a_pq)

Up to 50 sweeps; stops when the sum of the off-diagonal magnitudes is under 1e-15 times the sum of the diagonal magnitudes. The rotation uses the smaller-root form of the tangent, which avoids subtracting nearly equal numbers. An off-diagonal entry under 1e-300 is skipped.

  • measure.js
  • lines 355 to 374
  • PartCraft 0.8.1
/** Eigenvalues (ascending) and unit eigenvectors of a symmetric 3×3 (cyclic Jacobi). */
export function principal(T) {
  const a = T.map((r) => [...r]);
  const v = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
  for (let sweep = 0; sweep < 50; sweep++) {
    const off = Math.abs(a[0][1]) + Math.abs(a[0][2]) + Math.abs(a[1][2]);
    if (off < 1e-15 * (Math.abs(a[0][0]) + Math.abs(a[1][1]) + Math.abs(a[2][2]) + 1e-300)) break;
    for (const [p, q] of [[0, 1], [0, 2], [1, 2]]) {
      if (Math.abs(a[p][q]) < 1e-300) continue;
      const th = (a[q][q] - a[p][p]) / (2 * a[p][q]);
      const t = Math.sign(th || 1) / (Math.abs(th) + Math.sqrt(th * th + 1));
      const c = 1 / Math.sqrt(t * t + 1), s = t * c;
      for (let k = 0; k < 3; k++) { const akp = a[k][p], akq = a[k][q]; a[k][p] = c * akp - s * akq; a[k][q] = s * akp + c * akq; }
      for (let k = 0; k < 3; k++) { const apk = a[p][k], aqk = a[q][k]; a[p][k] = c * apk - s * aqk; a[q][k] = s * apk + c * aqk; }
      for (let k = 0; k < 3; k++) { const vkp = v[k][p], vkq = v[k][q]; v[k][p] = c * vkp - s * vkq; v[k][q] = s * vkp + c * vkq; }
    }
  }
  const order = [0, 1, 2].sort((i, j) => a[i][i] - a[j][j]);
  return { moments: order.map((i) => a[i][i]), axes: order.map((i) => [v[0][i], v[1][i], v[2][i]]) };
}
measure-brep.js PartCraft 0.8.1 64-bit 3 calculations Reviewed

Tests tools/partcraft-test/test-assembly-tools.mjs

Exact minimum distance

64-bit

The minimum distance between two picked faces, edges, points or bodies and the two closest points.

The value and points are read as 64-bit numbers from OpenCascade's BRepExtrema_DistShapeShape, which is passed 1e-9 as its numeric argument. A placement matrix is treated as the identity when every entry is within 1e-15 of it.

  • measure-brep.js
  • lines 134 to 145
  • PartCraft 0.8.1
/** Minimum distance between two picked entities, exact (OCCT). */
export function distance(oc, { a, b }) {
  const T = arena();
  try {
    const sa = placed(oc, T, subShape(oc, T, bodyOf(oc, a.part), a.sub), a.matrix);
    const sb = placed(oc, T, subShape(oc, T, bodyOf(oc, b.part), b.sub), b.matrix);
    const d = T(new oc.BRepExtrema_DistShapeShape(sa, sb, 1e-9, undefined, undefined, T(new oc.Message_ProgressRange())));
    if (!d.IsDone() || d.NbSolution() < 1) throw new Error(tr('model.measure_brep.no_distance'));
    const p1 = xyz(T(d.PointOnShape1(1))), p2 = xyz(T(d.PointOnShape2(1)));
    return { distance: d.Value(), p1, p2, solutions: d.NbSolution(), inside: !!d.InnerSolution(), exact: true };
  } finally { T.free(); }
}

Exact volume, area, center and inertia

64-bit

Mass properties of the part's exact body at density 1.

I_xy = I((x + y) ÷ √2) − (I_xx + I_yy) ÷ 2, and the same for yz and xz

Volume, area, center and the moments about six axes through the center come from OpenCascade's BRepGProp. Each product of inertia is a moment about a diagonal axis minus the mean of two axis moments, which subtracts nearly equal numbers when the product is small beside the moments.

  • measure-brep.js
  • lines 147 to 163
  • PartCraft 0.8.1
/** Exact volume, area, centre of mass and inertia tensor (density 1) of a part's body, in part coordinates. */
export function massProps(oc, { part }) {
  const T = arena();
  try {
    const body = bodyOf(oc, part);
    const gv = T(new oc.GProp_GProps());
    oc.BRepGProp.VolumeProperties(body.shape, gv, false, false, false);
    const ga = T(new oc.GProp_GProps());
    oc.BRepGProp.SurfaceProperties(body.shape, ga, false, false);
    const G = T(gv.CentreOfMass());
    const c = xyz(G);
    const I = (d) => { const n = V.norm(d); return gv.MomentOfInertia(T(new oc.gp_Ax1(G, T(new oc.gp_Dir(n[0], n[1], n[2]))))); };
    const xx = I([1, 0, 0]), yy = I([0, 1, 0]), zz = I([0, 0, 1]);
    const xy = I([1, 1, 0]) - (xx + yy) / 2, yz = I([0, 1, 1]) - (yy + zz) / 2, xz = I([1, 0, 1]) - (xx + zz) / 2;
    return { volume: gv.Mass(), area: ga.Mass(), centre: c, tensor: [[xx, xy, xz], [xy, yy, yz], [xz, yz, zz]], exact: true };
  } finally { T.free(); }
}

Curved edges of an assembly drawing view

64-bit

Turns each visible or hidden curve of a hidden-line view into the line segments the drawing keeps.

n = clamp(⌈span ÷ size × 160⌉, 8, 240) equal parameter steps

A straight edge is kept as its two ends. Any other curve is sampled at 8 to 240 equal parameter steps, so the drawing holds a polyline whose deviation from the curve depends on that count. The points are 64-bit values from OpenCascade.

  • measure-brep.js
  • lines 190 to 199
  • PartCraft 0.8.1
function edgePts(oc, T, e, size) {
  const c = T(new oc.BRepAdaptor_Curve(e));
  const t0 = c.FirstParameter(), t1 = c.LastParameter();
  const P = (t) => xyz(T(c.EvalD0(t)));
  if (c.GetType() === 'GeomAbs_Line') return [P(t0), P(t1)];
  const a = P(t0), b = P(t1), m = P((t0 + t1) / 2);
  const span = V.len(V.sub(a, m)) + V.len(V.sub(m, b));
  const n = Math.max(8, Math.min(240, Math.ceil(span / Math.max(1e-6, size) * 160)));
  return Array.from({ length: n + 1 }, (_, i) => P(t0 + (t1 - t0) * i / n));
}

In detail

Method
Circle through three points (circumcenter); closest point on a triangle and closest points of two segments (the code cites Ericson, Real-Time Collision Detection 5.1.5 and 5.1.9); divergence theorem over the closed mesh for volume, center and second moments; parallel-axis combination; cyclic Jacobi for principal moments.
Number types
64-bit numbers for the arithmetic; mesh positions and edge segments are Float32Array.
Tolerances
Full circle when |a − b| < 1e-9 × max(1, |a|). Collinear circle points when |n|² < 1e-24 (absolute). Segment degenerate when its squared length ≤ 1e-18 (absolute). Parallel planes or axes when ||d₁·d₂| − 1| < 1e-9. Jacobi stops when the off-diagonal sum is under 1e-15 × the diagonal sum, or after 50 sweeps.
Limits
The facet distance skips the segment-to-segment pass when the two picks have more than 4 million segment pairs. The rows shown in the panel and copied to the clipboard are rounded for display to between 3 and 6 decimals.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach