← Back | List all public functions

mybimapi/simplify_meshes

IFC Mesh simplification

tag v1
person mybimapi
copyright MIT Permissive: allows reuse, modification, distribution, private use, and commercial use with attribution.
payments estimate cost of the function when triggering a 100mb IFC file once.
Fork this open-source function to make quick adjustments to the script while keeping the ease of a scalable hosted function exposed as an API.

This function optimizes and reduces the file size of a 3D building model by decimating overly complex geometric meshes.

It scans the provided IFC file for face sets (such as IfcConnectedFaceSet and IfcTessellatedFaceSet), calculates their surface area, and checks the current vertex density. If the geometry is denser than your specified target, the function intelligently collapses edges and removes unnecessary polygons while preserving the overall shape. Finally, it safely deletes the original heavy geometry and returns the optimized IFC file.

This tool is highly valuable for preparing models for web viewers, improving software performance, or reducing storage costs by eliminating over-tessellated curved surfaces and overly detailed library objects (like furniture).

Parameters

  • f (ifcopenshell.file) The active IFC file object loaded into your application. This is the model you wish to optimize.
  • target_density (float) The desired maximum number of vertices per unit of surface area. This acts as the threshold for optimization. If an element's current density exceeds this value, the script calculates a reduction factor to shrink the polygon count down to meet this target. Lower values will result in more aggressive simplification.
  • round_digits (Optional[int]) The number of decimal places to which vertex coordinates should be rounded before processing. This is useful for "healing" meshes by forcing vertices that are extremely close together to merge into a single point. If set to None, no rounding is applied.

Returns

  • ifcopenshell.file Returns the modified IFC file object containing the simplified geometry and updated entity references.

Example Usage Notes

Note: The script automatically handles the conversion of complex, flat IFC polygons (including those with holes) into standard triangulated meshes. For modern IFC4 files, it reconstructs the optimized shapes using lightweight IfcTriangulatedFaceSet entities to further maximize file size reduction.

code Source code
from typing import Optional

import ifcopenshell
import ifcopenshell.util.element as el
import ifcopenshell.util.unit as unit
import numpy as np
import pyvista as pv
import triangle


def run(f: ifcopenshell.file, target_density: float, round_digits: Optional[int]) -> ifcopenshell.file:
    scale = unit.calculate_unit_scale(f)
    facesets = f.by_type("IfcConnectedFaceSet")
    if f.schema.upper().startswith("IFC4"):
        facesets += f.by_type("IfcTessellatedFaceSet") 
    el.batch_remove_deep2(f)
    for fs in facesets:
        simplify(scale, f, fs, round_digits, target_density)
    f = el.unbatch_remove_deep2(f)
    return f


def read(fs, nd):
    def triangulate(rs, holes=()):
        if any(len(r) < 3 or len(np.unique(np.asarray(r), axis=0)) < len(r) for r in rs):
            return None

        vs, ss = [], []
        for r in rs:
            n = len(vs)
            vs.extend(r)
            ss.extend((n + i, n + (i + 1) % len(r)) for i in range(len(r)))

        mesh = {
            "vertices": np.array(vs),
            "segments": np.array(ss),
        }
        if holes:
            mesh["holes"] = np.array(holes)

        try:
            mesh = triangle.triangulate(mesh, "p")
        except Exception:
            return None

        vs, ts = mesh.get("vertices"), mesh.get("triangles")
        return None if vs is None or ts is None else (vs, ts)

    def points(fs):
        vs = np.array(fs.Coordinates.CoordList, dtype=float)
        return vs[np.array(fs.PnIndex, dtype=np.int64) - 1] if fs.PnIndex else vs

    def poly(os, hs):
        def frame(ps):
            def normalize(v):
                n = np.linalg.norm(v)
                return None if n < 1e-12 else v / n

            o = ps[0]
            es = np.roll(ps, -1, axis=0) - ps

            for i, a in enumerate(es):
                u = normalize(a)
                if u is None:
                    continue
                for b in es[i + 1 :]:
                    n = normalize(np.cross(a, b))
                    if n is not None:
                        return o, np.array((u, np.cross(n, u), n))

            return None

        fr = frame(os)
        if fr is None:
            return None
        o, m = fr
        rs = [[((p - o) @ m.T)[:2] for p in r] for r in [os] + hs]

        def hole_tri_centroid(r):
            if mesh := triangulate([r]):
                return np.mean(mesh[0][mesh[1][0]], axis=0)

        hs = [h for r in rs[1:] if (h := hole_tri_centroid(r)) is not None]
        if len(hs) != len(rs) - 1:
            return None

        mesh = triangulate(rs, hs)
        if mesh is None:
            return None
        vs, gs = mesh

        ts = []
        for t in gs:
            cs = [vs[i] for i in t]
            ts.append([o + c @ m[:2] for c in cs])
        return ts or None

    def indexed_face(fa, vs):
        os = vs[np.array(fa.CoordIndex, dtype=np.int64) - 1]
        hs = [vs[np.array(h, dtype=np.int64) - 1] for h in getattr(fa, "InnerCoordIndices", ()) or ()]
        return [os[[0, i, i + 1]] for i in range(1, len(os) - 1)] if not hs else poly(os, hs)

    def face(fa):
        def ring(b):
            if not b.Bound or not b.Bound.is_a("IfcPolyLoop"):
                return None
            ps = np.array([p.Coordinates for p in b.Bound.Polygon])
            if b.Orientation is False:
                ps = ps[::-1]
            return ps

        bs = list(fa.Bounds or ())
        if not bs:
            return None
        if len(bs) == 1:
            b = bs[0]
            if b.Bound and b.Bound.is_a("IfcPolyLoop") and len(b.Bound.Polygon) == 3:
                ps = list(b.Bound.Polygon)
                if b.Orientation is False:
                    ps.reverse()
                return [np.array([p.Coordinates for p in ps], dtype=float)]
        ob = next((b for b in bs if b.is_a("IfcFaceOuterBound")), bs[0])
        os = ring(ob)
        hs = [ring(b) for b in bs if b.id() != ob.id()]
        if os is None or len(os) == 0 or any(h is None for h in hs):
            return None
        if not hs and len(os) == 3:
            return [os]
        return poly(os, hs)

    if fs.is_a("IfcConnectedFaceSet"):
        rs = [face(fa) for fa in fs.CfsFaces]
        rs = None if any(r is None for r in rs) else [t for r in rs for t in r]
    elif fs.is_a("IfcTriangulatedFaceSet"):
        vs = points(fs)
        rs = None if len(vs) == 0 or not fs.CoordIndex else vs[np.array(fs.CoordIndex, dtype=np.int64) - 1]
    elif fs.is_a("IfcPolygonalFaceSet"):
        vs = points(fs)
        if len(vs) == 0 or not fs.Faces:
            return None
        rs = [indexed_face(fa, vs) for fa in fs.Faces]
        rs = None if any(r is None for r in rs) else [t for r in rs for t in r]
    else:
        return None

    if rs is None or len(rs) == 0:
        return None
    vs = np.asarray(rs, dtype=float).reshape((-1, 3))
    if nd is not None:
        vs = np.round(vs, nd)
    vs, ix = np.unique(vs, axis=0, return_inverse=True)
    return vs, ix.reshape((-1, 3)).astype(np.int64)


def decimate(vs, ts, r):
    fs = np.empty((len(ts), 4), dtype=np.int64)
    fs[:, 0] = 3
    fs[:, 1:] = ts
    m = pv.PolyData(vs, fs.ravel())
    if m.n_cells < 4:
        return None
    try:
        m = m.decimate_pro(r)
    except Exception:
        return None
    m = m.triangulate()
    fs = m.faces.reshape((-1, 4))
    if len(fs) == 0 or np.any(fs[:, 0] != 3):
        return None
    return np.array(m.points), np.array(fs[:, 1:], dtype=np.int64)


def write(f, src, vs, ts):
    def make_faces(f, vs, ts):
        ps = [f.create_entity("IfcCartesianPoint", Coordinates=tuple(map(float, p))) for p in vs]
        fs = []
        for t in ts:
            lp = f.create_entity("IfcPolyLoop", Polygon=[ps[int(i)] for i in t])
            bd = f.create_entity("IfcFaceOuterBound", Bound=lp, Orientation=True)
            fs.append(f.create_entity("IfcFace", Bounds=[bd]))
        return tuple(fs)

    def make_tfs(f, src, vs, ts):
        ps = f.create_entity("IfcCartesianPointList3D", CoordList=[tuple(map(float, p)) for p in vs])
        kw = {
            "Coordinates": ps,
            "CoordIndex": [tuple(int(i) + 1 for i in t) for t in ts],
        }
        if src.is_a("IfcTessellatedFaceSet") and src.Closed is not None:
            kw["Closed"] = src.Closed
        return f.create_entity("IfcTriangulatedFaceSet", **kw)

    def deps(f, xs):
        ds = {}
        for x in xs:
            for y in f.traverse(x):
                if y.id():
                    ds[y.id()] = y
        return list(ds.values())

    def clean(f, xs):
        ids = {x.id() for x in xs}
        rm = []
        for x in xs:
            try:
                if any(y.id() not in ids for y in f.get_inverse(x)):
                    continue
            except RuntimeError:
                continue
            if x.id():
                rm.append(x)
        if f.to_delete is not None:
            f.to_delete.update(rm)
            return
        f.batch()
        try:
            for x in sorted(rm, key=lambda y: y.id(), reverse=True):
                f.remove(x)
        finally:
            f.unbatch()

    if f.schema.upper().startswith("IFC4"):
        dst = make_tfs(f, src, vs, ts)
        old = deps(f, (src,))
        el.replace_element(src, dst)
        clean(f, old)
    else:
        old = deps(f, tuple(src.CfsFaces))
        src.CfsFaces = make_faces(f, vs, ts)
        clean(f, old)


def simplify(scale, f, fs, round_digits, target_density):
    rs = read(fs, round_digits)
    if rs is None:
        return None
    vs, ts = rs

    def triangle_area(vs, ts):
        tris = vs[ts]
        ab = tris[:, 1] - tris[:, 0]
        ac = tris[:, 2] - tris[:, 0]
        return 0.5 * np.linalg.norm(np.cross(ab, ac), axis=1).sum()

    area = triangle_area(vs, ts) * scale * scale
    num_verts = vs.shape[0]
    density = num_verts / area

    factor = 1.0 - (target_density / density)

    if len(ts) < 32:
        return None
    rs = decimate(vs, ts, factor)
    if rs is None:
        return None
    vs, ts2 = rs
    if len(ts2) >= len(ts):
        return None

    write(f, fs, vs, ts2)
    return len(ts), len(ts2)

Input

POST /api/v1/mybimapi/simplify_meshes/ OpenAPI JSON

Output

IFC file