Download scripts/import_room.py from MacroSwan/cablepilot: direct link, hf CLI and curl.
- Browser
- Download file 20.8 kB
-
https://huggingface.co/MacroSwan/cablepilot/resolve/main/scripts/import_room.py
- Command line
-
hf download hf://MacroSwan/cablepilot/scripts/import_room.py
-
curl -L -o import_room.py https://huggingface.co/MacroSwan/cablepilot/resolve/main/scripts/import_room.py
20.8 kB
| """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() | |