# debim, read and measured: a YAML building language, a lossy IFC importer, and a fidelity score an empty frame passes

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.satyajit.si/articles/debim
> date: 2026-11-09
> tags: 3d, cad, bim, mcp, agents, evals

On 6 November 2026 an X account posted a short video under the line "your BIM model is too big for AI, so someone turned buildings into code" ([post](https://x.com/glade1579857/status/2118587463216115925)). The project is [debim](https://github.com/PRIDA-TAKON/debim), an open-source "Building-as-Code engine" by Prida Takon. The post makes three claims: a large BIM file cannot fit in an agent's context window, debim represents walls, columns, beams, dimensions and materials as compact structured text instead, and the developer reports "85.5% median visual fidelity across 255 real building models". To its credit it also says "some information can still be lost".

That last sentence is the interesting one, and nobody in the thread quantifies it. So I did the part I could do on a CPU in a sandbox: read the code, take four public IFC files, count their tokens with a real tokenizer, measure what a box-per-element representation costs and loses, and rebuild the two benchmark metrics to see what they reward.

Labels, as everywhere on this site: **reported** is the project's own claim (README, its research note, the post); **measured** is something I ran or read off a file; **reasoned** is my arithmetic or inference on the other two.

<RepoCard repo="PRIDA-TAKON/debim" />

## What debim is

**Read** at commit `4ffd1f3` (29 October 2026). `pyproject.toml` says version 1.2.0 (the README badge still says v1.1.0), Python 3.11 or newer, MIT licence. The core depends on pydantic, PyYAML, NumPy, ezdxf, Shapely and SciPy; IFC goes through the optional `ifcopenshell` extra, the MCP server through the optional `mcp` extra. The repository holds 570 tracked files.

A `.debim` file is YAML with a fixed top level: `schema: "IFC4-Minimal"`, a project block with units, a site box, storeys with elevation and height, a structural grid (`axes_x`, `axes_y` as named offsets in metres), a material list, and `elements`. Elements are placed on the grid, not in coordinates. From the repo's own `house.debim`:

```yaml
grids:
  axes_x: { "1": 0.00, "2": 4.00, "3": 8.00 }
  axes_y: { "A": 0.00, "B": 4.00, "C": 8.00 }

elements:
  - class: IfcColumn
    tag: "C1"
    material: "CONC_240"
    profile: { shape: BOX, width: 0.25, depth: 0.25 }
    placement: { grid: ["1", "A"], base_storey: "L1", top_storey: "L1" }

  - class: IfcWall
    tag: "W-EXT-01"
    height: 3.00
    placement: { from_grid: ["1", "A"], to_grid: ["2", "A"], storey: "L1" }
    layers:
      - { name: "plaster", material: "PLASTER_CEMENT", thickness: 0.015, function: "finish" }
      - { name: "AAC block", material: "AAC_BLOCK", thickness: 0.075, function: "structure" }
      - { name: "plaster", material: "PLASTER_CEMENT", thickness: 0.015, function: "finish" }
```

(The original has Thai layer names; I translated them and folded the layers onto one line each.) A wall is two grid points, a height and a layer stack. A slab is a boundary of grid points, a thickness and openings. That is the right shape for a structural engineer's first pass at a reinforced-concrete house, which is what most of the examples are: two Thai houses, a townhouse, a Farnsworth House transcription and a Soumaya Museum sketch.

Around that sits a lot of tooling, **read** from `src/debim/` and the README:

- a compiler from the YAML to IFC4 (`compiler.py`, through IfcOpenShell);
- a deterministic quantity take-off (`qto.py`: concrete volume, formwork area, rebar kilograms, steel, timber, masonry) and costing against a price file, including the Thai Comptroller General's BOQ forms;
- a zero-dependency single-file version (`skills/debim/scripts/debim_zero.py`) for sandboxes with no `pip`;
- an HTML viewer generator, compliance checks written as tests, Git diff tooling;
- an MCP server with seven tools: `debim_validate`, `debim_qto`, `debim_cost_template`, `debim_cost`, `debim_compile_ifc`, `debim_generate_viewer`, `debim_urban_ingest`;
- and an **importer**, `import_ifc_to_manifest()` in `importer.py`, 2,257 lines, which goes the other way: IFC in, YAML out.

The MCP design is the part I agree with most. The README's own motivation is that "Asking an LLM to guess building dimensions directly in text leads to fatal hallucinations", and the answer it chose is to let the model write a declarative file and have deterministic code do the arithmetic. Every tool takes the manifest as a string and returns computed numbers. That is how an agent should touch quantities.

## Two directions, one headline

The post's headline is about the other direction. "Your BIM model is too big for AI" describes an existing IFC from Revit or Archicad, and the fix it implies is to convert that file into debim text and give the text to the agent. That is the importer, and the importer is where "some information can still be lost" lives. Authoring a new building in YAML loses nothing, because there was nothing before it.

So the questions are: how big is an IFC in tokens, how big is the converted text, and what did the conversion keep?

## What I ran, and what I could not

The site owner authorised running the cloned repository. The sandbox's permission layer still refused both attempts, `pip install -e` of the clone and `python -m debim import`, so **I did not run debim's own code**. Everything below is one of three things:

1. **Measured with standard libraries**: IfcOpenShell 0.9.0.post1 from PyPI to parse and mesh the IFC files, tiktoken's `o200k_base` to count tokens, Python 3.13, on a shared 4-vCPU box.
2. **Read** from debim's source at commit `4ffd1f3`, with file and function names so you can check.
3. **Re-implemented** by me from that source where a piece was small enough to rebuild exactly: the visual-fidelity renderer and comparison (about 60 lines), and the material classifier (about 30 lines). Where my rebuild could differ from the original, I say how.

The inputs are four public IFC files that debim itself ships in `tests/fixtures/`: buildingSMART's `Building-Architecture.ifc`, `Building-Structural.ifc` and `Building-Hvac.ifc` (IFC4, written by a SketchUp IFC exporter), and the long-standing `Duplex_A_20110907.ifc` sample (IFC2X3, from Revit). They are small on purpose: they are the files its own tests run on, and the Duplex is the closest thing the field has to a standard small house.

## How many tokens a building is

**Measured.** Each IFC file tokenised whole, as text, with `o200k_base`:

<TokenLedger />

The Duplex, a two-unit house with 218 physical elements, is 2,380,763 bytes and 1,029,326 tokens. It does not fit a 1M-token window, never mind a 200k one. Most of that is geometry: of its 38,898 STEP entities, 25,514 are points, directions, placements, loops, faces and other representation entities. The three buildingSMART samples are a sharper case of the same thing. `Building-Architecture.ifc` has 13 physical elements and 69,273 tokens; one of its lines, a triangulated face set, is 53,736 characters long.

Two corrections to the project's own token arithmetic, both **measured**:

- Its benchmark estimates tokens as file bytes divided by 3.5 (`tools/kaggle/debim_kaggle_stress_test.py`, the `estimated_tokens_ifc` field). On these four files the real tokenizer counts 1.51 to 1.73 times that, because STEP text is short numbers, commas, parentheses and `#` references, which tokenise poorly. So the raw IFC side is, if anything, understated.
- The headline "558,629,804 tokens saved" is the sum, over 407 models, of (IFC bytes minus YAML bytes) divided by 3.5 (same file, `total_tokens_saved`). It is a statement about file sizes. No agent pays it, because no agent reads a whole IFC into context; agents that work with IFC today call IfcOpenShell (`by_type`, `get_psets`) and read what they asked for (**reasoned**).

The green bars are my stand-in for the converted side. I wrote one YAML record per physical element in the shape of debim's catch-all record (class, tag, source, position, width, depth, height, layer), with my own script, because I could not run the importer. For the Duplex that is 72,397 bytes and 27,580 tokens: 37 times fewer than the IFC. debim's real output will differ. Walls are shorter than boxes (two grid names, a height, a thickness), furniture is about the same, and every distinct wall end adds a grid line. But the order of magnitude is the point, and it is real: a building as one short record per element fits comfortably in any current window.

For scale, debim's hand-written examples, **measured** the same way: `house.debim` is 1,045 tokens, `examples/farnsworth_house/project.debim` is 2,801, and the larger `examples/thai_l_house/project.debim` is 17,853.

## What a record per element keeps

A record per element is small because it drops things. Here is what `import_ifc_to_manifest()` writes, **read** class by class.

**Walls** (`IfcWall`) become a straight segment between two grid points, with a thickness and a height. The length and thickness come from a rectangular extrusion profile if there is one, else from Revit property sets, else from defaults: 3.0 m long, 2.8 m tall, 0.15 m thick. A wall whose profile is not a rectangle (an L in plan, a curve, a wall with a sloped top) is written as a straight rectangular wall. Every endpoint becomes a grid line, and a new endpoint within 5 cm of an existing one is snapped onto it (`get_or_create_grid_x`, tolerance `0.05`), so wall ends can move by up to 5 cm. Doors and windows are attached as children of their host wall.

**Beams** (`IfcBeam`) always become `profile: BoxProfile(shape="BOX", ...)`, with the flange width as the width and the section depth as the depth. A steel wide-flange beam is written as a solid rectangle. When the beam's material is classified as concrete, the importer also writes reinforcement the IFC never had: `main_top="2-DB16"`, `main_bottom="2-DB16"`, `stirrups="RB6 @ 0.15m"`. That is a default for a small Thai RC beam, and the QTO will count its kilograms.

**Storeys** keep their names and elevations, and every storey's height is set to 3.5 m.

**Furniture** becomes a bounding box with a position and rotation; with `bake_assets=True` it also exports a GLB mesh beside the YAML (the README's "10% baked GLB assets"). It is off by default.

**Everything else**, every `IfcElement` without its own path (`IfcBuildingElementProxy`, `IfcDiscreteAccessory`, `IfcChimney` and so on), goes through the catch-all at the end of the function: an `IfcCustomElement` with the bounding box's centre and its width, depth and height in the element's own axes.

**Materials** are reduced to a category by keyword (`resolve_material`): a name containing TIMBER, WOOD, LUMBER or PLYWOOD is timber; STEEL, METAL, WIDE, `W-` or a steel section pattern is steel; CONC is concrete; **anything else is masonry**, priced against `MAT-BRICK-01`. I re-implemented that rule and ran it over the Duplex (**measured**): 160 of its 218 elements land in masonry, including all 61 furnishings, 24 windows and 14 doors (they have no material, so the rule reads their names), and the 7 concrete footings, which have no material either.

**Dropped entirely**: property sets (the importer reads a few to find dimensions and writes none; the Duplex has 2,093 property sets holding 12,030 values, **measured**), quantity sets, classification references, the element's `GlobalId` (the tag is its `Name`, or a prefix and the first 8 characters of the id), type objects, and any relationship other than containment and door-or-window-in-wall.

How much the box costs depends on the element. **Measured** on the Duplex: each element meshed in world coordinates, its volume by the divergence theorem, divided by its bounding box's volume.

<BoxOverstatement />

Walls, slabs, coverings and footings are boxes already, and the importer handles them well enough. The beams are the problem. The Duplex has eight, all wide-flange steel and all square to the world axes: six W410X60, which fill 10.7% of their box, and two W310X60, which fill 12.6%. A take-off on the solid box counts about 9.3 times the steel for the first and 7.9 times for the second (**reasoned**, 1 / 0.107 and 1 / 0.126). Windows and doors are mostly frame and air, but they are hosted openings, so their box is closer to what an estimator wants. For a tool whose stated origin is an accurate bill of quantities, the beam path is the one I would fix first: an `IShape` profile with flange and web thickness would cost a few more tokens and give the right kilograms.

## What "retention" counts

The README reports "100.0% Median Retention Rate" and an 85.0% mean over 407 real models. The research note in `docs/research/` gives the mean as 83.0%, and mean storage compression as 91.6% against the README's 88.4%; the two documents disagree with each other (**read**).

Here is the metric, **read** from `tools/kaggle/debim_kaggle_stress_test.py` and `tools/compare_ifc.py`: import the IFC, write the YAML, compile the YAML back to IFC, count `IfcElement` instances (minus openings) in both files, and report `min(100, recompiled / original)`. That number is a count. A W310X60 that comes back as a solid box counts as retained. So does a sofa that comes back as a box, and a proxy that comes back as an `IfcBuildingElementProxy` box. The catch-all guarantees every element produces a record, so on any file the importer and compiler do not crash on, retention is near 100% by construction (**reasoned**). The cap at 100 hides the other direction: a compiler that adds elements (rebar, sleeves, generated proxies) would make up for ones it lost. What the 100% median says is that the pipeline runs end to end on most files and keeps one thing per thing. That is worth something, and it is not fidelity.

## What "visual fidelity" scores

The 85.5% figure in the post is the median, over 255 models, of a per-model "macro average visual match" (**reported**; the README links a Kaggle notebook I could not run from here). The code behind it is in the repo, **read** from `tools/render_visual_regression.py`:

1. Pick up to 20 elements per class, 50 per model, deduplicated by type.
2. Mesh the original element with IfcOpenShell, and find the matching debim element by its tag.
3. Render each mesh **alone**, in matplotlib, at a fixed 30-degree elevation and 45-degree azimuth, 300 by 300 pixels, on a dark slate background. Before rendering, **centre each mesh on its own bounding box and scale it so its largest side spans 1.6 of a 2-unit frame**.
4. Compare the two pictures pixel by pixel. A pixel differs if its mean RGB difference exceeds 25. The score is 1 minus the share of differing pixels; 85% or more is a "PASS".
5. A missing match scores 0. Average per class, then across classes.

Step 3 makes the score blind to position and to size. Step 4 counts the background. Together they mean an element that lights a small share of the frame agrees with almost anything on the rest. The README calls 85% "the international standard benchmark"; I know of no such standard for this metric, and none is cited.

I rebuilt steps 3 and 4 exactly, in matplotlib with the same colours, view, normalisation, threshold and resize, and ran them on 50 elements, one per element type, from the four samples (**measured**). For each I compared the real element against several things that are not it. The toy below shows the mechanism in 2D; the table under it is the real run.

<PixelScore />

The median element lights 7.8% of the frame. Against its own bounding box it scores 96.8%, and 48 of 50 pass. Against itself made 3 times bigger and moved 40 m it scores 100%, every time, because of the normalisation. Against a blank frame, an element that is not there at all, it scores a median 92.2%, and 42 of 50 pass. Only a unit cube, which fills most of the frame, drops the score to 68.0%.

So an 85.5% median is below what an empty picture gets (**reasoned** from the two numbers, with one caveat). The caveat: in debim's benchmark a missing element scores 0, not the blank-frame score, so its 85.5% is a mix of matched elements scoring high and unmatched ones scoring nothing, averaged across classes. My blank-frame number is a baseline for the metric, not a reconstruction of their run. It does say what the metric can see: almost nothing about shape and nothing about placement.

It also explains the README's table (**reasoned**). Its best classes are cable trays at 99.1%, beams at 95.0% and bracing members at 94.2%: long, thin things that light the fewest pixels. In my run the Duplex's W310X60 beam lights 1.1% of the frame, and a blank frame scores 98.9% against it. The beams score highest under a metric that cannot tell an I-section from a rectangle, on the class where the importer writes a rectangle.

The same script computes a 3D bounding-box IoU between the original and the debim element, which does see position and size. The notebook reports it as a separate line; the headline uses the pixel score.

## What I would trust it with

None of this makes debim useless. It makes the importer a lossy summary with an honest-sounding name, and the benchmarks measure that it runs, not how lossy it is. What I would and would not do with it, **reasoned** from the above:

**Authoring a new building, early.** A structural grid, storeys, columns, beams, walls and slabs in a few thousand tokens of YAML, compiled to IFC4 and taken off deterministically, is a sensible format for an agent to write and for a human to review in a diff. The house example is 1,045 tokens. The QTO and costing tools are the right pattern: the model edits text, code does arithmetic. Check its take-off against a hand calculation on one small model before you trust a bill of quantities from it, and set the reinforcement yourself.

**Asking questions about an existing model.** Do not convert. Give the agent IfcOpenShell query tools, or an MCP server that wraps them, and let it read the classes, property sets and quantities it needs. Those are exactly what the importer drops. A record-per-element summary is still a useful first message (the 27,580-token Duplex fits easily), as long as everyone knows it is boxes.

**Round-tripping.** Not today. A model that goes IFC to debim to IFC comes back with steel beams as solid boxes, generic storey heights, no property sets and new ids. If a downstream tool keys on `GlobalId`, it will not find its elements.

**Reading the benchmarks.** Treat "retention" as "did not crash and kept the count", and "visual fidelity" as a number an empty frame passes. If the project wants a fidelity figure that means something, the pieces are already in its own script: report the bounding-box IoU per class, add volume and surface-area ratios of the meshes, and render both meshes in the original's frame instead of each in its own.

## Where it sits

Text-first, agent-facing building and geometry tools are having a moment. On this site, [NVIDIA's usd-brep](/articles/usd-brep-nurbs-kernel) puts a NURBS kernel under a USD schema with its own MCP surface, [Steve's desktop CAD](/articles/steve-desktop-cad) gives an agent a Python feature API over OpenCascade, and [KOLC+](/articles/kolc-3dgs-bim) puts Gaussian-splat scans beside BIM in a closed cloud. debim's bet is the simplest of these: no kernel at all, just a grid, boxes, layers and a compiler, which is why it fits in a context window and why it cannot hold a wide-flange beam.

## Reproduce it

The measurements need only PyPI packages, not debim:

```bash
python3 -m venv venv
./venv/bin/pip install ifcopenshell tiktoken pyyaml numpy matplotlib pillow
git clone --depth 1 https://github.com/PRIDA-TAKON/debim
ls debim/tests/fixtures/*.ifc   # the four samples used here (plus Duplex_MEP)
```

The token count is the whole file as text through `tiktoken.get_encoding("o200k_base")`. The fill ratio is, per element:

```python
# measure.py (excerpt): mesh volume over bounding-box volume
st = ifcopenshell.geom.settings(); st.set("use-world-coords", True)
sh = ifcopenshell.geom.create_shape(st, elem)
v = np.array(sh.geometry.verts).reshape(-1, 3)
f = np.array(sh.geometry.faces).reshape(-1, 3)
a, b, c = v[f[:, 0]], v[f[:, 1]], v[f[:, 2]]
vol = abs(np.einsum("ij,ij->i", a, np.cross(b, c)).sum() / 6)
fill = vol / np.prod(v.max(0) - v.min(0))
```

For the pixel score, copy `render_mesh_to_image()` and `compare_images()` from `tools/render_visual_regression.py`, which is what I rebuilt, and compare an element's render against `np.full((300, 300, 3), (15, 23, 42))`, the empty slate frame. If you can run the repo itself, `debim import` on the Duplex followed by a token count of the YAML would replace my stand-in with the real number; I would like to know it.
