Resources Numerical accuracy Calculation policy

Inset, bevel and moves along normals

Places the new vertices of an inset or a bevel at the stated thickness or width from the old edges, and moves vertices along their normals.

How the precision is kept

Every new point is one 64-bit expression from the mesh's Float64Array positions: unit edge directions, cross products and a division by the sine or cosine of the corner angle. Small sines and cosines are guarded with fixed thresholds, and offsets are capped as a fraction of the neighboring edge lengths.

Calculations under this policy

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

mesh/ops.js ModelCraft 0.8.1 64-bit 7 calculations Reviewed

Tests tools/modelcraft-test/mesh.mjs

Vertex normals

64-bit

A unit normal per vertex from the faces round it, weighted by face area.

N(v) = unit(Σ Newell normal of each face at v)

Each face's unnormalized Newell normal (its length is twice the face area) is added into a Float64Array for every corner, then each sum is divided by its Math.hypot length. A sum of zero length is left as zero.

  • mesh/ops.js
  • lines 47 to 57
  • ModelCraft 0.8.1
/** Each vertex's unit normal: the faces round it averaged by area (only `faces`, when given). */
export function vertexNormals(m, faces = null) {
  const N = new Float64Array(m.nv * 3), t = [0, 0, 0], cs = [];
  for (const f of faces ? liveFaces(m, faces) : Array.from({ length: m.nf }, (_, i) => i)) {
    if (m.faceDead(f)) continue;
    m.faceNormalRaw(f, t);
    for (const h of m.faceCorners(f, cs)) { const v = m.hV[h]; N[v * 3] += t[0]; N[v * 3 + 1] += t[1]; N[v * 3 + 2] += t[2]; }
  }
  for (let v = 0; v < m.nv; v++) { const l = Math.hypot(N[v * 3], N[v * 3 + 1], N[v * 3 + 2]); if (l > 0) { N[v * 3] /= l; N[v * 3 + 1] /= l; N[v * 3 + 2] /= l; } }
  return N;
}

Inset corner

64-bit

Where a face corner goes when both of its edges move inward by d.

bis = unit(in₁ + in₂); c = bis·in₁; k = d ÷ c; p′ = p + bis·k

in₁ and in₂ are unit cross products of the face normal with each edge direction. If c is not above 1e-6 the division is skipped and k = d. k is capped at 4 × lim, where lim = half the shorter edge ÷ √(1 − c²); 1 − c² is clamped at 0 before the root, a root of zero is replaced by 1 and the divisor is floored at 1e-9.

  • mesh/ops.js
  • lines 175 to 185
  • ModelCraft 0.8.1
/** Where corner p (between a before it and b after it, counter-clockwise about n) moves when its edges move in by d. */
function insetCorner(p, a, b, n, d) {
  const u1 = norm(sub(p, a)), u2 = norm(sub(b, p));
  const in1 = norm(cross(n, u1)), in2 = norm(cross(n, u2));   // inward, left of each edge
  const bis = norm(add(in1, in2));
  const c = dot(bis, in1);
  let k = c > 1e-6 ? d / c : d;
  const lim = 0.5 * Math.min(len(sub(p, a)), len(sub(b, p))) / Math.max(1e-9, Math.sqrt(Math.max(0, 1 - c * c)) || 1);
  if (k > lim * 4) k = lim * 4;
  return add(p, mul(bis, k));
}

Inset of a region's border

64-bit

The new border vertices of a region inset, each from the two border edges that meet at it, raised by the depth.

n = unit(n(face in) + n(face out)); p′ = insetCorner(p, a, b, n, thickness) + n·depth

The plane normal at a border vertex is the unit sum of the unit normals of the two faces carrying its border edges, so on a region that is not flat the offset is measured in that averaged plane and not in either face.

  • mesh/ops.js
  • lines 144 to 156
  • ModelCraft 0.8.1
// region: only border vertices move in; each border vertex's new place from its two border edges (in their faces' planes)
const inR = new Set(R), border = [];
for (const f of R) for (const h of m.faceCorners(f, cs)) { const t = m.hTw[h]; if (t < 0 || !inR.has(m.hF[t])) border.push(h); }
const inEdge = new Map(), outEdge = new Map();   // border vertex → the border half-edge ending at it / starting from it
for (const h of border) { outEdge.set(m.hV[h], h); inEdge.set(m.head(h), h); }
const map = new Map();
for (const [v, hOut] of outEdge) {
  const hIn = inEdge.get(v);
  if (hIn === undefined) continue;
  const p = P3(m, v), a = P3(m, m.hV[hIn]), b = P3(m, m.head(hOut));
  const nrm = norm(add(m.faceNormal(m.hF[hIn]), m.faceNormal(m.hF[hOut])));
  map.set(v, newVert(m, add(insetCorner(p, a, b, nrm, thickness), mul(nrm, depth)), v));
}

Bevel: slide along plain edges

64-bit

How far a new point slides along an edge that is not bevelled, so the bevel is the stated width inside the face.

sin = |u_in × u_out|; slide = width ÷ sin; point = v + u·min(slide, cap)

The sine is the length of the cross product of two unit edge directions; when it is not above 1e-6, or the corner lies between two plain edges, the width itself is used. Where the two faces on an edge ask for different slides the mean is taken, so neither face gets exactly the width. The slide is capped at 0.45 of the edge length, or 0.9 when the far end is not bevelled.

  • mesh/ops.js
  • lines 568 to 586
  • ModelCraft 0.8.1
const sinBetween = (u1, u2) => len(cross(u1, u2));
const corners = new Map();   // v → its outgoing half-edges (the face corners at v), clockwise
for (const v of V) corners.set(v, m.outgoing(v));
for (const [v, cs] of corners) for (const c of cs) {
  const hin = m.hPv[c], ein = isB(hin), eout = isB(c);
  const uOut = dirOf(v, m.head(c)), uIn = dirOf(v, m.hV[hin]);
  const sn = sinBetween(uIn, uOut);
  if (ein && !eout) want(v, c, sn > 1e-6 ? width / sn : width);
  else if (eout && !ein) want(v, hin, sn > 1e-6 ? width / sn : width);
  else if (!ein && !eout) { want(v, hin, null); want(v, c, null); }
}
// make the slide points (clamped along their edge)
for (const s of slide.values()) {
  const v = s.v, h = s.h, far = m.hV[h] === v ? m.head(h) : m.hV[h];
  let d = s.n ? s.sum / s.n : width;
  const L = elen(h), cap = (V.has(far) ? 0.45 : 0.9) * L;
  if (d > cap) d = cap;
  s.vert = newVert(m, add(P3(m, v), mul(dirOf(v, far), d)), v);
}

Bevel: where two bevelled edges meet

64-bit

The point inside a face where the offset lines of two bevelled edges cross.

k = width ÷ |u_in × u_out|; point = v + (u_in + u_out)·k

u_in + u_out is not normalized: multiplied by width ÷ sin it lands at the width from both edges. When the sine is not above 1e-6, k = width. k is capped at 2 × cap, with cap = 0.45 × the shorter of the two edges ÷ (|u_in + u_out| ÷ 2), that divisor floored at 1e-9 and replaced by 1 when it is zero.

  • mesh/ops.js
  • lines 588 to 598
  • ModelCraft 0.8.1
// meet points of two bevelled edges in a face
for (const [v, cs] of corners) for (const c of cs) {
  const hin = m.hPv[c];
  if (!(isB(hin) && isB(c))) continue;
  const uOut = dirOf(v, m.head(c)), uIn = dirOf(v, m.hV[hin]);
  const sn = sinBetween(uIn, uOut);
  let k = sn > 1e-6 ? width / sn : width;
  const cap = 0.45 * Math.min(elen(c), elen(hin)) / Math.max(1e-9, len(add(uIn, uOut)) / 2 || 1);
  if (k > cap * 2) k = cap * 2;
  meets.set(c, newVert(m, add(P3(m, v), mul(add(uIn, uOut), k)), v));
}

Bevel: profile points

64-bit

The points between the two sides of a bevelled edge when it has more than one segment.

M = (p₀ + p₁) ÷ 2; Q = M + (p_c − M)·2·profile; B(t) = (1 − t)²·p₀ + 2t(1 − t)·Q + t²·p₁, t = k ÷ segments

A quadratic Bézier curve evaluated by its three weights in one expression per point. With profile 0.5 the control point is the old corner, which gives a parabolic section between the two sides and not an arc of a circle.

  • mesh/ops.js
  • lines 606 to 614
  • ModelCraft 0.8.1
const profilePts = (p0, p1, pc, from) => {
  const out = [];
  const M = lerp(p0, p1, 0.5), Q = add(M, mul(sub(pc, M), 2 * profile));
  for (let k = 1; k < segs; k++) {
    const t = k / segs, a = (1 - t) * (1 - t), b = 2 * t * (1 - t), c = t * t;
    out.push(newVert(m, add(add(mul(p0, a), mul(Q, b)), mul(p1, c)), from));
  }
  return out;
};

Shrink or fatten

64-bit

Moves each listed vertex along its normal by d.

p′ = p + N(v)·d

One multiplication and one addition per coordinate, using the area-weighted unit normals of the whole mesh from vertexNormals.

  • mesh/ops.js
  • lines 934 to 938
  • ModelCraft 0.8.1
/** Shrink or fatten: every vertex along its normal (from the faces round it) by d. */
export function shrinkFatten(m, verts, d) {
  const N = vertexNormals(m);
  for (const v of verts) m.setPos(v, m.P[v * 3] + N[v * 3] * d, m.P[v * 3 + 1] + N[v * 3 + 1] * d, m.P[v * 3 + 2] + N[v * 3 + 2] * d);
}

In detail

Method
Inset: each corner moves along the bisector of its two inward edge normals by thickness ÷ cos of the half angle. Bevel: a slide of width ÷ sin(corner angle) along plain edges, a meeting point at v + (u₁ + u₂)·width ÷ sin(angle) between two bevelled edges, and a quadratic Bézier profile across each bevelled edge.
Number types
64-bit numbers; vertex normals in a Float64Array.
Tolerances
Inset: the cosine must exceed 1e-6 for the division; the sine in the cap is floored at 1e-9. Bevel: the sine must exceed 1e-6 for the division; slides are capped at 0.45 of the edge length (0.9 when the far end is not bevelled); the meeting distance is capped at 2 × 0.45 × the shorter edge ÷ (|u₁ + u₂| ÷ 2). All thresholds are absolute on unit-vector products.
Limits
The bevel profile is a quadratic Bézier curve, so a profile of 0.5 gives a parabolic section, not a circular arc. Where a cap applies, the offset is smaller than the width asked for.

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