Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84122ade16 | ||
|
|
318d0cc298 | ||
|
|
c9b566c4e8 | ||
|
|
8b817ff4e1 | ||
|
|
aef3843f9c | ||
|
|
43d4a44b6b | ||
|
|
f87e112710 | ||
|
|
fb60e91181 |
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).
|
||||
|
||||
@@ -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) -------------------------------------------------------------------
|
||||
|
||||
@@ -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 |
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||