
Yesterday, I put the first beta online of something I had been tinkering with for a while: Places Builder. It’s a web app that turns a floor plan (PDF, PNG or JPG) into an IMDF package you can import into Microsoft Places. It is open source, free, and you can run it yourself or use the hosted beta at placesbuilder.com.
In this post I explain why I built it, and then go a level deeper into how it works: the data model, the geometry, the IMDF output, and the Azure setup, including the multi-tenant bits.
Why I built this
I really like Microsoft Places. One of the coolest features, is that Microsoft Places can show your buildings on a map: floors, rooms, desks. To get your own floors on that map you need a map package in IMDF (Indoor Mapping Data Format, an Apple-originated GeoJSON-based spec). And that is where it gets tough. What facilities has is a PDF from an architect. What Places wants is a zip of GeoJSON files with polygons in real-world coordinates, linked to the rooms that exist in Places. So how do we get there?
Nobody wants to build that by hand. There is a community tool that does a similar job (Loryan Strant’s IMDF Builder for Places), and it inspired me to build something simmilar. What I wanted on top of it:
- A web app I can run in Azure, signed in with Entra ID, instead of something that runs on one person’s machine.
- Multiple offices, each with multiple floors, stored properly, so you can come back to a floor next month and fix one room.
- Import of the Places export (the list of floors and rooms), so rooms you trace on the plan are matched to the right Places item instead of typed in by hand.
- Something others can use too: self-hosted in their own tenant, or hosted by me.
The workflow
From the user’s side it is five steps:
- Create an office and add floors.
- Upload a plan for a floor (PDF, PNG or JPG). PDFs are rendered in the browser with pdf.js.
- Align the plan on a satellite map: position, scale and rotation, so the drawing lands on the real building.
- Trace the rooms (and sections, workspaces and desks) on the plan.
- Import the room list exported from Places, match rooms to items, and download the IMDF zip.
Nothing in this chain needs a CAD file. That was a deliberate constraint: the input is whatever you can get hold of.
Architecture at a glance
| |
Me, and my friend Claude ofcourse, made a few decisiong along the way.
One package, run from a zip
The whole product is one release package built from dist/server and dist/web. App Service runs it straight from that zip (WEBSITE_RUN_FROM_PACKAGE pointing at the release asset). “Installing” a release is changing one setting; rolling back is pointing it at the previous version. No container registry, no build on the server. This makes is easier to maintain, update and troubleshoot. No complex rebuilds within the app service, everything happens in the pipeline. The app service just points towards the package that results from the pipeline.
No web framework on the server
The server is plain node:http with a small router. The API is a handful of JSON routes, and a framework would mostly add surface area and dependencies for a project I want others to be able to audit. The server is about 2,000 lines of TypeScript including auth, storage, the repository layer and a rate limiter.
Shared logic, tested in one place
The interesting code lives in src/shared and runs in both browser and server, covered by Node’s built-in test runner (no extra packages for the logic tests):
| Module | What it does |
|---|---|
imdf.ts, validate.ts | Generates and validates the IMDF files |
georef.ts, geometry.ts | Plan pixels to lon/lat, polygon maths |
places.ts, csv.ts, match.ts | Reads the Places export, writes the correlation CSV, matches rooms |
types.ts | The shared data model |
The interesting problems
Georeferencing without a GIS
A floor plan is a bitmap. IMDF wants WGS84 coordinates. So each floor has a small georeference: a lon/lat anchor at the image centre, a scale in metres per pixel, and a rotation. Converting a plan pixel to a coordinate is then:
- Move the pixel relative to the image centre and multiply by
metersPerPixel(flipping y, because image y points down). - Rotate by the map rotation to get metres east and north.
- Convert metres to degrees using the series expansion for the length of a degree of latitude and longitude at that latitude.
| |
There is a matching inverse (lonLatToPlan) so the editor can move between plan and map. Over building-sized distances this is a flat-earth approximation, and it is good to centimetres, which is far below the accuracy of tracing a room by hand. A full projection library would be overkill.
In the browser, the plan is drawn as a MapLibre image source using the four corners computed from the same function. So what you see on the satellite map is exactly what ends up in the file.
Generating IMDF that Places accepts
An IMDF archive is a set of GeoJSON files. The app writes venue, building, footprint, level and unit files, plus address and manifest, and optionally sections, fixtures (desks) and workspaces. Two details are worth knowing:
- Stable IDs. Feature IDs are derived deterministically from the office, floor and room IDs, so re-exporting after a small change produces the same IDs instead of a new universe.
- The Places link. The Places item id goes into
properties.directory_id. That is what ties a polygon to a room in Places.
Along with the zip, the app produces the mapfeatures.csv correlation file in the same shape Microsoft’s Import-MapCorrelations outputs (PlaceId, Name, Type, FeatureType, FeatureId, FeatureName, FeatureCategory).
Not everything about the Places side is documented, so every assumption I could not source from documentation sits in one object, IMDF_ASSUMPTIONS (venue and level categories, whether units carry building_ids, how desks are exported, and so on). If a test import shows that a value is wrong, I change it in one place. This is what will be needing the most work in the upcoming period: making sure the feature set aligns with what Places can handle.
I tested the output of simple configuration in a test tenant during development: a package with building, footprint, level and unit (plus address, venue and manifest) was accepted by New-Map directly, rooms showed on the map in the right place and orientation, and sections, workspaces and desks imported and displayed correctly. I keep what I have not verified in docs/OPEN-QUESTIONS.md with a GitHub issue per item. Desks exported as units, very large floors and several floors on one position are on that list. That is the honest part of calling this a beta. I will be testing this, ofcourse, but I can also use your help by giving it a go and reporting any issues (and successes!).
Matching rooms to Places items
The Places export names rooms like 8.1 - Casablanca, while people type 8.1 Casablanca or Casablanca (8.1) when tracing. match.ts normalises names (strip accents and punctuation, lowercase, collapse whitespace), splits off a leading room code like 8.1, and scores candidates with the Sørensen–Dice coefficient on character bigrams. An exact code match wins; otherwise the name similarity decides. The result is a suggestion the user can accept or override, not a silent decision. I’m still thinking about this one: maybe I’ll just keep one drop down menu, populated based on the Places export, to select the correct name for rooms and areas. That way, the user always has a clear and consistent way to match rooms without relying on fuzzy logic alone.
Identity and authentication
The app never sees a password. App Service authentication (Easy Auth) signs users in with Entra ID and hands the identity to the app in the X-MS-CLIENT-PRINCIPAL header; in production the app is not reachable without it (only /healthz is excluded).
auth.ts then decodes that principal and applies a few rules:
- The user is identified by the
oidclaim and the tenant bytid. Never by e-mail or UPN, because those change and are not unique identifiers. - The issuer must belong to the tenant in the token.
- Personal Microsoft accounts are refused.
- On top of that, an access policy: allowed tenants, and optionally allowed groups, roles or users.
Storage and data separation
All state is JSON and image blobs in Azure Blob Storage, behind a small storage interface:
| |
Locally the same interface writes to a .data/ folder, which makes development and tests trivial. In Azure it is Blob Storage with the web app’s managed identity: no keys, no connection strings, and shared-key access is switched off on the account. Soft delete and versioning on the container make an accidental overwrite recoverable.
Why not a database? The data is small, document-shaped and read as a whole (an office with its floors). Blobs keep the moving parts few and cheap, and for a tool like this that is the right trade-off. If it ever needs queries across offices, that is a decision for later.
One workspace per tenant
The self-hosted edition has a single shared workspace. The hosted edition (EDITION=hosted) has one workspace per Entra tenant. The first person from an organisation becomes its owner and invites colleagues.
The isolation is structural rather than a convention. A workspace only ever gets a Repo whose storage is a PrefixedStorage, confined to its own key prefix, and the prefix is validated so .. and friends cannot escape it. Request handling code cannot name another workspace’s keys because it never holds a storage object that could. The repository layer also enforces the optional limits (offices, floors, upload size, total storage), so every route is covered at once. And every route has a cross-tenant test, which is a rule in the repo.
Running it yourself
The repository contains a Bicep template behind a Deploy to Azure button. It creates the web app, storage and Azure Maps with managed identities and role assignments, and a PowerShell script (configure-auth.ps1) sets up single-tenant sign-in against your own Entra. Updating is a version parameter plus a redeploy. No data or telemetry reaches me.
For local development:
| |
DEV_AUTH is for development only; the server refuses to start with it in production.
Beta, free, open source
A few honest notes on where this stands:
- It is a beta. Expect rough edges, and tell me about them.
- It is free. I decided not to monetise it. If you want to support the project, there will be a voluntary donation option, nothing more.
- It is AGPL-3.0. The name and logo are not covered by the licence (see
TRADEMARKS.md). - It is an independent project, not affiliated with or endorsed by Microsoft.
The hosted service is built from the same public code, pinned to a released tag, with a thin private layer for hosting-specific extras. The core never depends on it.
Try it, break it
- Website and docs: placesbuilder.com
- Source: github.com/ralpje/placesbuilder
If you have floor plans and a Places tenant, I would love to know how it behaves on your real data, especially large floors and odd plans. Open an issue on GitHub, or reach out directly.
