DEV Community

Chan Meng
Chan Meng

Posted on

Floor plans that compile: byte-identical output, errors as data, and an access graph

Floor plans that compile

I compiled the same floor plan twice and diffed the output:

import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { compile } from "@chanmeng666/archlang";

const src = readFileSync("laneway-house.arch", "utf8");
const a = compile(src).svg;
const b = compile(src).svg;

const sha = s => createHash("sha256").update(s).digest("hex");
console.log(Buffer.byteLength(a), sha(a) === sha(b), sha(a).slice(0, 16));
// 40104 true a44e7ed5d5bb1448
Enter fullscreen mode Exit fullscreen mode

Identical. Not "visually identical" — the same 40,104 bytes, the same hash. Across the 37 example plans that ship with the project, 35 recompile byte-identically; the other two import from other files and produced no bytes at all in this in-memory harness, which is a missing file loader rather than a difference in output. Every output quoted in this post is from version 1.57.0 of the package.

That property is not a party trick. It is the thing that makes everything else in this post possible, and it is the thing an image generator cannot give you at any price.

The problem with a picture

Ask a model for a floor plan today and you get an image. An image has one interface: look at it. You cannot diff it, you cannot ask it whether the bathroom is reachable, and when you say "make the bedroom a metre wider" you get a new image where the kitchen also moved, for reasons nobody can name.

ArchLang takes the other road. You write source (trimmed here from the shipped laneway-house.arch example, which adds a bath partition, two more doors, two more windows and the furniture):

plan "Laneway House" {
  units mm
  grid 50
  north up
  dims auto all
  site { street north }

  wall id=w_lane   exterior  thickness 200 { (0,0) (7500,0) }
  wall id=w_east   exterior  thickness 200 { (7500,0) (7500,6500) }
  wall id=w_garden exterior  thickness 200 { (0,6500) (7500,6500) }
  wall id=w_west   exterior  thickness 200 { (0,0) (0,6500) }
  wall id=w_hall   partition thickness 100 { (4200,0) (4200,6500) }

  room id=r_live at (0,0) size 4200x6500 label "Living / Kitchen" uses living kitchen
  strip down at (4200,0) gap 0 width 3300 {
    room id=r_bath size 2500 label "Bath"    uses bath
    room id=r_bed  size 4000 label "Bedroom" uses bedroom
  }

  door id=d_front on w_lane at 2100 width 900 hinge near start swing into r_live
  door id=d_bath  pocket on w_hall at 900 width 800 slide right

  window on w_west   at 2400 width 1200
  window on w_garden at 700  width 1000
}
Enter fullscreen mode Exit fullscreen mode

Coordinates are integer millimetres. Nothing places a door by eye: an opening is pinned to a distance along a named wall, so moving the wall moves the door. "Make the bedroom a metre wider" is one number, and the diff says so.

That compiles to SVG. Also DXF, PDF, PNG, and an ASCII plan for a terminal. Zero runtime dependencies; the whole compiler is TypeScript and runs in a browser as happily as in Node, which is why the playground has no server to send your plan to.

Reading a plan back, without looking at it

Here is the part I care about most. describe() turns the compiled plan into facts:

import { describe } from "@chanmeng666/archlang";

const d = describe(src);

console.log(d.caption);
// "Laneway House" — a 3-room floor plan, 48.75 m² total: Living / Kitchen (27.3 m²),
// Bath (8.25 m²), Bedroom (13.2 m²); 4 doors, 4 windows, entrances via d_front, d_garden.

console.log(d.totals);
// {
//   rooms: 3,
//   doors: 4,
//   windows: 4,
//   floor_area_m2: 48.75,
//   gross_internal_area_m2: 45.99,
//   gross_internal_area_all_levels_m2: 45.99,
//   net_floor_area_m2: 45.05
// }

console.log(d.access.entrances);       // [ 'd_front', 'd_garden' ]
console.log(d.rooms[0].adjacent);      // [ 'r_bath', 'r_bed' ]
Enter fullscreen mode Exit fullscreen mode

Those areas are shoelace arithmetic over the room polygon, not an estimate off a raster. The access graph is a real modeled graph of which door connects which pair of spaces, so "is the bathroom reachable from the front door" is a query rather than a squint.

A text-only model can call this. It never has to render anything to find out whether it built what it was asked for — which is the whole reason this language exists.

Errors are data, and some of them carry the fix

compile() never throws. A broken plan comes back as a result with diagnostics, each carrying a byte span:

const broken = `plan "Demo" {
  units mm
  wall id=w1 exterior thickness 200 { (0,0) (5000,0) (5000,4000) (0,4000) close }
  room id=r1 at (0,0) size 5000x4000 label "Room"
  door id=d1 on w1 at 30000 width 900
}`;

compile(broken).diagnostics[0];
Enter fullscreen mode Exit fullscreen mode
{
  "code": "E_ATTACH_POS_RANGE",
  "severity": "error",
  "message": "Door \"d1\" attachment position 30000 mm is outside the wall run (0…18000)",
  "span": { "start": 170, "end": 184 },
  "fixes": [{
    "title": "clamp the attachment position to 18000",
    "applicability": "machine-applicable",
    "fixId": "attach-pos-range",
    "edits": [{ "span": { "start": 170, "end": 184 }, "newText": "on w1 at 18000" }]
  }]
}
Enter fullscreen mode Exit fullscreen mode

applicability: "machine-applicable" is a promise: the edit can be applied without asking anyone. arch fix walks the diagnostics, applies those edits to a fixpoint, and prints the unified diff it would write. An agent's correction loop is three commands and no image:

npx @chanmeng666/archlang compile plan.arch --json   # { ok, diagnostics, summary }
npx @chanmeng666/archlang fix plan.arch --dry-run    # the exact diff it would apply
npx @chanmeng666/archlang describe plan.arch --json  # did I build what was asked?
Enter fullscreen mode Exit fullscreen mode

Every command takes --json and exits 0 ok, 2 user-source error, 1 IO, 3 bad usage. A typo earns the 3: --jsn gets did you mean --json? rather than being read as a filename.

Soundness is a separate layer

Compiling is not the same as being a sensible building. lint() is the layer that says so:

import { lint } from "@chanmeng666/archlang";

const twoRoomsNoDoors = `plan "Two rooms" {
  units mm
  wall id=w1 exterior thickness 200 { (0,0) (6000,0) (6000,4000) (0,4000) close }
  wall id=w2 partition thickness 100 { (3500,0) (3500,4000) }
  room id=r_k at (0,0) size 3500x4000 label "Kitchen" uses kitchen
  room id=r_b at (3500,0) size 2500x4000 label "Bath" uses bath
}`;

lint(twoRoomsNoDoors).map(d => `${d.code}: ${d.message}`);
// [
//   "W_ROOM_DISCONNECTED: Room \"Kitchen\" has no door or opening — it can't be entered.",
//   "W_ROOM_NO_FIXTURE: Kitchen \"Kitchen\" has no fixtures (sink, counter, stove…).",
//   "W_ROOM_DISCONNECTED: Room \"Bath\" has no door or opening — it can't be entered.",
//   "W_ROOM_NO_FIXTURE: Bathroom \"Bath\" has no fixtures (WC, basin, shower…).",
//   "W_NO_ENTRANCE: The plan has no exterior door or opening — there is no way into the building.",
//   "W_HABITABLE_NO_WINDOW: Kitchen \"Kitchen\" (14 m²) has no window on any of its walls; a room people live in needs daylight."
// ]
Enter fullscreen mode Exit fullscreen mode

That plan compiles perfectly. It is also a building with two rooms you cannot enter. Separating "is it valid" from "is it sound" is a deliberate split, and it is why lint has its own exit behaviour and its own catalogue of codes.

What I am not claiming

Three things in this system are measurements, and I will defend them: reachability (a modeled access graph), exact areas (shoelace arithmetic, or πR² on a round room), and byte-identical determinism (the hash at the top of this post).

Everything else is advisory and the code says so. Clear widths are a nominal width with a constant allowance subtracted. Walk distances come off a coarse quantised grid. The project's own design records call those numbers deliberately coarse and refuse to dress them up, and I would rather tell you which numbers to trust than let you assume all of them.

Some other limits, stated before you find them:

  • It draws a plan, and only a plan. Heights exist in the language but draw nothing, because a floor plan is a horizontal cut. No sections, no elevations.
  • No BIM, no IFC, no round-trip. DXF export is one-way, for a draftsperson to open.
  • No structural, no MEP, no code compliance — the last one is a refusal rather than a gap. Asserting standards compliance is a liability claim this project will not make.
  • A concept tool, not a permit set. Dimensioned, consistent, exportable to real CAD. Not stamped construction documents.
  • Solo maintainer, MIT. Bus factor of one. The licence is chosen so the format outlives me if it is worth keeping.

Try it

If you build something with it, or break it, I would like to know either way.

Top comments (0)