The length, radius, diameter or angle a 3D dimension shows, and how the model is resized when a new value is typed into a dimension, an edge length or a group size.
How the precision is kept
A dimension stores the vertices it is attached to and reads their positions each time, so the value is always computed from the current 64-bit coordinates through the placement matrices. Lengths are Math.hypot of coordinate differences and angles use atan2. A typed value moves geometry by the difference between the typed and the current value.
Open item. Let the angle label follow the same precision setting as lengths instead of a fixed 0.1 degree.
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.
Finds the vertex at a picked point so the dimension follows the geometry.
|M · q − p| ≤ 100 × EPS
Every vertex is moved to world coordinates with its placement matrix and compared with the picked point; one within 1e-4 m (0.1 mm, absolute) is taken, preferring the open group. The dimension then measures to that vertex, not to the picked point.
m3d/model.js
lines 2930 to 2941
CadCraft 0.8.2
/** The vertex at world point p as {path, v}, preferring one in the open context `prefer`; null when none. */
anchorAt(p, prefer = []) {
let best = null;
this.walk((def, m, path) => {
for (const [v, q] of def.verts) {
if (dist(apply(m, q), p) > EPS * 100) continue;
const score = (sameIds(path, prefer) ? 0 : 1000) + path.length;
if (!best || score < best.score) best = { score, path: path.slice(), v };
}
});
return best && { path: best.path, v: best.v };
}
Where a dimension end is now
64-bit
The current world position of a dimension's end.
p = M · q
The attached vertex's coordinates times the world matrix of its group. If the vertex is gone, a vertex within 1e-4 m of the last known place is used instead; failing that, the last known place itself.
m3d/model.js
lines 2948 to 2959
CadCraft 0.8.2
/** Where a dimension end is now: on its vertex (re-attached by position if the vertex was merged or regrouped), else its last place. */
endAt(e) {
if (!e.path) return e.p;
const ctx = this.context(e.path);
let q = ctx && ctx.def.verts.get(e.v);
if (!q && ctx) { const v = findVert(ctx.def, apply(ctx.inv, e.p), EPS * 100); if (v !== null) { e.v = v; q = ctx.def.verts.get(v); } }
if (q) return apply(ctx.m, q);
const a = this.anchorAt(e.p);
if (a) { e.path = a.path; e.v = a.v; return this.endAt(e); }
delete e.path; delete e.v;
return e.p;
}
Radius and center of a dimensioned curve
64-bit
The world center, radius and normal a radius or diameter dimension reads.
C = M · c; r = |C − M · p₀|
The world radius is measured from the transformed center to the transformed first corner of the curve, so it follows the placement's scale along that one direction. A curve that was moved into another group is found again by its center and radius, each within 1e-4 m.
m3d/model.js
lines 2977 to 3006
CadCraft 0.8.2
/**
* Where a radial dimension's curve is now: {c (world centre), r (world radius), n (normal), live (on its curve), flat
* (its radius can change), arc (not closed)}; its last known place when the curve is gone (live false); null if never.
* A curve moved into or out of a group is found again by its centre and radius.
*/
radialAt(c) {
const at = (ctx, ci) => {
const C = apply(ctx.m, ci.center), r = dist(C, apply(ctx.m, ctx.def.verts.get(ci.verts[0])));
return { c: C, r, n: norm(applyDir(ctx.m, ci.normal)), live: true, flat: ci.flat, arc: !ci.closed };
};
if (c.path) {
const ctx = this.context(c.path), ci = ctx && curveInfo(ctx.def, c.curve);
if (ci?.round) { const g = at(ctx, ci); c.p = g.c; c.r = g.r; c.n = g.n; return g; }
if (c.p) {
let found = null;
this.walk((def, m, path) => {
if (found) return;
for (const cid of new Set([...def.edges.values()].map((e) => e.curve).filter(Boolean))) {
const ci2 = curveInfo(def, cid);
if (!ci2?.round) continue;
const g = at({ def, m }, ci2);
if (dist(g.c, c.p) <= EPS * 100 && Math.abs(g.r - c.r) <= EPS * 100) { found = { path: path.slice(), curve: cid }; return; }
}
});
if (found) { c.path = found.path; c.curve = found.curve; return this.radialAt(c); }
}
delete c.path; delete c.curve;
}
return c.p ? { c: c.p, r: c.r, n: c.n, live: false, flat: false, arc: false } : null;
}
Typing a new value into a dimension
64-bit
Resizes the model so a dimension shows the typed length, radius or diameter.
radius: r_local′ = r_local × want ÷ r_world; length: see resize between two ends
For a radius or diameter the curve's own radius is scaled by the ratio of the wanted world radius to the current one (a diameter is halved first). A linear dimension goes through resizeBetween.
m3d/model.js
lines 3007 to 3027
CadCraft 0.8.2
/** Give a dimension a new length by moving the geometry at its moving end (see stretch). Throws a plain Error. */
setDimLength(id, L) {
const dm = this.dims.get(id);
if (!dm) throw new Error(t('m3d.model.the_dimension_is_gone'));
if (dm.kind === 'ang') throw new Error(t('m3d.model.dim_angle_only'));
if (dm.kind === 'rad' || dm.kind === 'dia') {
// a round curve to a new radius about its centre (setCurveRadius): the world size is scaled into the curve's group
const g = this.radialAt(dm.c);
if (!g?.live) throw new Error(t('m3d.model.dim_curve_gone'));
const want = dm.kind === 'dia' ? L / 2 : L;
if (!(want > EPS)) throw new Error(t('m3d.model.a_length_must_be_above_zero'));
const ctx = this.context(dm.c.path), ci = curveInfo(ctx.def, dm.c.curve);
setCurveRadius(ctx.def, dm.c.curve, ci.radius * want / g.r);
this.radialAt(dm.c);
return want - g.r;
}
const [pa, pb] = this.dimEnds(dm);
const r = this.resizeBetween(dm.a.path ? dm.a : null, dm.b.path ? dm.b : null, pa, pb, L, dm.move);
dm.a.p = this.endAt(dm.a); dm.b.p = this.endAt(dm.b);
return r;
}
Resize between two ends
64-bit
Moves one or both ends so the distance between them is the typed length.
δ = L − |p_b − p_a|, applied along the unit direction from a to b
The move is the typed length minus the current distance, so the new distance is the old one plus δ, not a freshly placed length. A δ of EPS (1e-6 m) or less in size changes nothing, and for the 'both' mode δ is halved for each end.
m3d/model.js
lines 3028 to 3039
CadCraft 0.8.2
/** Make the distance between ends A and B (anchors or null, now at world pa, pb) L, moving 'b', 'a' or 'both'. */
resizeBetween(A, B, pa, pb, L, move = 'b') {
if (!(L > EPS)) throw new Error(t('m3d.model.a_length_must_be_above_zero'));
const cur = dist(pa, pb);
if (cur <= EPS) throw new Error(t('m3d.model.the_two_ends_are_at_the'));
const d = norm(sub(pb, pa)), delta = L - cur;
if (Math.abs(delta) <= EPS) return 0;
if (move === 'both') { this.moveEnd(B, A, d, delta / 2); this.moveEnd(A, B, mul(d, -1), delta / 2); }
else if (move === 'a') this.moveEnd(A, B, mul(d, -1), delta);
else this.moveEnd(B, A, d, delta);
return delta;
}
Moving the end of a dimension
64-bit
Carries the move into the group that holds the moving end and stretches or moves the geometry there.
d_local = M⁻¹ · d (direction); s = |d_local|; local move = δ × s
The world direction is taken into the group's coordinates with the inverse placement matrix; its length s converts the world distance to the group's own units before the stretch.
m3d/model.js
lines 3040 to 3061
CadCraft 0.8.2
/** Move end E (an anchor) by delta along world direction d, away from the fixed end F (an anchor or null). */
moveEnd(E, F, d, delta) {
if (!E || !this.context(E.path)?.def.verts.has(E.v)) throw new Error(t('m3d.model.the_end_that_moves_is_not'));
let k = 0;
if (F) while (k < E.path.length && k < F.path.length && E.path[k] === F.path[k]) k++;
const P = F ? E.path.slice(0, k) : E.path;
const ctx = this.context(P);
if (isStacked(ctx.def)) throw new Error(t('m3d.model.mods_bake_first'));
const ld = applyDir(ctx.inv, d), s = len(ld), dl = norm(ld);
if (E.path.length > P.length) {
// the moving end is inside a group or component the fixed end is not in: the whole group moves
const inst = ctx.def.insts.get(E.path[P.length]);
inst.m = mmul(translation(mul(dl, delta * s)), inst.m);
return;
}
// the same definition: stretch the moving end's piece at its end; when the fixed end is on another piece (or
// inside a group), the piece moves whole
const def = ctx.def, piece = connectedVerts(def, E.v);
const fixedHere = F && F.path.length === P.length && piece.has(F.v);
const cut = fixedHere ? dot(def.verts.get(E.v), dl) : -Infinity;
stretch(def, dl, cut, delta * s, { only: piece, boxOf: (id) => this.bbox(this.defs.get(id)) });
}
Typing a new length for an edge
64-bit
Sets an edge to a typed length by moving one of its ends.
Both ends are taken to world coordinates; the end further along the edge's largest axis component moves. The comparison of components has a bias of EPS so ties go to red, then green, then blue.
m3d/model.js
lines 3062 to 3070
CadCraft 0.8.2
/** An edge of the open context `path` to length L; the end further along red, green, then blue moves. */
setEdgeLength(path, eid, L) {
const ctx = this.context(path), e = ctx?.def.edges.get(eid);
if (!e) throw new Error(t('m3d.model.the_edge_is_gone'));
const pa = apply(ctx.m, ctx.def.verts.get(e.a)), pb = apply(ctx.m, ctx.def.verts.get(e.b));
const dv = sub(pb, pa), i = [0, 1, 2].reduce((m, j) => (Math.abs(dv[j]) > Math.abs(dv[m]) + EPS ? j : m), 0);
const [A, B, qa, qb] = dv[i] >= 0 ? [e.a, e.b, pa, pb] : [e.b, e.a, pb, pa];
return this.resizeBetween({ path: path.slice(), v: A }, { path: path.slice(), v: B }, qa, qb, L, 'b');
}
Typing a new size for a group
64-bit
Sets a group's or component's size along one of its own axes.
δ = size − (max − min) on that axis
The current size is the difference of the bounding box's two sides on that axis. Everything within 10 × EPS (1e-5 m) of the far side moves by δ.
m3d/model.js
lines 3097 to 3107
CadCraft 0.8.2
/** A group's or component's size along its own red (0), green (1) or blue (2) axis: its far side moves. */
setDefSize(defId, axis, size) {
const def = this.defs.get(defId), bb = def && this.bbox(def);
if (isStacked(def)) throw new Error(t('m3d.model.mods_bake_first'));
if (!bb) throw new Error(t('m3d.model.it_is_empty'));
if (!(size > EPS)) throw new Error(t('m3d.model.a_size_must_be_above_zero'));
const cur = bb.max[axis] - bb.min[axis];
if (cur <= EPS) throw new Error(t('m3d.model.it_is_flat_that_way_push'));
const d = [0, 0, 0]; d[axis] = 1;
stretch(def, d, bb.max[axis], size - cur, { boxOf: (id) => this.bbox(this.defs.get(id)) });
}
Bounding box
64-bit
The smallest axis-aligned box round a group and everything placed in it.
min and max of M · p over every vertex
Each vertex is multiplied by its accumulated placement matrix and compared; no tolerance and no padding.
m3d/model.js
lines 2684 to 2691
CadCraft 0.8.2
bbox(def = this.root, m = ident()) {
let bb = null;
this.walk((d, wm) => {
if (d.mesh) { const S = d.shown, P = S.P; for (let v = 0; v < S.nv; v++) if (!S.vertDead(v)) bb = bboxAdd(bb, apply(wm, [P[v * 3], P[v * 3 + 1], P[v * 3 + 2]])); return; }
for (const p of d.verts.values()) bb = bboxAdd(bb, apply(wm, p));
}, def, m);
return bb;
}
The length a dimension between two points shows, and where its line is drawn.
L = |p_b − p_a|
L is Math.hypot of the three coordinate differences of the two measured world points. The offset to the dimension line is made square to the measured line by removing its component along it; that step is skipped when L is 1e-12 or less.
m3d/infer.js
lines 622 to 636
CadCraft 0.8.2
/**
* A dimension as drawn: dimLayout(model, dm, cam) → {pa, pb (measured points), qa, qb (the dimension line), L (length),
* A, B (its screen ends, or null), M (screen middle, where the text goes)}. The offset is kept square to the measured line.
*/
export function dimLayout(model, dm, cam, vp = cam.viewProj()) {
if (dm.kind === 'rad' || dm.kind === 'dia') return radialLayout(dm.c.path && model.radialAt ? model.radialAt(dm.c) : { c: dm.c.p, r: dm.c.r, n: dm.c.n }, dm, cam, vp);
if (dm.kind === 'ang') return angleLayout(model.dimEnds(dm), dm.off, cam, vp);
const [pa, pb] = model.dimEnds(dm);
const ab = sub(pb, pa), L = len(ab);
let off = dm.off;
if (L > 1e-12) { const u = mul(ab, 1 / L); off = sub(off, mul(u, dot(off, u))); }
const qa = add(pa, off), qb = add(pb, off);
const A = cam.project(qa, vp), B = cam.project(qb, vp);
return { kind: 'len', pa, pb, qa, qb, L, A, B, M: A && B ? [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2] : null, segs: A && B ? [[A, B]] : [] };
}
Value of a radius or diameter dimension
64-bit
The radius or diameter a dimension on an arc or circle shows.
L = r, or 2 × r for a diameter
r is the world radius computed in model.js (radialAt); the diameter is exactly twice it.
m3d/infer.js
lines 644 to 662
CadCraft 0.8.2
/**
* A radius or diameter dimension (0.4 M5) as drawn: {kind, C (centre), P (on the curve, toward the text), Q (across it,
* for a diameter), T (the text's point), L (the radius or diameter), lines (world segments to draw), arrows ([tip, from]
* pairs), M (screen text point), segs (screen segments, for picking)}. g = {c, r, n} from model.radialAt.
*/
function radialLayout(g, dm, cam, vp) {
if (!g) return { kind: dm.kind, L: 0, lines: [], arrows: [], M: null, segs: [] };
const n = norm(g.n);
let w = sub(dm.off, mul(n, dot(dm.off, n)));
if (len(w) < 1e-9) w = mul(planeAxes(n)[0], g.r * 1.5);
const u = norm(w), P = add(g.c, mul(u, g.r)), Q = add(g.c, mul(u, -g.r)), T = add(g.c, w);
const out = len(w) > g.r, dia = dm.kind === 'dia';
const lines = [[dia ? Q : g.c, out ? T : P]];
const arrows = [[P, g.c]];
if (dia) arrows.push([Q, g.c]);
const proj = (p) => cam.project(p, vp);
const segs = lines.map(([a, b]) => [proj(a), proj(b)]).filter(([a, b]) => a && b);
return { kind: dm.kind, C: g.c, P, Q, T, L: dia ? 2 * g.r : g.r, live: g.live !== false, lines, arrows, M: proj(T), segs };
}
Value of an angle dimension
64-bit
The angle a dimension at a corner shows.
θ = atan2(v · (n × u), v · u); the dimension shows θ or π − θ
The angle between the two lines is taken with atan2 of a cross-product term and a dot product, which keeps its digits for very small and nearly straight angles (acos is not used). Which of the four angles is shown is picked by where the offset points, with a slack of 1e-12 rad. Lines whose unit directions have a cross product under 1e-9 give no dimension.
m3d/infer.js
lines 663 to 691
CadCraft 0.8.2
/**
* An angle dimension (0.4 M5) as drawn: {kind: 'ang', o, L (the angle, radians), arc (world points along the dimension
* arc), lines (extension lines from the corner), arrows, M (screen text point), segs}. ends = [o, a, b] (world);
* off (world, from o) picks the angle — between the two lines, or one of the three beside them — and the arc's radius.
*/
export function angleLayout([o, a, b], off, cam, vp) {
const u = norm(sub(a, o)), v0 = norm(sub(b, o));
let n = cross(u, v0);
const empty = { kind: 'ang', o, L: 0, arc: [], lines: [], arrows: [], M: null, segs: [] };
if (len(n) < 1e-9 || !len(u) || !len(v0)) return empty;
n = norm(n);
const e2 = cross(n, u), th = Math.atan2(dot(v0, e2), dot(v0, u)); // 0 < th < π: the angle between the lines
const w = sub(off, mul(n, dot(off, n)));
const R = Math.max(len(w), 1e-6);
let phi = Math.atan2(dot(w, e2), dot(w, u));
if (phi < 0) phi += 2 * Math.PI;
// the four angles the two lines make, each [start, sweep]: u→v, v→−u, −u→−v, −v→u
const S = [[0, th], [th, Math.PI - th], [Math.PI, th], [Math.PI + th, Math.PI - th]];
const [s0, sw] = S.find(([s, k]) => phi >= s - 1e-12 && phi <= s + k + 1e-12) || S[0];
const at = (ang, r = R) => add(o, add(mul(u, r * Math.cos(ang)), mul(e2, r * Math.sin(ang))));
const N = Math.max(4, Math.ceil(sw / (Math.PI / 48)));
const arc = Array.from({ length: N + 1 }, (_, i) => at(s0 + (sw * i) / N));
const lines = [[o, at(s0, R * 1.08)], [o, at(s0 + sw, R * 1.08)]];
const arrows = [[arc[0], arc[1]], [arc[N], arc[N - 1]]];
const proj = (p) => cam.project(p, vp);
const segs = [];
for (let i = 0; i < N; i++) { const A = proj(arc[i]), B = proj(arc[i + 1]); if (A && B) segs.push([A, B]); }
return { kind: 'ang', o, L: sw, arc, lines, arrows, M: proj(at(s0 + sw / 2)), segs };
}
Angle label
64-bit
The text of an angle dimension, in degrees.
degrees = round(θ × 180 ÷ π × 10) ÷ 10
dimText converts the angle from radians to degrees and this function rounds it to one decimal of a degree with Math.round. Only the text is rounded; the dimension keeps the 64-bit angle.
m3d/infer.js
lines 736 to 737
CadCraft 0.8.2
/** Degrees to 0.1°, without a trailing .0. */
export const formatDeg = (d) => String(Math.round(d * 10) / 10);
In detail
Method
Distance between two world points; radius from the fitted circle of a curve; angle from atan2 of the cross and dot products of the two directions; resize by stretching along the measured direction.
Number types
64-bit numbers; 4×4 placement matrices.
Tolerances
A dimension end attaches to a vertex within 100 × EPS = 1e-4 m of the picked point. A typed length within EPS = 1e-6 m of the current one changes nothing. Lengths and sizes of EPS or less are refused. Angle: lines with |u × v| < 1e-9 give no dimension.
Limits
The angle label is rounded to 0.1 degree. Attachment and no-change tolerances are absolute, in meters.
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.