SecretoCho
menu

· 11 min

Drawn, not recognized

No recognition model: walls become rooms through a polygonizer, a storey pick scored 99.65% on 7,743 points, and one map id hid 17 buildings in a reproduction.

Part 5 of 7 in the Building Novi Kvadrat series.

Novi Kvadrat has three studios that run in the browser. One turns a scanned floor plan into rooms, one raises map buildings into storeys, and one draws site plans. None of them runs a recognition model. A person draws, and the code does the geometry.

That was on purpose, and Part 2 tells the research behind it. This part is the building: how walls become rooms, why the plan you upload is never shown, how a tap picks one storey on a 3D map, and the day I learned that a map’s feature id is not a building.

Why there is no model

The floor-plan research of 5 August read the literature and came back with one line: “To match CityExpert you need zero AI.” On real plans, the state of the art reached a corner F1 of 59.4. The only large public training set with room outlines, CubiCasa5K, is “CC BY-NC 4.0 — commercially unusable”. And “No floor-plan model in this literature was trained on Cyrillic room labels.”

So we built manual tracing into our own editor, with no vendor.

A scanned plan in, rooms out

The floor-plan program ran as eleven phases, FP0 to FP10, from 5 August to 13 September. FP0 went in on 5 August, all dark, and the launch on 13 August, eight days after the research, was “Launch floor plans: static + interactive tracing”.

Three early choices are worth borrowing. The licence comes before the file: an upload must carry a current acceptance of a derivative-work licence, because the licence request is what mints the storage key. No acceptance, no key, no upload.

Rooms are stored as rings in a 0-to-1 space, not in pixels. Uploaded images are re-encoded (WebP, quality 82, at 2,560 px) and served in CDN variants, and normalized rings survive all of that, “which pixel coordinates do not”.

And ids die on save: the save function “is DELETE+INSERT — region ids die on save”, so every room carries a stable client id, and photo links are re-pointed by it after the insert.

Walls into rooms

The wall tool came last, on 13 September, as FP10.6. You draw walls the way you would on paper, and a planar-graph polygonizer turns every closed area into a room.

The walls form a graph: corners and junctions are nodes, wall pieces are edges. Walk from any edge, and at every node take the turn that keeps the face you are tracing on your left. Each walk closes into a cycle, and the sign of its shoelace area says what it is. In screen coordinates, where y points down, a room comes out negative and the outside comes out positive. Holes join the room around them.

The tempting shortcut, “the largest cycle is the outside”, fails on the simplest drawing there is. One closed loop of walls gives two cycles of the same size, one each way round, or as the note in the code puts it: “never pick ‘the largest cycle’ as the outer face: a lone loop’s two cycles tie”.

// walls → rooms (simplified)
function polygonizeWalls(graph: WallGraph): Ring[] {
  const rooms: Ring[] = [];
  for (const edge of graph.halfEdges()) {
    if (edge.visited) continue;
    const ring = walkFace(edge); // at each node, keep the face on the LEFT
    if (shoelace(ring) < 0) rooms.push(ring); // y points down: a room is negative
  } // positive cycles are outsides: each component’s outer boundary; one nested inside another room becomes that room’s hole
  return rooms;
}

function shoelace(ring: Point[]): number {
  let twice = 0;
  for (let i = 0; i < ring.length; i++) {
    const [x1, y1] = ring[i];
    const [x2, y2] = ring[(i + 1) % ring.length];
    twice += x1 * y2 - x2 * y1;
  }
  return twice / 2;
}

One bug came out of it. Walls traced over an untyped room’s own outline made the polygonizer replace the room with an identical face, under a new client id, and the room lost its photo links. Now an untyped room is never replaced by an identical face.

Photos are dragged onto the plan and land in rooms by a point-in-polygon test, not a bounding box, and each can carry a camera: a bearing and a 45, 65 or 90° field of view.

  1. accept the licenceits request mints the storage key
  2. uploadthe scana tracing aid
  3. draw wallsroomsclosed faces, stored as 0-to-1 rings
  4. dragphotosland in rooms; each can carry a camera
  5. visitors seethe schematicdrawn from the rooms, never the scan

The diagram, step by step:

  1. An upload starts with a derivative-work licence. The licence request is what mints the storage key.
  2. No acceptance, no key, no upload. With one, the scan goes up as a tracing aid.
  3. You draw walls, and a polygonizer turns every closed area into a room, stored as a ring in a 0-to-1 space.
  4. Photos are dragged onto the plan and land in rooms. Each can carry a camera.
  5. Visitors see a schematic drawn from the rooms, never the scan.
pick the tool your inputs allow, not the one that sounds clever.

The plan you upload is never shown

Visitors see a schematic drawn from the rooms. Per-room paint-order="stroke" “provably FAILS on shared walls”, so the renderer draws the walls in two passes through one SVG mask, and the order is the trick: “the mask paints ALL region fills black BEFORE ALL white erode strokes — that ordering is the shared-wall fix”.

For the first month, the scan was the fallback when no schematic could be drawn, and a toggle could show it even when one could. On 13 September I ruled that the image an author uploads must never be visible on the public side, because “the raster is a tracing aid (often a third-party drawing)”.

The rule lives in the database, not in the viewer. A migration revokes read access on the table from the public roles, then grants back every column except the image’s URL and storage path. Checked the next day, an anonymous request for image_url failed with Postgres error 42501.

-- the uploaded image is never public (simplified)
revoke select on listing_floor_plans from anon, authenticated;
grant select (/* every column except image_url and storage_path */)
  on listing_floor_plans to anon, authenticated;

A floor that can’t be drawn well enough (fewer than two rooms, no scale, or under 60% coverage) now shows no plan at all. The honest limit: images served before the change, in crawler caches or on shared preview cards, can’t be recalled.

A gate set on fixtures

On 13 August the schematic got a quality gate: a coverage of at least 0.85, at least two rooms and at least one room with an area. Coverage is how much of their own bounding box the traced rooms fill: a 48 × 48 grid of points over the box, each counted as inside a room or not. The 0.85 was set on fixtures.

The next day the first field report came in: “still see the raster”. The obvious place to look was the viewer; the useful place was the data. A probe of the live rows showed every complete production tracing between 0.719 and 0.750, and the gate had refused every real plan. It went down to 0.6 on 14 August, with the lesson written down: “Never ‘fix’ a raster-still-showing report by touching the viewer — probe the gate against the live rows first.”

the gate, 13 Aug, set on fixtures
0.85
every complete live tracing
0.719–0.750all refused
the gate, 14 Aug
0.6

The diagram, step by step:

  1. 13 August: the schematic gets a quality gate, a coverage of at least 0.85, set on fixtures.
  2. A probe of the live rows: every complete production tracing sits between 0.719 and 0.750. The gate refuses every real plan.
  3. 14 August: the gate goes down to 0.6.
calibrate a gate on live rows, not on fixtures.

A month later, fixtures fooled us again. An SVG <mask> has a default region of x="-10%" y="-10%" width="120%" height="120%". With maskUnits="userSpaceOnUse", those percentages resolve against the nearest SVG viewport and are measured from user-space 0, not from the viewBox’s origin. A plan traced at the right and bottom of a 2732 × 1908 raster lost its right and bottom walls on every public surface. “The old 1600 × 1200 fixture plans never crossed the line, so no gate caught it.” It was fixed on 14 September.

A map’s id is not a building

Our new-build projects stand on the map as curated 3D shells. The basemap already extrudes every OpenStreetMap building from CARTO’s vector tiles, the same building included. The 5 August commit that set out to fix it describes the result: “Coincident walls and near-equal roof heights give the depth buffer ties it cannot resolve, so the surfaces alternate per fragment — the diagonal banding that shimmers under pitch.”

The first fix looked obvious: filter the basemap’s twin out by its feature id. It went in at 19:52 (commit times), and within hours a team member caught it: many other buildings nearby were hidden too.

The tiles “merge every building sharing a render_height/render_min_height pair into ONE MultiPolygon feature.” On one live tile over Novi Beograd, 40 features held 750 building polygons, and the largest single id held 90 of them, spread over 2.2 km. In a reproduction, filtering one id to remove one twin removed 17 innocent buildings. A CARTO feature id is a bucket of buildings that share a height. It is not a building.

At 22:42 the rule was rewritten: “Strictly guarded: an id is only ever filtered when every polygon it carries is one of ours.” On CARTO’s basemap, it adds, “suppression almost never qualifies”.

The fix that holds leaves the basemap untouched and encloses the twin. It had been in the 19:52 commit as a fallback at 0.6 m; the 22:42 commit made it the fix and widened it to 0.8 m. Our shell grows 0.8 m outward with a miter offset, sized to the tiles’ quantization (about 0.42 m per unit at zoom 14 there); the roof goes up 0.5 m and the base 0.05 m.

// first try: hide the basemap's twin by its feature id (simplified)
map.setFilter('basemap-buildings-3d',
  ['!', ['in', ['id'], ['literal', twinIds]]]);
// but one CARTO id is every building in the tile with the same heights

// what holds: leave the basemap untouched and enclose the twin
const shell = inflateBuildingFootprints(ours, {
  outMeters: 0.8, // miter offset, sized to the tile quantization
  roofMeters: 0.5,
  baseMeters: 0.05,
});

My own line on hiding basemap buildings: “We don’t want to hide 3D buildings. We tried before it is not working.” (My prompts and review lines are quoted with the spelling fixed.) The 3D research later wrote it down as a standing ruling of mine: never hide, flatten, id-filter or hover basemap buildings.

whenwhat happened
19:52hide the basemap’s twin by its feature id
within hoursa team member: many other buildings nearby are hidden too
thenone id held 90 polygons; in a reproduction, removing one twin removed 17 innocent buildings
22:42the id filter, strictly guarded: only when every polygon it carries is ours
22:42the fix that holds: our shell grows 0.8 m around the twin

The diagram, step by step:

  1. 19:52, commit time: filter the basemap’s twin of our building out by its feature id.
  2. Within hours, a team member catches it: many other buildings nearby are hidden too.
  3. One id held 90 building polygons. In a reproduction, removing one twin removed 17 innocent buildings.
  4. 22:42: the id filter is strictly guarded. An id is filtered only when every polygon it carries is ours.
  5. The fix that holds leaves the basemap untouched: our shell grows 0.8 m around the twin.
a CARTO feature id is a bucket of buildings that share a height, not a building.

Measure the pick before you build it

On 24 September I asked for real 3D with floor splits, floor selection and per-floor shape edits, as add-ons to our MapLibre, with no engine swap and no paid 3D services. Before any of it was built, agents put together a throwaway spike, and its runner rows span one afternoon.

The hard question was the tap. A storey is a thin slab, sometimes half hidden behind another building. MapLibre’s hit test doesn’t cast a ray. It takes the extruded buildings standing along the line of sight, projects each one’s roof and walls onto the screen, keeps those that cover the point and sorts them by depth, nearest first. The rule we tested: query our storeys and the basemap’s buildings together, and accept a pick only when the first hit is ours. If a basemap building is in front, refuse.

// pick a storey, with the basemap as an occluder (simplified)
const hits = map.queryRenderedFeatures(point, {
  layers: ['storeys', 'storey-copy', 'basemap-buildings-3d'],
});
const first = hits[0];
if (!first) return null; // nothing there
if (first.layer.id === 'basemap-buildings-3d') return null; // refuse
return { building: first.properties.building, storey: first.properties.storey };

To score it, each frame was rendered twice. The second render hid every other layer, painted each storey in a colour made from its id, kept the basemap buildings as an opaque blue occluder and turned the lighting off. The pixels under a point say which storey is truly visible there.

The run that counts, A-pick-grid, used 9 cameras and a 40 × 24 grid per camera: 8,640 points, of which 897 sat on an edge between two colours and were left out. On the remaining 7,743, the hit test agreed with the colour render 99.65% of the time. The other 0.35% were 27 refusals, with 0 wrong storeys and 0 false picks. Querying only our storeys never refused or named a wrong storey, but it made 442 false picks, a storey where the render showed none, and scored 94.29%. At three close-ups on one building, the occluder rule scored 92.76%, with 147 refusals and 48 false picks of its own, against 63.86% for storeys only.

“Every stock miss comes from the basemap layer’s hit test, not ours.” A local patch to MapLibre reached 100% on the grid, but it was “a test instrument, not proposed as app code”. The viewer accepts the refusal, and the storey list covers it.

The two numbers are easy to blur into “99.65% in 81 GPU runs”. 81 is every runner row of the five-experiment spike, A to E; 12 of them belong to the storey experiment, and 76 count as GPU rows. Every row ran on one Apple M2 Max laptop in headless Chromium, none on a software renderer, over illustrative buildings: OpenStreetMap geometry with hand-made storeys. The report says it plainly: “No row here predicts a phone.”

storeys onlywith the basemap as occluder
agreement, 7,743 grid points94.29%99.65%
false picks4420
refusals027
wrong storeys00
agreement, three close-ups63.86%92.76%, with 147 refusals and 48 false picks
predicts a phonenono

The diagram, step by step:

  1. On the grid’s 7,743 points, the hit test agrees with the colour render 94.29% of the time on our storeys alone, and 99.65% with the basemap as an occluder.
  2. Storeys alone make 442 false picks, a storey where the render shows none. The occluder rule makes 0.
  3. The occluder rule refuses 27 times instead. Storeys alone never refuse.
  4. Neither names a wrong storey.
  5. At three close-ups on one building: 63.86% for storeys alone, 92.76% with the occluder, which makes 147 refusals and 48 false picks of its own.
  6. One run, A-pick-grid, from 9 cameras on one M2 Max laptop, over illustrative buildings. “No row here predicts a phone.”
measure before you build, and say exactly what you measured.

Twenty buildings that were eight

A day before the spike, the site-plan research found the same kind of mistake in our own data. One new-build project had 20 building rows, “Building 1, 2.1, 3 … 20”, all with 0 units, no coordinates and no height. They matched the 20 OpenStreetMap ways there, which form “7 touching clusters”. The developer sells 8 buildings, and one of them is on no map. Rows like these went back to May, when draw-to-create on the old 3D map lived for about 17.6 hours and minted one “Building N” row per shape.

The fix was a data model, not a map trick. BD3, on 29 September, shipped one model for a developer’s sales buildings, “the developer’s sales building (‘Objekat A–H’, verbatim) → footprint parts with storey ranges → storeys keyed like units.floor → whole-floor plates → units”, with a guard against new “Building N” rows. The research for the new studio proposed to knowingly reverse the May ruling, and I accepted that on 25 September, so buildings could be created from shapes again, with a nine-point guard: one sales building per create, “A name is required, with no default”, and seven more.

Our own path editor

On 15 October I asked for a correct way to draw a building by hand, and for parcel tools. New buildings aren’t on OpenStreetMap yet, so OSM had to become an option, not the default. DRAW was committed that night, “3D Buildings Studio DRAW: the Building tool becomes a drawing kit, and a new Parcel tool (P)”, and went to production the next day.

After using it live, I asked for more: “We should have Figma like path controls … I really need proper drawing capabilities, think about every aspects. Don’t spread out.” The studio’s editor was terra-draw, and the research into extending it found “no single-vertex or multi-vertex selection, no marquee” and, further down the list, “no holes and no MultiPolygon”. So terra-draw left the studio, and we built our own editor, with full Bézier curves. At its heart is a gesture machine where “one gesture = one reducer action = one history entry, by construction”. It has 195 tests of its own, among 653 new ones.

One rule was my call, against Figma: “Pan is sacred: a drag pans in every state, inside the outline too”. I approved the decisions on 16 October, after the ten design boards (“yes, this is much better, you also add this feature to buildings which is really good”), and the editor went to production on 17 October.

A migration that passed its tests

One more, for anyone who tests SQL. DRAW added one migration, for the parcel columns, and its structure test passed. A review then compiled the file on a throwaway Postgres 17, and it didn’t compile: a CASE inside an IF, without parentheses. The structure test read the migration as text, and “the text-only structure test could not see it”.

A test that reads SQL tells you what the file says. Only a database tells you that it runs.

What I’d pass on

Pick the tool your inputs allow, not the one that sounds clever. Scans that are often someone else’s drawing, a training set you can’t use commercially, labels no model was trained on: a drawing tool with a person behind it was the honest answer.

Calibrate a gate on live rows, not on fixtures. Twice in a month, fixtures hid the truth: a 0.85 gate that refused every real plan, and a mask region that only real plan sizes crossed. Probe the data before you touch the code.

Measure before you build, and say exactly what you measured. One afternoon’s spike set the pick rule for the public viewer. The honest headline names the run, the 7,743 points, the one laptop and the illustrative buildings, and it keeps “No row here predicts a phone.”

Check what an id identifies in someone else’s data. One CARTO feature id held 90 building polygons. And when two renderers fight over one surface, separate the geometry instead of deleting one of them.

Most of these bugs left something to look at: missing walls, vanished buildings, a scan that wouldn’t go away. The ones that cost the most showed nothing at all. That’s Part 6.

The series

  1. Three flats and a platform
  2. Research before anything
  3. See it before you build it
  4. Five languages, five of every mistake
  5. Drawn, not recognized
  6. Silently
  7. More hours than I should
cd ..