bimtex
v0.3.5
Published
A markup language for buildings that AI agents can write. One file compiles to coordinated plans, elevations, sections and 3D.
Downloads
1,343
Maintainers
Readme
bimtex has zero required runtime dependencies. Optional packages power DXF, PDF, GLB, OBJ and fast in-process PNG export; they install by default, and --omit=optional keeps the compiler, IFC and SVG output. The agent harness (bimtex/drafter) rides on optional peer dependencies instead — npm i ai zod @openrouter/ai-sdk-provider to use it, zero bytes if you do not.
A building is a model. Drawings are its shadows.
The problem, from first principles
A set of construction drawings is many views of one building. A floor plan, four elevations, two sections, a roof plan, a site plan — eight or more drawings, each showing the same walls from a different position.
Any two of those views can contradict each other. With eight views there are twenty-eight pairs that must agree, and every one of them is a chance for the window on the elevation to sit at a height the section says is solid wall.
Ask a language model for those eight drawings and it will produce them one at a time. By the third it has contradicted the first — and it has no way to know, because nothing in its process compares view four against view one. The contradictions are not a quality problem to be fixed with a better model. They are a structural consequence of generating views independently.
There is exactly one way to make the contradictions impossible rather than merely unlikely:
Do not generate the views. Generate the building, and derive the views from it.
Two views of one model cannot disagree for the same reason that two photographs of one chair cannot disagree about how many legs it has. That is the whole idea. Everything below is a consequence of it.
model.ndjson
│
┌──────┴──────┐
compile validate ──→ "wall f-p2 on level 1 has nothing under it"
│
solids ─────┬──────────┬──────────┬──────────┬──────────┐
PLAN ELEVATION SECTION AXONOMETRIC 3D
(a real cut) (projection) (a cut) (projection) (three.js)The second idea: describe intent, not coordinates
Deriving views only helps if a model can actually be written. So the format asks for the thing language models are good at — what the building is — and computes the thing they are bad at, which is where everything sits.
Here is a minimal, executable excerpt from a real example. Its final line is an exterior wall:
{"t":"footprint","id":"building","x":0,"y":0,"w":12,"d":9}
{"t":"level","id":"g","name":"Ground","elev":0,"height":3}
{"t":"wall","id":"n","level":"g","side":"north","type":"exterior","thickness":0.3}There are no coordinates in it. Not one. Where that wall starts and ends is worked out by the compiler from the building's footprint and the word north. In the restaurant example, 33 of 84 lines carry no position at all.
The compiler does not merely place things — it generates them:
one parking line → 18 solids (stalls laid out from a count, accessible ones widened)
one street line → 14 solids (carriageway, kerbs, lane markings)
one wall line → 5 solids (because the doors and windows cut it into pieces)
84 lines of model → 141 pieces of geometry → 5 drawing sheetsThis is what makes the format writable. "The north wall is a 300 mm exterior wall" is a sentence a model gets right. "The north wall runs from (10.0, 15.0) to (26.5, 15.0)" is arithmetic, and arithmetic is where the mistakes are. The schema deliberately keeps the arithmetic on our side of the line.
The same principle covers heights. A door is 2.1 m, a worktop is 0.9 m, a fridge is 1.8 m. Nobody types those — the library knows them. Which is also why a 2D plan can be lifted into three dimensions at all: a floor plan is a building model that lost one axis, and putting it back is a lookup table plus convention.
Install
npm install bimtexQuick start
import { compile, parseNdjson, drawPlan, drawElevation } from 'bimtex';
const { entities } = parseNdjson(`
{"t":"project","id":"cabin","name":"Cabin","unit":"m"}
{"t":"footprint","id":"fp","x":0,"y":0,"w":7.2,"d":5.4}
{"t":"level","id":"g","name":"Ground","elev":0,"height":2.7}
{"t":"room","id":"main","label":"Living","level":"g","x":0.3,"y":0.3,"w":6.6,"d":3.2}
{"t":"wall","id":"n","level":"g","side":"north","type":"exterior","thickness":0.3}
{"t":"wall","id":"s","level":"g","side":"south","type":"exterior","thickness":0.3}
{"t":"wall","id":"e","level":"g","side":"east","type":"exterior","thickness":0.3}
{"t":"wall","id":"w","level":"g","side":"west","type":"exterior","thickness":0.3}
{"t":"opening","id":"d1","on":"s","kind":"door","at":"center","width":0.9,"room":"main"}
{"t":"opening","id":"w1","on":"n","kind":"window","at":"center","width":1.8,"sill":0.9,"head":2.3}
{"t":"furniture","id":"f1","type":"sofa","level":"g","x":1.0,"y":1.2,"w":2.1,"d":0.9}
{"t":"roof","id":"r","type":"gable","ridgeAxis":"x","pitch":0.45,"overhang":0.4}
`);
const model = compile(entities, { detail: 1 }); // 0 MASSING · 1 STANDARD · 2 DETAILED
drawPlan(model, model.levels[0]); // floor plan, cut at 1.20 m
drawElevation(model, 'south'); // the elevation with the door in itOr from the command line, without writing any code:
npx bimtex model.ndjson --detail 1 --dxf --pdf --ifc --glb --obj -o drawings/That writes the default sheet set — general arrangement, plans, elevations, sections and axonometrics — as SVG, editable millimetre DXF and vector PDF, plus model.ifc, model.glb and model.obj. Detail 1 is the default; detail 0 preserves simple massing and detail 2 adds secondary parts plus procedural texture in the 3D viewer. Plans and validation remain unchanged across all three.
The same exporters are public API:
import { compile, drawPlan, exportDxf, exportGlb, exportIfc, exportObj, exportPdf } from 'bimtex';
const compiled = compile(entities);
const ifc = exportIfc(compiled, { fileName: 'cabin.ifc' }); // string
const glb = await exportGlb(compiled); // Uint8Array
const obj = await exportObj(compiled); // string
const plan = drawPlan(compiled, compiled.levels[0]); // SVG string
const dxf = await exportDxf(plan); // string
const pdf = await exportPdf(plan, { title: 'Cabin plan' }); // Uint8ArrayWhat comes out
| Output | Format | Produced by | Notes |
|---|---|---|---|
| Floor plan | SVG | drawPlan() | a real horizontal cut at +1.20 m, looking down |
| Elevation | SVG | drawElevation() | orthographic projection, one per face |
| Section | SVG | drawSection() | a real vertical cut; station chosen to avoid walls |
| Axonometric | SVG | drawAxonometric() | painter-sorted, no 3D engine involved |
| Site plan | SVG | drawSitePlan() | parcel, setbacks, computed coverage, utilities |
| Egress plan | SVG | drawEgressPlan() | exits, graph-traced paths, travel distance and occupant load; opt-in only — buildSheetSet(model, { egress: true }) — because the occupant-load factors are quoted from building code, and qualified review still governs |
| Circulation diagram | SVG | drawCirculationDiagram() | optional public-to-private design reading; never bundled and never for construction |
| 3D model | Three.js scene | website/components/ModelThree.tsx | one pinned live renderer shared by Playground, docs and examples |
| Web interchange | GLB / glTF 2.0 | exportGlb() | the same scene, PBR materials and authored looks; Y-up with eid/kind node extras |
| BIM interchange | IFC4 | exportIfc() | strict spatial hierarchy, architectural elements, typed property sets, material associations and stable GlobalIds |
| Universal mesh | OBJ | exportObj() | metre-based z-up geometry and semantic object names; no MTL, PBR materials or BIM data |
| CAD drawing | AutoCAD 2013 DXF | exportDxf() | editable millimetre linework, Unicode labels and semantic CAD layers |
| Issued drawing | vector PDF | exportPdf() | SVG geometry stays vector; CJK glyphs are outlined for font-independent hand-off |
| Diagnostics | JSON | validate() | machine-readable, addressed to whoever wrote the file |
SVG is the primary output, not a fallback. Drawings have to render on a server, print at a stated scale, embed in a static page and export to vector PDF. WebGL does none of those. 3D is one consumer of the geometry, not the destination — the drawing engine contains no reference to three.js at all.
The one rule that generates most of the others
A plan is a cut, not a picture of a plan.
Door openings appear as gaps because the cutting plane passes through them. A window with a 1.55 m sill draws dashed because the 1.20 m cut passes below it — which is the convention a drafter would use, arrived at by cutting rather than by remembering to. Stair break lines, poché, section depth: all consequences, none of them decisions.
The same rule keeps generating. Two gable wings that overlap derive the valley between their planes — classified by drainage, drawn solid on the roof plan and dashed overhead on the floor plan, never authored. An attic bedroom declared with ceiling:"roof" shows a raked ceiling in section because its volume is clipped by the roof's underside, not because anyone drew a sloped line. A hillside house reads one storey uphill and two downhill because grade is a set of planes the elevations sample, not a single number they assume. A dome's section is a curve because the roof's profile is data — a ten-point formdef — and the cut evaluates it.
Validation is the point
A model that renders is not a model that is correct. The validator is where low hallucination actually comes from — not from a smarter language model, but from mistakes having nowhere to hide.
✗ [stair/through-wall] stair "s1" runs through wall "p1" (overlap 0.15 × 1.00 m)
→ move the flight 1 clear of the wall, shorten its run, or put an opening in that wall
✗ [opening/too-tall] "W3" reaches 2.85 m but storey "first" is only 2.5 m high
→ lower head/spring, or raise the storey heightEvery message names the entity and states the fix, because the reader is usually the model that wrote the file. A diagnostic a model cannot act on is a diagnostic that does nothing.
Errors are the small half. A rule blocks a release only when a violation would make two derived sheets disagree — an opening the elevation cannot place, a room stacked inside another so the area schedule counts one floor twice. Everything the checker merely finds unusual is reported and released, because architecture is inventive at the edges and a checker that cries wolf teaches a writer to rearrange a building around a remark.
Writing the examples in this repository, the validator caught a floating partition, a mezzanine modelled as a stacked storey, and — twice — a bug in the validator itself calling a correct model broken.
Then the spatial stair rules were added, and four of the five multi-storey examples failed at once: a flight running through a partition, two arriving inside bedrooms, one arriving in mid-air off a mezzanine, and every one of them arriving under a solid slab with no opening to come up through. The connectivity graph later found the same failure one level deeper: 13 gallery models contained unreachable rooms even though every sheet looked finished.
None of those drawings looked wrong. They rendered cleanly with correct tread symbols. The buildings were impossible and the drawings were fine — which is precisely the failure this project exists to prevent, found living inside the project's own examples.
The generalisation, now in the spec: a rule that only checks an object against itself will pass on models that are spatially absurd. Every entity needs at least one check relating it to something else.
Gallery
Every drawing below was derived. None was authored. See the whole gallery →
Corner House · 4 bed / 2.5 bath

A complete house: two storeys, a stacked stair, a pitched roof, furniture, and a site. This is the model the style plate and the README hero are cut from, so it exercises most of the drawing vocabulary at once.
It is also where the appearance boundary is proved. Delete the five lookdef
lines — the siding courses, the glass, the crown on the oak — and every sheet
comes out byte-identical, because a drawing never reads the appearance layer.
The acceptance suite makes exactly that cut and compares all eight sheets.
Glass Pyramid

Geometry, appearance and the line between them in one file a stranger can read
cold. The glazing lattice is a bimtex:TriLattice extension node: the model
names it, the renderer subdivides the compiled roof planes into panes, so the
glass fills exactly the envelope the sections were cut from. Its one line of
lighting, "light":"overcast", shows the other half of the def layer: light is
a closed vocabulary, so a model picks a whole tuned rig by name and never gets
to invent physics.
What "professional" costs
Every sheet carries a border, a title block, layered dimension strings, section markers with view arrows, level markers and real plan symbols. None of that is decoration — it is the difference between a drawing a builder accepts and a diagram that looks generated.
| Mark | Why it is not optional | |---|---| | Plan symbols | A grey rectangle labelled "sofa" is a placeholder. Conventional shapes — pillows on a bed, burners on a range, an inner line in a bathtub, treads and an UP arrow on a stair — are most of the perceived quality | | Layered dimensions | NCS hierarchy: openings, then structural bays, then overall. One overall dimension reads as a sketch | | Section markers | A section that does not say where it was cut is unusable. Circle, letter, arrow pointing the way you look | | Level markers | ▽ +0.00 / +2.70 / +7.51 on every elevation. Heights are the elevation's whole reason to exist | | Structural grid | Numbers across, letters down, bubbles both ends. Every question on site is "which one" — "the column at 3/B" is answerable, "the third from the left" is not | | Hatch by material | Concrete stipples, masonry rules at 45°, timber shows grain. Flat black says "wall"; a pattern says what it is made of | | Door & window schedule | Tags on the plan, table in the corner, identical openings collapsed to one row with a count — because the table is what gets priced and ordered | | Section depth | What the plane cuts draws heavy; what lies beyond draws thin. Without the second half a section is a flat black band | | Label placement | Room names dodge furniture by searching for the emptiest patch. A name printed across a bed is the loudest possible tell | | Graphic scale bar | A number in a title block does not survive photocopying and cropping. The bar does |
The format
NDJSON — newline-delimited JSON, one entity per line. Not one big JSON object, and not a DSL.
Three properties fall out of that choice, and all three matter:
It streams. A model can be parsed, validated and drawn line by line as it arrives. Someone watches the building appear instead of waiting for a closing brace.
It degrades gracefully. A malformed line costs you one entity. A malformed brace in a monolithic JSON object costs you the building — and long generations drop braces.
It edits locally. "Raise this wall to 3 m" is a change to one line. The model returns forty tokens, not the whole file.
import { applyOps, compile, diffCompiled } from 'bimtex';
const result = applyOps(entities, [
{ op: 'set', id: 'north-wall', top: 3 },
]);
if (!result.errors.length) {
const preview = diffCompiled(compile(entities), compile(result.entities));
console.log(preview.changedEids, preview.quantities.delta);
// result.inverse is an exact undo batch.
}Edit batches are atomic and use only set, move, add and remove. They travel as their own NDJSON stream, never as entities inside the model. See the typed edit contract.
It is also structurally the same shape as the IFC STEP emitted by exportIfc() (#12= IFCWALL(...) — one entity per line, referenced by id). The adapter keeps each bimtex id in typed Pset_Bimtex values, attaches standard common property sets and materials, and derives stable IFC GlobalId values for objects and relationships. A later round-trip therefore has explicit identity to recover instead of guessing from geometry.
Full reference: docs/spec/00-entity-schema.md. If you would rather see it assembled line by line, the tutorial builds one restaurant — lot, street, car park, shell, rooms, openings, fitout — in ten steps, drawing and validating each one.
The vocabulary
A model is written out of a fixed set of names. The library ships:
- 52 entity types —
wallopeninglevelroomroofformdefdormerstairrampslabcolumnparcelroadsetbackparkingstreetfencepoolsigntreeand the rest - 62 plan symbols —
sofabed-queenfridgerange-hoodwalk-intoiletpallet-rackcheckout… each one a function of the size you give it, not a stored picture, which is why a 3.2 m counter and a 1.2 m counter are both drawn correctly - 37 materials — carrying a colour for 3D and a hatch pattern for the drawings
The full list is in the schema reference, generated from the source so it cannot drift.
Extending it
Three tiers, with honest trade-offs:
| | How | Reusable | Real in the drawings |
|---|---|---|---|
| One-off | asset with parts[] | no | no — draws as a plain block |
| Your own object | module + instance | within the file | yes — built from named types |
| Everyone's vocabulary | contribute a symbol | globally | yes |
A module is a reusable group defined inline and stamped with instance:
{"t":"level","id":"g","name":"Ground","elev":0,"height":3}
{"t":"module","id":"unit-1br","entities":[{"t":"room","id":"living","level":"template","x":0,"y":0,"w":5.5,"d":7.2},{"t":"furniture","id":"sofa","type":"sofa","level":"template","x":0.6,"y":0.7}]}
{"t":"instance","id":"unit-01","module":"unit-1br","level":"g","x":4.25,"y":5.3}Twelve apartments cost one unit definition; downstream drawing and validation still see twelve ordinary, individually addressable flats.
Contributing a plan symbol is the best first pull request in this repository — each one is a pure function of ten to fifteen lines with no dependencies, and you can see the result immediately. See CONTRIBUTING.md.
For LLM integration
import { getSyntax, getVocabulary, getExamples,
validateNdjson, inspect, buildPromptContext, toNdjson,
getOpSchema, BIMTEX_OP_TOOL,
getModelSchema, BIMTEX_MODEL_TOOL } from 'bimtex/ai';
import { renderPng } from 'bimtex';getSyntax() returns the schema as one card — about 12,200 tokens, cacheable, and complete. There used to be abridged sizes; they are gone, because every one of them cut the field tables, and the field tables are where the units live. getVocabulary() returns every legal name — without it a model cannot discover that fridge already exists and will hand-build one out of boxes. validateNdjson() closes the loop: the model writes, receives diagnostics addressed to it, and repairs its own output. It accepts NDJSON text or the entities array straight off a tool call. (getExamples exists and the shipped drafter deliberately does not use it: measured across four runs, worked examples were half of everything the model read, and handing over a finished building of the same typology answers a different question than the one an authoring loop is asking.)
An agent can also inspect what it wrote as one labelled review image:
write model.ndjson
const { ok, diagnostics, site, review } = inspect(source, { review: true })
look at await renderPng(review.svg), using review.tiles to identify each tilebimtex supplies model inspection, the labelled review sheet and PNG rendering; the agent harness supplies the eyes. inspect() parses, compiles once, validates, returns the compact model index and site-planning metrics, and can include a selected review view set such as views: ['site', 'axonometric']. Pass split: true to receive review.sheets, one self-contained { svg, tile } image per resolved view, while review.tiles remains the ordered manifest. renderPng() lazily uses optional sharp, then falls back to the system rsvg-convert binary. The zero-install scripts/render-review.sh path remains available; on macOS install librsvg with brew install librsvg.
The CLI equivalent is npx bimtex model.ndjson --review --png -o review/, which writes review.svg, review.json, review.png and diagnostics.json in one compile.
Do not ask for the wire format — bind it
The cheapest mistake a model can make is the most expensive one to read. In the benchmark below, one response wrote type where the format says t, on every line, and a single mistake produced 96 findings. No amount of prose prevents that.
BIMTEX_MODEL_TOOL is an OpenAI-compatible function definition for writing a whole model; pin it with a forced tool choice on the first step of an authoring turn and t becomes a per-branch constant the model cannot spell wrong. BIMTEX_OP_TOOL does the same for edits, and getModelSchema() / getOpSchema() hand back the bare JSON Schema for integrations that have no tool channel.
Both schemas are projected from lib/entity-schema.mjs, never hand-written beside it: required fields, enums, counts, positive dimensions and id references come from the same registry the validator reads. A tool schema that disagreed with the validator would be worse than none, so test/acceptance-model-tool.mjs walks all 52 entity types field by field and puts every shipped example through the schema — 493 entities, zero rejections.
const model = JSON.parse(toolCall.function.arguments); // { entities: [...] }
const report = validateNdjson(model.entities); // diagnostics, no serialising step
const source = toNdjson(model.entities); // the .ndjson fileOr take the whole loop
Everything above is the primitives. bimtex/drafter ships the loop built on
them — the one the studio runs: a session
the host makes, seven tools whose refusal gates enforce design-before-drawing
and look-before-judging, drawing rounds closed by an independent crit that
grades likeness against the scheme's own rubric, and a stop rule made of
counts a model cannot flatter. It is host-agnostic — a browser tab and a
server run the same code and differ only in the two services they inject
(rasterize, required; renderShots, optional) and in who supplies the
OpenRouter key. Requires the optional peer dependencies named above; the
contract is specified in docs/spec/11-drafter.md.
import { createSession, runJob } from 'bimtex/drafter';
await runJob({
session: createSession(), hosts: { rasterize },
model: 'google/gemini-3.5-flash', critModel: 'google/gemini-3.5-flash',
apiKey, brief: 'A one-room garden studio, door to the south.',
ceiling: 3, emit: event => events.push(event),
});LLM benchmark
A fixed set of 20 plain-language briefs across residential, retail, restaurant, industrial, office, site, civic and multifamily typologies was run through buildPromptContext({ detail: "minimal" }) — an abridged-card option that has since been removed; the current signature takes { typology, examples, maxTokens, vocabularyFilter } — with Codex gpt-5.6-sol on 29 July 2026. After the generic entity-schema pass was added, the saved, unedited responses were revalidated without another model call:
| Metric | Earlier geometric-only scoring | v0.2 schema revalidation | Target | Current pass | |---|---:|---:|---:|:---:| | Lines that parse as NDJSON | 100.0% (1,246 / 1,246) | 100.0% (1,246 / 1,246) | >95% | yes | | Models valid on the first attempt | 55.0% (11 / 20) | 30.0% (6 / 20) | >50% | no | | Models valid after one diagnostic repair | 100.0% (20 / 20) | 55.0% (11 / 20) | >85% | no |
The older 55% / 100% result is retained only as historical evidence; it was too optimistic because unknown entity types and missing structural fields were not rejected. Under the v0.2 schema, the leading first-draft categories are schema/type (96 findings, concentrated in one response that used type instead of t), schema/required (38), and door/blocked (10). This is now the honest baseline: syntax is reliable, but authoring and repair prompts still need work before the validation targets are met.
The harness, all prompts, unedited model responses, repaired models and validator diagnostics are kept in test/benchmark-output/.
These numbers are the prompt-only path: both harness providers are text-in/text-out CLIs, so the model is asked for the wire format rather than bound to it. node test/benchmark-llm.mjs --tool-schema reruns the same 20 briefs with getModelSchema() carried as a binding contract, writes to its own directory, and prints a first-pass / one-repair / input-token comparison against the baseline above. The default run is left untouched so the table stays reproducible from the saved prompts.
npm run benchmark:revalidate replays the saved responses through the current validator without calling a model at all. It is the cheap way to ask whether a new rule reclassifies twenty real model outputs.
Architecture
lib/compile.mjs semantics → solids (axis-aligned boxes + parametric surfaces)
lib/draw.mjs solids → drawings (SVG, zero dependencies, no 3D engine)
lib/validate.mjs entities → diagnostics (architectural constraints)
lib/clip.mjs one clip volume (roof-off, level peeling, section — all one primitive)
docs/spec/04-adapters.md shipped native outputs + interchange roadmapThree layers, one direction. The compiler never draws; the drawing layer never validates; the validator never mutates.
Boxes, not meshes. A building is overwhelmingly prismatic, and boxes give exact plan cuts, exact elevations and trivial occlusion sorting for free. Curvature — vaults, arched heads, domes — is the exception and is carried as parametric surfaces, never as mesh soup. A round-arched opening is discretised into slices so that "cut the wall into pieces" keeps working. That is the seam where the model bends instead of breaking.
One clip volume, not four toggles. Roof-off, front-wall-off and floor-by-floor peeling are the same operation — restrict what is drawn to a sub-volume. And so is a section drawing: drawSection(model, 'x', 12.4) means keep everything with x < 12.4 and look north, which is exactly what the 3D clip does. One primitive, several consumers.
Scope
In: the entity schema · geometry compilation · view derivation · validation · lifting 2D plans into models · export mappings.
Out, deliberately:
| Not this | Because | |---|---| | A rendering engine | three.js exists and is better than anything we would write | | Photoreal materials, lighting, IBL | no architectural constraint lives there — and that market is being eaten by image models | | A mouse-driven modeller | that is SketchUp's twenty-year moat; we skip the modelling step entirely | | Structural analysis, energy, MEP routing | each is its own industry | | Free-form curved surfaces | straight lines and simple curves cover the built world |
The line: if a thing has no hard architectural constraint, it does not belong in this library. It decides what this library builds, not how much of it a model may use. Walls must close, upper walls need support, doors live in walls, roofs cover envelopes — checkable, so ours. "Is this furniture layout attractive" is taste, so not ours. "What is this wall's roughness value" is rendering, so not ours.
Status
Pre-1.0, and honest about it:
- Twenty worked examples, each grounded in a real prototype with cited dimensions, all validating clean
- The schema is not frozen. Entity names may change before 1.0; every change will be in the changelog
- The core claim has an honest baseline. All 1,246 saved lines parse, but only 6 of 20 models pass the complete v0.2 validator first time and 11 of 20 pass after one saved repair. Those misses are product evidence, not hidden by the earlier geometric-only score
Licence
MIT. See LICENSE.
