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