Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84122ade16 | ||
|
|
318d0cc298 | ||
|
|
c9b566c4e8 | ||
|
|
8b817ff4e1 | ||
|
|
aef3843f9c | ||
|
|
43d4a44b6b | ||
|
|
f87e112710 | ||
|
|
fb60e91181 | ||
|
|
e8c750bd53 | ||
|
|
20ae3bebab | ||
|
|
da58afa3e3 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Quick start
|
||||
|
||||
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) the build for Windows, macOS (Apple
|
||||
|
||||
@@ -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) -------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Updating
|
||||
|
||||
FramePort checks for a new version at start and every 6 hours. When there is one, the Library shows **Update now**:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 425 KiB |
|
Before Width: | Height: | Size: 505 KiB After Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 665 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
@@ -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 ->
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,"]}");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}
|
||||
@@ -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")),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?": "",
|
||||
"中文(简体)": "",
|
||||
"中文(繁體)": "",
|
||||
"日本語": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()]
|
||||
@@ -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"] == []
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||