/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Floor Plans as Building Models

Building registration accepts one input today: a JSON description of rooms. This page records the decisions for accepting an architectural drawing in the same place — what is extracted, what a drawing cannot supply, and where the extraction runs.

For the registration flow this feeds, see Digital Twin Service Architecture. For the bounded context, Building Management Context.

Built for SVG, DXF and vector PDF — phases 1 to 3, 5 and 6, plus multi-floor

All three formats work end to end: the modal takes one drawing per storey, calibrates them together, and hands the rooms to the same reviewable draft a JSON upload produces. The ladder is complete. Phases are listed at the bottom, and each one updates this page in its own commit.


The Seam

The format decision lives in one place — frontend/src/components/modals/creation/RegisterBuildingModal.vue:

const raw = JSON.parse(await file.text())
loadFromJson(raw)

Everything downstream of loadFromJson is already format-agnostic: the draft, the per-room editor cards, the threshold sliders, the sensor drafts, and submit() posting to /twin/register. A floor plan becomes a second way to produce the same BuildingDraft.

LayerChanges?
handleFileSelected — picks a parser by file typeyes, one branch
calibration and per-storey upload rowsyes, new
extraction — drawing to BuildingDraftyes, new
useBuildingDraft, room cards, threshold and sensor UIno
POST /twin/register, service::provisioning, the Kafka handshakeno
schemas/twin-schema, domain/building.rsno

The backend does not learn that floor plans exist. That is the point: a drawing is an authoring convenience, and the twin’s contract is the room list either way.

The target is easier than it looks

domain/building.rs models a room as an axis-aligned box — Coordinates { x, y, z } and Dimensions { width, height, depth }. There are no polygons, no wall thicknesses, no door graph. Extraction has to produce a bounding rectangle and a label, not reconstruct the architecture.

Y is up, so the plan lies in X–Z

useInstancedRooms.ts passes position and dimensions straight into a Three.js instance matrix as translation and scale. Two consequences the JSON schema does not state:

Reading the plan from above makes the sense of the axes agree without a flip: SVG’s downward y is the same direction as +z on screen when looking down the y axis.

The draft editor is the accuracy backstop

Extraction is never trusted. Its output lands in the same reviewable draft a hand-written JSON does, and the user corrects it before anything is submitted. The bar is “close enough to fix in half a minute”, not correctness. A phase that chases extraction accuracy instead of improving the editor is solving the wrong problem.


What a Drawing Cannot Tell You

Four of the six room fields are absent from any plan view, and the missing ones are not guessable from the geometry.

FieldOn the plan?Where it comes from
x, z, width, depthyesextracted, after calibration
ynofloor_index × floor_height; one drawing is one floor
heightnever — a plan is a horizontal sectiondefault, editable per room
capacityneverleft unset; the editor’s own default, as for hand-written JSON
colorsometimes — a fillleft unset; the editor’s own default

Only geometry is extracted. capacity and color are the two fields a drawing has no authority over, and loadFromJson already defaults both for every other upload — so extraction leaves them alone rather than inventing a second set of defaults that has to be kept in step.

Deriving capacity from floor area was considered and dropped. Occupancy per square metre is a function of room use, which a plan does not encode, so the number would be wrong at a plausible-looking magnitude — worse than the blank the user already expects to fill in.


Scale Is a Calibration, Not a Heuristic

The twin declares no unit. A drawing carries pixels, or drawing units, or a printed scale bar — nothing that resolves itself. There are exactly two honest sources:

There is no third option. Inferring metres from a bitmap’s dimensions, a door’s typical width, or a text label that happens to read 1:100 is guessing dressed as convenience, and it fails silently — a building that is uniformly 3× too large still renders, still registers, and is only noticed once someone stands in it.

When the format declares a scale the calibration control is pre-filled, not hidden. Drawings lie about their units often enough that the knob has to stay reachable.

A two-point measuring tool over a preview of the drawing is the friendlier control and was deliberately not built: it needs a rendered, click-mapped preview, which is more work than the rest of the upload path combined. The number field is the same calibration with a worse first guess, and the editor already shows whether the guess was right. Build the picker when a real drawing proves the number is unusable.


Room Ids Must Be Deterministic

Telemetry keys per-room thresholds by path — /telemetry/thresholds/peopleCount/buildings/{buildingId}/rooms/{roomId}. An id that changes between two extractions of the same drawing orphans every threshold attached to the old one.

Determinism pays a second time inside the modal. Changing the scale re-extracts every drawing, and because ids come from the storey and the label rather than array order, sensors already attached to a room stay attached across the re-run. Calibration is therefore free to be a loop instead of a decision made before upload.


What Counts as a Room

A drawing is mostly not rooms. Walls, furniture, dimension lines, north arrows, the sheet border and the title block all sit in the same file, and none of them are spaces. One rule separates them:

A shape is a room when a text label falls inside it.

The ceiling is stated plainly: an unlabelled room is not extracted. Rooms are named on plans nearly always, and the alternative — treating every closed shape as a space — produces a draft in which the real rooms are buried among furniture. Adding a room by hand costs less than finding twelve wrong ones.


Format Ladder

Formats were added one at a time, each one an extractor plus an arm in the type switch — the contract below is what kept that true. The ladder covers what building-drawing tools export.

FormatMechanismNew dependencyScale from
SVG — firstDOMParser; rect, polygon, polyline and straight-line path extents, enclosed text as the labelnone — the browser already parses ituser
DXF — secondASCII group-code pairs; LWPOLYLINE extents, TEXT/MTEXT labelsnone — hand-rolled, see below$INSUNITS, user-overridable
PDF, vector — thirdpdf.js down to path bounding boxes, then identical to SVGpdfjs-dist, dynamically importeduser

SVG went first because it cost nothing and proved the entire path — calibration, floor handling, id minting, the editor round trip — before a single dependency was added. It is rarely what an architect hands over; DXF is. Everything phases 2 and 3 built was reused verbatim by DXF and then by PDF.


The Extraction Contract

One pure function per format, and it reads geometry only:

(bytes: ArrayBuffer) => PlanReading | Promise<PlanReading>   // { shapes, warnings }

Bytes rather than text, because a PDF is binary: File.text() replaces every invalid UTF-8 sequence before a reader could see it, which silently corrupts the content stream. The two text formats decode in the dispatch table, so svg.ts and dxf.ts never learn the distinction. A reader may be async for the same reason — pdf.js is loaded on demand.

Turning shapes into rooms is a second, format-agnostic step:

(floors: PlanFloor[], options: PlanOptions) => ExtractedBuilding

The split is what makes a multi-storey building possible. Scale, centring, elevation and id minting need to see every storey at once, so they cannot live inside a per-file reader. building is exactly the JSON loadFromJson already consumes, so no draft-building logic is duplicated.

Reading the File is the caller’s job, not the extractor’s: the seam already awaits file.text() for the JSON path, and a function over a string is the one that tests without a DOM fixture harness.

warnings is the honest channel for what a drawing contains and the schema cannot hold — a rotated room, a curved outline. Reporting them beats silently squaring them off.

Three rules, and they are the whole insurance policy:

Held to those, moving one format to a service later is a swapped call site with the fixtures reused as-is — not a migration.


Three formats, three coordinate conventions

SVG’s y grows downwards; DXF’s and PDF’s grow upwards; the twin’s z runs down the screen. Each reader negates y at its own boundary, so draft.ts sees one convention and normalises against it. A missed flip mirrors the whole floor — and a mirrored building still renders, still registers and still looks plausible, so nothing downstream catches it. Both flipping readers assert it in a test for that reason.

DXF y points up

SVG and DXF disagree about the vertical axis: SVG’s y grows downwards, DXF’s grows upwards. dxf.ts negates y as it reads, so draft.ts normalises against flipped extents and the plan comes out the right way round. Passing y through unchanged mirrors the whole floor — and a mirrored building still renders, still registers and still looks plausible, so nothing downstream catches it. It is asserted by a test for that reason.

Reviewing before submitting

The draft editor lists rooms as cards, which is the right shape for correcting a name or a capacity and the wrong one for noticing that a room landed in the wrong place. The preview draws the extracted building from above, one plan per storey, in metres — the same view the user uploaded, so a misread wall or a bad scale is visible at a glance rather than inferable from a column of numbers.

It is read-only on purpose. Editing stays in the cards; the preview exists to make an error obvious, not to fix it.


Where Extraction Runs

In the frontend, beside loadFromJson. No new service, no route, no image, no auth surface, no services.json entry.

The instinct to put it behind an API is usually about accuracy, and that instinct is wrong here: SVG, DXF and vector PDF parse to the same bounding boxes on either side of the wire. There is no precision to be gained by moving, and a service would cost a services.json entry, an image, a route and an auth surface for work that runs once per building.

The drawing also never leaves the browser, which is worth keeping: a floor plan is a description of a customer’s premises.


Deliberate Omissions


Phases

PhaseDelivers
1 — this page ✅format ladder, calibration model, extraction contract, id rule
2 — SVG extractor ✅extractSvg plus fixtures; no UI
3 — wire the seam ✅type branch, calibration control, floor index; SVG works end to end
4 — automated end-to-end coveragedropped; see below
5 — DXF ✅one reader, one switch arm, $INSUNITS pre-fills the scale
6 — PDF, vector ✅pdf.js path bounds and text positions; readers move to bytes

Phase 4 was planned as an acceptance test covering upload, review, submit and the building appearing in the scene. It is dropped, and the numbering is kept so the later rungs do not move.

Nothing cheap would have covered it. backend/acceptance runs Python against HTTP and stubs the twin outright (stubs/twin.conf), so it cannot run a TypeScript extractor — it could only post a hand-written payload, which asserts nothing about extraction. frontend/e2e is still the Playwright starter template, so a browser test means building the harness first: a login flow, a composed stack in CI, and assertions against a canvas. That harness is something the whole frontend needs or does not need on its own merits, and this feature is the wrong place to fund it.

What remains is the extractor’s own fixtures and the draft editor, which the user reviews before anything is submitted. The modal wiring — file picked, scale changed, rooms rendered — is verified by hand.

Phases 5 and 6 were additive by construction, which is what the extraction contract exists to guarantee: each added a reader and an entry in the dispatch table, and changed nothing else.


Where This Lives

All paths are under frontend/src/. The seam row lands in phase 3; the rest exists.

Error: tablebyrows

Cannot call function tablebyrows(...) with arguments (...):
Unresolved reference: spec

.tablebyrows {.files}
    - - format dispatch, and merging storeys into one building
      - `utils/building/floorplan/index.ts`
    - - shapes to rooms — scale, centring, elevation, ids
      - `utils/building/floorplan/draft.ts`
    - - SVG reader — transforms, extents, label matching
      - `utils/building/floorplan/svg.ts`
    - - DXF reader — group-code pairs, `$INSUNITS`, y-axis flip
      - `utils/building/floorplan/dxf.ts`
    - - PDF reader — pdf.js path bounds, y-axis flip
... (11 more lines)

draft.ts names no format and svg.ts names no Vue symbol. That split is what phase 5 reuses: DXF adds a reader beside svg.ts and touches nothing else.

Extraction lives under utils/, not composables/, because none of it touches ref, computed or a lifecycle hook — the use prefix promises a setup()-only call site, and these are pure functions callable from anywhere.

Nothing under backend/ appears in this table, and that is the invariant worth keeping: a floor plan is an authoring format, and the twin’s contract is the room list.