A rendered ground floor seen down its length: a dining table and chairs
              in the middle distance, a doorway on the right whose reveal shows the thickness
              of the wall, and a lit floor lamp standing against the far plaster.

Notes from the studio

The room that came back 30 centimetres too wide

The living room in the picture above is 5.15 metres wide. That is the number on the drawing, the number a builder would put on a quote, and the number Duimstok's tape reports when you stretch it from one wall to the other.

An earlier version of the same tape reported 5.455 m. Every part of it was working. The raycast hit the right wall, the snap found a real point in the scene, the arithmetic was exact to the millimetre, and the answer was thirty centimetres of nonsense. This is the story of that thirty centimetres, and of the decision record we wrote so nobody re-introduces it.

What a tape has to land on

Duimstok is a first-person walkthrough of a ground floor at true scale. Press M, click a point, click a second point, and a dimension line is drawn between them with the distance in centimetres. It is the feature that decides whether the sofa fits.

Freehand aiming will not do. A tape that lands wherever the crosshair happened to be gives you 514.7 one time and 515.3 the next, and a measurement you cannot repeat is a measurement you cannot use. So the endpoint has to snap to something — and the whole question is what.

The things worth measuring between are a short list: room corners, door jambs, window sills, the corners of a piece of furniture. Everything a person points a real tape at is a place where two surfaces meet.

The list that was already there

The application already had a list that looked exactly like those points, and it had it for free.

Walls in Duimstok are not solid boxes with holes cut in them. There is no constructive solid geometry anywhere in the project; a wall with two openings in it is assembled from pieces — a pier between each pair of openings, a sill below each one and a header above it. The collision world is built from the same split, one 2D span per pier.

Which means that every collider endpoint in the house is a room corner or a door jamb. Not approximately: exactly, by construction, as a side effect of a decision taken for a completely different reason. Twelve walls became a few dozen spans whose ends were precisely the points a tape wants.

It is very hard to look at that and not use it. No raycasting against geometry, no per-triangle work, no dependence on what the crosshair happened to hit — just a proximity test against a list of points that was already in memory and already correct.

A centre line is not a surface

A Collider2D is a line segment with a thickness. It runs along the middle of the wall, because that is what a collision solver wants: push the walker out to half the thickness on whichever side they came from, and the walker stops with their nose against the plaster.

The room in the photograph is bounded on one side by a 0.36 m gable wall and on the other by a 0.25 m party wall. Their centre lines sit at x = −0.18 and x = 5.275. Do the subtraction and you get the number the broken tape was reporting:

gable wall      centre x = -0.180   thickness 0.36   inner face x = 0.000
party wall      centre x =  5.275   thickness 0.25   inner face x = 5.150

centre to centre   5.275 - (-0.180) = 5.455 m      what the collider list says
face to face       5.150 -   0.000  = 5.150 m      what the room actually is

Centre-to-centre is a real dimension. Structural engineers use it, and so does anyone setting out a floor plan before the walls exist. It is simply not the dimension anybody standing in a finished room and holding a tape has ever wanted. You cannot put a 5.4 m bookcase in a 5.15 m room, and the tape was cheerfully telling you that you could.

What makes this worth a decision record rather than a commit message is how it was caught. Not by looking at the screen: on screen the dimension line ran wall to wall and looked entirely plausible, with a number under it that was the right order of magnitude and had a believable number of decimals. It was caught by dumping the collider list and reading the coordinates before trusting them. A wrong answer that looks right is the expensive kind, and 5.455 looks extremely right.

A rendered corner of a living room where a plastered pier meets a full-height
                garden window, seen at an angle so the depth of the wall's return face is
                visible against the glass, with a bookshelf on the left.
The pier between the garden doors and the window, seen edge on. The lit return face is the wall's thickness — the eighteen centimetres between the surface you can touch and the line the collision solver actually stores.

Vertices only, never edges

The decision, written down as ADR-0012, is one sentence: the only snap is to a vertex of the triangle the crosshair actually hit.

The raycaster returns an intersection carrying the face it landed on. Fetch that face's three vertices, transform them into world space, and take the nearest one inside a radius:

function nearestTriangleVertex(hit: THREE.Intersection, radius: number): number | null {
  const face = hit.face;
  const mesh = hit.object as THREE.Mesh;
  const position = (mesh.geometry as THREE.BufferGeometry | undefined)?.attributes['position'] as
    | THREE.BufferAttribute
    | undefined;
  if (!face || !position) return null;

  let bestDistance = radius;
  let found = false;
  for (const index of [face.a, face.b, face.c]) {
    SCRATCH.fromBufferAttribute(position, index).applyMatrix4(mesh.matrixWorld);
    const d = SCRATCH.distanceTo(hit.point);
    if (d < bestDistance) {
      bestDistance = d;
      VERTEX.copy(SCRATCH);
      found = true;
    }
  }
  return found ? bestDistance : null;
}

Three candidates, one loop, no data structure. Mesh vertices sit on the surfaces you can actually touch, so a wall-to-wall measurement is the clear internal dimension by construction rather than by correction. They also cover furniture corners, window sills and door heads with one mechanism and no knowledge of what any of those things are — the tape does not know a sill from a shelf, and does not need to.

And when nothing is within the radius, the raw hit is kept. An unsnapped point lands on the face anyway, so aiming at the middle of a plain wall is already exact. There is no worse fallback to degrade into.

The radius is angular rather than fixed: hit.distance times the tangent of 1.5°, clamped between 4 cm and 25 cm. A corner across the room is a small target on screen and gets a small tolerance in metres; one at arm's length gets a generous one. That is the same tolerance in the only unit that matters here, which is pixels.

The word vertices is doing real work in that sentence, and the comment in the source says why: a box face is two triangles, so its edge set includes a diagonal across the face that is not an edge of anything. Snapping to the nearest point on an edge would sometimes land you in the middle of a wall, on a seam that exists only because a quad has to be triangulated. Corners are real. Edges are an implementation detail wearing a corner's clothes.

The axis lock does most of the work

Snapping to a triangle's vertex needs the ray to hit that triangle, which is less reliable at a glancing angle than a proximity test against a point list would have been. That cost is real, and it is paid by something else.

Almost every measurement anyone takes in a room is meant to be square — across, or floor to ceiling — and by hand you are always a degree or two out, which turns a 240.0 into a 240.1 and quietly makes the answer wrong. So a run within 4° of an axis is squared onto it, and one within 3° of horizontal is levelled. The locks combine: a run can be both level and along X.

Squaring a nearly-axial run is what actually makes wall-to-wall exact, and it is verified against the plan at 515.0 cm across and 1050.0 cm down the length of the house. The vertex snap gets you to the right surface; the axis lock gets you the right number once you are there.

The order matters too, and it is the sort of thing that is obvious only after it has bitten you. Snap first, then lock the axis, then clip at the wall. Squaring a run can push its endpoint into masonry, so it has to be the squared run that gets cut. Clipping first and locking afterwards puts the endpoint back inside the wall.

Colliders kept one job, and it is a good one

Collision spans were not thrown away. They do one thing for the tape: stop a run at the first wall it crosses, so a measurement taken through a wall reports the room you are in rather than the neighbour's kitchen. That job wants centre lines, because segmentsIntersect() is the same function the walking solver uses, and sharing it means the tape and your feet can never disagree about what is solid.

Once a crossing is found the endpoint is backed off along the run by half the wall's thickness — divided by the sine of the angle between run and wall, so a tape meeting the plaster at a shallow angle retreats further, as it must. The stored line goes back to being a line, and the reported point goes back to being a surface.

Two exceptions had to be carved out, and both are the good kind of special case: the ones that name a real distinction rather than paper over a bug.

/** Masonry stops a tape. Openings and staircases do not. */
function isMasonry(collider: Collider2D): boolean {
  // A doorway is a hole in the wall — you can see and walk through it, so a tape
  // goes through it too. A staircase blocks walking but is not a wall; stopping a
  // measurement at the bottom step would be nonsense.
  return collider.doorId === undefined && collider.kind !== 'stair';
}

Collider2D.kind exists in this codebase for exactly one reason: to stop the tape stopping at the bottom step. A staircase is an obstacle to a body and not to a measurement, and there is no way to know that from geometry alone.

What it costs

Being honest about the trade is the point of writing these down at all.

Vertex snapping is less forgiving than a point list. Aim at a corner from a very oblique angle and the ray may hit the neighbouring triangle, whose vertices are elsewhere; you get an unsnapped point on a face instead of the corner you wanted. It is a smaller failure than a wrong number, but it is a failure, and the axis lock is what keeps it from mattering in the measurements people actually take.

It is also per-frame work in the render loop rather than a lookup, and it depends on geometry being current — the raycaster reads matrixWorld, which only the renderer refreshes, and doors swing and furniture gets placed mid-frame. Both roots are brought up to date before every cast rather than trusting the frame order. That was learned from freshly placed items being unhittable for exactly one frame.

And nothing here validates the numbers on the drawing. If the floorplan says a wall is 0.36 m thick and the real one is 0.31, the tape will confidently report a wrong room, correctly.

The part worth taking away

None of this is really about tape measures. It is about a list that was already in memory, already correct, and already shaped like the thing that was needed.

The collider endpoints were not approximately the right points — they were exactly room corners and door jambs, provably, for a reason you could explain in one sentence. Everything about them was right except the semantics of the coordinate stored in them, and semantics is the one property a type checker will not check, a screenshot will not reveal, and a plausible-looking number will actively hide.

So the rule we ended up with is duller than the geometry: when a value is about to be reused for a second purpose, check what it means and not merely what it contains. A centre line and a surface are both a number of metres along the X axis. One of them will fit your bookcase.

You can check ours. The tape is under M in the app, and the same rule is available to a script through the public API — duimstok.measure([0.05, 1.2, 3], [5.1, 1.2, 3]) returns { metres: 5.05, clipped: false }, and walking either endpoint into the plaster reports the clipped face rather than the line behind it.