Compare commits

...
11 Commits
Author SHA1 Message Date
spoopyghosty0 84122ade16 Room tab: the right column sizes to its content (the Test Quest MR line was clipped when Delete room wrapped or the furniture chips took two rows); the top row stays level by measuring both sides (a short column's last card grows, a tall one makes the 3D view taller); box sizes under 10 cm show two decimals (a 3 cm rug was 0.0 m); room shots re-rendered 2026-10-19 00:13:55 -04:00
spoopyghosty0 318d0cc298 Docs screenshots: re-rendered with the Room tab in the sidebar 2026-10-18 23:50:34 -04:00
spoopyghosty0 c9b566c4e8 Docs: the Room tab (experimental) in the README features, INSTALL.md and the site's app tour; showcase shots room + room-editing from a made-up bedroom (bed under a wall TV, desk with two monitors under a window, closet, door, lamp, plant; noisy scan mesh, clean-up report and labels to match); steps open_room / room_editing 2026-10-18 23:50:29 -04:00
spoopyghosty0 8b817ff4e1 Room tab layout: the top row ends level (legend on the 3D view, the right column as tall as it), Test Quest MR as one line in In this room, equal cards and gaps, full-width sliders and segmented tracks, one slider per row in the box editor, dropdown and Delete the same height, even suggestion buttons, aligned Labels columns and counts; clean-up report follows the scanner's scan.json keys (walked_skipped as a note, truncated as a warning) 2026-10-18 23:19:29 -04:00
spoopyghosty0 aef3843f9c ui_smoke: Room tab editing steps (clean-up, more settings, box selected, doors, a suggestion taken, applying, applied); the pretend Frame cleans up and saves rooms; docs 2026-10-18 23:19:29 -04:00
spoopyghosty0 43d4a44b6b Room tab: Clean-up card (Recommended or your settings, the key ones in sight, More settings, Apply as a job, the last clean-up's counts), Labels card (rule decisions, Add as door / Add as closet), Furniture & screens editor (select in the list or by clicking the 3D view, type/move/size/turn, add/delete, doors and windows on a wall, Save) 2026-10-18 23:19:29 -04:00
spoopyghosty0 f87e112710 Rooms: REFINE_DEFAULTS (the owner's clean-up defaults + slider metadata), rooms.refine / put_room, doors and windows on a wall and suggestion helpers in mr/room, view3d furniture overlay + picking, CLI frameport frame room refine [--set key=value] [--reset] 2026-10-18 23:19:29 -04:00
spoopyghosty0 fb60e91181 Agent v107: room scan clean-up settings (rooms/refine.json, passed to every build), room_refine (fp_roomscan --refine-only on a copy of the room folder), raw files + scan.json kept with the room, room_status returns refine/report/labels/suggestions, edited rooms keep their boxes over a new clean-up 2026-10-18 23:19:29 -04:00
spoopyghosty0 e8c750bd53 Docs: room refinement (scan.md options + passes + parity, ROOM_FORMAT raw files and scan.json, CLAUDE.md MR line) 2026-10-18 23:04:58 -04:00
spoopyghosty0 20ae3bebab Tests: room refinement (floating pole + walked-through column removed, panels wind inward, --refine-only reproduces, --refine 0 = raw output, label rules on an older room folder) 2026-10-18 23:04:58 -04:00
spoopyghosty0 da58afa3e3 Room scanner: refinement + label rules after the mesh (raw files, scan.json, --refine-only)
The owner's tuned room-scan cleanup (2026-10-18) ported to C, pass for pass: plane offsets re-estimated from the
scan, one-sided structural bands, flat wall/floor/ceiling panels (or snap), near clearing, erase spheres, the walked
volume from the recording's head path, furniture tops, clipping with the unseen-edge allowance, islands, thin
floating pieces, Taubin smoothing; label rules on the scanner's volumes (merged/nested/outside boxes removed, shape
relabelling, SCREEN flatness from the raw mesh, door/closet suggestions in scan.json).

room.txt + room-mesh.bin are now refined (ceiling re-estimated); the unrefined output stays as room-raw.txt +
room-mesh-raw.bin with the same UUIDs; scan.json holds heads, settings, report, labels, suggestions. The scan path
refines its raw files as read back, so --refine-only on them reproduces the scan byte for byte; --refine 0 writes
the old output. The fusion grid is freed before the refinement (peak memory unchanged).
2026-10-18 23:04:41 -04:00
53 changed files with 4678 additions and 259 deletions

No files matched your search

+10 -2
View File
@@ -65,7 +65,15 @@ Detailed device findings per game/issue: docs/DEVICE_NOTES.md (read before debug
RoomCanvas over `mr/view3d.py` (pure-Python projection, mesh reduced to ≤4k/≤1.5k edges at rest/moving, ≤18 fps
worker; each redraw sends NEW shape controls: changing a Points list made Flet diff it item by item, 0.3-1 s);
"Open full 3D view" = `mr/room_viewer.py` token server on 127.0.0.1 + hand-written WebGL `mr/viewer_page.py`,
stopped on disconnect/close; Test Quest MR tip = `mr.TESTER_*`); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
stopped on disconnect/close; Test Quest MR tip = `mr.TESTER_*`; editing cards `views/room_edit.py` (agent v107):
Clean-up = fp_roomscan's refinement settings, `install/rooms.REFINE_DEFAULTS` = the owner's defaults + slider
metadata (agent `ROOM_REFINE_DEFAULTS` must match, a test checks), saved non-defaults in the Frame's
`rooms/refine.json`, used by every build; Apply = job → agent `room_refine` (`--refine-only` on a copy of the room
folder from `room-raw.txt`/`room-mesh-raw.bin`, scan.json → status `report`/`labels`/`suggestions`); Labels =
the label rules' decisions + Add as door / Add as closet; Furniture & screens = box editor (click a box in the
canvas: `view3d.pick`) + doors/windows on a wall (`mr.room.plane_on_wall`), Save = `rooms.put_room` → `room_put
edited` (keeps raw files, `edits.txt` survives a new clean-up; "Back to the scan's boxes" = keep_edits false); no
eraser in the GUI (`rooms.refine(erase=)` only)); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
(one `ft.ContextMenu` per view, filled on right-click; on one of several selected items they act on the whole
selection, `C.menu_targets`) and click-and-drag multi-select (`C.DragSelect`: pan start/end on the area + item
hover events, which Flutter also sends with the button held; Flet can't report item positions, so no rubber band).
@@ -598,7 +606,7 @@ Toy Master doesn't request a model after that (it uses its own), so the glb load
**Vader Immortal (GitHub #49):** stuck on its loading card and leaked GPU mappings (solved below: `gl_error_clear`); diagnostics FrameBridge `layer_debug`, `strip_color_bias`, `frame_balance`, `snapshot=N` (`snapshot_gl.c`: saves the submitted left-eye image as `files/fb_snap_*.ppm`). → docs/DEVICE_NOTES.md#vader-immortal-headless-investigation
**SteamVR overlay apps:** host OpenVR overlays composite over Lepton games; detection `analysis/vroverlay.py`, patch `pcvr.vr_overlay`, Linux fields `vr_overlay`/`vr_overlay_autostart`, agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay`; SteamVR on the Frame reads only `binary_path_linux_arm`; FramePort's own autostart (`ensure_overlay_service`, `frameport-vr-overlays.service`). See also docs/FRAME_RUNTIME.md "Overlay apps". → docs/DEVICE_NOTES.md#steamvr-overlay-apps
**Vader's leak solved / AC Nexus flips:** stale GL errors broke SteamVR's GL interop → adapter `gl_error_clear` (GLES, catalog for Vader ep1-3); read `xrclient_<pkg>.txt` in Steam's logs for runtime errors; the runtime caps swapchains per session (~15) → flip emulation in place; adapter `flip_dump`. → docs/DEVICE_NOTES.md#vaders-leak-solved--ac-nexus-flips
**Mixed reality: scanned rooms (branch mr-scene):** agent `room_capture` records Valve's RoomView 3D depth mesh -> `fp_roomscan` -> `room.txt` + `room-mesh.bin` (docs/mr/ROOM_FORMAT.md) in `~/.local/share/frameport/rooms/<universe>/`, copied next to settings.conf (`_room_sync`); FrameBridge (`scene_room.c/scene_meta.c/scene_anchors.c`, all through xrshim) serves walls, volumes, planes, the room mesh, META discovery/persistence, saved anchors and `XR_META_boundary_visibility`; `scene_emul` + `frame.meta_permissions` for every USE_SCENE/USE_ANCHOR_API game (migrations `scene_emul_all`, `scene_permissions`); per-game `scene_hide_bounds` (Demeter). → docs/DEVICE_NOTES.md#mixed-reality-scanned-rooms
**Mixed reality: scanned rooms (branch mr-scene):** agent `room_capture` records Valve's RoomView 3D depth mesh -> `fp_roomscan` -> `room.txt` + `room-mesh.bin` (docs/mr/ROOM_FORMAT.md; refined by default: the owner's cleanup passes + label rules in `native/roomscan/refine.c`/`rules.c`, unrefined output kept as `room-raw.txt` + `room-mesh-raw.bin`, head path/settings/report/labels/door suggestions in `scan.json`, `--refine-only <dir>` re-runs it, `--refine 0` = old output; docs/mr/scan.md "Refinement") in `~/.local/share/frameport/rooms/<universe>/`, copied next to settings.conf (`_room_sync`); FrameBridge (`scene_room.c/scene_meta.c/scene_anchors.c`, all through xrshim) serves walls, volumes, planes, the room mesh, META discovery/persistence, saved anchors and `XR_META_boundary_visibility`; `scene_emul` + `frame.meta_permissions` for every USE_SCENE/USE_ANCHOR_API game (migrations `scene_emul_all`, `scene_permissions`); per-game `scene_hide_bounds` (Demeter). → docs/DEVICE_NOTES.md#mixed-reality-scanned-rooms
**Unreal's self-restart:** UE 4.21+ restarts itself after the first shader precompile and Lepton ends the container → launch.sh RESTART_BLOCK starts Lepton once more (`upgrade_launchers`; `FRAMEPORT_NO_RESTART=1` off). → docs/DEVICE_NOTES.md#unreals-self-restart
**xrWaitFrame runs ahead:** the runtime sometimes returns a display time <1 refresh after the last submit and rejects the second submission (`VRCompositorError_AlreadySubmitted`) → the VrApi bridge re-waits (`pacing 2s:` counters). → docs/DEVICE_NOTES.md#xrwaitframe-runs-ahead-journey-of-the-gods
**Lepton's logcat mirror dies** (EOF, read failure, or silent) → launch.sh `_logcat_keeper` reads `podman exec <container> logcat` itself into launch.log (log `<base>/logcat-keeper.log`). → docs/DEVICE_NOTES.md#leptons-logcat-mirror-dies
+5
View File
@@ -36,6 +36,9 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
in FramePort.
- **Your Frame from your PC:** Type on Frame (your keyboard on the Frame), Files, Screenshots, Live view (what the
headset shows, in your browser) and Monitor (frame rate, temperatures, battery, processes).
- **Your room for mixed reality (experimental):** scan the room in the headset, see it in 3D, clean up the scan and
fix furniture, screen and door labels, so mixed-reality games get your room. Try it with
[Test Quest MR](https://github.com/spoopyghosty0/TestQuestMR).
- **Updates itself**, and reports problems without personal data.
Details for each: [Install and first steps](docs/INSTALL.md).
@@ -44,6 +47,8 @@ Details for each: [Install and first steps](docs/INSTALL.md).
![Screenshots](docs/images/screenshots.png)
![Room (experimental): the scanned room in 3D, clean-up and labels](docs/images/room.png)
## Quick start
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) the build for Windows, macOS (Apple
+207 -7
View File
@@ -47,7 +47,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 106
AGENT_VERSION = 107
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -4342,6 +4342,24 @@ ROOMS_DIR = os.path.join(HOME, ".local/share/frameport/rooms")
ROOM_SCANNER = os.path.join(HOME, ".local/share/frameport/bin/fp_roomscan")
ROOM_SYNC_LINE = ('python3 {agent_q} _room_sync {pkg_q} >"$app_dir/room-sync.log" 2>&1 9>&- || true '
'# frameport-room-sync')
# Scan clean-up (fp_roomscan's refinement + label rules): the owner's tuned defaults are compiled into the scanner;
# refine.json holds only the settings that differ from them (PC: install/rooms.REFINE_DEFAULTS, same keys).
ROOM_REFINE_FILE = os.path.join(ROOMS_DIR, "refine.json")
ROOM_REFINE_DEFAULTS = {
"refine": 1, "walls": "panels", "band": 0.35, "facing_deg": 60, "band_in": 0.12, "floor_in": 0.08,
"ceil_in": 0.25, "top_strip": 0.5, "near": 0.03, "clip": 0.05, "clip_open": 0.10, "island": 0.20, "smooth": 8,
"tops": 0.10, "protect": 0, "tubes": 3.0, "tube_thick": 0.15, "walked": 0.40, "label_rules": 1,
}
ROOM_REFINE_LIMITS = { # key -> (lowest, highest)
"refine": (0, 1), "band": (0, 2), "facing_deg": (1, 89), "band_in": (0, 2), "floor_in": (0, 2), "ceil_in": (0, 2),
"top_strip": (0, 3), "near": (0, 2), "clip": (0, 10), "clip_open": (0, 10), "island": (0, 10), "smooth": (0, 50),
"tops": (0, 1), "protect": (0, 1), "tubes": (0, 50), "tube_thick": (0, 2), "walked": (0, 3), "label_rules": (0, 1),
}
ROOM_REFINE_INTS = ("refine", "smooth", "protect", "label_rules")
# besides room.txt + its mesh + scan.log: the scanner's unrefined output and its clean-up report, and the player's
# own furniture / door edits (kept over a new clean-up)
ROOM_EXTRA_FILES = {"room-raw.txt": 8 * 1024 * 1024, "room-mesh-raw.bin": 64 * 1024 * 1024,
"scan.json": 8 * 1024 * 1024, "edits.txt": 1024 * 1024}
def room_universe(value):
@@ -4562,10 +4580,12 @@ def cmd_room_status(args):
continue
active = None
text = None
info = {}
if selected:
with open(os.path.join(selected, "room.txt")) as f:
text = f.read()
active = room_records(text)
info = room_scan_info(selected)
try:
_, points = room_boundary(room_chaperone(), current)
circle = room_default_circle(points)
@@ -4574,11 +4594,119 @@ def cmd_room_status(args):
return {"rooms": sorted(rooms, key=lambda r: r["time"], reverse=True), "universe": current,
"room": active, "room_text": text,
"aligned": bool(active and current and active["universe"] == current),
"default_circle": circle, "scanner_installed": os.access(ROOM_SCANNER, os.X_OK)}
"default_circle": circle, "scanner_installed": os.access(ROOM_SCANNER, os.X_OK),
# scan clean-up: the saved settings (non-defaults), what the last clean-up used, did and decided
"refine": room_refine_load(), "refine_used": info.get("refine"), "report": info.get("report"),
"labels": info.get("labels") or [], "suggestions": info.get("suggestions") or [],
"raw": bool(selected and os.path.isfile(os.path.join(selected, "room-raw.txt"))),
"edited": bool(selected and os.path.isfile(os.path.join(selected, "edits.txt")))}
def room_store(text, mesh=None, scan_log=None):
def room_refine_settings(settings):
"""Validated clean-up settings, only the keys that differ from the defaults ({} = the defaults)."""
import math
if not isinstance(settings, dict):
raise AgentError("clean-up settings must be an object")
out = {}
for key, value in settings.items():
if key not in ROOM_REFINE_DEFAULTS:
raise AgentError(f"unknown clean-up setting: {key}")
if key == "walls":
if value not in ("off", "snap", "panels"):
raise AgentError("walls must be off, snap or panels")
else:
if isinstance(value, bool):
value = int(value)
if not isinstance(value, (int, float)) or not math.isfinite(value):
raise AgentError(f"invalid clean-up setting: {key}")
lo, hi = ROOM_REFINE_LIMITS[key]
if not lo <= value <= hi:
raise AgentError(f"clean-up setting out of range: {key}")
if key in ROOM_REFINE_INTS:
if value != int(value):
raise AgentError(f"{key} must be a whole number")
value = int(value)
else:
value = float(value)
if value != ROOM_REFINE_DEFAULTS[key]:
out[key] = value
return out
def room_refine_load():
try:
with open(ROOM_REFINE_FILE) as f:
return room_refine_settings(json.load(f))
except (OSError, ValueError, AgentError):
return {}
def room_refine_args(settings, erase=None):
"""fp_roomscan options for the stored settings (only non-defaults: with none, an older scanner without the
clean-up still runs) and optional erase spheres [[x, y, z, r], ...] (or {x, y, z, r})."""
import math
args = []
for key, value in settings.items():
args += ["--" + key.replace("_", "-"), value if isinstance(value, str) else format(value, "g")]
if erase:
spots = []
for spot in erase:
if isinstance(spot, dict):
spot = [spot.get(k) for k in ("x", "y", "z", "r")]
if (not isinstance(spot, (list, tuple)) or len(spot) != 4
or not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
for v in spot) or spot[3] <= 0):
raise AgentError("invalid erase spot")
spots.append(",".join(format(float(v), ".4g") for v in spot))
if len(spots) > 500:
raise AgentError("too many erase spots")
args += ["--erase", ";".join(spots)]
return args
def room_extras(folder):
"""The extra files of a room folder (ROOM_EXTRA_FILES) as name -> bytes."""
out = {}
for name, limit in ROOM_EXTRA_FILES.items():
path = os.path.join(folder, name)
if os.path.isfile(path) and not os.path.islink(path):
with open(path, "rb") as f:
data = f.read(limit + 1)
if len(data) <= limit:
out[name] = data
return out
def room_scan_info(folder):
"""scan.json's clean-up settings, report, label decisions and door/closet suggestions ({} when there's none)."""
try:
with open(os.path.join(folder, "scan.json")) as f:
info = json.load(f)
except (OSError, ValueError, TypeError):
return {}
if not isinstance(info, dict):
return {}
out = {}
for key, kind in (("refine", dict), ("report", dict), ("labels", list), ("suggestions", list)):
if isinstance(info.get(key), kind):
out[key] = info[key]
return out
def room_merge_edits(text, edits):
"""The player's own furniture and doors/windows (volume + plane records) instead of the scanner's."""
keep = [line for line in text.splitlines() if line.split()[:1] not in (["volume"], ["plane"])]
mine = [line for line in edits.splitlines() if line.split()[:1] in (["volume"], ["plane"])]
merged = "\n".join(keep + mine) + "\n"
room_records(merged)
return merged
def room_store(text, mesh=None, scan_log=None, extras=None):
record = room_records(text)
for name in extras or {}:
if name not in ROOM_EXTRA_FILES:
raise AgentError(f"unexpected room file: {name}")
if record.get("mesh"):
if mesh is None:
raise AgentError("room mesh sidecar is missing")
@@ -4604,6 +4732,9 @@ def room_store(text, mesh=None, scan_log=None):
if scan_log:
with open(os.path.join(stage, "scan.log"), "w") as f:
f.write(scan_log)
for name, data in (extras or {}).items():
with open(os.path.join(stage, name), "wb") as f:
f.write(data)
shutil.rmtree(previous, ignore_errors=True)
if os.path.isdir(dest):
os.rename(dest, previous)
@@ -4639,7 +4770,15 @@ def cmd_room_put(args):
raise AgentError("linked mesh outside upload folder")
with open(mesh_path, "rb") as f:
mesh = f.read(64*1024*1024)
return {"room": room_store(text, mesh)}
extras = None
if args.get("edited"):
# the Room tab's furniture / door editor: the same room with the player's boxes. Keep the scan's raw files
# (a new clean-up starts from them) and remember the edits, so a new clean-up keeps them.
folder = os.path.join(ROOMS_DIR, room_universe(record["universe"]))
extras = room_extras(folder) if os.path.isdir(folder) and not os.path.islink(folder) else {}
extras["edits.txt"] = "".join(line + "\n" for line in text.splitlines()
if line.split()[:1] in (["volume"], ["plane"])).encode()
return {"room": room_store(text, mesh, extras=extras)}
def cmd_room_delete(args):
@@ -4688,7 +4827,8 @@ def cmd_room_build(args):
raise AgentError("Room scanner is not installed")
universe = room_universe(args.get("universe", room_current_universe()))
with tempfile.TemporaryDirectory(prefix=".build-", dir=ROOMS_DIR) as out:
result = subprocess.run([ROOM_SCANNER, "--offline", dump, "--out", out, "--universe", universe, "--json"],
result = subprocess.run([ROOM_SCANNER, "--offline", dump, "--out", out, "--universe", universe, "--json",
*room_refine_args(room_refine_load())],
capture_output=True, text=True, timeout=300)
if result.returncode:
raise AgentError("Room scan failed: " + result.stderr[-4000:])
@@ -4711,8 +4851,68 @@ def cmd_room_build(args):
scan_log += "\n" + log.read()
except OSError:
pass
room_store(text, mesh, scan_log)
return {"report": report, **cmd_room_status({})}
room_store(text, mesh, scan_log, room_extras(out))
return {**cmd_room_status({}), "report": report} # (the scanner's summary, not scan.json's)
def cmd_room_refine(args):
"""Clean up the saved room's scan again (fp_roomscan --refine-only on a copy of the room folder, from its raw
files), then publish it like room_store (games pick it up through room_sync).
settings: the clean-up settings to save (null = keep the saved ones); erase: [[x, y, z, r], ...] spheres of scan
to remove; keep_edits (default true): the player's own furniture / doors stay (false = back to the scan's)."""
settings = args.get("settings")
if settings is not None:
settings = room_refine_settings(settings)
else:
settings = room_refine_load()
options = room_refine_args(settings, args.get("erase"))
if args.get("settings") is not None:
os.makedirs(ROOMS_DIR, exist_ok=True)
_write_json(ROOM_REFINE_FILE, settings)
if not os.access(ROOM_SCANNER, os.X_OK):
raise AgentError("Room scanner is not installed")
selected = room_selected()
if not selected:
raise AgentError("No room saved yet")
with open(os.path.join(ROOMS_DIR, ".scan.lock"), "a") as lock: # not while a scan builds a room
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise AgentError("A room scan is running. Try again when it has finished.") from None
with tempfile.TemporaryDirectory(prefix=".refine-", dir=ROOMS_DIR) as stage:
for name in os.listdir(selected):
path = os.path.join(selected, name)
if os.path.isfile(path) and not os.path.islink(path):
shutil.copy2(path, os.path.join(stage, name))
result = subprocess.run([ROOM_SCANNER, "--refine-only", stage, *options, "--json"],
capture_output=True, text=True, timeout=300)
if result.returncode:
raise AgentError("Room clean-up failed: " + (result.stderr or result.stdout or "")[-4000:])
try:
summary = json.loads(result.stdout or "{}")
except ValueError:
summary = {}
with open(os.path.join(stage, "room.txt")) as f:
text = f.read()
record = room_records(text)
if str(record["universe"]) != os.path.basename(selected):
raise AgentError("Room clean-up returned the wrong universe")
extras = room_extras(stage)
edits = extras.get("edits.txt")
if edits is not None and not args.get("keep_edits", True):
del extras["edits.txt"]
elif edits is not None:
text = room_merge_edits(text, edits.decode("utf-8", "replace"))
mesh = None
if record.get("mesh"):
with open(os.path.join(stage, record["mesh"]["filename"]), "rb") as f:
mesh = f.read()
scan_log = None
if os.path.isfile(os.path.join(stage, "scan.log")):
with open(os.path.join(stage, "scan.log"), errors="replace") as f:
scan_log = f.read()
room_store(text, mesh, scan_log, extras)
return {"summary": summary if isinstance(summary, dict) else {}, **cmd_room_status({})}
# --- RoomView 3D capture (docs/mr/PHASE0.md) -------------------------------------------------------------------
+1 -1
View File
@@ -27,7 +27,7 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
63af0050054ad3705b17a0b00ce0c076ba1af0b73abd673ba6c588f64b4591cd ./linux-arm64-bin/fp_roomscan
2cc16b90abcd3d359f1658c581172bd650017bf2725e24268915f50904c000ec ./linux-arm64-bin/fp_roomscan
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
Binary file not shown.
+12
View File
@@ -192,8 +192,20 @@ Mixed-reality games ask for your room: walls, furniture and its surfaces. **Room
the SteamVR play area instead (no furniture).
- The room shows in 3D: drag to turn it, scroll to zoom, right-drag to move it, double-click to see all of it.
**Open full 3D view** shows every detail of the scan in your browser (a page on this PC only).
- **Clean-up** tidies every scan (flat walls, no scan behind walls or where you walked, no loose bits). The
recommended settings suit most rooms; change them (**More settings** has all of them) and press **Apply** to clean
up your saved scan again. Your settings are also used for your next scans.
- **Labels** shows what the scanner's boxes became and why. A tall, thin box by a wall can be **Add as door** or
**Add as closet**.
- **Furniture & screens**: pick a box in the list or click it in the 3D view to change its type, place or size, add
or delete boxes, and add doors and windows on a wall. **Save** sends them to the Frame; they stay when you clean up
the scan again (**Back to the scan's boxes** under Labels drops them).
- The free **Test Quest MR** app (Get Test Quest MR on the tab) shows what a game receives from your scan.
![Room](images/room.png)
![Room: a box picked to edit, and the labels of the clean-up](images/room-editing.png)
## Updating
FramePort checks for a new version at start and every 6 hours. When there is one, the Library shows **Update now**:
+4
View File
@@ -117,6 +117,10 @@ Hooks (`steps.HOOKS`) cover what a real Frame would do:
- `pairing_done`: the Frame ran the setup command, so FramePort connects.
- `live_stream`: a test picture through the real relay.
- `monitor_details`, `type_tab`, `select_files` and `settings_section`.
- `open_room` (the Room tab with the pretend Frame's room drawn) and `room_editing` (its editing cards: `cleanup`,
`more`, `box`, `doors`, `suggestion`). The room is a made-up bedroom built in code (`fakes.fake_room`, never a real
scan): a bed under a wall-mounted TV, a desk with two monitors under a window, a closet, a door, a lamp and a plant,
with a noisy scanned-surface mesh and a clean-up report and labels that match it.
In a video, clicking Install runs the scripted install, and the game is then installed on the pretend Frame.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 91 KiB

+32 -1
View File
@@ -65,8 +65,39 @@ float32 xyz[vertex_count], u32 indices[index_count] (triangles; counter-clockw
```
Coordinates in STAGE space. Kept below 200k triangles (decimated).
## Raw files and scan.json (scanner sidecars)
`fp_roomscan` refines its output by default (docs/mr/scan.md "Refinement"). `room.txt` + `room-mesh.bin` are always
the refined room (what games and the GUI read; the contract above is unchanged). Next to them:
- `room-raw.txt` + `room-mesh-raw.bin`: the scanner's unrefined output in the same formats (its `mesh` record names
`room-mesh-raw.bin`). Same UUIDs as the refined room.txt, so anchors stay the same when the room is refined again.
The refined room differs in: the ceiling height (re-estimated from the scan, wall tops follow it), the volumes
(after the label rules) and the mesh. Records the scanner doesn't model (e.g. `plane`) are copied verbatim.
- `scan.json` (UTF-8 JSON, `version` 1):
```
{"version": 1,
"heads": [[x, y, z], ...], # head path of the recording (STAGE), <= 2000 points, decimated evenly
"refine": {"refine": 1, "walls": "panels", "band": 0.35, ..., "erase": [[x, y, z, r], ...]},
# every refinement setting used (keys = the CLI options, docs/mr/scan.md)
"report": {"triangles", "kept", "structural", "near_walls", "outside", "small_pieces", "thin_pieces",
"erased", "walked", "tops", "panel_triangles", "truncated", "walked_skipped", "floor_y",
"ceiling_y", "boxes", "relabelled", "removed_boxes", "suggestions",
"plane_shifts": [{"kind": "wall"|"floor"|"ceiling", "wall": i, "shift": m}, ...]},
"labels": [{"from": "COUCH", "to": "TABLE"|null, "reason": "top 0.69 m", "suggest": "door"|"closet" (optional),
"id": "<volume uuid>", "box": {"cx", "cz", "top_y", "yaw_deg", "w", "d", "h"}}, ...],
# one per scanner volume, in room-raw.txt order; to = null: removed
"suggestions": [{"kind": "door"|"closet", "box": {...}, "wall": i, "along": t, "reason": "..."}, ...]}
# tall thin slabs; never applied by the scanner. wall = index of the nearest
# wall record in room-raw.txt, along = 0..1 position on it (from x0,z0)
```
`report` counts triangles of the raw mesh per pass (`panel_triangles` are added ones), `wall` in `plane_shifts`
indexes the wall records. Readers ignore unknown keys.
- `fp_roomscan --refine-only <dir>` re-runs only the refinement from these files (an older folder without them:
its room.txt + mesh are copied to the raw names first). Publishing order: [raw mesh, raw text,] `scan.json`,
`room-mesh.bin`, `room.txt`.
## Where the files live
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`).
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`, the raw files and
`scan.json` above).
`~/.local/share/frameport/rooms/current` = symlink to the newest room's folder.
- Per game: launch.sh copies the current universe's room (else `current`) to `<app_dir>/room.txt` (+ mesh) before
Lepton starts, next to `settings.conf`; FrameBridge reads `dirname($FRAMEBRIDGE_CONFIG)/room.txt`. No room ->
+124 -10
View File
@@ -2,7 +2,9 @@
`fp_roomscan` converts recorded FPRS v1 meshes to the [room contract](ROOM_FORMAT.md), including the optional
wall label adopted from upstream commit `8cb2783`:
`room.txt` and `room-mesh.bin`. It does not capture RoomView queues, contact a headset, install rooms, or modify
`room.txt` and `room-mesh.bin`, refined by default (see "Refinement" below), plus the unrefined `room-raw.txt` +
`room-mesh-raw.bin` and `scan.json` (head path, settings, per-pass report, label decisions, door/closet
suggestions; ROOM_FORMAT.md "Raw files and scan.json"). It does not capture RoomView queues, contact a headset, install rooms, or modify
the current-room symlink. Scanner work changes `native/roomscan/`, its build target and artifact/checksum,
`scripts/mr/synth_scan.py`, `tests/test_roomscan.py`, and this note. The upstream contract commit also brings its
existing prompt assets and renderer unchanged. No runtime, agent, patch, or analysis versions were bumped.
@@ -17,7 +19,7 @@ python native/build.py --only roomscan
The result is `artifacts/linux-arm64-bin/fp_roomscan`, a static aarch64 Linux ELF with its own `_start`, raw Linux
syscalls, and no interpreter, dynamic section, libc, libm, UAPI headers, generated tables, or new dependencies.
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same five C sources compile on the
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same eight C sources compile on the
host:
```sh
@@ -34,8 +36,13 @@ exactly one JSON summary, whether `--json` is supplied or omitted. Success exits
{"ok":true,"frames":30,"skipped_frames":0,"far_vertices":0,"discontinuity_triangles":0,
"walls":4,"detected_walls":4,"volumes":2,"floor_y":-0.0023,
"ceiling_y":2.5992,"coverage_percent":100.0,"hint_used":false,"triangles":70997,
"accepted_triangles":118804,"rejected_triangles":704,"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"total":0}}
"accepted_triangles":118804,"rejected_triangles":704,"heads":28,
"refine":{"triangles":70997,"kept":7159,"structural":46121,"near_walls":148,"outside":0,"small_pieces":38,
"thin_pieces":0,"erased":0,"walked":203,"tops":1896,"panel_triangles":12,"truncated":0,"walked_skipped":false,
"floor_y":-0.0051,"ceiling_y":2.6028,"boxes":2,"relabelled":1,"removed_boxes":0,"suggestions":0,
"plane_shifts":[{"kind":"wall","wall":0,"shift":-0.0004},...,{"kind":"ceiling","shift":-0.0021}],
"output_triangles":7171,"output_volumes":2},"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"refine":0,"total":0}}
```
The example above illustrates the schema; actual counts and timings depend on the input. Heights are absolute
@@ -43,7 +50,10 @@ STAGE coordinates. `frames` includes skipped frames; `skipped_frames`, `far_vert
`discontinuity_triangles` count input exclusions once, not once per pass. `walls` counts output polygon
edges including synthesized edges; `detected_walls` counts fitted physical planes. Coverage means the percentage
of the output perimeter supported by observed wall planes, **not camera coverage of all room surfaces**. Timings
include parsing, fusion, structural/furniture modelling, mesh generation, and total time including output.
include parsing, fusion, structural/furniture modelling, mesh generation, refinement, and total time including
output. The scanner fields describe the unrefined scan (`triangles`, `volumes`, `ceiling_y` are the raw files');
`refine` is the refinement report (same keys as scan.json's `report`) plus the refined `output_triangles` and
`output_volumes`. `heads` counts the head-path points kept for the walked-volume pass.
All frame headers, timestamps/vertices/poses, approximately unit quaternions, counts, indices and EOF boundaries
are checked before publication. A non-finite timestamp/pose/vertex or invalid tracking quaternion skips the entire
@@ -57,9 +67,10 @@ triangles fails. Empty frames may accompany useful frames. Structural corruption
bad counts or indices) remains fatal, preserves any existing output byte for byte and creates no output directory.
UUIDs come from `getrandom`, fresh per scan; mesh geometry is repeatable.
Both complete files are written to exclusive, randomly named temporary files, then individually renamed, with
the mesh first and `room.txt` last. A write failure removes the temporary files. These are atomic **file**
replacements, not a two-file filesystem transaction: the installer should scan into a fresh staging directory
All files are computed first, then each is written to an exclusive, randomly named temporary file and renamed, in
the order `room-mesh-raw.bin`, `room-raw.txt`, `scan.json`, `room-mesh.bin`, `room.txt` (meshes before their room
text, `room.txt` last). A write failure removes the temporary files. These are atomic **file** replacements, not a
multi-file filesystem transaction: the installer should scan into a fresh staging directory
and publish that directory/symlink after success. Do not run concurrent writers into the same output directory.
There is no fsync-based power-loss durability promise. Neither scan nor build writes application data outside
the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary directories.
@@ -111,6 +122,77 @@ the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary director
excess beyond that fails. Vertices are unshared triangle vertices, so at most 600,000 vertices. Structural
planes are semantic anchors, not substituted for the observed global mesh.
## Refinement
After the mesh, the scanner cleans the room the way the owner tuned it in the room-scan cleanup page (2026-10-18);
`native/roomscan/refine.c` and `rules.c` port that page's `refine()` and label rules pass for pass (local parity
check on the owner's recording: identical counts and bit-identical refined meshes, see below). `--refine 0`
switches it off: room.txt and room-mesh.bin are then byte-for-byte what the scanner wrote before refinement existed
(the raw files and scan.json are still written, so refinement can be switched on later without a new scan).
The input is exactly what the raw files hold: the raw room is written as text and read back (doubles parsed from the
4-decimal text, like the page reads it), the head path is rounded the same way. Therefore `--refine-only <dir>`
on the raw files reproduces the scan's refined files byte for byte. The mesh is welded at 1 mm (quantized, as the
page does) for the passes that need topology; the output is again unshared triangle vertices.
1. **Label rules** (`rules.c`, `--label-rules 0` off) on the scanner's volumes, using the raw room (walls, outline,
floor): a box that holds >= 60% of two boxes whose centres are >= 1 m apart covers several things (removed); a
box >= 75% inside a bigger box with a top no higher (+5 cm) is part of it (removed); a centre > 10 cm outside the
outline is behind a wall (removed). A tall thin slab from the floor (thinner than 22 cm, >= 1.8 m high, bottom
<= 0.3 m, at most 0.5 m behind the wall line) becomes a door (<= 1.1 m wide) or closet **suggestion** in
scan.json (box removed, nothing added: the GUI decides). Thin (< 22 cm) upright boxes are SCREEN only when
raised >= 0.3 m, top <= 2.2 m, width/height >= 1.2, within 15 degrees of the nearest wall (when closer than
0.35 m) and flat: the raw mesh's depth spread inside the box <= 3 cm (with >= 30 triangles; fewer = unknown,
accepted); otherwise removed. Then BED (top 0.25-0.65 m, area >= 2 m^2, long side 1.8-3 m), COUCH (top
0.35-0.6 m, long side >= 1.4 m), TABLE (top 0.6-1.1 m), STORAGE (>= 1.1 m high, >= 25 cm deep, within 0.35 m of a
wall); anything taller than 1.1 m otherwise is removed, the rest is OTHER. Every box gets a reason in scan.json.
These thresholds are constants in `rules.c` (the page's `RULES0`).
2. **Furniture**: triangles within 10 cm of a ruled box (after the rules) are furniture; the passes below never
classify, clear or walk them away, and the tubes pass keeps pieces that are mostly furniture.
3. **Planes**: seen walls (`WALL_FACE` records), floor and ceiling. Each plane's offset moves to the median signed
distance of the non-furniture triangles facing it (normal within `--facing-deg`) within `--band` of it (walls:
within their length + band), when there are more than 50. An unseen ceiling (floor + 2.5 m) moves to the real
one; the re-estimated ceiling is written to the refined room.txt (ceiling record and wall tops).
4. **Structural triangles**: facing a plane, not furniture, behind it by less than `--band`, in front of it by less
than `--band-in` (walls), `--floor-in`, `--ceil-in`; in a wall's top `--top-strip` below the ceiling the full
band applies in front too (the depth bends where wall meets ceiling). The nearest qualifying plane owns it.
5. **Erase spheres** (`--erase "x,y,z,r;..."`, from the GUI's eraser) drop every triangle whose centroid is inside.
6. **Walked volume** (`--walked` = radius R, 0 off): where the user stood, nothing solid can be. Non-furniture
triangles from 0.2 m above the floor up to 0.25 m above a head position, within R horizontally of it, go.
Needs the head path (scan.json); without it the pass is skipped (`walked_skipped`).
7. **Walls** (`--walls`): `panels` (default) removes the structural triangles, then non-furniture triangles closer
than `--near` to any plane (within the wall's extent), and adds flat panels: one quad per seen wall (moved by its
offset shift) from floor to ceiling, and the outline ear-clipped at the floor and the ceiling heights, all wound
counter-clockwise seen from inside. `snap` instead projects every vertex of a structural triangle onto its plane
(the last owner wins). `off` keeps them.
8. **Furniture tops** (`--tops`, 0 off): upward-facing (normal y > 0.8) triangles within `--tops` of a box top and
inside its footprint are flattened onto it.
9. **Clip**: centroids further than `--clip` outside the outline (`--clip` + `--clip-open` past an unseen wall), or
further than `--clip` above the ceiling / below the floor plane go. `--clip 1 --clip-open 2` switches it off.
10. **Islands**: connected pieces smaller than `--island` m^2 go (`--protect 1` keeps furniture triangles).
11. **Thin floating pieces** (`--tubes`, 0 off): pieces of >= 10 triangles, < 1 m^2, at most half furniture, whose
longest principal axis (vertex PCA, std) is >= `--tubes` times the second and the second <= `--tube-thick`.
12. **Smoothing**: `--smooth` Taubin iterations (lambda 0.5, mu -0.53) over the kept triangles.
Output: the kept scan triangles, then the panels (`refine.panel_triangles`), at most 200,000 triangles (more kept
triangles would be cut, `truncated`, with a warning). The refinement adds about 20 ms on the host for the owner's
64k-triangle scan; its buffers (welding hash, double positions, union-find, adjacency) stay below ~170 MiB for a
200k-triangle mesh and the fusion grid is freed before it runs, so peak memory is still the fusion phase's.
`--refine-only <room dir> [refinement options] [--json]` runs only steps 1-12 from `room-raw.txt` +
`room-mesh-raw.bin` (+ the head path from `scan.json`) and rewrites scan.json, room-mesh.bin and room.txt. A folder
from an older scanner without raw files: its room.txt + mesh become the raw files first (the room text is rewritten
with the mesh record pointing at `room-mesh-raw.bin`), a warning says so, and there is no head path. Its JSON:
`{"ok":true,"refine_only":true,"raw_triangles":..,"volumes":..,"floor_y":..,"ceiling_y":..,"heads":..,
"refine":{...},"warnings":[...],"timings_ms":{"refine":..,"total":..}}`.
Local parity (2026-10-18, private recording `room1.fprs`, not in the repository): the reference page's functions run
under node on this scanner's raw output give the same per-pass counts, the same label decisions and reasons, and
bit-identical refined meshes for the defaults, snap, off + protect, erase spheres + tubes 2, and a wide-band setting
(one triangle differs there: a facing test exactly at the threshold), and the same on an older 128k-triangle room
folder. Distance ties at outline corners decide which wall's clip allowance applies, so the corner distance uses
V8's Math.hypot algorithm.
Peak allocation stays below 260 MiB, including maximum frame buffers, the grid, model worklists and mesh buffers
(the latter phases do not retain frame buffers). Default synthetic scenes use a much smaller grid. Thresholds
are CLI options below or named constants next to their algorithms. No room-specific coordinates or dimensions are
@@ -143,6 +225,31 @@ Distances are metres, areas m², volumes m³. `--universe` accepts the full unsi
| `--gap` | 0.65 | 0.1–1.5, maximum adjacent-wall endpoint extrapolation |
| `--horizontal` | 0.85 | 0.6–0.99, minimum floor/ceiling/top normal cosine |
Refinement (all distances metres; defaults = the owner's settings):
| Option | Default | Accepted range / meaning |
| --- | ---: | --- |
| `--refine` | 1 | 0/1; 0 = the unrefined output in room.txt/room-mesh.bin |
| `--walls` | panels | `off`, `snap` (project onto the planes) or `panels` (flat wall/floor/ceiling panels) |
| `--band` | 0.35 | 0.02–1, structural band behind a surface; plane re-estimation window |
| `--facing-deg` | 60 | 5–89, maximum angle between a triangle's normal and the surface's |
| `--band-in` | 0.12 | 0–1, band in front of a wall (room side) |
| `--floor-in` | 0.08 | 0–1, band above the floor |
| `--ceil-in` | 0.25 | 0–1, band below the ceiling |
| `--top-strip` | 0.5 | 0–2, top part of a wall where the full band applies in front too |
| `--near` | 0.03 | 0–0.3, panels: also clear non-furniture scan this close to a plane |
| `--clip` | 0.05 | 0–1, clip distance outside the outline / beyond floor and ceiling |
| `--clip-open` | 0.10 | 0–2, extra allowance past unseen walls (`--clip 1 --clip-open 2` = no clipping) |
| `--island` | 0.20 | 0–5 m^2, minimum connected piece area (0 off) |
| `--smooth` | 8 | 0–50 integer, Taubin iterations |
| `--tops` | 0.10 | 0–0.5, flatten upward scan this close to a box top (0 off) |
| `--protect` | 0 | 0/1, islands never remove furniture triangles |
| `--tubes` | 3 | 0–20, elongation (PCA axis ratio) of a thin floating piece (0 off) |
| `--tube-thick` | 0.15 | 0.01–1, maximum second PCA axis (std) of a thin floating piece |
| `--walked` | 0.40 | 0 or 0.05–1.5, walked-volume radius around the head path (0 off) |
| `--label-rules` | 1 | 0/1, label rules on the scanner's volumes |
| `--erase` | none | `"x,y,z,r;..."`, up to 64 spheres (r 0–5 m) whose scan is removed |
Additional constants in `model.c` include RANSAC trials, normal agreement, wall span, furniture margins,
label height/area thresholds, box trimming and circle recognition. Fusion sampling/weight limits are in
`fusion.c`; mesh splat radii/decimation limits in `mesh.c`; hard resource limits in `scan.h`.
@@ -174,10 +281,17 @@ Incompatible inset play boundaries are ignored with a warning. Hint-completed ed
## Offline validation
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its 41 tests cover the noisy
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its tests cover the noisy
4 x 3 x 2.6 m room with table/couch, six-wall L, moving and rotated/translated mesh poses, ceiling fallback, a single
frame, missing walls, circular/matching hints, correct UUIDs/wire layout, interior mesh winding, triangle budget,
repeatable mesh bytes with fresh UUIDs, truncated/corrupt dumps, invalid counts/indices/poses and invalid CLI values.
Refinement tests: a floating pole and a column in the walked path (`synth_scan.py` `TUBE`/`GHOST`, `extra=`)
are meshed by the scanner and removed by the tubes / walked passes (each kept with its pass off, the table top kept);
panels wind into the room on the re-estimated planes; `--refine-only` reproduces a scan's files byte for byte;
`--refine 0` equals the raw output (and refining it later equals the default); scan.json settings/report/heads; head
path decimation to <= 2000 points; erase spheres; the label rules on a hand-written older room folder (merged,
nested, behind a wall, SCREEN from a flat panel, a door suggestion with wall and position, unknown records kept,
idempotent, `--label-rules 0`); invalid refinement options.
The test module takes about fourteen seconds including synthetic rendering and compilation (under the 60 s budget).
New cases cover non-finite frames before/after usable frames, per-vertex far rejection, depth jumps below the edge
limit, weak ceiling evidence, wall labels and a complete camera-fan scan. The deterministic stdlib renderer now
@@ -193,7 +307,7 @@ UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run ruff check scripts/mr/s
```
The synthetic camera fan passes AddressSanitizer + UndefinedBehaviorSanitizer. Two consecutive final native builds
produce identical SHA-256 `0d88e5adfa1a21da7e0cfec2698a835d7767961e15c0b41c84235fa24ae1e751` (54,248 bytes),
produce identical SHA-256 `2cc16b90abcd3d359f1658c581172bd650017bf2725e24268915f50904c000ec` (97,808 bytes, with refinement),
matching `artifacts/SHA256SUMS`. ELF inspection confirms aarch64, little-endian, no PT_INTERP / PT_DYNAMIC, and no
embedded local build paths.
+18
View File
@@ -108,3 +108,21 @@ shots:
steps:
- go: settings
- wait_for: "Portal (OLED)"
- name: room # experimental: the pretend Frame's made-up bedroom scan (fakes.fake_room), no real scan
docs: [README.md, docs/INSTALL.md]
viewport: [1280, 1690]
steps:
- hook: open_room
- wait_for: "Furniture & screens"
- park: true
- name: room-editing # a box picked in the list (its type, place and size) next to the clean-up's labels
docs: [docs/INSTALL.md]
viewport: [1280, 2000] # (tall enough that nothing scrolls)
steps:
- hook: open_room
- wait_for: "Furniture & screens"
- hook: {name: room_editing, args: [box]}
- park: true
crop: {anchor: "Furniture & screens", left: -26, top: -28, width: 670, height: 1262}
+2 -2
View File
@@ -209,8 +209,8 @@ def build_roomscan(tc: Path):
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlib", "-nostdlibinc", "-static",
"-fno-stack-protector", "-fno-builtin", "-ffp-contract=off", "-fno-math-errno", "-O2", "-Wall", "-Wextra", "-Werror",
"-fuse-ld=lld", "-Wl,--build-id=none", "-Wl,-z,max-page-size=65536", "-e", "_start",
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "platform.c",
"-o", out / "fp_roomscan"], cwd=src)
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "refine.c", "rules.c",
"roomio.c", "platform.c", "-o", out / "fp_roomscan"], cwd=src)
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
+14
View File
@@ -52,6 +52,19 @@ void warn(Scan *s, const char *m)
for (int i = 0; i < s->nwarnings; i++) if (same(s->warnings[i],m)) return;
if (s->nwarnings < 24) s->warnings[s->nwarnings++] = m;
}
/* The head path (where the user stood and walked) for the refinement's walked-volume pass. Bounded: every
* head_stride-th tracked frame, halving the kept points and doubling the stride when the buffer is full. */
static void record_head(Scan *s, Vec eye)
{
if(!s->head_stride) s->head_stride=1;
if(s->head_frames++%s->head_stride) return;
if(s->nheads==MAX_HEADS) {
for(int i=0;i<MAX_HEADS/2;i++) s->heads[i]=s->heads[2*i];
s->nheads=MAX_HEADS/2;s->head_stride*=2;
if((s->head_frames-1)%s->head_stride) return;
}
s->heads[s->nheads++]=eye;
}
static u32 u32le(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static float f32le(const u8 *p) { union { u32 u; float f; } v = {u32le(p)}; return v.f; }
/* 1 = complete, 0 = clean EOF, -1 = I/O error or partial record. */
@@ -207,6 +220,7 @@ const char *read_dump(Scan *s, int fuse)
for (int i = 3; i < 7; i++) norm += pose[i]*pose[i];
if (!finite(norm) || ab(norm-1) > QUATERNION_NORM_TOLERANCE) invalid=1;
if(!invalid) for (int i = 3; i < 7; i++) pose[i] /= root(norm);
if(!fuse && !invalid) record_head(s,vec(pose[0],pose[1],pose[2]));
u32 nv = u32le(header+36), ni = u32le(header+40);
if (nv > MAX_FRAME_VERTICES || ni > MAX_FRAME_INDICES || ni%3 || (!nv && ni)) {
error = "invalid frame counts"; break;
+297 -129
View File
@@ -1,110 +1,10 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#include "refine.h"
typedef struct { char data[65536]; usize n; int ok; } Text;
static void text(Text *t, const char *p)
{
while(*p) {
if(t->n>=sizeof(t->data)) { t->ok=0;return; }
t->data[t->n++]=*p++;
}
}
static void integer(Text *t, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
while(n) { char a[2]={rev[--n],0};text(t,a); }
}
static void decimal(Text *t, float value)
{
if(value<0) { text(t,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
integer(t,scaled/10000);text(t,".");
u64 fraction=scaled%10000;
if(fraction<1000) text(t,"0");
if(fraction<100) text(t,"0");
if(fraction<10) text(t,"0");
integer(t,fraction);
}
static void space_number(Text *t, float n) { text(t," ");decimal(t,n); }
static int uuid(Text *t)
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { char pair[3]={hex[bytes[i]>>4],hex[bytes[i]&15],0};text(t,pair); }
return 1;
}
static int record(Text *t, const char *name)
{
text(t,name);text(t," ");return uuid(t);
}
static int path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
static void le32(u8 *out,u32 v) { for(int i=0;i<4;i++) out[i]=(u8)(v>>(8*i)); }
static const char *write_files(Scan *s)
{
Text t={.ok=1};text(&t,"frameport-room 1\nuniverse ");integer(&t,s->opt.universe);
text(&t,"\nsource scan\ntime ");integer(&t,(u64)(io_time(0)/1000000000));text(&t,"\n");
if(!record(&t,"room")) return "getrandom failed";
text(&t,"\n");if(!record(&t,"floor")) return "getrandom failed";
space_number(&t,s->floor);text(&t,"\n");if(!record(&t,"ceiling")) return "getrandom failed";
space_number(&t,s->ceiling);text(&t,"\noutline ");integer(&t,(u64)s->noutline);
for(int i=0;i<s->noutline;i++) { space_number(&t,s->outline[i].x);space_number(&t,s->outline[i].z); }
text(&t,"\n");
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
if(!record(&t,"wall")) return "getrandom failed";
space_number(&t,a.x);space_number(&t,a.z);space_number(&t,b.x);space_number(&t,b.z);
space_number(&t,s->floor);space_number(&t,s->ceiling);
if(s->invisible[i]) text(&t," INVISIBLE_WALL_FACE");
text(&t,"\n");
}
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];
if(!record(&t,"volume")) return "getrandom failed";
text(&t," ");text(&t,v->label);space_number(&t,v->p.x);space_number(&t,v->p.z);
space_number(&t,v->p.y);space_number(&t,v->yaw);space_number(&t,v->w);space_number(&t,v->d);space_number(&t,v->h);
text(&t,"\n");
}
if(!record(&t,"mesh")) return "getrandom failed";
text(&t," room-mesh.bin ");integer(&t,(u64)s->nv);text(&t," ");integer(&t,(u64)s->ni);text(&t,"\n");
if(!t.ok) return "room text exceeds output budget";
Text rname={.ok=1},mname={.ok=1};
text(&rname,"room.txt.");text(&mname,"room-mesh.bin.");
if(!uuid(&rname) || !uuid(&mname)) return "getrandom failed";
text(&rname,".tmp");text(&mname,".tmp");rname.data[rname.n]=0;mname.data[mname.n]=0;
char room[4096],mesh[4096],rtmp[4096],mtmp[4096];
if(!path(room,s->opt.out,"room.txt") || !path(mesh,s->opt.out,"room-mesh.bin") ||
!path(rtmp,s->opt.out,rname.data) || !path(mtmp,s->opt.out,mname.data)) return "output path too long";
if(!io_mkdir(s->opt.out)) return "cannot create output directory";
int fd=io_open(rtmp,1),ok;
if(fd<0) return "cannot open room temporary file";
ok=io_write(fd,t.data,t.n);if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);return "cannot write room temporary file"; }
fd=io_open(mtmp,1);
if(fd<0) { io_remove(rtmp);return "cannot open mesh temporary file"; }
u8 header[16]={'F','P','R','M'};le32(header+4,1);le32(header+8,(u32)s->nv);le32(header+12,(u32)s->ni);
ok=io_write(fd,header,16);
/* Both supported targets are little-endian IEEE float32; parser always decodes explicitly. */
ok=ok && io_write(fd,s->vertices,(usize)s->nv*sizeof(Vec));
ok=ok && io_write(fd,s->indices,(usize)s->ni*sizeof(u32));
if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);io_remove(mtmp);return "cannot write mesh temporary file"; }
if(io_rename(mtmp,mesh)) { io_remove(rtmp);io_remove(mtmp);return "cannot publish mesh"; }
if(io_rename(rtmp,room)) { io_remove(rtmp);return "cannot publish room"; }
return 0;
}
static int error_json(const char *error)
{
Text t={.ok=1};text(&t,"{\"ok\":false,\"error\":\"");text(&t,error);text(&t,"\"}\n");
io_write(1,t.data,t.n);return 2;
Buf t;buf_init(&t,4096);put(&t,"{\"ok\":false,\"error\":\"");put(&t,error);put(&t,"\"}\n");
io_write(1,t.data,t.n);buf_free(&t);return 2;
}
static int parse_float(const char *p,float *out)
{
@@ -125,21 +25,60 @@ static int parse_universe(const char *p,u64 *out)
}
*out=n;return 1;
}
/* "x,y,z,r;x,y,z,r;..." (metres, STAGE space) */
static int parse_erase(const char *value, Refine *o)
{
char *p=(char *)value;o->nerase=0;
while(*p) {
if(o->nerase==MAX_ERASE) return 0;
float v[4];
for(int i=0;i<4;i++) {
if(!parse_number(&p,&v[i])) return 0;
if(i<3) { if(*p!=',') return 0;p++; }
}
if(v[3]<=0 || v[3]>5) return 0;
o->erase[o->nerase++]=(Sphere){v[0],v[1],v[2],v[3]};
while(*p==' ') p++;
if(*p==';') p++;
else if(*p) return 0;
}
return 1;
}
static const char *options(Options *o,int argc,char **argv)
{
*o=(Options){.voxel=.05f,.ceiling_default=2.5f,.truncation=3,.max_edge=.6f,.grazing=.12f,.range=4.f,
.depth_jump=.25f,.depth_ratio=.12f,.min_views=5,.patch_residual=.03f,
.plane_distance=.045f,.min_plane_area=.45f,.wall_height=1.5f,.snap_degrees=5,.floor_band=.35f,
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f};
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f,
/* The owner's refinement settings (room-scan cleanup page, 2026-10-18). */
.rf={.refine=1,.walls=2,.protect=0,.label_rules=1,.smooth=8,.band=.35f,.facing_deg=60,.band_in=.12f,
.floor_in=.08f,.ceil_in=.25f,.top_strip=.5f,.near=.03f,.clip=.05f,.clip_open=.10f,.island=.20f,
.tops=.10f,.tubes=3,.tube_thick=.15f,.walked=.40f}};
for(int i=1;i<argc;i++) {
const char *arg=argv[i];
if(same(arg,"--json")) continue;
if(i+1==argc) return "option requires a value";
const char *value=argv[++i];
float number=0;
if(same(arg,"--offline")) o->input=value;
else if(same(arg,"--out")) o->out=value;
else if(same(arg,"--hint")) o->hint=value;
else if(same(arg,"--refine-only")) o->refine_only=value;
else if(same(arg,"--universe")) { if(!parse_universe(value,&o->universe)) return "invalid universe"; }
else if(same(arg,"--walls")) {
if(same(value,"off")) o->rf.walls=0;
else if(same(value,"snap")) o->rf.walls=1;
else if(same(value,"panels")) o->rf.walls=2;
else return "option outside supported range";
}
else if(same(arg,"--erase")) { if(!parse_erase(value,&o->rf)) return "invalid erase spheres"; }
else if(same(arg,"--refine") || same(arg,"--protect") || same(arg,"--label-rules") || same(arg,"--smooth")) {
int high=same(arg,"--smooth") ? 50 : 1;
if(!parse_float(value,&number) || number!=(float)(int)number || number>high) return "option outside supported range";
int *dest=same(arg,"--refine") ? &o->rf.refine : same(arg,"--protect") ? &o->rf.protect :
same(arg,"--label-rules") ? &o->rf.label_rules : &o->rf.smooth;
*dest=(int)number;
}
else {
float *dest=0,low=0,high=100;
if(same(arg,"--voxel")) { dest=&o->voxel;low=.04f;high=.2f; }
@@ -162,44 +101,265 @@ static const char *options(Options *o,int argc,char **argv)
else if(same(arg,"--cluster-gap")) { dest=&o->cluster_gap;low=.04f;high=.2f; }
else if(same(arg,"--gap")) { dest=&o->gap;low=.1f;high=1.5f; }
else if(same(arg,"--horizontal")) { dest=&o->horizontal;low=.6f;high=.99f; }
else if(same(arg,"--band")) { dest=&o->rf.band;low=.02f;high=1; }
else if(same(arg,"--facing-deg")) { dest=&o->rf.facing_deg;low=5;high=89; }
else if(same(arg,"--band-in")) { dest=&o->rf.band_in;high=1; }
else if(same(arg,"--floor-in")) { dest=&o->rf.floor_in;high=1; }
else if(same(arg,"--ceil-in")) { dest=&o->rf.ceil_in;high=1; }
else if(same(arg,"--top-strip")) { dest=&o->rf.top_strip;high=2; }
else if(same(arg,"--near")) { dest=&o->rf.near;high=.3f; }
else if(same(arg,"--clip")) { dest=&o->rf.clip;high=1; }
else if(same(arg,"--clip-open")) { dest=&o->rf.clip_open;high=2; }
else if(same(arg,"--island")) { dest=&o->rf.island;high=5; }
else if(same(arg,"--tops")) { dest=&o->rf.tops;high=.5f; }
else if(same(arg,"--tubes")) { dest=&o->rf.tubes;high=20; }
else if(same(arg,"--tube-thick")) { dest=&o->rf.tube_thick;low=.01f;high=1; }
else if(same(arg,"--walked")) { dest=&o->rf.walked;high=1.5f; }
else return "unknown option";
if(!parse_float(value,dest) || *dest<low || *dest>high) return "option outside supported range";
}
}
if(o->rf.walked>0 && o->rf.walked<.05f) return "option outside supported range";
if(o->refine_only) return o->input ? "--refine-only takes no --offline dump" : 0;
return o->input && o->out ? 0 : "required: --offline dump.fprs --out directory";
}
static void duration(Text *t,i64 a,i64 b) { decimal(t,(float)(b-a)/1000000); }
static void summary(Scan *s)
/* ------------------------------------------------------------------------------------------------ refinement */
typedef struct {
Report rep;
Label labels[MAX_VOLUMES];
int nlabels, changed, removed, nsuggest, out_triangles, out_volumes;
float ceiling_y;
i64 ms;
} Outcome;
static const char *copy_name(char *dst, const char *src, usize cap)
{
Text t={.ok=1};text(&t,"{\"ok\":true,\"frames\":");integer(&t,s->frames);
text(&t,",\"skipped_frames\":");integer(&t,s->skipped_frames);
text(&t,",\"far_vertices\":");integer(&t,s->far_vertices);
text(&t,",\"discontinuity_triangles\":");integer(&t,s->discontinuities);
text(&t,",\"walls\":");integer(&t,(u64)s->noutline);
text(&t,",\"detected_walls\":");integer(&t,(u64)s->nplanes);
text(&t,",\"volumes\":");integer(&t,(u64)s->nvolumes);
text(&t,",\"floor_y\":");decimal(&t,s->floor);text(&t,",\"ceiling_y\":");decimal(&t,s->ceiling);
text(&t,",\"coverage_percent\":");decimal(&t,s->coverage);
text(&t,",\"hint_used\":");text(&t,s->hint_used ? "true" : "false");
text(&t,",\"triangles\":");integer(&t,(u64)s->ni/3);
text(&t,",\"accepted_triangles\":");integer(&t,s->accepted);
text(&t,",\"rejected_triangles\":");integer(&t,s->rejected);
text(&t,",\"warnings\":[");
for(int i=0;i<s->nwarnings;i++) { if(i) text(&t,",");text(&t,"\"");text(&t,s->warnings[i]);text(&t,"\""); }
text(&t,"],\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
text(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
text(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
text(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
text(&t,",\"total\":");duration(&t,s->t0,io_time(1));text(&t,"}}\n");io_write(1,t.data,t.n);
usize i=0;while(src[i] && i+1<cap) { dst[i]=src[i];i++; }
dst[i]=0;return dst;
}
static void box_json(Buf *b, const RVol *v)
{
put(b,"{\"cx\":");put_dec(b,v->cx);put(b,",\"cz\":");put_dec(b,v->cz);put(b,",\"top_y\":");put_dec(b,v->top);
put(b,",\"yaw_deg\":");put_dec(b,v->yaw);put(b,",\"w\":");put_dec(b,v->w);put(b,",\"d\":");put_dec(b,v->d);
put(b,",\"h\":");put_dec(b,v->h);put(b,"}");
}
static void scan_json(Buf *b, const Refine *o, const Vec *heads, int nheads, const Outcome *r)
{
put(b,"{\"version\":1,\"heads\":");heads_json(b,heads,nheads);
put(b,",\"refine\":");settings_json(b,o);
put(b,",\"report\":");report_json(b,&r->rep,r->nlabels,r->changed,r->removed,r->nsuggest);
put(b,",\"labels\":[");
for(int i=0;i<r->nlabels;i++) {
const Label *l=&r->labels[i];
if(i) put(b,",");
put(b,"{\"from\":\"");put(b,l->from);put(b,"\",\"to\":");
if(l->to[0]) { put(b,"\"");put(b,l->to);put(b,"\""); } else put(b,"null");
put(b,",\"reason\":\"");put(b,l->reason);put(b,"\"");
if(l->suggest[0]) { put(b,",\"suggest\":\"");put(b,l->suggest);put(b,"\""); }
put(b,",\"id\":\"");put(b,l->box.id);put(b,"\",\"box\":");box_json(b,&l->box);put(b,"}");
}
put(b,"],\"suggestions\":[");
int first=1;
for(int i=0;i<r->nlabels;i++) {
const Label *l=&r->labels[i];
if(!l->suggest[0]) continue;
if(!first) put(b,",");
first=0;
put(b,"{\"kind\":\"");put(b,l->suggest);put(b,"\",\"box\":");box_json(b,&l->box);
put(b,",\"wall\":");put_int(b,l->wall);put(b,",\"along\":");put_dec(b,l->along);
put(b,",\"reason\":\"");put(b,l->reason);put(b,"\"}");
}
put(b,"]}\n");
}
/* Refines `raw` (as read back from its own text) and publishes: [raw mesh, raw text,] scan.json, room-mesh.bin,
* room.txt. Everything is computed before the first file is written, so a failure leaves the old files. */
static const char *refine_publish(const char *dir, const Room *raw, const Vec *v, const u32 *idx, int nv, int ni,
const Vec *heads, int nheads, const Refine *o, int write_raw, Outcome *res)
{
static Room refined;
static RVol ruled[MAX_VOLUMES];
const char *error=0;
Prep prep={0};
Vec *rv=0;u32 *ri=0;int rn=0;
u8 *raw_mesh=0,*mesh=0;usize raw_size=0,mesh_size=0;
Buf raw_text,text,json;
buf_init(&raw_text,1u<<20);buf_init(&text,1u<<20);buf_init(&json,MAX_JSON_FILE);
i64 t0=io_time(1);
memset(res,0,sizeof(*res));
refined=*raw;
copy_name(refined.mesh_name,"room-mesh.bin",sizeof(refined.mesh_name));
if(o->refine) {
error=prep_mesh(&prep,v,idx,nv,ni);
int nruled=raw->nvols;
if(!error) {
if(o->label_rules) res->nlabels=rules_apply(raw,&prep,ruled,&nruled,res->labels);
else memcpy(ruled,raw->vols,sizeof(RVol)*(usize)raw->nvols);
}
if(!error) error=refine_run(o,raw,&prep,ruled,nruled,heads,nheads,&rv,&ri,&rn,&res->rep);
if(!error) {
refined.ceiling=res->rep.ceiling_y;
for(int i=0;i<refined.nwalls;i++) if(refined.walls[i].y1==raw->ceiling) refined.walls[i].y1=refined.ceiling;
memcpy(refined.vols,ruled,sizeof(RVol)*(usize)nruled);refined.nvols=nruled;
refined.mesh_nv=refined.mesh_ni=(u32)rn;refined.has_mesh=1;
for(int i=0;i<res->nlabels;i++) {
Label *l=&res->labels[i];
if(l->suggest[0]) res->nsuggest++;
if(!l->to[0]) res->removed++;
else if(!same(l->to,l->from)) res->changed++;
}
}
}
if(!error) error=mesh_bytes(rv ? rv : v,rv ? ri : idx,rv ? rn : nv,rv ? rn : ni,&mesh,&mesh_size);
if(!error && write_raw) error=mesh_bytes(v,idx,nv,ni,&raw_mesh,&raw_size);
if(!error && write_raw && !room_text(raw,&raw_text)) error="room text exceeds output budget";
if(!error && !room_text(&refined,&text)) error="room text exceeds output budget";
if(!error) { scan_json(&json,o,heads,nheads,res);if(!json.ok) error="scan.json exceeds output budget"; }
res->ms=io_time(1)-t0;
res->out_triangles=(int)refined.mesh_ni/3;res->out_volumes=refined.nvols;res->ceiling_y=refined.ceiling;
if(!error && !io_mkdir(dir)) error="cannot create output directory";
if(!error && write_raw) error=publish(dir,"room-mesh-raw.bin",raw_mesh,raw_size);
if(!error && write_raw) error=publish(dir,"room-raw.txt",raw_text.data,raw_text.n);
if(!error) error=publish(dir,"scan.json",json.data,json.n);
if(!error) error=publish(dir,"room-mesh.bin",mesh,mesh_size);
if(!error) error=publish(dir,"room.txt",text.data,text.n);
prep_free(&prep);
io_free(rv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(ri,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(raw_mesh,raw_size);io_free(mesh,mesh_size);
buf_free(&raw_text);buf_free(&text);buf_free(&json);
return error;
}
static void refine_summary(Buf *t, const Outcome *r)
{
put(t,"\"refine\":");report_json(t,&r->rep,r->nlabels,r->changed,r->removed,r->nsuggest);
if(t->ok && t->n) t->n--; /* reopen the object for the output counts */
put(t,",\"output_triangles\":");put_int(t,r->out_triangles);
put(t,",\"output_volumes\":");put_int(t,r->out_volumes);put(t,"}");
}
/* ------------------------------------------------------------------------------------------------ summaries */
static void duration(Buf *t,i64 a,i64 b) { put_dec(t,(float)(b-a)/1000000); }
static void warnings_json(Buf *t, const char **w, int n)
{
put(t,",\"warnings\":[");
for(int i=0;i<n;i++) { if(i) put(t,",");put(t,"\"");put(t,w[i]);put(t,"\""); }
put(t,"]");
}
static void summary(Scan *s, const Outcome *r)
{
Buf t;buf_init(&t,1u<<16);put(&t,"{\"ok\":true,\"frames\":");put_u64(&t,s->frames);
put(&t,",\"skipped_frames\":");put_u64(&t,s->skipped_frames);
put(&t,",\"far_vertices\":");put_u64(&t,s->far_vertices);
put(&t,",\"discontinuity_triangles\":");put_u64(&t,s->discontinuities);
put(&t,",\"walls\":");put_u64(&t,(u64)s->noutline);
put(&t,",\"detected_walls\":");put_u64(&t,(u64)s->nplanes);
put(&t,",\"volumes\":");put_u64(&t,(u64)s->nvolumes);
put(&t,",\"floor_y\":");put_dec(&t,s->floor);put(&t,",\"ceiling_y\":");put_dec(&t,s->ceiling);
put(&t,",\"coverage_percent\":");put_dec(&t,s->coverage);
put(&t,",\"hint_used\":");put(&t,s->hint_used ? "true" : "false");
put(&t,",\"triangles\":");put_u64(&t,(u64)s->ni/3);
put(&t,",\"accepted_triangles\":");put_u64(&t,s->accepted);
put(&t,",\"rejected_triangles\":");put_u64(&t,s->rejected);
put(&t,",\"heads\":");put_int(&t,s->nheads);put(&t,",");
refine_summary(&t,r);
warnings_json(&t,s->warnings,s->nwarnings);
put(&t,",\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
put(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
put(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
put(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
put(&t,",\"refine\":");duration(&t,s->tmesh,s->trefine);
put(&t,",\"total\":");duration(&t,s->t0,io_time(1));put(&t,"}}\n");io_write(1,t.data,t.n);buf_free(&t);
}
/* ------------------------------------------------------------------------------------------------ scan path */
static const char *scan_outputs(Scan *s, Outcome *out)
{
static Room built,raw;
static Vec heads[MAX_HEADS];
const char *error=room_from_scan(s,&built);
if(error) return error;
copy_name(built.mesh_name,"room-mesh-raw.bin",sizeof(built.mesh_name));
/* Refine what the raw files will hold, so --refine-only on them gives the same refined files. */
Buf t;buf_init(&t,1u<<20);
if(!room_text(&built,&t)) { buf_free(&t);return "room text exceeds output budget"; }
error=room_parse(t.data,t.n,&raw);
buf_free(&t);
if(error) return error;
Buf h;buf_init(&h,1u<<20);heads_json(&h,s->heads,s->nheads);put(&h,"}");
Buf wrap;buf_init(&wrap,1u<<20);put(&wrap,"{\"heads\":");put_n(&wrap,h.data,h.n);
int nheads=wrap.ok ? heads_parse(wrap.data,wrap.n,heads) : 0;
buf_free(&h);buf_free(&wrap);
return refine_publish(s->opt.out,&raw,s->vertices,s->indices,s->nv,s->ni,heads,nheads,&s->opt.rf,1,out);
}
/* ------------------------------------------------------------------------------------------------ --refine-only */
static int refine_only(Options *o)
{
static Room raw;
static Vec heads[MAX_HEADS];
static Outcome res;
const char *warns[4];int nwarn=0;
i64 t0=io_time(1);
const char *dir=o->refine_only,*error=0;
char path[4096];
u8 *text=0,*bin=0,*json=0;usize text_n=0,bin_n=0,json_n=0;
Vec *v=0;u32 *idx=0;int nv=0,ni=0,nheads=0,write_raw=0;
if(!join_path(path,dir,"room-raw.txt")) return error_json("output path too long");
error=read_file(path,1u<<20,&text,&text_n);
if(error) {
/* An older room folder: its room.txt + mesh are the unrefined scan. */
write_raw=1;
if(!join_path(path,dir,"room.txt")) return error_json("output path too long");
error=read_file(path,1u<<20,&text,&text_n);
if(error) return error_json("room folder has neither room-raw.txt nor room.txt");
}
error=room_parse((const char *)text,text_n,&raw);
io_free(text,(1u<<20)+1);
if(error) return error_json(error);
if(!raw.has_mesh) return error_json("room has no mesh to refine");
if(!join_path(path,dir,write_raw ? raw.mesh_name : "room-mesh-raw.bin")) return error_json("output path too long");
error=read_file(path,MAX_RAW_FILE,&bin,&bin_n);
if(!error) error=mesh_parse(bin,bin_n,&v,&idx,&nv,&ni);
io_free(bin,MAX_RAW_FILE+1);
if(error) return error_json(error);
if((u32)nv!=raw.mesh_nv || (u32)ni!=raw.mesh_ni) {
io_free(v,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(idx,(usize)MAX_TRIANGLES*3*sizeof(u32));
return error_json("mesh record does not match the mesh file");
}
copy_name(raw.mesh_name,"room-mesh-raw.bin",sizeof(raw.mesh_name));
if(join_path(path,dir,"scan.json") && !read_file(path,MAX_JSON_FILE,&json,&json_n)) {
nheads=heads_parse((const char *)json,json_n,heads);
io_free(json,MAX_JSON_FILE+1);
}
if(!nheads && o->rf.walked>0) warns[nwarn++]="no head path; walked volume skipped";
if(write_raw) warns[nwarn++]="no raw files; the current room was taken as the unrefined scan";
error=refine_publish(dir,&raw,v,idx,nv,ni,heads,nheads,&o->rf,write_raw,&res);
io_free(v,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(idx,(usize)MAX_TRIANGLES*3*sizeof(u32));
if(error) return error_json(error);
if(res.rep.truncated) warns[nwarn++]="refined mesh truncated to the triangle budget";
Buf t;buf_init(&t,1u<<16);
put(&t,"{\"ok\":true,\"refine_only\":true,\"raw_triangles\":");put_int(&t,ni/3);
put(&t,",\"volumes\":");put_int(&t,raw.nvols);put(&t,",\"floor_y\":");put_dec(&t,raw.floor);
put(&t,",\"ceiling_y\":");put_dec(&t,res.ceiling_y);put(&t,",\"heads\":");put_int(&t,nheads);put(&t,",");
refine_summary(&t,&res);
warnings_json(&t,warns,nwarn);
put(&t,",\"timings_ms\":{\"refine\":");put_dec(&t,(float)res.ms/1000000);
put(&t,",\"total\":");duration(&t,t0,io_time(1));put(&t,"}}\n");
io_write(1,t.data,t.n);buf_free(&t);
return 0;
}
int main(int argc,char **argv)
{
if(argc==2 && same(argv[1],"--help")) {
const char usage[]="fp_roomscan --offline dump.fprs --out directory [--universe N] [--voxel 0.05]\n"
" [--ceiling-default 2.5] [--hint outline.txt] [--json]\n"
"fp_roomscan --refine-only room-directory [refinement options] [--json]\n"
"Tuning: --truncation --max-edge --grazing --range --depth-jump --depth-ratio --plane-distance --min-plane-area\n"
" --min-views --patch-residual --wall-height --snap-degrees --floor-band --ceiling-min\n"
" --min-volume --cluster-gap --gap --horizontal\n"
"Refinement: --refine 0|1 --walls off|snap|panels --band --facing-deg --band-in --floor-in --ceil-in\n"
" --top-strip --near --clip --clip-open --island --smooth --tops --protect 0|1 --tubes --tube-thick\n"
" --walked --label-rules 0|1 --erase \"x,y,z,r;...\"\n"
"Always prints one JSON summary; errors exit 2. See docs/mr/scan.md for units and ranges.\n";
io_write(1,usage,sizeof(usage)-1);return 0;
}
@@ -207,6 +367,11 @@ int main(int argc,char **argv)
if(!s) return error_json("scan allocation failed");
s->rng=0x714ce3u;s->t0=io_time(1);
const char *error=options(&s->opt,argc,argv);
if(!error && s->opt.refine_only) {
int result=refine_only(&s->opt);
io_free(s,sizeof(Scan));return result;
}
static Outcome outcome;
if(!error) error=read_dump(s,0);
s->tread=io_time(1);
if(!error) error=init_grid(s);
@@ -217,10 +382,13 @@ int main(int argc,char **argv)
s->tmodel=io_time(1);
if(!error) error=make_mesh(s);
s->tmesh=io_time(1);
if(!error) error=write_files(s);
/* The grid is not needed after the mesh: freeing it keeps the refinement inside the same memory budget. */
io_free(s->cells,(usize)s->count*sizeof(Cell));s->cells=0;
if(!error) error=scan_outputs(s,&outcome);
s->trefine=io_time(1);
if(!error && outcome.rep.truncated) warn(s,"refined mesh truncated to the triangle budget");
int result=0;
if(error) result=error_json(error);else summary(s);
io_free(s->cells,(usize)s->count*sizeof(Cell));
if(error) result=error_json(error);else summary(s,&outcome);
io_free(s->vertices,(usize)MAX_MESH_VERTICES*sizeof(Vec));
io_free(s->indices,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(s,sizeof(Scan));return result;
+2 -2
View File
@@ -596,7 +596,7 @@ const char *make_model(Scan *s)
return error;
}
static int number(char **cursor, float *value)
int parse_number(char **cursor, float *value)
{
char *p=*cursor;while (*p==' ' || *p=='\t' || *p=='\r') p++;
int sign=1,digits=0;if (*p=='-' || *p=='+') { if (*p=='-') sign=-1;p++; }
@@ -631,7 +631,7 @@ static const char *hint(Scan *s)
while (*p==' ' || *p=='\t' || *p=='\r' || *p=='\n') p++;
if (!*p) break;
if (*p=='#') { while (*p && *p!='\n') p++;continue; }
if (n==MAX_OUTLINE || !number(&p,&pts[n].x) || !number(&p,&pts[n].z)) return "invalid outline hint";
if (n==MAX_OUTLINE || !parse_number(&p,&pts[n].x) || !parse_number(&p,&pts[n].z)) return "invalid outline hint";
n++;while (*p==' ' || *p=='\t' || *p=='\r') p++;
if (*p && *p!='\n' && *p!='#') return "invalid outline hint";
if (*p=='#') while (*p && *p!='\n') p++;
+548
View File
@@ -0,0 +1,548 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Mesh refinement: the passes of the owner's tuned cleanup page (2026-10-18), in its order and with its rules:
* plane offsets re-estimated from the scan, structural (wall/floor/ceiling) triangles classified with one-sided
* bands, erase spheres, the walked volume, flat panels or snapping, furniture tops, clipping outside the outline,
* small islands, thin floating pieces, Taubin smoothing. docs/mr/scan.md "Refinement" describes each pass. */
#include "refine.h"
#define WELD_MM 1000.0 /* vertices closer than 1 mm (quantized) are one vertex */
#define WELD_BIAS 16384 /* key offset, as the reference: +-16 m */
#define FURNITURE_PAD .1 /* m around a box that still counts as the box */
#define MIN_SHIFT_SAMPLES 50 /* plane offsets move only with more facing triangles than this */
#define WALKED_BELOW .2 /* m above the floor: the walked column starts here (feet, rugs stay) */
#define WALKED_ABOVE .25 /* m above the head: the column ends here */
#define TOP_NORMAL_Y .8 /* a triangle this upward-facing near a box top is part of the top */
#define TUBE_MIN_TRIANGLES 10
#define TUBE_MAX_AREA 1.0 /* m^2: bigger pieces are never thin floating pieces */
#define TUBE_MAX_FURNITURE .5 /* share of a piece inside furniture boxes that keeps it */
#define TAUBIN_LAMBDA .5
#define TAUBIN_MU -.53
double dfloor(double x) { i64 i=(i64)x; return x<(double)i ? (double)(i-1) : (double)i; }
double droot(double x) { return x>0 ? __builtin_sqrt(x) : 0; }
/* Double-precision sine (range reduction + Taylor series to x^19, error < 1e-15), as Math.sin. */
double dsin(double x)
{
const double pi=3.14159265358979323846;
x-=dfloor(x/(2*pi)+.5)*(2*pi);
if(x>pi/2) x=pi-x;
if(x<-pi/2) x=-pi-x;
double q=x*x,term=x,sum=x;
for(int k=1;k<10;k++) { term*=-q/((2*k)*(2*k+1));sum+=term; }
return sum;
}
double dcos(double x) { return dsin(x+3.14159265358979323846/2); }
/* Math.hypot as V8 computes it (scaled, Kahan-compensated): distance ties at outline corners break the same way. */
double dhypot(double a, double b)
{
a=a<0 ? -a : a;b=b<0 ? -b : b;
double m=a>b ? a : b;
if(m==0) return 0;
double sum=0,comp=0,v[2]={a/m,b/m};
for(int i=0;i<2;i++) { double s=v[i]*v[i]-comp,p=sum+s;comp=(p-sum)-s;sum=p; }
return droot(sum)*m;
}
static double dab(double x) { return x<0 ? -x : x; }
static void *alloc(usize n) { return n ? io_alloc(n) : 0; }
/* ------------------------------------------------------------------------------------------------ welding */
static u64 mix(u64 k) { k^=k>>33;k*=0xff51afd7ed558ccdull;k^=k>>33;k*=0xc4ceb9fe1a85ec53ull;k^=k>>33;return k; }
static i64 quant(float v) { return (i64)dfloor((double)v*WELD_MM+.5)+WELD_BIAS; }
const char *prep_mesh(Prep *p, const Vec *v, const u32 *idx, int nv, int ni)
{
*p=(Prep){0};
int T=ni/3;
usize cap=1024;while(cap<(usize)nv*2) cap*=2;
i64 *keys=alloc(cap*sizeof(i64));
int *slot=alloc(cap*sizeof(int)),*wid=alloc((usize)(nv ? nv : 1)*sizeof(int));
double *sums=alloc((usize)(nv ? nv : 1)*4*sizeof(double));
const char *error=0;
if(!keys || !slot || !wid || !sums) { error="refine allocation failed";goto done; }
int W=0;
for(int i=0;i<nv;i++) {
i64 key=(quant(v[i].x)*32768+quant(v[i].y))*32768+quant(v[i].z);
usize h=(usize)mix((u64)key)&(cap-1);
while(slot[h] && keys[h]!=key) h=(h+1)&(cap-1);
if(!slot[h]) { keys[h]=key;slot[h]=++W; }
int w=slot[h]-1;wid[i]=w;
sums[4*w]+=v[i].x;sums[4*w+1]+=v[i].y;sums[4*w+2]+=v[i].z;sums[4*w+3]+=1;
}
p->T=T;p->W=W;
p->pos=alloc((usize)(W ? W : 1)*3*sizeof(double));
p->F=alloc((usize)(T ? T : 1)*3*sizeof(int));
p->fn=alloc((usize)(T ? T : 1)*3*sizeof(float));
p->cen=alloc((usize)(T ? T : 1)*3*sizeof(float));
p->area=alloc((usize)(T ? T : 1)*sizeof(float));
if(!p->pos || !p->F || !p->fn || !p->cen || !p->area) { error="refine allocation failed";goto done; }
for(int w=0;w<W;w++) for(int k=0;k<3;k++) p->pos[3*w+k]=sums[4*w+k]/sums[4*w+3];
for(int k=0;k<T*3;k++) p->F[k]=wid[idx[k]];
for(int t=0;t<T;t++) {
const double *a=p->pos+3*p->F[3*t],*b=p->pos+3*p->F[3*t+1],*c=p->pos+3*p->F[3*t+2];
double ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2],wx=c[0]-a[0],wy=c[1]-a[1],wz=c[2]-a[2];
double nx=uy*wz-uz*wy,ny=uz*wx-ux*wz,nz=ux*wy-uy*wx,l=droot(nx*nx+ny*ny+nz*nz);
if(!(l>0)) l=1e-12;
p->fn[3*t]=(float)(nx/l);p->fn[3*t+1]=(float)(ny/l);p->fn[3*t+2]=(float)(nz/l);p->area[t]=(float)(l/2);
for(int k=0;k<3;k++) p->cen[3*t+k]=(float)((a[k]+b[k]+c[k])/3);
}
done:
io_free(keys,cap*sizeof(i64));io_free(slot,cap*sizeof(int));
io_free(wid,(usize)(nv ? nv : 1)*sizeof(int));io_free(sums,(usize)(nv ? nv : 1)*4*sizeof(double));
if(error) prep_free(p);
return error;
}
void prep_free(Prep *p)
{
int T=p->T ? p->T : 1,W=p->W ? p->W : 1;
io_free(p->pos,(usize)W*3*sizeof(double));io_free(p->F,(usize)T*3*sizeof(int));
io_free(p->fn,(usize)T*3*sizeof(float));io_free(p->cen,(usize)T*3*sizeof(float));io_free(p->area,(usize)T*sizeof(float));
*p=(Prep){0};
}
/* ------------------------------------------------------------------------------------------------ helpers */
typedef struct {
int kind, wall; /* 0 wall (index into room->walls), 1 floor, 2 ceiling */
double n[3], off, shift;
double ox, oz, ux, uz, L;
} RPlane;
typedef struct { double cs, sn; const RVol *v; } Box;
static void box_local(const Box *b, double x, double z, double *lx, double *lz)
{
double dx=x-b->v->cx,dz=z-b->v->cz;
*lx=b->cs*dx-b->sn*dz;*lz=b->sn*dx+b->cs*dz;
}
static double s_dist(const RPlane *pl, const float *c) { return pl->n[0]*c[0]+pl->n[1]*c[1]+pl->n[2]*c[2]-pl->off; }
static int in_ext(const RPlane *pl, const float *c, double m)
{
if(pl->kind) return 1;
double s=(c[0]-pl->ox)*pl->ux+(c[2]-pl->oz)*pl->uz;
return s>-m && s<pl->L+m;
}
static int point_in_poly(double x, double z, const DPt *poly, int n)
{
int inside=0;
for(int i=0,j=n-1;i<n;j=i++) {
double xi=poly[i].x,zi=poly[i].z,xj=poly[j].x,zj=poly[j].z;
if((zi>z)!=(zj>z) && x<(xj-xi)*(z-zi)/(zj-zi+1e-12)+xi) inside=!inside;
}
return inside;
}
/* Distance outside the outline (0 inside) and the extra allowance of the nearest wall (unseen walls). */
static double outside_info(const Room *r, double x, double z, double open_extra, double *allow)
{
double best=1e300;*allow=0;
for(int i=0;i<r->nwalls;i++) {
const RWall *w=&r->walls[i];
double abx=w->x1-w->x0,abz=w->z1-w->z0,l2=abx*abx+abz*abz;
double t=l2>0 ? ((x-w->x0)*abx+(z-w->z0)*abz)/l2 : 0;
t=t<0 ? 0 : t>1 ? 1 : t;
double dx=x-(w->x0+t*abx),dz=z-(w->z0+t*abz),d=dhypot(dx,dz);
if(d<best) { best=d;*allow=w->invisible ? open_extra : 0; }
}
return point_in_poly(x,z,r->outline,r->noutline) ? 0 : best;
}
static void nth(float *a, int n, int k)
{
int lo=0,hi=n-1;
while(lo<hi) {
float pivot=a[(lo+hi)/2];int i=lo,j=hi;
while(i<=j) {
while(a[i]<pivot) i++;
while(a[j]>pivot) j--;
if(i<=j) { float t=a[i];a[i++]=a[j];a[j--]=t; }
}
if(k<=j) hi=j;else if(k>=i) lo=i;else break;
}
}
static int find(int *parent, int x)
{
while(parent[x]!=x) { parent[x]=parent[parent[x]];x=parent[x]; }
return x;
}
static void unite(int *parent, const int *F, const u8 *keep, int T, int W)
{
for(int i=0;i<W;i++) parent[i]=i;
for(int t=0;t<T;t++) if(keep[t]) {
int r=find(parent,F[3*t]);
parent[find(parent,F[3*t+1])]=r;parent[find(parent,F[3*t+2])]=r;
}
}
/* Eigenvalues of a symmetric 3x3 matrix by Jacobi rotations, as the reference's eigSym3. */
static void eig3(double a[3][3], double out[3])
{
static const int pairs[3][2]={{0,1},{0,2},{1,2}};
for(int sweep=0;sweep<20;sweep++) {
double off=dab(a[0][1])+dab(a[0][2])+dab(a[1][2]);
if(off<1e-14) break;
for(int k=0;k<3;k++) {
int p=pairs[k][0],q=pairs[k][1];
if(dab(a[p][q])<1e-18) continue;
double th=(a[q][q]-a[p][p])/(2*a[p][q]);
double t=(th<0 ? -1 : 1)/(dab(th)+droot(th*th+1)),c=1/droot(t*t+1),s=t*c;
for(int m=0;m<3;m++) { double akp=a[m][p],akq=a[m][q];a[m][p]=c*akp-s*akq;a[m][q]=s*akp+c*akq; }
for(int m=0;m<3;m++) { double apk=a[p][m],aqk=a[q][m];a[p][m]=c*apk-s*aqk;a[q][m]=s*apk+c*aqk; }
}
}
out[0]=a[0][0];out[1]=a[1][1];out[2]=a[2][2];
}
/* Ear clipping of the (positively wound) outline, exactly the reference's order. */
static int ear_clip(const DPt *poly, int n, int tris[][3])
{
int idx[MAX_OUTLINE],m=n,count=0,guard=0;
for(int i=0;i<n;i++) idx[i]=i;
#define A2(a,b,c) ((poly[b].x-poly[a].x)*(poly[c].z-poly[a].z)-(poly[b].z-poly[a].z)*(poly[c].x-poly[a].x))
while(m>3 && guard++<10000) {
int cut=0;
for(int k=0;k<m;k++) {
int i0=idx[(k-1+m)%m],i1=idx[k],i2=idx[(k+1)%m];
if(A2(i0,i1,i2)<=0) continue;
int blocked=0;
for(int q=0;q<m && !blocked;q++) {
int j=idx[q];
if(j!=i0 && j!=i1 && j!=i2 && A2(i0,i1,j)>0 && A2(i1,i2,j)>0 && A2(i2,i0,j)>0) blocked=1;
}
if(blocked) continue;
tris[count][0]=i0;tris[count][1]=i1;tris[count][2]=i2;count++;
for(int q=k;q+1<m;q++) idx[q]=idx[q+1];
m--;cut=1;break;
}
if(!cut) break;
}
#undef A2
if(m==3) { tris[count][0]=idx[0];tris[count][1]=idx[1];tris[count][2]=idx[2];count++; }
return count;
}
/* ------------------------------------------------------------------------------------------------ passes */
typedef struct {
u8 *furn, *keep;
signed char *owner;
int *parent, *start, *nb, *fill, *comp, *stamp;
double *pos, *tmp, *acc;
float *ds;
int nb_n, comp_n;
} Work;
static void work_free(Work *w, int T, int W)
{
usize t=(usize)(T ? T : 1),v=(usize)(W ? W : 1);
io_free(w->furn,t);io_free(w->keep,t);io_free(w->owner,t);io_free(w->parent,v*sizeof(int));
io_free(w->start,(v+1)*sizeof(int));io_free(w->nb,(usize)(w->nb_n ? w->nb_n : 1)*sizeof(int));
io_free(w->fill,v*sizeof(int));io_free(w->comp,v*sizeof(int));io_free(w->stamp,v*sizeof(int));
io_free(w->pos,v*3*sizeof(double));io_free(w->tmp,v*3*sizeof(double));
io_free(w->acc,(usize)(w->comp_n ? w->comp_n : 1)*12*sizeof(double));io_free(w->ds,t*sizeof(float));
}
/* Heads binned on a horizontal grid with cells of at least the walked radius: the 3 x 3 cells around a point hold
* every head within the radius, so this gives the same result as testing every head, with far fewer tests. */
typedef struct { double x0, z0, cell; int nx, nz; int *start, *list; } HeadGrid;
static int head_cell(const HeadGrid *g, double v, double v0) { return (int)dfloor((v-v0)/g->cell); }
static int head_grid(HeadGrid *g, const Vec *heads, int n, double radius)
{
double x0=1e300,x1=-1e300,z0=1e300,z1=-1e300;
for(int i=0;i<n;i++) { x0=heads[i].x<x0 ? heads[i].x : x0;x1=heads[i].x>x1 ? heads[i].x : x1;
z0=heads[i].z<z0 ? heads[i].z : z0;z1=heads[i].z>z1 ? heads[i].z : z1; }
g->cell=radius;
if((x1-x0)/256>g->cell) g->cell=(x1-x0)/256;
if((z1-z0)/256>g->cell) g->cell=(z1-z0)/256;
g->x0=x0;g->z0=z0;g->nx=head_cell(g,x1,x0)+1;g->nz=head_cell(g,z1,z0)+1;
g->start=alloc((usize)(g->nx*g->nz+1)*sizeof(int));g->list=alloc((usize)n*sizeof(int));
int *fill=alloc((usize)(g->nx*g->nz)*sizeof(int));
if(!g->start || !g->list || !fill) { io_free(fill,(usize)(g->nx*g->nz)*sizeof(int));return 0; }
for(int i=0;i<n;i++) g->start[head_cell(g,heads[i].z,z0)*g->nx+head_cell(g,heads[i].x,x0)+1]++;
for(int c=0;c<g->nx*g->nz;c++) g->start[c+1]+=g->start[c];
for(int i=0;i<n;i++) {
int c=head_cell(g,heads[i].z,z0)*g->nx+head_cell(g,heads[i].x,x0);
g->list[g->start[c]+fill[c]++]=i;
}
io_free(fill,(usize)(g->nx*g->nz)*sizeof(int));
return 1;
}
static void head_grid_free(HeadGrid *g, int n)
{
io_free(g->start,(usize)(g->nx*g->nz+1)*sizeof(int));io_free(g->list,(usize)n*sizeof(int));
}
static int walked_hit(const HeadGrid *g, const Vec *heads, double x, double y, double z, double r2)
{
int cx=head_cell(g,x,g->x0),cz=head_cell(g,z,g->z0);
for(int v=cz-1;v<=cz+1;v++) for(int u=cx-1;u<=cx+1;u++) {
if(u<0 || v<0 || u>=g->nx || v>=g->nz) continue;
for(int k=g->start[v*g->nx+u];k<g->start[v*g->nx+u+1];k++) {
const Vec *h=&heads[g->list[k]];
if(y<h->y+WALKED_ABOVE && (x-h->x)*(x-h->x)+(z-h->z)*(z-h->z)<=r2) return 1;
}
}
return 0;
}
const char *refine_run(const Refine *o, const Room *room, const Prep *P, const RVol *vols, int nvols,
const Vec *heads, int nheads, Vec **out_v, u32 **out_i, int *out_n, Report *rep)
{
int T=P->T,W=P->W;
const float *cen=P->cen,*fn=P->fn,*area=P->area;
const int *F=P->F;
Work w={0};
const char *error=0;
*out_v=0;*out_i=0;*out_n=0;
memset(rep,0,sizeof(*rep));rep->triangles=T;
usize tb=(usize)(T ? T : 1),wb=(usize)(W ? W : 1);
w.furn=alloc(tb);w.keep=alloc(tb);w.owner=alloc(tb);w.ds=alloc(tb*sizeof(float));
w.parent=alloc(wb*sizeof(int));w.pos=alloc(wb*3*sizeof(double));w.stamp=alloc(wb*sizeof(int));
if(!w.furn || !w.keep || !w.owner || !w.ds || !w.parent || !w.pos || !w.stamp) { error="refine allocation failed";goto done; }
memcpy(w.pos,P->pos,(usize)W*3*sizeof(double));
double *pos=w.pos;
/* furniture flags: the ruled boxes (+10 cm) */
Box boxes[MAX_VOLUMES];
for(int i=0;i<nvols;i++) {
double a=vols[i].yaw*3.14159265358979323846/180;
boxes[i]=(Box){dcos(a),dsin(a),&vols[i]};
}
for(int i=0;i<nvols;i++) {
const RVol *v=&vols[i];
for(int t=0;t<T;t++) {
double lx,lz,y=cen[3*t+1];
box_local(&boxes[i],cen[3*t],cen[3*t+2],&lx,&lz);
if(dab(lx)<=v->w/2.+FURNITURE_PAD && dab(lz)<=v->d/2.+FURNITURE_PAD &&
y<=v->top+FURNITURE_PAD && y>=v->top-v->h-FURNITURE_PAD) w.furn[t]=1;
}
}
/* 1. planes: seen walls, floor, ceiling; offsets re-estimated (median of facing triangles within the band) */
RPlane planes[MAX_OUTLINE+2];int np=0;
for(int i=0;i<room->nwalls;i++) {
const RWall *wl=&room->walls[i];
if(wl->invisible) continue;
double dx=wl->x1-wl->x0,dz=wl->z1-wl->z0,L=dhypot(dx,dz);
if(!(L>0)) continue;
RPlane *pl=&planes[np++];
*pl=(RPlane){0,i,{-dz/L,0,dx/L},0,0,wl->x0,wl->z0,dx/L,dz/L,L};
pl->off=pl->n[0]*wl->x0+pl->n[2]*wl->z0;
}
planes[np++]=(RPlane){1,-1,{0,1,0},room->floor,0,0,0,0,0,0};
planes[np++]=(RPlane){2,-1,{0,-1,0},-(double)room->ceiling,0,0,0,0,0,0};
double cos_lim=dcos(o->facing_deg*3.14159265358979323846/180);
#define FACING(pl,t) ((pl)->n[0]*fn[3*(t)]+(pl)->n[1]*fn[3*(t)+1]+(pl)->n[2]*fn[3*(t)+2]>cos_lim)
for(int k=0;k<np;k++) {
RPlane *pl=&planes[k];int n=0;
for(int t=0;t<T;t++) {
if(w.furn[t]) continue;
double d=s_dist(pl,cen+3*t);
if(dab(d)<o->band && FACING(pl,t) && in_ext(pl,cen+3*t,o->band)) w.ds[n++]=(float)d;
}
if(n>MIN_SHIFT_SAMPLES) { nth(w.ds,n,n>>1);pl->shift=w.ds[n>>1]; }
pl->off+=pl->shift;
rep->shifts[rep->nshifts++]=(Shift){pl->kind,pl->wall,(float)pl->shift};
}
double floor_y=planes[np-2].off,ceil_y=-planes[np-1].off;
rep->floor_y=(float)floor_y;rep->ceiling_y=(float)ceil_y;
/* 2. structural triangles: behind a surface = band; in front = band_in (walls; the full band in the wall's top
* strip), floor_in, ceil_in */
for(int t=0;t<T;t++) w.owner[t]=-1;
if(o->walls) for(int t=0;t<T;t++) {
if(w.furn[t]) continue;
double best=1e300;
for(int k=0;k<np;k++) {
const RPlane *pl=&planes[k];double d=s_dist(pl,cen+3*t);
double front=!pl->kind ? (cen[3*t+1]>ceil_y-o->top_strip ? o->band : o->band_in) :
pl->kind==2 ? o->ceil_in : o->floor_in;
if(d>-o->band && d<front && dab(d)<best && FACING(pl,t) && in_ext(pl,cen+3*t,o->band)) {
best=dab(d);w.owner[t]=(signed char)k;
}
}
}
#undef FACING
for(int t=0;t<T;t++) w.keep[t]=1;
/* erase spheres */
for(int e=0;e<o->nerase;e++) {
const Sphere *s=&o->erase[e];
for(int t=0;t<T;t++) {
double dx=cen[3*t]-s->x,dy=cen[3*t+1]-s->y,dz=cen[3*t+2]-s->z;
if(w.keep[t] && dx*dx+dy*dy+dz*dz<=(double)s->r*s->r) { w.keep[t]=0;rep->erased++; }
}
}
/* the walked volume: a column under each head position, never inside furniture */
if(o->walked>0 && nheads) {
HeadGrid g={0};
if(!head_grid(&g,heads,nheads,o->walked)) { head_grid_free(&g,nheads);error="refine allocation failed";goto done; }
double r2=(double)o->walked*o->walked;
for(int t=0;t<T;t++) {
if(!w.keep[t] || w.furn[t]) continue;
double y=cen[3*t+1];
if(y<room->floor+WALKED_BELOW) continue;
if(walked_hit(&g,heads,cen[3*t],y,cen[3*t+2],r2)) { w.keep[t]=0;rep->walked++; }
}
head_grid_free(&g,nheads);
} else if(o->walked>0) rep->walked_skipped=1;
for(int t=0;t<T;t++) if(w.owner[t]>=0) rep->structural++;
if(o->walls==2) {
for(int t=0;t<T;t++) if(w.owner[t]>=0) w.keep[t]=0;
if(o->near>0) for(int t=0;t<T;t++) {
if(!w.keep[t] || w.furn[t]) continue;
for(int k=0;k<np;k++) if(dab(s_dist(&planes[k],cen+3*t))<o->near && in_ext(&planes[k],cen+3*t,o->near)) {
w.keep[t]=0;rep->near++;break;
}
}
} else if(o->walls==1) {
/* snap: a vertex goes onto the plane of the last structural triangle that uses it */
int *vown=w.stamp;
for(int i=0;i<W;i++) vown[i]=-1;
for(int t=0;t<T;t++) if(w.owner[t]>=0) for(int j=0;j<3;j++) vown[F[3*t+j]]=w.owner[t];
for(int i=0;i<W;i++) {
if(vown[i]<0) continue;
const RPlane *pl=&planes[vown[i]];
double d=pl->n[0]*pos[3*i]+pl->n[1]*pos[3*i+1]+pl->n[2]*pos[3*i+2]-pl->off;
for(int k=0;k<3;k++) pos[3*i+k]-=d*pl->n[k];
}
}
/* 2b. furniture tops: upward scan near a box's top, inside its footprint, goes onto the top */
if(o->tops>0) {
for(int i=0;i<W;i++) w.stamp[i]=0;
for(int i=0;i<nvols;i++) {
const RVol *v=&vols[i];int mark=i+1;
for(int t=0;t<T;t++) {
if(!w.keep[t]) continue;
const double *a=pos+3*F[3*t],*b=pos+3*F[3*t+1],*c=pos+3*F[3*t+2];
double cy=(a[1]+b[1]+c[1])/3;
if(dab(cy-v->top)>=o->tops) continue;
double lx,lz;
box_local(&boxes[i],(a[0]+b[0]+c[0])/3,(a[2]+b[2]+c[2])/3,&lx,&lz);
if(dab(lx)>v->w/2. || dab(lz)>v->d/2.) continue;
double ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2],wx=c[0]-a[0],wy=c[1]-a[1],wz=c[2]-a[2];
double nx=uy*wz-uz*wy,ny=uz*wx-ux*wz,nz=ux*wy-uy*wx,l=droot(nx*nx+ny*ny+nz*nz);
if(ny/(l>0 ? l : 1)<=TOP_NORMAL_Y) continue;
for(int j=0;j<3;j++) w.stamp[F[3*t+j]]=mark;
rep->tops++;
}
for(int k=0;k<W;k++) if(w.stamp[k]==mark) pos[3*k+1]=v->top;
}
}
/* 3. clip outside the room (unseen walls allow clip_open more) and above/below the planes */
if(o->clip<1 || o->clip_open<2) for(int t=0;t<T;t++) {
if(!w.keep[t]) continue;
double allow,out=outside_info(room,cen[3*t],cen[3*t+2],o->clip_open,&allow),y=cen[3*t+1];
if(out>o->clip+allow || y>ceil_y+o->clip || y<floor_y-o->clip) { w.keep[t]=0;rep->outside++; }
}
/* 4. islands: connected pieces below the minimum area */
if(o->island>0) {
double *comp=w.tmp=alloc(wb*3*sizeof(double));
if(!comp) { error="refine allocation failed";goto done; }
unite(w.parent,F,w.keep,T,W);
for(int t=0;t<T;t++) if(w.keep[t]) comp[find(w.parent,F[3*t])]+=area[t];
for(int t=0;t<T;t++)
if(w.keep[t] && comp[find(w.parent,F[3*t])]<o->island && !(o->protect && w.furn[t])) { w.keep[t]=0;rep->islands++; }
io_free(w.tmp,wb*3*sizeof(double));w.tmp=0;
}
/* 4b. thin floating pieces: elongated (PCA), thin, small, not furniture */
if(o->tubes>0) {
unite(w.parent,F,w.keep,T,W);
w.comp=alloc(wb*sizeof(int));
if(!w.comp) { error="refine allocation failed";goto done; }
for(int i=0;i<W;i++) w.comp[i]=-1;
int nc=0;
for(int t=0;t<T;t++) if(w.keep[t]) { int r=find(w.parent,F[3*t]);if(w.comp[r]<0) w.comp[r]=nc++; }
w.comp_n=nc;
w.acc=alloc((usize)(nc ? nc : 1)*12*sizeof(double));
if(!w.acc) { error="refine allocation failed";goto done; }
for(int t=0;t<T;t++) {
if(!w.keep[t]) continue;
double *a=w.acc+12*w.comp[find(w.parent,F[3*t])];
for(int j=0;j<3;j++) {
const double *q=pos+3*F[3*t+j];double x=q[0],y=q[1],z=q[2];
a[0]++;a[1]+=x;a[2]+=y;a[3]+=z;a[4]+=x*x;a[5]+=x*y;a[6]+=x*z;a[7]+=y*y;a[8]+=y*z;a[9]+=z*z;
}
a[10]+=area[t];a[11]+=w.furn[t] ? 1 : 0;
}
u8 *drop=(u8 *)w.stamp; /* nc <= T bytes fit in the W ints */
for(int c=0;c<nc;c++) {
double *a=w.acc+12*c,n=a[0],tris=n/3;drop[c]=0;
if(tris<TUBE_MIN_TRIANGLES || a[11]/tris>TUBE_MAX_FURNITURE || a[10]>=TUBE_MAX_AREA) continue;
double mx_=a[1]/n,my=a[2]/n,mz=a[3]/n;
double C[3][3]={{a[4]/n-mx_*mx_,a[5]/n-mx_*my,a[6]/n-mx_*mz},{0,a[7]/n-my*my,a[8]/n-my*mz},{0,0,a[9]/n-mz*mz}};
C[1][0]=C[0][1];C[2][0]=C[0][2];C[2][1]=C[1][2];
double ev[3];eig3(C,ev);
for(int k=0;k<3;k++) ev[k]=droot(ev[k]>1e-12 ? ev[k] : 1e-12);
for(int i=0;i<2;i++) for(int j=0;j<2-i;j++) if(ev[j]>ev[j+1]) { double s=ev[j];ev[j]=ev[j+1];ev[j+1]=s; }
if(ev[2]/(ev[1]>1e-6 ? ev[1] : 1e-6)>=o->tubes && ev[1]<=o->tube_thick) drop[c]=1;
}
for(int t=0;t<T;t++) if(w.keep[t] && drop[w.comp[find(w.parent,F[3*t])]]) { w.keep[t]=0;rep->tubes++; }
}
/* 5. Taubin smoothing over the kept triangles (lambda/mu pairs keep the volume) */
if(o->smooth>0) {
w.start=alloc((wb+1)*sizeof(int));w.fill=alloc(wb*sizeof(int));w.tmp=alloc(wb*3*sizeof(double));
if(!w.start || !w.fill || !w.tmp) { error="refine allocation failed";goto done; }
for(int t=0;t<T;t++) if(w.keep[t]) for(int j=0;j<3;j++) w.start[F[3*t+j]+1]+=2;
for(int i=0;i<W;i++) w.start[i+1]+=w.start[i];
w.nb_n=w.start[W];
w.nb=alloc((usize)(w.nb_n ? w.nb_n : 1)*sizeof(int));
if(!w.nb) { error="refine allocation failed";goto done; }
for(int i=0;i<W;i++) w.fill[i]=w.start[i];
for(int t=0;t<T;t++) if(w.keep[t]) for(int j=0;j<3;j++) {
int a=F[3*t+j],b=F[3*t+(j+1)%3],c=F[3*t+(j+2)%3];
w.nb[w.fill[a]++]=b;w.nb[w.fill[a]++]=c;
}
for(int it=0;it<o->smooth;it++) for(int pass=0;pass<2;pass++) {
double f=pass ? TAUBIN_MU : TAUBIN_LAMBDA;
for(int i=0;i<W;i++) {
int s0=w.start[i],e=w.start[i+1];
if(s0==e) { for(int k=0;k<3;k++) w.tmp[3*i+k]=pos[3*i+k];continue; }
double x=0,y=0,z=0;
for(int k=s0;k<e;k++) { const double *q=pos+3*w.nb[k];x+=q[0];y+=q[1];z+=q[2]; }
int n=e-s0;
w.tmp[3*i]=pos[3*i]+f*(x/n-pos[3*i]);w.tmp[3*i+1]=pos[3*i+1]+f*(y/n-pos[3*i+1]);
w.tmp[3*i+2]=pos[3*i+2]+f*(z/n-pos[3*i+2]);
}
memcpy(pos,w.tmp,(usize)W*3*sizeof(double));
}
}
/* 6. output: the kept scan, then flat panels for seen walls, the floor and the ceiling */
for(int t=0;t<T;t++) rep->kept+=w.keep[t];
Vec panel[(MAX_OUTLINE*2+MAX_OUTLINE*2)*3];int npanel=0;
if(o->walls==2) {
for(int k=0;k<np;k++) {
const RPlane *pl=&planes[k];
if(pl->kind) continue;
const RWall *wl=&room->walls[pl->wall];double sh=pl->shift;
double a0x=wl->x0+pl->n[0]*sh,a0z=wl->z0+pl->n[2]*sh,a1x=wl->x1+pl->n[0]*sh,a1z=wl->z1+pl->n[2]*sh;
Vec q[4]={vec((float)a0x,(float)floor_y,(float)a0z),vec((float)a1x,(float)floor_y,(float)a1z),
vec((float)a1x,(float)ceil_y,(float)a1z),vec((float)a0x,(float)ceil_y,(float)a0z)};
double ux=a1x-a0x,uz=a1z-a0z,vy=ceil_y-floor_y;
int ok=(-uz*vy)*pl->n[0]+(ux*vy)*pl->n[2]>0;
int order[2][6]={{0,1,2,0,2,3},{1,0,3,1,3,2}};
for(int j=0;j<6;j++) panel[npanel*3+j]=q[order[!ok][j]];
npanel+=2;
}
int ears[MAX_OUTLINE][3];
int ne=ear_clip(room->outline,room->noutline,ears);
for(int e=0;e<ne;e++) {
DPt A=room->outline[ears[e][0]],B=room->outline[ears[e][1]],C=room->outline[ears[e][2]];
panel[npanel*3]=vec((float)A.x,(float)floor_y,(float)A.z);panel[npanel*3+1]=vec((float)C.x,(float)floor_y,(float)C.z);
panel[npanel*3+2]=vec((float)B.x,(float)floor_y,(float)B.z);npanel++;
panel[npanel*3]=vec((float)A.x,(float)ceil_y,(float)A.z);panel[npanel*3+1]=vec((float)B.x,(float)ceil_y,(float)B.z);
panel[npanel*3+2]=vec((float)C.x,(float)ceil_y,(float)C.z);npanel++;
}
}
rep->panels=npanel;
int room_left=MAX_TRIANGLES-npanel,kept_out=rep->kept<room_left ? rep->kept : room_left;
rep->truncated=rep->kept-kept_out;
*out_v=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
*out_i=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!*out_v || !*out_i) { error="refine allocation failed";goto done; }
int o_n=0;
for(int t=0;t<T && o_n<kept_out*3;t++) {
if(!w.keep[t]) continue;
for(int j=0;j<3;j++) {
const double *q=pos+3*F[3*t+j];
(*out_v)[o_n]=vec((float)q[0],(float)q[1],(float)q[2]);(*out_i)[o_n]=(u32)o_n;o_n++;
}
}
for(int k=0;k<npanel*3;k++) { (*out_v)[o_n]=panel[k];(*out_i)[o_n]=(u32)o_n;o_n++; }
*out_n=o_n;
done:
if(error) {
io_free(*out_v,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(*out_i,(usize)MAX_TRIANGLES*3*sizeof(u32));
*out_v=0;*out_i=0;*out_n=0;
}
work_free(&w,T,W);
return error;
}
+101
View File
@@ -0,0 +1,101 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_REFINE_H
#define FP_ROOMSCAN_REFINE_H
#include "scan.h"
/* Room cleanup after the mesh (docs/mr/scan.md "Refinement"): the scanner's unrefined output is kept as
* room-raw.txt + room-mesh-raw.bin, the refined one replaces room.txt + room-mesh.bin, scan.json holds the head
* path, the settings used, the per-pass report, the label-rule decisions and door/closet suggestions. */
#define MAX_EXTRA_TEXT 8192
#define MAX_LABEL 24
#define MAX_REASON 240
#define MAX_RAW_FILE (16u+(usize)MAX_MESH_VERTICES*12u+(usize)MAX_TRIANGLES*12u)
#define MAX_JSON_FILE (1u<<20)
/* Growable text buffer (io_alloc backed). ok=0 after an allocation failure or the cap. */
typedef struct { char *data; usize n, cap, limit; int ok; } Buf;
void buf_init(Buf *b, usize limit);
void buf_free(Buf *b);
void put(Buf *b, const char *s);
void put_n(Buf *b, const char *s, usize n);
void put_int(Buf *b, i64 v);
void put_u64(Buf *b, u64 v);
void put_dec(Buf *b, float v); /* 4 decimals, the room.txt number format */
void put_fixed(Buf *b, double v, int places); /* JavaScript's toFixed */
void put_round(Buf *b, double v); /* JavaScript's Math.round */
typedef struct { double x, z; } DPt;
/* Room numbers are doubles parsed exactly from their decimal text, as the reference page reads them. */
typedef struct { double x0, z0, x1, z1, y0, y1; int invisible; char id[33]; } RWall;
typedef struct { double cx, cz, top, yaw, w, d, h; char label[MAX_LABEL]; char id[33]; } RVol;
typedef struct {
u64 universe, time;
char source[16];
char room_id[33], floor_id[33], ceiling_id[33], mesh_id[33];
double floor, ceiling;
DPt outline[MAX_OUTLINE];
int noutline;
RWall walls[MAX_OUTLINE];
int nwalls;
RVol vols[MAX_VOLUMES];
int nvols;
char mesh_name[64];
u32 mesh_nv, mesh_ni;
int has_mesh;
char extra[MAX_EXTRA_TEXT]; /* records this scanner does not model (plane, ...), kept verbatim */
usize nextra;
} Room;
/* The mesh with its vertices welded at 1 mm (as the reference page does), per-triangle normals/centroids/areas. */
typedef struct {
int T, W;
double *pos;
int *F;
float *fn, *cen, *area;
} Prep;
typedef struct { int kind, wall; float shift; } Shift; /* kind: 0 wall, 1 floor, 2 ceiling */
typedef struct {
int triangles, kept, structural, near, outside, islands, tubes, erased, walked, tops, panels, truncated;
int walked_skipped;
float floor_y, ceiling_y;
Shift shifts[MAX_OUTLINE+2];
int nshifts;
} Report;
typedef struct {
char from[MAX_LABEL], to[MAX_LABEL], suggest[8], reason[MAX_REASON];
RVol box;
int wall;
float along;
} Label;
/* roomio.c */
const char *room_from_scan(Scan *s, Room *r);
const char *room_parse(const char *text, usize n, Room *r);
int room_text(const Room *r, Buf *b);
const char *read_file(const char *path, usize limit, u8 **data, usize *size);
const char *publish(const char *dir, const char *name, const void *data, usize size);
int join_path(char *out, const char *dir, const char *name);
const char *mesh_parse(const u8 *data, usize size, Vec **vertices, u32 **indices, int *nv, int *ni);
const char *mesh_bytes(const Vec *vertices, const u32 *indices, int nv, int ni, u8 **data, usize *size);
void heads_json(Buf *b, const Vec *heads, int n);
int heads_parse(const char *text, usize n, Vec *heads);
void settings_json(Buf *b, const Refine *o);
void report_json(Buf *b, const Report *r, int nlabels, int changed, int removed, int nsuggest);
/* refine.c */
const char *prep_mesh(Prep *p, const Vec *vertices, const u32 *indices, int nv, int ni);
void prep_free(Prep *p);
const char *refine_run(const Refine *o, const Room *room, const Prep *p, const RVol *vols, int nvols,
const Vec *heads, int nheads, Vec **out_v, u32 **out_i, int *out_n, Report *rep);
double dfloor(double x);
double droot(double x);
double dsin(double x);
double dcos(double x);
double dhypot(double a, double b);
/* rules.c */
int rules_apply(const Room *room, const Prep *p, RVol *out, int *nout, Label *labels);
#endif
+476
View File
@@ -0,0 +1,476 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Room files: the room.txt writer/reader (docs/mr/ROOM_FORMAT.md), mesh sidecars, atomic publishing and the JSON
* pieces of scan.json. Every number goes through put_dec, so a room read back from its own text is bit-identical
* to the one the scan path refines (that is what makes --refine-only reproduce a scan's refined output). */
#include "refine.h"
void buf_init(Buf *b, usize limit) { *b=(Buf){0};b->limit=limit;b->ok=1; }
void buf_free(Buf *b) { io_free(b->data,b->cap);*b=(Buf){0}; }
void put_n(Buf *b, const char *s, usize n)
{
if(!b->ok) return;
if(b->n+n+1>b->cap) {
usize cap=b->cap ? b->cap : 256;
while(cap<b->n+n+1) cap*=2;
if(cap>b->limit) cap=b->limit;
if(cap<b->n+n+1) { b->ok=0;return; }
char *data=io_alloc(cap);
if(!data) { b->ok=0;return; }
if(b->n) memcpy(data,b->data,b->n);
io_free(b->data,b->cap);b->data=data;b->cap=cap;
}
memcpy(b->data+b->n,s,n);b->n+=n;b->data[b->n]=0;
}
void put(Buf *b, const char *s) { usize n=0;while(s[n]) n++;put_n(b,s,n); }
void put_u64(Buf *b, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
char out[24];for(int i=0;i<n;i++) out[i]=rev[n-1-i];
put_n(b,out,(usize)n);
}
void put_int(Buf *b, i64 v) { if(v<0) { put(b,"-");v=-v; } put_u64(b,(u64)v); }
void put_dec(Buf *b, float value)
{
/* Same arithmetic as the scanner's original writer: the --refine 0 output stays byte-for-byte unchanged. */
if(value<0) { put(b,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
put_u64(b,scaled/10000);put(b,".");
u64 fraction=scaled%10000;
if(fraction<1000) put(b,"0");
if(fraction<100) put(b,"0");
if(fraction<10) put(b,"0");
put_u64(b,fraction);
}
void put_fixed(Buf *b, double v, int places)
{
/* Exact, like Number.prototype.toFixed: v*10^places from the double's bits in 128-bit integers, ties up. */
double scale=1;for(int i=0;i<places;i++) scale*=10;
int negative=v<0;if(negative) v=-v;
if(!(v<1e9)) v=0;
union { double d; u64 u; } bits={v};
int e=(int)((bits.u>>52)&0x7ff);
u64 m=bits.u&((1ull<<52)-1);
if(e) m|=1ull<<52;else e=1;
e-=1075; /* v = m * 2^e */
unsigned __int128 big=(unsigned __int128)m*(u64)scale;
u64 scaled;
if(e>=0) scaled=(u64)(big<<e);
else if(e<=-127) scaled=0;
else {
unsigned __int128 half=(unsigned __int128)1<<(-e-1),rest=big&(((unsigned __int128)1<<-e)-1);
scaled=(u64)(big>>-e)+(rest>=half);
}
if(negative && scaled) put(b,"-");
put_u64(b,(u64)(scaled/(u64)scale));
if(places) {
put(b,".");u64 fraction=scaled%(u64)scale;
for(u64 d=(u64)scale/10;d>1 && fraction<d;d/=10) put(b,"0");
put_u64(b,fraction);
}
}
void put_round(Buf *b, double v) { put_int(b,(i64)dfloor(v+.5)); }
static int uuid_hex(char out[33])
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { out[2*i]=hex[bytes[i]>>4];out[2*i+1]=hex[bytes[i]&15]; }
out[32]=0;return 1;
}
static void copy(char *dst, const char *src, usize cap)
{
usize i=0;while(src[i] && i+1<cap) { dst[i]=src[i];i++; }
dst[i]=0;
}
const char *room_from_scan(Scan *s, Room *r)
{
memset(r,0,sizeof(*r));
r->universe=s->opt.universe;r->time=(u64)(io_time(0)/1000000000);copy(r->source,"scan",sizeof(r->source));
if(!uuid_hex(r->room_id) || !uuid_hex(r->floor_id) || !uuid_hex(r->ceiling_id)) return "getrandom failed";
r->floor=s->floor;r->ceiling=s->ceiling;r->noutline=s->noutline;
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
r->outline[i]=(DPt){a.x,a.z};
r->walls[i]=(RWall){a.x,a.z,b.x,b.z,s->floor,s->ceiling,s->invisible[i],{0}};
if(!uuid_hex(r->walls[i].id)) return "getrandom failed";
}
r->nwalls=s->noutline;
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];RVol *o=&r->vols[i];
*o=(RVol){v->p.x,v->p.z,v->p.y,v->yaw,v->w,v->d,v->h,{0},{0}};
copy(o->label,v->label,sizeof(o->label));
if(!uuid_hex(o->id)) return "getrandom failed";
}
r->nvols=s->nvolumes;
if(!uuid_hex(r->mesh_id)) return "getrandom failed";
copy(r->mesh_name,"room-mesh.bin",sizeof(r->mesh_name));
r->has_mesh=1;r->mesh_nv=(u32)s->nv;r->mesh_ni=(u32)s->ni;
return 0;
}
static void sp(Buf *b, double v) { put(b," ");put_dec(b,(float)v); }
int room_text(const Room *r, Buf *b)
{
put(b,"frameport-room 1\nuniverse ");put_u64(b,r->universe);
put(b,"\nsource ");put(b,r->source);put(b,"\ntime ");put_u64(b,r->time);
put(b,"\nroom ");put(b,r->room_id);
put(b,"\nfloor ");put(b,r->floor_id);sp(b,r->floor);
put(b,"\nceiling ");put(b,r->ceiling_id);sp(b,r->ceiling);
put(b,"\noutline ");put_u64(b,(u64)r->noutline);
for(int i=0;i<r->noutline;i++) { sp(b,r->outline[i].x);sp(b,r->outline[i].z); }
put(b,"\n");
for(int i=0;i<r->nwalls;i++) {
const RWall *w=&r->walls[i];
put(b,"wall ");put(b,w->id);sp(b,w->x0);sp(b,w->z0);sp(b,w->x1);sp(b,w->z1);sp(b,w->y0);sp(b,w->y1);
if(w->invisible) put(b," INVISIBLE_WALL_FACE");
put(b,"\n");
}
for(int i=0;i<r->nvols;i++) {
const RVol *v=&r->vols[i];
put(b,"volume ");put(b,v->id);put(b," ");put(b,v->label);
sp(b,v->cx);sp(b,v->cz);sp(b,v->top);sp(b,v->yaw);sp(b,v->w);sp(b,v->d);sp(b,v->h);put(b,"\n");
}
put_n(b,r->extra,r->nextra);
if(r->has_mesh) {
put(b,"mesh ");put(b,r->mesh_id);put(b," ");put(b,r->mesh_name);put(b," ");
put_u64(b,r->mesh_nv);put(b," ");put_u64(b,r->mesh_ni);put(b,"\n");
}
return b->ok;
}
/* ------------------------------------------------------------------------------------------------ reading */
typedef struct { char *p[64]; int n; } Fields;
static int is_space(char c) { return c==' ' || c=='\t' || c=='\r'; }
/* Semantic labels are Meta's upper-case names; anything else would also be unsafe in scan.json strings. */
static int field_label(const char *p)
{
int n=0;
for(;p[n];n++) if(!((p[n]>='A' && p[n]<='Z') || (p[n]>='0' && p[n]<='9') || p[n]=='_')) return 0;
return n>0 && n<MAX_LABEL;
}
static int field_uuid(const char *p)
{
int n=0;
for(;p[n];n++) if(!((p[n]>='0' && p[n]<='9') || (p[n]>='a' && p[n]<='f'))) return 0;
return n==32;
}
/* Decimal text to double, correctly rounded for the room format's numbers (<= 17 significant digits): the
* mantissa and the power of ten are exact doubles, so one division/multiplication rounds once, as parseFloat. */
static int field_double(const char *p, double *out)
{
int negative=0,digits=0,exponent=0;u64 mantissa=0;
if(*p=='-' || *p=='+') negative=*p++=='-';
for(;*p>='0' && *p<='9';p++,digits++) { if(mantissa<100000000000000000ull) mantissa=mantissa*10+(u64)(*p-'0');else exponent++; }
if(*p=='.') for(p++;*p>='0' && *p<='9';p++,digits++)
if(mantissa<100000000000000000ull) { mantissa=mantissa*10+(u64)(*p-'0');exponent--; }
if(!digits) return 0;
if(*p=='e' || *p=='E') {
p++;int sign=1,e=0,ed=0;
if(*p=='-' || *p=='+') sign=*p++=='-' ? -1 : 1;
for(;*p>='0' && *p<='9';p++,ed++) { e=e*10+*p-'0';if(e>400) return 0; }
if(!ed) return 0;
exponent+=sign*e;
}
if(*p) return 0;
double v=(double)mantissa,scale=1;
int e=exponent<0 ? -exponent : exponent;
for(int i=0;i<e && scale<1e300;i++) scale*=10;
v=exponent<0 ? v/scale : v*scale;
if(!(v<=1e7)) return 0; /* also rejects NaN; room coordinates are metres */
*out=negative ? -v : v;return 1;
}
static int field_u64(const char *p, u64 *out)
{
u64 n=0;if(!*p) return 0;
for(;*p;p++) {
if(*p<'0' || *p>'9') return 0;
unsigned digit=(unsigned)(*p-'0');
if(n>(~(u64)0-digit)/10) return 0;
n=n*10+digit;
}
*out=n;return 1;
}
static int floats(Fields *f, int from, double *out, int n)
{
if(f->n<from+n) return 0;
for(int i=0;i<n;i++) if(!field_double(f->p[from+i],&out[i])) return 0;
return 1;
}
const char *room_parse(const char *text, usize n, Room *r)
{
static char line[16384];
memset(r,0,sizeof(*r));
int have_floor=0,have_ceiling=0,first=1;
usize at=0;
while(at<n) {
usize start=at,len=0;
while(at<n && text[at]!='\n') at++;
len=at-start;at++;
if(len>=sizeof(line)) return "room line too long";
memcpy(line,text+start,len);line[len]=0;
usize raw_len=len;
for(usize i=0;i<len;i++) if(line[i]=='#') { line[i]=0;len=i;break; }
Fields f={{0},0};
char *p=line;
while(*p) {
while(is_space(*p)) *p++=0;
if(!*p) break;
if(f.n==64) return "room line has too many fields";
f.p[f.n++]=p;
while(*p && !is_space(*p)) p++;
}
if(first) {
if(f.n!=2 || !same(f.p[0],"frameport-room") || !same(f.p[1],"1")) return "not a frameport-room 1 file";
first=0;continue;
}
if(!f.n) continue;
const char *k=f.p[0];
if(same(k,"universe")) { if(f.n<2 || !field_u64(f.p[1],&r->universe)) return "invalid universe record"; }
else if(same(k,"time")) { if(f.n<2 || !field_u64(f.p[1],&r->time)) return "invalid time record"; }
else if(same(k,"source")) { if(f.n<2) return "invalid source record";copy(r->source,f.p[1],sizeof(r->source)); }
else if(same(k,"room")) { if(f.n<2 || !field_uuid(f.p[1])) return "invalid room record";copy(r->room_id,f.p[1],33); }
else if(same(k,"floor") || same(k,"ceiling")) {
int c=same(k,"ceiling");
if(f.n<3 || !field_uuid(f.p[1]) || !field_double(f.p[2],c ? &r->ceiling : &r->floor)) return "invalid floor/ceiling record";
copy(c ? r->ceiling_id : r->floor_id,f.p[1],33);
if(c) have_ceiling=1;else have_floor=1;
} else if(same(k,"outline")) {
u64 count;
if(f.n<2 || !field_u64(f.p[1],&count) || count<3 || count>MAX_OUTLINE || (u64)f.n!=2+2*count) return "invalid outline record";
for(int i=0;i<(int)count;i++)
if(!field_double(f.p[2+2*i],&r->outline[i].x) || !field_double(f.p[3+2*i],&r->outline[i].z)) return "invalid outline record";
r->noutline=(int)count;
} else if(same(k,"wall")) {
double v[6];
if(r->nwalls==MAX_OUTLINE) return "too many walls";
if(f.n<8 || !field_uuid(f.p[1]) || !floats(&f,2,v,6)) return "invalid wall record";
RWall *w=&r->walls[r->nwalls++];
*w=(RWall){v[0],v[1],v[2],v[3],v[4],v[5],f.n>8 && same(f.p[8],"INVISIBLE_WALL_FACE"),{0}};
copy(w->id,f.p[1],33);
} else if(same(k,"volume")) {
double v[7];
if(r->nvols==MAX_VOLUMES) return "too many volumes";
if(f.n<10 || !field_uuid(f.p[1]) || !field_label(f.p[2]) || !floats(&f,3,v,7)) return "invalid volume record";
RVol *o=&r->vols[r->nvols++];
*o=(RVol){v[0],v[1],v[2],v[3],v[4],v[5],v[6],{0},{0}};
copy(o->label,f.p[2],sizeof(o->label));copy(o->id,f.p[1],33);
} else if(same(k,"mesh")) {
u64 nv,ni;
if(f.n<5 || !field_uuid(f.p[1]) || !field_u64(f.p[3],&nv) || !field_u64(f.p[4],&ni) ||
nv>MAX_MESH_VERTICES || ni>(u64)MAX_TRIANGLES*3) return "invalid mesh record";
for(const char *c=f.p[2];*c;c++) if(*c=='/' || *c=='\\') return "invalid mesh file name";
copy(r->mesh_id,f.p[1],33);copy(r->mesh_name,f.p[2],sizeof(r->mesh_name));
r->mesh_nv=(u32)nv;r->mesh_ni=(u32)ni;r->has_mesh=1;
} else {
/* Forward compatibility: unknown records (plane, ...) are kept as they were written. */
if(r->nextra+raw_len+1>=MAX_EXTRA_TEXT) return "too many extra room records";
memcpy(r->extra+r->nextra,text+start,raw_len);r->nextra+=raw_len;r->extra[r->nextra++]='\n';
}
}
if(first) return "empty room file";
if(!r->source[0]) copy(r->source,"scan",sizeof(r->source));
if(!r->room_id[0] && !uuid_hex(r->room_id)) return "getrandom failed";
if(!have_floor && !uuid_hex(r->floor_id)) return "getrandom failed";
if(!have_ceiling) { if(!uuid_hex(r->ceiling_id)) return "getrandom failed";r->ceiling=r->floor+2.5; }
if(!r->noutline && r->nwalls>=3) {
double x0=1e10,x1=-1e10,z0=1e10,z1=-1e10;
for(int i=0;i<r->nwalls;i++) {
RWall *w=&r->walls[i];
double lx=w->x0<w->x1 ? w->x0 : w->x1,hx=w->x0<w->x1 ? w->x1 : w->x0;
double lz=w->z0<w->z1 ? w->z0 : w->z1,hz=w->z0<w->z1 ? w->z1 : w->z0;
x0=lx<x0 ? lx : x0;x1=hx>x1 ? hx : x1;z0=lz<z0 ? lz : z0;z1=hz>z1 ? hz : z1;
}
r->outline[0]=(DPt){x0,z0};r->outline[1]=(DPt){x1,z0};r->outline[2]=(DPt){x1,z1};r->outline[3]=(DPt){x0,z1};
r->noutline=4;
}
if(r->noutline<3) return "room has no outline";
double twice=0;
for(int i=0;i<r->noutline;i++) {
DPt a=r->outline[i],b=r->outline[(i+1)%r->noutline];twice+=a.x*b.z-b.x*a.z;
}
if(twice<0) for(int i=0;i<r->noutline/2;i++) { /* readers reverse a negative outline (ROOM_FORMAT.md) */
DPt t=r->outline[i];r->outline[i]=r->outline[r->noutline-1-i];r->outline[r->noutline-1-i]=t;
}
if(!r->nwalls) {
for(int i=0;i<r->noutline;i++) {
DPt a=r->outline[i],b=r->outline[(i+1)%r->noutline];
r->walls[i]=(RWall){a.x,a.z,b.x,b.z,r->floor,r->ceiling,0,{0}};
if(!uuid_hex(r->walls[i].id)) return "getrandom failed";
}
r->nwalls=r->noutline;
}
return 0;
}
/* ------------------------------------------------------------------------------------------------ files */
int join_path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
const char *read_file(const char *path, usize limit, u8 **data, usize *size)
{
*data=0;*size=0;
int fd=io_open(path,0);
if(fd<0) return "cannot open file";
usize cap=limit+1;
u8 *buf=io_alloc(cap);
if(!buf) { io_close(fd);return "file allocation failed"; }
usize n=0;
for(;;) {
long r=io_read(fd,buf+n,cap-n);
if(r<0) { io_close(fd);io_free(buf,cap);return "cannot read file"; }
if(!r) break;
n+=(usize)r;
if(n>limit) { io_close(fd);io_free(buf,cap);return "file too large"; }
}
io_close(fd);
/* The caller frees with io_free(data, limit+1); the size is the buffer, not the content. */
*data=buf;*size=n;return 0;
}
const char *publish(const char *dir, const char *name, const void *data, usize size)
{
char id[33],tmpname[256],final[4096],tmp[4096];
if(!uuid_hex(id)) return "getrandom failed";
usize n=0;
for(const char *p=name;*p && n<180;p++) tmpname[n++]=*p;
tmpname[n++]='.';
for(int i=0;i<32;i++) tmpname[n++]=id[i];
for(const char *p=".tmp";*p;p++) tmpname[n++]=*p;
tmpname[n]=0;
if(!join_path(final,dir,name) || !join_path(tmp,dir,tmpname)) return "output path too long";
int fd=io_open(tmp,1);
if(fd<0) return "cannot open temporary file";
int ok=io_write(fd,data,size);
if(io_close(fd)) ok=0;
if(!ok) { io_remove(tmp);return "cannot write temporary file"; }
if(io_rename(tmp,final)) { io_remove(tmp);return "cannot publish file"; }
return 0;
}
static u32 rd32(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static void wr32(u8 *p, u32 v) { for(int i=0;i<4;i++) p[i]=(u8)(v>>(8*i)); }
const char *mesh_parse(const u8 *data, usize size, Vec **vertices, u32 **indices, int *nv, int *ni)
{
*vertices=0;*indices=0;*nv=*ni=0;
if(size<16 || data[0]!='F' || data[1]!='P' || data[2]!='R' || data[3]!='M' || rd32(data+4)!=1) return "invalid mesh header";
u32 v=rd32(data+8),i=rd32(data+12);
if(v>MAX_MESH_VERTICES || i>(u32)MAX_TRIANGLES*3 || i%3 || size!=16+(usize)v*12+(usize)i*4) return "invalid mesh size";
Vec *pv=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
u32 *pi=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!pv || !pi) { io_free(pv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(pi,(usize)MAX_TRIANGLES*3*sizeof(u32));return "mesh allocation failed"; }
for(u32 k=0;k<v*3;k++) {
union { u32 u; float f; } c={rd32(data+16+4*(usize)k)};
if(!finite(c.f)) { io_free(pv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(pi,(usize)MAX_TRIANGLES*3*sizeof(u32));return "non-finite mesh vertex"; }
((float *)pv)[k]=c.f;
}
for(u32 k=0;k<i;k++) {
pi[k]=rd32(data+16+(usize)v*12+4*(usize)k);
if(pi[k]>=v) { io_free(pv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(pi,(usize)MAX_TRIANGLES*3*sizeof(u32));return "mesh index outside vertex array"; }
}
*vertices=pv;*indices=pi;*nv=(int)v;*ni=(int)i;return 0;
}
const char *mesh_bytes(const Vec *vertices, const u32 *indices, int nv, int ni, u8 **data, usize *size)
{
*size=16+(usize)nv*12+(usize)ni*4;
*data=io_alloc(*size);
if(!*data) return "mesh allocation failed";
u8 *p=*data;
p[0]='F';p[1]='P';p[2]='R';p[3]='M';wr32(p+4,1);wr32(p+8,(u32)nv);wr32(p+12,(u32)ni);
/* Both supported targets are little-endian IEEE float32, like the scanner's original writer. */
memcpy(p+16,vertices,(usize)nv*12);
memcpy(p+16+(usize)nv*12,indices,(usize)ni*4);
return 0;
}
/* ------------------------------------------------------------------------------------------------ scan.json */
void heads_json(Buf *b, const Vec *heads, int n)
{
put(b,"[");
for(int i=0;i<n;i++) {
if(i) put(b,",");
put(b,"[");put_dec(b,heads[i].x);put(b,",");put_dec(b,heads[i].y);put(b,",");put_dec(b,heads[i].z);put(b,"]");
}
put(b,"]");
}
int heads_parse(const char *text, usize n, Vec *heads)
{
/* Finds "heads":[[x,y,z],...] in a scan.json written by heads_json; anything else gives no heads. */
const char *key="\"heads\"";
usize at=0;
for(;at+7<=n;at++) {
usize k=0;while(k<7 && text[at+k]==key[k]) k++;
if(k==7) break;
}
if(at+7>n) return 0;
char *p=(char *)text+at+7;const char *end=text+n;
while(p<end && (is_space(*p) || *p=='\n' || *p==':')) p++;
if(p>=end || *p!='[') return 0;
p++;
int count=0;
while(p<end && count<MAX_HEADS) {
while(p<end && (is_space(*p) || *p=='\n' || *p==',')) p++;
if(p>=end || *p==']') break;
if(*p!='[') return 0;
p++;
float v[3];
for(int i=0;i<3;i++) {
while(p<end && (is_space(*p) || *p==',')) p++;
if(!parse_number(&p,&v[i])) return 0;
}
while(p<end && is_space(*p)) p++;
if(p>=end || *p!=']') return 0;
p++;
heads[count++]=vec(v[0],v[1],v[2]);
}
return count;
}
static void key_float(Buf *b, const char *k, float v) { put(b,",\"");put(b,k);put(b,"\":");put_dec(b,v); }
static void key_int(Buf *b, const char *k, i64 v, int first)
{
if(!first) put(b,",");
put(b,"\"");put(b,k);put(b,"\":");put_int(b,v);
}
void settings_json(Buf *b, const Refine *o)
{
static const char *walls[]={"off","snap","panels"};
put(b,"{");key_int(b,"refine",o->refine,1);
put(b,",\"walls\":\"");put(b,walls[o->walls]);put(b,"\"");
key_float(b,"band",o->band);key_float(b,"facing_deg",o->facing_deg);key_float(b,"band_in",o->band_in);
key_float(b,"floor_in",o->floor_in);key_float(b,"ceil_in",o->ceil_in);key_float(b,"top_strip",o->top_strip);
key_float(b,"near",o->near);key_float(b,"clip",o->clip);key_float(b,"clip_open",o->clip_open);
key_float(b,"island",o->island);key_int(b,"smooth",o->smooth,0);key_float(b,"tops",o->tops);
key_int(b,"protect",o->protect,0);key_float(b,"tubes",o->tubes);key_float(b,"tube_thick",o->tube_thick);
key_float(b,"walked",o->walked);key_int(b,"label_rules",o->label_rules,0);
put(b,",\"erase\":[");
for(int i=0;i<o->nerase;i++) {
const Sphere *e=&o->erase[i];
if(i) put(b,",");
put(b,"[");put_dec(b,e->x);put(b,",");put_dec(b,e->y);put(b,",");put_dec(b,e->z);put(b,",");put_dec(b,e->r);put(b,"]");
}
put(b,"]}");
}
void report_json(Buf *b, const Report *r, int nlabels, int changed, int removed, int nsuggest)
{
static const char *kinds[]={"wall","floor","ceiling"};
put(b,"{");key_int(b,"triangles",r->triangles,1);key_int(b,"kept",r->kept,0);
key_int(b,"structural",r->structural,0);key_int(b,"near_walls",r->near,0);key_int(b,"outside",r->outside,0);
key_int(b,"small_pieces",r->islands,0);key_int(b,"thin_pieces",r->tubes,0);key_int(b,"erased",r->erased,0);
key_int(b,"walked",r->walked,0);key_int(b,"tops",r->tops,0);key_int(b,"panel_triangles",r->panels,0);
key_int(b,"truncated",r->truncated,0);
put(b,",\"walked_skipped\":");put(b,r->walked_skipped ? "true" : "false");
key_float(b,"floor_y",r->floor_y);key_float(b,"ceiling_y",r->ceiling_y);
key_int(b,"boxes",nlabels,0);key_int(b,"relabelled",changed,0);key_int(b,"removed_boxes",removed,0);
key_int(b,"suggestions",nsuggest,0);
put(b,",\"plane_shifts\":[");
for(int i=0;i<r->nshifts;i++) {
if(i) put(b,",");
put(b,"{\"kind\":\"");put(b,kinds[r->shifts[i].kind]);put(b,"\"");
if(!r->shifts[i].kind) { put(b,",\"wall\":");put_int(b,r->shifts[i].wall); }
key_float(b,"shift",r->shifts[i].shift);put(b,"}");
}
put(b,"]}");
}
+255
View File
@@ -0,0 +1,255 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Label rules on the scanner's furniture boxes (the owner's tuned rules, 2026-10-18): boxes outside the room,
* boxes that merged several things, boxes nested in a bigger one are removed; the rest are relabelled BED, COUCH,
* TABLE, STORAGE, SCREEN or OTHER by shape; tall thin slabs from the floor become door/closet suggestions (never
* applied automatically). Every decision is reported with a reason in scan.json. */
#include "refine.h"
#define R_INSIDE .1f /* m: a centre further outside the outline is behind a wall */
#define R_NEAR_WALL .35f
#define R_BED_AREA 2.f
#define R_BED_LONG 1.8f
#define R_BED_MAX 3.f
#define R_BED_TOP .65f
#define R_BED_MIN_TOP .25f
#define R_COUCH_MIN_TOP .35f
#define R_COUCH_TOP .6f
#define R_COUCH_LONG 1.4f
#define R_TABLE_MIN .6f
#define R_TABLE_MAX 1.1f
#define R_STORAGE_H 1.1f
#define R_STORAGE_D .25f
#define R_SCREEN_BOTTOM .3f
#define R_SCREEN_TOP 2.2f
#define R_SCREEN_ASPECT 1.2f
#define R_SCREEN_PARALLEL 15.f /* degrees off the nearest wall */
#define R_SCREEN_FLAT .03f /* m: spread of the scan's depth inside the box */
#define R_THIN .22f
#define R_TALL_MIN 1.8f
#define R_TALL_FLOOR .3f /* m: a tall slab's bottom at most this far above the floor */
#define R_TALL_BEHIND .5f
#define R_DOOR_MAX 1.1f
#define R_MERGE .6f /* share of a box's footprint inside another that counts as held by it */
#define R_MERGE_APART 1.f
#define R_NESTED .75f
#define FLAT_MIN_TRIANGLES 30
#define FLAT_DEPTH_PAD .05f
#define FOOTPRINT_STEP .05
typedef struct { double cs, sn; } Rot;
static Rot rot(const RVol *v) { double a=v->yaw*3.14159265358979323846/180; return (Rot){dcos(a),dsin(a)}; }
static void local(const RVol *v, Rot r, double x, double z, double *lx, double *lz)
{
double dx=x-v->cx,dz=z-v->cz;
*lx=r.cs*dx-r.sn*dz;*lz=r.sn*dx+r.cs*dz;
}
static double dabs(double x) { return x<0 ? -x : x; }
static void corners(const RVol *v, Rot r, double out[4][2])
{
static const int sg[4][2]={{-1,-1},{1,-1},{1,1},{-1,1}};
for(int k=0;k<4;k++) {
double x=sg[k][0]*v->w/2.,z=sg[k][1]*v->d/2.;
out[k][0]=v->cx+r.cs*x+r.sn*z;out[k][1]=v->cz-r.sn*x+r.cs*z;
}
}
static int point_in_poly(double x, double z, const DPt *poly, int n)
{
int inside=0;
for(int i=0,j=n-1;i<n;j=i++) {
double xi=poly[i].x,zi=poly[i].z,xj=poly[j].x,zj=poly[j].z;
if((zi>z)!=(zj>z) && x<(xj-xi)*(z-zi)/(zj-zi+1e-12)+xi) inside=!inside;
}
return inside;
}
typedef struct { double dist, t, L, ux, uz; int i; } Near;
static Near nearest_wall(const Room *r, double x, double z)
{
Near best={1e300,0,0,1,0,-1};
for(int i=0;i<r->nwalls;i++) {
const RWall *w=&r->walls[i];
double abx=w->x1-w->x0,abz=w->z1-w->z0,l2=abx*abx+abz*abz,L=dhypot(abx,abz);
if(!(l2>0)) continue;
double t=((x-w->x0)*abx+(z-w->z0)*abz)/l2;
t=t<0 ? 0 : t>1 ? 1 : t;
double dx=x-(w->x0+t*abx),dz=z-(w->z0+t*abz),d=dhypot(dx,dz);
if(d<best.dist) best=(Near){d,t,L,abx/L,abz/L,i};
}
return best;
}
static double outside(const Room *r, double x, double z)
{
return point_in_poly(x,z,r->outline,r->noutline) ? 0 : nearest_wall(r,x,z).dist;
}
/* Spread (std) of the scan's depth inside a thin upright box; <0 = too little scan to tell. */
static double flatness(const Prep *p, const RVol *v)
{
Rot r=rot(v);
double sum=0,sum2=0;int n=0;
for(int pass=0;pass<2;pass++) {
double mean=n ? sum/n : 0;
for(int t=0;t<p->T;t++) {
double y=p->cen[3*t+1];
if(y>v->top || y<v->top-v->h) continue;
double lx,lz;local(v,r,p->cen[3*t],p->cen[3*t+2],&lx,&lz);
double a=lx,b=lz,half=v->w/2.,depth=v->d/2.+FLAT_DEPTH_PAD;
if(v->d>v->w) { a=lz;b=lx;half=v->d/2.;depth=v->w/2.+FLAT_DEPTH_PAD; }
if(dabs(a)<=half && dabs(b)<=depth) {
if(!pass) { sum+=b;n++; } else sum2+=(b-mean)*(b-mean);
}
}
if(!pass && n<FLAT_MIN_TRIANGLES) return -1;
}
return droot(sum2/n);
}
static void cm(Buf *b, double x) { put_round(b,x*100);put(b," cm"); }
static void lower(Buf *b, const char *s)
{
char c[2]={0,0};
for(;*s;s++) { c[0]=*s>='A' && *s<='Z' ? (char)(*s+32) : *s;put(b,c); }
}
static void set_text(char *dst, usize cap, Buf *b)
{
usize n=b->ok ? b->n : 0;
if(n>=cap) { /* cut at a UTF-8 boundary */
n=cap-1;
while(n && ((u8)b->data[n]&0xc0)==0x80) n--;
}
if(n) memcpy(dst,b->data,n);
dst[n]=0;
}
static void copy(char *dst, const char *src, usize cap)
{
usize i=0;while(src[i] && i+1<cap) { dst[i]=src[i];i++; }
dst[i]=0;
}
/* label "" = remove, suggest "door"/"closet" or "" */
static void classify(const Room *room, const Prep *p, const RVol *v, char *label, char *suggest, Buf *why)
{
double out=outside(room,v->cx,v->cz);
double top=v->top-room->floor,bottom=top-v->h,lng=v->w>v->d ? v->w : v->d,shrt=v->w<v->d ? v->w : v->d;
label[0]=suggest[0]=0;
if(shrt<R_THIN && v->h>=R_TALL_MIN && bottom<=R_TALL_FLOOR && out<=R_TALL_BEHIND) {
int door=lng<=R_DOOR_MAX;
copy(suggest,door ? "door" : "closet",8);
put(why,"tall thin slab from the floor (");put_fixed(why,v->h,2);put(why," m)");
if(out>0) { put(why,", ");cm(why,out);put(why," behind the wall line"); }
put(why,door ? ": a door or closet door?" : ": a closet front?");
return;
}
if(out>R_INSIDE) { put(why,"centre ");cm(why,out);put(why," outside the room (behind a wall)");return; }
Near wall=nearest_wall(room,v->cx,v->cz);
if(shrt<R_THIN) {
Rot r=rot(v);
double ax=v->w>=v->d ? r.cs : r.sn,az=v->w>=v->d ? -r.sn : r.cs;
double c=dabs(ax*wall.ux+az*wall.uz);c=c<1 ? c : 1;
double off=angle((float)droot(1-c*c),(float)c)*180/PI;
double fl=flatness(p,v);
Buf b;buf_init(&b,1024);int n=0;
if(bottom<R_SCREEN_BOTTOM) { put(&b,"starts ");cm(&b,bottom>0 ? bottom : 0);put(&b," off the floor");n++; }
if(top>R_SCREEN_TOP) { if(n++) put(&b,", ");put(&b,"top at ");put_fixed(&b,top,2);put(&b," m"); }
if(lng/v->h<R_SCREEN_ASPECT) { if(n++) put(&b,", ");put(&b,"taller than wide (");put_fixed(&b,lng/v->h,2);put(&b,")"); }
if(wall.dist<R_NEAR_WALL && off>R_SCREEN_PARALLEL) { if(n++) put(&b,", ");put_round(&b,off);put(&b,"\xc2\xb0 off the wall"); }
if(fl>=0 && fl>R_SCREEN_FLAT) { if(n++) put(&b,", ");put(&b,"not flat (");cm(&b,fl);put(&b," spread)"); }
if(!n) {
copy(label,"SCREEN",MAX_LABEL);put(why,"thin, raised, screen-shaped");
if(fl>=0) { put(why,", flat (");cm(why,fl);put(why,")"); }
} else { put(why,"thin upright slab: ");put_n(why,b.data,b.n); }
buf_free(&b);
return;
}
if(top<=R_BED_TOP && v->w*v->d>=R_BED_AREA && lng>=R_BED_LONG && lng<=R_BED_MAX && top>=R_BED_MIN_TOP) {
copy(label,"BED",MAX_LABEL);put_fixed(why,(double)v->w*v->d,1);put(why," m\xc2\xb2, ");put_fixed(why,lng,1);
put(why," m long, top ");put_fixed(why,top,2);put(why," m");return;
}
if(top>=R_COUCH_MIN_TOP && top<=R_COUCH_TOP && lng>=R_COUCH_LONG) {
copy(label,"COUCH",MAX_LABEL);put(why,"seat-height top ");put_fixed(why,top,2);put(why," m, ");
put_fixed(why,lng,1);put(why," m long");return;
}
if(top>=R_TABLE_MIN && top<=R_TABLE_MAX) { copy(label,"TABLE",MAX_LABEL);put(why,"top ");put_fixed(why,top,2);put(why," m");return; }
if(v->h>=R_STORAGE_H && shrt>=R_STORAGE_D && wall.dist<R_NEAR_WALL) {
copy(label,"STORAGE",MAX_LABEL);put_fixed(why,v->h,2);put(why," m tall by a wall");return;
}
if(top>R_TABLE_MAX) { put_fixed(why,top,2);put(why," m tall, matches nothing");return; }
copy(label,"OTHER",MAX_LABEL);put(why,"top ");put_fixed(why,top,2);put(why," m, ");put_fixed(why,lng,1);
put(why," \xc3\x97 ");put_fixed(why,shrt,1);put(why," m");
}
/* Share of a's and b's footprint they have in common, sampled on a 5 cm grid over both (as the reference). */
static void footprint_overlap(const RVol *a, const RVol *b, double *fa, double *fb)
{
Rot ra=rot(a),rb=rot(b);
double ca[4][2],cb[4][2],x0=1e300,x1=-1e300,z0=1e300,z1=-1e300;
corners(a,ra,ca);corners(b,rb,cb);
for(int k=0;k<4;k++) {
x0=ca[k][0]<x0 ? ca[k][0] : x0;x1=ca[k][0]>x1 ? ca[k][0] : x1;z0=ca[k][1]<z0 ? ca[k][1] : z0;z1=ca[k][1]>z1 ? ca[k][1] : z1;
x0=cb[k][0]<x0 ? cb[k][0] : x0;x1=cb[k][0]>x1 ? cb[k][0] : x1;z0=cb[k][1]<z0 ? cb[k][1] : z0;z1=cb[k][1]>z1 ? cb[k][1] : z1;
}
double na=0,nb=0,both=0;
for(double x=x0;x<=x1;x+=FOOTPRINT_STEP) for(double z=z0;z<=z1;z+=FOOTPRINT_STEP) {
double ax,az,bx,bz;local(a,ra,x,z,&ax,&az);local(b,rb,x,z,&bx,&bz);
int ia=dabs(ax)<=a->w/2. && dabs(az)<=a->d/2.,ib=dabs(bx)<=b->w/2. && dabs(bz)<=b->d/2.;
na+=ia;nb+=ib;both+=ia && ib;
}
*fa=both/(na>1 ? na : 1);*fb=both/(nb>1 ? nb : 1);
}
static int boxes_apart(const RVol *a, const RVol *b)
{
/* footprints whose bounding circles don't touch share nothing (skips the sampling, same result) */
double ra=droot((double)a->w*a->w+(double)a->d*a->d)/2,rb=droot((double)b->w*b->w+(double)b->d*b->d)/2;
double dx=a->cx-b->cx,dz=a->cz-b->cz;
return dx*dx+dz*dz>(ra+rb+.1)*(ra+rb+.1);
}
int rules_apply(const Room *room, const Prep *p, RVol *out, int *nout, Label *labels)
{
int n=room->nvols;const RVol *box=room->vols;
typedef struct { int i, j; double fa, fb; } Ov;
static Ov ov[MAX_VOLUMES*(MAX_VOLUMES-1)/2];
int nov=0,removed[MAX_VOLUMES]={0};
static char reason[MAX_VOLUMES][MAX_REASON];
for(int i=0;i<n;i++) for(int j=i+1;j<n;j++) {
if(boxes_apart(&box[i],&box[j])) continue;
double fa,fb;footprint_overlap(&box[i],&box[j],&fa,&fb);
if(fa>0 || fb>0) ov[nov++]=(Ov){i,j,fa,fb};
}
/* merged: a box that holds most of two boxes far apart covers several things */
for(int k=0;k<n;k++) {
int held[MAX_VOLUMES],nh=0;
for(int q=0;q<nov;q++) if(ov[q].i==k && ov[q].fb>=R_MERGE) held[nh++]=ov[q].j;
for(int q=0;q<nov;q++) if(ov[q].j==k && ov[q].fa>=R_MERGE) held[nh++]=ov[q].i;
for(int x=0;x<nh && !removed[k];x++) for(int y=0;y<nh && !removed[k];y++) {
const RVol *a=&box[held[x]],*b=&box[held[y]];
double dx=a->cx-b->cx,dz=a->cz-b->cz,d=dhypot(dx,dz);
if(held[x]<held[y] && d>=R_MERGE_APART) {
Buf t;buf_init(&t,1024);
put(&t,"holds a ");lower(&t,a->label);put(&t," and a ");lower(&t,b->label);put(&t," ");
put_fixed(&t,d,1);put(&t," m apart: several things merged");
set_text(reason[k],MAX_REASON,&t);buf_free(&t);removed[k]=1;
}
}
}
/* nested: mostly inside a bigger box with a lower top = part of it */
for(int q=0;q<nov;q++) for(int side=0;side<2;side++) {
int s=side ? ov[q].j : ov[q].i,g=side ? ov[q].i : ov[q].j;double f=side ? ov[q].fb : ov[q].fa;
const RVol *S=&box[s],*G=&box[g];
if(removed[s] || removed[g] || f<R_NESTED || (double)S->w*S->d>=(double)G->w*G->d || S->top>G->top+.05) continue;
Buf t;buf_init(&t,1024);
put_round(&t,f*100);put(&t,"% inside the bigger ");lower(&t,G->label);put(&t," box: part of it");
set_text(reason[s],MAX_REASON,&t);buf_free(&t);removed[s]=1;
}
int count=0;
for(int i=0;i<n;i++) {
Label *l=&labels[i];
memset(l,0,sizeof(*l));
copy(l->from,box[i].label,MAX_LABEL);l->box=box[i];l->wall=-1;
if(removed[i]) { copy(l->reason,reason[i],MAX_REASON);continue; }
Buf why;buf_init(&why,1024);
classify(room,p,&box[i],l->to,l->suggest,&why);
set_text(l->reason,MAX_REASON,&why);buf_free(&why);
if(l->suggest[0]) {
Near nw=nearest_wall(room,box[i].cx,box[i].cz);
l->wall=nw.i;l->along=(float)nw.t;
}
if(l->to[0]) { out[count]=box[i];copy(out[count].label,l->to,MAX_LABEL);count++; }
}
*nout=count;
return n;
}
+17 -2
View File
@@ -13,6 +13,8 @@
#define MAX_VOLUMES 128
#define MAX_TRIANGLES 200000
#define MAX_MESH_VERTICES 600000
#define MAX_HEADS 2000
#define MAX_ERASE 64
#define PI 3.14159265358979323846f
typedef struct { float x, y, z; } Vec;
@@ -29,8 +31,17 @@ typedef struct {
int used;
} Plane;
typedef struct { Vec p; float yaw, w, d, h; const char *label; } Volume;
typedef struct { float x, y, z, r; } Sphere;
/* Refinement + label rules (refine.c, rules.c); defaults = the owner's tuned settings (2026-10-18). */
typedef struct {
const char *input, *out, *hint;
int refine, walls, protect, label_rules, smooth, nerase; /* walls: 0 off, 1 snap, 2 panels */
float band, facing_deg, band_in, floor_in, ceil_in, top_strip, near, clip, clip_open, island, tops;
float tubes, tube_thick, walked;
Sphere erase[MAX_ERASE];
} Refine;
typedef struct {
const char *input, *out, *hint, *refine_only;
Refine rf;
u64 universe;
float voxel, ceiling_default, truncation, max_edge, grazing, range, depth_jump, depth_ratio, min_views;
float patch_residual;
@@ -56,10 +67,13 @@ typedef struct {
const char *warnings[24];
int nwarnings, hint_used;
u32 rng;
Vec heads[MAX_HEADS]; /* head path of tracked frames; the stride doubles whenever the buffer is full */
int nheads;
unsigned head_stride, head_frames;
Vec *vertices;
u32 *indices;
int nv, ni;
i64 t0, tread, tfuse, tmodel, tmesh;
i64 t0, tread, tfuse, tmodel, tmesh, trefine;
} Scan;
static inline float ab(float x) { return x < 0 ? -x : x; }
@@ -89,5 +103,6 @@ void finish_fusion(Scan *s);
const char *make_model(Scan *s);
const char *make_mesh(Scan *s);
int same(const char *a, const char *b);
int parse_number(char **cursor, float *value);
#endif
+8
View File
@@ -61,6 +61,14 @@ def from_data() -> dict:
texts[label] = ""
for _, title in GROUPS: # the Game settings dialog
texts[title] = ""
from frameport.install.rooms import REFINE_DEFAULTS, REFINE_GROUPS
for meta in REFINE_DEFAULTS.values(): # the Room tab's scan clean-up settings
texts[meta["label"]] = ""
for _, label in meta.get("choices", ()):
texts[label] = ""
for _, title in REFINE_GROUPS:
texts[title] = ""
for ui in [*UI.values(), *FOVEATION_UI.values()]:
for t in (ui.get("label"), ui.get("help")):
if t:
+6 -2
View File
@@ -12,6 +12,10 @@ BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)]
L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)]
TABLE = ((-.6, 0, -.25), (1.2, .75, .7))
COUCH = ((.6, 0, .95), (1.6, .65, .65))
# Clutter for the refinement tests: a thin pole floating in the air (a stereo streak) and a column where the user
# stood (a "ghost" of the user's own body or a passer-by in the depth stream).
TUBE = ((1.3, 1.35, -.5), (.12, .12, 1.4))
GHOST = ((.35, .45, 0), (.3, .9, .3))
ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128
ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0
@@ -104,7 +108,7 @@ def cast(eye, direction, polygon, ceiling, furniture, missing_wall):
def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004,
holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7,
yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview",
invalid_start=None, smoothing=3):
invalid_start=None, smoothing=3, extra=()):
"""Each frame has its own moving pose; no ground-truth room data is put in the dump."""
rng = random.Random(seed)
roomview = profile == "roomview"
@@ -113,7 +117,7 @@ def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise
invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start
polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE
ceiling = 2.6
furniture = [TABLE, COUCH] if objects and shape == "box" else []
furniture = ([TABLE, COUCH] if objects and shape == "box" else []) + list(extra)
rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2))
rays = list(fan(width, height, roomview))
with path.open("wb") as out:
+214 -23
View File
@@ -8,6 +8,7 @@ Quest builds are "installed" (the 8 largest).
from __future__ import annotations
import json
import math
import os
import random
import threading
@@ -30,61 +31,98 @@ def frame_state() -> dict:
return {}
def fake_room_mesh(room, cell: float = 0.25, seed: int = 7):
def fake_room_mesh(room, cell: float = 0.2, seed: int = 7):
"""A made-up scan of `room`: its floor, walls and furniture as noisy triangle grids, wound like a real scan's
(counter-clockwise seen from inside the room)."""
from frameport.mr.room import Mesh
(counter-clockwise seen from inside the room). Like a real scan it has holes where the headset sees nothing:
the floor under furniture that stands on it, and window glass."""
from frameport.mr.room import Mesh, plane_corners
from frameport.mr.view3d import box_corners
rnd = random.Random(seed)
verts, idx = [], []
def surface(o, u, v): # o + s*u + t*v, normal = u x v
def surface(o, u, v, noise=0.012, keep=None): # o + s*u + t*v, normal = u x v; keep(point) False = a hole
lu = max(1, round((sum(c * c for c in u) ** 0.5) / cell))
lv = max(1, round((sum(c * c for c in v) ** 0.5) / cell))
base = len(verts)
for i in range(lv + 1):
for j in range(lu + 1):
n = 0.015 if 0 < i < lv and 0 < j < lu else 0.0
n = noise if 0 < i < lv and 0 < j < lu else 0.0
verts.append(tuple(o[k] + u[k] * j / lu + v[k] * i / lv + rnd.uniform(-n, n) for k in range(3)))
for i in range(lv):
for j in range(lu):
mid = tuple(o[k] + u[k] * (j + 0.5) / lu + v[k] * (i + 0.5) / lv for k in range(3))
if keep is not None and not keep(mid):
continue
a = base + i * (lu + 1) + j
b, c, d = a + 1, a + lu + 1, a + lu + 2
idx.extend((a, b, d, a, d, c))
def inside(vol, x, z): # (x, z) on the box's footprint
a = math.radians(vol.yaw_deg)
dx, dz = x - vol.cx, z - vol.cz
lx, lz = math.cos(a) * dx - math.sin(a) * dz, math.sin(a) * dx + math.cos(a) * dz
return abs(lx) < vol.w / 2 and abs(lz) < vol.d / 2
y0 = room.floor
pts = room.outline
xs, zs = [x for x, _ in pts], [z for _, z in pts]
surface((min(xs), y0, max(zs)), (max(xs) - min(xs), 0, 0), (0, 0, min(zs) - max(zs))) # floor, facing up
standing = [v for v in room.volumes if v.top_y - v.h - y0 < 0.05 and v.h > 0.3] # on the floor, no gap below
windows = [plane_corners(p) for p in room.planes if p.label == "WINDOW_FRAME"]
def floor_seen(p):
return not any(inside(v, p[0], p[2]) for v in standing)
def wall_seen(p): # not through a window's glass
for c in windows:
xs, ys, zs = [q[0] for q in c], [q[1] for q in c], [q[2] for q in c]
if (min(ys) + 0.05 < p[1] < max(ys) - 0.05 and min(xs) - 0.05 <= p[0] <= max(xs) + 0.05
and min(zs) - 0.05 <= p[2] <= max(zs) + 0.05):
return False
return True
xs, zs = [x for x, _ in room.outline], [z for _, z in room.outline]
surface((min(xs), y0, max(zs)), (max(xs) - min(xs), 0, 0), (0, 0, min(zs) - max(zs)), 0.008,
floor_seen) # floor, facing up
for w in room.walls:
if w.label == "INVISIBLE_WALL_FACE":
continue
surface((w.x0, w.y0, w.z0), (w.x1 - w.x0, 0, w.z1 - w.z0), (0, w.y1 - w.y0, 0)) # facing into the room
surface((w.x0, w.y0, w.z0), (w.x1 - w.x0, 0, w.z1 - w.z0), (0, w.y1 - w.y0, 0), 0.012,
wall_seen) # facing into the room
for vol in room.volumes:
c = box_corners(vol.cx, vol.cz, vol.yaw_deg, vol.w, vol.d)
top, bottom = vol.top_y, vol.top_y - vol.h
soft = 0.03 if vol.label in ("BED", "COUCH") else 0.015 # (a duvet isn't flat)
surface((c[0][0], top, c[0][1]), (c[3][0] - c[0][0], 0, c[3][1] - c[0][1]),
(c[1][0] - c[0][0], 0, c[1][1] - c[0][1])) # top, facing up
(c[1][0] - c[0][0], 0, c[1][1] - c[0][1]), soft) # top, facing up
for i in range(4):
a, b = c[i], c[(i + 1) % 4]
surface((a[0], bottom, a[1]), (0, top - bottom, 0), (b[0] - a[0], 0, b[1] - a[1])) # facing out
surface((a[0], bottom, a[1]), (0, top - bottom, 0), (b[0] - a[0], 0, b[1] - a[1]), 0.012) # facing out
return Mesh(vertices=verts, indices=idx)
def fake_room():
"""The pretend Frame's scanned room: a 5 x 4 m living room with a couch, a table, a TV and shelves."""
from frameport.mr.room import Volume, box_room
"""The pretend Frame's scanned room, made up from scratch (no real scan): a 3.6 x 3.2 m bedroom with a bed and a
nightstand under a wall-mounted TV, a closet, a desk with two monitors under the window, a door, a lamp and a
plant. Walls: 0 = the bed's (-Z, forward), 1 = right (+X), 2 = behind (+Z), 3 = the desk's and the door's (-X);
the 3D view looks at walls 0 and 3."""
from frameport.mr.room import Volume, box_room, plane_on_wall
room = box_room(5, 4, 2.6)
room = box_room(3.6, 3.2, 2.5)
room.source, room.universe, room.time = "scan", 7, int(SHOTS_AT)
room.walls[1].label = "INVISIBLE_WALL_FACE" # an open side (a doorway the scan closed)
room.walls = [type(w)(f"{0xa0 + i:032x}", w.x0, w.z0, w.x1, w.z1, w.y0, w.y1, w.label)
for i, w in enumerate(room.walls)] # (fixed ids: the same room in every render)
room.uuid, room.floor_uuid, room.ceiling_uuid = (f"{0xf0 + i:032x}" for i in range(3))
vid = iter(f"{i:032x}" for i in range(1, 99))
room.volumes = [Volume(next(vid), "COUCH", -1.2, 1.3, 0.85, 0, 2.2, 0.9, 0.85),
Volume(next(vid), "TABLE", -0.9, 0.1, 0.45, 0, 1.1, 0.6, 0.45),
Volume(next(vid), "SCREEN", -1.0, -1.85, 1.5, 0, 1.4, 0.12, 0.8),
Volume(next(vid), "STORAGE", 1.9, -1.4, 1.8, 90, 1.2, 0.4, 1.8),
Volume(next(vid), "TABLE", 1.6, 1.2, 0.75, 20, 1.2, 0.7, 0.75)]
room.volumes = [Volume(next(vid), "BED", 0.6, -0.58, 0.55, 0, 1.45, 2.05, 0.55),
Volume(next(vid), "SCREEN", 0.6, -1.55, 1.78, 0, 1.12, 0.07, 0.66), # TV above the bed
Volume(next(vid), "TABLE", -1.44, -0.25, 0.75, 90, 1.4, 0.7, 0.75), # desk
Volume(next(vid), "SCREEN", -1.6, -0.56, 1.2, 82, 0.6, 0.06, 0.36), # monitors on it
Volume(next(vid), "SCREEN", -1.6, 0.06, 1.2, 98, 0.6, 0.06, 0.36),
Volume(next(vid), "STORAGE", -1.25, -1.3, 2.1, 0, 1.0, 0.6, 2.1), # closet
Volume(next(vid), "TABLE", -0.42, -1.38, 0.52, 0, 0.45, 0.4, 0.52), # nightstand
Volume(next(vid), "LAMP", -0.42, -1.42, 0.9, 0, 0.2, 0.2, 0.38),
Volume(next(vid), "PLANT", 1.56, -1.37, 0.95, 0, 0.36, 0.36, 0.95)] # in the corner
room.planes = [plane_on_wall(room, 3, 0.17, 0.0, 0.85, 2.05, "DOOR_FRAME", f"{0xd0:032x}"),
plane_on_wall(room, 3, 0.58, 1.22, 1.05, 1.05, "WINDOW_FRAME", f"{0xd1:032x}")]
return room
@@ -99,6 +137,7 @@ class FakeRoom:
self.home = home
self.scan: dict = {"state": "none"}
self.polls = 0
self.mesh_triangles = 0
if ROOM_DIR:
from pathlib import Path
@@ -106,14 +145,21 @@ class FakeRoom:
src = Path(ROOM_DIR)
self.room = parse((src / "room.txt").read_text())
self.mesh = (src / self.room.mesh.filename).read_bytes() if self.room.mesh else None
if self.room.mesh:
self.mesh_triangles = self.room.mesh.index_count // 3
else:
from frameport.mr.room import Mesh
self.room = fake_room()
m = fake_room_mesh(self.room)
self.room.mesh = Mesh(filename="room-mesh.bin", vertex_count=len(m.vertices), index_count=len(m.indices))
self.room.mesh = Mesh(uuid=f"{0xe0:032x}", filename="room-mesh.bin", vertex_count=len(m.vertices),
index_count=len(m.indices))
self.mesh = write_mesh(m)
self.mesh_triangles = len(m.indices) // 3
self.text = dumps(self.room)
self.refine: dict = {} # the saved clean-up settings (non-defaults)
self.edited = False
self.last_report = self.report()
self._write()
def _folder(self):
@@ -127,15 +173,72 @@ class FakeRoom:
with open(os.path.join(self._folder(), self.room.mesh.filename), "wb") as f:
f.write(self.mesh)
# the scan clean-up (scan.json, docs/mr/ROOM_FORMAT.md): what the label rules decided for each of the scanner's
# boxes (in its order; to = None: removed), and the door / closet suggestions (never applied by the scanner)
LABELS = [
{"from": "BED", "to": "BED", "reason": "flat top at 0.55 m, 2.05 x 1.45 m against a wall: a bed",
"box": {"w": 1.45, "d": 2.05, "h": 0.55}},
{"from": "TABLE", "to": "SCREEN", "reason": "7 cm thin, flat on the wall, 1.1 m above the floor: a screen",
"box": {"w": 1.12, "d": 0.07, "h": 0.66}},
{"from": "TABLE", "to": "TABLE", "reason": "top at 0.75 m by a wall: a desk",
"box": {"w": 1.4, "d": 0.7, "h": 0.75}},
{"from": "OTHER", "to": "SCREEN", "reason": "6 cm thin and upright, standing on a table: a screen",
"box": {"w": 0.6, "d": 0.06, "h": 0.36}},
{"from": "SCREEN", "to": "SCREEN", "reason": "upright on a table, facing the room: a screen",
"box": {"w": 0.6, "d": 0.06, "h": 0.36}},
{"from": "STORAGE", "to": "STORAGE", "reason": "2.1 m high and 0.6 m deep in a corner: storage",
"box": {"w": 1.0, "d": 0.6, "h": 2.1}},
{"from": "STORAGE", "to": "TABLE", "reason": "top at 0.52 m, 0.45 m wide by the bed: a nightstand",
"box": {"w": 0.45, "d": 0.4, "h": 0.52}},
{"from": "LAMP", "to": "LAMP", "reason": "0.38 m high, standing on a table: a lamp",
"box": {"w": 0.2, "d": 0.2, "h": 0.38}},
{"from": "PLANT", "to": "PLANT", "reason": "0.95 m high and round from above: a plant",
"box": {"w": 0.36, "d": 0.36, "h": 0.95}},
{"from": "OTHER", "to": None, "reason": "0.5 m behind the wall: outside the room",
"box": {"w": 0.6, "d": 0.4, "h": 0.8}},
{"from": "TABLE", "to": None, "reason": "only 3 cm above the floor: a rug, not furniture",
"box": {"w": 1.6, "d": 1.1, "h": 0.03}},
{"from": "STORAGE", "to": None, "suggest": "door", "reason": "2.0 m high, 10 cm thin, flat on the wall",
"box": {"w": 0.8, "d": 0.1, "h": 2.0}}]
SUGGESTIONS = [{"kind": "door", "box": {"cx": 1.75, "cz": 1.0, "top_y": 2.0, "yaw_deg": 90, "w": 0.8, "d": 0.1,
"h": 2.0}, "wall": 1, "along": 0.81,
"reason": "A tall, thin slab on the wall, 0.8 m wide: a door, or a closet's front"}]
def report(self, settings: dict | None = None) -> dict:
"""scan.json's report (its keys: docs/mr/ROOM_FORMAT.md), consistent with the room's own mesh: `kept` is
the mesh's triangle count with the recommended settings, other settings remove more or less."""
s = {**{"walked": 0.4, "smooth": 8, "island": 0.2, "walls": "panels"}, **(settings or {})}
def removed(s):
return {"walked": int(1900 * s["walked"]), "small_pieces": int(3600 * s["island"]),
"near_walls": 140 if s["walls"] == "panels" else 40}
now, default = removed(s), removed({"walked": 0.4, "island": 0.2, "walls": "panels"})
fixed = {"outside": 1260, "thin_pieces": 184, "erased": 0}
kept = self.mesh_triangles - (sum(now.values()) - sum(default.values()))
return {"triangles": self.mesh_triangles + sum(default.values()) + sum(fixed.values()), "kept": kept,
"structural": int(self.mesh_triangles * 0.62) if s["walls"] != "off" else 0, **now, **fixed,
"tops": 410, "panel_triangles": 64 if s["walls"] == "panels" else 0, "truncated": 0,
"walked_skipped": False, "floor_y": 0.0, "ceiling_y": 2.5,
"boxes": sum(1 for lb in self.LABELS if lb["to"]),
"relabelled": sum(1 for lb in self.LABELS if lb["to"] and lb["to"] != lb["from"]),
"removed_boxes": sum(1 for lb in self.LABELS if not lb["to"]),
"suggestions": len(self.SUGGESTIONS), "plane_shifts": [{"kind": "wall", "wall": 3, "shift": 0.02}]}
def status(self) -> dict:
from frameport.mr.room import summary
if self.text is None:
return {"rooms": [], "universe": self.room.universe, "room": None, "room_text": None, "aligned": False,
"default_circle": False, "scanner_installed": True}
"default_circle": False, "scanner_installed": True, "refine": self.refine, "labels": [],
"suggestions": [], "report": None}
scanned = self.room.source == "scan"
return {"rooms": [], "universe": self.room.universe, "aligned": True, "default_circle": False,
"scanner_installed": True, "room_text": self.text,
"room": {"universe": self.room.universe, "source": self.room.source, "summary": summary(self.room)}}
"room": {"universe": self.room.universe, "source": self.room.source, "summary": summary(self.room)},
"refine": dict(self.refine), "report": self.last_report if scanned else None,
"labels": self.LABELS if scanned else [], "suggestions": self.SUGGESTIONS if scanned else [],
"raw": scanned, "edited": self.edited}
def agent(self, command, **args):
if command == "room_status":
@@ -160,6 +263,31 @@ class FakeRoom:
{"state": "recording", "directions": 7, "looked_up": 6, "looked_down": 2}]
self.scan.update(steps[min(self.polls - 1, len(steps) - 1)])
return dict(self.scan)
if command == "room_refine": # a clean-up takes a moment; the room's mesh stays the made-up one
from frameport.mr.room import dumps
time.sleep(5)
if args.get("settings") is not None:
self.refine = dict(args["settings"])
if not args.get("keep_edits", True):
self.edited = False
self.last_report = self.report(self.refine)
self.room.time += 1
self.text = dumps(self.room)
self._write()
return {"summary": {"refine": self.last_report}, **self.status()}
if command == "room_put": # the box editor's Save
from pathlib import Path
from frameport.mr.room import dumps, parse
path = Path(args["path"])
self.room = parse(path.read_text())
if self.room.mesh:
self.mesh = (path.parent / self.room.mesh.filename).read_bytes()
self.text = dumps(self.room)
self.edited = bool(args.get("edited"))
self._write()
return {"room": {"universe": self.room.universe}}
if command == "room_capture_stop":
if self.scan.get("state") in ("waiting", "recording", "building"):
self.scan.update(state="failed", error="cancelled", cancelled=True)
@@ -194,6 +322,29 @@ class FakeFS:
self.shots = self._screenshots()
self.room = FakeRoom(self.home)
def run(self, cmd: str):
"""Only the room uploads' folder (rooms._upload_dir: mkdir -p / rm -rf below the room store)."""
import shlex
import shutil
rooms_dir = os.path.join(self.home, ".local/share/frameport/rooms")
words = shlex.split(cmd)
path = words[-1] if words else ""
if words[:2] == ["mkdir", "-p"] and path.startswith(rooms_dir):
os.makedirs(path, exist_ok=True)
return 0, "", ""
if words[:3] == ["rm", "-rf", "--"] and path.startswith(rooms_dir + "/.incoming-"):
shutil.rmtree(path, ignore_errors=True)
return 0, "", ""
raise AttributeError(f"fake Frame: no shell for {cmd!r}")
def put(self, local, remote, **kwargs):
import shutil
if not str(remote).startswith(os.path.join(self.home, ".local/share/frameport/rooms/.incoming-")):
raise AttributeError("fake Frame: uploads only for rooms")
shutil.copyfile(local, remote)
def _screenshots(self) -> list[dict]:
"""Sample Steam screenshots for the Screenshots tab, two days, a few games + SteamVR. They are the games' own
in-game store screenshots (shot_*.jpg from the store details), never cover art: games installed on the
@@ -598,6 +749,8 @@ def start_fake_scan(app: FramePortApp) -> None:
from frameport.ui import room_state
app.go("room")
if app.room_view is not None and app.room_view.root is not None: # (back to the top after the editing cards)
app.page.run_task(app.room_view.root.scroll_to, offset=0, duration=0)
room_state.operate(app, "capture")
time.sleep(5) # rooms.scan polls every 2 s
@@ -609,6 +762,44 @@ def cancel_fake_scan(app: FramePortApp) -> None:
time.sleep(3)
def show_room_editing(app: FramePortApp, which: str) -> None:
"""The Room tab's editing cards (scrolled into view): cleanup (Clean-up with the key settings), more (+ More
settings), box (a box selected in the editor), doors (a door added on a wall), suggestion (the Labels card's door
suggestion taken), applied (other settings applied: the job runs, then the new counts)."""
view = app.room_view
if view is None or view.root is None:
open_room(app)
view = app.room_view
if which == "cleanup":
if view.cleanup.more.visible:
view.cleanup.toggle_more()
elif which == "more":
if not view.cleanup.more.visible:
view.cleanup.toggle_more()
elif which == "box":
if view.cleanup.more.visible:
view.cleanup.toggle_more()
view.editor.select(("box", 1))
elif which == "doors":
view.editor._add_plane("DOOR_FRAME")
view.editor._plane_value("along", 0.3)
elif which == "suggestion":
view.editor.accept(app.target.frame.room.SUGGESTIONS[0], "door")
elif which == "applied":
view.editor.discard()
view.cleanup.set("walls", "snap")
view.cleanup._slid("smooth", 4)
view.cleanup.apply()
time.sleep(0.6)
app.page.run_task(view.show_editing)
time.sleep(0.6)
return # (the running clean-up: the next step shows its result)
elif which == "result":
time.sleep(6)
app.page.run_task(view.show_editing)
time.sleep(1.5)
def show_room_state(app: FramePortApp, which: str) -> None:
"""none: the Room tab without a saved room; disconnected: its not-connected page; frame: the Frame page's room card
(scrolled into view) with the room back."""
+12 -1
View File
@@ -399,6 +399,17 @@ def _long_upload(session, package: str, seconds: float = 30.0, after: float = 2.
app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install"))
def _open_room(session) -> None:
"""The Room tab with the pretend Frame's scanned room loaded and drawn (3D view, cards)."""
fakes.open_room(session.app)
def _room_editing(session, which: str = "box") -> None:
"""The Room tab's editing cards scrolled into view (fakes.show_room_editing: cleanup, more, box = a box selected,
doors, suggestion = the door suggestion taken)."""
fakes.show_room_editing(session.app, which)
def _press_keys(session, text: str, gap: float = 0.28) -> None:
"""Keys typed on the Type on Frame tab (the pretend keyboard), one by one."""
view = session.app.keyboard_view
@@ -414,4 +425,4 @@ HOOKS = {"fake_install": _fake_install, "wait_jobs": _wait_jobs, "held_install":
"frame_asks": _frame_asks,
"easter_eggs": _easter_eggs, "fps_dip": _fps_dip, "perfect_pacing": _perfect_pacing,
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys,
"monitor_settled": _monitor_settled}
"monitor_settled": _monitor_settled, "open_room": _open_room, "room_editing": _room_editing}
+11 -1
View File
@@ -33,6 +33,7 @@ from showcase.fakes import ( # noqa: E402,F401
open_monitor_details,
open_room,
open_type_tab,
show_room_editing,
show_room_state,
start_fake_install,
start_fake_scan,
@@ -143,7 +144,16 @@ def main() -> int:
("keyboard", lambda a: (stop_fake_live(a), a.go("keyboard"))),
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
# the Room tab (--fake-frame: a made-up scanned room; a scan steps forward and waits, then is cancelled)
("room", open_room), ("room-scanning", start_fake_scan), ("room-cancelled", cancel_fake_scan),
("room", open_room),
# its editing cards: the scan clean-up, the box editor, doors and windows, the label rules' suggestions
("room-cleanup", lambda a: show_room_editing(a, "cleanup")),
("room-cleanup-more", lambda a: show_room_editing(a, "more")),
("room-box-selected", lambda a: show_room_editing(a, "box")),
("room-doors", lambda a: show_room_editing(a, "doors")),
("room-suggestion-door", lambda a: show_room_editing(a, "suggestion")),
("room-cleanup-applying", lambda a: show_room_editing(a, "applied")),
("room-cleanup-applied", lambda a: show_room_editing(a, "result")),
("room-scanning", start_fake_scan), ("room-cancelled", cancel_fake_scan),
("room-none", lambda a: show_room_state(a, "none")),
("room-disconnected", lambda a: show_room_state(a, "disconnected")),
("room-frame-card", lambda a: show_room_state(a, "frame")),
+1 -1
View File
@@ -10,7 +10,7 @@ const site = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repo = resolve(site, '..');
const SHOTS = ['library.png', 'game.png', 'monitor.png', 'live-view.png', 'files.png', 'type-on-frame.png',
'screenshots.png', 'install-progress.png', 'patches.png'];
'screenshots.png', 'install-progress.png', 'patches.png', 'room.png'];
const PUBLIC = {
'docs/images': ['tour-teaser.webp'],
'docs/media': ['frameport-tour.mp4', 'frameport-tour.jpg', 'frameport-install.mp4', 'frameport-install.jpg'],
+2
View File
@@ -13,6 +13,7 @@ import keys from '../assets/media/type-on-frame.png';
import screenshots from '../assets/media/screenshots.png';
import progress from '../assets/media/install-progress.png';
import patches from '../assets/media/patches.png';
import room from '../assets/media/room.png';
import { url } from '../data/site';
interface Props { games: MockGame[] }
@@ -21,6 +22,7 @@ const shots = [
{ img: library, title: 'Library', text: 'Your games with their artwork. Search, filter, install several at once.' },
{ img: game, title: 'Game page', text: 'One click to install or play. Patches under Customize.' },
{ img: progress, title: 'Installing', text: 'Each stage, with speed and time left.' },
{ img: room, title: 'Room (experimental)', text: 'Your scanned room in 3D for mixed-reality games. Clean up the scan, fix the labels.' },
{ img: monitor, title: 'Monitor', text: "Frame rate, load, temperature and battery, live." },
{ img: live, title: 'Live view', text: 'What the headset shows, with sound, in your browser.' },
{ img: screenshots, title: 'Screenshots', text: 'Your headset screenshots, by game and day.' },
+36
View File
@@ -886,6 +886,42 @@ def room_scan(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
typer.echo(json.dumps(rooms.scan(_target(frame).frame, seconds, wait, progress=progress, preview=preview), indent=2))
@room_app.command("refine")
def room_refine(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
set_: list[str] = typer.Option([], "--set", metavar="KEY=VALUE",
help="a clean-up setting (repeatable), e.g. walls=snap; "
"the others keep their saved value"),
reset: bool = typer.Option(False, "--reset", help="back to the recommended settings first"),
keep_edits: bool = typer.Option(True, "--keep-edits/--scan-boxes",
help="keep your own furniture and doors, or use the scan's boxes"),
list_: bool = typer.Option(False, "--list", help="print the settings and their ranges, then stop")):
"""Clean up the saved room's scan again on the Frame (and keep the settings for the next scans)."""
from .install import rooms
if list_:
for key, meta in rooms.REFINE_DEFAULTS.items():
span = ("/".join(c for c, _ in meta["choices"]) if meta["kind"] == "choice" else "0/1"
if meta["kind"] == "bool" else f"{meta['min']:g}..{meta['max']:g} step {meta['step']:g}")
typer.echo(f"{key:<12} default {meta['default']!s:<7} {span:<24} {meta['label']}")
return
target = _target(frame)
settings = None
if reset or set_:
saved = {} if reset else rooms.normalize_refine(rooms.status(target.frame).get("refine") or {})
for item in set_ or []:
key, sep, value = item.partition("=")
if not sep:
raise typer.BadParameter(f"expected KEY=VALUE, got {item!r}", param_hint="--set")
saved[key.strip()] = value.strip()
try:
settings = rooms.normalize_refine(saved)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--set") from exc
result = rooms.refine(target.frame, settings, keep_edits=keep_edits)
typer.echo(json.dumps({k: result.get(k) for k in ("refine", "summary", "report", "labels", "suggestions",
"room")}, indent=2))
@room_app.command("show")
def room_show(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
output: Optional[Path] = typer.Option(None, help="SVG preview path")):
+132
View File
@@ -167,6 +167,138 @@ def build(frame, dump: Path, universe: int | None = None) -> dict:
**({"universe": universe} if universe is not None else {}))
# ---------------------------------------------------------------- scan clean-up (fp_roomscan's refinement)
# The one list of clean-up settings: the owner's tuned defaults (compiled into the scanner; the agent's
# ROOM_REFINE_DEFAULTS repeats them, a test keeps both equal) and what the Room tab shows for each one.
# kind: choice / bool / number; unit: how the value reads (cm = metres shown in cm, m2, deg, times, count);
# off: the value that turns the step off (shown as "off"); all: a value that keeps everything ("keep all");
# group: walls / outside / cleanup / labels; key: shown without "More settings"; help: a key of ui/help.py HELP;
# flag: fp_roomscan's option. Labels are English here and translated where they're shown
# (scripts/i18n_extract.py collects them).
REFINE_DEFAULTS: dict[str, dict] = {
"refine": dict(default=1, kind="bool", label="Clean up the scan", group="cleanup", help="refine_refine"),
"walls": dict(default="panels", kind="choice", label="Walls", group="walls", key=True, help="refine_walls",
choices=(("off", "Keep scan"), ("snap", "Snap"), ("panels", "Flat panels"))),
"walked": dict(default=0.40, min=0, max=0.8, step=0.05, unit="cm", off=0, label="Where you walked",
group="cleanup", key=True, help="refine_walked"),
"smooth": dict(default=8, min=0, max=15, step=1, unit="times", off=0, label="Smoothing", group="cleanup",
key=True, help="refine_smooth"),
"island": dict(default=0.20, min=0, max=1, step=0.01, unit="m2", off=0, label="Remove pieces smaller than",
group="cleanup", key=True, help="refine_island"),
"clip": dict(default=0.05, min=0, max=1, step=0.01, unit="cm", all=1, label="Keep past walls",
group="outside", key=True, help="refine_clip"),
"clip_open": dict(default=0.10, min=0, max=2, step=0.05, unit="cm", all=2, label="Extra past unseen edges",
group="outside", help="refine_clip_open"),
"band": dict(default=0.35, min=0.05, max=0.6, step=0.01, unit="cm", label="Behind a surface", group="walls",
help="refine_band"),
"band_in": dict(default=0.12, min=0.02, max=0.6, step=0.01, unit="cm", label="In front of a wall",
group="walls", help="refine_band_in"),
"floor_in": dict(default=0.08, min=0.02, max=0.4, step=0.01, unit="cm", label="Above the floor", group="walls",
help="refine_floor_in"),
"ceil_in": dict(default=0.25, min=0.02, max=0.6, step=0.01, unit="cm", label="Below the ceiling",
group="walls", help="refine_ceil_in"),
"top_strip": dict(default=0.5, min=0, max=1, step=0.05, unit="cm", off=0, label="Top of walls", group="walls",
help="refine_top_strip"),
"facing_deg": dict(default=60, min=10, max=80, step=1, unit="deg", label="Facing within", group="walls",
help="refine_facing_deg"),
"near": dict(default=0.03, min=0, max=0.6, step=0.01, unit="cm", off=0, label="Also clear near walls",
group="walls", help="refine_near"),
"tubes": dict(default=3.0, min=0, max=6, step=0.1, unit="times", off=0, label="Remove thin floating pieces",
group="cleanup", help="refine_tubes"),
"tube_thick": dict(default=0.15, min=0.04, max=0.4, step=0.01, unit="cm", label="…no thicker than",
group="cleanup", help="refine_tube_thick"),
"tops": dict(default=0.10, min=0, max=0.2, step=0.01, unit="cm", off=0, label="Flatten furniture tops",
group="cleanup", help="refine_tops"),
"protect": dict(default=0, kind="bool", label="Never remove scan inside furniture", group="cleanup",
help="refine_protect"),
"label_rules": dict(default=1, kind="bool", label="Check furniture labels", group="labels",
help="refine_label_rules"),
}
for _key, _meta in REFINE_DEFAULTS.items():
_meta.setdefault("kind", "number")
_meta.setdefault("key", False)
_meta["flag"] = "--" + _key.replace("_", "-")
REFINE_GROUPS = (("walls", "Walls, floor and ceiling"), ("outside", "Outside the room"),
("cleanup", "Floating pieces and furniture"),
("labels", "Label rules"))
INT_SETTINGS = ("refine", "smooth", "protect", "label_rules")
def refine_defaults() -> dict:
return {k: m["default"] for k, m in REFINE_DEFAULTS.items()}
def normalize_refine(settings: dict | None) -> dict:
"""Only the settings that differ from the defaults, each checked, snapped to its step and clamped to its slider
(unknown keys raise ValueError)."""
out = {}
for key, value in (settings or {}).items():
meta = REFINE_DEFAULTS.get(key)
if meta is None:
raise ValueError(f"unknown clean-up setting: {key}")
if meta["kind"] == "choice":
if value not in [c for c, _ in meta["choices"]]:
raise ValueError(f"{key}: expected one of " + ", ".join(c for c, _ in meta["choices"]))
elif meta["kind"] == "bool":
if isinstance(value, str):
value = value.strip().lower() in ("1", "true", "on", "yes")
value = 1 if value else 0
else:
value = float(value)
if value != value or value in (float("inf"), float("-inf")):
raise ValueError(f"{key}: not a number")
value = min(meta["max"], max(meta["min"], value))
value = round(round((value - meta["min"]) / meta["step"]) * meta["step"] + meta["min"], 4)
value = int(round(value)) if key in INT_SETTINGS else value
if value != meta["default"]:
out[key] = value
return out
def current_refine(status: dict | None) -> dict:
"""Every clean-up setting as the Frame has it saved (room_status' refine = the non-defaults)."""
return {**refine_defaults(), **normalize_refine((status or {}).get("refine") or {})}
def refine(frame, settings: dict | None = None, erase: list | None = None, keep_edits: bool = True) -> dict:
"""Clean up the saved room's scan again on the Frame (fp_roomscan --refine-only from its raw files) and save
`settings` there for the next scans (None = keep the saved ones; {} = the defaults). erase: [[x, y, z, r], ...]
spheres of scan to remove. keep_edits=False drops the player's own furniture / doors (back to the scan's).
Returns room_status with the clean-up's report, label decisions and suggestions (+ summary: its JSON output)."""
ensure_scanner(frame)
args: dict = {"settings": None if settings is None else normalize_refine(settings), "keep_edits": keep_edits}
if erase:
args["erase"] = [[float(v) for v in spot] for spot in erase]
result = frame.agent("room_refine", **args)
if result.get("error"):
raise RuntimeError(result["error"])
return result
def put_room(frame, room, mesh: bytes | None = None) -> dict:
"""Save an edited copy of the stored room (furniture boxes, doors and windows from the Room tab): the Frame keeps
the scan's raw files and remembers the edits, so a new clean-up keeps them. `mesh` = the room's mesh sidecar
(fetched from the Frame when not given)."""
if room.mesh and mesh is None:
remote = f"{frame.home}/.local/share/frameport/rooms/{room.universe}/{room.mesh.filename}"
with frame.sftp.open(remote, "rb") as f:
mesh = f.read(64 * 1024 * 1024)
if room.mesh:
m = read_mesh(mesh)
if (m.vertex_count, m.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("The room changed on the Frame; refresh and edit again")
staging = user_data_dir() / "rooms"
staging.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="edit-", dir=staging) as local, _upload_dir(frame) as remote:
path = Path(local) / "room.txt"
path.write_text(dumps(room), encoding="utf-8")
frame.put(path, f"{remote}/room.txt", resume=False)
if room.mesh:
(Path(local) / room.mesh.filename).write_bytes(mesh)
frame.put(Path(local) / room.mesh.filename, f"{remote}/{room.mesh.filename}", resume=False)
return frame.agent("room_put", path=f"{remote}/room.txt", edited=True)
def show(frame, path: Path) -> dict:
from ..mr.room import summary
+136 -2
View File
@@ -61,6 +61,8 @@
"90 Hz": "",
"920 × 430": "",
"96 Hz": "",
"A closet?": "",
"A door?": "",
"A folder with Quest, Android or PC VR games.": "",
"A game is running": "",
"A game is running on the Frame": "",
@@ -86,6 +88,7 @@
"A small helper FramePort runs on the Frame for installs, launch tests and Steam entries. FramePort updates it by itself.": "",
"a source checkout (git pull + uv sync)": "",
"A SteamVR overlay app: SteamVR on the Frame draws it over whatever you play, Quest and PC VR games alike. With 'Start with SteamVR' on, SteamVR starts it by itself; otherwise start it from the library before the game.": "",
"A tall, thin box by a wall: the scanner can't tell a door from a closet.": "",
"A VR app made for another Android headset. OpenXR apps work like Quest games; apps built for Pico or HTC Wave can't run on the Frame.": "",
"A web page asked FramePort to install {name}.": "",
"A Windows game without VR. On the Frame, Proton runs it in a window from the Steam library.": "",
@@ -94,6 +97,7 @@
"A zip with FramePort's logs, the game's recipe and the Frame's details, without game files. Addresses, names and Steam IDs are replaced. Attach it to a GitHub issue.": "",
"About": "",
"About this game": "",
"Above the floor": "",
"AC Nexus: no AppSW at 72 Hz": "",
"AC Nexus: no AppSW at 90 Hz": "",
"Activity": "",
@@ -104,13 +108,19 @@
"Add a tag": "",
"Add a Windows program…": "",
"Add an APK…": "",
"Add as closet": "",
"Add as door": "",
"Add box": "",
"Add door": "",
"Add folder…": "",
"Add from a link…": "",
"Add games": "",
"Add them on a wall: games that ask for a door get it.": "",
"Add this tag": "",
"Add to Steam library: {title}": "",
"Add videos": "",
"Add videos and files": "",
"Add window": "",
"Add your games": "",
"Add {name}": "",
"Added automatically from the engine, VR API or store genre.": "",
@@ -124,6 +134,7 @@
],
"Added {title}": "",
"Added {title} — it starts {exe}. Wrong program? Change it on its page.": "",
"Added. Check it in the view, then Save.": "",
"Adding to the library": "",
"Adding {title} to the Frame's Steam library — Steam restarts once": "",
"Adds a launcher entry point (Lepton additionally needs category LAUNCHER, see the Frame 'launcher' fix).": "",
@@ -149,10 +160,12 @@
"Allows reading the game's logs, which helps find problems.": "",
"Allows the game's data to be backed up.": "",
"Allows very large pictures (8K video, theaters) instead of quitting.": "",
"Along the wall": "",
"Already patched: don't change the game's files": "",
"Already patched: install unchanged": "",
"Already set up?": "",
"Already up to date": "",
"Also clear near walls": "",
"Also delete its saves and everything it stored on the Frame (mods, downloaded content)": "",
"Also delete this game's files on this PC ({size})": "",
"Also in your library: {value} version": "",
@@ -186,6 +199,7 @@
"",
""
],
"Apply": "",
"Apply controller buttons to installed games": "",
"Apply to installed games now": "",
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.": "",
@@ -208,6 +222,8 @@
"Automatic ({auto:.0%})": "",
"Automatic SteamVR performance settings (this PC)": "",
"Back to library": "",
"Back to the scan's boxes": "",
"Back to the scan's boxes?": "",
"Back up all saves": "",
"Back up its saves to this PC before deleting them": "",
"Back up saves": "",
@@ -225,12 +241,17 @@
"Bed": "",
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (for example 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
"Behind a surface": "",
"Below the ceiling": "",
"Best guess": "",
"bHaptics' vest SDK asks for an Android 12 Bluetooth permission at start; on Lepton's Android 11 the permission screen takes the foreground for a moment and Meta's OVRPlugin stops drawing (for example Yupitergrad freezing on its loading screen). The request returns at once instead (an in-place edit of classes.dex).": "",
"bHaptics: skip the Bluetooth permission request": "",
"BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which never comes on the Frame, before they create the game's own controllers: the second menu ignored all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and every instruction checked); anything else is left as it is.": "",
"BlazeRush: controllers without Meta avatars, own shader cache": "",
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Bottom edge": "",
"Boxes removed": "",
"Boxes renamed": "",
"Browse this game's files.": "",
"Build page": "",
"Building the room…": "",
@@ -264,6 +285,7 @@
"Change…": "",
"Check for new versions automatically": "",
"Check for updates": "",
"Check furniture labels": "",
"Check game files": "",
"Check games from install links for new versions": "",
"Check install links for updates": "",
@@ -278,6 +300,7 @@
"Checking this PC…": "",
"Checking tools…": "",
"Checking which app opens these links…": "",
"Checks every box the scanner found against size and place rules (see Labels). Off keeps the scanner's own names.": "",
"checksum checked": "",
"Check…": "",
"Choose a backup to put back on the Frame. Close the game first.": "",
@@ -296,7 +319,13 @@
"Choose which Quest Touch buttons the Frame's face buttons send. Takes effect at the next launch.": "",
"Choose…": "",
"Chosen by you.": "",
"Clean up a scan to see what the rules decided.": "",
"Clean up Frida leftovers in smali": "",
"Clean up the room scan": "",
"Clean up the scan": "",
"Clean up your saved scan again with these settings. They are also used for your next scans.": "",
"Clean-up": "",
"Cleaning up the scan on the Frame…": "",
"Cleans up graphics data that crashes some Unreal games on the Frame.": "",
"Clear": "",
"Clear android:debuggable": "",
@@ -305,6 +334,7 @@
"Clear selection": "",
"Clear tags": "",
"Clear the game's GL errors (GLES)": "",
"Cleared near walls": "",
"Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code fails on any pending error; after one such failure at the start it allocated GPU memory every frame without freeing it (e.g. Vader Immortal, ~20 MB a second).": "",
"Click here, then type. Every key goes to the Frame.": "",
"Click to open · right-click for quick actions": "",
@@ -438,6 +468,7 @@
"Demo": "",
"demo: nothing is downloaded": "",
"Deny": "",
"Depth": "",
"Deselect": "",
"Desktop": "",
"Desktop Mode": "",
@@ -454,6 +485,7 @@
"Disable application space warp if used": "",
"Disable controller tracking offset": "",
"Disable Meta XR Audio telemetry": "",
"Discard changes": "",
"Disconnect": "",
"Dismiss": "",
"Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). Applied through SteamVR before the game starts. A video's frame rate that divides the refresh rate plays smoothest (for example 30 fps at 90 Hz, 24 fps at 72 Hz).": "",
@@ -464,6 +496,9 @@
"Done — start the game again to see whether it helped.": "",
"Done. It's used the next time you start {title}.": "",
"Door": "",
"Door or closet suggestions": "",
"Doors & windows": "",
"Doors and windows": "",
"Download all ({n})…": "",
"Download all…": "",
"Download and install": "",
@@ -493,7 +528,7 @@
"Downloading…": "",
"Downloads": "",
"Download…": "",
"Drag to turn the room, scroll or pinch to zoom, right-drag to move it, double-click to see all of it again. The walls nearest to you are left out so you can look inside. The full 3D view in your browser shows every detail of the scan.": "",
"Drag to turn the room, scroll or pinch to zoom, right-drag to move it, double-click to see all of it again. The walls nearest to you are left out so you can look inside. The full 3D view in your browser shows every detail of the scan. Click a box or a door to edit it.": "",
"Drag {name} into the GitHub issue that opened, then submit it.": "",
"Draw each eye separately (OpenGL ES)": "",
"Draw fades over the picture": "",
@@ -504,6 +539,7 @@
"Drop layer color fades": "",
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
"Drop to upload": "",
"Drop your own boxes, doors and windows and use the boxes the scan found.": "",
"Drops Quest-only shared library requirements.": "",
"Each game start used to use up one kernel key; after about 200 starts no game started. FramePort stops that, but used keys only come back after a restart of the Frame.": "",
"Enabled by you.": "",
@@ -524,8 +560,10 @@
"Environment variables for the Proton launcher on the Frame (for example DXVK_HUD=fps), one KEY=value per line.": "",
"Epic Online Services (EOS) keeps its login keychain in the app's settings storage. When it can't write there, it resets the keychain and tries again without end (for example Beyond Sandbox: a start that never finishes, 100 % CPU, a log of gigabytes). The reset method returns at once instead (an in-place edit of classes.dex), so EOS reports the failure to the game once.": "",
"Epic Online Services: stop the keychain retry loop": "",
"Erased": "",
"Español": "",
"Español (Latinoamérica)": "",
"Evens out bumps in the scan without shrinking shapes (Taubin smoothing). More passes, smoother.": "",
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and the runtime clock's offset from the monotonic clock. No effect on the game.": "",
"Every detail of the scan, in your browser.": "",
@@ -547,14 +585,18 @@
"Experimental: shows HUDs, menus and other flat panels that stay black on the Frame (for example Doom3Quest's HUD and PDA).": "",
"Extra diagnostics (log)": "",
"Extra Lepton settings": "",
"Extra past unseen edges": "",
"Extra Proton settings (Frame)": "",
"Extra room past edges the scanner closed without seeing a wall: they may be open space.": "",
"Extra settings for Proton (for testing).": "",
"Extra settings for the Android container (for testing).": "",
"Eye-image snapshots (diagnostics)": "",
"Eye-tracked": "",
"Facing within": "",
"failed": "",
"Failed": "",
"Fan": "",
"Fewer settings": "",
"File": "",
"file": "",
"Files": "",
@@ -581,7 +623,13 @@
"Fixed (doesn't follow the eyes)": "",
"Flat": "",
"Flat panel diagnostics (log)": "",
"Flat panel triangles": "",
"Flat panels": "",
"Flat panels only: other scan this close to a wall, floor or ceiling is removed. Furniture boxes are kept.": "",
"Flat window": "",
"Flatten furniture tops": "",
"Flattened onto furniture tops": "",
"Floating pieces and furniture": "",
"focus_hold hides focus dips up to this long (100-5000). Longer for a headset whose wear sensor flickers (\"HMD off\" for 0.5-2 s while worn, for example Blade & Sorcery pausing); taking the headset off or the system menu pauses the game only after this long.": "",
"Folder": "",
"folder": "",
@@ -691,10 +739,16 @@
"Freed {value:.1f} GiB on the Frame": "",
"From the tested recipe for this game": "",
"From the {src}": "",
"Front / back": "",
"Full (headset view size)": "",
"Full launch log": "",
"Full-resolution area": "",
"Furniture": "",
"Furniture & screens": "",
"Furniture boxes": "",
"Furniture boxes in the room": "",
"Furniture games see. Pick a box here or click it in the view, then fix its type, place or size. Your boxes stay when you clean up the scan again.": "",
"Furniture rarely reaches this close to the ceiling: here the whole band counts as wall.": "",
"Game": "",
"Game data (.obb) is missing, so these games hang at start. Put the .obb files in a folder named after the game next to the APK, then add it again.": "",
"Game data (.obb) is missing. Put it in a folder named {package} next to the APK and add the folder again.": "",
@@ -769,6 +823,7 @@
"Heads-up: {name} looks like a different app than {title}.": "",
"headset found": "",
"Headset model": "",
"Height": "",
"Hi there!": "",
"Hide Android's navigation bar": "",
"Hide details": "",
@@ -807,6 +862,7 @@
"Image for the {label}": "",
"Imported room": "",
"in an mpv window": "",
"In front of a wall": "",
"In System, turn on Enable Developer Mode. Nothing else changes.": "",
"In the app menu": "",
"In the headset, select a text field, then type here.": "",
@@ -885,10 +941,14 @@
"Java runtime": "",
"Java, OVRPort and apksigner are ready.": "",
"just now": "",
"keep all": "",
"Keep converted copies on this PC (only needed to inspect a build)": "",
"Keep game saves on the Frame": "",
"Keep menus in place": "",
"Keep past walls": "",
"Keep pictures in bounds": "",
"Keep scan": "",
"Keep scan leaves walls as scanned. Snap moves scan near a wall onto it. Flat panels replace the scanned walls, floor and ceiling with flat surfaces.": "",
"Keep the game's saved resolution in step": "",
"Keep the same Meta user between starts": "",
"Keep the signing keys: without them, updating a game loses its saves.": "",
@@ -903,10 +963,14 @@
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).": "",
"Keeps three snapshots per game on the Frame and copies new ones here when connected.": "",
"Keeps your progress in games that file their saves under your Meta user.": "",
"Kept": "",
"kept": "",
"Keyboard connected to {label}.": "",
"Keyboard disconnected: {error}": "",
"KiB": "",
"kshoji's USB MIDI driver (Unity MIDI plugins, for example PianoVision) polls Android's USB service for MIDI devices; the Frame's Android has no USB service, so the app crashes a few seconds after start (NullPointerException on MidiDeviceConnectionWatchThread). The poll returns at once instead, so no USB MIDI device is ever found (an in-place edit of classes.dex).": "",
"Label rules": "",
"Labels": "",
"Lamp": "",
"Language": "",
"Language packs from the game's files": "",
@@ -914,6 +978,7 @@
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
"Larger areas retain more detail but save less GPU work.": "",
"Last build": "",
"Last clean-up": "",
"Last launch test's log": "",
"Last launch test: passed": "",
"Last launch test: {get} · got as far as: {value}": "",
@@ -931,6 +996,7 @@
"Launch the game through Revive's injector, which translates the Oculus PC SDK (LibOVR) to OpenXR. Needed by every Oculus Rift game that isn't OpenXR-native. FramePort downloads Revive itself.": "",
"Launcher category (INFO → LAUNCHER)": "",
"launcher started": "",
"Left / right": "",
"Left out of the last build, OVRPort now does it itself: {names}": "",
"Left out of the last build: OVRPort now does this itself.": "",
"Lepton is installed": "",
@@ -984,6 +1050,7 @@
"Live": "",
"Live view": "",
"Loading the room in 3D…": "",
"Loading the room…": "",
"Loading…": "",
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
"Loads FramePort's shader-fix Vulkan layer into an OpenGL ES game. On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan); the layer sits between Zink and the driver and inserts the words of the adapter setting zink_shader_fix into the SPIR-V modules whose size and SHA-256 match (every other module is untouched), for example stores that set loop counters and accumulators a shader reads before writing, which hang the GPU (Vader Immortal's lightspeed jump; found by Klownicle, GitHub #49). Does nothing without zink_shader_fix (or zink_shader_dump) in the recipe, and either setting brings it in by itself. The layer adds itself to Android's Vulkan debug layer list for the game's process only.": "",
@@ -1001,6 +1068,7 @@
"Logs graphics pipelines the Frame's driver refuses. No effect on the game.": "",
"Logs how game poses line up with the Frame's clock. No effect on the game.": "",
"Logs Vulkan mistakes the game makes. Needs a special test build.": "",
"Long, thin pieces hanging in the air (depth streaks), at least this many times longer than wide, are removed. Never furniture.": "",
"Longer for headsets that briefly think they're off your head.": "",
"Look for the cable": "",
"Looking at the app": "",
@@ -1011,6 +1079,7 @@
"Looking for the Frame on the USB cable…": "",
"Looking for your Frame…": "",
"Looking up the store description and screenshots…": "",
"Loose bits of scan smaller than this area are removed.": "",
"Low": "",
"Made in another play area: scan again or use your play area.": "",
"Make Unity text fields work": "",
@@ -1052,6 +1121,7 @@
"Monitor is using {pct}% of the Frame's CPU": "",
"More actions": "",
"More actions…": "",
"More settings": "",
"Move": "",
"Move to…": "",
"Move {title} to {label}": "",
@@ -1071,6 +1141,7 @@
"Network": "",
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
"never": "",
"Never remove scan inside furniture": "",
"New folder": "",
"New folder…": "",
"New games go to {label}": "",
@@ -1087,6 +1158,7 @@
"No activity": "",
"no answer from the Frame": "",
"No backups of {title} on this PC yet. Back up its saves first (game menu → Back up saves).": "",
"No changes to save": "",
"no checksum": "",
"No dev build is published right now": "",
"No file at that path": "",
@@ -1127,6 +1199,7 @@
"Not now": "",
"Not offered by Steam on this Frame yet": "",
"Not recommended: breaks games that load VrApi through OVRPlugin.": "",
"Not saved yet": "",
"Not set up": "",
"Not set up yet. FramePort sets it up when it starts.": "",
"Not streaming": "",
@@ -1156,6 +1229,8 @@
"Oculus OS telemetry class stubs": "",
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or gray (for example I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
"Off": "",
"off": "",
"Off keeps the scanner's mesh exactly as recorded; the room's walls and boxes stay.": "",
"Off — launch from FramePort": "",
"Off: these links open whatever app is set up for them.": "",
"off: {names}": "",
@@ -1165,6 +1240,7 @@
"Older way to turn upside-down panels; prefer Fix upside-down menus.": "",
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
"Older xLua/LuaJIT engines reject native pointers outside their 47-bit range, causing a startup error and sometimes a second crash during Unity shutdown (e.g. BattleTalent). Uses a Lua engine that preserves full pointer values while keeping the game's original xLua bindings and native helpers. Applied only to the verified compatible library build.": "",
"on": "",
"ON AIR": "",
"On Frame": "",
"On the desktop": "",
@@ -1180,11 +1256,14 @@
"On your Frame": "",
"on: {names}": "",
"One PC game folder": "",
"Only a scanned room can be cleaned up.": "",
"Only FramePort's own key, and your SSH agent's keys.": "",
"Only if 360° pictures show wrongly.": "",
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
"Only internal storage": "",
"Only thin floating pieces up to this thickness are removed.": "",
"Only this thin layer on the room side counts as wall, so wardrobes and shelves against it stay.": "",
"Open": "",
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
"Open backups folder": "",
@@ -1218,6 +1297,7 @@
"Orange for actions, blue for the PC side: the logo's two portals": "",
"Other": "",
"other file": "",
"Outside the room": "",
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
"OVRPort converts Quest games to standard OpenXR. These are its optional patches.": "",
@@ -1264,6 +1344,7 @@
"Perfect pacing": "",
"Photon Voice's microphone plugin (for example Table Troopers) picks the audio device with Android 12 methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the game crashes with a NoSuchMethodError when voice chat starts. That method returns at once instead (an in-place edit of classes.dex); the microphone keeps Android's default audio route.": "",
"Photon Voice: skip Android 12 audio routing": "",
"Pick a box here or click it in the view, then fix its type, place or size.": "",
"Pick several games and install them in one go. FramePort asks every question first, then works through them in the background.": "",
"Pick the field in the headset": "",
"Pick the program you'd double-click to play. Oculus builds usually work better than Steam builds.": "",
@@ -1364,6 +1445,7 @@
"Recipe reset": "",
"Recipe reset — update the game on the Frame to use it": "",
"Recipes tested on a real Frame: built in, fetched online and the ones you saved as known-good.": "",
"Recommended": "",
"Reconnect": "",
"Recreate spaces after the first frames (diagnostic).": "",
"Reduce motion": "",
@@ -1386,13 +1468,16 @@
"Remove folder": "",
"Remove from library…": "",
"Remove localized app names": "",
"Remove pieces smaller than": "",
"Remove tag": "",
"Remove thin floating pieces": "",
"Remove this theme": "",
"Remove Unreal's ForceQuit": "",
"Remove uses-library": "",
"Remove VrApi library": "",
"Remove XR_KHR_composition_layer_depth chains (diagnostic).": "",
"Remove {title} from the library?": "",
"removed": "",
"Removed from Desktop Mode": "",
"Removed from the desktop": "",
"Removed the converted copies ({size})": "",
@@ -1478,6 +1563,8 @@
"Room floor plan": "",
"Room height": "",
"Room positions belong to the play area where they were recorded. If SteamVR changes it, scan again or use the new boundary to line up virtual walls with your room.": "",
"Room saved": "",
"Room scan cleaned up": "",
"Room scan saved": "",
"Room scanned": "",
"Room setup": "",
@@ -1502,6 +1589,7 @@
"Save": "",
"Save as known-good recipe": "",
"Save backups to keep per game": "",
"Save room edits": "",
"Save the screenshots to which folder on this PC?": "",
"Saved as a known-good recipe": "",
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
@@ -1516,11 +1604,21 @@
"Saves the game's shaders to capture one for a fix. No effect on the game.": "",
"Saves the game's shaders to find one that hangs the GPU. No effect on the game.": "",
"Saves what the game draws every few seconds, for testing without the headset.": "",
"Saving your room on the Frame…": "",
"Saving…": "",
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
"Scan": "",
"Scan a folder…": "",
"Scan canceled. Your saved room hasn't changed.": "",
"Scan further than this behind a seen wall is removed. At the maximum nothing is removed.": "",
"Scan inside a furniture box is never removed by the other steps.": "",
"Scan near the top of a table, bed or other box, facing up, goes onto the box's top.": "",
"Scan only counts as a wall when it faces the room within this angle of the wall's direction.": "",
"Scan room in the headset": "",
"Scan this close above the floor counts as floor.": "",
"Scan this close below the ceiling counts as ceiling.": "",
"Scan this far behind a wall, under the floor or above the ceiling counts as that surface.": "",
"Scan triangles": "",
"Scan when requested": "",
"Scan your room and look at it in 3D.": "",
"Scan your room for mixed reality": "",
@@ -1545,6 +1643,7 @@
"Searched a moment ago.": "",
"Searching…": "",
"See every patch and turn them on or off.": "",
"See what a game gets:": "",
"See what the headset sees": "",
"See what's new…": "",
"See your room": "",
@@ -1553,6 +1652,7 @@
"Select all": "",
"Select all shown": "",
"Select games to install": "",
"Send your boxes, doors and windows to the Frame. Games get them at their next start.": "",
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).": "",
"Set by you.": "",
"Set up a play area in SteamVR first. Valve's default circle does not describe your room.": "",
@@ -1618,7 +1718,10 @@
"Skips picture layers the Frame would refuse. Leave it on.": "",
"Sleep": "",
"Small (saves most)": "",
"Small pieces": "",
"Smooth as butter": "",
"Smoothing": "",
"Snap": "",
"software encoder": "",
"Some apps open a flat Android launcher that then starts a separate VR activity (for example WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
"Some apps run their game activity in a process of its own (android:process, e.g. Halo CE VR's \":halo_game\"), started from a 2D launcher activity. Lepton starts the game activity directly and watches only the app's main process, which then never exists: about a minute in, Lepton decides the app \"did not start\" and closes it, at whatever point the game has reached. This runs those activities in the main process (only their android:process value changes).": "",
@@ -1764,6 +1867,7 @@
"That's me!": "",
"The activity of Unity's App UI package (for example PianoVision) gets the vibrator through Android 12's VibratorManager without checking the Android version; on Lepton's Android 11 the game quits at start with a NoClassDefFoundError. The activity then starts without a vibrator and its UI haptic feedback does nothing (an in-place edit of classes.dex).": "",
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
"The cleaned-up scan had more triangles than a room can hold: {n} were left out.": "",
"The connection to the Frame is busy. Try again in a moment.": "",
"The download didn't finish. Check your internet connection.": "",
"the downloaded app": "",
@@ -1788,6 +1892,7 @@
"The Frame only offers its USB connection in Developer Mode:": "",
"The Frame refused that password. Check it, or use Steam Frame → Start setup instead.": "",
"The Frame restarts in a few seconds. FramePort reconnects when it's back.": "",
"The Frame tidies up every scan: flat walls, floor and ceiling, no scan behind walls or where you walked, no small or floating bits. Apply runs it again on your saved scan with these settings.": "",
"The Frame turns off in a few seconds. Turn it on again with its power button.": "",
"The Frame went offline: {exc}": "",
"The Frame's battery is at {pct}% and not charging. Plug it into a strong charger — installs pause at {pause}%.": "",
@@ -1823,6 +1928,8 @@
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games (Gear VR, Oculus Go) run through FramePort's 32-bit bridge (experimental).": "",
"The room is still loading.": "",
"The room mixed-reality games see: walls, furniture and the scanned surfaces": "",
"The scanner can't see doors or windows. Add them on a wall: games that ask for a door get it.": "",
"The scanner names each box it finds. These rules check the names against the box's size and place: boxes behind walls go, desks stop being couches, a screen has to look like one.": "",
"The scanner turns a recorded depth-mesh dump into a room. FramePort installs it when you build a recorded scan.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
@@ -1843,6 +1950,7 @@
"They run on the Frame too.": "",
"They're deleted on the Frame for good. Steam's own list may show them until Steam restarts.": "",
"They're part of SteamOS. Ending them may restart Steam and stop the running game.": "",
"Thin floating pieces": "",
"This applies pending library changes and closes Desktop Mode and FramePort on the Frame.": "",
"This deletes the {what} on the Frame. It can't be undone.": "",
"This folder contains an installer without a launchable Linux program. FramePort runs programs, not installers. Use the project's own install instructions.": "",
@@ -1872,19 +1980,22 @@
"Today": "",
"Tools": "",
"Tools are up to date": "",
"Top height": "",
"Top of walls": "",
"Trace Meta platform requests (diagnostics)": "",
"transparent PNG": "",
"Treat newer Quest controllers as Touch": "",
"Triangles in the room": "",
"Tried it in the headset? Share how it runs, and your recipe can become the built-in one for everyone.": "",
"Troubleshoot": "",
"Troubleshooting": "",
"Trust the new key": "",
"Try again": "",
"Try it out: Test Quest MR shows the room, furniture and anchors a game receives from this scan.": "",
"Try now": "",
"Try Refresh. The Frame needs to be on and connected.": "",
"Try the modes first: Test Quest Controllers shows which Quest button each Frame button sends.": "",
"Try this setting": "",
"Turn": "",
"Turn it down if controller vibrations are too strong.": "",
"Turn off only for games you play with your hands instead of controllers.": "",
"Turn off Quest Vulkan foveation": "",
@@ -2019,6 +2130,7 @@
"Use the default": "",
"Use the new recipe": "",
"Use the repack's launcher (bundled Revive)": "",
"Use the scan's boxes": "",
"Use the setup command": "",
"Use this PC's keyboard on the Frame": "",
"Use this PC's keyboard on the Frame.": "",
@@ -2076,13 +2188,19 @@
"Waiting ({n})": "",
"Waiting for the cable…": "",
"Waiting for the Frame": "",
"Waiting for the Frame…": "",
"Waiting for your Frame": "",
"Waiting for your Frame to ask…": "",
"Waiting for your Frame… (code {code})": "",
"waiting {pct}%": "",
"Wall art": "",
"Wall {n} ({length} m)": "",
"Wall {n} ({length} m, unseen)": "",
"wall {n} · {w}×{h} m": "",
"Wall, floor and ceiling surfaces": "",
"Walls": "",
"Walls from your play area": "",
"Walls, floor and ceiling": "",
"Watch what the headset shows, in a window on this PC": "",
"Web browser": "",
"Welcome to FramePort": "",
@@ -2100,11 +2218,14 @@
"Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.": "",
"Where the power goes": "",
"Where the recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
"Where you walked": "",
"Where you walked: skipped, this scan has no head path": "",
"Which program starts {get}?": "",
"Which Proton build runs the game on the Frame: the stable Proton (the default: smoother in our tests) or Proton Experimental (gets Valve's ARM64 fixes first; try it when a game doesn't start or runs badly). Also takes any compat tool name from the Frame's ARM64 list.": "",
"Which Proton runs the game on the Frame. Stable is smoother; try Experimental if the game doesn't start or runs badly.": "",
"Wide cover": "",
"wide, 1920 × 620": "",
"Width": "",
"Window": "",
"Windows": "",
"Windows game (Proton)": "",
@@ -2142,6 +2263,7 @@
"You have {version}. {how}": "",
"You only do this once. FramePort connects by itself when the setup is done.": "",
"You pasted a link to {name}.": "",
"You walked through this space while scanning, so nothing stands there: scan this far below your head's path is your own body or controllers. Furniture boxes are kept.": "",
"Your changes to its patches and game settings are replaced by the suggested recipe.": "",
"Your copy is installed unchanged (the Frame's copy gets launch patches)": "",
"Your custom recipe": "",
@@ -2152,9 +2274,12 @@
"Your Frame restarted while {title} was running. If the game caused it, please report it.": "",
"Your Frame's files, from this PC": "",
"Your own artwork for {title}": "",
"Your own boxes and doors stay.": "",
"Your own furniture boxes, doors and windows are removed and the scan is cleaned up again.": "",
"Your own game files aren't touched.": "",
"Your room in 3D": "",
"Your saves on the Frame and each game's signing key are kept.": "",
"Your settings": "",
"{app} opens these links on this PC.": "",
"{count} files · {human}": "",
"{count} on": "",
@@ -2193,6 +2318,7 @@
"{name} is still running.": "",
"{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)": "",
"{name}: waiting for your answer": "",
"{n} cm": "",
"{n} file": [
"",
""
@@ -2254,7 +2380,9 @@
""
],
"{n} lines, the last 4000 shown": "",
"{n} m": "",
"{n} mesh triangles": "",
"{n} m²": "",
"{n} needs a newer FramePort": [
"",
""
@@ -2287,6 +2415,8 @@
"",
""
],
"{n}°": "",
"{n}×": "",
"{old} → {new} · from {host}": "",
"{on} on your Frame": "",
"{rate}/s": "",
@@ -2329,6 +2459,7 @@
"{value} took too long — update by hand: {join}": "",
"{walls} walls · {area:.1f} m² · {height:.1f} m high": "",
"{walls} walls · {area:.1f} m² · {height:.1f} m high · {furniture} pieces of furniture": "",
"{w}×{d}×{h} m": "",
"Čeština": "",
"Русский": "",
"Українська": "",
@@ -2341,6 +2472,9 @@
""
],
"… {node} more (use the filter to find them)": "",
"…no thicker than": "",
"→ closet?": "",
"→ door?": "",
"中文(简体)": "",
"中文(繁體)": "",
"日本語": "",
+107
View File
@@ -278,6 +278,113 @@ def summary(room: Room) -> dict:
"ceiling_height": room.ceiling - room.floor}
# ------------------------------------------------------------------------------------------ editing helpers
VOLUME_LABELS = ("TABLE", "COUCH", "BED", "STORAGE", "SCREEN", "LAMP", "PLANT", "OTHER")
PLANE_LABELS = ("DOOR_FRAME", "WINDOW_FRAME", "WALL_ART")
PLANE_INSET = 0.01 # doors/windows sit this far in front of their wall (into the room)
def new_id() -> str:
return _id()
def nearest_wall(room: Room, x: float, z: float) -> dict | None:
"""The wall closest to (x, z): index i, distance d, t (0..1 along it), length L, direction (ux, uz) and the
closest point (px, pz)."""
best = None
for i, w in enumerate(room.walls):
abx, abz = w.x1 - w.x0, w.z1 - w.z0
length = math.hypot(abx, abz)
if length < 1e-9:
continue
t = max(0.0, min(1.0, ((x - w.x0) * abx + (z - w.z0) * abz) / (length * length)))
px, pz = w.x0 + t * abx, w.z0 + t * abz
d = math.hypot(x - px, z - pz)
if best is None or d < best["d"]:
best = dict(i=i, d=d, t=t, L=length, ux=abx / length, uz=abz / length, px=px, pz=pz)
return best
def plane_on_wall(room: Room, wall: int, along: float, bottom: float, w: float, h: float,
label: str = "DOOR_FRAME", uuid: str | None = None) -> Plane:
"""A door / window / wall-art plane on a wall (the cleanup page's planeGeom): `along` = 0..1 of the wall's length
for its middle (kept fully on the wall), `bottom` = metres above the floor. Meta's convention: bounds in local XY,
local +Z = the normal into the room."""
wl = room.walls[wall]
dx, dz = wl.x1 - wl.x0, wl.z1 - wl.z0
length = math.hypot(dx, dz)
ux, uz = dx / length, dz / length
nx, nz = -uz, ux
a = max(min(w / 2, length / 2), min(length - w / 2, along * length))
th = math.atan2(-uz, ux) # about +Y: +X becomes the wall's direction, +Z its inward normal
return Plane(uuid or _id(), label, wl.x0 + ux * a + nx * PLANE_INSET, room.floor + bottom + h / 2,
wl.z0 + uz * a + nz * PLANE_INSET, 0.0, math.sin(th / 2), 0.0, math.cos(th / 2), w, h)
def plane_place(room: Room, plane: Plane) -> dict:
"""Where a plane sits for the editor: wall (index of the nearest wall), along (0..1), bottom (m above the floor)."""
near = nearest_wall(room, plane.px, plane.pz)
if near is None:
return dict(wall=0, along=0.5, bottom=max(0.0, plane.py - plane.h / 2 - room.floor))
return dict(wall=near["i"], along=near["t"], bottom=max(0.0, plane.py - plane.h / 2 - room.floor))
def plane_corners(plane: Plane) -> list[tuple[float, float, float]]:
"""A plane's four corners (bottom left, bottom right, top right, top left, seen from the room)."""
th = 2 * math.atan2(plane.qy, plane.qw) # (planes are upright: a turn about +Y)
ux, uz = math.cos(th), -math.sin(th)
hw, hh = plane.w / 2, plane.h / 2
x, y, z = plane.px, plane.py, plane.pz
return [(x - ux * hw, y - hh, z - uz * hw), (x + ux * hw, y - hh, z + uz * hw),
(x + ux * hw, y + hh, z + uz * hw), (x - ux * hw, y + hh, z - uz * hw)]
def default_plane(room: Room, label: str) -> Plane:
"""A new door (0.9 x 2.05 m from the floor) or window (1.2 x 1.2 m, 0.9 m up) in the middle of the first wall
the scan saw."""
wall = next((i for i, w in enumerate(room.walls) if w.label != "INVISIBLE_WALL_FACE"), 0)
door = label == "DOOR_FRAME"
return plane_on_wall(room, wall, 0.5, 0 if door else 0.9, 0.9 if door else 1.2, 2.05 if door else 1.2, label)
def default_volume(room: Room) -> Volume:
"""A new box: a 1.2 x 0.6 m table in the middle of the room."""
xs = [x for x, _ in room.outline]
zs = [z for _, z in room.outline]
return Volume(_id(), "TABLE", (min(xs) + max(xs)) / 2, (min(zs) + max(zs)) / 2, room.floor + 0.75, 0.0,
1.2, 0.6, 0.75)
def accept_suggestion(room: Room, suggestion: dict, kind: str) -> Volume | Plane:
"""A scanner suggestion (a tall thin slab by a wall) as a door on that wall or as a closet against it (the
cleanup page's acceptSuggestion). Added to the room and returned."""
box = suggestion.get("box") or {}
cx, cz = float(box.get("cx", 0)), float(box.get("cz", 0))
w, d = float(box.get("w", 0.9)), float(box.get("d", 0.1))
h = float(box.get("h", 2.0))
top = float(box.get("top_y", room.floor + h))
width = max(w, d)
near = nearest_wall(room, cx, cz)
wall, along = (near["i"], near["t"]) if near else (0, 0.5)
if isinstance(suggestion.get("wall"), int) and 0 <= suggestion["wall"] < len(room.walls):
wall = suggestion["wall"]
along = float(suggestion.get("along", along))
if kind == "door":
plane = plane_on_wall(room, wall, along, 0.0, min(max(width, 0.8), 1.2), min(max(h, 1.95), 2.2), "DOOR_FRAME")
room.planes.append(plane)
return plane
wl = room.walls[wall]
length = math.hypot(wl.x1 - wl.x0, wl.z1 - wl.z0)
ux, uz = (wl.x1 - wl.x0) / length, (wl.z1 - wl.z0) / length
px, pz = wl.x0 + ux * along * length, wl.z0 + uz * along * length
nx, nz, depth = -uz, ux, 0.6 # against the wall, inside the room
height = min(top - room.floor, 2.4)
vol = Volume(_id(), "STORAGE", px + nx * depth / 2, pz + nz * depth / 2, room.floor + height,
math.degrees(math.atan2(-uz, ux)), max(width, 0.6), depth, height)
room.volumes.append(vol)
return vol
def preview_svg(room: Room) -> str:
"""Self-contained top-down preview; text labels are escaped, never interpreted as markup."""
points = room.outline + room.play_area
+85 -15
View File
@@ -9,7 +9,7 @@ right and down. Walls and mesh triangles face into the room, so the walls neares
from __future__ import annotations
import math
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from .room import Mesh, Room
@@ -33,6 +33,7 @@ class Face:
normal: tuple[float, float, float] # facing direction; the face is drawn when the camera is on this side
kind: str = "wall" # wall | hidden_wall | box
label: str = ""
index: int = -1 # a box side: the volume's index in the room
@dataclass
@@ -56,6 +57,8 @@ class Scene:
lo: tuple[float, float, float] = (-1.0, 0.0, -1.0)
hi: tuple[float, float, float] = (1.0, 2.5, 1.0)
triangles: int = 0 # of the full mesh
planes: list[list[tuple[float, float, float]]] = field(default_factory=list) # doors / windows (corners)
selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = field(default_factory=list)
# ------------------------------------------------------------------------------------------------ vector helpers
@@ -183,19 +186,7 @@ def prepare(room: Room, mesh: Mesh | None = None, budget: int = REST_BUDGET,
sc.faces.append(Face([(w.x0, w.y0, w.z0), (w.x1, w.y0, w.z1), (w.x1, w.y1, w.z1), (w.x0, w.y1, w.z0)], n,
"hidden_wall" if w.label == "INVISIBLE_WALL_FACE" else "wall", w.label))
sc.floor = [(x, y0, z) for x, z in room.outline]
for v in room.volumes:
foot = box_corners(v.cx, v.cz, v.yaw_deg, v.w, v.d)
top, bottom = v.top_y, v.top_y - v.h
lo_pts = [(x, bottom, z) for x, z in foot]
hi_pts = [(x, top, z) for x, z in foot]
center = (v.cx, (top + bottom) / 2, v.cz)
sides = [[lo_pts[i], lo_pts[(i + 1) % 4], hi_pts[(i + 1) % 4], hi_pts[i]] for i in range(4)]
for quad in sides + [hi_pts, lo_pts]:
mid = tuple(sum(p[k] for p in quad) / 4 for k in range(3))
sc.faces.append(Face(quad, _norm(_sub(mid, center)), "box", v.label)) # outwards
for i in range(4):
j = (i + 1) % 4
sc.box_edges += [(lo_pts[i], lo_pts[j]), (hi_pts[i], hi_pts[j]), (lo_pts[i], hi_pts[i])]
_add_furniture(sc, room)
xs = [x for x, _ in room.outline]
zs = [z for _, z in room.outline]
lo, hi = [min(xs), y0, min(zs)], [max(xs), y1, max(zs)]
@@ -216,6 +207,75 @@ def prepare(room: Room, mesh: Mesh | None = None, budget: int = REST_BUDGET,
return sc
def _box_quads(v):
foot = box_corners(v.cx, v.cz, v.yaw_deg, v.w, v.d)
top, bottom = v.top_y, v.top_y - v.h
lo_pts = [(x, bottom, z) for x, z in foot]
hi_pts = [(x, top, z) for x, z in foot]
sides = [[lo_pts[i], lo_pts[(i + 1) % 4], hi_pts[(i + 1) % 4], hi_pts[i]] for i in range(4)]
edges = []
for i in range(4):
j = (i + 1) % 4
edges += [(lo_pts[i], lo_pts[j]), (hi_pts[i], hi_pts[j]), (lo_pts[i], hi_pts[i])]
return sides + [hi_pts, lo_pts], edges, (v.cx, (top + bottom) / 2, v.cz)
def _add_furniture(sc: Scene, room: Room, selected: tuple[str, int] | None = None) -> None:
from .room import plane_corners
for n, v in enumerate(room.volumes):
quads, edges, center = _box_quads(v)
for quad in quads:
mid = tuple(sum(p[k] for p in quad) / 4 for k in range(3))
sc.faces.append(Face(quad, _norm(_sub(mid, center)), "box", v.label, n)) # outwards
sc.box_edges += edges
if selected == ("box", n):
sc.selected = list(edges)
sc.planes = [plane_corners(p) for p in room.planes]
if selected and selected[0] == "plane" and 0 <= selected[1] < len(sc.planes):
c = sc.planes[selected[1]]
sc.selected = [(c[i], c[(i + 1) % 4]) for i in range(4)]
def with_furniture(scene: Scene, room: Room, selected: tuple[str, int] | None = None) -> Scene:
"""A copy of a prepared scene with the room's furniture boxes and doors / windows drawn anew (the box editor:
cheap, the walls and the reduced mesh are shared); selected = ("box", i) / ("plane", i) is outlined."""
sc = replace(scene, faces=[f for f in scene.faces if f.kind != "box"], box_edges=[], planes=[], selected=[])
_add_furniture(sc, room, selected)
return sc
def _inside(poly, x, y) -> bool:
hit = False
for i, (ax, ay) in enumerate(poly):
bx, by = poly[i - 1]
if (ay > y) != (by > y) and x < ax + (bx - ax) * (y - ay) / (by - ay):
hit = not hit
return hit
def pick(scene: Scene, cam: Camera, x: float, y: float) -> tuple[str, int] | None:
"""What's under a click at screen (x, y): ("box", volume index) / ("plane", plane index), the nearest to the
camera, or None."""
pr = cam.frame()
best = None
for face in scene.faces:
if face.kind != "box":
continue
poly = pr.polygon(face.points)
if poly and _inside(poly, x, y):
d = pr.depth(face.points)
if best is None or d < best[0]:
best = (d, ("box", face.index))
for n, corners in enumerate(scene.planes):
poly = pr.polygon(corners)
if poly and _inside(poly, x, y):
d = pr.depth(corners) - 0.05 # a door in front of the box it's on
if best is None or d < best[0]:
best = (d, ("plane", n))
return best[1] if best else None
# ------------------------------------------------------------------------------------------------ camera
@dataclass
class Camera:
@@ -355,10 +415,12 @@ class Frame2D:
mesh: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
grid: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
marker: list[tuple[float, float]] | None = None # where the play area's centre is, pointing forward (-Z)
planes: list[list[tuple[float, float]]] = field(default_factory=list) # doors / windows
selected: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
@property
def segments(self) -> int:
return len(self.wall_lines) + len(self.box_lines) + len(self.mesh) + len(self.grid)
return len(self.wall_lines) + len(self.box_lines) + len(self.mesh) + len(self.grid) + len(self.selected)
def render(scene: Scene, cam: Camera, lod: str = "rest") -> Frame2D:
@@ -414,6 +476,14 @@ def render(scene: Scene, cam: Camera, lod: str = "rest") -> Frame2D:
if face_on[t]:
out.mesh.append((pu, pv))
break
for corners in scene.planes:
poly = pr.polygon(corners)
if poly:
out.planes.append(poly)
for a, b in scene.selected:
s = pr.segment(a, b)
if s:
out.selected.append(s)
y = scene.floor[0][1] if scene.floor else 0.0
tri = [(0.0, y, -0.35), (0.2, y, 0.15), (-0.2, y, 0.15)]
out.marker = pr.polygon(tri)
+29 -16
View File
@@ -623,26 +623,18 @@ def reveal(box: ft.Container, on: bool) -> bool:
return True
def segmented(options: list[tuple[str, str]], value: str | None, on_change: Callable[[str], None]) -> ft.Container:
def segmented(options: list[tuple[str, str]], value: str | None, on_change: Callable[[str], None],
full: bool = False) -> ft.Container:
"""A pill track of choices (one selected): the selected one is the portal fade in dual themes, else SURFACE_3.
Clicking restyles in place, then calls on_change(value)."""
Clicking restyles in place, then calls on_change(value). full = the track takes the whole width it gets, the
choices sharing it equally (put it in a stretching Column or a Row as an expanding child)."""
items: dict[str, ft.Container] = {}
def paint(current):
for v, box in items.items():
on = v == current
box.content.color = T.TEXT if on else T.TEXT_2
if T.DUAL:
box.bgcolor, box.gradient = None, portal_gradient(opacity=0.22) if on else None
else:
box.bgcolor, box.gradient = T.SURFACE_3 if on else None, None
def click(v):
def handler(e):
if track.data == v:
return
track.data = v
paint(v)
set_segmented(track, v)
update(track)
on_change(v)
return handler
@@ -650,13 +642,34 @@ def segmented(options: list[tuple[str, str]], value: str | None, on_change: Call
for v, label in options:
items[v] = ft.Container(ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600),
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20),
on_click=click(v), ink=True)
track = ft.Container(ft.Row(list(items.values()), spacing=T.px(2), tight=True), padding=T.px(3),
on_click=click(v), ink=True, data=v,
**(dict(expand=True, alignment=ft.Alignment.CENTER) if full else {}))
track = ft.Container(ft.Row(list(items.values()), spacing=T.px(2), tight=not full), padding=T.px(3),
border_radius=T.px(22), border=ft.Border.all(1, T.BORDER), data=value)
paint(value)
set_segmented(track, value)
return track
def set_segmented(track: ft.Container, value: str | None) -> None:
"""Select `value` in a segmented() track from code (no on_change; the caller updates the track)."""
track.data = value
for box in track.content.controls:
on = box.data == value
box.content.color = T.TEXT if on else T.TEXT_2
if T.DUAL:
box.bgcolor, box.gradient = None, portal_gradient(opacity=0.22) if on else None
else:
box.bgcolor, box.gradient = T.SURFACE_3 if on else None, None
def set_switch(control: ft.Control, value: bool) -> None:
"""Turn a switch() on or off from code (no on_change; the caller updates the control)."""
knob = control.controls[0] if getattr(control, "data", None) == "switch" else control
track, sw = knob.controls
sw.value = bool(value)
paint_switch_track(track, bool(value), bool(sw.disabled))
def hoverable(box: ft.Container) -> ft.Container:
"""A clickable container: ink (the hover highlight and the click ripple, drawn over its background or the
selected fade) like the buttons around it."""
+33 -1
View File
@@ -33,7 +33,39 @@ HELP: dict[str, str] = _Translated({
"to check a scan before you try it in a game.",
"room_view": "Drag to turn the room, scroll or pinch to zoom, right-drag to move it, double-click to see all of "
"it again. The walls nearest to you are left out so you can look inside. The full 3D view in your "
"browser shows every detail of the scan.",
"browser shows every detail of the scan. Click a box or a door to edit it.",
# ---- room scan clean-up (install/rooms.REFINE_DEFAULTS)
"room_cleanup": "The Frame tidies up every scan: flat walls, floor and ceiling, no scan behind walls or where you "
"walked, no small or floating bits. Apply runs it again on your saved scan with these settings.",
"room_labels": "The scanner names each box it finds. These rules check the names against the box's size and "
"place: boxes behind walls go, desks stop being couches, a screen has to look like one.",
"room_boxes": "Furniture games see. Pick a box here or click it in the view, then fix its type, place or size. "
"Your boxes stay when you clean up the scan again.",
"room_doors": "The scanner can't see doors or windows. Add them on a wall: games that ask for a door get it.",
"refine_refine": "Off keeps the scanner's mesh exactly as recorded; the room's walls and boxes stay.",
"refine_walls": "Keep scan leaves walls as scanned. Snap moves scan near a wall onto it. Flat panels replace the "
"scanned walls, floor and ceiling with flat surfaces.",
"refine_walked": "You walked through this space while scanning, so nothing stands there: scan this far below your "
"head's path is your own body or controllers. Furniture boxes are kept.",
"refine_smooth": "Evens out bumps in the scan without shrinking shapes (Taubin smoothing). More passes, smoother.",
"refine_island": "Loose bits of scan smaller than this area are removed.",
"refine_clip": "Scan further than this behind a seen wall is removed. At the maximum nothing is removed.",
"refine_clip_open": "Extra room past edges the scanner closed without seeing a wall: they may be open space.",
"refine_band": "Scan this far behind a wall, under the floor or above the ceiling counts as that surface.",
"refine_band_in": "Only this thin layer on the room side counts as wall, so wardrobes and shelves against it stay.",
"refine_floor_in": "Scan this close above the floor counts as floor.",
"refine_ceil_in": "Scan this close below the ceiling counts as ceiling.",
"refine_top_strip": "Furniture rarely reaches this close to the ceiling: here the whole band counts as wall.",
"refine_facing_deg": "Scan only counts as a wall when it faces the room within this angle of the wall's direction.",
"refine_near": "Flat panels only: other scan this close to a wall, floor or ceiling is removed. Furniture boxes "
"are kept.",
"refine_tubes": "Long, thin pieces hanging in the air (depth streaks), at least this many times longer than wide, "
"are removed. Never furniture.",
"refine_tube_thick": "Only thin floating pieces up to this thickness are removed.",
"refine_tops": "Scan near the top of a table, bed or other box, facing up, goes onto the box's top.",
"refine_protect": "Scan inside a furniture box is never removed by the other steps.",
"refine_label_rules": "Checks every box the scanner found against size and place rules (see Labels). Off keeps the "
"scanner's own names.",
"steam_library":"Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. "
"Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.",
# ---- game status / library
+11 -2
View File
@@ -52,13 +52,16 @@ class RoomCanvas:
"wall_lines": ("lines", dict(color=T.soft(blue, 0.6), stroke_width=T.px(1.4), style=stroke)),
"boxes": ("fill", dict(color=T.soft(T.ACCENT, 0.16), style=fill)),
"box_lines": ("lines", dict(color=T.soft(T.ACCENT, 0.85), stroke_width=T.px(1.3), style=stroke)),
"planes": ("fill", dict(color=T.soft(T.OK, 0.35), style=fill)),
"selected": ("lines", dict(color=T.TEXT, stroke_width=T.px(2.5), style=stroke)),
"marker": ("fill", dict(color=T.ACCENT, style=fill)),
}
self.on_pick = None # callback(("box", i) / ("plane", i) / None) for a click on the room (the box editor)
self.canvas = cv.Canvas([], expand=True, on_resize=self._resized)
self.detector = ft.GestureDetector(
self.canvas, mouse_cursor=ft.MouseCursor.MOVE, drag_interval=16,
on_scale_start=self._scale_start, on_scale_update=self._scale_update, on_scale_end=self._scale_end,
on_scroll=self._scroll, on_double_tap=lambda e: self.fit(),
on_scroll=self._scroll, on_double_tap=lambda e: self.fit(), on_tap_up=self._tap,
on_right_pan_start=self._right_start, on_right_pan_update=self._right_update,
on_right_pan_end=lambda e: self._end_move())
self.control = ft.Container(self.detector, height=self.height, expand=True, bgcolor=T.BG,
@@ -109,6 +112,11 @@ class RoomCanvas:
self._move()
self._end_move()
def _tap(self, e) -> None:
if self.on_pick is None or self.scene is None or e.local_position is None:
return
self.on_pick(V.pick(self.scene, self.cam, e.local_position.x, e.local_position.y))
def _right_start(self, e) -> None:
self._right = (e.local_position.x, e.local_position.y)
@@ -181,7 +189,7 @@ class RoomCanvas:
f = V.render(self.scene, self.cam, lod)
data = {"grid": f.grid, "floor": [f.floor] if f.floor else [], "hidden": f.hidden_walls, "walls": f.walls,
"mesh": f.mesh, "wall_lines": f.wall_lines, "boxes": f.boxes, "box_lines": f.box_lines,
"marker": [f.marker] if f.marker else []}
"planes": f.planes, "selected": f.selected, "marker": [f.marker] if f.marker else []}
if lod == "drag" and len(f.boxes) > MAX_DRAG_BOX_FACES: # a room full of furniture: outlines while moving
data["boxes"] = []
shapes = []
@@ -207,6 +215,7 @@ class RoomCanvas:
return ft.Row([key(T.soft(T.SECONDARY, 0.5), tr("Walls")),
key(T.soft(T.SECONDARY, 0.7), tr("Scanned surfaces"), line=True),
key(T.soft(T.ACCENT, 0.6), tr("Furniture")),
key(T.soft(T.OK, 0.6), tr("Doors and windows")),
key(T.ACCENT, tr("Middle of your play area, facing forward"))],
spacing=T.S4, run_spacing=T.S2, wrap=True)
+64 -7
View File
@@ -67,7 +67,9 @@ def model_key(status: dict | None) -> tuple | None:
from ..mr.room import parse
room = parse(text)
return room.universe, room.time, room.uuid, room.mesh.uuid if room.mesh else None
# + the text itself: a clean-up or a box edit may keep the room's ids and time
return (room.universe, room.time, room.uuid, room.mesh.uuid if room.mesh else None,
hashlib.sha256(text.encode()).hexdigest()[:16])
def _cache_root(target):
@@ -114,19 +116,17 @@ def load_model(target, room_text: str, key: tuple) -> dict:
the Frame), parsed and prepared for the canvas."""
from ..install import rooms
from ..mr import view3d
from ..mr.room import parse, read_mesh
from ..mr.room import dumps, parse, read_mesh
room = parse(room_text)
root = _cache_root(target)
folder = root / f"{room.universe}-{room.time}-{room.uuid[:8]}"
folder = root / f"{room.universe}-{room.time}-{room.uuid[:8]}-{key[-1] if len(key) > 4 else ''}"
mesh_bytes = None
path = folder / "room.txt"
fresh = path.is_file() and (room.mesh is None or (folder / room.mesh.filename).is_file())
if fresh:
try:
cached_room = parse(path.read_text(encoding="utf-8"))
fresh = cached_room.uuid == room.uuid and (cached_room.mesh and cached_room.mesh.uuid) == (
room.mesh and room.mesh.uuid)
try: # the same room file (a clean-up keeps the ids but changes the mesh: its counts and the text differ)
fresh = path.read_text(encoding="utf-8") == dumps(room)
except (OSError, ValueError):
fresh = False
if not fresh:
@@ -221,3 +221,60 @@ def operate(app, action: str) -> None:
state(app, force=True)
app.refresh_view()
app.run_bg(work)
def _frame_job(app, working: str, title: str, run) -> None:
"""A Room tab action on the Frame as an Activity job (kind tool-frame: a lost connection pauses it); the cache
says what runs (working) and its stage (job_stage) for the tab's progress line, and takes the room status the
action returns (the 3D model reloads when the room changed)."""
cached = state(app)
target = app.target
if target is None or cached["loading"] or cached.get("working"):
return
cached.update(working=working, job_stage=tr("Waiting for the Frame…"))
app.refresh_view()
def job(j):
def stage(text):
cached["job_stage"] = text
j.reporter.stage(text)
if app.target is target:
app.refresh_view()
try:
result = run(target, stage)
if isinstance(result, dict) and "room_text" in result:
cached.update(status=result, time=time.monotonic(), error=None)
return result.pop("_message", None) if isinstance(result, dict) else None
finally:
cached.update(working=False, job_stage=None)
if app.target is target:
state(app, force=True) # (the Frame page's preview too)
_maybe_load_model(app, cached)
app.refresh_view()
app.submit(title, job, kind="tool-frame")
def refine_room(app, settings: dict | None, keep_edits: bool = True) -> None:
"""Clean up the saved room's scan again on the Frame with `settings` (also saved there for the next scans)."""
def run(target, stage):
from ..install import rooms
stage(tr("Cleaning up the scan on the Frame…"))
result = rooms.refine(target.frame, settings, keep_edits=keep_edits)
state(app)["refine_result"] = result.get("report") or result.get("summary") or {}
result["_message"] = tr("Room scan cleaned up")
return result
_frame_job(app, "refine", tr("Clean up the room scan"), run)
def save_room(app, room, mesh: bytes | None) -> None:
"""Save the Room tab's edited furniture boxes, doors and windows on the Frame."""
def run(target, stage):
from ..install import rooms
stage(tr("Saving your room on the Frame…"))
rooms.put_room(target.frame, room, mesh)
result = rooms.status(target.frame)
result["_message"] = tr("Room saved")
return result
_frame_job(app, "save", tr("Save room edits"), run)
+92 -24
View File
@@ -1,6 +1,8 @@
"""Room: the connected Frame's saved room for mixed-reality games. Scan it in the headset (with progress and Cancel),
use the play area instead, delete it, and look at it in 3D: in the window (ui/room3d.RoomCanvas) and, with every
detail, in the browser (mr/room_viewer.py, a local page stopped on disconnect / window close).
detail, in the browser (mr/room_viewer.py, a local page stopped on disconnect / window close). Below it the editing
cards of views/room_edit.py: the scan clean-up settings (Apply runs it again on the Frame), what the label rules
decided (door / closet suggestions) and the furniture box + doors / windows editor (Save sends the room).
Persistent like the Monitor (built once per connection, re-mounted on every visit): state changes (the room cache in
ui/room_state.py, scan progress every 2 s) update properties of the controls built here; buttons are never rebuilt."""
@@ -21,7 +23,7 @@ from ..help import HELP
if TYPE_CHECKING:
from ..app import FramePortApp
CANVAS_HEIGHT = 480
CANVAS_HEIGHT = 560
def furniture_name(label: str) -> str:
@@ -65,17 +67,17 @@ class RoomView:
def _tester(self) -> ft.Control:
"""Test Quest MR: Get (its install link: FramePort's own install question) or Open once it's in the library."""
if library.game(TESTER_PACKAGE):
button = C.secondary(tr("Open Test Quest MR"), ft.Icons.VIEW_IN_AR_ROUNDED,
lambda e: self.app.open_game(TESTER_PACKAGE), tooltip=C.tip(HELP["room_tester"]))
label, go = tr("Open Test Quest MR"), lambda e: self.app.open_game(TESTER_PACKAGE)
else:
from .link_dialog import open_link
button = C.secondary(tr("Get Test Quest MR"), ft.Icons.VIEW_IN_AR_ROUNDED,
lambda e: open_link(self.app, TESTER_MANIFEST), tooltip=C.tip(HELP["room_tester"]))
return C.callout(ft.Column([
C.body(tr("Try it out: Test Quest MR shows the room, furniture and anchors a game receives from this "
"scan."), T.TEXT),
button], spacing=T.S3, tight=True), "info", ft.Icons.LIGHTBULB_OUTLINE_ROUNDED)
label, go = tr("Get Test Quest MR"), lambda e: open_link(self.app, TESTER_MANIFEST)
link = ft.Container(ft.Text(label, size=T.T_META, color=T.ACCENT, weight=ft.FontWeight.W_600), on_click=go,
ink=True, border_radius=T.RADIUS_XS, tooltip=C.tip(HELP["room_tester"]))
# one line at the bottom of "In this room" (not a card of its own)
return ft.Row([ft.Icon(ft.Icons.LIGHTBULB_OUTLINE_ROUNDED, size=T.ICON_S, color=T.TEXT_3),
C.meta(tr("See what a game gets:")), link], spacing=T.S1, wrap=True,
vertical_alignment=ft.CrossAxisAlignment.CENTER)
def _build(self) -> None:
from ..room3d import RoomCanvas
@@ -103,13 +105,17 @@ class RoomView:
alignment=ft.Alignment.CENTER, left=0, right=0, top=0, bottom=0, bgcolor=T.soft(T.BG, 0.88))
self.reset_btn = C.icon_btn(ft.Icons.CENTER_FOCUS_STRONG_ROUNDED, tr("Show the whole room"),
lambda e: self.canvas.fit())
view = ft.Stack([self.canvas.control, self.placeholder,
ft.Container(self.reset_btn, right=T.S2, top=T.S2)], height=T.px(CANVAS_HEIGHT))
self.canvas_card = C.card(ft.Column([
view,
ft.Row([ft.Container(self.canvas.legend(), expand=True), C.help_icon("room_view")],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
], spacing=T.S3), padding=T.S3, col={"xs": 12, "lg": 8})
# the legend sits on the view's bottom edge, so the card is exactly the view's height (the right column is
# made the same height: the top row ends level)
legend = ft.Container(ft.Row([ft.Container(self.canvas.legend(), expand=True), C.help_icon("room_view")],
vertical_alignment=ft.CrossAxisAlignment.CENTER),
left=0, right=0, bottom=0, bgcolor=T.soft(T.BG, 0.82),
padding=ft.Padding(T.S3, T.S2, T.S3, T.S2),
border_radius=ft.BorderRadius(0, 0, T.RADIUS_SM, T.RADIUS_SM))
self.view = ft.Stack([self.canvas.control, legend, self.placeholder,
ft.Container(self.reset_btn, right=T.S2, top=T.S2)], height=T.px(CANVAS_HEIGHT))
self.canvas_card = C.card(self.view, padding=T.S3, col={"xs": 12, "lg": 8})
self.canvas_card.on_size_change = self._card_sized
# status
self.checks = ft.Container(C.checklist([C.Check(None, tr("Checking the room…"))]))
@@ -132,21 +138,37 @@ class RoomView:
self.summary = C.body("", T.TEXT)
self.source = C.meta("")
self.furniture = ft.Row([], spacing=T.S2, run_spacing=T.S2, wrap=True)
# the right column sizes to its content (never clipped: window width, UI scale and the furniture chips change
# its height); level() then makes the top row end level: a short column's last card grows to the 3D view's
# card (self.level_gap), a tall column makes the 3D view taller. Every card with the same padding.
self.level_gap = ft.Container(height=0)
side = ft.Column([
C.card(ft.Column([self.checks, self.error], spacing=T.S2),
padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
C.card(ft.Column([self.checks, self.error], spacing=T.S2), padding=T.S4),
C.card(ft.Column([C.section(tr("Scan"), self.scan_box,
ft.Row([self.scan_btn, self.cancel_btn], spacing=T.S2, wrap=True),
ft.Row([self.boundary_btn, self.delete_btn], spacing=T.S2, wrap=True),
help="mr_room")], spacing=T.S3), padding=T.S4),
C.card(ft.Column([C.meta(tr("In this room")), self.summary, self.source, self.furniture],
spacing=T.S2), padding=T.S4),
self._tester(),
], spacing=T.S3, col={"xs": 12, "lg": 4}, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
C.card(ft.Column([C.meta(tr("In this room")), self.summary, self.source, self.furniture,
self.level_gap, self._tester()], spacing=T.S2),
padding=T.S4),
], spacing=T.S4, col={"xs": 12, "lg": 4}, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
on_size_change=self._side_sized)
self._sizes = {"card": None, "side": None}
# editing: the scan clean-up, what the label rules decided, furniture boxes + doors / windows
from .room_edit import CleanupCard, FurnitureEditor, LabelsCard
self.editor = FurnitureEditor(self)
self.cleanup = CleanupCard(self)
self.labels = LabelsCard(self)
self.canvas.on_pick = lambda hit: self.editor.select(hit) if self.editor.room is not None else None
self.root = ft.Column([
self._heading([self.refresh_btn, self.full_btn]),
ft.ResponsiveRow([self.canvas_card, side], spacing=T.S4, run_spacing=T.S4,
vertical_alignment=ft.CrossAxisAlignment.START),
ft.Container(ft.ResponsiveRow([self.cleanup.control, self.editor.control, self.labels.control],
spacing=T.S4, run_spacing=T.S4,
vertical_alignment=ft.CrossAxisAlignment.START),
key=ft.ScrollKey("room-edit")),
ft.Container(height=T.S4),
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
@@ -229,10 +251,17 @@ class RoomView:
# the canvas, or what's in its place
key = model.get("key") if model else None
if key != self._shown_key:
first = self._shown_key in ("unset", None)
self._shown_key = key
self.canvas.set_scene(model["scene"] if model else None)
self.editor.load(model) # edits start again from the new room
self.canvas.set_scene(self.editor.scene(), fit=first or model is None)
changed += [self.editor.box_list, self.editor.plane_list, self.editor.box_panel,
self.editor.plane_panel]
if self.viewer is not None and model is not None:
self.viewer.set_room(model["room_text"], model["mesh"]) # the open page reloads by itself
changed += self.cleanup.refresh(cached, status, room)
changed += self.labels.refresh(cached, status, room)
changed += self.editor.refresh(cached, status, room)
self._placeholder(cached, status, room, model)
if update:
self._push(*changed)
@@ -284,6 +313,45 @@ class RoomView:
C.confirm(self.app.page, tr("Delete room?"), tr("Games will use a simple room until you save another room."),
tr("Delete room"), lambda: RS.operate(self.app, "delete"), danger=True)
# ------------------------------------------------------------------ the top row ends level
def _card_sized(self, e) -> None:
self._sizes["card"] = (e.width, e.height)
self.level()
def _side_sized(self, e) -> None:
self._sizes["side"] = (e.width, e.height)
self.level()
def level(self) -> None:
"""The right column's height (its content's, measured) against the 3D view's card: a shorter column gets a
gap in its last card, a taller one makes the 3D view taller. Stacked (narrow window): neither."""
card, side = self._sizes["card"], self._sizes["side"]
if not card or not side:
return
base = T.px(CANVAS_HEIGHT)
base_card = base + 2 * T.S3 + 2 # (the card's padding and border)
content = side[1] - (self.level_gap.height or 0)
gap, view = 0, base
if side[0] < card[0] * 0.9: # side by side
if content <= base_card:
gap = int(base_card - content)
else:
view = base + int(content - base_card + 0.999)
changed = []
if gap != (self.level_gap.height or 0):
self.level_gap.height = gap
changed.append(self.level_gap)
if view != self.view.height:
self.view.height = self.canvas.control.height = view
changed.append(self.view)
if changed:
C.update(*changed)
async def show_editing(self) -> None:
"""Scroll down to the editing cards (Clean-up, Furniture & screens, Labels)."""
if self.root is not None:
await self.root.scroll_to(scroll_key="room-edit", duration=0)
def open_full(self, e=None) -> None:
"""The browser view: a local page with the whole mesh (started once, kept until disconnect / close)."""
cached = RS.state(self.app, model=True)
+737
View File
@@ -0,0 +1,737 @@
"""The Room tab's editing cards: Clean-up (the scan clean-up settings, Apply runs it again on the Frame), Labels (what
the label rules decided, door / closet suggestions) and Furniture & screens (a box editor + doors and windows on a
wall; Save sends the edited room to the Frame).
Built once with the Room tab (persistent): refresh() brings the cards up to date with the room cache and returns the
controls it changed. Lists with buttons are rebuilt only when the room status or the edited room's structure changes
(a user action), never on the scan's progress ticks; sliders and readouts are updated in place.
The scan eraser of the clean-up page isn't here (the agent accepts erase spots: rooms.refine(erase=...))."""
from __future__ import annotations
import copy
import json
from typing import TYPE_CHECKING
import flet as ft
from ...i18n import tr
from ...install import rooms
from ...mr import room as R
from ...mr import view3d
from .. import components as C
from .. import room_state as RS
from .. import theme as T
if TYPE_CHECKING:
from .room import RoomView
def _furniture_name(label: str) -> str:
from .room import furniture_name
return furniture_name(label)
def setting_label(key: str) -> str:
return tr(rooms.REFINE_DEFAULTS[key]["label"])
def setting_text(key: str, value) -> str:
"""A clean-up setting's value in words (the readout next to its slider)."""
meta = rooms.REFINE_DEFAULTS[key]
if meta["kind"] == "choice":
return tr(dict(meta["choices"])[value])
if meta["kind"] == "bool":
return tr("on") if value else tr("off")
if "off" in meta and value == meta["off"]:
return tr("off")
if "all" in meta and value >= meta["all"]:
return tr("keep all")
unit = meta.get("unit")
if unit == "cm":
return tr("{n} cm").format(n=round(value * 100))
if unit == "m2":
return tr("{n} m²").format(n=f"{value:.2f}")
if unit == "deg":
return tr("{n}°").format(n=round(value))
if unit == "times":
return tr("{n}×").format(n=f"{value:g}")
return f"{value:g}"
# what the clean-up report's counts are called (scan.json "report"; unknown keys read as their name)
def report_label(key: str) -> str:
return {"triangles": tr("Scan triangles"), "kept": tr("Kept"), "structural": tr("Wall, floor and ceiling surfaces"),
"near_walls": tr("Cleared near walls"), "outside": tr("Outside the room"),
"small_pieces": tr("Small pieces"), "thin_pieces": tr("Thin floating pieces"), "erased": tr("Erased"),
"walked": tr("Where you walked"), "tops": tr("Flattened onto furniture tops"),
"panel_triangles": tr("Flat panel triangles"), "boxes": tr("Furniture boxes"),
"removed_boxes": tr("Boxes removed"), "relabelled": tr("Boxes renamed"),
"suggestions": tr("Door or closet suggestions"), "output_triangles": tr("Triangles in the room"),
"output_volumes": tr("Furniture boxes in the room")}.get(key, key.replace("_", " ").capitalize())
# scan.json report keys that aren't counts (heights, flags: see report_notes) - docs/mr/ROOM_FORMAT.md
REPORT_NOT_COUNTS = ("floor_y", "ceiling_y", "walked_skipped", "truncated", "version")
def report_notes(report: dict | None) -> tuple[list[str], str]:
"""(notes, warning) about a clean-up: the walked pass skipped (no head path), triangles cut off (the room's
triangle limit)."""
report = report or {}
notes = [tr("Where you walked: skipped, this scan has no head path")] if report.get("walked_skipped") else []
cut = report.get("truncated")
warning = tr("The cleaned-up scan had more triangles than a room can hold: {n} were left out.").format(
n=f"{cut:,}" if isinstance(cut, int) else cut) if cut else ""
return notes, warning
def report_rows(report: dict | None) -> list[tuple[str, str]]:
"""(label, value) for the clean-up's counts; nested groups are flattened, lists and empty values left out."""
out = []
def walk(d, prefix=""):
for k, v in d.items():
if k in REPORT_NOT_COUNTS:
continue
if isinstance(v, dict):
walk(v, prefix)
elif isinstance(v, bool) or v is None or isinstance(v, (list, str)):
continue
elif isinstance(v, (int, float)):
out.append((report_label(k), f"{v:,}" if isinstance(v, int) else f"{v:,.2f}".rstrip("0").rstrip(".")))
walk(report or {})
return out
def _slider(lo, hi, step, value, on_change, width=None) -> ft.Slider:
return ft.Slider(min=lo, max=hi, divisions=max(1, round((hi - lo) / step)), value=min(max(value, lo), hi),
active_color=T.ACCENT, on_change=on_change, width=width, height=T.px(28),
padding=ft.Padding(0, 0, 0, 0)) # the track spans the row: its ends under the label and value
def _full(control: ft.Control) -> ft.Row:
"""`control` across the whole content width (a full segmented() track)."""
return ft.Row([ft.Container(control, expand=True)])
def _even(button: ft.Control) -> ft.Control:
"""A button sharing its row equally with the others (same width and height)."""
button.expand = True
return button
def _labelled(title: str, readout: ft.Text, control: ft.Control, help_key: str | None = None) -> ft.Column:
head = ft.Row([ft.Container(C.with_help(C.body(title, T.TEXT), help_key), expand=True), readout],
vertical_alignment=ft.CrossAxisAlignment.CENTER)
return ft.Column([head, control], spacing=0, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
# ===================================================================================================== Clean-up
class CleanupCard:
"""Recommended (the defaults) or your own settings: the key ones in sight, the rest under More settings. Apply
cleans up the saved room's scan again on the Frame (a background job) and saves the settings there."""
def __init__(self, view: RoomView):
self.view = view
self.values = rooms.refine_defaults()
self.mine: dict | None = None # your settings while Recommended is shown
self.touched = False # changed here since the Frame's settings were loaded
self._loaded = None # the Frame's saved settings last shown (json)
self._report_seen = "unset"
self.sliders: dict[str, ft.Slider] = {}
self.readouts: dict[str, ft.Text] = {}
self.switches: dict[str, ft.Control] = {}
self.preset = C.segmented([("recommended", tr("Recommended")), ("mine", tr("Your settings"))], "recommended",
self._preset, full=True)
walls = rooms.REFINE_DEFAULTS["walls"]
self.walls = C.segmented([(v, tr(label)) for v, label in walls["choices"]], self.values["walls"],
lambda v: self.set("walls", v), full=True)
keys = [k for k, m in rooms.REFINE_DEFAULTS.items() if m["key"] and m["kind"] == "number"]
visible = [_labelled(tr("Walls"), C.meta(""), _full(self.walls), "refine_walls")]
visible += [self._row(k) for k in keys]
groups = []
for group, title in rooms.REFINE_GROUPS:
rows = [self._row(k) for k, m in rooms.REFINE_DEFAULTS.items() if m["group"] == group and not m["key"]]
if rows:
groups.append(ft.Column([C.body(tr(title), T.TEXT_2, weight=ft.FontWeight.W_600), *rows],
spacing=T.S2, tight=True))
self.more = ft.Column(groups, spacing=T.S4, visible=False)
self.more_btn = C.ghost(tr("More settings"), ft.Icons.EXPAND_MORE_ROUNDED, self.toggle_more)
self.apply_btn = C.primary(tr("Apply"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, self.apply,
tooltip=C.tip(tr("Clean up your saved scan again with these settings. They are "
"also used for your next scans.")))
self.state_text = C.meta("")
self.progress = C.progress_bar(None)
self.progress.visible = False
self.result_title = C.meta(tr("Last clean-up"))
self.result = ft.Column([], spacing=T.px(2), tight=True)
# (no inset: the counts end at the same edge as the slider values above)
self.result_notes = ft.Column([], spacing=T.px(2), tight=True)
self.result_warn = C.callout("", "warn")
self.result_warn.visible = False
self.result_box = ft.Column([ft.Divider(height=1, color=T.BORDER), self.result_title, self.result,
self.result_notes, self.result_warn], spacing=T.S2, tight=True, visible=False)
self.control = C.card(ft.Column([
C.section(tr("Clean-up"), _full(self.preset), *visible, self.more_btn, self.more,
ft.Row([self.apply_btn, ft.Container(self.state_text, expand=True)], spacing=T.S3,
vertical_alignment=ft.CrossAxisAlignment.CENTER),
self.progress, self.result_box, help="room_cleanup"),
], spacing=T.S3), padding=T.S4, col={"xs": 12, "md": 6, "xl": 4})
self._show()
def _row(self, key: str) -> ft.Control:
meta = rooms.REFINE_DEFAULTS[key]
if meta["kind"] == "bool":
sw = C.switch(setting_label(key), value=bool(self.values[key]),
on_change=lambda e, k=key: self.set(k, 1 if e.control.value else 0))
self.switches[key] = sw
return C.with_help(sw, meta["help"])
self.readouts[key] = C.body("", T.TEXT, weight=ft.FontWeight.W_600)
self.sliders[key] = _slider(meta["min"], meta["max"], meta["step"], self.values[key],
lambda e, k=key: self._slid(k, e.control.value))
return _labelled(setting_label(key), self.readouts[key], self.sliders[key], meta["help"])
# values -> controls
def _show(self) -> list[ft.Control]:
for k, s in self.sliders.items():
meta = rooms.REFINE_DEFAULTS[k]
s.value = min(max(self.values[k], meta["min"]), meta["max"])
self.readouts[k].value = setting_text(k, self.values[k])
for k, sw in self.switches.items():
C.set_switch(sw, bool(self.values[k]))
C.set_segmented(self.walls, self.values["walls"])
C.set_segmented(self.preset, "recommended" if not rooms.normalize_refine(self.values) else "mine")
self.sliders["near"].disabled = self.values["walls"] != "panels"
return [*self.sliders.values(), *self.readouts.values(), *self.switches.values(), self.walls, self.preset]
def _slid(self, key: str, value: float) -> None:
meta = rooms.REFINE_DEFAULTS[key]
value = round(round((value - meta["min"]) / meta["step"]) * meta["step"] + meta["min"], 4)
if key in rooms.INT_SETTINGS:
value = int(round(value))
if self.values[key] == value:
return
self.values[key] = value
self.readouts[key].value = setting_text(key, value)
self.touched = True
changed = self.preset.data != ("mine" if rooms.normalize_refine(self.values) else "recommended")
C.set_segmented(self.preset, "recommended" if not rooms.normalize_refine(self.values) else "mine")
C.update(self.readouts[key], *([self.preset] if changed else []))
def set(self, key: str, value) -> None:
self.values[key] = value
self.touched = True
C.update(*self._show())
def _preset(self, which: str) -> None:
if which == "recommended":
self.mine = dict(self.values) if rooms.normalize_refine(self.values) else self.mine
self.values = rooms.refine_defaults()
else:
self.values = dict(self.mine) if self.mine else self.values
self.touched = True
C.update(*self._show())
def toggle_more(self, e=None) -> None:
shown = self.more.visible = not self.more.visible
self.more_btn.content = tr("Fewer settings") if shown else tr("More settings")
self.more_btn.icon = ft.Icons.EXPAND_LESS_ROUNDED if shown else ft.Icons.EXPAND_MORE_ROUNDED
C.update(self.more, self.more_btn)
def apply(self, e=None) -> None:
self.touched = False
RS.refine_room(self.view.app, dict(self.values))
# cache -> controls
def refresh(self, cached: dict, status: dict, room: dict | None) -> list[ft.Control]:
changed = [self.apply_btn, self.state_text, self.progress, self.result_box]
saved = status.get("refine") if status else None
key = json.dumps(saved, sort_keys=True) if saved is not None else None
if key is not None and key != self._loaded and not self.touched:
self._loaded = key
self.values = rooms.current_refine(status)
changed += self._show()
working = cached.get("working")
refining = working == "refine"
self.apply_btn.disabled = bool(working) or not room or not status.get("scanner_installed", True) \
or room.get("source") != "scan"
self.progress.visible = refining
if refining:
self.state_text.value = cached.get("job_stage") or tr("Cleaning up the scan on the Frame…")
elif room and room.get("source") != "scan":
self.state_text.value = tr("Only a scanned room can be cleaned up.")
elif status and status.get("edited"):
self.state_text.value = tr("Your own boxes and doors stay.")
else:
self.state_text.value = ""
report = (status or {}).get("report")
if report is not self._report_seen:
self._report_seen = report
rows = report_rows(report)
self.result.controls = [ft.Row([ft.Container(C.meta(a), expand=True), C.body(b, T.TEXT)],
spacing=T.S2) for a, b in rows]
notes, warning = report_notes(report)
self.result_notes.controls = [C.meta(n) for n in notes]
self.result_warn.visible = bool(warning)
if warning: # (callout: icon, then the text's container)
self.result_warn.content.controls[1].content.value = warning
self.result_box.visible = bool(rows or notes or warning)
return changed
# ===================================================================================================== Labels
LABEL_NAME_W = 72 # the Labels rows' first column (T.px)
class LabelsCard:
"""What the label rules decided for each box the scanner found (scanner label -> label, and why), and door /
closet suggestions with Add as door / Add as closet (into the box editor, saved with Save)."""
def __init__(self, view: RoomView):
self.view = view
self._seen = "unset"
self.rows = ft.Column([], spacing=T.S2, tight=True)
self.suggestions = ft.Column([], spacing=T.S2, tight=True)
self.empty = C.meta(tr("Clean up a scan to see what the rules decided."))
self.reset_btn = C.ghost(tr("Back to the scan's boxes"), ft.Icons.UNDO_ROUNDED, self._ask_reset,
tooltip=C.tip(tr("Drop your own boxes, doors and windows and use the boxes the "
"scan found.")))
self.reset_btn.visible = False
self.control = C.card(ft.Column([
C.section(tr("Labels"), self.empty, self.suggestions, self.rows, self.reset_btn, help="room_labels"),
], spacing=T.S3), padding=T.S4, col={"xs": 12, "md": 6, "xl": 4})
def _ask_reset(self, e=None) -> None:
C.confirm(self.view.app.page, tr("Back to the scan's boxes?"),
tr("Your own furniture boxes, doors and windows are removed and the scan is cleaned up again."),
tr("Use the scan's boxes"), lambda: RS.refine_room(self.view.app, None, keep_edits=False))
def refresh(self, cached: dict, status: dict, room: dict | None) -> list[ft.Control]:
self.reset_btn.visible = bool(status and status.get("edited"))
self.reset_btn.disabled = bool(cached.get("working"))
changed = [self.reset_btn]
if cached.get("status") is self._seen:
return changed
self._seen = cached.get("status")
labels = [x for x in (status or {}).get("labels") or [] if isinstance(x, dict)]
suggestions = [x for x in (status or {}).get("suggestions") or [] if isinstance(x, dict)]
self.rows.controls = [self._label_row(x) for x in labels]
self.suggestions.controls = [self._suggestion_row(x) for x in suggestions]
self.empty.visible = not labels and not suggestions
return changed + [self.rows, self.suggestions, self.empty]
@staticmethod
def _size(box: dict) -> str:
try:
return size_text(float(box["w"]), float(box["d"]), float(box["h"]))
except (KeyError, TypeError, ValueError):
return ""
def _label_row(self, x: dict) -> ft.Control:
src, dst = str(x.get("from") or ""), x.get("to")
if x.get("suggest") in ("door", "closet"): # a tall thin slab: see the suggestions above
change, color = (tr("→ door?") if x["suggest"] == "door" else tr("→ closet?")), T.OK
elif dst is None:
change, color = tr("removed"), T.TEXT_3
elif dst == src:
change, color = tr("kept"), T.TEXT_2
else:
change, color = "→ " + _furniture_name(str(dst)), T.ACCENT
# columns: the scanner's name (fixed width), what it became, the size at the right edge
head = ft.Row([ft.Container(ft.Text(_furniture_name(src), color=T.TEXT if dst is not None else T.TEXT_3,
weight=ft.FontWeight.W_600, size=T.T_BODY, no_wrap=True),
width=T.px(LABEL_NAME_W)),
ft.Container(ft.Text(change, color=color, size=T.T_BODY), expand=True),
C.meta(self._size(x.get("box") or {}))],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
reason = str(x.get("reason") or "")
return ft.Column([head] + ([C.meta(reason)] if reason else []), spacing=T.px(2), tight=True)
def _suggestion_row(self, x: dict) -> ft.Control:
kind = x.get("kind")
text = tr("A door?") if kind == "door" else tr("A closet?")
editor = self.view.editor
return ft.Container(ft.Column([
ft.Row([ft.Icon(ft.Icons.DOOR_FRONT_DOOR_ROUNDED if kind == "door" else ft.Icons.SHELVES, color=T.OK,
size=T.ICON_S),
C.body(text, T.TEXT, weight=ft.FontWeight.W_600), ft.Container(expand=True),
C.meta(self._size(x.get("box") or {}))], spacing=T.S2,
vertical_alignment=ft.CrossAxisAlignment.CENTER),
C.meta(str(x.get("reason") or "") or
tr("A tall, thin box by a wall: the scanner can't tell a door from a closet.")),
ft.Row([_even(C.secondary(tr("Add as door"), None, lambda e, s=x: editor.accept(s, "door"))),
_even(C.secondary(tr("Add as closet"), None, lambda e, s=x: editor.accept(s, "closet")))],
spacing=T.S2),
], spacing=T.S2, tight=True), bgcolor=T.soft(T.OK, 0.07), border=ft.Border.all(1, T.soft(T.OK, 0.3)),
border_radius=T.RADIUS_SM, padding=T.S3)
# ===================================================================================================== box editor
BOX_FIELDS = ( # attribute, label, min, max, step, unit ("" = metres, "deg")
("cx", "Left / right", None, None, 0.01, ""), ("cz", "Front / back", None, None, 0.01, ""),
("top_y", "Top height", 0.0, 3.0, 0.01, ""), ("yaw_deg", "Turn", -180.0, 180.0, 1.0, "deg"),
("w", "Width", 0.05, 4.0, 0.01, ""), ("d", "Depth", 0.05, 4.0, 0.01, ""), ("h", "Height", 0.02, 3.0, 0.01, ""))
PLANE_FIELDS = (("along", "Along the wall", 0.0, 1.0, 0.01, "%"), ("bottom", "Bottom edge", 0.0, 2.0, 0.01, ""),
("w", "Width", 0.3, 3.0, 0.01, ""), ("h", "Height", 0.2, 2.6, 0.01, ""))
CONTROL_H = 44 # the editors' dropdowns and their Delete button share this height (T.px)
def _picker(options, on_select) -> ft.Dropdown:
"""A dropdown filling its row, as tall as the Delete button next to it."""
return C.dropdown(options=options, on_select=on_select, dense=True, expand=True,
content_padding=ft.Padding(T.px(12), 0, T.px(8), 0))
def _delete_button(on_click) -> ft.OutlinedButton:
btn = C.secondary(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, on_click)
btn.style.padding = ft.Padding(T.px(14), 0, T.px(14), 0)
btn.height = T.px(CONTROL_H)
return btn
def _panel(rows: list[ft.Control]) -> ft.Container:
"""The selected box's / door's editor: its rows across the whole width."""
return ft.Container(ft.Column(rows, spacing=T.S3, tight=True,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
bgcolor=T.SURFACE_2, border_radius=T.RADIUS_SM, padding=T.S3, visible=False)
def metres(value: float) -> str:
"""A box size in metres: one decimal, two below 10 cm (a rug's 3 cm isn't "0.0")."""
return f"{value:.2f}" if abs(value) < 0.095 else f"{value:.1f}"
def size_text(w: float, d: float, h: float) -> str:
return tr("{w}×{d}×{h} m").format(w=metres(w), d=metres(d), h=metres(h))
def field_label(name: str) -> str:
return {"Left / right": tr("Left / right"), "Front / back": tr("Front / back"), "Top height": tr("Top height"),
"Turn": tr("Turn"), "Width": tr("Width"), "Depth": tr("Depth"), "Height": tr("Height"),
"Along the wall": tr("Along the wall"), "Bottom edge": tr("Bottom edge")}[name]
def field_text(unit: str, value: float) -> str:
if unit == "deg":
return tr("{n}°").format(n=round(value))
if unit == "%":
return f"{round(value * 100)} %"
return tr("{n} m").format(n=f"{value:.2f}")
class FurnitureEditor:
"""The room's furniture boxes and doors / windows, editable: select one (in the list or by clicking it in the 3D
view), change its type, move / size / turn it; add or delete. Save sends the room to the Frame (rooms.put_room)."""
def __init__(self, view: RoomView):
self.view = view
self.model = None # the room cache's model the edits started from
self.room: R.Room | None = None # the edited copy
self.places: list[dict] = [] # per plane: wall, along, bottom (the editor's own terms)
self.sel: tuple[str, int] | None = None
self.dirty = False
# boxes
self.box_list = ft.Column([], spacing=T.px(2), tight=True)
self.box_type = _picker([ft.DropdownOption(key=k, text=_furniture_name(k)) for k in R.VOLUME_LABELS],
lambda e: self._box_label(e.control.value))
self.box_sliders: dict[str, ft.Slider] = {}
self.box_readouts: dict[str, ft.Text] = {}
rows = []
for attr, label, lo, hi, step, _unit in BOX_FIELDS: # one slider per row, the whole width
self.box_readouts[attr] = C.body("", T.TEXT, weight=ft.FontWeight.W_600)
self.box_sliders[attr] = _slider(lo if lo is not None else -5, hi if hi is not None else 5, step, 0,
lambda e, a=attr: self._box_value(a, e.control.value))
rows.append(_labelled(field_label(label), self.box_readouts[attr], self.box_sliders[attr]))
self.box_delete = _delete_button(self._delete)
self.box_panel = _panel([ft.Row([ft.Container(self.box_type, expand=True), self.box_delete],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER), *rows])
self.add_box = C.secondary(tr("Add box"), ft.Icons.ADD_BOX_ROUNDED, self._add_box)
# doors / windows
self.plane_list = ft.Column([], spacing=T.px(2), tight=True)
self.plane_type = _picker([ft.DropdownOption(key=k, text=_furniture_name(k)) for k in R.PLANE_LABELS],
lambda e: self._plane_label(e.control.value))
self.plane_wall = _picker([], lambda e: self._plane_wall(e.control.value))
self.plane_sliders: dict[str, ft.Slider] = {}
self.plane_readouts: dict[str, ft.Text] = {}
rows = []
for attr, label, lo, hi, step, _unit in PLANE_FIELDS:
self.plane_readouts[attr] = C.body("", T.TEXT, weight=ft.FontWeight.W_600)
self.plane_sliders[attr] = _slider(lo, hi, step, lo,
lambda e, a=attr: self._plane_value(a, e.control.value))
rows.append(_labelled(field_label(label), self.plane_readouts[attr], self.plane_sliders[attr]))
self.plane_delete = _delete_button(self._delete)
self.plane_panel = _panel([ft.Row([ft.Container(self.plane_type, expand=True), self.plane_delete],
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER),
self.plane_wall, *rows])
self.add_door = C.secondary(tr("Add door"), ft.Icons.DOOR_FRONT_DOOR_ROUNDED,
lambda e: self._add_plane("DOOR_FRAME"))
self.add_window = C.secondary(tr("Add window"), ft.Icons.WINDOW_ROUNDED,
lambda e: self._add_plane("WINDOW_FRAME"))
# save
self.save_btn = C.primary(tr("Save"), ft.Icons.SAVE_ROUNDED, self.save,
tooltip=C.tip(tr("Send your boxes, doors and windows to the Frame. Games get them "
"at their next start.")))
self.discard_btn = C.ghost(tr("Discard changes"), ft.Icons.UNDO_ROUNDED, self.discard)
self.save_text = C.meta("")
self.empty = C.meta(tr("No room saved yet"))
self.body = ft.Column([
C.meta(tr("Pick a box here or click it in the view, then fix its type, place or size.")),
self.box_list, self.box_panel, ft.Row([self.add_box], wrap=True),
ft.Container(height=T.S2),
C.section(tr("Doors & windows"), C.meta(tr("Add them on a wall: games that ask for a door get it.")),
self.plane_list, self.plane_panel, ft.Row([self.add_door, self.add_window], spacing=T.S2,
wrap=True), help="room_doors"),
ft.Divider(height=1, color=T.BORDER),
ft.Row([self.save_btn, self.discard_btn, self.save_text], spacing=T.S2, wrap=True,
vertical_alignment=ft.CrossAxisAlignment.CENTER),
], spacing=T.S3, tight=True, visible=False)
self.control = C.card(ft.Column([C.section(tr("Furniture & screens"), self.empty, self.body,
help="room_boxes")], spacing=T.S3),
padding=T.S4, col={"xs": 12, "md": 6, "xl": 4})
# ------------------------------------------------------------------ model
def load(self, model: dict | None) -> None:
"""A new room from the cache (a scan, a clean-up, a save): edits start again from it."""
self.model = model
self.room = copy.deepcopy(model["room"]) if model else None
self.places = [R.plane_place(self.room, p) for p in self.room.planes] if self.room else []
self.sel = None
self.dirty = False
if self.room is not None:
xs = [x for x, _ in self.room.outline]
zs = [z for _, z in self.room.outline]
for attr, lo, hi in (("cx", min(xs) - 1, max(xs) + 1), ("cz", min(zs) - 1, max(zs) + 1),
("top_y", self.room.floor, self.room.floor + 3)):
s = self.box_sliders[attr]
s.min, s.max = round(lo, 2), round(hi, 2)
s.divisions = max(1, round((s.max - s.min) / 0.01))
self.plane_wall.options = [
ft.DropdownOption(key=str(i), text=(tr("Wall {n} ({length} m, unseen)") if w.label ==
"INVISIBLE_WALL_FACE" else tr("Wall {n} ({length} m)")).format(
n=i + 1, length=f"{((w.x1 - w.x0) ** 2 + (w.z1 - w.z0) ** 2) ** 0.5:.1f}"))
for i, w in enumerate(self.room.walls)]
self._lists()
self._panels()
def scene(self):
"""The 3D scene with the edited furniture (None while there's no room)."""
if self.model is None or self.room is None:
return None
return view3d.with_furniture(self.model["scene"], self.room, self.sel)
def _redraw(self) -> None:
canvas = self.view.canvas
if canvas is not None and self.model is not None:
canvas.set_scene(self.scene(), fit=False)
def _changed(self, structure: bool = False) -> None:
self.dirty = True
if structure:
self._lists()
self._redraw()
C.update(*self._save_state(), *([self.box_list, self.plane_list] if structure else []))
# ------------------------------------------------------------------ lists
@staticmethod
def _box_size(v) -> str:
return size_text(v.w, v.d, v.h)
def _plane_info(self, i: int) -> str:
p = self.room.planes[i]
return tr("wall {n} · {w}×{h} m").format(n=self.places[i]["wall"] + 1, w=f"{p.w:.2f}", h=f"{p.h:.2f}")
def _item(self, kind: str, i: int, name: str, info: str, color: str) -> ft.Container:
on = self.sel == (kind, i)
text = ft.Text(name, size=T.T_BODY, color=T.TEXT if on else T.TEXT_2, weight=ft.FontWeight.W_600)
box = ft.Container(ft.Row([ft.Container(width=T.px(10), height=T.px(10), bgcolor=color,
border_radius=T.px(3)),
text, ft.Container(expand=True), C.meta(info)], spacing=T.S2,
vertical_alignment=ft.CrossAxisAlignment.CENTER),
padding=ft.Padding(T.S3, T.px(6), T.S3, T.px(6)), border_radius=T.RADIUS_XS,
on_click=lambda e: self.select(None if self.sel == (kind, i) else (kind, i)))
C.selected_style(box, on, text=text)
return C.hoverable(box)
def _lists(self) -> None:
room = self.room
self.box_list.controls = [self._item("box", i, _furniture_name(v.label), self._box_size(v), T.ACCENT)
for i, v in enumerate(room.volumes)] if room else []
if room and not room.volumes:
self.box_list.controls = [C.meta(tr("No furniture found"))]
self.plane_list.controls = [self._item("plane", i, _furniture_name(p.label), self._plane_info(i), T.OK)
for i, p in enumerate(room.planes)] if room else []
# ------------------------------------------------------------------ panels
def _panels(self) -> list[ft.Control]:
room, sel = self.room, self.sel
box = room.volumes[sel[1]] if room and sel and sel[0] == "box" and sel[1] < len(room.volumes) else None
plane = room.planes[sel[1]] if room and sel and sel[0] == "plane" and sel[1] < len(room.planes) else None
self.box_panel.visible = box is not None
self.plane_panel.visible = plane is not None
if box is not None:
self.box_type.value = box.label if box.label in R.VOLUME_LABELS else "OTHER"
for attr, *_rest, unit in BOX_FIELDS:
s, value = self.box_sliders[attr], getattr(box, attr)
if attr == "yaw_deg":
value = (value + 180) % 360 - 180
s.value = min(max(value, s.min), s.max)
self.box_readouts[attr].value = field_text(unit, value)
if plane is not None:
place = self.places[sel[1]]
self.plane_type.value = plane.label if plane.label in R.PLANE_LABELS else "WALL_ART"
self.plane_wall.value = str(place["wall"])
values = {"along": place["along"], "bottom": place["bottom"], "w": plane.w, "h": plane.h}
for attr, *_rest, unit in PLANE_FIELDS:
s = self.plane_sliders[attr]
s.value = min(max(values[attr], s.min), s.max)
self.plane_readouts[attr].value = field_text(unit, values[attr])
return [self.box_panel, self.plane_panel]
def select(self, sel: tuple[str, int] | None) -> None:
self.sel = sel
self._lists()
changed = self._panels()
self._redraw()
C.update(self.box_list, self.plane_list, *changed)
# ------------------------------------------------------------------ edits
def _box(self):
if self.room and self.sel and self.sel[0] == "box" and self.sel[1] < len(self.room.volumes):
return self.room.volumes[self.sel[1]]
return None
def _box_value(self, attr: str, value: float) -> None:
v = self._box()
if v is None:
return
step = next(f[4] for f in BOX_FIELDS if f[0] == attr)
value = round(round(value / step) * step, 4)
setattr(v, attr, value)
unit = next(f[5] for f in BOX_FIELDS if f[0] == attr)
self.box_readouts[attr].value = field_text(unit, value)
row = self.box_list.controls[self.sel[1]] if self.sel[1] < len(self.box_list.controls) else None
if row is not None: # the list row's size, in place (no new clickable control on a slider tick)
row.content.controls[-1].value = self._box_size(v)
self._changed()
C.update(self.box_readouts[attr], *([row] if row is not None else []))
def _box_label(self, label: str) -> None:
v = self._box()
if v is not None and label:
v.label = label
self._changed(structure=True)
def _plane_index(self) -> int | None:
if self.room and self.sel and self.sel[0] == "plane" and self.sel[1] < len(self.room.planes):
return self.sel[1]
return None
def _rebuild_plane(self, i: int) -> None:
p, place = self.room.planes[i], self.places[i]
self.room.planes[i] = R.plane_on_wall(self.room, place["wall"], place["along"], place["bottom"], p.w, p.h,
p.label, p.uuid)
def _plane_value(self, attr: str, value: float) -> None:
i = self._plane_index()
if i is None:
return
value = round(round(value / 0.01) * 0.01, 4)
if attr in ("along", "bottom"):
self.places[i][attr] = value
else:
setattr(self.room.planes[i], attr, value)
self._rebuild_plane(i)
unit = next(f[5] for f in PLANE_FIELDS if f[0] == attr)
self.plane_readouts[attr].value = field_text(unit, value)
row = self.plane_list.controls[i] if i < len(self.plane_list.controls) else None
if row is not None:
row.content.controls[-1].value = self._plane_info(i)
self._changed()
C.update(self.plane_readouts[attr], *([row] if row is not None else []))
def _plane_label(self, label: str) -> None:
i = self._plane_index()
if i is not None and label:
self.room.planes[i].label = label
self._changed(structure=True)
def _plane_wall(self, value: str) -> None:
i = self._plane_index()
if i is not None and value is not None:
self.places[i]["wall"] = int(value)
self._rebuild_plane(i)
self._changed(structure=True)
def _delete(self, e=None) -> None:
if not self.room or not self.sel:
return
kind, i = self.sel
if kind == "box" and i < len(self.room.volumes):
del self.room.volumes[i]
elif kind == "plane" and i < len(self.room.planes):
del self.room.planes[i]
del self.places[i]
self.sel = None
self._changed(structure=True)
C.update(*self._panels())
def _add_box(self, e=None) -> None:
if self.room is None:
return
self.room.volumes.append(R.default_volume(self.room))
self._select_new(("box", len(self.room.volumes) - 1))
def _add_plane(self, label: str) -> None:
if self.room is None:
return
plane = R.default_plane(self.room, label)
self.room.planes.append(plane)
self.places.append(R.plane_place(self.room, plane))
self._select_new(("plane", len(self.room.planes) - 1))
def accept(self, suggestion: dict, kind: str) -> None:
"""A label-rules suggestion taken as a door (on its wall) or a closet (a storage box against it)."""
if self.room is None:
return
made = R.accept_suggestion(self.room, suggestion, kind)
if isinstance(made, R.Plane):
self.places.append(R.plane_place(self.room, made))
self._select_new(("plane", len(self.room.planes) - 1))
else:
self._select_new(("box", len(self.room.volumes) - 1))
self.view.app.toast(tr("Added. Check it in the view, then Save."))
def _select_new(self, sel) -> None:
self.dirty = True
self.select(sel)
C.update(*self._save_state())
def discard(self, e=None) -> None:
self.load(self.model)
self._redraw()
C.update(self.box_list, self.plane_list, self.box_panel, self.plane_panel, *self._save_state())
def save(self, e=None) -> None:
if self.room is None or self.model is None:
return
RS.save_room(self.view.app, copy.deepcopy(self.room), self.model.get("mesh"))
# ------------------------------------------------------------------ cache -> controls
def _save_state(self) -> list[ft.Control]:
working = (getattr(self.view.app, "_room_state", None) or {}).get("working")
self.save_btn.disabled = not self.dirty or bool(working)
self.discard_btn.visible = self.dirty
self.save_text.value = (tr("Saving…") if working == "save" else
tr("Not saved yet") if self.dirty else tr("No changes to save"))
return [self.save_btn, self.discard_btn, self.save_text]
def refresh(self, cached: dict, status: dict, room: dict | None) -> list[ft.Control]:
model = cached.get("model")
has = model is not None and room is not None
self.body.visible = has
self.empty.visible = not has
self.empty.value = tr("Loading the room…") if room and model is None else tr("No room saved yet")
return [self.body, self.empty, *self._save_state()]
+215
View File
@@ -0,0 +1,215 @@
"""Scan clean-up on a temporary fake Frame: refine.json, the options every build passes, room_refine (a fake
fp_roomscan script writes the scanner interface's files) and the player's own furniture kept over a new clean-up."""
import json
import os
import sys
from pathlib import Path
import pytest
from test_agent import load_agent
from frameport.install.rooms import REFINE_DEFAULTS
from frameport.mr.room import Mesh, Volume, box_room, dumps, parse, write_mesh
# A stand-in for fp_roomscan (scanner interface of docs/mr): --offline <dump> --out <dir> builds a room; --refine-only
# <dir> cleans one up again from room-raw.txt (an old room without raw files: room.txt becomes the raw one). It writes
# room.txt + room-mesh.bin (refined), room-raw.txt + room-mesh-raw.bin, scan.json, and logs its arguments.
FAKE_SCANNER = r'''#!PYTHON
import json, os, shutil, struct, sys, uuid
argv = sys.argv[1:]
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "scanner-calls.jsonl"), "a") as log:
log.write(json.dumps(sys.argv) + "\n")
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
if os.environ.get("FAKE_SCANNER_FAIL"):
sys.stderr.write("boom")
sys.exit(3)
mesh = (struct.pack("<4sIII", b"FPRM", 1, 3, 3) + struct.pack("<9f", 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ struct.pack("<3I", 0, 1, 2))
if "--offline" in argv:
out = opt("--out")
universe = opt("--universe")
walls = ("wall {} -2 -1.5 2 -1.5 0 2.5\nwall {} 2 -1.5 2 1.5 0 2.5\n"
"wall {} 2 1.5 -2 1.5 0 2.5\nwall {} -2 1.5 -2 -1.5 0 2.5\n")
raw = ("frameport-room 1\nuniverse " + universe + "\nsource scan\ntime 100\nroom " + uuid.uuid4().hex +
"\nfloor " + uuid.uuid4().hex + " 0\nceiling " + uuid.uuid4().hex + " 2.5\n" +
walls.format(*(uuid.uuid4().hex for _ in range(4))) +
"volume " + uuid.uuid4().hex + " COUCH 0 1 0.75 0 1.5 0.6 0.75\n"
"mesh " + uuid.uuid4().hex + " room-mesh.bin 3 3\n")
open(os.path.join(out, "room-raw.txt"), "w").write(raw.replace("room-mesh.bin", "room-mesh-raw.bin"))
open(os.path.join(out, "room-mesh-raw.bin"), "wb").write(mesh)
src = raw
folder = out
else:
folder = opt("--refine-only")
if not os.path.exists(os.path.join(folder, "room-raw.txt")):
text = open(os.path.join(folder, "room.txt")).read()
open(os.path.join(folder, "room-raw.txt"), "w").write(text.replace("room-mesh.bin", "room-mesh-raw.bin"))
shutil.copy(os.path.join(folder, "room-mesh.bin"), os.path.join(folder, "room-mesh-raw.bin"))
src = open(os.path.join(folder, "room-raw.txt")).read().replace("room-mesh-raw.bin", "room-mesh.bin")
# the label rules: the couch is really a table (--label-rules 0 keeps the scanner's label)
rules = opt("--label-rules", "1") == "1"
refined = src.replace(" COUCH ", " TABLE ") if rules else src
open(os.path.join(folder, "room-mesh.bin"), "wb").write(mesh)
open(os.path.join(folder, "room.txt"), "w").write(refined)
settings = {"walls": opt("--walls", "panels"), "smooth": int(opt("--smooth", "8"))}
json.dump({"version": 1, "heads": [[0, 1.6, 0], [0.5, 1.6, 0]], "refine": settings,
"report": {"triangles": 1, "kept": 1, "walked": 0},
"labels": [{"from": "COUCH", "to": "TABLE" if rules else "COUCH", "reason": "top 0.75 m: a table",
"box": {"cx": 0, "cz": 1, "top_y": 0.75, "yaw_deg": 0, "w": 1.5, "d": 0.6, "h": 0.75}}],
"suggestions": [{"kind": "door", "box": {"cx": 2, "cz": 0, "top_y": 2, "yaw_deg": 90, "w": 0.9, "d": 0.1,
"h": 2}, "wall": 1, "along": 0.5}]},
open(os.path.join(folder, "scan.json"), "w"))
print(json.dumps({"triangles": 1, "refine": {"kept": 1}}))
'''
def scanner(a, monkeypatch):
path = Path(a.ROOM_SCANNER)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(FAKE_SCANNER.replace("PYTHON", sys.executable))
path.chmod(0o755)
monkeypatch.setattr(a, "room_current_universe", lambda: 7)
return path.parent / "scanner-calls.jsonl"
def calls(log):
return [json.loads(line) for line in log.read_text().splitlines()]
def test_defaults_match_the_pc_and_settings_validate(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
assert a.ROOM_REFINE_DEFAULTS == {k: m["default"] for k, m in REFINE_DEFAULTS.items()}
assert set(a.ROOM_REFINE_LIMITS) | {"walls"} == set(REFINE_DEFAULTS)
for key, meta in REFINE_DEFAULTS.items(): # every slider value the PC offers is accepted
if meta["kind"] == "number":
lo, hi = a.ROOM_REFINE_LIMITS[key]
assert lo <= meta["min"] and meta["max"] <= hi, key
assert a.room_refine_settings({"band": 0.35, "walls": "snap", "smooth": 4.0, "protect": True}) == \
{"walls": "snap", "smooth": 4, "protect": 1}
for bad in ({"nope": 1}, {"walls": "maybe"}, {"band": float("nan")}, {"smooth": 2.5}, {"band": 9},
{"island": "big"}):
with pytest.raises(a.AgentError):
a.room_refine_settings(bad)
assert a.room_refine_args({"walls": "snap", "facing_deg": 45.0, "clip_open": 0.2}) == \
["--walls", "snap", "--facing-deg", "45", "--clip-open", "0.2"]
assert a.room_refine_args({}, [[0, 1, 2.5, 0.25], {"x": 1, "y": 0, "z": 0, "r": 0.1}])[-2:] == \
["--erase", "0,1,2.5,0.25;1,0,0,0.1"]
for bad in ([[0, 1, 2]], [[0, 1, 2, 0]], [["a", 1, 2, 3]]):
with pytest.raises(a.AgentError, match="erase"):
a.room_refine_args({}, bad)
assert a.room_refine_load() == {} # no file / a broken one = the defaults
Path(a.ROOMS_DIR).mkdir(parents=True)
Path(a.ROOM_REFINE_FILE).write_text("{broken")
assert a.room_refine_load() == {}
def test_build_passes_settings_and_keeps_raw_files(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
log = scanner(a, monkeypatch)
Path(a.ROOMS_DIR).mkdir(parents=True)
dump = Path(a.ROOMS_DIR, "scan.fprs")
dump.write_bytes(b"FPRS")
status = a.cmd_room_build({"dump": str(dump)}) # nothing saved: no clean-up options (an old scanner works)
argv = calls(log)[-1]
assert argv[:3] == [a.ROOM_SCANNER, "--offline", str(dump)] and argv[argv.index("--universe") + 1] == "7"
assert "--walls" not in argv and argv[-1] == "--json"
folder = Path(a.ROOMS_DIR, "7")
assert {p.name for p in folder.iterdir()} >= {"room.txt", "room-mesh.bin", "room-raw.txt", "room-mesh-raw.bin",
"scan.json"}
assert status["report"] == {"triangles": 1, "refine": {"kept": 1}}
assert status["labels"][0]["to"] == "TABLE" and status["suggestions"][0]["kind"] == "door"
assert status["refine"] == {} and status["raw"] and not status["edited"]
assert status["room"]["summary"]["volumes"] == {"TABLE": 1}
a._write_json(a.ROOM_REFINE_FILE, {"walls": "snap", "smooth": 3})
a.cmd_room_build({"dump": str(dump)})
argv = calls(log)[-1]
assert argv[argv.index("--walls") + 1] == "snap" and argv[argv.index("--smooth") + 1] == "3"
def test_refine_saves_settings_republishes_and_keeps_edits(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
log = scanner(a, monkeypatch)
with pytest.raises(a.AgentError, match="No room"):
a.cmd_room_refine({})
# an old room (no raw files): the scanner takes room.txt as the raw one
r = box_room(4, 3)
r.universe = 7
r.source = "scan"
r.volumes = [Volume("a" * 32, "COUCH", 0, 1, 0.75, 0, 1.5, 0.6, 0.75)]
m = Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2])
r.mesh = Mesh(vertex_count=3, index_count=3)
a.room_store(dumps(r), write_mesh(m), "old scan log")
status = a.cmd_room_refine({"settings": {"walls": "snap", "band": 0.35, "label_rules": 0}})
assert json.loads(Path(a.ROOM_REFINE_FILE).read_text()) == {"walls": "snap", "label_rules": 0}
argv = calls(log)[-1]
assert argv[:2] == [a.ROOM_SCANNER, "--refine-only"] and argv[-1] == "--json"
assert argv[argv.index("--walls") + 1] == "snap" and "--band" not in argv
assert status["summary"] == {"triangles": 1, "refine": {"kept": 1}}
assert status["refine"] == {"walls": "snap", "label_rules": 0} and status["raw"]
assert status["room"]["summary"]["volumes"] == {"COUCH": 1} # label rules off
folder = Path(a.ROOMS_DIR, "7")
assert (folder / "scan.log").read_text() == "old scan log" and (folder / "room-raw.txt").is_file()
assert not [n for n in os.listdir(a.ROOMS_DIR) if n.startswith(".refine-")] # no stage left behind
# null settings keep the saved ones; erase spots go to the scanner
a.cmd_room_refine({"settings": None, "erase": [[0, 1, 0, 0.3]]})
argv = calls(log)[-1]
assert argv[argv.index("--walls") + 1] == "snap" and argv[argv.index("--erase") + 1] == "0,1,0,0.3"
# the Room tab's box editor: an edited room keeps the raw files and its edits survive a new clean-up
edited = parse(status["room_text"])
edited.volumes = [Volume("b" * 32, "BED", 1, 0, 0.5, 0, 2, 1.4, 0.5)]
incoming = Path(a.ROOMS_DIR, ".incoming-x")
incoming.mkdir()
(incoming / "room.txt").write_text(dumps(edited))
(incoming / "room-mesh.bin").write_bytes(write_mesh(m))
a.cmd_room_put({"path": str(incoming / "room.txt"), "edited": True})
assert (folder / "edits.txt").read_text().startswith("volume " + "b" * 32 + " BED")
assert (folder / "room-raw.txt").is_file() and (folder / "scan.json").is_file()
status = a.cmd_room_refine({"settings": {}})
assert status["edited"] and status["room"]["summary"]["volumes"] == {"BED": 1}
assert status["refine"] == {} and json.loads(Path(a.ROOM_REFINE_FILE).read_text()) == {}
status = a.cmd_room_refine({"keep_edits": False}) # back to the scan's boxes
assert not status["edited"] and status["room"]["summary"]["volumes"] == {"TABLE": 1}
# a plain import (another room) doesn't keep this one's raw files
a.cmd_room_put({"path": str(incoming / "room.txt")})
assert not (folder / "room-raw.txt").exists() and not a.cmd_room_status({})["raw"]
def test_refine_failures_leave_the_room(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
scanner(a, monkeypatch)
r = box_room(4, 3)
r.universe = 7
text = dumps(r)
a.room_store(text)
with pytest.raises(a.AgentError, match="unknown"):
a.cmd_room_refine({"settings": {"bogus": 1}})
assert not Path(a.ROOM_REFINE_FILE).exists()
monkeypatch.setenv("FAKE_SCANNER_FAIL", "1")
with pytest.raises(a.AgentError, match="clean-up failed: boom"):
a.cmd_room_refine({})
assert Path(a.ROOMS_DIR, "7", "room.txt").read_text() == text
monkeypatch.delenv("FAKE_SCANNER_FAIL")
import fcntl
with open(os.path.join(a.ROOMS_DIR, ".scan.lock"), "a") as lock: # a scan is building a room
fcntl.flock(lock, fcntl.LOCK_EX)
with pytest.raises(a.AgentError, match="scan is running"):
a.cmd_room_refine({})
with pytest.raises(a.AgentError, match="unexpected"):
a.room_store(text, extras={"../evil": b""})
Path(a.ROOM_SCANNER).unlink()
with pytest.raises(a.AgentError, match="not installed"):
a.cmd_room_refine({})
def test_status_reads_scan_json_defensively(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a, "room_current_universe", lambda: 7)
r = box_room(4, 3)
r.universe = 7
info = b'{"report": [1], "labels": {"x": 1}, "suggestions": [{"kind": "door"}]}'
a.room_store(dumps(r), extras={"scan.json": info})
st = a.cmd_room_status({})
assert st["report"] is None and st["labels"] == [] and st["suggestions"] == [{"kind": "door"}]
a.room_store(dumps(r), extras={"scan.json": b"[]"})
assert a.cmd_room_status({})["labels"] == []
+381
View File
@@ -0,0 +1,381 @@
"""Room scan clean-up and editing on the PC: install/rooms settings + refine/put_room, the mr/room editing helpers,
view3d's furniture overlay and picking, and the Room tab's Clean-up / Labels / Furniture cards (fake Frame)."""
import io
import math
from pathlib import Path
from types import SimpleNamespace
import pytest
from frameport.install import rooms
from frameport.mr import view3d
from frameport.mr.room import (
Mesh,
Volume,
accept_suggestion,
box_room,
default_plane,
dumps,
nearest_wall,
parse,
plane_corners,
plane_on_wall,
plane_place,
summary,
write_mesh,
)
# ------------------------------------------------------------------------------------------------ settings
def test_refine_defaults_are_the_owners_and_normalize():
d = rooms.refine_defaults()
assert d == {"refine": 1, "walls": "panels", "walked": 0.4, "smooth": 8, "island": 0.2, "clip": 0.05,
"clip_open": 0.1, "band": 0.35, "band_in": 0.12, "floor_in": 0.08, "ceil_in": 0.25,
"top_strip": 0.5, "facing_deg": 60, "near": 0.03, "tubes": 3.0, "tube_thick": 0.15, "tops": 0.1,
"protect": 0, "label_rules": 1}
for key, meta in rooms.REFINE_DEFAULTS.items():
assert meta["flag"] == "--" + key.replace("_", "-") and meta["help"].startswith("refine_")
if meta["kind"] == "number":
assert meta["min"] <= meta["default"] <= meta["max"], key
assert {k for k, m in rooms.REFINE_DEFAULTS.items() if m["key"]} == {"walls", "walked", "smooth", "island",
"clip"}
assert rooms.normalize_refine(d) == {}
assert rooms.normalize_refine({"band": 0.351, "smooth": "4", "protect": "on", "walls": "snap", "clip": 7}) == \
{"smooth": 4, "protect": 1, "walls": "snap", "clip": 1.0} # 0.351 snaps to the default 0.35
assert rooms.normalize_refine({"band": 0.356}) == {"band": 0.36}
for bad in ({"walls": "flat"}, {"nope": 1}, {"band": "x"}):
with pytest.raises(ValueError):
rooms.normalize_refine(bad)
assert rooms.current_refine({"refine": {"smooth": 2}})["smooth"] == 2
assert rooms.current_refine(None) == d
class Frame:
home = "/fake-frame"
def __init__(self):
self.files, self.calls, self.answer = {}, [], {"room_text": "x"}
self.sftp = SimpleNamespace(open=lambda path, mode: io.BytesIO(self.files[path]))
def run(self, cmd):
return 0, "", ""
def put(self, local, remote, **kwargs):
self.files[remote] = Path(local).read_bytes()
def agent(self, command, **args):
self.calls.append((command, args))
return self.answer
def test_refine_and_put_room_go_to_the_agent(monkeypatch):
f = Frame()
synced = []
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: synced.append(1))
rooms.refine(f, {"walls": "snap", "band": 0.35}, erase=[(0, 1, 0, 0.2)])
assert synced and f.calls[-1] == ("room_refine", {"settings": {"walls": "snap"}, "keep_edits": True,
"erase": [[0.0, 1.0, 0.0, 0.2]]})
rooms.refine(f, None, keep_edits=False)
assert f.calls[-1] == ("room_refine", {"settings": None, "keep_edits": False})
f.answer = {"error": "Room clean-up failed: boom"}
with pytest.raises(RuntimeError, match="boom"):
rooms.refine(f, {})
# an edited room: the mesh comes from the Frame when not given, room_put says it's an edit
f.answer = {"room": {}}
r = box_room(4, 3)
r.universe = 7
m = write_mesh(Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2]))
r.mesh = Mesh(vertex_count=3, index_count=3)
f.files["/fake-frame/.local/share/frameport/rooms/7/room-mesh.bin"] = m
r.volumes.append(Volume("c" * 32, "BED", 0, 0, 0.5, 0, 2, 1.4, 0.5))
rooms.put_room(f, r)
command, args = f.calls[-1]
assert command == "room_put" and args["edited"] and args["path"].endswith("/room.txt")
uploaded = {k.rsplit("/", 1)[1]: v for k, v in f.files.items() if "/.incoming-" in k}
assert parse(uploaded["room.txt"].decode()).volumes[0].label == "BED" and uploaded["room-mesh.bin"] == m
with pytest.raises(ValueError, match="changed"):
rooms.put_room(f, r, write_mesh(Mesh(vertices=[(0, 0, 0)] * 4, indices=[0, 1, 2])))
def test_cli_room_refine(monkeypatch):
from typer.testing import CliRunner
from frameport import cli
f = Frame()
f.answer = {"refine": {"smooth": 4}, "report": {"kept": 1}, "room_text": "x"}
monkeypatch.setattr(cli, "_target", lambda frame: SimpleNamespace(frame=f))
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: None)
runner = CliRunner()
result = runner.invoke(cli.app, ["frame", "room", "refine", "--set", "walls=snap", "--set", "island=0.5"])
assert result.exit_code == 0, result.output
assert f.calls[-1] == ("room_refine", {"settings": {"smooth": 4, "walls": "snap", "island": 0.5},
"keep_edits": True})
assert '"kept": 1' in result.output
result = runner.invoke(cli.app, ["frame", "room", "refine", "--reset", "--scan-boxes"])
assert f.calls[-1] == ("room_refine", {"settings": {}, "keep_edits": False})
result = runner.invoke(cli.app, ["frame", "room", "refine"])
assert f.calls[-1][1]["settings"] is None
result = runner.invoke(cli.app, ["frame", "room", "refine", "--set", "walls=flat"])
assert result.exit_code != 0
result = runner.invoke(cli.app, ["frame", "room", "refine", "--list"])
assert result.exit_code == 0 and "facing_deg" in result.output and "Where you walked" in result.output
def test_report_follows_the_scanner_keys():
from frameport.ui.views.room_edit import report_notes, report_rows
report = {"triangles": 1000, "kept": 800, "structural": 300, "near_walls": 5, "outside": 40, "small_pieces": 30,
"thin_pieces": 2, "erased": 0, "walked": 20, "tops": 9, "panel_triangles": 12, "truncated": 0,
"walked_skipped": True, "floor_y": -0.01, "ceiling_y": 2.5, "boxes": 6, "relabelled": 2,
"removed_boxes": 1, "suggestions": 1, "plane_shifts": [{"kind": "wall", "wall": 0, "shift": 0.02}]}
rows = dict(report_rows(report))
assert rows["Wall, floor and ceiling surfaces"] == "300" and rows["Cleared near walls"] == "5"
assert rows["Small pieces"] == "30" and rows["Thin floating pieces"] == "2"
assert rows["Flat panel triangles"] == "12" and rows["Boxes renamed"] == "2" and rows["Kept"] == "800"
assert not {"Floor y", "Ceiling y", "Walked skipped", "Truncated", "Plane shifts"} & set(rows)
notes, warning = report_notes(report)
assert notes == ["Where you walked: skipped, this scan has no head path"] and warning == ""
assert "1,234" in report_notes({"truncated": 1234})[1]
summary = dict(report_rows({"triangles": 9, "refine": {"output_triangles": 7, "output_volumes": 3}}))
assert summary["Triangles in the room"] == "7" and summary["Furniture boxes in the room"] == "3"
# ------------------------------------------------------------------------------------------------ editing helpers
def test_planes_on_walls_follow_the_cleanup_page():
room = box_room(4, 3) # walls run counter-clockwise; their inward normal is (-uz, ux)
w = room.walls[0]
p = plane_on_wall(room, 0, 0.5, 0.0, 0.9, 2.05)
ux, uz = (w.x1 - w.x0) / 4, (w.z1 - w.z0) / 4
assert p.label == "DOOR_FRAME" and math.isclose(p.py, 2.05 / 2)
mid = ((w.x0 + w.x1) / 2, (w.z0 + w.z1) / 2)
assert math.isclose(p.px, mid[0] - uz * 0.01, abs_tol=1e-9) and math.isclose(p.pz, mid[1] + ux * 0.01, abs_tol=1e-9)
# the quaternion turns +X onto the wall and +Z into the room
th = 2 * math.atan2(p.qy, p.qw)
assert math.isclose(math.cos(th), ux, abs_tol=1e-9) and math.isclose(-math.sin(th), uz, abs_tol=1e-9)
c = plane_corners(p)
assert math.isclose(math.dist(c[0], c[1]), 0.9) and math.isclose(c[3][1] - c[0][1], 2.05)
edge = plane_on_wall(room, 0, 0.0, 0, 0.9, 2) # kept fully on the wall
assert math.isclose(math.dist((edge.px, edge.pz), (w.x0, w.z0)), math.hypot(0.45, 0.01), rel_tol=1e-6)
place = plane_place(room, p)
assert place["wall"] == 0 and math.isclose(place["along"], 0.5, abs_tol=0.01) and math.isclose(place["bottom"], 0)
window = default_plane(room, "WINDOW_FRAME")
assert window.label == "WINDOW_FRAME" and math.isclose(plane_place(room, window)["bottom"], 0.9)
near = nearest_wall(room, 0, -1.4)
assert near["d"] < 0.11
room.planes.append(p)
assert parse(dumps(room)).planes[0].label == "DOOR_FRAME"
def test_suggestions_become_doors_or_closets():
room = box_room(4, 3)
sug = {"kind": "door", "box": {"cx": 1.95, "cz": 0, "top_y": 2.0, "yaw_deg": 90, "w": 1.0, "d": 0.1, "h": 2.0},
"wall": 1, "along": 0.5}
door = accept_suggestion(room, sug, "door")
assert room.planes == [door] and door.label == "DOOR_FRAME" and door.w == 1.0 and math.isclose(door.h, 2.0)
assert plane_place(room, door)["wall"] == 1
closet = accept_suggestion(room, dict(sug, wall=None), "closet")
assert room.volumes == [closet] and closet.label == "STORAGE" and closet.d == 0.6 and closet.h == 2.0
corners = view3d.box_corners(closet.cx, closet.cz, closet.yaw_deg, closet.w, closet.d)
assert max(x for x, _ in corners) <= 2.0 + 1e-6 # against the wall, inside the room
assert parse(dumps(room)).volumes[0].label == "STORAGE"
def test_with_furniture_and_pick():
room = box_room(4, 3)
room.volumes.append(Volume("1" * 32, "TABLE", 0, 0, 0.75, 0, 1, 1, 0.75))
scene = view3d.prepare(room)
cam = view3d.Camera(width=800, height=480)
cam.fit(scene)
x, y = cam.project([(0, 0.75, 0)])[0]
assert view3d.pick(scene, cam, x, y) == ("box", 0)
assert view3d.pick(scene, cam, 2, 2) is None
edited = box_room(4, 3)
edited.volumes = [Volume("1" * 32, "BED", 1, 0.5, 0.5, 30, 2, 1.4, 0.5)]
edited.planes = [plane_on_wall(edited, 0, 0.5, 0, 0.9, 2.05)]
sc = view3d.with_furniture(scene, edited, ("plane", 0))
assert sc is not scene and len([f for f in scene.faces if f.kind == "box"]) == 6 # the cache isn't touched
assert [f.index for f in sc.faces if f.kind == "box"] == [0] * 6 and len(sc.planes) == 1
assert len(sc.selected) == 4
frame = view3d.render(sc, cam)
assert frame.planes and frame.selected
px, py = cam.project([(edited.planes[0].px, edited.planes[0].py, edited.planes[0].pz)])[0]
assert view3d.pick(sc, cam, px, py) == ("plane", 0)
# ------------------------------------------------------------------------------------------------ the Room tab
class RefineFrame:
"""A Frame with a scanned room, its clean-up results, room_refine / room_put and the mesh over SFTP."""
def __init__(self, tmp_path):
self.home = str(tmp_path / "frame")
self.room = box_room(4, 3)
self.room.source, self.room.universe = "scan", 7
self.room.volumes = [Volume("1" * 32, "TABLE", 0, 0, 0.75, 0, 1.2, 0.6, 0.75),
Volume("2" * 32, "SCREEN", 0, -1.4, 1.4, 0, 1.2, 0.1, 0.7)]
self.mesh = write_mesh(Mesh(vertices=[(-1, 0, -1), (1, 0, -1), (1, 0, 1)], indices=[0, 2, 1]))
self.room.mesh = Mesh(vertex_count=3, index_count=3)
self.folder = tmp_path / "frame/.local/share/frameport/rooms/7"
self.folder.mkdir(parents=True)
(self.folder / "room-mesh.bin").write_bytes(self.mesh)
self.saved, self.calls, self.files = {}, [], {}
self.report = {"triangles": 1200, "kept": 900, "walked": 40}
def agent(self, command, **kw):
self.calls.append((command, kw))
if command == "room_refine":
if kw.get("settings") is not None:
self.saved = kw["settings"]
self.room.time += 1
self.report = {"triangles": 1200, "kept": 850, "walked": 60}
if command == "room_put":
self.room = parse(Path(kw["path"]).read_text())
self.edited = True
return dict(room_text=dumps(self.room), room=dict(source=self.room.source, summary=summary(self.room)),
aligned=True, scanner_installed=True, default_circle=False, refine=dict(self.saved),
report=self.report, edited=getattr(self, "edited", False),
labels=[{"from": "COUCH", "to": "TABLE", "reason": "top at 0.75 m: a table",
"box": {"w": 1.2, "d": 0.6, "h": 0.75}},
{"from": "TABLE", "to": None, "reason": "behind a wall", "box": {}}],
suggestions=[{"kind": "closet", "box": {"cx": 1.9, "cz": 0.5, "top_y": 2.0, "yaw_deg": 90,
"w": 1.0, "d": 0.3, "h": 2.0}, "wall": 1,
"along": 0.6}])
def run(self, cmd):
return 0, "", ""
def put(self, local, remote, **kw): # uploads land in a local folder (the agent reads room.txt from there)
Path(remote).parent.mkdir(parents=True, exist_ok=True)
Path(remote).write_bytes(Path(local).read_bytes())
@property
def sftp(self):
class SFTP:
def open(self, path, mode="rb"):
return open(path, mode) # noqa: SIM115
return SFTP()
def refine_app(frame):
from test_ui_rooms import sync_app
app = sync_app(frame)
jobs = []
def submit(title, run, *a, **kw):
job = SimpleNamespace(title=title, reporter=SimpleNamespace(stage=lambda *a: None))
jobs.append(title)
job.result = run(job)
return job
app.submit, app.jobs_run = submit, jobs
return app
def test_room_tab_cleanup_labels_and_editor(tmp_path, monkeypatch):
from frameport.ui.views.room import RoomView
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: None)
monkeypatch.setattr(rooms, "_upload_dir", lambda frame: _local_dir(tmp_path))
frame = RefineFrame(tmp_path)
app = refine_app(frame)
view = RoomView(app)
view.mount()
cleanup, labels, editor = view.cleanup, view.labels, view.editor
# clean-up: the defaults, the key settings in sight, the rest under More settings
assert cleanup.preset.data == "recommended" and cleanup.walls.data == "panels" and not cleanup.more.visible
assert cleanup.readouts["walked"].value == "40 cm" and cleanup.readouts["island"].value == "0.20 m²"
assert cleanup.readouts["smooth"].value == "8×" and not cleanup.apply_btn.disabled
assert cleanup.result_box.visible and any("1,200" in r.controls[1].value for r in cleanup.result.controls)
cleanup.toggle_more()
assert cleanup.more.visible and set(cleanup.sliders) == {k for k, m in rooms.REFINE_DEFAULTS.items()
if m["kind"] == "number"}
cleanup._slid("smooth", 4.2)
cleanup.set("walls", "snap")
assert cleanup.preset.data == "mine" and cleanup.sliders["near"].disabled
cleanup._preset("recommended")
assert cleanup.values == rooms.refine_defaults()
cleanup._preset("mine")
assert cleanup.values["smooth"] == 4 and cleanup.values["walls"] == "snap"
cleanup.apply()
command, args = [c for c in frame.calls if c[0] == "room_refine"][-1]
assert command == "room_refine" and args["settings"] == {"smooth": 4, "walls": "snap"}
assert app.jobs_run == ["Clean up the room scan"] and not app._room_state["working"]
view.refresh(update=False)
assert cleanup.values["smooth"] == 4 and any("850" in r.controls[1].value for r in cleanup.result.controls)
# labels: the scanner's label -> the rule's, the reason, and a suggestion to take
assert len(labels.rows.controls) == 2 and len(labels.suggestions.controls) == 1 and not labels.empty.visible
texts = [t.value for t in _walk(labels.rows) if isinstance(getattr(t, "value", None), str)]
assert "Couch" in texts and "→ Table" in texts and "removed" in texts and "behind a wall" in texts
# the box editor: select, change, add a door, take the closet suggestion, save
assert len(editor.box_list.controls) == 2 and not editor.box_panel.visible
editor.select(("box", 0))
assert editor.box_panel.visible and editor.box_type.value == "TABLE"
assert editor.box_sliders["cx"].min == -3 and editor.box_sliders["cx"].max == 3
editor._box_value("w", 1.6)
editor._box_label("COUCH")
assert editor.dirty and editor.room.volumes[0].w == 1.6 and editor.room.volumes[0].label == "COUCH"
assert view.canvas.scene.selected # the canvas shows the edited, selected box
editor._add_plane("DOOR_FRAME")
assert editor.plane_panel.visible and editor.plane_wall.value == "0" and len(editor.plane_list.controls) == 1
editor._plane_value("along", 0.25)
editor._plane_wall("2")
p = editor.room.planes[0]
assert plane_place(editor.room, p)["wall"] == 2 and math.isclose(plane_place(editor.room, p)["along"], 0.25,
abs_tol=0.02)
labels.refresh(app._room_state, app._room_state["status"], app._room_state["status"]["room"])
editor.accept(frame.agent("room_status")["suggestions"][0], "closet")
assert editor.room.volumes[-1].label == "STORAGE" and editor.sel == ("box", 2)
editor._delete()
assert len(editor.room.volumes) == 2 and editor.sel is None
assert not editor.save_btn.disabled
editor.save()
command, args = [c for c in frame.calls if c[0] == "room_put"][-1]
assert command == "room_put" and args["edited"]
saved = frame.room
assert [v.label for v in saved.volumes] == ["COUCH", "SCREEN"] and saved.planes[0].label == "DOOR_FRAME"
view.refresh(update=False)
assert not editor.dirty and editor.room.volumes[0].label == "COUCH" # reloaded from the saved room
assert labels.reset_btn.visible # an edited room can go back to the scan's boxes
# a click on the canvas selects what's under it
view.canvas.on_pick(("plane", 0))
assert editor.sel == ("plane", 0)
def test_cleanup_waits_for_a_scanned_room(tmp_path):
from frameport.ui.views.room import RoomView
frame = RefineFrame(tmp_path)
frame.room.source = "boundary"
app = refine_app(frame)
view = RoomView(app)
view.mount()
assert view.cleanup.apply_btn.disabled and "scanned" in view.cleanup.state_text.value
def _walk(control):
yield control
kids = list(getattr(control, "controls", None) or [])
content = getattr(control, "content", None)
if content is not None and not isinstance(content, str):
kids.append(content)
for child in kids:
yield from _walk(child)
class _local_dir:
def __init__(self, tmp_path):
self.path = tmp_path / "frame-upload"
def __enter__(self):
self.path.mkdir(exist_ok=True)
return str(self.path)
def __exit__(self, *a):
return False
def test_box_sizes_keep_thin_sizes_readable():
from frameport.ui.views.room_edit import metres, size_text
assert metres(0.03) == "0.03" and metres(0.07) == "0.07" and metres(0.5) == "0.5" and metres(2.05) == "2.0"
assert size_text(1.6, 1.1, 0.03) == "1.6×1.1×0.03 m"
+241 -8
View File
@@ -54,9 +54,9 @@ def run(scanner, dump, directory, *args, success=True):
return report
def room(directory):
def room(directory, name="room.txt"):
records = {}
lines = (directory / "room.txt").read_text().splitlines()
lines = (directory / name).read_text().splitlines()
assert lines[0] == "frameport-room 1"
for line in lines:
if not line or line.startswith("#"):
@@ -95,8 +95,8 @@ def assert_walls(polygon, expected):
(a[1] + b[1]) / 2 + .04 * dx / length, polygon)
def mesh(directory):
data = (directory / "room-mesh.bin").read_bytes()
def mesh(directory, name="room-mesh.bin"):
data = (directory / name).read_bytes()
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
assert (magic, version) == (b"FPRM", 1)
assert 0 < ni // 3 <= 200_000 and ni % 3 == 0
@@ -115,7 +115,8 @@ def test_box_room_geometry_furniture_and_wire_format(scanner, dumps, tmp_path):
assert report["coverage_percent"] == 100
assert abs(report["floor_y"]) < .05
assert abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path)
# The scanner's own output is kept as the raw files; room.txt + room-mesh.bin are refined (tests further down).
records = room(tmp_path, "room-raw.txt")
assert records["universe"] == [["18446744073709551615"]]
assert records["source"] == [["scan"]]
assert_walls(outline(records), synth.BOX_OUTLINE)
@@ -131,8 +132,12 @@ def test_box_room_geometry_furniture_and_wire_format(scanner, dumps, tmp_path):
ids = [row[0] for kind in ("room", "floor", "ceiling", "wall", "volume", "mesh") for row in records[kind]]
assert len(ids) == len(set(ids))
assert all(re.fullmatch("[0-9a-f]{32}", value) for value in ids)
vertices, indices = mesh(tmp_path)
assert records["mesh"][0][1:] == ["room-mesh.bin", str(len(vertices)), str(len(indices))]
vertices, indices = mesh(tmp_path, "room-mesh-raw.bin")
assert records["mesh"][0][1:] == ["room-mesh-raw.bin", str(len(vertices)), str(len(indices))]
refined = room(tmp_path)
assert [row[0] for row in refined["room"] + refined["floor"] + refined["wall"]] == \
[row[0] for row in records["room"] + records["floor"] + records["wall"]] # same anchors as the raw room
assert refined["mesh"][0][1:3] == ["room-mesh.bin", str(len(mesh(tmp_path)[0]))]
# Normals on the structural surfaces must face the room interior.
correct = checked = 0
for i in range(0, len(indices), 3):
@@ -350,11 +355,14 @@ def test_roomview_style_scan_retains_room_and_furniture(scanner, roomview_dump,
assert report["far_vertices"] > 1000
assert report["detected_walls"] == 4
assert abs(report["floor_y"]) < .05 and abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path)
records = room(tmp_path, "room-raw.txt")
assert_walls(outline(records), synth.BOX_OUTLINE)
assert any(row[1] == "TABLE" and abs(float(row[4]) - .75) < .05 for row in records["volume"])
assert any(row[1] == "COUCH" for row in records["volume"])
mesh(tmp_path, "room-mesh-raw.bin")
mesh(tmp_path)
assert report["heads"] == 28 # the tracked frames' head path, for the walked-volume pass
assert len(json.loads((tmp_path / "scan.json").read_text())["heads"]) == 28
def test_depth_discontinuity_is_rejected_below_absolute_edge_limit(scanner, dumps, tmp_path):
@@ -388,3 +396,228 @@ def test_small_ceiling_patch_does_not_override_default(scanner, dumps, tmp_path)
report = run(scanner, path, tmp_path / "out", "--ceiling-default", "2.7")
assert "ceiling not seen" in report["warnings"]
assert abs(report["ceiling_y"] - report["floor_y"] - 2.7) < .001
# --- Refinement + label rules (docs/mr/scan.md "Refinement") ---------------------------------------------------------
def refine_only(scanner, directory, *args, success=True):
proc = subprocess.run([str(scanner), "--refine-only", str(directory), "--json", *args],
text=True, capture_output=True, timeout=15)
assert proc.returncode == (0 if success else 2), proc.stdout + proc.stderr
report = json.loads(proc.stdout)
assert report["ok"] is success
return report
def centroids(directory, name="room-mesh.bin"):
vertices, indices = mesh(directory, name)
return [[sum(vertices[indices[i + k]][a] for k in range(3)) / 3 for a in range(3)]
for i in range(0, len(indices), 3)]
def in_box(c, box, pad=.06):
(cx, bottom, cz), (w, h, d) = box
return abs(c[0] - cx) <= w / 2 + pad and bottom - pad <= c[1] <= bottom + h + pad and abs(c[2] - cz) <= d / 2 + pad
def masked(text, mesh_name=None):
text = re.sub(r"(?m)^time \d+", "time T", re.sub(r"[0-9a-f]{32}", "U", text))
return text.replace(f" {mesh_name} ", " MESH ") if mesh_name else text
@pytest.fixture(scope="module")
def clutter_dump(tmp_path_factory):
path = tmp_path_factory.mktemp("clutter") / "clutter.fprs"
synth.render(path, profile="legacy", extra=[synth.TUBE, synth.GHOST])
return path
@pytest.fixture(scope="module")
def box_scan(scanner, dumps, tmp_path_factory):
out = tmp_path_factory.mktemp("box-scan") / "room"
return out, run(scanner, dumps["box"], out)
def test_floating_tube_and_walked_through_ghost_are_removed(scanner, clutter_dump, tmp_path):
report = run(scanner, clutter_dump, tmp_path / "default")["refine"]
raw, refined = centroids(tmp_path / "default", "room-mesh-raw.bin"), centroids(tmp_path / "default")
for box in (synth.TUBE, synth.GHOST):
assert sum(in_box(c, box) for c in raw) > 100 # the scanner meshed both
assert not any(in_box(c, box) for c in refined)
assert report["thin_pieces"] > 100 and report["walked"] > 100
# Each piece goes for its own reason: without the walked volume the ghost stays, without tubes the pole stays.
run(scanner, clutter_dump, tmp_path / "unwalked", "--walked", "0")
assert sum(in_box(c, synth.GHOST) for c in centroids(tmp_path / "unwalked")) > 100
run(scanner, clutter_dump, tmp_path / "untubed", "--tubes", "0")
assert sum(in_box(c, synth.TUBE) for c in centroids(tmp_path / "untubed")) > 100
# Real furniture survives: the table top is flattened onto its box, not removed.
assert any(in_box(c, synth.TABLE) and abs(c[1] - .75) < .06 for c in refined)
def test_panels_wind_into_the_room_on_the_reestimated_planes(box_scan):
out, report = box_scan
refine = report["refine"]
assert refine["panel_triangles"] == 12 # four seen walls + floor and ceiling (two ears each)
vertices, indices = mesh(out)
records = room(out)
ceiling = float(records["ceiling"][0][1])
assert abs(ceiling - refine["ceiling_y"]) < 1e-4 and abs(ceiling - 2.6) < .05
assert all(float(w[6]) == ceiling for w in records["wall"]) # wall tops follow the re-estimated ceiling
panels = len(indices) // 3 - refine["panel_triangles"]
for t in range(panels, len(indices) // 3):
a, b, c = (vertices[indices[3 * t + k]] for k in range(3))
u, v = [b[k] - a[k] for k in range(3)], [c[k] - a[k] for k in range(3)]
normal = (u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0])
centroid = [(a[k] + b[k] + c[k]) / 3 for k in range(3)]
assert sum(n * (o - p) for n, o, p in zip(normal, (0, 1.3, 0), centroid, strict=True)) > 0
ys = {round(p[1], 4) for p in (a, b, c)}
assert ys <= {round(refine["floor_y"], 4), round(ceiling, 4)}
def test_refine_only_reproduces_the_scan_and_refine_zero_is_the_old_output(scanner, dumps, box_scan, tmp_path):
out, report = box_scan
copy = tmp_path / "copy"
shutil.copytree(out, copy)
for name in ("room.txt", "room-mesh.bin"):
(copy / name).unlink()
again = refine_only(scanner, copy)
assert again["refine"] == report["refine"]
for name in ("room.txt", "room-mesh.bin", "scan.json", "room-raw.txt", "room-mesh-raw.bin"):
assert (copy / name).read_bytes() == (out / name).read_bytes(), name
# --refine 0 writes what the scanner wrote before refinement existed: its raw output, byte for byte.
plain = tmp_path / "plain"
run(scanner, dumps["box"], plain, "--refine", "0")
assert (plain / "room-mesh.bin").read_bytes() == (out / "room-mesh-raw.bin").read_bytes()
assert masked((plain / "room.txt").read_text(), "room-mesh.bin") == \
masked((out / "room-raw.txt").read_text(), "room-mesh-raw.bin")
# ... and switching refinement back on later gives the default output again (same anchors as its own raw room).
refine_only(scanner, plain)
assert (plain / "room-mesh.bin").read_bytes() == (out / "room-mesh.bin").read_bytes()
assert masked((plain / "room.txt").read_text()) == masked((out / "room.txt").read_text())
def test_scan_json_holds_settings_report_and_heads(box_scan):
out, report = box_scan
scan = json.loads((out / "scan.json").read_text())
assert scan["version"] == 1 and len(scan["heads"]) == report["heads"] > 0
assert all(len(h) == 3 for h in scan["heads"])
assert scan["refine"] == {"refine": 1, "walls": "panels", "band": .35, "facing_deg": 60, "band_in": .12,
"floor_in": .08, "ceil_in": .25, "top_strip": .5, "near": .03, "clip": .05,
"clip_open": .1, "island": .2, "smooth": 8, "tops": .1, "protect": 0, "tubes": 3,
"tube_thick": .15, "walked": .4, "label_rules": 1, "erase": []}
summary = dict(report["refine"])
assert summary.pop("output_triangles") == len(mesh(out)[1]) // 3 and summary.pop("output_volumes") == 2
assert summary == scan["report"]
assert scan["report"]["kept"] + scan["report"]["panel_triangles"] == len(mesh(out)[1]) // 3
assert sorted((label["from"], label["to"]) for label in scan["labels"]) == [("COUCH", "TABLE"), ("TABLE", "TABLE")]
assert scan["suggestions"] == []
def test_head_path_is_decimated_to_two_thousand_points(scanner, dumps, tmp_path):
data = dumps["one"].read_bytes()
empty = b"".join(struct.pack("<d7fII", i, i * .001, 1.6, 0, 0, 0, 0, 1, 0, 0) for i in range(2500))
dump = tmp_path / "long.fprs"
dump.write_bytes(data + empty)
report = run(scanner, dump, tmp_path / "out", "--min-views", "1") # one frame with depth, 2500 without
heads = json.loads((tmp_path / "out" / "scan.json").read_text())["heads"]
assert 1000 <= report["heads"] == len(heads) <= 2000
assert heads[-1][0] > 2 # still spans the whole recording
def test_erase_spheres_remove_the_scan_inside_them(scanner, box_scan, tmp_path):
copy = tmp_path / "copy"
shutil.copytree(box_scan[0], copy)
report = refine_only(scanner, copy, "--erase", "-0.6,0.75,-0.25,0.3;1.5,1.0,-1.0,0.2")["refine"]
assert report["erased"] > 0
assert not any((c[0] + .6) ** 2 + (c[1] - .75) ** 2 + (c[2] + .25) ** 2 <= .09 for c in centroids(copy))
assert json.loads((copy / "scan.json").read_text())["refine"]["erase"] == [[-.6, .75, -.25, .3], [1.5, 1, -1, .2]]
ROOM_IDS = iter(f"{i:032x}" for i in range(1, 1000))
def volume(label, cx, cz, top, yaw, w, d, h):
return f"volume {next(ROOM_IDS)} {label} {cx} {cz} {top} {yaw} {w} {d} {h}\n"
def test_label_rules_on_an_older_room_folder(scanner, tmp_path):
"""Merged and nested boxes and boxes behind a wall go, shapes are relabelled, a door slab becomes a suggestion.
The folder has no raw files (an older scan): its room.txt + mesh are taken as the unrefined scan."""
walls = [(-2, -1.5, 2, -1.5), (2, -1.5, 2, 1.5), (2, 1.5, -2, 1.5), (-2, 1.5, -2, -1.5)]
plane = f"plane {next(ROOM_IDS)} DOOR_FRAME 0.0000 1.0000 1.4900 0.0000 0.0000 0.0000 1.0000 0.9000 2.0000\n"
text = (f"frameport-room 1\nuniverse 7\nsource scan\ntime 1\nroom {next(ROOM_IDS)}\nfloor {next(ROOM_IDS)} 0\n"
f"ceiling {next(ROOM_IDS)} 2.6\noutline 4 -2 -1.5 2 -1.5 2 1.5 -2 1.5\n"
+ "".join(f"wall {next(ROOM_IDS)} {a} {b} {c} {d} 0 2.6\n" for a, b, c, d in walls)
+ volume("TABLE", -1.2, -.5, .75, 0, 1.0, .6, .75)
+ volume("COUCH", .6, -.5, .5, 0, 1.6, .8, .5)
+ volume("OTHER", -.3, -.5, .8, 0, 3.0, 1.0, .8) # holds the table and the couch: merged
+ volume("BED", .6, .85, .55, 0, 2.0, 1.1, .55)
+ volume("OTHER", .6, .85, .45, 0, .5, .4, .45) # inside the bed: part of it
+ volume("OTHER", 1.85, -.8, 1.6, -90, 1.0, .06, .6) # a flat panel along the x = 2 wall: a screen
+ volume("SCREEN", -1.0, -1.45, 2.0, 0, .9, .1, 2.0) # a tall slab by the z = -1.5 wall: a door?
+ volume("STORAGE", 2.6, 0, 1.5, 0, .5, .5, 1.5) # behind a wall
+ plane)
vertices, indices = [], []
def quad(a, b, c, d):
base = len(vertices)
vertices.extend((a, b, c, d))
indices.extend((base, base + 1, base + 2, base, base + 2, base + 3))
for i in range(18): # the screen: a flat 0.9 x 0.5 m grid at x = 1.85
for j in range(10):
z, y = -1.25 + i * .05, 1.05 + j * .05
quad((1.85, y, z), (1.85, y, z + .05), (1.85, y + .05, z + .05), (1.85, y + .05, z))
for i in range(38): # some floor
for j in range(28):
x, z = -1.9 + i * .1, -1.4 + j * .1
quad((x, 0, z), (x, 0, z + .1), (x + .1, 0, z + .1), (x + .1, 0, z))
text += f"mesh {next(ROOM_IDS)} room-mesh.bin {len(vertices)} {len(indices)}\n"
mesh_bytes = (struct.pack("<4sIII", b"FPRM", 1, len(vertices), len(indices))
+ b"".join(struct.pack("<3f", *v) for v in vertices) + struct.pack(f"<{len(indices)}I", *indices))
folder = tmp_path / "room"
folder.mkdir()
(folder / "room.txt").write_text(text)
(folder / "room-mesh.bin").write_bytes(mesh_bytes)
report = refine_only(scanner, folder)
assert "no raw files; the current room was taken as the unrefined scan" in report["warnings"]
assert "no head path; walked volume skipped" in report["warnings"]
assert (folder / "room-mesh-raw.bin").read_bytes() == mesh_bytes
scan = json.loads((folder / "scan.json").read_text())
decisions = [(label["from"], label["to"], label["reason"]) for label in scan["labels"]]
assert decisions == [
("TABLE", "TABLE", "top 0.75 m"),
("COUCH", "COUCH", "seat-height top 0.50 m, 1.6 m long"),
("OTHER", None, "holds a table and a couch 1.8 m apart: several things merged"),
("BED", "BED", "2.2 m², 2.0 m long, top 0.55 m"),
("OTHER", None, "100% inside the bigger bed box: part of it"),
("OTHER", "SCREEN", "thin, raised, screen-shaped, flat (0 cm)"),
("SCREEN", None, "tall thin slab from the floor (2.00 m): a door or closet door?"),
("STORAGE", None, "centre 60 cm outside the room (behind a wall)"),
]
assert scan["suggestions"] == [{"kind": "door", "box": {"cx": -1, "cz": -1.45, "top_y": 2, "yaw_deg": 0,
"w": .9, "d": .1, "h": 2}, "wall": 0, "along": .25,
"reason": decisions[6][2]}]
raw = room(folder, "room-raw.txt")
assert raw["mesh"][0][1] == "room-mesh-raw.bin" and len(raw["volume"]) == 8
refined = room(folder)
assert [row[1] for row in refined["volume"]] == ["TABLE", "COUCH", "BED", "SCREEN"]
assert refined["universe"] == [["7"]] and refined["plane"] == [plane.split()[1:]] # unknown records stay
assert report["refine"]["removed_boxes"] == 4 and report["refine"]["relabelled"] == 1
assert report["refine"]["suggestions"] == 1
first = {name: (folder / name).read_bytes() for name in ("room.txt", "room-mesh.bin", "scan.json")}
refine_only(scanner, folder) # now from the raw files: the same result
assert first == {name: (folder / name).read_bytes() for name in first}
refine_only(scanner, folder, "--label-rules", "0")
assert [row[1] for row in room(folder)["volume"]] == [
"TABLE", "COUCH", "OTHER", "BED", "OTHER", "OTHER", "SCREEN", "STORAGE"]
@pytest.mark.parametrize("args", [("--walls", "shell"), ("--smooth", "1.5"), ("--erase", "1,2,3"), ("--refine", "2"),
("--band", "2"), ("--walked", ".01"), ("--erase", "0,0,0,-1")])
def test_invalid_refinement_options_are_json_errors(scanner, dumps, tmp_path, args):
run(scanner, dumps["one"], tmp_path / "out", *args, success=False)
def test_refine_only_needs_a_room(scanner, tmp_path):
report = refine_only(scanner, tmp_path, success=False)
assert "room-raw.txt" in report["error"]