Resources Numerical accuracy Calculation policy

Merge, spin, bridge, slide and smooth

Merges vertices at their center or within a distance, spins a region round an axis, pairs two loops for a bridge, slides vertices along edges and smooths them.

How the precision is kept

All positions are read from and written to the mesh's Float64Array. Spin rotates every step from the starting positions by i ÷ n of the total angle, so rounding does not build up from step to step. Merge by distance compares true distances against the given limit.

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

Merge at center

64-bit

Joins vertices into one at their mean position, at the first or last of them, or at a given point.

p = (Σ pᵢ) × (1 ÷ n)

The positions are added in list order and the sum is multiplied by 1 ÷ n. For the first, last or a given point the coordinates are copied without arithmetic.

  • mesh/ops.js
  • lines 188 to 204
  • ModelCraft 0.8.1
/**
 * Merge vertices into one: at their centre, at the first or the last listed, or at a point. Faces that lose corners
 * shrink; faces left with fewer than three go. → the vertex kept.
 */
export function mergeVerts(m, verts, at = 'center') {
  const vs = [...new Set(verts)].filter((v) => !m.vertDead(v));
  if (vs.length < 2) return vs[0] ?? -1;
  const keep = at === 'last' ? vs[vs.length - 1] : vs[0];
  let p;
  if (Array.isArray(at)) p = at;
  else if (at === 'center') { p = [0, 0, 0]; for (const v of vs) p = add(p, P3(m, v)); p = mul(p, 1 / vs.length); }
  else p = P3(m, keep);
  const map = new Map(vs.map((v) => [v, keep]));
  remap(m, map);
  m.setPos(keep, p[0], p[1], p[2]);
  return keep;
}

Merge by distance

64-bit

Merges every vertex that lies within the given distance of a vertex already kept.

cell = max(dist, 1e-9); merge v into w when |P(w) − P(v)| ≤ dist

Candidates come from the 27 grid cells round the vertex (cell index = floor(coordinate ÷ cell)); the test itself is the true distance from Math.hypot compared with ≤ dist. The kept vertex stays where it is, and which vertex is kept depends on the order of the list.

  • mesh/ops.js
  • lines 205 to 224
  • ModelCraft 0.8.1
/** Merge vertices closer than `dist` (among `verts`, else all): → how many were merged away. */
export function mergeByDistance(m, dist, verts = null) {
  const vs = verts ? [...new Set(verts)] : Array.from({ length: m.nv }, (_, i) => i);
  const live = vs.filter((v) => !m.vertDead(v));
  const cell = Math.max(dist, 1e-9), grid = new Map(), map = new Map();
  const kk = (x, y, z) => x + ',' + y + ',' + z;
  for (const v of live) {
    const p = P3(m, v), c = p.map((x) => Math.floor(x / cell));
    let hit = -1;
    for (let dx = -1; dx <= 1 && hit < 0; dx++) for (let dy = -1; dy <= 1 && hit < 0; dy++) for (let dz = -1; dz <= 1 && hit < 0; dz++) {
      for (const w of grid.get(kk(c[0] + dx, c[1] + dy, c[2] + dz)) || []) if (len(sub(P3(m, w), p)) <= dist) { hit = w; break; }
    }
    if (hit >= 0) { map.set(v, hit); continue; }
    const k = kk(c[0], c[1], c[2]);
    if (!grid.has(k)) grid.set(k, []);
    grid.get(k).push(v);
  }
  if (map.size) remap(m, map);
  return map.size;
}

Spin

64-bit

Extrudes a region or open edges round an axis in a number of steps over a total angle.

aᵢ = angle·i ÷ n; pᵢ = c + q·cos aᵢ + (k × q)·sin aᵢ + k·(k·q)·(1 − cos aᵢ), q = p₀ − c

Each step's vertices are placed by rotating the starting position p₀ by angle·i ÷ n, not by rotating the previous step, so errors do not add up over the steps. A full turn is recognized when ||angle| − 2π| < 1e-9; the last ring is then merged onto the first within 1e-7 × max(1, distance of the first new vertex from the center). Steps are rounded to a whole number between 1 and 512.

  • mesh/ops.js
  • lines 670 to 721
  • ModelCraft 0.8.1
/**
 * Spin: extrude a region of faces (or open border edges) round an axis `steps` times by `angle` (radians) in all,
 * about `center`, axis `axis` (unit). A full turn (2π) joins the last step to the first. → {verts (the last copy's), faces}.
 */
export function spin(m, { faces = [], edges = [], center = [0, 0, 0], axis = [0, 0, 1], angle = Math.PI * 2, steps = 12 } = {}) {
  const n = Math.max(1, Math.min(512, Math.round(steps)));
  const full = Math.abs(Math.abs(angle) - Math.PI * 2) < 1e-9;
  const rot = (p, a) => {
    const k = norm(axis), q = sub(p, center), c = Math.cos(a), sn = Math.sin(a);
    return add(center, add(add(mul(q, c), mul(cross(k, q), sn)), mul(k, dot(k, q) * (1 - c))));
  };
  let curF = liveFaces(m, faces), curE = edges.filter((h) => !m.heDead(h) && m.hTw[h] < 0), made = [];
  if (curF.length) {
    // a region facing against the way it turns is turned over first, so the solid made faces out
    let nrm = [0, 0, 0], c = [0, 0, 0], k = 0;
    const t = [0, 0, 0];
    for (const f of curF) { m.faceNormalRaw(f, t); nrm = add(nrm, t); c = add(c, m.faceCenter(f)); k++; }
    const tangent = mul(cross(norm(axis), sub(mul(c, 1 / k), center)), Math.sign(angle) || 1);
    if (dot(nrm, tangent) < 0) curF = flipFaces(m, curF);
  }
  const start = new Map();
  // the region where it starts: a cap there (the region's own faces move on with the spin)
  const startCap = curF.map((f) => ({ corners: m.faceCorners(f, []).slice(), mat: m.fMat[f], from: f }));
  const firstVerts = new Set();
  for (const c of startCap) for (const h of c.corners) firstVerts.add(m.hV[h]);
  for (const h of curE) { firstVerts.add(m.hV[h]); firstVerts.add(m.head(h)); }
  for (let i = 1; i <= n; i++) {
    const r = curF.length ? extrudeRegion(m, curF) : extrudeEdges(m, curE);
    const a = (angle * i) / n;
    for (const v of r.verts) {
      if (!start.has(v)) {
        // the copy's start: the vertex it came from, followed back to the first step's
        const src = sourceOf(m, v);
        start.set(v, start.get(src) ?? src);
      }
      const p0 = P3(m, start.get(v));
      const p = rot(p0, a);
      m.setPos(v, p[0], p[1], p[2]);
    }
    made = r.verts;
    if (curF.length) curF = r.faces;
    else curE = openOf(m, r.verts);
  }
  if (full) {
    // the last step lands on the first: its end faces go, its ring is merged onto the start's
    if (curF.length) m.removeFaces(curF, { keepLoose: true });
    mergeByDistance(m, 1e-7 * Math.max(1, len(sub(P3(m, made[0] ?? 0), center))), [...firstVerts, ...made]);
  } else if (startCap.length) {
    m.replaceFaces([], startCap.map((c) => { const rc = c.corners.slice().reverse(); return { verts: rc.map((h) => m.hV[h]), mat: c.mat, corners: rc }; }));
  }
  return { verts: made };
}

Source of a copied vertex

64-bit

Finds the vertex a fresh copy was made from, so spin can rotate from the starting position.

The source is the first vertex joined to the copy by an edge and lying within 1e-12 of it (absolute). A copy is created at exactly its source's coordinates, so the distance is zero when the test is made.

  • mesh/ops.js
  • lines 722 to 728
  • ModelCraft 0.8.1
/** The vertex a freshly copied vertex was made from: the one at the same place joined to it by an edge. */
function sourceOf(m, v) {
  const p = P3(m, v);
  for (const h of m.outgoing(v)) { const w = m.head(h); if (len(sub(P3(m, w), p)) < 1e-12) return w; }
  for (let h = 0; h < m.nh; h++) if (!m.heDead(h) && m.head(h) === v) { const w = m.hV[h]; if (len(sub(P3(m, w), p)) < 1e-12) return w; }
  return v;
}

Bridge: pairing two loops

64-bit

Chooses the rotation and direction that pair the vertices of two loops with the shortest total distance.

d(r, dir) = Σᵢ |P(Aᵢ) − P(B₍r + dir·i₎)|; choose the smallest

For each of the 2n pairings the n distances are added in order and the smallest sum wins. The comparison is strict, so of two pairings with equal sums the first one tried is kept.

  • mesh/ops.js
  • lines 519 to 525
  • ModelCraft 0.8.1
// B runs the opposite way round to A when facing it: try both directions and every rotation
let best = null;
for (const dir of [1, -1]) for (let r = 0; r < n; r++) {
  let d = 0;
  for (let i = 0; i < n; i++) d += len(sub(P3(m, A[i]), P3(m, B[(r + dir * i + n * n) % n])));
  if (!best || d < best.d) best = { d, dir, r };
}

Slide along rails

64-bit

Puts each slid vertex a fraction t of the way from its starting place toward the far end of its rail.

p′ = p + (P(w) − p)·|t|, w = rail end a for t ≥ 0, b for t < 0

The starting places come from the base array when one is given (the edit tool passes a Float64Array copy taken when the slide began), so repeated pointer moves do not compound. At |t| = 1 the form p + (w − p)·1 can differ from w in the last bit.

  • mesh/ops.js
  • lines 924 to 933
  • ModelCraft 0.8.1
/** Put the slid vertices at t (−1…1) along their rails from their places in `base` (Float64Array of x, y, z per vertex, or the mesh's own). */
export function applySlide(m, rails, t, base) {
  for (const [v, r] of rails) {
    const p = base ? [base[v * 3], base[v * 3 + 1], base[v * 3 + 2]] : P3(m, v);
    const w = t >= 0 ? r.a : r.b;
    if (w < 0) continue;
    const q = lerp(p, P3(m, w), Math.abs(t));
    m.setPos(v, q[0], q[1], q[2]);
  }
}

Smooth vertices

64-bit

Moves each vertex part of the way to the average of its neighbors, a number of times.

c = (Σ neighbors) × (1 ÷ n); p′ = p + (c − p)·factor

All new positions of a pass are computed from the old ones before any is written. A vertex on an open border uses only its two border neighbors.

  • mesh/ops.js
  • lines 954 to 968
  • ModelCraft 0.8.1
export function smoothVerts(m, verts, { factor = 0.5, repeat = 1 } = {}) {
  const vs = [...new Set(verts)].filter((v) => !m.vertDead(v) && m.vHe[v] >= 0);
  for (let r = 0; r < repeat; r++) {
    const next = vs.map((v) => {
      const border = m.isBoundaryVert(v);
      const nb = m.outgoing(v).map((h) => m.head(h));
      if (border) { const ring = m.outgoing(v); nb.length = 0; nb.push(m.head(ring[ring.length - 1]), m.hV[m.hPv[ring[0]]]); }
      let c = [0, 0, 0];
      for (const w of nb) c = add(c, P3(m, w));
      c = mul(c, 1 / nb.length);
      return lerp(P3(m, v), c, factor);
    });
    vs.forEach((v, i) => m.setPos(v, next[i][0], next[i][1], next[i][2]));
  }
}

In detail

Method
Mean of positions; grid search with cells the size of the merge distance; Rodrigues' rotation; sum of distances for each rotation and direction of a loop pairing; linear blends for slide and smooth.
Number types
64-bit numbers; Float64Array positions.
Tolerances
Merge by distance: the given distance (0.0001 m from the edit tool when none is set), grid cell at least 1e-9. Spin: a full turn when ||angle| − 2π| < 1e-9; the closing merge uses 1e-7 × max(1, distance of the first new vertex from the center); a copied vertex's source is the neighbor within 1e-12 of it.
Limits
Merge by distance keeps the first vertex met and merges later ones into it, so the result depends on vertex order and the kept vertex does not move to the group's center.

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