Skip to content

v1.35.0

Latest

Choose a tag to compare

@github-actions github-actions released this 04 Sep 08:21
· 8 commits to main since this release

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.

  1. No trigonometry under src/view/. Math.sin/cos/tan/atan are
    implementation-approximated in ECMAScript — the specification requires only a close result, so
    two engines may differ in the last bits — while Math.sqrt is 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. iso is a yaw of −45° with a pitch of atan(1/√2); axon the 30°/60° plan
    oblique. Two presets, no free angles.
  2. 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.sort is 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.ts takes 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: one Face can carry several DISJOINT solid pieces (a wall ring cut by two openings
    comes back as two separate loops on one cap), and boundaryDepth reads only loops[0].
  3. The view computes no footprint of its own. A wall solid is the joined wall outline — the
    very EdgeLoop[] the plan view lowers, consumed before emitLoops narrows 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 same openingCut the 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-GLAZ are declared in the DXF LAYER table only on a drawing
    that uses them.
    V- sits outside the A-/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 caught A-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-mcp 0.2.15 — a twelfth consecutive version-bump-only
    release
    (git diff v1.34.0..main -- packages/mcp is 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.md moved
    llms-full.txt moved
    grammars/archlang.gbnf moved
    schemas/plan.schema.json moved
    schemas/intent.schema.json unchanged

    The grammar moving is what makes this the sharp case: sill and head are 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.json moved 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.json is unchanged, and
    correctly: a height is not an intent assertion. Dep range re-pinned to ^1.35.0.

  • VS Code extension ChanMeng.archlang 0.24.0 — packaged against core 1.35.0, carrying the two
    new keywords, the three new E_* codes and the --view surface in the bundled server. The
    Marketplace upload is a human web step and is not performed by this repository.