File size: 20,796 Bytes
6628826
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
"""Import a real Stringman room from a naavox LeRobot dataset into the twin: geometry, named places, the floor image.

    hf download naavox/playroom-sep29-2 --repo-type dataset --local-dir data/hf/playroom-sep29-2
    python scripts/import_room.py --dataset data/hf/playroom-sep29-2 --conf conf_playroom --calibrations <fw>/src/nf_robot/ml/calibrations --name playroom
    python scripts/import_room.py --dataset data/hf/bedroom-sep6 --conf conf_bedroom_move_clutter_2 --name bedroom
    uv run python scripts/import_room.py --building commercial_real_estate --office r5 --name cre_r5

A BUILDING (the room layer, milestone R, 2026-10-13): one office cut out of a scanned building
(~/robot-lab/buildings/<name>/, shared by every robot; see import_building's docstring). Writes configs/rooms/<name>.yaml
and data/rooms/<name>/ (visual meshes, the fixed map's points; outside git, rebuilt by this command).

Writes configs/rooms/<name>.yaml and assets/rooms/<name>/floor.png. Everything comes from the recording itself, in its
own room frame (fw ml/calibrations/readme.md: a config is only valid for the sessions it was recorded with):
  - anchors: the `anchor_poses` feature (datasets from August 2026 on carry it per frame; constant within a session)
  - named places: observation.state `<place>_bearing` / `_distance`, which are in the GRIPPER frame:
        place = gripper_pos_xy + distance * (sin(a), cos(a)),  a = bearing + spin
    (found 2026-10-08 by trying the conventions: this one puts every frame of playroom-sep29-2 on the same point, std
    0.00 m; the others scatter by 0.6-1.8 m). A place whose solution is the origin is unset.
  - floor: the median of the episodes' first overhead frames. The overhead camera is the firmware's orthographic floor
    view (host/floor_view.py: room origin at the image centre, +y up, 5 m across), so it drops onto the twin's floor
    at its true scale. Static clutter stays in it: it is the room's look, not something to pick.
  - eyelets (the indirect lines' far corners) are NOT in the recording: taken from the room's calibration file
    (fw ml/calibrations) through the 2D similarity that maps that file's two anchors onto the recording's, z = the
    recording's anchor height. APPROXIMATE (that file is an older calibration of the same room).
"""
from __future__ import annotations

import argparse
import json
import os
from pathlib import Path

import sys

import numpy as np
import yaml

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
# the firmware's calibration files (cranebot3-firmware src/nf_robot/ml/calibrations): --calibrations or $NF_CALIBRATIONS
FW_CAL = Path(os.environ.get("NF_CALIBRATIONS", "nf_robot/ml/calibrations"))
PLACES = ("hamper", "toybox", "trashcan", "gamepad", "parking_location")
EXTENT_M = 5.0          # fw host/floor_view.EXTENT_M


def similarity_2d(src: np.ndarray, dst: np.ndarray):
    """Scale, rotation, translation mapping 2 src points onto 2 dst points (complex-number form)."""
    s0, s1 = complex(*src[0]), complex(*src[1])
    d0, d1 = complex(*dst[0]), complex(*dst[1])
    a = (d1 - d0) / (s1 - s0)
    b = d0 - a * s0
    return lambda p: np.array([(a * complex(*p) + b).real, (a * complex(*p) + b).imag]), abs(a)


def floor_median(ds: Path, n_max: int = 60) -> np.ndarray:
    import av
    import pandas as pd
    ep = pd.read_parquet(ds / "meta/episodes/chunk-000/file-000.parquet")
    col = "videos/observation.images.overhead_camera/from_timestamp"
    fidx = "videos/observation.images.overhead_camera/file_index"
    ts = sorted(ep.loc[ep[fidx] == 0, col].values[:n_max] + 0.5)
    c = av.open(str(ds / "videos/observation.images.overhead_camera/chunk-000/file-000.mp4"))
    s = c.streams.video[0]
    s.thread_type = "AUTO"
    out, i = [], 0
    for f in c.decode(s):
        t = float(f.pts * s.time_base)
        while i < len(ts) and t >= ts[i]:
            out.append(f.to_ndarray(format="rgb24"))
            i += 1
        if i >= len(ts):
            break
    return np.median(np.stack(out), 0).astype(np.uint8), len(out)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dataset")
    ap.add_argument("--conf", help="fw ml/calibrations/<conf>.json for the eyelets")
    ap.add_argument("--calibrations", help="the firmware's ml/calibrations folder (default $NF_CALIBRATIONS)")
    ap.add_argument("--building", help="a scanned building (~/robot-lab/buildings/<name>): import one office of it")
    ap.add_argument("--office", help="the office's id in building.yaml (e.g. r5)")
    ap.add_argument("--anchor-z", type=float, default=2.65, help="mount height: ceiling 2.85 less the winch block")
    ap.add_argument("--name", required=True)
    args = ap.parse_args()
    if args.building:
        return import_building(args)
    import pandas as pd
    ds = Path(args.dataset)
    info = json.loads((ds / "meta/info.json").read_text())
    df = pd.read_parquet(ds / "data/chunk-000/file-000.parquet")
    names = info["features"]["observation.state"]["names"]
    S = np.stack(df["observation.state"].values)
    col = lambda k: S[:, names.index(k)]  # noqa: E731
    ap_ = np.stack(df["anchor_poses"].values)
    assert len(np.unique(ap_.round(4), axis=0)) == 1, "anchors moved within the recording"
    a = ap_[0].reshape(2, 6)                                     # rx, ry, rz, x, y, z per anchor
    anchors = a[:, 3:6]

    places = {}
    g = np.c_[col("gripper_pos_x"), col("gripper_pos_y")]
    for p in PLACES:
        ang = col(f"{p}_bearing") + col("spin")
        P = g + col(f"{p}_distance")[:, None] * np.c_[np.sin(ang), np.cos(ang)]
        med, sd = np.median(P, 0), P.std(0).max()
        if sd < 0.02 and np.linalg.norm(med) > 0.05:
            places[p] = [round(float(med[0]), 3), round(float(med[1]), 3)]

    conf = json.loads((Path(args.calibrations or FW_CAL) / f"{args.conf}.json").read_text())
    ca = np.array([[x["pose"]["position"][k] if "pose" in x else x["position"][k] for k in "xy"] for x in conf["anchors"]])
    ce = np.array([[x["indirectLine"]["eyeletPos"][k] for k in "xy"] for x in conf["anchors"]])
    f, scale = similarity_2d(ca, anchors[:, :2])
    eyelets = np.array([f(e) for e in ce])
    z = anchors[:, 2]
    # line order (fw-sim): 0 anchor0 direct, 1 anchor0 indirect via its eyelet, 2 anchor1 direct, 3 anchor1 indirect
    mounts = [anchors[0, :2], eyelets[0], anchors[1, :2], eyelets[1]]
    mz = [z[0], z[0], z[1], z[1]]
    runs = [0.0, float(np.linalg.norm(eyelets[0] - anchors[0, :2])), 0.0, float(np.linalg.norm(eyelets[1] - anchors[1, :2]))]

    img, n = floor_median(ds)
    out_dir = ROOT / "assets/rooms" / args.name
    out_dir.mkdir(parents=True, exist_ok=True)
    from PIL import Image
    Image.fromarray(img).save(out_dir / "floor.png")
    gan = np.c_[col("gantry_position_x"), col("gantry_position_y"), col("gantry_position_z")]
    room = {
        "source": {"dataset": f"naavox/{ds.name}", "frames": int(len(df)), "conf_for_eyelets": args.conf,
                   "eyelet_similarity_scale": round(float(scale), 3), "floor_median_of": int(n)},
        "mounts_xyz": [[round(float(m[0]), 3), round(float(m[1]), 3), round(float(zz), 3)] for m, zz in zip(mounts, mz)],
        "indirect_run_m": [round(r, 3) for r in runs],
        "ceiling_m": round(float(z.max()) + 0.13, 3),           # docs: anchors ~13 cm below the ceiling
        "named_places": places,
        "floor_image": {"path": f"assets/rooms/{args.name}/floor.png", "extent_m": EXTENT_M, "centre": [0.0, 0.0]},
        "recorded_gantry_box": [[round(float(gan[:, i].min()), 2), round(float(gan[:, i].max()), 2)] for i in range(3)],
        "anchor_yaw_rad": [round(float(a[0, 2]), 3), round(float(a[1, 2]), 3)],
    }
    path = ROOT / "configs/rooms" / f"{args.name}.yaml"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(f"# Imported by scripts/import_room.py from naavox/{ds.name} (see its docstring for each field's source).\n"
                    + yaml.safe_dump(room, sort_keys=False))
    print(path.read_text())


# ----------------------------------------------------------------------------------------------- a scanned building
def floor_field(P, box, fz, cell=0.25):
    """The scanned floor's height as a smooth field f(x, y): the scan is not flat. r5's floor points wander from -6.7 to
    +1.0 cm (0.6 m cell medians, 2026-10-13; a RANSAC plane came out 1.05 deg tilted and left the open floor at
    +1.7 cm median): Polycam drift, not a sloping office. Per 25 cm cell: the median of the points within 4 cm of
    the cell's 10th percentile (the floor, not a chair leg), cells with < 20 such points filled from the nearest
    measured ones (under the table), then smoothed (sigma 2 cells). Returns f (callable on (n, 2)), its grid."""
    from scipy import ndimage
    from scipy.interpolate import NearestNDInterpolator, RegularGridInterpolator
    x0, x1, y0, y1 = box
    Q = P[(P[:, 0] > x0) & (P[:, 0] < x1) & (P[:, 1] > y0) & (P[:, 1] < y1) & (np.abs(P[:, 2] - fz) < 0.12)]
    nx, ny = int(np.ceil((x1 - x0) / cell)), int(np.ceil((y1 - y0) / cell))
    ij = np.minimum(((Q[:, :2] - [x0, y0]) / cell).astype(int), [nx - 1, ny - 1])
    G = np.full((nx, ny), np.nan)
    key = ij[:, 0] * ny + ij[:, 1]
    order = np.argsort(key)
    ks, starts = np.unique(key[order], return_index=True)
    for k, a, b in zip(ks, starts, np.r_[starts[1:], len(order)]):
        z = Q[order[a:b], 2]
        lo = np.percentile(z, 10)
        z = z[z < lo + 0.04]
        if len(z) >= 20:
            G[k // ny, k % ny] = np.median(z)
    ok = ~np.isnan(G)
    ii, jj = np.nonzero(ok)
    fill = NearestNDInterpolator(np.c_[ii, jj], G[ok])
    ai, aj = np.nonzero(~ok)
    if len(ai):
        G[~ok] = fill(np.c_[ai, aj])
    G = ndimage.gaussian_filter(G, 2.0, mode="nearest")
    xs, ys = x0 + (np.arange(nx) + 0.5) * cell, y0 + (np.arange(ny) + 0.5) * cell
    f = RegularGridInterpolator((xs, ys), G, bounds_error=False, fill_value=None)
    return lambda xy: f(np.clip(xy, [xs[0], ys[0]], [xs[-1], ys[-1]])), G, float(ok.mean())


def import_building(args):
    """One office of a scanned building -> a room for the cable robot.

      - mounts: the office's four corners at anchor height, found from the scan, not typed in: the free space in the
        band 1.0 m .. anchor_z + 0.15 m (wall points occupy a 4 cm grid; the anchor band alone leaked out of r5
        through two scan holes in its walls, 2026-10-13), the connected part around the office centre,
        eroded 5 cm (fw definitions: eyelets 5 cm off the walls); the corner = the free cell furthest along
        (+-1, +-1). Order as scripts/make_room3d.py (CCW from +x+y; lines 0, 2 direct, 1, 3 via eyelets along a wall).
      - the room frame: origin on the floor under the mounts' centre, axes = the building's (pose in building.yaml)
      - the visual mesh: the OBJ's faces in the office box (+30 cm: its walls), the ceiling dropped (the overhead
        camera hangs under it), one mesh per texture. VISUAL ONLY in the plant (contype 0).
      - the fixed map's points: the PLY in the same crop (data/rooms/<name>/fixed_points.npy, xyz + rgb).
      - supports: the floor and each large horizontal surface (upward mesh faces 0.3-1.5 m, clustered by height and
        place, >= 0.25 m^2): a table top's height and box. The plant gets a thin collision box under each so waste
        can lie on it (the mesh itself has no contacts).
      - the bin (named place `hamper`): free floor near a wall, >= 0.6 m inside the mount polygon, nothing of the
        scan 0.1-1.5 m above within 0.45 m."""
    from scipy import ndimage
    from cablepilot.roommap.scan import load_building, load_mtl, load_obj, load_ply, obj_to_frame, write_obj
    bdir, B = load_building(args.building)
    off = B["offices"][args.office]
    x0, x1, y0, y1 = off["box"]
    fz = float(B["floor_z"])
    P, C = load_ply(bdir / B["files"]["points"])
    pad = 0.3
    near = (P[:, 0] > x0 - pad) & (P[:, 0] < x1 + pad) & (P[:, 1] > y0 - pad) & (P[:, 1] < y1 + pad)
    # the office's own floor, FOUND (armpilot scan_furniture: RANSAC, "found, not given"): the building's floor_z is
    # the whole floor's histogram peak, and r5's floor lies 2.2 cm under it (median, 2026-10-13); a level-floor
    # assumption left a speckle of "obstacle" voxels 2-4 cm up all over the room
    ffield, G, measured = floor_field(P, (x0 - pad, x1 + pad, y0 - pad, y1 + pad), fz)
    P = P.copy()
    P[near, 2] -= ffield(P[near, :2])                        # flatten (the same correction for the mesh below)
    fz_mean = float(np.mean(G))

    # mounts: free space at anchor height
    res = 0.04
    lo = np.array([x0 - pad, y0 - pad])
    shape = (np.array([x1 - x0, y1 - y0]) + 2 * pad) / res
    shape = shape.astype(int) + 1
    band = near & (P[:, 2] > 1.0) & (P[:, 2] < args.anchor_z + 0.15)
    occ = np.zeros(shape, bool)
    ij = ((P[band, :2] - lo) / res).astype(int)
    occ[ij[:, 0], ij[:, 1]] = True
    occ = ndimage.binary_closing(occ, iterations=2)          # the scan's 2.2 cm points leave pinholes in a wall
    lab, _ = ndimage.label(~occ)
    c_ij = ((np.array([(x0 + x1) / 2, (y0 + y1) / 2]) - lo) / res).astype(int)
    free = ndimage.binary_erosion(lab == lab[tuple(c_ij)], iterations=2)   # 2 cells = 8 cm from a wall point's cell
    fi, fj = np.nonzero(free)
    cells = lo + (np.c_[fi, fj] + 0.5) * res
    M = []
    for d in ((1, 1), (-1, 1), (-1, -1), (1, -1)):
        M.append(cells[np.argmax(cells @ np.array(d, float))])
    M = np.array(M)
    O = M.mean(0).round(3)                                   # the room frame's origin (floor under the mounts' centre)
    mounts = np.c_[M - O, np.full(4, args.anchor_z)]
    runs = [0.0, float(np.linalg.norm(M[1] - M[0])), 0.0, float(np.linalg.norm(M[3] - M[2]))]

    out = ROOT / "data/rooms" / args.name
    out.mkdir(parents=True, exist_ok=True)
    keep = near & (P[:, 2] > -0.05) & (P[:, 2] < off["ceiling_m"] - 0.1)
    pts = np.c_[P[keep] - [*O, 0], C[keep]].astype(np.float32)
    np.save(out / "fixed_points.npy", pts)

    V, T, Fc, Mt = load_obj(bdir / B["files"]["mesh"])
    V = obj_to_frame(V)                                      # the mesh shares the PLY's frame (same capture)
    V[:, 2] -= ffield(V[:, :2])
    V = V - [*O, 0]
    A = V[Fc[:, :, 0]]
    nrm = np.cross(A[:, 1] - A[:, 0], A[:, 2] - A[:, 0])
    area = np.linalg.norm(nrm, axis=1) / 2
    up = nrm[:, 2] / (2 * area + 1e-12)
    cen = A.mean(1)
    bx0, bx1, by0, by1 = x0 - O[0] - pad, x1 - O[0] + pad, y0 - O[1] - pad, y1 - O[1] + pad
    inbox = (cen[:, 0] > bx0) & (cen[:, 0] < bx1) & (cen[:, 1] > by0) & (cen[:, 1] < by1)
    keepF = inbox & ~((cen[:, 2] > off["ceiling_m"] - 0.25) & (up < -0.5))
    mtl = load_mtl(bdir / B["files"]["mesh"].replace(" ", "_").replace(".obj", ".mtl"))
    meshes = []
    for k, mat in enumerate(sorted(set(Mt[keepF]))):
        sel = keepF & (Mt == mat)
        n = write_obj(out / f"mesh_{k}.obj", V, T, Fc[sel])
        from PIL import Image                                 # MuJoCo reads PNG textures, not the scan's JPEGs
        Image.open(bdir / mtl[mat]).convert("RGB").save(out / f"mesh_{k}.png")
        meshes.append({"obj": f"mesh_{k}.obj", "texture": f"mesh_{k}.png", "faces": int(n)})

    # supports: large horizontal surfaces above the floor
    hz = keepF & (up > 0.95) & (cen[:, 2] > 0.3) & (cen[:, 2] < 1.5)
    supports = [{"name": "floor", "z": 0.0}]
    g = 0.05
    zb = np.round(cen[hz, 2] / 0.02).astype(int)
    for zk in np.unique(zb):
        s = np.nonzero(hz)[0][np.abs(zb - zk) <= 1]
        if area[s].sum() < 0.25:
            continue
        q = ((cen[s, :2] - [bx0, by0]) / g).astype(int)
        grid = np.zeros((int((bx1 - bx0) / g) + 1, int((by1 - by0) / g) + 1), bool)
        grid[q[:, 0], q[:, 1]] = True
        grid = ndimage.binary_closing(grid, iterations=2)
        lab, nl = ndimage.label(grid)
        for L in range(1, nl + 1):
            mem = lab[q[:, 0], q[:, 1]] == L
            if area[s[mem]].sum() < 0.25 or any(abs(sp["z"] - np.average(cen[s[mem], 2], weights=area[s[mem]])) < 0.03
                                                 and sp.get("_l") == (zk, L) for sp in supports):
                continue
            xy = cen[s[mem], :2]
            zz = float(np.average(cen[s[mem], 2], weights=area[s[mem]]))
            if any(sp["name"] != "floor" and abs(sp["z"] - zz) < 0.03 and np.all(np.abs(np.mean(xy, 0) - np.mean(sp["box"], 1)) < 0.3)
                   for sp in supports):
                continue                                      # the same table seen in the neighbouring height bin
            bxy = np.percentile(xy, [3, 97], axis=0)
            supports.append({"name": f"top{len(supports)}", "z": round(zz, 3),
                             "box": [[round(float(bxy[0, 0]), 3), round(float(bxy[1, 0]), 3)],
                                     [round(float(bxy[0, 1]), 3), round(float(bxy[1, 1]), 3)]],
                             "area_m2": round(float(area[s[mem]].sum()), 2)})

    # one table, one support: the height bins overlap (r5's table came out three times, 0.710 / 0.717 / 0.718 m,
    # boxes inside each other, 2026-10-13): merge same-height (3 cm) supports whose boxes overlap into their union
    tops = [sp for sp in supports if "box" in sp]
    merged = True
    while merged:
        merged = False
        for i in range(len(tops)):
            for j in range(i + 1, len(tops)):
                a, b = tops[i], tops[j]
                (ax0, ax1), (ay0, ay1) = a["box"]
                (bx0_, bx1_), (by0_, by1_) = b["box"]
                if abs(a["z"] - b["z"]) < 0.03 and ax0 < bx1_ and bx0_ < ax1 and ay0 < by1_ and by0_ < ay1:
                    w = a["area_m2"] + b["area_m2"]
                    a["z"] = round((a["z"] * a["area_m2"] + b["z"] * b["area_m2"]) / w, 3)
                    a["box"] = [[min(ax0, bx0_), max(ax1, bx1_)], [min(ay0, by0_), max(ay1, by1_)]]
                    a["area_m2"] = round(max(a["area_m2"], b["area_m2"]), 2)       # nested bins: the larger, not the sum
                    tops.pop(j)
                    merged = True
                    break
            if merged:
                break
    supports = [supports[0]] + [{**t, "name": f"table{k}"} for k, t in enumerate(tops)]

    # the bin: free floor near a wall
    from cablepilot.sim.kin import ccw_polygon
    poly = ccw_polygon(mounts[:, :2])
    e = np.roll(poly, -1, 0) - poly
    nr = np.c_[-e[:, 1], e[:, 0]] / np.linalg.norm(e, axis=1)[:, None]
    tall = pts[(pts[:, 2] > 0.1) & (pts[:, 2] < 1.5), :2]
    from scipy.spatial import cKDTree
    tree = cKDTree(tall)
    best = None
    for x in np.arange(poly[:, 0].min(), poly[:, 0].max(), 0.05):
        for y in np.arange(poly[:, 1].min(), poly[:, 1].max(), 0.05):
            q = np.array([x, y])
            ins = ((q - poly) * nr).sum(1).min()
            if ins < 0.6 or tree.query(q)[0] < 0.45:
                continue
            if best is None or ins < best[0]:                 # nearest a wall (yet >= 0.6 m in)
                best = (ins, q)
    hamper = best[1].round(3).tolist()

    room = {"source": {"building": str(bdir).replace(str(Path.home()), "~"), "office": args.office, "imported_by": "scripts/import_room.py --building",
                       "scan_points_kept": int(keep.sum())},
            "floor_flattened": {"note": "scan z - f(x, y), f = the scanned floor's smooth height (floor_field)",
                                "f_range_m": [round(float(G.min()), 4), round(float(G.max()), 4)],
                                "cells_measured": round(measured, 2)},
            "building_pose": {"xyz": [float(O[0]), float(O[1]), round(fz_mean, 4)], "yaw_rad": 0.0,
                              "note": "room frame = building frame shifted by xyz (floor at z 0); same axes"},
            "size_m": [round(float(np.ptp(M[:, 0])), 3), round(float(np.ptp(M[:, 1])), 3)],
            "mounts_xyz": mounts.round(3).tolist(), "indirect_run_m": [round(r, 3) for r in runs],
            "ceiling_m": float(off["ceiling_m"]),
            "named_places": {"hamper": hamper},
            "scan": {"dir": f"data/rooms/{args.name}", "meshes": meshes, "points": "fixed_points.npy",
                     "box": [[round(float(bx0), 3), round(float(bx1), 3)], [round(float(by0), 3), round(float(by1), 3)]]},
            "supports": supports}
    path = ROOT / "configs/rooms" / f"{args.name}.yaml"
    path.write_text(f"# One office of a scanned building ({B['name']}, office {args.office}), imported by "
                    f"scripts/import_room.py --building (see import_building's docstring for each field's source).\n"
                    + yaml.safe_dump(room, sort_keys=False))
    print(path.read_text())


if __name__ == "__main__":
    main()