Cuts openings and clipping solids out of an IFC element's mesh, so the element is drawn and measured with its voids.
How the precision is kept
Plane clipping and the convex cut work on 64-bit positions, with tolerances scaled to the size of the mesh. Solid booleans go through manifold-3d: both meshes are moved so the first one's box center is at the origin, welded, and handed over as a Float32Array; the result is read back from the library's mesh and moved back in 64-bit. A cut solid therefore carries 32-bit positions relative to its own center.
Open item. Give manifold-3d and take back 64-bit positions (or re-snap the unchanged corners to their 64-bit originals) so a cut element keeps the digits of the uncut one.
Calculations under this policy
8 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.
ifc/geo-bool.jsBimCraft 0.8.1 32-bit in part8 calculationsReviewed
Tests tools/ifc-test/test.mjs
Mesh handed to the solid kernel
32-bit in part
Welds the mesh's corners and passes it to manifold-3d.
Corners are merged on a grid of 1e-6 times the largest box side (never below 1e-9), then Float32Array.from(pos) stores every coordinate in 32-bit. Anything built from the result carries about 7 digits of the coordinate as shifted by the caller.
ifc/geo-bool.js
lines 29 to 39
BimCraft 0.8.1
function toManifold(mesh) {
const { pos, idx } = mesh.welded(1e-6 * scaleOf(mesh));
if (!idx.length) return null;
const mm = new M.Mesh({ numProp: 3, vertProperties: Float32Array.from(pos), triVerts: Uint32Array.from(idx) });
mm.merge();
let man;
try { man = new M.Manifold(mm); } catch (e) { return null; }
const st = typeof man.status === 'function' ? man.status() : 'NoError';
if (st !== 'NoError') { man.delete?.(); return null; }
return man;
}
Boolean of two solids
32-bit in part
The difference, union or intersection of two element meshes.
c = center of a's box; R = (a − c) OP (b − c); result = R + c
Both meshes are moved by the first mesh's box center in 64-bit before the 32-bit handover, so the 32-bit rounding is relative to the element's size and not to its distance from the file origin. The result is read from the library's mesh and moved back by the same center in 64-bit. Corners the cut did not touch come back rounded to 32-bit as well.
ifc/geo-bool.js
lines 53 to 72
BimCraft 0.8.1
/** a OP b with manifold-3d, both meshes shifted near the origin first (float precision). null when it cannot. */
export function boolean(op, a, b, g = null) {
if (!M || a.isEmpty) return op === 'UNION' ? new Mesh().append(a).append(b) : null;
if (b.isEmpty) return op === 'INTERSECTION' ? new Mesh() : a;
const ba = a.bounds();
const c = [(ba.min[0] + ba.max[0]) / 2, (ba.min[1] + ba.max[1]) / 2, (ba.min[2] + ba.max[2]) / 2];
const T = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -c[0], -c[1], -c[2], 1];
const Tb = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, c[0], c[1], c[2], 1];
const A = toManifold(new Mesh().append(a, T));
if (!A) return null;
const B = toManifold(new Mesh().append(b, T));
if (!B) { A.delete?.(); return null; }
let R;
try {
R = op === 'DIFFERENCE' ? A.subtract(B) : op === 'UNION' ? A.add(B) : A.intersect(B);
const out = fromManifold(R).transform(Tb);
return out;
} catch (e) { if (g) g.note('A boolean could not be computed'); return null; }
finally { A.delete?.(); B.delete?.(); R?.delete?.(); }
}
Element minus all its openings
32-bit in part
Subtracts every opening from an element, one after another.
A₀ = base − c; Aᵢ = Aᵢ₋₁ − (cutterᵢ − c); result = Aₙ + c
The base is converted to 32-bit once and all subtractions stay inside manifold-3d, so the rounding does not repeat per opening. A cutter that cannot be made a manifold is cut afterwards on the triangles by convexCut, in 64-bit, on the positions that came back from the 32-bit result.
ifc/geo-bool.js
lines 74 to 100
BimCraft 0.8.1
/** base minus every cutter (all in one frame). Never throws; a cutter that cannot be removed is noted and skipped. */
export function subtractAll(base, cutters, g = null) {
if (!cutters.length || base.isEmpty) return base;
if (M) {
const bb = base.bounds();
const c = [(bb.min[0] + bb.max[0]) / 2, (bb.min[1] + bb.max[1]) / 2, (bb.min[2] + bb.max[2]) / 2];
const T = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -c[0], -c[1], -c[2], 1];
const Tb = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, c[0], c[1], c[2], 1];
let A = toManifold(new Mesh().append(base, T));
if (A) {
const rest = [];
for (const cut of cutters) {
const B = toManifold(new Mesh().append(cut, T));
if (!B) { rest.push(cut); continue; }
try { const R = A.subtract(B); A.delete?.(); A = R; } catch (e) { rest.push(cut); }
B.delete?.();
}
let out = fromManifold(A).transform(Tb);
A.delete?.();
for (const cut of rest) out = convexCut(out, cut, g);
return out;
}
}
let out = base;
for (const cut of cutters) out = convexCut(out, cut, g);
return out;
}
Face planes of a convex cutter
64-bit
Finds the outward planes of a cutter and decides whether it is convex.
n = (b − a) × (c − a) ÷ |…|; d = n · a; convex when n · p − d ≤ 10 ε for every corner p
ε is 1e-6 times the box diagonal. A triangle is skipped when the length of its cross product (twice its area) is below ε². Two planes are the same when their normals' dot product is above 1 − 1e-9 and their offsets differ by less than ε.
ifc/geo-bool.js
lines 102 to 121
BimCraft 0.8.1
/** Outward face planes [{ n, d }] of a convex closed mesh (n · p ≤ d inside), or null when it is not convex. */
export function convexPlanes(mesh, tol = 1e-6) {
const P = mesh.pos, I = mesh.idx;
const b = mesh.bounds();
if (!b) return null;
const size = Math.max(1e-9, len(sub(b.max, b.min)));
const eps = size * tol;
const planes = [];
for (let t = 0; t < I.length; t += 3) {
const a = [P[I[t] * 3], P[I[t] * 3 + 1], P[I[t] * 3 + 2]], bb = [P[I[t + 1] * 3], P[I[t + 1] * 3 + 1], P[I[t + 1] * 3 + 2]], c = [P[I[t + 2] * 3], P[I[t + 2] * 3 + 1], P[I[t + 2] * 3 + 2]];
const cr = cross(sub(bb, a), sub(c, a));
if (len(cr) < eps * eps) continue;
const n = norm(cr), d = dot(n, a);
if (planes.some((q) => dot(q.n, n) > 1 - 1e-9 && Math.abs(q.d - d) < eps)) continue;
planes.push({ n, d });
}
if (planes.length < 4) return null;
for (let i = 0; i < P.length; i += 3) for (const q of planes) if (q.n[0] * P[i] + q.n[1] * P[i + 1] + q.n[2] * P[i + 2] - q.d > eps * 10) return null;
return planes;
}
Polygon split by a plane
64-bit
Cuts a convex polygon into the part in front of a plane and the part behind it.
t = dₐ ÷ (dₐ − d_b); x = a + (b − a) t
The crossing is computed only where the two signed distances have opposite signs, so the divisor adds two magnitudes and cannot cancel. The side test is a plain comparison with zero, with no tolerance.
ifc/geo-bool.js
lines 145 to 159
BimCraft 0.8.1
/** Split a convex polygon by plane q: [part with n·p ≥ d, part with n·p < d]. */
function splitPoly(poly, q) {
const front = [], back = [];
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const da = dot(q.n, a) - q.d, db = dot(q.n, b) - q.d;
if (da >= 0) front.push(a); else back.push(a);
if ((da >= 0) !== (db >= 0)) {
const t = da / (da - db);
const x = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
front.push(x); back.push(x);
}
}
return [front, back];
}
Mesh clipped by a plane
64-bit
Keeps the part of a mesh on one side of a plane and closes the cut.
dᵢ = (pᵢ − p) · n; ε = 10⁻⁹ × (d_max − d_min); cut point = a + (b − a) dₐ ÷ (dₐ − d_b)
Signed distances are kept in a Float64Array. A corner within ε of the plane is treated as on it and becomes a cut point without interpolation. The point p is subtracted from each coordinate before the dot product, so a plane far from the origin does not cost digits.
ifc/geo-bool.js
lines 161 to 203
BimCraft 0.8.1
/**
* Keep the part of `mesh` on the side of the plane (point p, normal n) that n points to. Triangles are split exactly;
* cut edges are chained into loops and capped (outer loops and the holes inside them) when they close.
*/
export function clipPlane(mesh, p, n) {
n = norm(n);
const out = new Mesh();
const P = mesh.pos, N = mesh.nrm, I = mesh.idx;
const sd = new Float64Array(P.length / 3);
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < sd.length; i++) { const d = (P[i * 3] - p[0]) * n[0] + (P[i * 3 + 1] - p[1]) * n[1] + (P[i * 3 + 2] - p[2]) * n[2]; sd[i] = d; if (d < lo) lo = d; if (d > hi) hi = d; }
const span = Math.max(1e-9, hi - lo);
const eps = span * 1e-9;
if (lo >= -eps) return out.append(mesh);
if (hi <= eps) return out;
const segs = [];
const v = (i) => [P[i * 3], P[i * 3 + 1], P[i * 3 + 2]];
const nv = (i) => [N[i * 3], N[i * 3 + 1], N[i * 3 + 2]];
const lerp = (i, j) => { const t = sd[i] / (sd[i] - sd[j]); const a = v(i), b = v(j); return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; };
for (let t = 0; t < I.length; t += 3) {
const ids = [I[t], I[t + 1], I[t + 2]];
const inside = ids.map((i) => sd[i] >= -eps);
const k = inside.filter(Boolean).length;
const fn = nv(ids[0]);
const emit = (pts) => { const base = out.vertexCount; for (const q of pts) out.vertex(q, fn); for (let s = 1; s + 1 < pts.length; s++) out.idx.push(base, base + s, base + s + 1); };
if (k === 3) { emit(ids.map(v)); continue; }
if (k === 0) continue;
// walk the triangle's edges, keeping the inside part
const poly = [], cut = [];
for (let e = 0; e < 3; e++) {
const i = ids[e], j = ids[(e + 1) % 3];
const ii = sd[i] >= -eps, jj = sd[j] >= -eps;
if (ii) poly.push(v(i));
if (ii !== jj && Math.abs(sd[i]) > eps && Math.abs(sd[j]) > eps) { const q = lerp(i, j); poly.push(q); cut.push(q); }
else if (ii && !jj && Math.abs(sd[i]) <= eps) cut.push(v(i));
else if (!ii && jj && Math.abs(sd[j]) <= eps) cut.push(v(j));
}
if (poly.length >= 3) emit(poly);
if (cut.length === 2) segs.push(cut);
}
capLoops(out, segs, scale(n, -1), eps * 10 + span * 1e-7);
return out;
}
Cap of a cut
64-bit
Joins the cut segments into loops, sorts outer loops from holes and fills them.
key = round(x ÷ tol), round(y ÷ tol), round(z ÷ tol); area = ½ |Σ (xᵢ yᵢ₊₁ − xᵢ₊₁ yᵢ)|
Segment ends are matched by rounding to a grid of tol, so two ends closer than tol can still fall in neighboring cells and stay unjoined. The cap uses the first point stored for each cell, unrounded. Nesting is decided by a ray-crossing test on each loop's first point.
ifc/geo-bool.js
lines 205 to 254
BimCraft 0.8.1
/** Chain cut segments into closed loops on the plane and fill them (normal `cn`, facing out of the kept part). */
function capLoops(out, segs, cn, tol) {
if (!segs.length) return;
const key = (q) => Math.round(q[0] / tol) + ',' + Math.round(q[1] / tol) + ',' + Math.round(q[2] / tol);
const adj = new Map();
for (const [a, b] of segs) {
const ka = key(a), kb = key(b);
if (ka === kb) continue;
if (!adj.has(ka)) adj.set(ka, { p: a, to: [] });
if (!adj.has(kb)) adj.set(kb, { p: b, to: [] });
adj.get(ka).to.push(kb); adj.get(kb).to.push(ka);
}
const used = new Set(), loops = [];
const ek = (a, b) => (a < b ? a + '|' + b : b + '|' + a);
for (const [start] of adj) {
for (const first of adj.get(start).to) {
if (used.has(ek(start, first))) continue;
const loop = [adj.get(start).p];
used.add(ek(start, first));
let prev = start, cur = first, guard = 0;
while (cur !== start && guard++ < 1e6) {
loop.push(adj.get(cur).p);
const nxt = adj.get(cur).to.find((x) => x !== prev && !used.has(ek(cur, x)));
if (!nxt) break;
used.add(ek(cur, nxt));
prev = cur; cur = nxt;
}
if (cur === start && loop.length >= 3) loops.push(loop);
}
}
if (!loops.length) return;
// 2D frame on the plane; loops CCW about cn are outers, nesting decides holes
const u = perp(cn), w = cross(cn, u);
const to2 = (q) => [dot(q, u), dot(q, w)];
const L = loops.map((l) => { const p2 = l.map(to2); let a = 0; for (let i = 0; i < p2.length; i++) { const x = p2[i], y = p2[(i + 1) % p2.length]; a += x[0] * y[1] - y[0] * x[1]; } return { l, p2, area: Math.abs(a / 2), ccw: a > 0 }; });
L.sort((x, y) => y.area - x.area);
const 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; };
const depth = L.map((x, i) => L.slice(0, i).filter((y) => inside(x.p2[0], y.p2)).length);
for (let i = 0; i < L.length; i++) {
if (depth[i] % 2) continue;
const outer = L[i].ccw ? L[i].l : L[i].l.slice().reverse();
const holes = [];
for (let j = i + 1; j < L.length; j++) if (depth[j] === depth[i] + 1 && inside(L[j].p2[0], L[i].p2)) holes.push(L[j].ccw ? L[j].l.slice().reverse() : L[j].l);
const rings = [outer, ...holes];
const tris = planeTriangulate(rings);
const base = out.vertexCount;
for (const r of rings) for (const q of r) out.vertex(q, cn);
for (let k = 0; k < tris.length; k += 3) out.idx.push(base + tris[k], base + tris[k + 1], base + tris[k + 2]);
}
}
Half-space cut
32-bit in part
Removes the part of an element behind a plane, optionally only inside a polygon boundary.
L = 4 × max(1, box diagonal) + |P₀ − box min|; prism height = 2 L
A plain or boxed half-space is one 64-bit plane clip. A polygon-bounded one builds a prism of length 2 L, clips it by the plane and subtracts it with boolean(), which passes through 32-bit. If that fails, triangles are chosen by their centers and the result is noted as approximate.
ifc/geo-bool.js
lines 266 to 296
BimCraft 0.8.1
/** base − half-space solid (the half-space's material is behind its outward normal n: keep what n points to). */
export function halfSpaceCut(g, base, hs) {
const t = g.t(hs);
if (g.t(g.ref(g.a(hs, 'BaseSurface'))) !== 'IFCPLANE') { g.note('Half-spaces on curved surfaces are not cut yet'); return base; }
const { p, n } = halfSpacePlane(g, hs);
if (t === 'IFCHALFSPACESOLID' || t === 'IFCBOXEDHALFSPACE') return clipPlane(base, p, n);
if (t === 'IFCPOLYGONALBOUNDEDHALFSPACE') {
const P = g.axis2(g.ref(g.a(hs, 'Position')));
const ring = curvePoints(g, g.ref(g.a(hs, 'PolygonalBoundary'))).map((q) => [q[0], q[1]]);
if (ring.length > 1 && Math.abs(ring[0][0] - ring[ring.length - 1][0]) < 1e-9 && Math.abs(ring[0][1] - ring[ring.length - 1][1]) < 1e-9) ring.pop();
if (ring.length < 3) return clipPlane(base, p, n);
const bb = base.bounds();
const L = bb ? 4 * Math.max(1, len(sub(bb.max, bb.min))) + len(sub([P[12], P[13], P[14]], bb.min)) : 1e6;
// the bounded half-space as a closed solid: the prism over the boundary, long enough, cut by the plane
const prism = extrudeShapes([{ outer: ring, holes: [] }], [0, 0, 1], 2 * L).transform([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, -L, 1]).transform(P);
const solid = clipPlane(prism, p, scale(n, -1));
const r = boolean('DIFFERENCE', base, solid, g);
if (r) return r;
// not a closed manifold: cut by the plane only where the triangles lie over the boundary
const Pi = invert(P);
const inPrism = (q) => { const l = apply(Pi, q[0], q[1], q[2]); let c = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const a = ring[i], b = ring[j]; if ((a[1] > l[1]) !== (b[1] > l[1]) && l[0] < (b[0] - a[0]) * (l[1] - a[1]) / (b[1] - a[1]) + a[0]) c = !c; } return c; };
const cut = clipPlane(base, p, n), keepOut = new Mesh();
// keep triangles outside the boundary from the uncut mesh, inside it from the cut one
splitBy(base, (c) => !inPrism(c), keepOut);
splitBy(cut, (c) => inPrism(c), keepOut);
g.note('A bounded half-space was cut approximately (the mesh is not closed)');
return keepOut;
}
g.note(g.s.pretty(t) + ' is not cut yet');
return base;
}
In detail
Method
Solid booleans with manifold-3d (subtract, add, intersect). Without it, or for a mesh that is not a closed manifold: triangles split by the face planes of a convex cutter, or by one plane with the cut capped by triangulated loops.
Number types
64-bit numbers in plain arrays and one Float64Array of signed distances; Float32Array for the positions handed to manifold-3d; Uint32Array for indices.
Tolerances
Weld grid before a boolean: 1e-6 × the largest box side (floor 1e-9). Convex test: 1e-6 × the box diagonal, with 10 × that for a point outside a plane, and 1 − 1e-9 on the dot product of two normals. Plane clip: 1e-9 × the spread of signed distances (floor 1e-9 on the spread). Cap loops join points on a grid of 10 × that plus 1e-7 × the spread. A polygon boundary counts as closed when its ends agree within 1e-9 in both coordinates (absolute).
Limits
A cutter that is neither closed nor convex is left uncut, with a note. The convex cut leaves the hole open (no reveal faces). A bounded half-space on an open mesh is cut by triangle centers and noted as approximate. Half-spaces on curved surfaces are not cut.
Approach: 32-bit step, flagged
One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.