Roadmap — the settled plan after the 2026-08-09 platform review

This is the plan of record for work after M9. It exists because a proposed four-layer platform specification (Python API / Rust core / TypeScript client / WebGPU runtime) was reviewed against what actually exists, and the review changed three things: it reversed one decision, rejected two recommendations, and found that most of the proposed platform is already built in sibling repositories nobody had mapped.

What the review found

The proposed platform is ~80% already built, in eight repositories

The single most useful output of the review. MassingCloud holds:

Repository What it is Maps to
ibuilder/massing FastAPI + Postgres + Redis + Celery, RBAC, tenancy, audit, 96 authoring recipes "Layer 1: Python Platform"
massingviser A federated AEC platform in pure Python: plugin kernel, 15 capability tokens "Layer 1", federation
massingifc Framework-agnostic kernel and plugin architecture for a federated AEC platform contracts, plugin host
massing-pdf (MIT) PDF markup, calibrated takeoff, issue pins, revision compare, XFDF/BCF markup, review desk
massing-families (CC0 content) 419 families, 2,769 types, 57 discipline packs, IFC4 family libraries
massingcapture Reality capture for AEC in pure Python, content-first format detection "digital twin", point clouds
massingbill, massingplan Billing (G702/G703), CPM scheduling 5D, 4D
MassingViewer this repository "Layer 3: TypeScript Client"

Settled 2026-08-09: modelmaker is the platform, and this repository integrates with it. massingviser is analysed for what is worth borrowing server-side — its Python geometry pipeline (BVH picking, frustum culling, clash, LOD, crease-aware tessellation), its capability-family contracts, its content-addressed versioning — and not adopted as a second browser viewer. See ADR-0014, which also draws the Reflex boundary: Python and Rust own the server, compiled JavaScript owns the viewport, and a Reflex shell (if adopted) mounts @massing/embed as a React component rather than putting a WebSocket in the snapping path.

Our own ground truth was wrong about one of these. The plan records massingviser as an "unrelated Python project" needing a README disambiguation line. It is a federated AEC platform with a plugin kernel — the closest thing in the org to the proposed Layer 1. That mis-characterisation is corrected here.

So what the proposal genuinely adds is three things: a Rust core, WebGPU rendering, and federation. Its Phase 1 — upload IFC, convert, load one model, tree/properties/search/isolate/measure/saved views, RBAC, signed assets — describes software that already ships.

What was rejected, and why

Decisions taken

The programme, in order

Ordering is by dependency and by blast radius, not by appeal.

1. Renderer seam + federation — one breaking change, not two

Batched deliberately. WebGPURenderer.init() returns Promise<this>, so createViewport becomes async; federation replaces showModel(...) with add/remove and per-model state. Both break the same viewport API, and massing is mid-adoption — so they ship together and massing absorbs one break.

Acceptance:

419 families, 2,769 types, 57 discipline packs, CC0-1.0, IFC4. The four draw verbs (wall, slab, column, plus door/window openings) are not a family library and were never meant to be.

Content is loaded, never vendored. The plan already specifies this: "Asset packs are code-free plugins. One .mvpack manifest serves 3D families, 2D blocks, markup stamps, hatch patterns and sheet templates." So MassingViewer ships the format, the loader and the gallery under MIT; massing-families ships content under CC0.

The integration point is massing's library endpoint, not the catalog repository. Confirmed with the massing session on 2026-08-09, against their tree rather than inferred: massing has already vendored their copy of "fetch_families.py" there, services/data/families/external/ holds 59 files including the architectural-assemblies pack, GET /families/library is live and returns external.packs, and POST /projects/{pid}/families/import copies every IfcTypeProduct in, deduped by class and name. So a connected viewer reads their shelf. Fetching massing-families independently would give two copies of the same library, free to drift — and the offline case is a cached pack, not a second fetcher.

CC0 status, stated because it was worth asking. massing's permitted list is MIT / BSD-2 / BSD-3 / 0BSD / Apache-2.0 / ISC and does not name CC0-1.0 — while they already ship CC0-1.0 content, with "license": "CC0-1.0" recorded in four places in their own manifest. Their written rule is narrower than their shipped reality, which is the same shape of defect as this repository's KNOWN_GOOD tuple reading as a seven-package pin. Treated as accepted on the basis of existing practice; the formal list change is with the repository owner, because a permitted-licence list is not a thing either side should widen quietly.

The licence gate was corrected as part of this review: massing-families was blanket-forbidden on the strength of GitHub reporting NOASSERTION, which is a detection artefact of its two licence files. Only "upstream/" is genuinely off limits — it is a derivative of ibuilder/massing. Blocking 2,769 types of public-domain content for a metadata artefact was the gate being wrong, not cautious.

Landed 2026-08-10: packages/assets — the format, parser and queries, with 18 tests. It reads the real snake_case document shape (modelled on massing-families' own _family_index() rather than invented), maps it to one camelCase type, groups by discipline then category preserving the library's own order, searches label / key / classification, and reports the families the library itself calls L200 proxies so a gallery cannot present them as finished geometry. It performs no I/O — the host owns the transport, which makes a cached offline pack and a live endpoint the same code path. Every entry carries its license through, defaulting from the library rather than blank, because that string follows the object into any model that imports it.

Still to do for item 2: the Build-ribbon gallery UI, drag-to-place, and wiring to massing's endpoint.

Acceptance: DRAFT_ELEMENTS becomes galleries by discipline; the params[] form renders as the gallery flyout and the prompt loop's keywords, one schema three renderings; drag-to-place from the gallery; every placed type carries MF_Library.License through into the model, as the library intends.

3. Sheets, title blocks, and the review desk — adopt @massingcloud/pdf-viewer

The plan pane has no title block. That is accurate and it is a real gapsvg.ts emits a border and the drawing, no title block, no revision, no north arrow, no scale bar. Tier-3 rasterisation found this from the other direction: there was nothing to mask.

massing-pdf is MIT, public, and already on the plan's M0 list as a package to consume. It treats a markup as a record — author, sheet revision, discipline, measurement, spec clause, IFC object, review status — with rendering as one projection and XFDF/BCF/CSV/flattened-PDF as others. That is the review desk this product needs, and rebuilding it here would be waste.

Acceptance: a sheet composes one or more viewports plus a title block from a template; R38-SHEET-MARKUP lands (markup on a generated sheet resolving to an IFC GUID); @massingcloud/pdf-viewer is a dependency, not a fork; its pdfjs-dist worker is bundled, never CDN-loaded, so the offline claim holds.

4. Benchmarks at scale — measured 2026-08-10, and the answer is "not yet"

fixtures/scale.ts generates buildings in memory rather than committing them, and fixtures/scale.test.ts measures parse → tessellate → section end to end. Run it with SCALE=1; the generator's own correctness tests run every PR, because a generator emitting subtly invalid STEP would make every number below authoritative-looking noise.

case IFC products triangles parse ms µs/element cut ms µs/mesh
small 0.01 MB 16 192 10 625 7 438
medium 0.07 MB 144 1,728 85 590 14 97
large 0.34 MB 640 7,680 130 203 11 17
xlarge 1.11 MB 2,000 24,000 256 128 29 15

Cost per element falls monotonically as the model grows — 625 → 128 µs — so the pipeline is linear or better and the small cases are dominated by fixed costs and JIT warm-up. Section cost per mesh flattens at ~15 µs. Nothing here says the TypeScript parser is a ceiling, which is the evidence the Rust decision was waiting for, and the answer at these sizes is no.

Three honest limits on that conclusion, because it is the kind of result that gets over-read:

What would change the decision: extending the generator past ~100,000 elements and finding per-element cost rising, or finding peak memory unacceptable. massingviser's Python geometry pipeline (BVH picking, culling, clash, LOD) is the comparison to borrow rather than repeat — see ADR-0014.

4b. What the benchmark does not yet cover

Mostly done, and re-read on 2026-08-15 rather than trusted. The paragraph here said "two small fixtures today, scale explicitly deferred", which stopped being true when fixtures/scale.ts landed: four generated fixtures — small, medium, large, xlarge (20 storeys, 8 bays) — run nightly through fixtures/scale.test.ts, with budgets measured rather than guessed, exactly the posture perf/README.md asks for. A section that reads as wholly not-done while being three-quarters done is how the one genuinely missing piece stays invisible.

The genuinely missing piece was the federated case, and it landed the same day. The original list named small / medium / large / federated; what shipped substituted xlarge. So every scale number in this repository is for a single model, while federation is a shipped feature with its own per-model visibility, per-model GUID resolution and a selection path that has to stay correct when two files share an expressID. It is measured now — see item 5 in the list below. The costs that show up there — per-model resolver maps, a scene graph with two roots, colour-key churn across models — are precisely the ones a single-model benchmark cannot see.

This is the gate that decides whether a Rust core is justified, so it comes before it.

5. Then, and only if the numbers say so: Rust, version diffing, capture overlays

What this roadmap does not do

M9 seam: validated from tarballs, 2026-08-14

The plan's risk #1 is divergence — every week both repositories hold a copy of the engine is debt at compound interest — and M9 closes it by having massing consume @massing/* instead of its own apps/web/src/viewer. Nothing had exercised that path, so "the seam is ready" rested on a ledger rather than on a consumer.

It has now been exercised without publishing anything. npm pack --workspaces produced 26 tarballs; installing them as a set into an empty project — one with no access to @massing/* on any registry — resolved cleanly, and from there:

So the packaging, the dependency closure and the public surface all hold for a real outside consumer. The only remaining step on this side is publishing, which is deliberately not automated here.

What this does not prove: that massing's application code compiles against these packages, or that deleting apps/web/src/viewer leaves its test suite green. Those need the other repository and are the actual M9 work; this removes the packaging unknown from in front of them.


Reconciliation, 2026-08-14 — what shipped, what did not, and what is blocked

The programme above was written on 2026-08-09 and is still the plan. This section states, item by item, what is actually true of the repository now, because a roadmap whose items are never marked off becomes a wish list that reads like a status report.

The five programme items

# Item State
1 Renderer seam + federation Done, and both halves needed fixing afterwards. The WebGPU fallback was not transparent — twice. Selection silently stopped highlighting the moment a second model loaded. See ADR-0012 and ADR-0013.
2 Families — @massing/assets + a Build-ribbon gallery Done. Library parsing, galleryFor layout, a rendered panel with discipline tabs, search, drag-to-place, and availability dimmed with a reason.
3 Sheets, title blocks, review desk Half done. Sheet furniture — border, title block, revision table, scale bar — ships across SVG, DXF and PDF, and 2D is now a peer surface rather than a side pane (ADR-0015). The review desk is blocked: it needs @massingcloud/pdf-viewer on npm.
4 Benchmarks at scale Done for a single model, and extended: the drawing benchmark is joined by a main-thread measurement that found sectioning blocking for ~450 ms, and since 2026-08-15 by p95 frame time. Four generated fixtures run nightly. The federated case is not measured — see 4b, where "done" had been hiding it.
5 Rust, version diffing, capture overlays Not started, and correctly so. Nothing measured says the TypeScript parser is the ceiling.

What shipped since the plan was written, that the plan did not ask for

Each of these came out of a defect found while doing something else, which is the honest reason they exist:

Not done, in the order I would take them

  1. The two publishes. @massing/* unblocks M9, and @massingcloud/pdf-viewer unblocks the review desk. Both are outside this repository's control. The packaging half of M9 is proven — see the tarball run above — so nothing technical stands in front of the first one.

  2. massing consumes the packages, and deletes its own viewer. This is risk #1, the only one the plan says can end the project, and it is the one item where every week of delay costs something.

  3. Tessellator out of apps/demo. Done. @massing/tessellate at layer 2, imported by both apps and by fixtures/; SourceMesh moved to @massing/core so a producer need not depend on a renderer. The divergence was worse than "two copies": the shell's had no refDirection and no IfcRelVoidsElement, so a rotated wall drew unrotated and a wall with a door drew solid — both silent, both shipped. Each is now pinned by a test verified by removing the behaviour and watching that test fail.

  4. The p95 frame-time gate. Done 2026-08-15, in the nightly's frames job alongside the long-task spec — which turned out to have run in no workflow at all since it was written, so it had never reported anything. The predicted threshold problem arrived exactly as expected and was answered the same way: both fail on liveness (a loop not producing frames, a quarter-second of main-thread work) and report timings to perf/frames.jsonl rather than gating on a number a shared runner cannot hold still.

    The frame-time gate's first draft had an unreachable failing branch — a fixed 180-frame sample meant any stall large enough to hit the threshold also blew the test timeout, which fired first. Found by injecting a real main-thread stall rather than by lowering the threshold until it went red; the second proves the assertion is wired and nothing about whether the condition can occur. Both branches are now verified reachable with two different stall shapes.

  5. A federated scale benchmark. Done 2026-08-15. Two buildings of the same size, so their expressIDs collide completely — the realistic federated shape and the hazard ElementRef exists for — with distinct GlobalIds as IFC4 requires. Both properties are asserted rather than assumed, because a generator change could otherwise leave this quietly measuring two unrelated buildings.

    The assertion worth having is that both models reach the drawing. A merge keyed by expressID rather than by GlobalId keeps one model's element per colliding id and silently drops the other's; the plan still renders, still looks plausible, and is missing an entire consultant's building. Sabotage-checked by resolving every GUID from model 0, which fails with "model 1 contributed no cut geometry to the federated plan".

    What the numbers say, carefully: 288 products across two models at 42 µs/element, against 111 for medium (144) and 56 for large (640). No superlinear per-model cost is visible. That is a weak claim on purpose — the absolute times are 3–16 ms, small enough that JIT warm-up and ordering dominate, so this establishes the shape is not obviously wrong rather than that federation is free.

  6. Boot cost. 148–182 ms of script evaluation. The stated trap — "changing the chunk graph is what broke the offline test twice on 2026-08-13"was wrong, and rested on the diagnosis overturned on 2026-08-14: the offline failure was Vary: Origin, not the chunk graph. A chunk-graph change shifted which assets were fetched as CORS-mode module requests and so changed how often the race lost, which is why it looked causal. With ignoreVary in place that coupling is gone and code-splitting is no longer blocked on it.

    What is measured, on a clean build: entry JS 199.2 KB br (120.2 entry + 78.7 three.core + 0.3 registration), total 217.4. three is already split into its own chunk — the note claiming otherwise predates the WebGPU dynamic import that caused the split. The remaining candidates are the export paths (toPdf, toDxf, toBcfZip), which are reachable only from a button and are currently in the entry. Not yet measured, so not yet claimed as a saving.

Known and unexplained