CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
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.
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 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.
| Layer | Changes? |
|---|---|
handleFileSelected — picks a parser by file type | yes, one branch |
| calibration and per-storey upload rows | yes, new |
extraction — drawing to BuildingDraft | yes, new |
useBuildingDraft, room cards, threshold and sensor UI | no |
POST /twin/register, service::provisioning, the Kafka handshake | no |
schemas/twin-schema, domain/building.rs | no |
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.
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.
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:
y is the vertical axis. A drawing’s two axes are x and z, and height is the ceiling. Mapping a plan’s vertical axis to y mirrors the storey into the floor.position is the box centre, on every axis — a BoxGeometry is origin-centred. A rectangle’s top-left corner is not a position; the centre is.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.
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.
Four of the six room fields are absent from any plan view, and the missing ones are not guessable from the geometry.
| Field | On the plan? | Where it comes from |
|---|---|---|
x, z, width, depth | yes | extracted, after calibration |
y | no | floor_index × floor_height; one drawing is one floor |
height | never — a plan is a horizontal section | default, editable per room |
capacity | never | left unset; the editor’s own default, as for hand-written JSON |
color | sometimes — a fill | left 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.
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:
$INSUNITS. Take it.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.
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.
f0-, f1-. Two floors of an office both hold an Office 1, and a shared collision suffix would decide the winner by upload order and renumber the survivors whenever a floor is removed.Office are normal; two rooms with the same id are a data-loss bug.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.
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.
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.
| Format | Mechanism | New dependency | Scale from |
|---|---|---|---|
| SVG — first | DOMParser; rect, polygon, polyline and straight-line path extents, enclosed text as the label | none — the browser already parses it | user |
| DXF — second | ASCII group-code pairs; LWPOLYLINE extents, TEXT/MTEXT labels | none — hand-rolled, see below | $INSUNITS, user-overridable |
| PDF, vector — third | pdf.js down to path bounding boxes, then identical to SVG | pdfjs-dist, dynamically imported | user |
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.
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) => ExtractedBuildingThe 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:
BuildingDraft JSON out, asserted per format.Held to those, moving one format to a service later is a swapped call site with the fixtures reused as-is — not a migration.
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.
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.
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.
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.
rotate or matrix transform has no correct box, so the shape is skipped and counted in warnings rather than squared off into a plausible wrong rectangle.path carrying a curve command is not a room outline — it is a door swing or a logo — and is left alone.translate and scale transforms are honoured. They cover CAD and Inkscape exports. Ignoring transforms entirely would silently displace every room on a layered drawing.LWPOLYLINE extents, TEXT/MTEXT — is about a hundred lines. A library would also read BLOCKS, which is the one thing worth having, but its coverage cannot be judged without real drawings, and a new npm dependency means regenerating the Linux lockfile. Revisit when a real export proves blocks are common.BLOCKS and places them with INSERT yields nothing. The reader counts the INSERTs and says so, rather than returning an empty building that looks like a bad drawing.pdfjs-dist is the one dependency the ladder adds, loaded through a dynamic import so its 1.3 MB worker is a separate chunk rather than part of the main bundle — most sessions never open a PDF.Uint8Array.prototype.toHex, which is too new for the test runtime and for browsers this app still supports.warnings, rather than silently merging storeys.| Phase | Delivers |
|---|---|
| 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 coverage | dropped; 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.
All paths are under frontend/src/. The seam row lands in phase 3; the rest exists.
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.