Resources Numerical accuracy Calculation policy

Meshing a slab or wall into plates

Cuts a slab or wall outline into four-node plates and places their nodes in the frame model.

How the precision is kept

Node positions are 64-bit: grid lines through the outline's corners divided into equal parts, or bilinear blends between corners. Several tolerances are absolute lengths in millimeters. A mesh node within the merge tolerance of an existing model node becomes that node, so a plate corner can sit up to that tolerance from where the mesh put it.

Calculations under this policy

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

frame/plate-mesh.js CadCraft 0.8.2 64-bit 10 calculations Reviewed

Tests tools/frame/test-shell.mjs

Cleaning an outline

64-bit

Removes repeated points and corners that lie on a straight run, and makes the outline counter-clockwise.

drop a point when |p − q| ≤ 1e-6; drop a corner when |cross| ≤ 1e-9 × |b − a|²

The repeated-point limit of 1e-6 mm and the smallest accepted doubled area of 1e-6 mm² are absolute. The straight-run test is scaled by the squared distance between the neighbors.

  • frame/plate-mesh.js
  • lines 41 to 50
  • CadCraft 0.8.2
function cleanLoop(loop) {
  if (!Array.isArray(loop)) return null;
  const pts = loop.map((p) => (Array.isArray(p) ? [Number(p[0]), Number(p[1])] : [Number(p?.x), Number(p?.y)])).filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
  const out = [];
  for (const p of pts) { const q = out[out.length - 1]; if (!q || Math.hypot(p[0] - q[0], p[1] - q[1]) > 1e-6) out.push(p); }
  if (out.length > 2 && Math.hypot(out[0][0] - out[out.length - 1][0], out[0][1] - out[out.length - 1][1]) <= 1e-6) out.pop();
  // drop corners that lie on a straight run
  const s = out.filter((p, i) => { const a = out[(i - 1 + out.length) % out.length], b = out[(i + 1) % out.length]; return Math.abs((p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0])) > 1e-9 * Math.hypot(b[0] - a[0], b[1] - a[1]) ** 2; });
  return s.length >= 3 && Math.abs(area2(s)) > 1e-6 ? ccw(s) : null;
}

Is the outline square-cornered

64-bit

Finds the turn that makes every edge of the outline and its openings run along one of two square directions.

turn = atan2 of the longest edge; each edge: min(|dx|, |dy|) ≤ 1e-6 × max(1, length)

The test is relative to the edge length, with a floor of 1e-6 mm for edges shorter than 1 mm.

  • frame/plate-mesh.js
  • lines 52 to 63
  • CadCraft 0.8.2
/** The turn (radians) that makes every edge run along u or v, or null. */
function squareTurn(loops) {
  let best = null, len = 0;
  for (const l of loops) for (let i = 0; i < l.length; i++) { const p = l[i], q = l[(i + 1) % l.length], d = Math.hypot(q[0] - p[0], q[1] - p[1]); if (d > len) { len = d; best = Math.atan2(q[1] - p[1], q[0] - p[0]); } }
  const c = Math.cos(-best), s = Math.sin(-best);
  for (const l of loops) for (let i = 0; i < l.length; i++) {
    const p = l[i], q = l[(i + 1) % l.length];
    const dx = c * (q[0] - p[0]) - s * (q[1] - p[1]), dy = s * (q[0] - p[0]) + c * (q[1] - p[1]);
    if (Math.min(Math.abs(dx), Math.abs(dy)) > 1e-6 * Math.max(1, Math.hypot(dx, dy))) return null;
  }
  return best;
}

Grid mesh

64-bit

Lays grid lines through every corner and given point and divides each gap into equal parts no longer than the size asked for.

n = ⌈gap ÷ size − 1e-9⌉; line_j = a + (b − a) × j ÷ n

Each line is computed from the gap's start, not by adding a step repeatedly, so the error does not grow along the gap. For a turned outline the nodes are rotated back with the cosine and sine of the turn. A turn smaller than 1e-12 rad is treated as none.

  • frame/plate-mesh.js
  • lines 82 to 108
  • CadCraft 0.8.2
function gridMesh(outline, holes, size, points, turnIn) {
  // a grid is the same a quarter turn on: use the smallest turn, and none at all for an outline square to the axes
  let turn = turnIn - Math.round(turnIn / (Math.PI / 2)) * (Math.PI / 2);
  if (Math.abs(turn) < 1e-12) turn = 0;
  const c = turn ? Math.cos(turn) : 1, s = turn ? Math.sin(turn) : 0;
  const toG = (p) => [c * p[0] + s * p[1], -s * p[0] + c * p[1]];
  const toW = (p) => [c * p[0] - s * p[1], s * p[0] + c * p[1]];
  const O = outline.map(toG), H = holes.map((h) => h.map(toG)), Pt = points.map(toG);
  const lines = (k) => {
    const v = [...O, ...H.flat(), ...Pt].map((p) => p[k]).sort((a, b) => a - b);
    const u = [];
    for (const x of v) if (!u.length || x - u[u.length - 1] > 1e-6) u.push(x);
    const fine = [u[0]];
    for (let i = 1; i < u.length; i++) { const n = Math.max(1, Math.ceil((u[i] - u[i - 1]) / size - 1e-9)); for (let j = 1; j <= n; j++) fine.push(u[i - 1] + (u[i] - u[i - 1]) * j / n); }
    return fine;
  };
  const xs = lines(0), ys = lines(1);
  if ((xs.length - 1) * (ys.length - 1) > 4 * MESH_LIMIT) throw { code: 'mesh-too-many', n: (xs.length - 1) * (ys.length - 1) };
  const id = new Map(), nodes = [], quads = [];
  const node = (i, j) => { const k = i * ys.length + j; if (!id.has(k)) { id.set(k, nodes.length); nodes.push(toW([xs[i], ys[j]])); } return id.get(k); };
  for (let i = 0; i + 1 < xs.length; i++) for (let j = 0; j + 1 < ys.length; j++) {
    const m = [(xs[i] + xs[i + 1]) / 2, (ys[j] + ys[j + 1]) / 2];
    if (!inside(m, O) || H.some((h) => inside(m, h))) continue;
    quads.push([node(i, j), node(i + 1, j), node(i + 1, j + 1), node(i, j + 1)]);
  }
  return { kind: 'grid', nodes, quads };
}

Point inside a polygon

64-bit

Decides whether a grid cell's center lies inside the outline or inside an opening.

even–odd crossing count

The crossing position is computed with one division and compared directly. Only cell centers are tested, which lie away from the edges.

  • frame/plate-mesh.js
  • lines 31 to 39
  • CadCraft 0.8.2
/** Point in polygon (even–odd), points on the edge count as inside only through the cell-centre use below. */
export function inside(pt, poly) {
  let c = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const a = poly[i], b = poly[j];
    if ((a[1] > pt[1]) !== (b[1] > pt[1]) && pt[0] < (b[0] - a[0]) * (pt[1] - a[1]) / (b[1] - a[1]) + a[0]) c = !c;
  }
  return c;
}

Mesh of a four-corner outline

64-bit

A structured mesh between the opposite edges of a convex four-corner outline.

P(s, t) = (1 − s)(1 − t) P₀ + s (1 − t) P₁ + s t P₂ + (1 − s) t P₃; s = i ÷ n, t = j ÷ m

Each node is one bilinear blend of the four corners, with s and t formed by a single division.

  • frame/plate-mesh.js
  • lines 110 to 121
  • CadCraft 0.8.2
function transfinite(o, size) {
  const d = (a, b) => Math.hypot(b[0] - a[0], b[1] - a[1]);
  const n = Math.max(1, Math.ceil(Math.max(d(o[0], o[1]), d(o[3], o[2])) / size - 1e-9));
  const m = Math.max(1, Math.ceil(Math.max(d(o[1], o[2]), d(o[0], o[3])) / size - 1e-9));
  const nodes = [], quads = [];
  for (let i = 0; i <= n; i++) for (let j = 0; j <= m; j++) {
    const s = i / n, t = j / m;
    nodes.push([0, 1].map((k) => (1 - s) * (1 - t) * o[0][k] + s * (1 - t) * o[1][k] + s * t * o[2][k] + (1 - s) * t * o[3][k]));
  }
  for (let i = 0; i < n; i++) for (let j = 0; j < m; j++) quads.push([i * (m + 1) + j, (i + 1) * (m + 1) + j, (i + 1) * (m + 1) + j + 1, i * (m + 1) + j + 1]);
  return { kind: 'transfinite', nodes, quads };
}

Cutting an outline into triangles

64-bit

Ear clipping of an outline that is neither square-cornered nor four-cornered.

an ear: cross(a, b, c) > 1e-12 and no other corner inside

The convexity limit 1e-12 is an absolute cross product in mm², not scaled to the outline's size. The orientation tests are plain 64-bit cross products, not exact predicates.

  • frame/plate-mesh.js
  • lines 123 to 147
  • CadCraft 0.8.2
/** Ear clipping of a simple counter-clockwise polygon → triangles (index triples). */
export function earClip(poly) {
  const idx = poly.map((_, i) => i), tris = [];
  const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
  let guard = 0;
  while (idx.length > 3 && guard++ < 10000) {
    let cut = false;
    for (let k = 0; k < idx.length; k++) {
      const i0 = idx[(k - 1 + idx.length) % idx.length], i1 = idx[k], i2 = idx[(k + 1) % idx.length];
      const a = poly[i0], b = poly[i1], c = poly[i2];
      if (cross(a, b, c) <= 1e-12) continue;
      let ok = true;
      for (const j of idx) {
        if (j === i0 || j === i1 || j === i2) continue;
        const p = poly[j];
        if (cross(a, b, p) >= 0 && cross(b, c, p) >= 0 && cross(c, a, p) >= 0) { ok = false; break; }
      }
      if (!ok) continue;
      tris.push([i0, i1, i2]); idx.splice(k, 1); cut = true; break;
    }
    if (!cut) throw { code: 'mesh-outline' };
  }
  tris.push([idx[0], idx[1], idx[2]]);
  return tris;
}

Triangles into quadrilaterals

64-bit

Cuts each triangle into three quadrilaterals and each of those into k × k plates.

k = ⌈longest edge ÷ 2 ÷ size − 1e-9⌉; nodes by bilinear blend

Nodes on shared edges are matched by their coordinates rounded to 1e-4 mm. The first node found keeps its unrounded coordinates.

  • frame/plate-mesh.js
  • lines 149 to 167
  • CadCraft 0.8.2
function splitMesh(outline, size) {
  const tris = earClip(outline);
  let longest = 0;
  for (const t of tris) for (let e = 0; e < 3; e++) { const a = outline[t[e]], b = outline[t[(e + 1) % 3]]; longest = Math.max(longest, Math.hypot(b[0] - a[0], b[1] - a[1])); }
  const k = Math.max(1, Math.ceil(longest / 2 / size - 1e-9));
  const key = new Map(), nodes = [], quads = [];
  const put = (p) => { const s = Math.round(p[0] * 1e4) + ',' + Math.round(p[1] * 1e4); if (!key.has(s)) { key.set(s, nodes.length); nodes.push(p); } return key.get(s); };
  const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
  for (const [i, j, l] of tris) {
    const A = outline[i], B = outline[j], C = outline[l], g = [(A[0] + B[0] + C[0]) / 3, (A[1] + B[1] + C[1]) / 3];
    for (const q of [[A, mid(A, B), g, mid(C, A)], [B, mid(B, C), g, mid(A, B)], [C, mid(C, A), g, mid(B, C)]]) {
      for (let a = 0; a < k; a++) for (let b = 0; b < k; b++) {
        const at = (s, t) => [0, 1].map((c) => (1 - s) * (1 - t) * q[0][c] + s * (1 - t) * q[1][c] + s * t * q[2][c] + (1 - s) * t * q[3][c]);
        quads.push([put(at(a / k, b / k)), put(at((a + 1) / k, b / k)), put(at((a + 1) / k, (b + 1) / k)), put(at(a / k, (b + 1) / k))]);
      }
    }
  }
  return { kind: 'split', nodes, quads };
}

Mesh quality

64-bit

The largest side ratio and the smallest corner angle of the plates.

aspect = longest side ÷ shortest side; angle = acos(u·v ÷ (|u| |v|))

The cosine is clamped to [−1, 1] before the arc cosine. The division by the shortest side is not guarded.

  • frame/plate-mesh.js
  • lines 169 to 182
  • CadCraft 0.8.2
function quality({ nodes, quads }) {
  let maxAspect = 1, minAngle = 180;
  for (const q of quads) {
    const P = q.map((i) => nodes[i]);
    const L = P.map((p, i) => Math.hypot(P[(i + 1) % 4][0] - p[0], P[(i + 1) % 4][1] - p[1]));
    maxAspect = Math.max(maxAspect, Math.max(...L) / Math.min(...L));
    for (let i = 0; i < 4; i++) {
      const a = P[(i + 3) % 4], b = P[i], c = P[(i + 1) % 4];
      const u = [a[0] - b[0], a[1] - b[1]], v = [c[0] - b[0], c[1] - b[1]];
      minAngle = Math.min(minAngle, Math.acos(Math.max(-1, Math.min(1, (u[0] * v[0] + u[1] * v[1]) / (Math.hypot(...u) * Math.hypot(...v))))) * 180 / Math.PI);
    }
  }
  return { maxAspect, minAngle };
}

Mesh nodes onto model nodes

64-bit

Uses an existing node of the frame for a mesh node that lies within the merge tolerance of it, else adds a new node.

|existing − new| ≤ tol (1 mm by default)

The tolerance is an absolute length. When a mesh node is merged, the plate's corner takes the existing node's position, which can differ from the meshed position by up to the tolerance.

  • frame/plate-mesh.js
  • lines 184 to 206
  • CadCraft 0.8.2
/** Nodes of a frame file found or added by position. */
function nodeFinder(json, tol) {
  json.nodes = json.nodes || [];
  const grid = new Map(), cell = Math.max(tol, 1e-6) * 4;
  const k = (x, y, z) => [Math.floor(x / cell), Math.floor(y / cell), Math.floor(z / cell)];
  const addIdx = (n) => { const c = k(n.x, n.y, n.z).join(','); if (!grid.has(c)) grid.set(c, []); grid.get(c).push(n); };
  json.nodes.forEach(addIdx);
  const ids = new Set(json.nodes.map((n) => n.id));
  let added = 0;
  const find = (x, y, z, prefix) => {
    const [a, b, c] = k(x, y, z);
    for (let i = -1; i <= 1; i++) for (let j = -1; j <= 1; j++) for (let l = -1; l <= 1; l++) {
      for (const n of grid.get([a + i, b + j, c + l].join(',')) || []) if (Math.hypot(n.x - x, n.y - y, n.z - z) <= tol) return n.id;
    }
    let id = prefix + '.' + (++added);
    while (ids.has(id)) id = prefix + '.' + (++added);
    ids.add(id);
    const n = { id, x, y, z };
    json.nodes.push(n); addIdx(n);
    return id;
  };
  return { find, added: () => added };
}

Wall plates

64-bit

Meshes a vertical wall along a line in plan between two heights.

node = from + e_x × u, z = z₀ + v; e_x = (to − from) ÷ L

The wall is meshed in its own flat coordinates and each node is placed with one multiplication by the wall's unit direction.

  • frame/plate-mesh.js
  • lines 217 to 229
  • CadCraft 0.8.2
export function wallPlates(json, { id = 'wall', from, to, z0 = 0, z1, t, material, size, openings = [], points = [], tol = 1 }) {
  const L = Math.hypot(to[0] - from[0], to[1] - from[1]), H = z1 - z0;
  if (!(L > 0) || !(H > 0)) throw { code: 'mesh-outline' };
  const outline = [[0, 0], [L, 0], [L, H], [0, H]];
  const holes = openings.map((o) => [[o.u0, o.v0], [o.u1, o.v0], [o.u1, o.v1], [o.u0, o.v1]]);
  const m = meshOutline(outline, { holes, size, points });
  const ex = [(to[0] - from[0]) / L, (to[1] - from[1]) / L];
  const f = nodeFinder(json, tol);
  const ids = m.nodes.map(([u, v]) => f.find(from[0] + ex[0] * u, from[1] + ex[1] * u, z0 + v, id));
  json.plates = json.plates || [];
  const plates = m.quads.map((q, k) => { const p = { id: id + '.p' + (k + 1), nodes: q.map((i) => ids[i]), t, material }; json.plates.push(p); return p.id; });
  return { nodes: f.added(), plates, kind: m.kind, warnings: m.warnings, quality: m.quality, nodeIds: ids };
}
frame/plate-model.js CadCraft 0.8.2 64-bit 2 calculations Reviewed

Tests None found by the review

Is a plate a proper quadrilateral

64-bit

Refuses a plate whose corners are in a line or folded over.

each corner: (e₁ × e₂) · n ÷ |n| > 1e-3 × |e₁| × |e₂|

The limit is scaled by the two edge lengths, so it is a limit on the sine of the corner's turn, about 0.06°. The normal comes from the cross product of the diagonals.

  • frame/plate-model.js
  • lines 45 to 59
  • CadCraft 0.8.2
/** True when the four corners make a strictly convex quadrilateral (seen along its own normal). */
export function convexQuad(P) {
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
  const cr = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
  const n = cr(sub(P[2], P[0]), sub(P[3], P[1]));
  const ln = Math.hypot(...n);
  if (!(ln > 0)) return false;
  for (let i = 0; i < 4; i++) {
    const c = cr(sub(P[(i + 1) % 4], P[i]), sub(P[(i + 2) % 4], P[(i + 1) % 4]));
    const s = (c[0] * n[0] + c[1] * n[1] + c[2] * n[2]) / ln;
    const e1 = Math.hypot(...sub(P[(i + 1) % 4], P[i])), e2 = Math.hypot(...sub(P[(i + 2) % 4], P[(i + 1) % 4]));
    if (!(s > 1e-3 * e1 * e2)) return false;                      // each corner turns the same way, by more than ~0.06°
  }
  return true;
}

Nodes of a rigid floor by height

64-bit

Collects the nodes that belong to a floor given by its height.

|z_node − z_floor| ≤ tol (1 mm by default)

The tolerance is an absolute length. A floor given by its node list takes the mean of their heights as its height.

  • frame/plate-model.js
  • lines 61 to 83
  • CadCraft 0.8.2
export function cleanDiaphragms(list, ctx, warn) {
  const out = [], seen = new Set(), taken = new Set();
  for (const d of Array.isArray(list) ? list.slice(0, 1000) : []) {
    const did = id(d?.id);
    if (!did || seen.has(did)) { warn(did ? 'duplicate' : 'bad-diaphragm', { kind: 'diaphragm', id: did }); continue; }
    let nodes;
    const z = fin(d?.z);
    if (Array.isArray(d?.nodes)) nodes = [...new Set(d.nodes.map(id))].filter((n) => ctx.nodeIds.has(n));
    else if (z !== null) { const tol = fin(d.tol ?? 1, 0, 1e4) ?? 1; nodes = [...ctx.at.values()].filter((n) => Math.abs(n.z - z) <= tol).map((n) => n.id); }
    else { warn('bad-diaphragm', { id: did }); continue; }
    const free = nodes.filter((n) => !taken.has(n));
    if (free.length < nodes.length) warn('diaphragm-shared', { id: did, n: nodes.length - free.length });
    if (free.length < 2) { warn('bad-diaphragm', { id: did }); continue; }
    seen.add(did);
    free.forEach((n) => taken.add(n));
    const zz = z ?? free.reduce((s, n) => s + ctx.at.get(n).z, 0) / free.length;
    const o = { id: did, name: text(d.name) || did, z: zz, nodes: free };
    const mx = fin(d.master?.x), my = fin(d.master?.y);
    if (mx !== null && my !== null) o.master = { x: mx, y: my };
    out.push(o);
  }
  return out;
}

In detail

Method
Grid mesh for outlines with square corners, transfinite mesh for a convex four-corner outline, ear clipping and triangle splitting for anything else.
Number types
64-bit numbers.
Tolerances
Points closer than 1e-6 mm are one point. A corner on a straight run is dropped at 1e-9 × the squared length of the run (relative). Square test: 1e-6 × the edge length. Grid lines closer than 1e-6 mm are one line. Ear test: cross product above 1e-12 (absolute). Split-mesh nodes are matched to 1e-4 mm. Nodes merge with existing ones within 1 mm by default. A plate corner must turn by more than 1e-3 of the product of its two edge lengths. Floor nodes are found within 1 mm of the floor's height by default.
Limits
Openings are supported only in the grid mesh. A turned outline is rotated to the grid and back, so its nodes carry the rounding of two rotations.

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