The third dimension, added twice — once as a number nothing draws, once as a picture nothing
measures. A floor plan is a horizontal cut, and until now that cut was all ArchLang had: a wall
carried a thickness and no height, a window a width and no sill. This release adds the missing
datum and then, separately, the first thing that consumes it. They are deliberately independent —
the datum ships with nothing drawing differently, pinned over all thirty shipped examples and
every storey of each, and the view ships taking no part in any measurement, with describe()
and lint() structurally unable to read it.
Two new keywords (sill, head), height reused at three new sites rather than a fourth spelling
invented, three new E_* refusal codes, and one new opt-in flag (--view iso|axon). No
removals. A plan that authors no height and passes no --view is byte-identical in SVG,
describe() and lint() — held as a law on both digests, not asserted.
The two findings worth carrying past this release. No test in this repository had ever
executed an error-catalog example: the drift gate proves docs/error-codes.md reproduces the
catalog, never that the catalog is true, and E_HEIGHT_RANGE's own example documented a refusal
the compiler does not make. test/error-catalog-examples.test.ts now runs every one, holding 95 of
139 to raising their own code and pruning the 44 excuses in both directions — which already
paid for itself, four entries excused on sight turning out to raise their code anyway. And the
SVG backend's per-CAD-layer <g> grouping re-orders nodes within a pass, which a painter's
algorithm cannot survive: the first axonometric render drew every floor plate after every wall and
read as an open box seen from inside.
Added — the opt-in axonometric view
compile(source, { view: "iso" | "axon" }), and arch compile|preview --view iso|axon, draw the
building the plan describes instead of the plan: extruded walls with their doors and windows
cut, floor plates, and every storey stacked into one picture. It produces ordinary 2D
SceneNodes in the existing Scene, so SVG, PDF, PNG and DXF all serialize it with no backend
change at all.
It is a picture, not a drawing, and that is the whole design. describe() and lint() take
no view option and never learn it exists — no area, adjacency, route or diagnostic is derived from
a projection. The view carries no scale, no sheet, no title block, no north arrow and no
dimensions: all four would make it look issuable, and two of them would be false (an arrow points
at a compass direction on a drawing whose plan has been turned; a scale bar measures an axis the
projection foreshortens). test/iso-describe-blind.test.ts makes the case structurally rather than
behaviourally — it greps the five summary modules for any import of src/view/, because a
behavioural check would go green again the moment someone read a view number and had not yet
exposed it.
Nothing about a compile that passes no view moves. Pinned over all thirty shipped examples,
every storey of each, on both the whole-surface digest and the summary-only one
(test/iso-byte-identity.test.ts), against the same measured table the height law uses. That
matters because the change did not stay inside a new directory: it extracted joinWallSet out of
lowerWallSet — the path every wall in every drawing takes — added a field to Scene, put a
branch in the SVG backend's layer grouping and another in the PDF's chrome, and made the DXF
LAYER table conditional.
Three decisions worth not re-litigating.
- No trigonometry under
src/view/.Math.sin/cos/tan/atanare
implementation-approximated in ECMAScript — the specification requires only a close result, so
two engines may differ in the last bits — whileMath.sqrtis exactly rounded by IEEE-754. CI
spans Windows and Linux across Node 18/20/22 and the whole verification system rests on
byte-identical output, so both cameras are written as square-root expressions and a grep test
keeps them that way.isois a yaw of −45° with a pitch ofatan(1/√2);axonthe 30°/60° plan
oblique. Two presets, no free angles. - The painter's order is TOTAL, and its depth is quantised through
fmt2— the formatter the
SVG coordinates themselves print at — so two faces that serialise identically are equal to the
sort rather than merely close, and cannot swap on a 1e-15 difference no output byte records. The
tie-break tail (element id, loop index, face index) is unique per face by construction. The
reference implementation this borrows from sorts on depth alone with no tie-break;test/iso-sort.test.ts
proves the difference by REVERSING and shuffling the input, not by checking that ties are broken
(Array.sortis only stable with respect to the array it was given).
That family of laws compares the sort to a perturbation of its own input, so all of them stay
green on an order keyed off the wrong quantity — the general shape of backlog G.11. So
test/iso-painter-order.test.tstakes its expected answer from the geometry instead: over all
thirty examples in both presets it re-derives each drawn face's true depth RANGE from its own
vertices and requires that no face is drawn before one it is entirely behind, with screen overlap
over-approximated by bounding box so the gate errs strict. The shipped corpus scores zero,
and the same detector run over the reversed draw order scores 10,761 — which is what makes
the zero a measurement rather than a vacuous pass. It also covers the case the centroid key is
weakest at: oneFacecan carry several DISJOINT solid pieces (a wall ring cut by two openings
comes back as two separate loops on one cap), andboundaryDepthreads onlyloops[0]. - The view computes no footprint of its own. A wall solid is the joined wall outline — the
veryEdgeLoop[]the plan view lowers, consumed beforeemitLoopsnarrows it — so junction
trimming, exact mitres and opening subtraction are identical in the two drawings by construction
rather than by agreement. An opening's hole is the sameopeningCutthe plan subtracts, and the
glazing band is that same function asked for a wall 20% as thick, which is right on a curved host
where an inset is an annular sector and not an offset rectangle. Openings get the real
millimetres the v1.35 datum resolved, never a fraction of the wall.
Two findings the work turned up, both fixed where they bit.
- The SVG backend's per-CAD-layer
<g>grouping RE-ORDERS nodes within a pass. A painter's
algorithm cannot survive that: every floor plate was drawn after every wall, and the first render
read as an open box seen from inside. A view now emits one group, in collection order; the plan
view's grouping is untouched, and the DXF export keeps the per-node layer, having no order to
lose. Any future consumer of node order must know this. V-3D-WALL/V-3D-FLOR/V-3D-GLAZare declared in the DXF LAYER table only on a drawing
that uses them.V-sits outside theA-/L-/C-NCS discipline namespace on purpose — a CAD
user freezes by discipline, and an illustrative view is not a discipline's drawing — and making
the rows conditional keeps the "declares no dead layer" guard that caughtA-ROOF's two-release
absence a real guard, while leaving a plan-view DXF byte-identical.
Deliberately not drawn, and named rather than half-answered: the roof (ArchLang stores an eaves
outline, not a pitch, so there is no datum to build a surface from — inventing one is the move
roof itself refuses), furniture, columns, the stair/elevator/escalator shafts, ground,
fences, the site boundary, labels, dimensions, schedules, legends, axes and hatches. Walls and floor
plates are the whole of what is solid. The column is the nearest of the deferrals — it is already a
solid with a footprint and a resolved height, and examples/museum.arch draws eleven in plan and
none here. And the painter's algorithm has no answer for two interpenetrating solids.
Surface changes. New CompileOptions.view (folded into the compile cache key) and an optional
Scene.view read only by the two backends that draw page chrome; both additive. CompileResult
gains no key — and a multi-storey plan under --view returns no pages, because the view is
one drawing of the whole building rather than a set of sheets, so -o house.svg writes exactly
that file. New --view <iso|axon> on compile and preview only (not watch, not batch), with
four exit-3 refusals rather than a silent fall back to the plan: an unrecognised value, with a
did-you-mean; --view with -f txt / preview --ascii, since the ASCII backend draws a plan and
would print a meaningless grid from a projection; --view with --level, because there is no
per-storey page to narrow to (and the generic handler, seeing a view's empty level list, told an
author that a plan with two level blocks "declares no level blocks (it is single-storey)" — a
confident wrong answer about their own source); and --view with --overlay, because an overlay
draws a measurement and nothing measured reaches this drawing. New public exports toIso, cameraFor,
isViewName, projectedArea2, VIEW_NAMES, VIEW_LAYERS, VIEW_LAYER_NAMES and the ViewName,
Camera, Point3 and Projected types. New page docs/axonometric.md. Backlog P3-3 closes;
P3-2 stays open, and now records what building the first consumer of the height datum discovered:
nothing was missing for a 2.5D slice, but there is still no slab thickness.
Added — the vertical datum layer (heights, sills and heads)
A floor plan is a horizontal cut, so ArchLang has never had a third dimension: a wall had a
thickness and no height, a window a width and no sill, and a consumer asking "how tall is this?"
had nothing to read. It has one now, and it is deliberately a datum rather than a feature.
Nothing draws differently. That is the headline, not a caveat: it is what makes the layer
cheap to adopt, and it is pinned rather than asserted. test/height-byte-identity.test.ts covers
all 30 shipped examples, every storey of each, on both the whole-surface digest (SVG +
describe() + lint()) and the summary-only one, against numbers measured on f4548db before a
line of src/ moved. npm run gen:example-svgs re-renders the twenty committed drawings and git
reports no change, which is the constructive half of the same claim.
The language. height is reused at three new sites rather than a fourth spelling being
invented for the same idea — a plan setting, a level header clause, and a wall clause — and
exactly two new keywords are added, sill and head, as trailing clauses of window / door /
opening. All take a full expression, as thickness does. The fallback chain is wall → level →
plan → 3000, with one implementation (ResolveCtx.storeyHeight, fed by levelPlanFor folding a
storey's height into its synthetic plan) so it cannot have two.
Elevation ACCUMULATES the storeys below; it is not level × storey_height. The two agree on a
building whose storeys share a height and disagree on exactly the plans that write level … height
— a 3600 mm ground floor puts the first floor at 3600 however tall the floors above it are.
Three refusals, never clamps (the E_ROOF_* precedent), each with a machine-applicable fix:
E_HEIGHT_RANGE (at or below zero, or above 100 m), E_SILL_ABOVE_HEAD, and
E_OPENING_ABOVE_WALL — measured against the host wall, not the storey, so a 2400 head is fine
in a 3000 storey and refused in a 2200 parapet, and its hint names the plan-level height setting
when the wall inherited its own. A window sill of exactly 0 is legal and means a floor-length
window, which is why the range check is two predicates rather than one > 0 and why a sill's
refusal says "0 or greater" where every other height's says "greater than 0". What
E_HEIGHT_RANGE deliberately does not catch is a unit slip: height 3 is three millimetres,
inside the range, and a compiler that guessed the author meant metres would be inventing a number.
Fixed — a catalog example that documented an error the compiler does not raise
E_HEIGHT_RANGE's own arch explain example was wall … height 3 { … }, commented "3 mm, not
3 m", and it raises nothing: three millimetres is inside the range, and the entry's prose claimed
a unit slip is caught when the paragraph above says it is not. Both are corrected.
Nothing in the repository could have caught it, so a gate now does. test/explain.test.ts
checks each catalog field is non-empty; its drift check proves docs/error-codes.md reproduces the
catalog, never that the catalog is true; test/docs-fences.test.ts skips these snippets on purpose
(gen-error-codes.ts emits them as arch static, since v1.26 when 104 of them rendered parse-error
cards on the public page); and test/spec-forms.test.ts's negative corpus is hand-written in that
file and never reads ERROR_CATALOG[code].example. No test had ever executed a catalog example.
test/error-catalog-examples.test.ts now runs every one and requires it to raise its own code, or
to be named with a reason. 95 of the 139 are held to it; the 44 excused are the intent-channel
codes (raised by validateIntent, unreachable from .arch source), the import codes (they need a
World), the Plan JSON codes, E_PNG_DEPENDENCY, and one-line fragments whose code needs a whole
building. The excuse list is pruned in both directions, which already paid for itself: four
entries excused on sight for containing a literal … turned out to raise their code anyway, and the
reverse check caught all four rather than letting them sit ungated on a guess.
The gating decision, and why it is one boolean. describe() and Plan JSON emit height keys
only when the source authored a height/sill/head anywhere — the doors[].kind rule
(present only when not the default) applied to a whole block. Both surfaces read the same flag,
ResolvedPlan._heightsAuthored, so they cannot disagree, and the flag is whole-PLAN rather than
per-storey: a height written on the second floor alone must not leave two pages disagreeing about
whether the third dimension exists. strip … height <mm> is excluded, because that clause shares
the keyword and means a plan extent.
describe --json gains a top-level heights block (storey_height, elevation, and walls[]
with each resolved height — walls have no summary array of their own) plus head on doors[] and
openings[] and sill/head on windows[]. Plan JSON gains storey_height, WallJson.height
(a vertical height, not a plan extent like a room's height) and sill/head on the
openings, all round-tripping through compile --from-json. arch manifest gains a datum block
carrying the six defaults, because a plan that authors none reports none and an agent still needs
to know what the compiler would have used.
Schema compatibility, measured with ajv (the v1.33.0 method, both directions, over all 30
shipped examples plus a plan that authors heights):
| Direction | Result |
|---|---|
| Backward — the new schema against every payload this core emits | compatible, 0 rejections |
| Forward — the published 1.34.0 schema against the 30 shipped examples | compatible, 0 rejections |
| Forward — the published 1.34.0 schema against a plan that authors a height | rejected |
The zero in the middle row is the byte-identity law showing up in a second place: no shipped example
authors a height, so none emits a new key. The rejection in the third is additionalProperties: false at the root and on walls[]/openings[], on exactly the four new keys — storey_height,
walls[].height, openings[].sill and openings[].head. A consumer pinned to the published 1.34.0
schema therefore rejects payloads from a plan that uses this release's syntax, and must re-pin.
arch fmt round-trips every new clause, checked as a fixed point rather than a string match —
v1.26.1 shipped a formatter that silently returned a pocket door as a hinged one, and the same
failure here would return a 2200 mm parapet as a full-height wall.
Two things this v1 does not do, deferred by name rather than half-answered: there is no slab
thickness (separating structural depth from clear height needs a second number and a rule for which
one every consumer means), and nothing consumes the datum yet — the 2.5D occupancy export and the
axonometric preview it unblocks (docs/backlog.md P3-2 / P3-3) are their own projects.
Two rough edges accepted rather than fixed, both recorded so they are not rediscovered as bugs.
E_OPENING_ABOVE_WALL still fans out one diagnostic per opening when a single plan-level height
is the real cause; only the hint was improved, because deduplicating needs cross-element state the
resolver does not keep. And in Plan JSON, rooms[].height / furniture[].height are plan-Y extents
while storey_height and walls[].height are vertical — the same word for two dimensions. height
is kept on the wall because it is the .arch clause the payload round-trips to and the word a model
has to produce; both schema descriptions say which is which.
Shipped alongside
-
MCP shim
@chanmeng666/archlang-mcp0.2.15 — a twelfth consecutive version-bump-only
release (git diff v1.34.0..main -- packages/mcpis empty), and the sharpest instance of the
pack-time law since 0.2.11. Measured by SHA-256 against the v1.34.0 tag, four of the five baked
resources moved and one did not:Baked resource vs v1.34.0 spec.llm.mdmoved llms-full.txtmoved grammars/archlang.gbnfmoved schemas/plan.schema.jsonmoved schemas/intent.schema.jsonunchanged The grammar moving is what makes this the sharp case:
sillandheadare real tokens, so a
constrained decoder pointed at published 0.2.14's GBNF cannot derive either clause at all —
not mis-taught, unable to emit the syntax.plan.schema.jsonmoved for the four new keys
(storey_height,walls[].height,openings[].sill,openings[].head), so a host reading the
published 0.2.14 schema rejects payloads this core emits.intent.schema.jsonis unchanged, and
correctly: a height is not an intent assertion. Dep range re-pinned to^1.35.0. -
VS Code extension
ChanMeng.archlang0.24.0 — packaged against core 1.35.0, carrying the two
new keywords, the three newE_*codes and the--viewsurface in the bundled server. The
Marketplace upload is a human web step and is not performed by this repository.