Compare commits

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

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

No files matched your search

+1 -1
View File
@@ -606,7 +606,7 @@ Toy Master doesn't request a model after that (it uses its own), so the glb load
**Vader Immortal (GitHub #49):** stuck on its loading card and leaked GPU mappings (solved below: `gl_error_clear`); diagnostics FrameBridge `layer_debug`, `strip_color_bias`, `frame_balance`, `snapshot=N` (`snapshot_gl.c`: saves the submitted left-eye image as `files/fb_snap_*.ppm`). → docs/DEVICE_NOTES.md#vader-immortal-headless-investigation
**SteamVR overlay apps:** host OpenVR overlays composite over Lepton games; detection `analysis/vroverlay.py`, patch `pcvr.vr_overlay`, Linux fields `vr_overlay`/`vr_overlay_autostart`, agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay`; SteamVR on the Frame reads only `binary_path_linux_arm`; FramePort's own autostart (`ensure_overlay_service`, `frameport-vr-overlays.service`). See also docs/FRAME_RUNTIME.md "Overlay apps". → docs/DEVICE_NOTES.md#steamvr-overlay-apps
**Vader's leak solved / AC Nexus flips:** stale GL errors broke SteamVR's GL interop → adapter `gl_error_clear` (GLES, catalog for Vader ep1-3); read `xrclient_<pkg>.txt` in Steam's logs for runtime errors; the runtime caps swapchains per session (~15) → flip emulation in place; adapter `flip_dump`. → docs/DEVICE_NOTES.md#vaders-leak-solved--ac-nexus-flips
**Mixed reality: scanned rooms (branch mr-scene):** agent `room_capture` records Valve's RoomView 3D depth mesh -> `fp_roomscan` -> `room.txt` + `room-mesh.bin` (docs/mr/ROOM_FORMAT.md) in `~/.local/share/frameport/rooms/<universe>/`, copied next to settings.conf (`_room_sync`); FrameBridge (`scene_room.c/scene_meta.c/scene_anchors.c`, all through xrshim) serves walls, volumes, planes, the room mesh, META discovery/persistence, saved anchors and `XR_META_boundary_visibility`; `scene_emul` + `frame.meta_permissions` for every USE_SCENE/USE_ANCHOR_API game (migrations `scene_emul_all`, `scene_permissions`); per-game `scene_hide_bounds` (Demeter). → docs/DEVICE_NOTES.md#mixed-reality-scanned-rooms
**Mixed reality: scanned rooms (branch mr-scene):** agent `room_capture` records Valve's RoomView 3D depth mesh -> `fp_roomscan` -> `room.txt` + `room-mesh.bin` (docs/mr/ROOM_FORMAT.md; refined by default: the owner's cleanup passes + label rules in `native/roomscan/refine.c`/`rules.c`, unrefined output kept as `room-raw.txt` + `room-mesh-raw.bin`, head path/settings/report/labels/door suggestions in `scan.json`, `--refine-only <dir>` re-runs it, `--refine 0` = old output; docs/mr/scan.md "Refinement") in `~/.local/share/frameport/rooms/<universe>/`, copied next to settings.conf (`_room_sync`); FrameBridge (`scene_room.c/scene_meta.c/scene_anchors.c`, all through xrshim) serves walls, volumes, planes, the room mesh, META discovery/persistence, saved anchors and `XR_META_boundary_visibility`; `scene_emul` + `frame.meta_permissions` for every USE_SCENE/USE_ANCHOR_API game (migrations `scene_emul_all`, `scene_permissions`); per-game `scene_hide_bounds` (Demeter). → docs/DEVICE_NOTES.md#mixed-reality-scanned-rooms
**Unreal's self-restart:** UE 4.21+ restarts itself after the first shader precompile and Lepton ends the container → launch.sh RESTART_BLOCK starts Lepton once more (`upgrade_launchers`; `FRAMEPORT_NO_RESTART=1` off). → docs/DEVICE_NOTES.md#unreals-self-restart
**xrWaitFrame runs ahead:** the runtime sometimes returns a display time <1 refresh after the last submit and rejects the second submission (`VRCompositorError_AlreadySubmitted`) → the VrApi bridge re-waits (`pacing 2s:` counters). → docs/DEVICE_NOTES.md#xrwaitframe-runs-ahead-journey-of-the-gods
**Lepton's logcat mirror dies** (EOF, read failure, or silent) → launch.sh `_logcat_keeper` reads `podman exec <container> logcat` itself into launch.log (log `<base>/logcat-keeper.log`). → docs/DEVICE_NOTES.md#leptons-logcat-mirror-dies
+5
View File
@@ -36,6 +36,9 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
in FramePort.
- **Your Frame from your PC:** Type on Frame (your keyboard on the Frame), Files, Screenshots, Live view (what the
headset shows, in your browser) and Monitor (frame rate, temperatures, battery, processes).
- **Your room for mixed reality (experimental):** scan the room in the headset, see it in 3D, clean up the scan and
fix furniture, screen and door labels, so mixed-reality games get your room. Try it with
[Test Quest MR](https://github.com/spoopyghosty0/TestQuestMR).
- **Updates itself**, and reports problems without personal data.
Details for each: [Install and first steps](docs/INSTALL.md).
@@ -44,6 +47,8 @@ Details for each: [Install and first steps](docs/INSTALL.md).
![Screenshots](docs/images/screenshots.png)
![Room (experimental): the scanned room in 3D, clean-up and labels](docs/images/room.png)
## Quick start
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) the build for Windows, macOS (Apple
+1 -1
View File
@@ -27,7 +27,7 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
63af0050054ad3705b17a0b00ce0c076ba1af0b73abd673ba6c588f64b4591cd ./linux-arm64-bin/fp_roomscan
2cc16b90abcd3d359f1658c581172bd650017bf2725e24268915f50904c000ec ./linux-arm64-bin/fp_roomscan
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
Binary file not shown.
+4
View File
@@ -202,6 +202,10 @@ Mixed-reality games ask for your room: walls, furniture and its surfaces. **Room
the scan again (**Back to the scan's boxes** under Labels drops them).
- The free **Test Quest MR** app (Get Test Quest MR on the tab) shows what a game receives from your scan.
![Room](images/room.png)
![Room: a box picked to edit, and the labels of the clean-up](images/room-editing.png)
## Updating
FramePort checks for a new version at start and every 6 hours. When there is one, the Library shows **Update now**:
+4
View File
@@ -117,6 +117,10 @@ Hooks (`steps.HOOKS`) cover what a real Frame would do:
- `pairing_done`: the Frame ran the setup command, so FramePort connects.
- `live_stream`: a test picture through the real relay.
- `monitor_details`, `type_tab`, `select_files` and `settings_section`.
- `open_room` (the Room tab with the pretend Frame's room drawn) and `room_editing` (its editing cards: `cleanup`,
`more`, `box`, `doors`, `suggestion`). The room is a made-up bedroom built in code (`fakes.fake_room`, never a real
scan): a bed under a wall-mounted TV, a desk with two monitors under a window, a closet, a door, a lamp and a plant,
with a noisy scanned-surface mesh and a clean-up report and labels that match it.
In a video, clicking Install runs the scripted install, and the game is then installed on the pretend Frame.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 91 KiB

+32 -1
View File
@@ -65,8 +65,39 @@ float32 xyz[vertex_count], u32 indices[index_count] (triangles; counter-clockw
```
Coordinates in STAGE space. Kept below 200k triangles (decimated).
## Raw files and scan.json (scanner sidecars)
`fp_roomscan` refines its output by default (docs/mr/scan.md "Refinement"). `room.txt` + `room-mesh.bin` are always
the refined room (what games and the GUI read; the contract above is unchanged). Next to them:
- `room-raw.txt` + `room-mesh-raw.bin`: the scanner's unrefined output in the same formats (its `mesh` record names
`room-mesh-raw.bin`). Same UUIDs as the refined room.txt, so anchors stay the same when the room is refined again.
The refined room differs in: the ceiling height (re-estimated from the scan, wall tops follow it), the volumes
(after the label rules) and the mesh. Records the scanner doesn't model (e.g. `plane`) are copied verbatim.
- `scan.json` (UTF-8 JSON, `version` 1):
```
{"version": 1,
"heads": [[x, y, z], ...], # head path of the recording (STAGE), <= 2000 points, decimated evenly
"refine": {"refine": 1, "walls": "panels", "band": 0.35, ..., "erase": [[x, y, z, r], ...]},
# every refinement setting used (keys = the CLI options, docs/mr/scan.md)
"report": {"triangles", "kept", "structural", "near_walls", "outside", "small_pieces", "thin_pieces",
"erased", "walked", "tops", "panel_triangles", "truncated", "walked_skipped", "floor_y",
"ceiling_y", "boxes", "relabelled", "removed_boxes", "suggestions",
"plane_shifts": [{"kind": "wall"|"floor"|"ceiling", "wall": i, "shift": m}, ...]},
"labels": [{"from": "COUCH", "to": "TABLE"|null, "reason": "top 0.69 m", "suggest": "door"|"closet" (optional),
"id": "<volume uuid>", "box": {"cx", "cz", "top_y", "yaw_deg", "w", "d", "h"}}, ...],
# one per scanner volume, in room-raw.txt order; to = null: removed
"suggestions": [{"kind": "door"|"closet", "box": {...}, "wall": i, "along": t, "reason": "..."}, ...]}
# tall thin slabs; never applied by the scanner. wall = index of the nearest
# wall record in room-raw.txt, along = 0..1 position on it (from x0,z0)
```
`report` counts triangles of the raw mesh per pass (`panel_triangles` are added ones), `wall` in `plane_shifts`
indexes the wall records. Readers ignore unknown keys.
- `fp_roomscan --refine-only <dir>` re-runs only the refinement from these files (an older folder without them:
its room.txt + mesh are copied to the raw names first). Publishing order: [raw mesh, raw text,] `scan.json`,
`room-mesh.bin`, `room.txt`.
## Where the files live
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`).
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`, the raw files and
`scan.json` above).
`~/.local/share/frameport/rooms/current` = symlink to the newest room's folder.
- Per game: launch.sh copies the current universe's room (else `current`) to `<app_dir>/room.txt` (+ mesh) before
Lepton starts, next to `settings.conf`; FrameBridge reads `dirname($FRAMEBRIDGE_CONFIG)/room.txt`. No room ->
+124 -10
View File
@@ -2,7 +2,9 @@
`fp_roomscan` converts recorded FPRS v1 meshes to the [room contract](ROOM_FORMAT.md), including the optional
wall label adopted from upstream commit `8cb2783`:
`room.txt` and `room-mesh.bin`. It does not capture RoomView queues, contact a headset, install rooms, or modify
`room.txt` and `room-mesh.bin`, refined by default (see "Refinement" below), plus the unrefined `room-raw.txt` +
`room-mesh-raw.bin` and `scan.json` (head path, settings, per-pass report, label decisions, door/closet
suggestions; ROOM_FORMAT.md "Raw files and scan.json"). It does not capture RoomView queues, contact a headset, install rooms, or modify
the current-room symlink. Scanner work changes `native/roomscan/`, its build target and artifact/checksum,
`scripts/mr/synth_scan.py`, `tests/test_roomscan.py`, and this note. The upstream contract commit also brings its
existing prompt assets and renderer unchanged. No runtime, agent, patch, or analysis versions were bumped.
@@ -17,7 +19,7 @@ python native/build.py --only roomscan
The result is `artifacts/linux-arm64-bin/fp_roomscan`, a static aarch64 Linux ELF with its own `_start`, raw Linux
syscalls, and no interpreter, dynamic section, libc, libm, UAPI headers, generated tables, or new dependencies.
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same five C sources compile on the
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same eight C sources compile on the
host:
```sh
@@ -34,8 +36,13 @@ exactly one JSON summary, whether `--json` is supplied or omitted. Success exits
{"ok":true,"frames":30,"skipped_frames":0,"far_vertices":0,"discontinuity_triangles":0,
"walls":4,"detected_walls":4,"volumes":2,"floor_y":-0.0023,
"ceiling_y":2.5992,"coverage_percent":100.0,"hint_used":false,"triangles":70997,
"accepted_triangles":118804,"rejected_triangles":704,"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"total":0}}
"accepted_triangles":118804,"rejected_triangles":704,"heads":28,
"refine":{"triangles":70997,"kept":7159,"structural":46121,"near_walls":148,"outside":0,"small_pieces":38,
"thin_pieces":0,"erased":0,"walked":203,"tops":1896,"panel_triangles":12,"truncated":0,"walked_skipped":false,
"floor_y":-0.0051,"ceiling_y":2.6028,"boxes":2,"relabelled":1,"removed_boxes":0,"suggestions":0,
"plane_shifts":[{"kind":"wall","wall":0,"shift":-0.0004},...,{"kind":"ceiling","shift":-0.0021}],
"output_triangles":7171,"output_volumes":2},"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"refine":0,"total":0}}
```
The example above illustrates the schema; actual counts and timings depend on the input. Heights are absolute
@@ -43,7 +50,10 @@ STAGE coordinates. `frames` includes skipped frames; `skipped_frames`, `far_vert
`discontinuity_triangles` count input exclusions once, not once per pass. `walls` counts output polygon
edges including synthesized edges; `detected_walls` counts fitted physical planes. Coverage means the percentage
of the output perimeter supported by observed wall planes, **not camera coverage of all room surfaces**. Timings
include parsing, fusion, structural/furniture modelling, mesh generation, and total time including output.
include parsing, fusion, structural/furniture modelling, mesh generation, refinement, and total time including
output. The scanner fields describe the unrefined scan (`triangles`, `volumes`, `ceiling_y` are the raw files');
`refine` is the refinement report (same keys as scan.json's `report`) plus the refined `output_triangles` and
`output_volumes`. `heads` counts the head-path points kept for the walked-volume pass.
All frame headers, timestamps/vertices/poses, approximately unit quaternions, counts, indices and EOF boundaries
are checked before publication. A non-finite timestamp/pose/vertex or invalid tracking quaternion skips the entire
@@ -57,9 +67,10 @@ triangles fails. Empty frames may accompany useful frames. Structural corruption
bad counts or indices) remains fatal, preserves any existing output byte for byte and creates no output directory.
UUIDs come from `getrandom`, fresh per scan; mesh geometry is repeatable.
Both complete files are written to exclusive, randomly named temporary files, then individually renamed, with
the mesh first and `room.txt` last. A write failure removes the temporary files. These are atomic **file**
replacements, not a two-file filesystem transaction: the installer should scan into a fresh staging directory
All files are computed first, then each is written to an exclusive, randomly named temporary file and renamed, in
the order `room-mesh-raw.bin`, `room-raw.txt`, `scan.json`, `room-mesh.bin`, `room.txt` (meshes before their room
text, `room.txt` last). A write failure removes the temporary files. These are atomic **file** replacements, not a
multi-file filesystem transaction: the installer should scan into a fresh staging directory
and publish that directory/symlink after success. Do not run concurrent writers into the same output directory.
There is no fsync-based power-loss durability promise. Neither scan nor build writes application data outside
the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary directories.
@@ -111,6 +122,77 @@ the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary director
excess beyond that fails. Vertices are unshared triangle vertices, so at most 600,000 vertices. Structural
planes are semantic anchors, not substituted for the observed global mesh.
## Refinement
After the mesh, the scanner cleans the room the way the owner tuned it in the room-scan cleanup page (2026-10-18);
`native/roomscan/refine.c` and `rules.c` port that page's `refine()` and label rules pass for pass (local parity
check on the owner's recording: identical counts and bit-identical refined meshes, see below). `--refine 0`
switches it off: room.txt and room-mesh.bin are then byte-for-byte what the scanner wrote before refinement existed
(the raw files and scan.json are still written, so refinement can be switched on later without a new scan).
The input is exactly what the raw files hold: the raw room is written as text and read back (doubles parsed from the
4-decimal text, like the page reads it), the head path is rounded the same way. Therefore `--refine-only <dir>`
on the raw files reproduces the scan's refined files byte for byte. The mesh is welded at 1 mm (quantized, as the
page does) for the passes that need topology; the output is again unshared triangle vertices.
1. **Label rules** (`rules.c`, `--label-rules 0` off) on the scanner's volumes, using the raw room (walls, outline,
floor): a box that holds >= 60% of two boxes whose centres are >= 1 m apart covers several things (removed); a
box >= 75% inside a bigger box with a top no higher (+5 cm) is part of it (removed); a centre > 10 cm outside the
outline is behind a wall (removed). A tall thin slab from the floor (thinner than 22 cm, >= 1.8 m high, bottom
<= 0.3 m, at most 0.5 m behind the wall line) becomes a door (<= 1.1 m wide) or closet **suggestion** in
scan.json (box removed, nothing added: the GUI decides). Thin (< 22 cm) upright boxes are SCREEN only when
raised >= 0.3 m, top <= 2.2 m, width/height >= 1.2, within 15 degrees of the nearest wall (when closer than
0.35 m) and flat: the raw mesh's depth spread inside the box <= 3 cm (with >= 30 triangles; fewer = unknown,
accepted); otherwise removed. Then BED (top 0.25-0.65 m, area >= 2 m^2, long side 1.8-3 m), COUCH (top
0.35-0.6 m, long side >= 1.4 m), TABLE (top 0.6-1.1 m), STORAGE (>= 1.1 m high, >= 25 cm deep, within 0.35 m of a
wall); anything taller than 1.1 m otherwise is removed, the rest is OTHER. Every box gets a reason in scan.json.
These thresholds are constants in `rules.c` (the page's `RULES0`).
2. **Furniture**: triangles within 10 cm of a ruled box (after the rules) are furniture; the passes below never
classify, clear or walk them away, and the tubes pass keeps pieces that are mostly furniture.
3. **Planes**: seen walls (`WALL_FACE` records), floor and ceiling. Each plane's offset moves to the median signed
distance of the non-furniture triangles facing it (normal within `--facing-deg`) within `--band` of it (walls:
within their length + band), when there are more than 50. An unseen ceiling (floor + 2.5 m) moves to the real
one; the re-estimated ceiling is written to the refined room.txt (ceiling record and wall tops).
4. **Structural triangles**: facing a plane, not furniture, behind it by less than `--band`, in front of it by less
than `--band-in` (walls), `--floor-in`, `--ceil-in`; in a wall's top `--top-strip` below the ceiling the full
band applies in front too (the depth bends where wall meets ceiling). The nearest qualifying plane owns it.
5. **Erase spheres** (`--erase "x,y,z,r;..."`, from the GUI's eraser) drop every triangle whose centroid is inside.
6. **Walked volume** (`--walked` = radius R, 0 off): where the user stood, nothing solid can be. Non-furniture
triangles from 0.2 m above the floor up to 0.25 m above a head position, within R horizontally of it, go.
Needs the head path (scan.json); without it the pass is skipped (`walked_skipped`).
7. **Walls** (`--walls`): `panels` (default) removes the structural triangles, then non-furniture triangles closer
than `--near` to any plane (within the wall's extent), and adds flat panels: one quad per seen wall (moved by its
offset shift) from floor to ceiling, and the outline ear-clipped at the floor and the ceiling heights, all wound
counter-clockwise seen from inside. `snap` instead projects every vertex of a structural triangle onto its plane
(the last owner wins). `off` keeps them.
8. **Furniture tops** (`--tops`, 0 off): upward-facing (normal y > 0.8) triangles within `--tops` of a box top and
inside its footprint are flattened onto it.
9. **Clip**: centroids further than `--clip` outside the outline (`--clip` + `--clip-open` past an unseen wall), or
further than `--clip` above the ceiling / below the floor plane go. `--clip 1 --clip-open 2` switches it off.
10. **Islands**: connected pieces smaller than `--island` m^2 go (`--protect 1` keeps furniture triangles).
11. **Thin floating pieces** (`--tubes`, 0 off): pieces of >= 10 triangles, < 1 m^2, at most half furniture, whose
longest principal axis (vertex PCA, std) is >= `--tubes` times the second and the second <= `--tube-thick`.
12. **Smoothing**: `--smooth` Taubin iterations (lambda 0.5, mu -0.53) over the kept triangles.
Output: the kept scan triangles, then the panels (`refine.panel_triangles`), at most 200,000 triangles (more kept
triangles would be cut, `truncated`, with a warning). The refinement adds about 20 ms on the host for the owner's
64k-triangle scan; its buffers (welding hash, double positions, union-find, adjacency) stay below ~170 MiB for a
200k-triangle mesh and the fusion grid is freed before it runs, so peak memory is still the fusion phase's.
`--refine-only <room dir> [refinement options] [--json]` runs only steps 1-12 from `room-raw.txt` +
`room-mesh-raw.bin` (+ the head path from `scan.json`) and rewrites scan.json, room-mesh.bin and room.txt. A folder
from an older scanner without raw files: its room.txt + mesh become the raw files first (the room text is rewritten
with the mesh record pointing at `room-mesh-raw.bin`), a warning says so, and there is no head path. Its JSON:
`{"ok":true,"refine_only":true,"raw_triangles":..,"volumes":..,"floor_y":..,"ceiling_y":..,"heads":..,
"refine":{...},"warnings":[...],"timings_ms":{"refine":..,"total":..}}`.
Local parity (2026-10-18, private recording `room1.fprs`, not in the repository): the reference page's functions run
under node on this scanner's raw output give the same per-pass counts, the same label decisions and reasons, and
bit-identical refined meshes for the defaults, snap, off + protect, erase spheres + tubes 2, and a wide-band setting
(one triangle differs there: a facing test exactly at the threshold), and the same on an older 128k-triangle room
folder. Distance ties at outline corners decide which wall's clip allowance applies, so the corner distance uses
V8's Math.hypot algorithm.
Peak allocation stays below 260 MiB, including maximum frame buffers, the grid, model worklists and mesh buffers
(the latter phases do not retain frame buffers). Default synthetic scenes use a much smaller grid. Thresholds
are CLI options below or named constants next to their algorithms. No room-specific coordinates or dimensions are
@@ -143,6 +225,31 @@ Distances are metres, areas m², volumes m³. `--universe` accepts the full unsi
| `--gap` | 0.65 | 0.1–1.5, maximum adjacent-wall endpoint extrapolation |
| `--horizontal` | 0.85 | 0.6–0.99, minimum floor/ceiling/top normal cosine |
Refinement (all distances metres; defaults = the owner's settings):
| Option | Default | Accepted range / meaning |
| --- | ---: | --- |
| `--refine` | 1 | 0/1; 0 = the unrefined output in room.txt/room-mesh.bin |
| `--walls` | panels | `off`, `snap` (project onto the planes) or `panels` (flat wall/floor/ceiling panels) |
| `--band` | 0.35 | 0.02–1, structural band behind a surface; plane re-estimation window |
| `--facing-deg` | 60 | 5–89, maximum angle between a triangle's normal and the surface's |
| `--band-in` | 0.12 | 0–1, band in front of a wall (room side) |
| `--floor-in` | 0.08 | 0–1, band above the floor |
| `--ceil-in` | 0.25 | 0–1, band below the ceiling |
| `--top-strip` | 0.5 | 0–2, top part of a wall where the full band applies in front too |
| `--near` | 0.03 | 0–0.3, panels: also clear non-furniture scan this close to a plane |
| `--clip` | 0.05 | 0–1, clip distance outside the outline / beyond floor and ceiling |
| `--clip-open` | 0.10 | 0–2, extra allowance past unseen walls (`--clip 1 --clip-open 2` = no clipping) |
| `--island` | 0.20 | 0–5 m^2, minimum connected piece area (0 off) |
| `--smooth` | 8 | 0–50 integer, Taubin iterations |
| `--tops` | 0.10 | 0–0.5, flatten upward scan this close to a box top (0 off) |
| `--protect` | 0 | 0/1, islands never remove furniture triangles |
| `--tubes` | 3 | 0–20, elongation (PCA axis ratio) of a thin floating piece (0 off) |
| `--tube-thick` | 0.15 | 0.01–1, maximum second PCA axis (std) of a thin floating piece |
| `--walked` | 0.40 | 0 or 0.05–1.5, walked-volume radius around the head path (0 off) |
| `--label-rules` | 1 | 0/1, label rules on the scanner's volumes |
| `--erase` | none | `"x,y,z,r;..."`, up to 64 spheres (r 0–5 m) whose scan is removed |
Additional constants in `model.c` include RANSAC trials, normal agreement, wall span, furniture margins,
label height/area thresholds, box trimming and circle recognition. Fusion sampling/weight limits are in
`fusion.c`; mesh splat radii/decimation limits in `mesh.c`; hard resource limits in `scan.h`.
@@ -174,10 +281,17 @@ Incompatible inset play boundaries are ignored with a warning. Hint-completed ed
## Offline validation
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its 41 tests cover the noisy
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its tests cover the noisy
4 x 3 x 2.6 m room with table/couch, six-wall L, moving and rotated/translated mesh poses, ceiling fallback, a single
frame, missing walls, circular/matching hints, correct UUIDs/wire layout, interior mesh winding, triangle budget,
repeatable mesh bytes with fresh UUIDs, truncated/corrupt dumps, invalid counts/indices/poses and invalid CLI values.
Refinement tests: a floating pole and a column in the walked path (`synth_scan.py` `TUBE`/`GHOST`, `extra=`)
are meshed by the scanner and removed by the tubes / walked passes (each kept with its pass off, the table top kept);
panels wind into the room on the re-estimated planes; `--refine-only` reproduces a scan's files byte for byte;
`--refine 0` equals the raw output (and refining it later equals the default); scan.json settings/report/heads; head
path decimation to <= 2000 points; erase spheres; the label rules on a hand-written older room folder (merged,
nested, behind a wall, SCREEN from a flat panel, a door suggestion with wall and position, unknown records kept,
idempotent, `--label-rules 0`); invalid refinement options.
The test module takes about fourteen seconds including synthetic rendering and compilation (under the 60 s budget).
New cases cover non-finite frames before/after usable frames, per-vertex far rejection, depth jumps below the edge
limit, weak ceiling evidence, wall labels and a complete camera-fan scan. The deterministic stdlib renderer now
@@ -193,7 +307,7 @@ UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run ruff check scripts/mr/s
```
The synthetic camera fan passes AddressSanitizer + UndefinedBehaviorSanitizer. Two consecutive final native builds
produce identical SHA-256 `0d88e5adfa1a21da7e0cfec2698a835d7767961e15c0b41c84235fa24ae1e751` (54,248 bytes),
produce identical SHA-256 `2cc16b90abcd3d359f1658c581172bd650017bf2725e24268915f50904c000ec` (97,808 bytes, with refinement),
matching `artifacts/SHA256SUMS`. ELF inspection confirms aarch64, little-endian, no PT_INTERP / PT_DYNAMIC, and no
embedded local build paths.
+18
View File
@@ -108,3 +108,21 @@ shots:
steps:
- go: settings
- wait_for: "Portal (OLED)"
- name: room # experimental: the pretend Frame's made-up bedroom scan (fakes.fake_room), no real scan
docs: [README.md, docs/INSTALL.md]
viewport: [1280, 1690]
steps:
- hook: open_room
- wait_for: "Furniture & screens"
- park: true
- name: room-editing # a box picked in the list (its type, place and size) next to the clean-up's labels
docs: [docs/INSTALL.md]
viewport: [1280, 2000] # (tall enough that nothing scrolls)
steps:
- hook: open_room
- wait_for: "Furniture & screens"
- hook: {name: room_editing, args: [box]}
- park: true
crop: {anchor: "Furniture & screens", left: -26, top: -28, width: 670, height: 1262}
+2 -2
View File
@@ -209,8 +209,8 @@ def build_roomscan(tc: Path):
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlib", "-nostdlibinc", "-static",
"-fno-stack-protector", "-fno-builtin", "-ffp-contract=off", "-fno-math-errno", "-O2", "-Wall", "-Wextra", "-Werror",
"-fuse-ld=lld", "-Wl,--build-id=none", "-Wl,-z,max-page-size=65536", "-e", "_start",
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "platform.c",
"-o", out / "fp_roomscan"], cwd=src)
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "refine.c", "rules.c",
"roomio.c", "platform.c", "-o", out / "fp_roomscan"], cwd=src)
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
+14
View File
@@ -52,6 +52,19 @@ void warn(Scan *s, const char *m)
for (int i = 0; i < s->nwarnings; i++) if (same(s->warnings[i],m)) return;
if (s->nwarnings < 24) s->warnings[s->nwarnings++] = m;
}
/* The head path (where the user stood and walked) for the refinement's walked-volume pass. Bounded: every
* head_stride-th tracked frame, halving the kept points and doubling the stride when the buffer is full. */
static void record_head(Scan *s, Vec eye)
{
if(!s->head_stride) s->head_stride=1;
if(s->head_frames++%s->head_stride) return;
if(s->nheads==MAX_HEADS) {
for(int i=0;i<MAX_HEADS/2;i++) s->heads[i]=s->heads[2*i];
s->nheads=MAX_HEADS/2;s->head_stride*=2;
if((s->head_frames-1)%s->head_stride) return;
}
s->heads[s->nheads++]=eye;
}
static u32 u32le(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static float f32le(const u8 *p) { union { u32 u; float f; } v = {u32le(p)}; return v.f; }
/* 1 = complete, 0 = clean EOF, -1 = I/O error or partial record. */
@@ -207,6 +220,7 @@ const char *read_dump(Scan *s, int fuse)
for (int i = 3; i < 7; i++) norm += pose[i]*pose[i];
if (!finite(norm) || ab(norm-1) > QUATERNION_NORM_TOLERANCE) invalid=1;
if(!invalid) for (int i = 3; i < 7; i++) pose[i] /= root(norm);
if(!fuse && !invalid) record_head(s,vec(pose[0],pose[1],pose[2]));
u32 nv = u32le(header+36), ni = u32le(header+40);
if (nv > MAX_FRAME_VERTICES || ni > MAX_FRAME_INDICES || ni%3 || (!nv && ni)) {
error = "invalid frame counts"; break;
+297 -129
View File
@@ -1,110 +1,10 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#include "refine.h"
typedef struct { char data[65536]; usize n; int ok; } Text;
static void text(Text *t, const char *p)
{
while(*p) {
if(t->n>=sizeof(t->data)) { t->ok=0;return; }
t->data[t->n++]=*p++;
}
}
static void integer(Text *t, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
while(n) { char a[2]={rev[--n],0};text(t,a); }
}
static void decimal(Text *t, float value)
{
if(value<0) { text(t,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
integer(t,scaled/10000);text(t,".");
u64 fraction=scaled%10000;
if(fraction<1000) text(t,"0");
if(fraction<100) text(t,"0");
if(fraction<10) text(t,"0");
integer(t,fraction);
}
static void space_number(Text *t, float n) { text(t," ");decimal(t,n); }
static int uuid(Text *t)
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { char pair[3]={hex[bytes[i]>>4],hex[bytes[i]&15],0};text(t,pair); }
return 1;
}
static int record(Text *t, const char *name)
{
text(t,name);text(t," ");return uuid(t);
}
static int path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
static void le32(u8 *out,u32 v) { for(int i=0;i<4;i++) out[i]=(u8)(v>>(8*i)); }
static const char *write_files(Scan *s)
{
Text t={.ok=1};text(&t,"frameport-room 1\nuniverse ");integer(&t,s->opt.universe);
text(&t,"\nsource scan\ntime ");integer(&t,(u64)(io_time(0)/1000000000));text(&t,"\n");
if(!record(&t,"room")) return "getrandom failed";
text(&t,"\n");if(!record(&t,"floor")) return "getrandom failed";
space_number(&t,s->floor);text(&t,"\n");if(!record(&t,"ceiling")) return "getrandom failed";
space_number(&t,s->ceiling);text(&t,"\noutline ");integer(&t,(u64)s->noutline);
for(int i=0;i<s->noutline;i++) { space_number(&t,s->outline[i].x);space_number(&t,s->outline[i].z); }
text(&t,"\n");
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
if(!record(&t,"wall")) return "getrandom failed";
space_number(&t,a.x);space_number(&t,a.z);space_number(&t,b.x);space_number(&t,b.z);
space_number(&t,s->floor);space_number(&t,s->ceiling);
if(s->invisible[i]) text(&t," INVISIBLE_WALL_FACE");
text(&t,"\n");
}
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];
if(!record(&t,"volume")) return "getrandom failed";
text(&t," ");text(&t,v->label);space_number(&t,v->p.x);space_number(&t,v->p.z);
space_number(&t,v->p.y);space_number(&t,v->yaw);space_number(&t,v->w);space_number(&t,v->d);space_number(&t,v->h);
text(&t,"\n");
}
if(!record(&t,"mesh")) return "getrandom failed";
text(&t," room-mesh.bin ");integer(&t,(u64)s->nv);text(&t," ");integer(&t,(u64)s->ni);text(&t,"\n");
if(!t.ok) return "room text exceeds output budget";
Text rname={.ok=1},mname={.ok=1};
text(&rname,"room.txt.");text(&mname,"room-mesh.bin.");
if(!uuid(&rname) || !uuid(&mname)) return "getrandom failed";
text(&rname,".tmp");text(&mname,".tmp");rname.data[rname.n]=0;mname.data[mname.n]=0;
char room[4096],mesh[4096],rtmp[4096],mtmp[4096];
if(!path(room,s->opt.out,"room.txt") || !path(mesh,s->opt.out,"room-mesh.bin") ||
!path(rtmp,s->opt.out,rname.data) || !path(mtmp,s->opt.out,mname.data)) return "output path too long";
if(!io_mkdir(s->opt.out)) return "cannot create output directory";
int fd=io_open(rtmp,1),ok;
if(fd<0) return "cannot open room temporary file";
ok=io_write(fd,t.data,t.n);if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);return "cannot write room temporary file"; }
fd=io_open(mtmp,1);
if(fd<0) { io_remove(rtmp);return "cannot open mesh temporary file"; }
u8 header[16]={'F','P','R','M'};le32(header+4,1);le32(header+8,(u32)s->nv);le32(header+12,(u32)s->ni);
ok=io_write(fd,header,16);
/* Both supported targets are little-endian IEEE float32; parser always decodes explicitly. */
ok=ok && io_write(fd,s->vertices,(usize)s->nv*sizeof(Vec));
ok=ok && io_write(fd,s->indices,(usize)s->ni*sizeof(u32));
if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);io_remove(mtmp);return "cannot write mesh temporary file"; }
if(io_rename(mtmp,mesh)) { io_remove(rtmp);io_remove(mtmp);return "cannot publish mesh"; }
if(io_rename(rtmp,room)) { io_remove(rtmp);return "cannot publish room"; }
return 0;
}
static int error_json(const char *error)
{
Text t={.ok=1};text(&t,"{\"ok\":false,\"error\":\"");text(&t,error);text(&t,"\"}\n");
io_write(1,t.data,t.n);return 2;
Buf t;buf_init(&t,4096);put(&t,"{\"ok\":false,\"error\":\"");put(&t,error);put(&t,"\"}\n");
io_write(1,t.data,t.n);buf_free(&t);return 2;
}
static int parse_float(const char *p,float *out)
{
@@ -125,21 +25,60 @@ static int parse_universe(const char *p,u64 *out)
}
*out=n;return 1;
}
/* "x,y,z,r;x,y,z,r;..." (metres, STAGE space) */
static int parse_erase(const char *value, Refine *o)
{
char *p=(char *)value;o->nerase=0;
while(*p) {
if(o->nerase==MAX_ERASE) return 0;
float v[4];
for(int i=0;i<4;i++) {
if(!parse_number(&p,&v[i])) return 0;
if(i<3) { if(*p!=',') return 0;p++; }
}
if(v[3]<=0 || v[3]>5) return 0;
o->erase[o->nerase++]=(Sphere){v[0],v[1],v[2],v[3]};
while(*p==' ') p++;
if(*p==';') p++;
else if(*p) return 0;
}
return 1;
}
static const char *options(Options *o,int argc,char **argv)
{
*o=(Options){.voxel=.05f,.ceiling_default=2.5f,.truncation=3,.max_edge=.6f,.grazing=.12f,.range=4.f,
.depth_jump=.25f,.depth_ratio=.12f,.min_views=5,.patch_residual=.03f,
.plane_distance=.045f,.min_plane_area=.45f,.wall_height=1.5f,.snap_degrees=5,.floor_band=.35f,
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f};
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f,
/* The owner's refinement settings (room-scan cleanup page, 2026-10-18). */
.rf={.refine=1,.walls=2,.protect=0,.label_rules=1,.smooth=8,.band=.35f,.facing_deg=60,.band_in=.12f,
.floor_in=.08f,.ceil_in=.25f,.top_strip=.5f,.near=.03f,.clip=.05f,.clip_open=.10f,.island=.20f,
.tops=.10f,.tubes=3,.tube_thick=.15f,.walked=.40f}};
for(int i=1;i<argc;i++) {
const char *arg=argv[i];
if(same(arg,"--json")) continue;
if(i+1==argc) return "option requires a value";
const char *value=argv[++i];
float number=0;
if(same(arg,"--offline")) o->input=value;
else if(same(arg,"--out")) o->out=value;
else if(same(arg,"--hint")) o->hint=value;
else if(same(arg,"--refine-only")) o->refine_only=value;
else if(same(arg,"--universe")) { if(!parse_universe(value,&o->universe)) return "invalid universe"; }
else if(same(arg,"--walls")) {
if(same(value,"off")) o->rf.walls=0;
else if(same(value,"snap")) o->rf.walls=1;
else if(same(value,"panels")) o->rf.walls=2;
else return "option outside supported range";
}
else if(same(arg,"--erase")) { if(!parse_erase(value,&o->rf)) return "invalid erase spheres"; }
else if(same(arg,"--refine") || same(arg,"--protect") || same(arg,"--label-rules") || same(arg,"--smooth")) {
int high=same(arg,"--smooth") ? 50 : 1;
if(!parse_float(value,&number) || number!=(float)(int)number || number>high) return "option outside supported range";
int *dest=same(arg,"--refine") ? &o->rf.refine : same(arg,"--protect") ? &o->rf.protect :
same(arg,"--label-rules") ? &o->rf.label_rules : &o->rf.smooth;
*dest=(int)number;
}
else {
float *dest=0,low=0,high=100;
if(same(arg,"--voxel")) { dest=&o->voxel;low=.04f;high=.2f; }
@@ -162,44 +101,265 @@ static const char *options(Options *o,int argc,char **argv)
else if(same(arg,"--cluster-gap")) { dest=&o->cluster_gap;low=.04f;high=.2f; }
else if(same(arg,"--gap")) { dest=&o->gap;low=.1f;high=1.5f; }
else if(same(arg,"--horizontal")) { dest=&o->horizontal;low=.6f;high=.99f; }
else if(same(arg,"--band")) { dest=&o->rf.band;low=.02f;high=1; }
else if(same(arg,"--facing-deg")) { dest=&o->rf.facing_deg;low=5;high=89; }
else if(same(arg,"--band-in")) { dest=&o->rf.band_in;high=1; }
else if(same(arg,"--floor-in")) { dest=&o->rf.floor_in;high=1; }
else if(same(arg,"--ceil-in")) { dest=&o->rf.ceil_in;high=1; }
else if(same(arg,"--top-strip")) { dest=&o->rf.top_strip;high=2; }
else if(same(arg,"--near")) { dest=&o->rf.near;high=.3f; }
else if(same(arg,"--clip")) { dest=&o->rf.clip;high=1; }
else if(same(arg,"--clip-open")) { dest=&o->rf.clip_open;high=2; }
else if(same(arg,"--island")) { dest=&o->rf.island;high=5; }
else if(same(arg,"--tops")) { dest=&o->rf.tops;high=.5f; }
else if(same(arg,"--tubes")) { dest=&o->rf.tubes;high=20; }
else if(same(arg,"--tube-thick")) { dest=&o->rf.tube_thick;low=.01f;high=1; }
else if(same(arg,"--walked")) { dest=&o->rf.walked;high=1.5f; }
else return "unknown option";
if(!parse_float(value,dest) || *dest<low || *dest>high) return "option outside supported range";
}
}
if(o->rf.walked>0 && o->rf.walked<.05f) return "option outside supported range";
if(o->refine_only) return o->input ? "--refine-only takes no --offline dump" : 0;
return o->input && o->out ? 0 : "required: --offline dump.fprs --out directory";
}
static void duration(Text *t,i64 a,i64 b) { decimal(t,(float)(b-a)/1000000); }
static void summary(Scan *s)
/* ------------------------------------------------------------------------------------------------ refinement */
typedef struct {
Report rep;
Label labels[MAX_VOLUMES];
int nlabels, changed, removed, nsuggest, out_triangles, out_volumes;
float ceiling_y;
i64 ms;
} Outcome;
static const char *copy_name(char *dst, const char *src, usize cap)
{
Text t={.ok=1};text(&t,"{\"ok\":true,\"frames\":");integer(&t,s->frames);
text(&t,",\"skipped_frames\":");integer(&t,s->skipped_frames);
text(&t,",\"far_vertices\":");integer(&t,s->far_vertices);
text(&t,",\"discontinuity_triangles\":");integer(&t,s->discontinuities);
text(&t,",\"walls\":");integer(&t,(u64)s->noutline);
text(&t,",\"detected_walls\":");integer(&t,(u64)s->nplanes);
text(&t,",\"volumes\":");integer(&t,(u64)s->nvolumes);
text(&t,",\"floor_y\":");decimal(&t,s->floor);text(&t,",\"ceiling_y\":");decimal(&t,s->ceiling);
text(&t,",\"coverage_percent\":");decimal(&t,s->coverage);
text(&t,",\"hint_used\":");text(&t,s->hint_used ? "true" : "false");
text(&t,",\"triangles\":");integer(&t,(u64)s->ni/3);
text(&t,",\"accepted_triangles\":");integer(&t,s->accepted);
text(&t,",\"rejected_triangles\":");integer(&t,s->rejected);
text(&t,",\"warnings\":[");
for(int i=0;i<s->nwarnings;i++) { if(i) text(&t,",");text(&t,"\"");text(&t,s->warnings[i]);text(&t,"\""); }
text(&t,"],\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
text(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
text(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
text(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
text(&t,",\"total\":");duration(&t,s->t0,io_time(1));text(&t,"}}\n");io_write(1,t.data,t.n);
usize i=0;while(src[i] && i+1<cap) { dst[i]=src[i];i++; }
dst[i]=0;return dst;
}
static void box_json(Buf *b, const RVol *v)
{
put(b,"{\"cx\":");put_dec(b,v->cx);put(b,",\"cz\":");put_dec(b,v->cz);put(b,",\"top_y\":");put_dec(b,v->top);
put(b,",\"yaw_deg\":");put_dec(b,v->yaw);put(b,",\"w\":");put_dec(b,v->w);put(b,",\"d\":");put_dec(b,v->d);
put(b,",\"h\":");put_dec(b,v->h);put(b,"}");
}
static void scan_json(Buf *b, const Refine *o, const Vec *heads, int nheads, const Outcome *r)
{
put(b,"{\"version\":1,\"heads\":");heads_json(b,heads,nheads);
put(b,",\"refine\":");settings_json(b,o);
put(b,",\"report\":");report_json(b,&r->rep,r->nlabels,r->changed,r->removed,r->nsuggest);
put(b,",\"labels\":[");
for(int i=0;i<r->nlabels;i++) {
const Label *l=&r->labels[i];
if(i) put(b,",");
put(b,"{\"from\":\"");put(b,l->from);put(b,"\",\"to\":");
if(l->to[0]) { put(b,"\"");put(b,l->to);put(b,"\""); } else put(b,"null");
put(b,",\"reason\":\"");put(b,l->reason);put(b,"\"");
if(l->suggest[0]) { put(b,",\"suggest\":\"");put(b,l->suggest);put(b,"\""); }
put(b,",\"id\":\"");put(b,l->box.id);put(b,"\",\"box\":");box_json(b,&l->box);put(b,"}");
}
put(b,"],\"suggestions\":[");
int first=1;
for(int i=0;i<r->nlabels;i++) {
const Label *l=&r->labels[i];
if(!l->suggest[0]) continue;
if(!first) put(b,",");
first=0;
put(b,"{\"kind\":\"");put(b,l->suggest);put(b,"\",\"box\":");box_json(b,&l->box);
put(b,",\"wall\":");put_int(b,l->wall);put(b,",\"along\":");put_dec(b,l->along);
put(b,",\"reason\":\"");put(b,l->reason);put(b,"\"}");
}
put(b,"]}\n");
}
/* Refines `raw` (as read back from its own text) and publishes: [raw mesh, raw text,] scan.json, room-mesh.bin,
* room.txt. Everything is computed before the first file is written, so a failure leaves the old files. */
static const char *refine_publish(const char *dir, const Room *raw, const Vec *v, const u32 *idx, int nv, int ni,
const Vec *heads, int nheads, const Refine *o, int write_raw, Outcome *res)
{
static Room refined;
static RVol ruled[MAX_VOLUMES];
const char *error=0;
Prep prep={0};
Vec *rv=0;u32 *ri=0;int rn=0;
u8 *raw_mesh=0,*mesh=0;usize raw_size=0,mesh_size=0;
Buf raw_text,text,json;
buf_init(&raw_text,1u<<20);buf_init(&text,1u<<20);buf_init(&json,MAX_JSON_FILE);
i64 t0=io_time(1);
memset(res,0,sizeof(*res));
refined=*raw;
copy_name(refined.mesh_name,"room-mesh.bin",sizeof(refined.mesh_name));
if(o->refine) {
error=prep_mesh(&prep,v,idx,nv,ni);
int nruled=raw->nvols;
if(!error) {
if(o->label_rules) res->nlabels=rules_apply(raw,&prep,ruled,&nruled,res->labels);
else memcpy(ruled,raw->vols,sizeof(RVol)*(usize)raw->nvols);
}
if(!error) error=refine_run(o,raw,&prep,ruled,nruled,heads,nheads,&rv,&ri,&rn,&res->rep);
if(!error) {
refined.ceiling=res->rep.ceiling_y;
for(int i=0;i<refined.nwalls;i++) if(refined.walls[i].y1==raw->ceiling) refined.walls[i].y1=refined.ceiling;
memcpy(refined.vols,ruled,sizeof(RVol)*(usize)nruled);refined.nvols=nruled;
refined.mesh_nv=refined.mesh_ni=(u32)rn;refined.has_mesh=1;
for(int i=0;i<res->nlabels;i++) {
Label *l=&res->labels[i];
if(l->suggest[0]) res->nsuggest++;
if(!l->to[0]) res->removed++;
else if(!same(l->to,l->from)) res->changed++;
}
}
}
if(!error) error=mesh_bytes(rv ? rv : v,rv ? ri : idx,rv ? rn : nv,rv ? rn : ni,&mesh,&mesh_size);
if(!error && write_raw) error=mesh_bytes(v,idx,nv,ni,&raw_mesh,&raw_size);
if(!error && write_raw && !room_text(raw,&raw_text)) error="room text exceeds output budget";
if(!error && !room_text(&refined,&text)) error="room text exceeds output budget";
if(!error) { scan_json(&json,o,heads,nheads,res);if(!json.ok) error="scan.json exceeds output budget"; }
res->ms=io_time(1)-t0;
res->out_triangles=(int)refined.mesh_ni/3;res->out_volumes=refined.nvols;res->ceiling_y=refined.ceiling;
if(!error && !io_mkdir(dir)) error="cannot create output directory";
if(!error && write_raw) error=publish(dir,"room-mesh-raw.bin",raw_mesh,raw_size);
if(!error && write_raw) error=publish(dir,"room-raw.txt",raw_text.data,raw_text.n);
if(!error) error=publish(dir,"scan.json",json.data,json.n);
if(!error) error=publish(dir,"room-mesh.bin",mesh,mesh_size);
if(!error) error=publish(dir,"room.txt",text.data,text.n);
prep_free(&prep);
io_free(rv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(ri,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(raw_mesh,raw_size);io_free(mesh,mesh_size);
buf_free(&raw_text);buf_free(&text);buf_free(&json);
return error;
}
static void refine_summary(Buf *t, const Outcome *r)
{
put(t,"\"refine\":");report_json(t,&r->rep,r->nlabels,r->changed,r->removed,r->nsuggest);
if(t->ok && t->n) t->n--; /* reopen the object for the output counts */
put(t,",\"output_triangles\":");put_int(t,r->out_triangles);
put(t,",\"output_volumes\":");put_int(t,r->out_volumes);put(t,"}");
}
/* ------------------------------------------------------------------------------------------------ summaries */
static void duration(Buf *t,i64 a,i64 b) { put_dec(t,(float)(b-a)/1000000); }
static void warnings_json(Buf *t, const char **w, int n)
{
put(t,",\"warnings\":[");
for(int i=0;i<n;i++) { if(i) put(t,",");put(t,"\"");put(t,w[i]);put(t,"\""); }
put(t,"]");
}
static void summary(Scan *s, const Outcome *r)
{
Buf t;buf_init(&t,1u<<16);put(&t,"{\"ok\":true,\"frames\":");put_u64(&t,s->frames);
put(&t,",\"skipped_frames\":");put_u64(&t,s->skipped_frames);
put(&t,",\"far_vertices\":");put_u64(&t,s->far_vertices);
put(&t,",\"discontinuity_triangles\":");put_u64(&t,s->discontinuities);
put(&t,",\"walls\":");put_u64(&t,(u64)s->noutline);
put(&t,",\"detected_walls\":");put_u64(&t,(u64)s->nplanes);
put(&t,",\"volumes\":");put_u64(&t,(u64)s->nvolumes);
put(&t,",\"floor_y\":");put_dec(&t,s->floor);put(&t,",\"ceiling_y\":");put_dec(&t,s->ceiling);
put(&t,",\"coverage_percent\":");put_dec(&t,s->coverage);
put(&t,",\"hint_used\":");put(&t,s->hint_used ? "true" : "false");
put(&t,",\"triangles\":");put_u64(&t,(u64)s->ni/3);
put(&t,",\"accepted_triangles\":");put_u64(&t,s->accepted);
put(&t,",\"rejected_triangles\":");put_u64(&t,s->rejected);
put(&t,",\"heads\":");put_int(&t,s->nheads);put(&t,",");
refine_summary(&t,r);
warnings_json(&t,s->warnings,s->nwarnings);
put(&t,",\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
put(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
put(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
put(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
put(&t,",\"refine\":");duration(&t,s->tmesh,s->trefine);
put(&t,",\"total\":");duration(&t,s->t0,io_time(1));put(&t,"}}\n");io_write(1,t.data,t.n);buf_free(&t);
}
/* ------------------------------------------------------------------------------------------------ scan path */
static const char *scan_outputs(Scan *s, Outcome *out)
{
static Room built,raw;
static Vec heads[MAX_HEADS];
const char *error=room_from_scan(s,&built);
if(error) return error;
copy_name(built.mesh_name,"room-mesh-raw.bin",sizeof(built.mesh_name));
/* Refine what the raw files will hold, so --refine-only on them gives the same refined files. */
Buf t;buf_init(&t,1u<<20);
if(!room_text(&built,&t)) { buf_free(&t);return "room text exceeds output budget"; }
error=room_parse(t.data,t.n,&raw);
buf_free(&t);
if(error) return error;
Buf h;buf_init(&h,1u<<20);heads_json(&h,s->heads,s->nheads);put(&h,"}");
Buf wrap;buf_init(&wrap,1u<<20);put(&wrap,"{\"heads\":");put_n(&wrap,h.data,h.n);
int nheads=wrap.ok ? heads_parse(wrap.data,wrap.n,heads) : 0;
buf_free(&h);buf_free(&wrap);
return refine_publish(s->opt.out,&raw,s->vertices,s->indices,s->nv,s->ni,heads,nheads,&s->opt.rf,1,out);
}
/* ------------------------------------------------------------------------------------------------ --refine-only */
static int refine_only(Options *o)
{
static Room raw;
static Vec heads[MAX_HEADS];
static Outcome res;
const char *warns[4];int nwarn=0;
i64 t0=io_time(1);
const char *dir=o->refine_only,*error=0;
char path[4096];
u8 *text=0,*bin=0,*json=0;usize text_n=0,bin_n=0,json_n=0;
Vec *v=0;u32 *idx=0;int nv=0,ni=0,nheads=0,write_raw=0;
if(!join_path(path,dir,"room-raw.txt")) return error_json("output path too long");
error=read_file(path,1u<<20,&text,&text_n);
if(error) {
/* An older room folder: its room.txt + mesh are the unrefined scan. */
write_raw=1;
if(!join_path(path,dir,"room.txt")) return error_json("output path too long");
error=read_file(path,1u<<20,&text,&text_n);
if(error) return error_json("room folder has neither room-raw.txt nor room.txt");
}
error=room_parse((const char *)text,text_n,&raw);
io_free(text,(1u<<20)+1);
if(error) return error_json(error);
if(!raw.has_mesh) return error_json("room has no mesh to refine");
if(!join_path(path,dir,write_raw ? raw.mesh_name : "room-mesh-raw.bin")) return error_json("output path too long");
error=read_file(path,MAX_RAW_FILE,&bin,&bin_n);
if(!error) error=mesh_parse(bin,bin_n,&v,&idx,&nv,&ni);
io_free(bin,MAX_RAW_FILE+1);
if(error) return error_json(error);
if((u32)nv!=raw.mesh_nv || (u32)ni!=raw.mesh_ni) {
io_free(v,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(idx,(usize)MAX_TRIANGLES*3*sizeof(u32));
return error_json("mesh record does not match the mesh file");
}
copy_name(raw.mesh_name,"room-mesh-raw.bin",sizeof(raw.mesh_name));
if(join_path(path,dir,"scan.json") && !read_file(path,MAX_JSON_FILE,&json,&json_n)) {
nheads=heads_parse((const char *)json,json_n,heads);
io_free(json,MAX_JSON_FILE+1);
}
if(!nheads && o->rf.walked>0) warns[nwarn++]="no head path; walked volume skipped";
if(write_raw) warns[nwarn++]="no raw files; the current room was taken as the unrefined scan";
error=refine_publish(dir,&raw,v,idx,nv,ni,heads,nheads,&o->rf,write_raw,&res);
io_free(v,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(idx,(usize)MAX_TRIANGLES*3*sizeof(u32));
if(error) return error_json(error);
if(res.rep.truncated) warns[nwarn++]="refined mesh truncated to the triangle budget";
Buf t;buf_init(&t,1u<<16);
put(&t,"{\"ok\":true,\"refine_only\":true,\"raw_triangles\":");put_int(&t,ni/3);
put(&t,",\"volumes\":");put_int(&t,raw.nvols);put(&t,",\"floor_y\":");put_dec(&t,raw.floor);
put(&t,",\"ceiling_y\":");put_dec(&t,res.ceiling_y);put(&t,",\"heads\":");put_int(&t,nheads);put(&t,",");
refine_summary(&t,&res);
warnings_json(&t,warns,nwarn);
put(&t,",\"timings_ms\":{\"refine\":");put_dec(&t,(float)res.ms/1000000);
put(&t,",\"total\":");duration(&t,t0,io_time(1));put(&t,"}}\n");
io_write(1,t.data,t.n);buf_free(&t);
return 0;
}
int main(int argc,char **argv)
{
if(argc==2 && same(argv[1],"--help")) {
const char usage[]="fp_roomscan --offline dump.fprs --out directory [--universe N] [--voxel 0.05]\n"
" [--ceiling-default 2.5] [--hint outline.txt] [--json]\n"
"fp_roomscan --refine-only room-directory [refinement options] [--json]\n"
"Tuning: --truncation --max-edge --grazing --range --depth-jump --depth-ratio --plane-distance --min-plane-area\n"
" --min-views --patch-residual --wall-height --snap-degrees --floor-band --ceiling-min\n"
" --min-volume --cluster-gap --gap --horizontal\n"
"Refinement: --refine 0|1 --walls off|snap|panels --band --facing-deg --band-in --floor-in --ceil-in\n"
" --top-strip --near --clip --clip-open --island --smooth --tops --protect 0|1 --tubes --tube-thick\n"
" --walked --label-rules 0|1 --erase \"x,y,z,r;...\"\n"
"Always prints one JSON summary; errors exit 2. See docs/mr/scan.md for units and ranges.\n";
io_write(1,usage,sizeof(usage)-1);return 0;
}
@@ -207,6 +367,11 @@ int main(int argc,char **argv)
if(!s) return error_json("scan allocation failed");
s->rng=0x714ce3u;s->t0=io_time(1);
const char *error=options(&s->opt,argc,argv);
if(!error && s->opt.refine_only) {
int result=refine_only(&s->opt);
io_free(s,sizeof(Scan));return result;
}
static Outcome outcome;
if(!error) error=read_dump(s,0);
s->tread=io_time(1);
if(!error) error=init_grid(s);
@@ -217,10 +382,13 @@ int main(int argc,char **argv)
s->tmodel=io_time(1);
if(!error) error=make_mesh(s);
s->tmesh=io_time(1);
if(!error) error=write_files(s);
/* The grid is not needed after the mesh: freeing it keeps the refinement inside the same memory budget. */
io_free(s->cells,(usize)s->count*sizeof(Cell));s->cells=0;
if(!error) error=scan_outputs(s,&outcome);
s->trefine=io_time(1);
if(!error && outcome.rep.truncated) warn(s,"refined mesh truncated to the triangle budget");
int result=0;
if(error) result=error_json(error);else summary(s);
io_free(s->cells,(usize)s->count*sizeof(Cell));
if(error) result=error_json(error);else summary(s,&outcome);
io_free(s->vertices,(usize)MAX_MESH_VERTICES*sizeof(Vec));
io_free(s->indices,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(s,sizeof(Scan));return result;
+2 -2
View File
@@ -596,7 +596,7 @@ const char *make_model(Scan *s)
return error;
}
static int number(char **cursor, float *value)
int parse_number(char **cursor, float *value)
{
char *p=*cursor;while (*p==' ' || *p=='\t' || *p=='\r') p++;
int sign=1,digits=0;if (*p=='-' || *p=='+') { if (*p=='-') sign=-1;p++; }
@@ -631,7 +631,7 @@ static const char *hint(Scan *s)
while (*p==' ' || *p=='\t' || *p=='\r' || *p=='\n') p++;
if (!*p) break;
if (*p=='#') { while (*p && *p!='\n') p++;continue; }
if (n==MAX_OUTLINE || !number(&p,&pts[n].x) || !number(&p,&pts[n].z)) return "invalid outline hint";
if (n==MAX_OUTLINE || !parse_number(&p,&pts[n].x) || !parse_number(&p,&pts[n].z)) return "invalid outline hint";
n++;while (*p==' ' || *p=='\t' || *p=='\r') p++;
if (*p && *p!='\n' && *p!='#') return "invalid outline hint";
if (*p=='#') while (*p && *p!='\n') p++;
+548
View File
@@ -0,0 +1,548 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Mesh refinement: the passes of the owner's tuned cleanup page (2026-10-18), in its order and with its rules:
* plane offsets re-estimated from the scan, structural (wall/floor/ceiling) triangles classified with one-sided
* bands, erase spheres, the walked volume, flat panels or snapping, furniture tops, clipping outside the outline,
* small islands, thin floating pieces, Taubin smoothing. docs/mr/scan.md "Refinement" describes each pass. */
#include "refine.h"
#define WELD_MM 1000.0 /* vertices closer than 1 mm (quantized) are one vertex */
#define WELD_BIAS 16384 /* key offset, as the reference: +-16 m */
#define FURNITURE_PAD .1 /* m around a box that still counts as the box */
#define MIN_SHIFT_SAMPLES 50 /* plane offsets move only with more facing triangles than this */
#define WALKED_BELOW .2 /* m above the floor: the walked column starts here (feet, rugs stay) */
#define WALKED_ABOVE .25 /* m above the head: the column ends here */
#define TOP_NORMAL_Y .8 /* a triangle this upward-facing near a box top is part of the top */
#define TUBE_MIN_TRIANGLES 10
#define TUBE_MAX_AREA 1.0 /* m^2: bigger pieces are never thin floating pieces */
#define TUBE_MAX_FURNITURE .5 /* share of a piece inside furniture boxes that keeps it */
#define TAUBIN_LAMBDA .5
#define TAUBIN_MU -.53
double dfloor(double x) { i64 i=(i64)x; return x<(double)i ? (double)(i-1) : (double)i; }
double droot(double x) { return x>0 ? __builtin_sqrt(x) : 0; }
/* Double-precision sine (range reduction + Taylor series to x^19, error < 1e-15), as Math.sin. */
double dsin(double x)
{
const double pi=3.14159265358979323846;
x-=dfloor(x/(2*pi)+.5)*(2*pi);
if(x>pi/2) x=pi-x;
if(x<-pi/2) x=-pi-x;
double q=x*x,term=x,sum=x;
for(int k=1;k<10;k++) { term*=-q/((2*k)*(2*k+1));sum+=term; }
return sum;
}
double dcos(double x) { return dsin(x+3.14159265358979323846/2); }
/* Math.hypot as V8 computes it (scaled, Kahan-compensated): distance ties at outline corners break the same way. */
double dhypot(double a, double b)
{
a=a<0 ? -a : a;b=b<0 ? -b : b;
double m=a>b ? a : b;
if(m==0) return 0;
double sum=0,comp=0,v[2]={a/m,b/m};
for(int i=0;i<2;i++) { double s=v[i]*v[i]-comp,p=sum+s;comp=(p-sum)-s;sum=p; }
return droot(sum)*m;
}
static double dab(double x) { return x<0 ? -x : x; }
static void *alloc(usize n) { return n ? io_alloc(n) : 0; }
/* ------------------------------------------------------------------------------------------------ welding */
static u64 mix(u64 k) { k^=k>>33;k*=0xff51afd7ed558ccdull;k^=k>>33;k*=0xc4ceb9fe1a85ec53ull;k^=k>>33;return k; }
static i64 quant(float v) { return (i64)dfloor((double)v*WELD_MM+.5)+WELD_BIAS; }
const char *prep_mesh(Prep *p, const Vec *v, const u32 *idx, int nv, int ni)
{
*p=(Prep){0};
int T=ni/3;
usize cap=1024;while(cap<(usize)nv*2) cap*=2;
i64 *keys=alloc(cap*sizeof(i64));
int *slot=alloc(cap*sizeof(int)),*wid=alloc((usize)(nv ? nv : 1)*sizeof(int));
double *sums=alloc((usize)(nv ? nv : 1)*4*sizeof(double));
const char *error=0;
if(!keys || !slot || !wid || !sums) { error="refine allocation failed";goto done; }
int W=0;
for(int i=0;i<nv;i++) {
i64 key=(quant(v[i].x)*32768+quant(v[i].y))*32768+quant(v[i].z);
usize h=(usize)mix((u64)key)&(cap-1);
while(slot[h] && keys[h]!=key) h=(h+1)&(cap-1);
if(!slot[h]) { keys[h]=key;slot[h]=++W; }
int w=slot[h]-1;wid[i]=w;
sums[4*w]+=v[i].x;sums[4*w+1]+=v[i].y;sums[4*w+2]+=v[i].z;sums[4*w+3]+=1;
}
p->T=T;p->W=W;
p->pos=alloc((usize)(W ? W : 1)*3*sizeof(double));
p->F=alloc((usize)(T ? T : 1)*3*sizeof(int));
p->fn=alloc((usize)(T ? T : 1)*3*sizeof(float));
p->cen=alloc((usize)(T ? T : 1)*3*sizeof(float));
p->area=alloc((usize)(T ? T : 1)*sizeof(float));
if(!p->pos || !p->F || !p->fn || !p->cen || !p->area) { error="refine allocation failed";goto done; }
for(int w=0;w<W;w++) for(int k=0;k<3;k++) p->pos[3*w+k]=sums[4*w+k]/sums[4*w+3];
for(int k=0;k<T*3;k++) p->F[k]=wid[idx[k]];
for(int t=0;t<T;t++) {
const double *a=p->pos+3*p->F[3*t],*b=p->pos+3*p->F[3*t+1],*c=p->pos+3*p->F[3*t+2];
double ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2],wx=c[0]-a[0],wy=c[1]-a[1],wz=c[2]-a[2];
double nx=uy*wz-uz*wy,ny=uz*wx-ux*wz,nz=ux*wy-uy*wx,l=droot(nx*nx+ny*ny+nz*nz);
if(!(l>0)) l=1e-12;
p->fn[3*t]=(float)(nx/l);p->fn[3*t+1]=(float)(ny/l);p->fn[3*t+2]=(float)(nz/l);p->area[t]=(float)(l/2);
for(int k=0;k<3;k++) p->cen[3*t+k]=(float)((a[k]+b[k]+c[k])/3);
}
done:
io_free(keys,cap*sizeof(i64));io_free(slot,cap*sizeof(int));
io_free(wid,(usize)(nv ? nv : 1)*sizeof(int));io_free(sums,(usize)(nv ? nv : 1)*4*sizeof(double));
if(error) prep_free(p);
return error;
}
void prep_free(Prep *p)
{
int T=p->T ? p->T : 1,W=p->W ? p->W : 1;
io_free(p->pos,(usize)W*3*sizeof(double));io_free(p->F,(usize)T*3*sizeof(int));
io_free(p->fn,(usize)T*3*sizeof(float));io_free(p->cen,(usize)T*3*sizeof(float));io_free(p->area,(usize)T*sizeof(float));
*p=(Prep){0};
}
/* ------------------------------------------------------------------------------------------------ helpers */
typedef struct {
int kind, wall; /* 0 wall (index into room->walls), 1 floor, 2 ceiling */
double n[3], off, shift;
double ox, oz, ux, uz, L;
} RPlane;
typedef struct { double cs, sn; const RVol *v; } Box;
static void box_local(const Box *b, double x, double z, double *lx, double *lz)
{
double dx=x-b->v->cx,dz=z-b->v->cz;
*lx=b->cs*dx-b->sn*dz;*lz=b->sn*dx+b->cs*dz;
}
static double s_dist(const RPlane *pl, const float *c) { return pl->n[0]*c[0]+pl->n[1]*c[1]+pl->n[2]*c[2]-pl->off; }
static int in_ext(const RPlane *pl, const float *c, double m)
{
if(pl->kind) return 1;
double s=(c[0]-pl->ox)*pl->ux+(c[2]-pl->oz)*pl->uz;
return s>-m && s<pl->L+m;
}
static int point_in_poly(double x, double z, const DPt *poly, int n)
{
int inside=0;
for(int i=0,j=n-1;i<n;j=i++) {
double xi=poly[i].x,zi=poly[i].z,xj=poly[j].x,zj=poly[j].z;
if((zi>z)!=(zj>z) && x<(xj-xi)*(z-zi)/(zj-zi+1e-12)+xi) inside=!inside;
}
return inside;
}
/* Distance outside the outline (0 inside) and the extra allowance of the nearest wall (unseen walls). */
static double outside_info(const Room *r, double x, double z, double open_extra, double *allow)
{
double best=1e300;*allow=0;
for(int i=0;i<r->nwalls;i++) {
const RWall *w=&r->walls[i];
double abx=w->x1-w->x0,abz=w->z1-w->z0,l2=abx*abx+abz*abz;
double t=l2>0 ? ((x-w->x0)*abx+(z-w->z0)*abz)/l2 : 0;
t=t<0 ? 0 : t>1 ? 1 : t;
double dx=x-(w->x0+t*abx),dz=z-(w->z0+t*abz),d=dhypot(dx,dz);
if(d<best) { best=d;*allow=w->invisible ? open_extra : 0; }
}
return point_in_poly(x,z,r->outline,r->noutline) ? 0 : best;
}
static void nth(float *a, int n, int k)
{
int lo=0,hi=n-1;
while(lo<hi) {
float pivot=a[(lo+hi)/2];int i=lo,j=hi;
while(i<=j) {
while(a[i]<pivot) i++;
while(a[j]>pivot) j--;
if(i<=j) { float t=a[i];a[i++]=a[j];a[j--]=t; }
}
if(k<=j) hi=j;else if(k>=i) lo=i;else break;
}
}
static int find(int *parent, int x)
{
while(parent[x]!=x) { parent[x]=parent[parent[x]];x=parent[x]; }
return x;
}
static void unite(int *parent, const int *F, const u8 *keep, int T, int W)
{
for(int i=0;i<W;i++) parent[i]=i;
for(int t=0;t<T;t++) if(keep[t]) {
int r=find(parent,F[3*t]);
parent[find(parent,F[3*t+1])]=r;parent[find(parent,F[3*t+2])]=r;
}
}
/* Eigenvalues of a symmetric 3x3 matrix by Jacobi rotations, as the reference's eigSym3. */
static void eig3(double a[3][3], double out[3])
{
static const int pairs[3][2]={{0,1},{0,2},{1,2}};
for(int sweep=0;sweep<20;sweep++) {
double off=dab(a[0][1])+dab(a[0][2])+dab(a[1][2]);
if(off<1e-14) break;
for(int k=0;k<3;k++) {
int p=pairs[k][0],q=pairs[k][1];
if(dab(a[p][q])<1e-18) continue;
double th=(a[q][q]-a[p][p])/(2*a[p][q]);
double t=(th<0 ? -1 : 1)/(dab(th)+droot(th*th+1)),c=1/droot(t*t+1),s=t*c;
for(int m=0;m<3;m++) { double akp=a[m][p],akq=a[m][q];a[m][p]=c*akp-s*akq;a[m][q]=s*akp+c*akq; }
for(int m=0;m<3;m++) { double apk=a[p][m],aqk=a[q][m];a[p][m]=c*apk-s*aqk;a[q][m]=s*apk+c*aqk; }
}
}
out[0]=a[0][0];out[1]=a[1][1];out[2]=a[2][2];
}
/* Ear clipping of the (positively wound) outline, exactly the reference's order. */
static int ear_clip(const DPt *poly, int n, int tris[][3])
{
int idx[MAX_OUTLINE],m=n,count=0,guard=0;
for(int i=0;i<n;i++) idx[i]=i;
#define A2(a,b,c) ((poly[b].x-poly[a].x)*(poly[c].z-poly[a].z)-(poly[b].z-poly[a].z)*(poly[c].x-poly[a].x))
while(m>3 && guard++<10000) {
int cut=0;
for(int k=0;k<m;k++) {
int i0=idx[(k-1+m)%m],i1=idx[k],i2=idx[(k+1)%m];
if(A2(i0,i1,i2)<=0) continue;
int blocked=0;
for(int q=0;q<m && !blocked;q++) {
int j=idx[q];
if(j!=i0 && j!=i1 && j!=i2 && A2(i0,i1,j)>0 && A2(i1,i2,j)>0 && A2(i2,i0,j)>0) blocked=1;
}
if(blocked) continue;
tris[count][0]=i0;tris[count][1]=i1;tris[count][2]=i2;count++;
for(int q=k;q+1<m;q++) idx[q]=idx[q+1];
m--;cut=1;break;
}
if(!cut) break;
}
#undef A2
if(m==3) { tris[count][0]=idx[0];tris[count][1]=idx[1];tris[count][2]=idx[2];count++; }
return count;
}
/* ------------------------------------------------------------------------------------------------ passes */
typedef struct {
u8 *furn, *keep;
signed char *owner;
int *parent, *start, *nb, *fill, *comp, *stamp;
double *pos, *tmp, *acc;
float *ds;
int nb_n, comp_n;
} Work;
static void work_free(Work *w, int T, int W)
{
usize t=(usize)(T ? T : 1),v=(usize)(W ? W : 1);
io_free(w->furn,t);io_free(w->keep,t);io_free(w->owner,t);io_free(w->parent,v*sizeof(int));
io_free(w->start,(v+1)*sizeof(int));io_free(w->nb,(usize)(w->nb_n ? w->nb_n : 1)*sizeof(int));
io_free(w->fill,v*sizeof(int));io_free(w->comp,v*sizeof(int));io_free(w->stamp,v*sizeof(int));
io_free(w->pos,v*3*sizeof(double));io_free(w->tmp,v*3*sizeof(double));
io_free(w->acc,(usize)(w->comp_n ? w->comp_n : 1)*12*sizeof(double));io_free(w->ds,t*sizeof(float));
}
/* Heads binned on a horizontal grid with cells of at least the walked radius: the 3 x 3 cells around a point hold
* every head within the radius, so this gives the same result as testing every head, with far fewer tests. */
typedef struct { double x0, z0, cell; int nx, nz; int *start, *list; } HeadGrid;
static int head_cell(const HeadGrid *g, double v, double v0) { return (int)dfloor((v-v0)/g->cell); }
static int head_grid(HeadGrid *g, const Vec *heads, int n, double radius)
{
double x0=1e300,x1=-1e300,z0=1e300,z1=-1e300;
for(int i=0;i<n;i++) { x0=heads[i].x<x0 ? heads[i].x : x0;x1=heads[i].x>x1 ? heads[i].x : x1;
z0=heads[i].z<z0 ? heads[i].z : z0;z1=heads[i].z>z1 ? heads[i].z : z1; }
g->cell=radius;
if((x1-x0)/256>g->cell) g->cell=(x1-x0)/256;
if((z1-z0)/256>g->cell) g->cell=(z1-z0)/256;
g->x0=x0;g->z0=z0;g->nx=head_cell(g,x1,x0)+1;g->nz=head_cell(g,z1,z0)+1;
g->start=alloc((usize)(g->nx*g->nz+1)*sizeof(int));g->list=alloc((usize)n*sizeof(int));
int *fill=alloc((usize)(g->nx*g->nz)*sizeof(int));
if(!g->start || !g->list || !fill) { io_free(fill,(usize)(g->nx*g->nz)*sizeof(int));return 0; }
for(int i=0;i<n;i++) g->start[head_cell(g,heads[i].z,z0)*g->nx+head_cell(g,heads[i].x,x0)+1]++;
for(int c=0;c<g->nx*g->nz;c++) g->start[c+1]+=g->start[c];
for(int i=0;i<n;i++) {
int c=head_cell(g,heads[i].z,z0)*g->nx+head_cell(g,heads[i].x,x0);
g->list[g->start[c]+fill[c]++]=i;
}
io_free(fill,(usize)(g->nx*g->nz)*sizeof(int));
return 1;
}
static void head_grid_free(HeadGrid *g, int n)
{
io_free(g->start,(usize)(g->nx*g->nz+1)*sizeof(int));io_free(g->list,(usize)n*sizeof(int));
}
static int walked_hit(const HeadGrid *g, const Vec *heads, double x, double y, double z, double r2)
{
int cx=head_cell(g,x,g->x0),cz=head_cell(g,z,g->z0);
for(int v=cz-1;v<=cz+1;v++) for(int u=cx-1;u<=cx+1;u++) {
if(u<0 || v<0 || u>=g->nx || v>=g->nz) continue;
for(int k=g->start[v*g->nx+u];k<g->start[v*g->nx+u+1];k++) {
const Vec *h=&heads[g->list[k]];
if(y<h->y+WALKED_ABOVE && (x-h->x)*(x-h->x)+(z-h->z)*(z-h->z)<=r2) return 1;
}
}
return 0;
}
const char *refine_run(const Refine *o, const Room *room, const Prep *P, const RVol *vols, int nvols,
const Vec *heads, int nheads, Vec **out_v, u32 **out_i, int *out_n, Report *rep)
{
int T=P->T,W=P->W;
const float *cen=P->cen,*fn=P->fn,*area=P->area;
const int *F=P->F;
Work w={0};
const char *error=0;
*out_v=0;*out_i=0;*out_n=0;
memset(rep,0,sizeof(*rep));rep->triangles=T;
usize tb=(usize)(T ? T : 1),wb=(usize)(W ? W : 1);
w.furn=alloc(tb);w.keep=alloc(tb);w.owner=alloc(tb);w.ds=alloc(tb*sizeof(float));
w.parent=alloc(wb*sizeof(int));w.pos=alloc(wb*3*sizeof(double));w.stamp=alloc(wb*sizeof(int));
if(!w.furn || !w.keep || !w.owner || !w.ds || !w.parent || !w.pos || !w.stamp) { error="refine allocation failed";goto done; }
memcpy(w.pos,P->pos,(usize)W*3*sizeof(double));
double *pos=w.pos;
/* furniture flags: the ruled boxes (+10 cm) */
Box boxes[MAX_VOLUMES];
for(int i=0;i<nvols;i++) {
double a=vols[i].yaw*3.14159265358979323846/180;
boxes[i]=(Box){dcos(a),dsin(a),&vols[i]};
}
for(int i=0;i<nvols;i++) {
const RVol *v=&vols[i];
for(int t=0;t<T;t++) {
double lx,lz,y=cen[3*t+1];
box_local(&boxes[i],cen[3*t],cen[3*t+2],&lx,&lz);
if(dab(lx)<=v->w/2.+FURNITURE_PAD && dab(lz)<=v->d/2.+FURNITURE_PAD &&
y<=v->top+FURNITURE_PAD && y>=v->top-v->h-FURNITURE_PAD) w.furn[t]=1;
}
}
/* 1. planes: seen walls, floor, ceiling; offsets re-estimated (median of facing triangles within the band) */
RPlane planes[MAX_OUTLINE+2];int np=0;
for(int i=0;i<room->nwalls;i++) {
const RWall *wl=&room->walls[i];
if(wl->invisible) continue;
double dx=wl->x1-wl->x0,dz=wl->z1-wl->z0,L=dhypot(dx,dz);
if(!(L>0)) continue;
RPlane *pl=&planes[np++];
*pl=(RPlane){0,i,{-dz/L,0,dx/L},0,0,wl->x0,wl->z0,dx/L,dz/L,L};
pl->off=pl->n[0]*wl->x0+pl->n[2]*wl->z0;
}
planes[np++]=(RPlane){1,-1,{0,1,0},room->floor,0,0,0,0,0,0};
planes[np++]=(RPlane){2,-1,{0,-1,0},-(double)room->ceiling,0,0,0,0,0,0};
double cos_lim=dcos(o->facing_deg*3.14159265358979323846/180);
#define FACING(pl,t) ((pl)->n[0]*fn[3*(t)]+(pl)->n[1]*fn[3*(t)+1]+(pl)->n[2]*fn[3*(t)+2]>cos_lim)
for(int k=0;k<np;k++) {
RPlane *pl=&planes[k];int n=0;
for(int t=0;t<T;t++) {
if(w.furn[t]) continue;
double d=s_dist(pl,cen+3*t);
if(dab(d)<o->band && FACING(pl,t) && in_ext(pl,cen+3*t,o->band)) w.ds[n++]=(float)d;
}
if(n>MIN_SHIFT_SAMPLES) { nth(w.ds,n,n>>1);pl->shift=w.ds[n>>1]; }
pl->off+=pl->shift;
rep->shifts[rep->nshifts++]=(Shift){pl->kind,pl->wall,(float)pl->shift};
}
double floor_y=planes[np-2].off,ceil_y=-planes[np-1].off;
rep->floor_y=(float)floor_y;rep->ceiling_y=(float)ceil_y;
/* 2. structural triangles: behind a surface = band; in front = band_in (walls; the full band in the wall's top
* strip), floor_in, ceil_in */
for(int t=0;t<T;t++) w.owner[t]=-1;
if(o->walls) for(int t=0;t<T;t++) {
if(w.furn[t]) continue;
double best=1e300;
for(int k=0;k<np;k++) {
const RPlane *pl=&planes[k];double d=s_dist(pl,cen+3*t);
double front=!pl->kind ? (cen[3*t+1]>ceil_y-o->top_strip ? o->band : o->band_in) :
pl->kind==2 ? o->ceil_in : o->floor_in;
if(d>-o->band && d<front && dab(d)<best && FACING(pl,t) && in_ext(pl,cen+3*t,o->band)) {
best=dab(d);w.owner[t]=(signed char)k;
}
}
}
#undef FACING
for(int t=0;t<T;t++) w.keep[t]=1;
/* erase spheres */
for(int e=0;e<o->nerase;e++) {
const Sphere *s=&o->erase[e];
for(int t=0;t<T;t++) {
double dx=cen[3*t]-s->x,dy=cen[3*t+1]-s->y,dz=cen[3*t+2]-s->z;
if(w.keep[t] && dx*dx+dy*dy+dz*dz<=(double)s->r*s->r) { w.keep[t]=0;rep->erased++; }
}
}
/* the walked volume: a column under each head position, never inside furniture */
if(o->walked>0 && nheads) {
HeadGrid g={0};
if(!head_grid(&g,heads,nheads,o->walked)) { head_grid_free(&g,nheads);error="refine allocation failed";goto done; }
double r2=(double)o->walked*o->walked;
for(int t=0;t<T;t++) {
if(!w.keep[t] || w.furn[t]) continue;
double y=cen[3*t+1];
if(y<room->floor+WALKED_BELOW) continue;
if(walked_hit(&g,heads,cen[3*t],y,cen[3*t+2],r2)) { w.keep[t]=0;rep->walked++; }
}
head_grid_free(&g,nheads);
} else if(o->walked>0) rep->walked_skipped=1;
for(int t=0;t<T;t++) if(w.owner[t]>=0) rep->structural++;
if(o->walls==2) {
for(int t=0;t<T;t++) if(w.owner[t]>=0) w.keep[t]=0;
if(o->near>0) for(int t=0;t<T;t++) {
if(!w.keep[t] || w.furn[t]) continue;
for(int k=0;k<np;k++) if(dab(s_dist(&planes[k],cen+3*t))<o->near && in_ext(&planes[k],cen+3*t,o->near)) {
w.keep[t]=0;rep->near++;break;
}
}
} else if(o->walls==1) {
/* snap: a vertex goes onto the plane of the last structural triangle that uses it */
int *vown=w.stamp;
for(int i=0;i<W;i++) vown[i]=-1;
for(int t=0;t<T;t++) if(w.owner[t]>=0) for(int j=0;j<3;j++) vown[F[3*t+j]]=w.owner[t];
for(int i=0;i<W;i++) {
if(vown[i]<0) continue;
const RPlane *pl=&planes[vown[i]];
double d=pl->n[0]*pos[3*i]+pl->n[1]*pos[3*i+1]+pl->n[2]*pos[3*i+2]-pl->off;
for(int k=0;k<3;k++) pos[3*i+k]-=d*pl->n[k];
}
}
/* 2b. furniture tops: upward scan near a box's top, inside its footprint, goes onto the top */
if(o->tops>0) {
for(int i=0;i<W;i++) w.stamp[i]=0;
for(int i=0;i<nvols;i++) {
const RVol *v=&vols[i];int mark=i+1;
for(int t=0;t<T;t++) {
if(!w.keep[t]) continue;
const double *a=pos+3*F[3*t],*b=pos+3*F[3*t+1],*c=pos+3*F[3*t+2];
double cy=(a[1]+b[1]+c[1])/3;
if(dab(cy-v->top)>=o->tops) continue;
double lx,lz;
box_local(&boxes[i],(a[0]+b[0]+c[0])/3,(a[2]+b[2]+c[2])/3,&lx,&lz);
if(dab(lx)>v->w/2. || dab(lz)>v->d/2.) continue;
double ux=b[0]-a[0],uy=b[1]-a[1],uz=b[2]-a[2],wx=c[0]-a[0],wy=c[1]-a[1],wz=c[2]-a[2];
double nx=uy*wz-uz*wy,ny=uz*wx-ux*wz,nz=ux*wy-uy*wx,l=droot(nx*nx+ny*ny+nz*nz);
if(ny/(l>0 ? l : 1)<=TOP_NORMAL_Y) continue;
for(int j=0;j<3;j++) w.stamp[F[3*t+j]]=mark;
rep->tops++;
}
for(int k=0;k<W;k++) if(w.stamp[k]==mark) pos[3*k+1]=v->top;
}
}
/* 3. clip outside the room (unseen walls allow clip_open more) and above/below the planes */
if(o->clip<1 || o->clip_open<2) for(int t=0;t<T;t++) {
if(!w.keep[t]) continue;
double allow,out=outside_info(room,cen[3*t],cen[3*t+2],o->clip_open,&allow),y=cen[3*t+1];
if(out>o->clip+allow || y>ceil_y+o->clip || y<floor_y-o->clip) { w.keep[t]=0;rep->outside++; }
}
/* 4. islands: connected pieces below the minimum area */
if(o->island>0) {
double *comp=w.tmp=alloc(wb*3*sizeof(double));
if(!comp) { error="refine allocation failed";goto done; }
unite(w.parent,F,w.keep,T,W);
for(int t=0;t<T;t++) if(w.keep[t]) comp[find(w.parent,F[3*t])]+=area[t];
for(int t=0;t<T;t++)
if(w.keep[t] && comp[find(w.parent,F[3*t])]<o->island && !(o->protect && w.furn[t])) { w.keep[t]=0;rep->islands++; }
io_free(w.tmp,wb*3*sizeof(double));w.tmp=0;
}
/* 4b. thin floating pieces: elongated (PCA), thin, small, not furniture */
if(o->tubes>0) {
unite(w.parent,F,w.keep,T,W);
w.comp=alloc(wb*sizeof(int));
if(!w.comp) { error="refine allocation failed";goto done; }
for(int i=0;i<W;i++) w.comp[i]=-1;
int nc=0;
for(int t=0;t<T;t++) if(w.keep[t]) { int r=find(w.parent,F[3*t]);if(w.comp[r]<0) w.comp[r]=nc++; }
w.comp_n=nc;
w.acc=alloc((usize)(nc ? nc : 1)*12*sizeof(double));
if(!w.acc) { error="refine allocation failed";goto done; }
for(int t=0;t<T;t++) {
if(!w.keep[t]) continue;
double *a=w.acc+12*w.comp[find(w.parent,F[3*t])];
for(int j=0;j<3;j++) {
const double *q=pos+3*F[3*t+j];double x=q[0],y=q[1],z=q[2];
a[0]++;a[1]+=x;a[2]+=y;a[3]+=z;a[4]+=x*x;a[5]+=x*y;a[6]+=x*z;a[7]+=y*y;a[8]+=y*z;a[9]+=z*z;
}
a[10]+=area[t];a[11]+=w.furn[t] ? 1 : 0;
}
u8 *drop=(u8 *)w.stamp; /* nc <= T bytes fit in the W ints */
for(int c=0;c<nc;c++) {
double *a=w.acc+12*c,n=a[0],tris=n/3;drop[c]=0;
if(tris<TUBE_MIN_TRIANGLES || a[11]/tris>TUBE_MAX_FURNITURE || a[10]>=TUBE_MAX_AREA) continue;
double mx_=a[1]/n,my=a[2]/n,mz=a[3]/n;
double C[3][3]={{a[4]/n-mx_*mx_,a[5]/n-mx_*my,a[6]/n-mx_*mz},{0,a[7]/n-my*my,a[8]/n-my*mz},{0,0,a[9]/n-mz*mz}};
C[1][0]=C[0][1];C[2][0]=C[0][2];C[2][1]=C[1][2];
double ev[3];eig3(C,ev);
for(int k=0;k<3;k++) ev[k]=droot(ev[k]>1e-12 ? ev[k] : 1e-12);
for(int i=0;i<2;i++) for(int j=0;j<2-i;j++) if(ev[j]>ev[j+1]) { double s=ev[j];ev[j]=ev[j+1];ev[j+1]=s; }
if(ev[2]/(ev[1]>1e-6 ? ev[1] : 1e-6)>=o->tubes && ev[1]<=o->tube_thick) drop[c]=1;
}
for(int t=0;t<T;t++) if(w.keep[t] && drop[w.comp[find(w.parent,F[3*t])]]) { w.keep[t]=0;rep->tubes++; }
}
/* 5. Taubin smoothing over the kept triangles (lambda/mu pairs keep the volume) */
if(o->smooth>0) {
w.start=alloc((wb+1)*sizeof(int));w.fill=alloc(wb*sizeof(int));w.tmp=alloc(wb*3*sizeof(double));
if(!w.start || !w.fill || !w.tmp) { error="refine allocation failed";goto done; }
for(int t=0;t<T;t++) if(w.keep[t]) for(int j=0;j<3;j++) w.start[F[3*t+j]+1]+=2;
for(int i=0;i<W;i++) w.start[i+1]+=w.start[i];
w.nb_n=w.start[W];
w.nb=alloc((usize)(w.nb_n ? w.nb_n : 1)*sizeof(int));
if(!w.nb) { error="refine allocation failed";goto done; }
for(int i=0;i<W;i++) w.fill[i]=w.start[i];
for(int t=0;t<T;t++) if(w.keep[t]) for(int j=0;j<3;j++) {
int a=F[3*t+j],b=F[3*t+(j+1)%3],c=F[3*t+(j+2)%3];
w.nb[w.fill[a]++]=b;w.nb[w.fill[a]++]=c;
}
for(int it=0;it<o->smooth;it++) for(int pass=0;pass<2;pass++) {
double f=pass ? TAUBIN_MU : TAUBIN_LAMBDA;
for(int i=0;i<W;i++) {
int s0=w.start[i],e=w.start[i+1];
if(s0==e) { for(int k=0;k<3;k++) w.tmp[3*i+k]=pos[3*i+k];continue; }
double x=0,y=0,z=0;
for(int k=s0;k<e;k++) { const double *q=pos+3*w.nb[k];x+=q[0];y+=q[1];z+=q[2]; }
int n=e-s0;
w.tmp[3*i]=pos[3*i]+f*(x/n-pos[3*i]);w.tmp[3*i+1]=pos[3*i+1]+f*(y/n-pos[3*i+1]);
w.tmp[3*i+2]=pos[3*i+2]+f*(z/n-pos[3*i+2]);
}
memcpy(pos,w.tmp,(usize)W*3*sizeof(double));
}
}
/* 6. output: the kept scan, then flat panels for seen walls, the floor and the ceiling */
for(int t=0;t<T;t++) rep->kept+=w.keep[t];
Vec panel[(MAX_OUTLINE*2+MAX_OUTLINE*2)*3];int npanel=0;
if(o->walls==2) {
for(int k=0;k<np;k++) {
const RPlane *pl=&planes[k];
if(pl->kind) continue;
const RWall *wl=&room->walls[pl->wall];double sh=pl->shift;
double a0x=wl->x0+pl->n[0]*sh,a0z=wl->z0+pl->n[2]*sh,a1x=wl->x1+pl->n[0]*sh,a1z=wl->z1+pl->n[2]*sh;
Vec q[4]={vec((float)a0x,(float)floor_y,(float)a0z),vec((float)a1x,(float)floor_y,(float)a1z),
vec((float)a1x,(float)ceil_y,(float)a1z),vec((float)a0x,(float)ceil_y,(float)a0z)};
double ux=a1x-a0x,uz=a1z-a0z,vy=ceil_y-floor_y;
int ok=(-uz*vy)*pl->n[0]+(ux*vy)*pl->n[2]>0;
int order[2][6]={{0,1,2,0,2,3},{1,0,3,1,3,2}};
for(int j=0;j<6;j++) panel[npanel*3+j]=q[order[!ok][j]];
npanel+=2;
}
int ears[MAX_OUTLINE][3];
int ne=ear_clip(room->outline,room->noutline,ears);
for(int e=0;e<ne;e++) {
DPt A=room->outline[ears[e][0]],B=room->outline[ears[e][1]],C=room->outline[ears[e][2]];
panel[npanel*3]=vec((float)A.x,(float)floor_y,(float)A.z);panel[npanel*3+1]=vec((float)C.x,(float)floor_y,(float)C.z);
panel[npanel*3+2]=vec((float)B.x,(float)floor_y,(float)B.z);npanel++;
panel[npanel*3]=vec((float)A.x,(float)ceil_y,(float)A.z);panel[npanel*3+1]=vec((float)B.x,(float)ceil_y,(float)B.z);
panel[npanel*3+2]=vec((float)C.x,(float)ceil_y,(float)C.z);npanel++;
}
}
rep->panels=npanel;
int room_left=MAX_TRIANGLES-npanel,kept_out=rep->kept<room_left ? rep->kept : room_left;
rep->truncated=rep->kept-kept_out;
*out_v=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
*out_i=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!*out_v || !*out_i) { error="refine allocation failed";goto done; }
int o_n=0;
for(int t=0;t<T && o_n<kept_out*3;t++) {
if(!w.keep[t]) continue;
for(int j=0;j<3;j++) {
const double *q=pos+3*F[3*t+j];
(*out_v)[o_n]=vec((float)q[0],(float)q[1],(float)q[2]);(*out_i)[o_n]=(u32)o_n;o_n++;
}
}
for(int k=0;k<npanel*3;k++) { (*out_v)[o_n]=panel[k];(*out_i)[o_n]=(u32)o_n;o_n++; }
*out_n=o_n;
done:
if(error) {
io_free(*out_v,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(*out_i,(usize)MAX_TRIANGLES*3*sizeof(u32));
*out_v=0;*out_i=0;*out_n=0;
}
work_free(&w,T,W);
return error;
}
+101
View File
@@ -0,0 +1,101 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_REFINE_H
#define FP_ROOMSCAN_REFINE_H
#include "scan.h"
/* Room cleanup after the mesh (docs/mr/scan.md "Refinement"): the scanner's unrefined output is kept as
* room-raw.txt + room-mesh-raw.bin, the refined one replaces room.txt + room-mesh.bin, scan.json holds the head
* path, the settings used, the per-pass report, the label-rule decisions and door/closet suggestions. */
#define MAX_EXTRA_TEXT 8192
#define MAX_LABEL 24
#define MAX_REASON 240
#define MAX_RAW_FILE (16u+(usize)MAX_MESH_VERTICES*12u+(usize)MAX_TRIANGLES*12u)
#define MAX_JSON_FILE (1u<<20)
/* Growable text buffer (io_alloc backed). ok=0 after an allocation failure or the cap. */
typedef struct { char *data; usize n, cap, limit; int ok; } Buf;
void buf_init(Buf *b, usize limit);
void buf_free(Buf *b);
void put(Buf *b, const char *s);
void put_n(Buf *b, const char *s, usize n);
void put_int(Buf *b, i64 v);
void put_u64(Buf *b, u64 v);
void put_dec(Buf *b, float v); /* 4 decimals, the room.txt number format */
void put_fixed(Buf *b, double v, int places); /* JavaScript's toFixed */
void put_round(Buf *b, double v); /* JavaScript's Math.round */
typedef struct { double x, z; } DPt;
/* Room numbers are doubles parsed exactly from their decimal text, as the reference page reads them. */
typedef struct { double x0, z0, x1, z1, y0, y1; int invisible; char id[33]; } RWall;
typedef struct { double cx, cz, top, yaw, w, d, h; char label[MAX_LABEL]; char id[33]; } RVol;
typedef struct {
u64 universe, time;
char source[16];
char room_id[33], floor_id[33], ceiling_id[33], mesh_id[33];
double floor, ceiling;
DPt outline[MAX_OUTLINE];
int noutline;
RWall walls[MAX_OUTLINE];
int nwalls;
RVol vols[MAX_VOLUMES];
int nvols;
char mesh_name[64];
u32 mesh_nv, mesh_ni;
int has_mesh;
char extra[MAX_EXTRA_TEXT]; /* records this scanner does not model (plane, ...), kept verbatim */
usize nextra;
} Room;
/* The mesh with its vertices welded at 1 mm (as the reference page does), per-triangle normals/centroids/areas. */
typedef struct {
int T, W;
double *pos;
int *F;
float *fn, *cen, *area;
} Prep;
typedef struct { int kind, wall; float shift; } Shift; /* kind: 0 wall, 1 floor, 2 ceiling */
typedef struct {
int triangles, kept, structural, near, outside, islands, tubes, erased, walked, tops, panels, truncated;
int walked_skipped;
float floor_y, ceiling_y;
Shift shifts[MAX_OUTLINE+2];
int nshifts;
} Report;
typedef struct {
char from[MAX_LABEL], to[MAX_LABEL], suggest[8], reason[MAX_REASON];
RVol box;
int wall;
float along;
} Label;
/* roomio.c */
const char *room_from_scan(Scan *s, Room *r);
const char *room_parse(const char *text, usize n, Room *r);
int room_text(const Room *r, Buf *b);
const char *read_file(const char *path, usize limit, u8 **data, usize *size);
const char *publish(const char *dir, const char *name, const void *data, usize size);
int join_path(char *out, const char *dir, const char *name);
const char *mesh_parse(const u8 *data, usize size, Vec **vertices, u32 **indices, int *nv, int *ni);
const char *mesh_bytes(const Vec *vertices, const u32 *indices, int nv, int ni, u8 **data, usize *size);
void heads_json(Buf *b, const Vec *heads, int n);
int heads_parse(const char *text, usize n, Vec *heads);
void settings_json(Buf *b, const Refine *o);
void report_json(Buf *b, const Report *r, int nlabels, int changed, int removed, int nsuggest);
/* refine.c */
const char *prep_mesh(Prep *p, const Vec *vertices, const u32 *indices, int nv, int ni);
void prep_free(Prep *p);
const char *refine_run(const Refine *o, const Room *room, const Prep *p, const RVol *vols, int nvols,
const Vec *heads, int nheads, Vec **out_v, u32 **out_i, int *out_n, Report *rep);
double dfloor(double x);
double droot(double x);
double dsin(double x);
double dcos(double x);
double dhypot(double a, double b);
/* rules.c */
int rules_apply(const Room *room, const Prep *p, RVol *out, int *nout, Label *labels);
#endif
+476
View File
@@ -0,0 +1,476 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Room files: the room.txt writer/reader (docs/mr/ROOM_FORMAT.md), mesh sidecars, atomic publishing and the JSON
* pieces of scan.json. Every number goes through put_dec, so a room read back from its own text is bit-identical
* to the one the scan path refines (that is what makes --refine-only reproduce a scan's refined output). */
#include "refine.h"
void buf_init(Buf *b, usize limit) { *b=(Buf){0};b->limit=limit;b->ok=1; }
void buf_free(Buf *b) { io_free(b->data,b->cap);*b=(Buf){0}; }
void put_n(Buf *b, const char *s, usize n)
{
if(!b->ok) return;
if(b->n+n+1>b->cap) {
usize cap=b->cap ? b->cap : 256;
while(cap<b->n+n+1) cap*=2;
if(cap>b->limit) cap=b->limit;
if(cap<b->n+n+1) { b->ok=0;return; }
char *data=io_alloc(cap);
if(!data) { b->ok=0;return; }
if(b->n) memcpy(data,b->data,b->n);
io_free(b->data,b->cap);b->data=data;b->cap=cap;
}
memcpy(b->data+b->n,s,n);b->n+=n;b->data[b->n]=0;
}
void put(Buf *b, const char *s) { usize n=0;while(s[n]) n++;put_n(b,s,n); }
void put_u64(Buf *b, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
char out[24];for(int i=0;i<n;i++) out[i]=rev[n-1-i];
put_n(b,out,(usize)n);
}
void put_int(Buf *b, i64 v) { if(v<0) { put(b,"-");v=-v; } put_u64(b,(u64)v); }
void put_dec(Buf *b, float value)
{
/* Same arithmetic as the scanner's original writer: the --refine 0 output stays byte-for-byte unchanged. */
if(value<0) { put(b,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
put_u64(b,scaled/10000);put(b,".");
u64 fraction=scaled%10000;
if(fraction<1000) put(b,"0");
if(fraction<100) put(b,"0");
if(fraction<10) put(b,"0");
put_u64(b,fraction);
}
void put_fixed(Buf *b, double v, int places)
{
/* Exact, like Number.prototype.toFixed: v*10^places from the double's bits in 128-bit integers, ties up. */
double scale=1;for(int i=0;i<places;i++) scale*=10;
int negative=v<0;if(negative) v=-v;
if(!(v<1e9)) v=0;
union { double d; u64 u; } bits={v};
int e=(int)((bits.u>>52)&0x7ff);
u64 m=bits.u&((1ull<<52)-1);
if(e) m|=1ull<<52;else e=1;
e-=1075; /* v = m * 2^e */
unsigned __int128 big=(unsigned __int128)m*(u64)scale;
u64 scaled;
if(e>=0) scaled=(u64)(big<<e);
else if(e<=-127) scaled=0;
else {
unsigned __int128 half=(unsigned __int128)1<<(-e-1),rest=big&(((unsigned __int128)1<<-e)-1);
scaled=(u64)(big>>-e)+(rest>=half);
}
if(negative && scaled) put(b,"-");
put_u64(b,(u64)(scaled/(u64)scale));
if(places) {
put(b,".");u64 fraction=scaled%(u64)scale;
for(u64 d=(u64)scale/10;d>1 && fraction<d;d/=10) put(b,"0");
put_u64(b,fraction);
}
}
void put_round(Buf *b, double v) { put_int(b,(i64)dfloor(v+.5)); }
static int uuid_hex(char out[33])
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { out[2*i]=hex[bytes[i]>>4];out[2*i+1]=hex[bytes[i]&15]; }
out[32]=0;return 1;
}
static void copy(char *dst, const char *src, usize cap)
{
usize i=0;while(src[i] && i+1<cap) { dst[i]=src[i];i++; }
dst[i]=0;
}
const char *room_from_scan(Scan *s, Room *r)
{
memset(r,0,sizeof(*r));
r->universe=s->opt.universe;r->time=(u64)(io_time(0)/1000000000);copy(r->source,"scan",sizeof(r->source));
if(!uuid_hex(r->room_id) || !uuid_hex(r->floor_id) || !uuid_hex(r->ceiling_id)) return "getrandom failed";
r->floor=s->floor;r->ceiling=s->ceiling;r->noutline=s->noutline;
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
r->outline[i]=(DPt){a.x,a.z};
r->walls[i]=(RWall){a.x,a.z,b.x,b.z,s->floor,s->ceiling,s->invisible[i],{0}};
if(!uuid_hex(r->walls[i].id)) return "getrandom failed";
}
r->nwalls=s->noutline;
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];RVol *o=&r->vols[i];
*o=(RVol){v->p.x,v->p.z,v->p.y,v->yaw,v->w,v->d,v->h,{0},{0}};
copy(o->label,v->label,sizeof(o->label));
if(!uuid_hex(o->id)) return "getrandom failed";
}
r->nvols=s->nvolumes;
if(!uuid_hex(r->mesh_id)) return "getrandom failed";
copy(r->mesh_name,"room-mesh.bin",sizeof(r->mesh_name));
r->has_mesh=1;r->mesh_nv=(u32)s->nv;r->mesh_ni=(u32)s->ni;
return 0;
}
static void sp(Buf *b, double v) { put(b," ");put_dec(b,(float)v); }
int room_text(const Room *r, Buf *b)
{
put(b,"frameport-room 1\nuniverse ");put_u64(b,r->universe);
put(b,"\nsource ");put(b,r->source);put(b,"\ntime ");put_u64(b,r->time);
put(b,"\nroom ");put(b,r->room_id);
put(b,"\nfloor ");put(b,r->floor_id);sp(b,r->floor);
put(b,"\nceiling ");put(b,r->ceiling_id);sp(b,r->ceiling);
put(b,"\noutline ");put_u64(b,(u64)r->noutline);
for(int i=0;i<r->noutline;i++) { sp(b,r->outline[i].x);sp(b,r->outline[i].z); }
put(b,"\n");
for(int i=0;i<r->nwalls;i++) {
const RWall *w=&r->walls[i];
put(b,"wall ");put(b,w->id);sp(b,w->x0);sp(b,w->z0);sp(b,w->x1);sp(b,w->z1);sp(b,w->y0);sp(b,w->y1);
if(w->invisible) put(b," INVISIBLE_WALL_FACE");
put(b,"\n");
}
for(int i=0;i<r->nvols;i++) {
const RVol *v=&r->vols[i];
put(b,"volume ");put(b,v->id);put(b," ");put(b,v->label);
sp(b,v->cx);sp(b,v->cz);sp(b,v->top);sp(b,v->yaw);sp(b,v->w);sp(b,v->d);sp(b,v->h);put(b,"\n");
}
put_n(b,r->extra,r->nextra);
if(r->has_mesh) {
put(b,"mesh ");put(b,r->mesh_id);put(b," ");put(b,r->mesh_name);put(b," ");
put_u64(b,r->mesh_nv);put(b," ");put_u64(b,r->mesh_ni);put(b,"\n");
}
return b->ok;
}
/* ------------------------------------------------------------------------------------------------ reading */
typedef struct { char *p[64]; int n; } Fields;
static int is_space(char c) { return c==' ' || c=='\t' || c=='\r'; }
/* Semantic labels are Meta's upper-case names; anything else would also be unsafe in scan.json strings. */
static int field_label(const char *p)
{
int n=0;
for(;p[n];n++) if(!((p[n]>='A' && p[n]<='Z') || (p[n]>='0' && p[n]<='9') || p[n]=='_')) return 0;
return n>0 && n<MAX_LABEL;
}
static int field_uuid(const char *p)
{
int n=0;
for(;p[n];n++) if(!((p[n]>='0' && p[n]<='9') || (p[n]>='a' && p[n]<='f'))) return 0;
return n==32;
}
/* Decimal text to double, correctly rounded for the room format's numbers (<= 17 significant digits): the
* mantissa and the power of ten are exact doubles, so one division/multiplication rounds once, as parseFloat. */
static int field_double(const char *p, double *out)
{
int negative=0,digits=0,exponent=0;u64 mantissa=0;
if(*p=='-' || *p=='+') negative=*p++=='-';
for(;*p>='0' && *p<='9';p++,digits++) { if(mantissa<100000000000000000ull) mantissa=mantissa*10+(u64)(*p-'0');else exponent++; }
if(*p=='.') for(p++;*p>='0' && *p<='9';p++,digits++)
if(mantissa<100000000000000000ull) { mantissa=mantissa*10+(u64)(*p-'0');exponent--; }
if(!digits) return 0;
if(*p=='e' || *p=='E') {
p++;int sign=1,e=0,ed=0;
if(*p=='-' || *p=='+') sign=*p++=='-' ? -1 : 1;
for(;*p>='0' && *p<='9';p++,ed++) { e=e*10+*p-'0';if(e>400) return 0; }
if(!ed) return 0;
exponent+=sign*e;
}
if(*p) return 0;
double v=(double)mantissa,scale=1;
int e=exponent<0 ? -exponent : exponent;
for(int i=0;i<e && scale<1e300;i++) scale*=10;
v=exponent<0 ? v/scale : v*scale;
if(!(v<=1e7)) return 0; /* also rejects NaN; room coordinates are metres */
*out=negative ? -v : v;return 1;
}
static int field_u64(const char *p, u64 *out)
{
u64 n=0;if(!*p) return 0;
for(;*p;p++) {
if(*p<'0' || *p>'9') return 0;
unsigned digit=(unsigned)(*p-'0');
if(n>(~(u64)0-digit)/10) return 0;
n=n*10+digit;
}
*out=n;return 1;
}
static int floats(Fields *f, int from, double *out, int n)
{
if(f->n<from+n) return 0;
for(int i=0;i<n;i++) if(!field_double(f->p[from+i],&out[i])) return 0;
return 1;
}
const char *room_parse(const char *text, usize n, Room *r)
{
static char line[16384];
memset(r,0,sizeof(*r));
int have_floor=0,have_ceiling=0,first=1;
usize at=0;
while(at<n) {
usize start=at,len=0;
while(at<n && text[at]!='\n') at++;
len=at-start;at++;
if(len>=sizeof(line)) return "room line too long";
memcpy(line,text+start,len);line[len]=0;
usize raw_len=len;
for(usize i=0;i<len;i++) if(line[i]=='#') { line[i]=0;len=i;break; }
Fields f={{0},0};
char *p=line;
while(*p) {
while(is_space(*p)) *p++=0;
if(!*p) break;
if(f.n==64) return "room line has too many fields";
f.p[f.n++]=p;
while(*p && !is_space(*p)) p++;
}
if(first) {
if(f.n!=2 || !same(f.p[0],"frameport-room") || !same(f.p[1],"1")) return "not a frameport-room 1 file";
first=0;continue;
}
if(!f.n) continue;
const char *k=f.p[0];
if(same(k,"universe")) { if(f.n<2 || !field_u64(f.p[1],&r->universe)) return "invalid universe record"; }
else if(same(k,"time")) { if(f.n<2 || !field_u64(f.p[1],&r->time)) return "invalid time record"; }
else if(same(k,"source")) { if(f.n<2) return "invalid source record";copy(r->source,f.p[1],sizeof(r->source)); }
else if(same(k,"room")) { if(f.n<2 || !field_uuid(f.p[1])) return "invalid room record";copy(r->room_id,f.p[1],33); }
else if(same(k,"floor") || same(k,"ceiling")) {
int c=same(k,"ceiling");
if(f.n<3 || !field_uuid(f.p[1]) || !field_double(f.p[2],c ? &r->ceiling : &r->floor)) return "invalid floor/ceiling record";
copy(c ? r->ceiling_id : r->floor_id,f.p[1],33);
if(c) have_ceiling=1;else have_floor=1;
} else if(same(k,"outline")) {
u64 count;
if(f.n<2 || !field_u64(f.p[1],&count) || count<3 || count>MAX_OUTLINE || (u64)f.n!=2+2*count) return "invalid outline record";
for(int i=0;i<(int)count;i++)
if(!field_double(f.p[2+2*i],&r->outline[i].x) || !field_double(f.p[3+2*i],&r->outline[i].z)) return "invalid outline record";
r->noutline=(int)count;
} else if(same(k,"wall")) {
double v[6];
if(r->nwalls==MAX_OUTLINE) return "too many walls";
if(f.n<8 || !field_uuid(f.p[1]) || !floats(&f,2,v,6)) return "invalid wall record";
RWall *w=&r->walls[r->nwalls++];
*w=(RWall){v[0],v[1],v[2],v[3],v[4],v[5],f.n>8 && same(f.p[8],"INVISIBLE_WALL_FACE"),{0}};
copy(w->id,f.p[1],33);
} else if(same(k,"volume")) {
double v[7];
if(r->nvols==MAX_VOLUMES) return "too many volumes";
if(f.n<10 || !field_uuid(f.p[1]) || !field_label(f.p[2]) || !floats(&f,3,v,7)) return "invalid volume record";
RVol *o=&r->vols[r->nvols++];
*o=(RVol){v[0],v[1],v[2],v[3],v[4],v[5],v[6],{0},{0}};
copy(o->label,f.p[2],sizeof(o->label));copy(o->id,f.p[1],33);
} else if(same(k,"mesh")) {
u64 nv,ni;
if(f.n<5 || !field_uuid(f.p[1]) || !field_u64(f.p[3],&nv) || !field_u64(f.p[4],&ni) ||
nv>MAX_MESH_VERTICES || ni>(u64)MAX_TRIANGLES*3) return "invalid mesh record";
for(const char *c=f.p[2];*c;c++) if(*c=='/' || *c=='\\') return "invalid mesh file name";
copy(r->mesh_id,f.p[1],33);copy(r->mesh_name,f.p[2],sizeof(r->mesh_name));
r->mesh_nv=(u32)nv;r->mesh_ni=(u32)ni;r->has_mesh=1;
} else {
/* Forward compatibility: unknown records (plane, ...) are kept as they were written. */
if(r->nextra+raw_len+1>=MAX_EXTRA_TEXT) return "too many extra room records";
memcpy(r->extra+r->nextra,text+start,raw_len);r->nextra+=raw_len;r->extra[r->nextra++]='\n';
}
}
if(first) return "empty room file";
if(!r->source[0]) copy(r->source,"scan",sizeof(r->source));
if(!r->room_id[0] && !uuid_hex(r->room_id)) return "getrandom failed";
if(!have_floor && !uuid_hex(r->floor_id)) return "getrandom failed";
if(!have_ceiling) { if(!uuid_hex(r->ceiling_id)) return "getrandom failed";r->ceiling=r->floor+2.5; }
if(!r->noutline && r->nwalls>=3) {
double x0=1e10,x1=-1e10,z0=1e10,z1=-1e10;
for(int i=0;i<r->nwalls;i++) {
RWall *w=&r->walls[i];
double lx=w->x0<w->x1 ? w->x0 : w->x1,hx=w->x0<w->x1 ? w->x1 : w->x0;
double lz=w->z0<w->z1 ? w->z0 : w->z1,hz=w->z0<w->z1 ? w->z1 : w->z0;
x0=lx<x0 ? lx : x0;x1=hx>x1 ? hx : x1;z0=lz<z0 ? lz : z0;z1=hz>z1 ? hz : z1;
}
r->outline[0]=(DPt){x0,z0};r->outline[1]=(DPt){x1,z0};r->outline[2]=(DPt){x1,z1};r->outline[3]=(DPt){x0,z1};
r->noutline=4;
}
if(r->noutline<3) return "room has no outline";
double twice=0;
for(int i=0;i<r->noutline;i++) {
DPt a=r->outline[i],b=r->outline[(i+1)%r->noutline];twice+=a.x*b.z-b.x*a.z;
}
if(twice<0) for(int i=0;i<r->noutline/2;i++) { /* readers reverse a negative outline (ROOM_FORMAT.md) */
DPt t=r->outline[i];r->outline[i]=r->outline[r->noutline-1-i];r->outline[r->noutline-1-i]=t;
}
if(!r->nwalls) {
for(int i=0;i<r->noutline;i++) {
DPt a=r->outline[i],b=r->outline[(i+1)%r->noutline];
r->walls[i]=(RWall){a.x,a.z,b.x,b.z,r->floor,r->ceiling,0,{0}};
if(!uuid_hex(r->walls[i].id)) return "getrandom failed";
}
r->nwalls=r->noutline;
}
return 0;
}
/* ------------------------------------------------------------------------------------------------ files */
int join_path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
const char *read_file(const char *path, usize limit, u8 **data, usize *size)
{
*data=0;*size=0;
int fd=io_open(path,0);
if(fd<0) return "cannot open file";
usize cap=limit+1;
u8 *buf=io_alloc(cap);
if(!buf) { io_close(fd);return "file allocation failed"; }
usize n=0;
for(;;) {
long r=io_read(fd,buf+n,cap-n);
if(r<0) { io_close(fd);io_free(buf,cap);return "cannot read file"; }
if(!r) break;
n+=(usize)r;
if(n>limit) { io_close(fd);io_free(buf,cap);return "file too large"; }
}
io_close(fd);
/* The caller frees with io_free(data, limit+1); the size is the buffer, not the content. */
*data=buf;*size=n;return 0;
}
const char *publish(const char *dir, const char *name, const void *data, usize size)
{
char id[33],tmpname[256],final[4096],tmp[4096];
if(!uuid_hex(id)) return "getrandom failed";
usize n=0;
for(const char *p=name;*p && n<180;p++) tmpname[n++]=*p;
tmpname[n++]='.';
for(int i=0;i<32;i++) tmpname[n++]=id[i];
for(const char *p=".tmp";*p;p++) tmpname[n++]=*p;
tmpname[n]=0;
if(!join_path(final,dir,name) || !join_path(tmp,dir,tmpname)) return "output path too long";
int fd=io_open(tmp,1);
if(fd<0) return "cannot open temporary file";
int ok=io_write(fd,data,size);
if(io_close(fd)) ok=0;
if(!ok) { io_remove(tmp);return "cannot write temporary file"; }
if(io_rename(tmp,final)) { io_remove(tmp);return "cannot publish file"; }
return 0;
}
static u32 rd32(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static void wr32(u8 *p, u32 v) { for(int i=0;i<4;i++) p[i]=(u8)(v>>(8*i)); }
const char *mesh_parse(const u8 *data, usize size, Vec **vertices, u32 **indices, int *nv, int *ni)
{
*vertices=0;*indices=0;*nv=*ni=0;
if(size<16 || data[0]!='F' || data[1]!='P' || data[2]!='R' || data[3]!='M' || rd32(data+4)!=1) return "invalid mesh header";
u32 v=rd32(data+8),i=rd32(data+12);
if(v>MAX_MESH_VERTICES || i>(u32)MAX_TRIANGLES*3 || i%3 || size!=16+(usize)v*12+(usize)i*4) return "invalid mesh size";
Vec *pv=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
u32 *pi=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!pv || !pi) { io_free(pv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(pi,(usize)MAX_TRIANGLES*3*sizeof(u32));return "mesh allocation failed"; }
for(u32 k=0;k<v*3;k++) {
union { u32 u; float f; } c={rd32(data+16+4*(usize)k)};
if(!finite(c.f)) { io_free(pv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(pi,(usize)MAX_TRIANGLES*3*sizeof(u32));return "non-finite mesh vertex"; }
((float *)pv)[k]=c.f;
}
for(u32 k=0;k<i;k++) {
pi[k]=rd32(data+16+(usize)v*12+4*(usize)k);
if(pi[k]>=v) { io_free(pv,(usize)MAX_MESH_VERTICES*sizeof(Vec));io_free(pi,(usize)MAX_TRIANGLES*3*sizeof(u32));return "mesh index outside vertex array"; }
}
*vertices=pv;*indices=pi;*nv=(int)v;*ni=(int)i;return 0;
}
const char *mesh_bytes(const Vec *vertices, const u32 *indices, int nv, int ni, u8 **data, usize *size)
{
*size=16+(usize)nv*12+(usize)ni*4;
*data=io_alloc(*size);
if(!*data) return "mesh allocation failed";
u8 *p=*data;
p[0]='F';p[1]='P';p[2]='R';p[3]='M';wr32(p+4,1);wr32(p+8,(u32)nv);wr32(p+12,(u32)ni);
/* Both supported targets are little-endian IEEE float32, like the scanner's original writer. */
memcpy(p+16,vertices,(usize)nv*12);
memcpy(p+16+(usize)nv*12,indices,(usize)ni*4);
return 0;
}
/* ------------------------------------------------------------------------------------------------ scan.json */
void heads_json(Buf *b, const Vec *heads, int n)
{
put(b,"[");
for(int i=0;i<n;i++) {
if(i) put(b,",");
put(b,"[");put_dec(b,heads[i].x);put(b,",");put_dec(b,heads[i].y);put(b,",");put_dec(b,heads[i].z);put(b,"]");
}
put(b,"]");
}
int heads_parse(const char *text, usize n, Vec *heads)
{
/* Finds "heads":[[x,y,z],...] in a scan.json written by heads_json; anything else gives no heads. */
const char *key="\"heads\"";
usize at=0;
for(;at+7<=n;at++) {
usize k=0;while(k<7 && text[at+k]==key[k]) k++;
if(k==7) break;
}
if(at+7>n) return 0;
char *p=(char *)text+at+7;const char *end=text+n;
while(p<end && (is_space(*p) || *p=='\n' || *p==':')) p++;
if(p>=end || *p!='[') return 0;
p++;
int count=0;
while(p<end && count<MAX_HEADS) {
while(p<end && (is_space(*p) || *p=='\n' || *p==',')) p++;
if(p>=end || *p==']') break;
if(*p!='[') return 0;
p++;
float v[3];
for(int i=0;i<3;i++) {
while(p<end && (is_space(*p) || *p==',')) p++;
if(!parse_number(&p,&v[i])) return 0;
}
while(p<end && is_space(*p)) p++;
if(p>=end || *p!=']') return 0;
p++;
heads[count++]=vec(v[0],v[1],v[2]);
}
return count;
}
static void key_float(Buf *b, const char *k, float v) { put(b,",\"");put(b,k);put(b,"\":");put_dec(b,v); }
static void key_int(Buf *b, const char *k, i64 v, int first)
{
if(!first) put(b,",");
put(b,"\"");put(b,k);put(b,"\":");put_int(b,v);
}
void settings_json(Buf *b, const Refine *o)
{
static const char *walls[]={"off","snap","panels"};
put(b,"{");key_int(b,"refine",o->refine,1);
put(b,",\"walls\":\"");put(b,walls[o->walls]);put(b,"\"");
key_float(b,"band",o->band);key_float(b,"facing_deg",o->facing_deg);key_float(b,"band_in",o->band_in);
key_float(b,"floor_in",o->floor_in);key_float(b,"ceil_in",o->ceil_in);key_float(b,"top_strip",o->top_strip);
key_float(b,"near",o->near);key_float(b,"clip",o->clip);key_float(b,"clip_open",o->clip_open);
key_float(b,"island",o->island);key_int(b,"smooth",o->smooth,0);key_float(b,"tops",o->tops);
key_int(b,"protect",o->protect,0);key_float(b,"tubes",o->tubes);key_float(b,"tube_thick",o->tube_thick);
key_float(b,"walked",o->walked);key_int(b,"label_rules",o->label_rules,0);
put(b,",\"erase\":[");
for(int i=0;i<o->nerase;i++) {
const Sphere *e=&o->erase[i];
if(i) put(b,",");
put(b,"[");put_dec(b,e->x);put(b,",");put_dec(b,e->y);put(b,",");put_dec(b,e->z);put(b,",");put_dec(b,e->r);put(b,"]");
}
put(b,"]}");
}
void report_json(Buf *b, const Report *r, int nlabels, int changed, int removed, int nsuggest)
{
static const char *kinds[]={"wall","floor","ceiling"};
put(b,"{");key_int(b,"triangles",r->triangles,1);key_int(b,"kept",r->kept,0);
key_int(b,"structural",r->structural,0);key_int(b,"near_walls",r->near,0);key_int(b,"outside",r->outside,0);
key_int(b,"small_pieces",r->islands,0);key_int(b,"thin_pieces",r->tubes,0);key_int(b,"erased",r->erased,0);
key_int(b,"walked",r->walked,0);key_int(b,"tops",r->tops,0);key_int(b,"panel_triangles",r->panels,0);
key_int(b,"truncated",r->truncated,0);
put(b,",\"walked_skipped\":");put(b,r->walked_skipped ? "true" : "false");
key_float(b,"floor_y",r->floor_y);key_float(b,"ceiling_y",r->ceiling_y);
key_int(b,"boxes",nlabels,0);key_int(b,"relabelled",changed,0);key_int(b,"removed_boxes",removed,0);
key_int(b,"suggestions",nsuggest,0);
put(b,",\"plane_shifts\":[");
for(int i=0;i<r->nshifts;i++) {
if(i) put(b,",");
put(b,"{\"kind\":\"");put(b,kinds[r->shifts[i].kind]);put(b,"\"");
if(!r->shifts[i].kind) { put(b,",\"wall\":");put_int(b,r->shifts[i].wall); }
key_float(b,"shift",r->shifts[i].shift);put(b,"}");
}
put(b,"]}");
}
+255
View File
@@ -0,0 +1,255 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Label rules on the scanner's furniture boxes (the owner's tuned rules, 2026-10-18): boxes outside the room,
* boxes that merged several things, boxes nested in a bigger one are removed; the rest are relabelled BED, COUCH,
* TABLE, STORAGE, SCREEN or OTHER by shape; tall thin slabs from the floor become door/closet suggestions (never
* applied automatically). Every decision is reported with a reason in scan.json. */
#include "refine.h"
#define R_INSIDE .1f /* m: a centre further outside the outline is behind a wall */
#define R_NEAR_WALL .35f
#define R_BED_AREA 2.f
#define R_BED_LONG 1.8f
#define R_BED_MAX 3.f
#define R_BED_TOP .65f
#define R_BED_MIN_TOP .25f
#define R_COUCH_MIN_TOP .35f
#define R_COUCH_TOP .6f
#define R_COUCH_LONG 1.4f
#define R_TABLE_MIN .6f
#define R_TABLE_MAX 1.1f
#define R_STORAGE_H 1.1f
#define R_STORAGE_D .25f
#define R_SCREEN_BOTTOM .3f
#define R_SCREEN_TOP 2.2f
#define R_SCREEN_ASPECT 1.2f
#define R_SCREEN_PARALLEL 15.f /* degrees off the nearest wall */
#define R_SCREEN_FLAT .03f /* m: spread of the scan's depth inside the box */
#define R_THIN .22f
#define R_TALL_MIN 1.8f
#define R_TALL_FLOOR .3f /* m: a tall slab's bottom at most this far above the floor */
#define R_TALL_BEHIND .5f
#define R_DOOR_MAX 1.1f
#define R_MERGE .6f /* share of a box's footprint inside another that counts as held by it */
#define R_MERGE_APART 1.f
#define R_NESTED .75f
#define FLAT_MIN_TRIANGLES 30
#define FLAT_DEPTH_PAD .05f
#define FOOTPRINT_STEP .05
typedef struct { double cs, sn; } Rot;
static Rot rot(const RVol *v) { double a=v->yaw*3.14159265358979323846/180; return (Rot){dcos(a),dsin(a)}; }
static void local(const RVol *v, Rot r, double x, double z, double *lx, double *lz)
{
double dx=x-v->cx,dz=z-v->cz;
*lx=r.cs*dx-r.sn*dz;*lz=r.sn*dx+r.cs*dz;
}
static double dabs(double x) { return x<0 ? -x : x; }
static void corners(const RVol *v, Rot r, double out[4][2])
{
static const int sg[4][2]={{-1,-1},{1,-1},{1,1},{-1,1}};
for(int k=0;k<4;k++) {
double x=sg[k][0]*v->w/2.,z=sg[k][1]*v->d/2.;
out[k][0]=v->cx+r.cs*x+r.sn*z;out[k][1]=v->cz-r.sn*x+r.cs*z;
}
}
static int point_in_poly(double x, double z, const DPt *poly, int n)
{
int inside=0;
for(int i=0,j=n-1;i<n;j=i++) {
double xi=poly[i].x,zi=poly[i].z,xj=poly[j].x,zj=poly[j].z;
if((zi>z)!=(zj>z) && x<(xj-xi)*(z-zi)/(zj-zi+1e-12)+xi) inside=!inside;
}
return inside;
}
typedef struct { double dist, t, L, ux, uz; int i; } Near;
static Near nearest_wall(const Room *r, double x, double z)
{
Near best={1e300,0,0,1,0,-1};
for(int i=0;i<r->nwalls;i++) {
const RWall *w=&r->walls[i];
double abx=w->x1-w->x0,abz=w->z1-w->z0,l2=abx*abx+abz*abz,L=dhypot(abx,abz);
if(!(l2>0)) continue;
double t=((x-w->x0)*abx+(z-w->z0)*abz)/l2;
t=t<0 ? 0 : t>1 ? 1 : t;
double dx=x-(w->x0+t*abx),dz=z-(w->z0+t*abz),d=dhypot(dx,dz);
if(d<best.dist) best=(Near){d,t,L,abx/L,abz/L,i};
}
return best;
}
static double outside(const Room *r, double x, double z)
{
return point_in_poly(x,z,r->outline,r->noutline) ? 0 : nearest_wall(r,x,z).dist;
}
/* Spread (std) of the scan's depth inside a thin upright box; <0 = too little scan to tell. */
static double flatness(const Prep *p, const RVol *v)
{
Rot r=rot(v);
double sum=0,sum2=0;int n=0;
for(int pass=0;pass<2;pass++) {
double mean=n ? sum/n : 0;
for(int t=0;t<p->T;t++) {
double y=p->cen[3*t+1];
if(y>v->top || y<v->top-v->h) continue;
double lx,lz;local(v,r,p->cen[3*t],p->cen[3*t+2],&lx,&lz);
double a=lx,b=lz,half=v->w/2.,depth=v->d/2.+FLAT_DEPTH_PAD;
if(v->d>v->w) { a=lz;b=lx;half=v->d/2.;depth=v->w/2.+FLAT_DEPTH_PAD; }
if(dabs(a)<=half && dabs(b)<=depth) {
if(!pass) { sum+=b;n++; } else sum2+=(b-mean)*(b-mean);
}
}
if(!pass && n<FLAT_MIN_TRIANGLES) return -1;
}
return droot(sum2/n);
}
static void cm(Buf *b, double x) { put_round(b,x*100);put(b," cm"); }
static void lower(Buf *b, const char *s)
{
char c[2]={0,0};
for(;*s;s++) { c[0]=*s>='A' && *s<='Z' ? (char)(*s+32) : *s;put(b,c); }
}
static void set_text(char *dst, usize cap, Buf *b)
{
usize n=b->ok ? b->n : 0;
if(n>=cap) { /* cut at a UTF-8 boundary */
n=cap-1;
while(n && ((u8)b->data[n]&0xc0)==0x80) n--;
}
if(n) memcpy(dst,b->data,n);
dst[n]=0;
}
static void copy(char *dst, const char *src, usize cap)
{
usize i=0;while(src[i] && i+1<cap) { dst[i]=src[i];i++; }
dst[i]=0;
}
/* label "" = remove, suggest "door"/"closet" or "" */
static void classify(const Room *room, const Prep *p, const RVol *v, char *label, char *suggest, Buf *why)
{
double out=outside(room,v->cx,v->cz);
double top=v->top-room->floor,bottom=top-v->h,lng=v->w>v->d ? v->w : v->d,shrt=v->w<v->d ? v->w : v->d;
label[0]=suggest[0]=0;
if(shrt<R_THIN && v->h>=R_TALL_MIN && bottom<=R_TALL_FLOOR && out<=R_TALL_BEHIND) {
int door=lng<=R_DOOR_MAX;
copy(suggest,door ? "door" : "closet",8);
put(why,"tall thin slab from the floor (");put_fixed(why,v->h,2);put(why," m)");
if(out>0) { put(why,", ");cm(why,out);put(why," behind the wall line"); }
put(why,door ? ": a door or closet door?" : ": a closet front?");
return;
}
if(out>R_INSIDE) { put(why,"centre ");cm(why,out);put(why," outside the room (behind a wall)");return; }
Near wall=nearest_wall(room,v->cx,v->cz);
if(shrt<R_THIN) {
Rot r=rot(v);
double ax=v->w>=v->d ? r.cs : r.sn,az=v->w>=v->d ? -r.sn : r.cs;
double c=dabs(ax*wall.ux+az*wall.uz);c=c<1 ? c : 1;
double off=angle((float)droot(1-c*c),(float)c)*180/PI;
double fl=flatness(p,v);
Buf b;buf_init(&b,1024);int n=0;
if(bottom<R_SCREEN_BOTTOM) { put(&b,"starts ");cm(&b,bottom>0 ? bottom : 0);put(&b," off the floor");n++; }
if(top>R_SCREEN_TOP) { if(n++) put(&b,", ");put(&b,"top at ");put_fixed(&b,top,2);put(&b," m"); }
if(lng/v->h<R_SCREEN_ASPECT) { if(n++) put(&b,", ");put(&b,"taller than wide (");put_fixed(&b,lng/v->h,2);put(&b,")"); }
if(wall.dist<R_NEAR_WALL && off>R_SCREEN_PARALLEL) { if(n++) put(&b,", ");put_round(&b,off);put(&b,"\xc2\xb0 off the wall"); }
if(fl>=0 && fl>R_SCREEN_FLAT) { if(n++) put(&b,", ");put(&b,"not flat (");cm(&b,fl);put(&b," spread)"); }
if(!n) {
copy(label,"SCREEN",MAX_LABEL);put(why,"thin, raised, screen-shaped");
if(fl>=0) { put(why,", flat (");cm(why,fl);put(why,")"); }
} else { put(why,"thin upright slab: ");put_n(why,b.data,b.n); }
buf_free(&b);
return;
}
if(top<=R_BED_TOP && v->w*v->d>=R_BED_AREA && lng>=R_BED_LONG && lng<=R_BED_MAX && top>=R_BED_MIN_TOP) {
copy(label,"BED",MAX_LABEL);put_fixed(why,(double)v->w*v->d,1);put(why," m\xc2\xb2, ");put_fixed(why,lng,1);
put(why," m long, top ");put_fixed(why,top,2);put(why," m");return;
}
if(top>=R_COUCH_MIN_TOP && top<=R_COUCH_TOP && lng>=R_COUCH_LONG) {
copy(label,"COUCH",MAX_LABEL);put(why,"seat-height top ");put_fixed(why,top,2);put(why," m, ");
put_fixed(why,lng,1);put(why," m long");return;
}
if(top>=R_TABLE_MIN && top<=R_TABLE_MAX) { copy(label,"TABLE",MAX_LABEL);put(why,"top ");put_fixed(why,top,2);put(why," m");return; }
if(v->h>=R_STORAGE_H && shrt>=R_STORAGE_D && wall.dist<R_NEAR_WALL) {
copy(label,"STORAGE",MAX_LABEL);put_fixed(why,v->h,2);put(why," m tall by a wall");return;
}
if(top>R_TABLE_MAX) { put_fixed(why,top,2);put(why," m tall, matches nothing");return; }
copy(label,"OTHER",MAX_LABEL);put(why,"top ");put_fixed(why,top,2);put(why," m, ");put_fixed(why,lng,1);
put(why," \xc3\x97 ");put_fixed(why,shrt,1);put(why," m");
}
/* Share of a's and b's footprint they have in common, sampled on a 5 cm grid over both (as the reference). */
static void footprint_overlap(const RVol *a, const RVol *b, double *fa, double *fb)
{
Rot ra=rot(a),rb=rot(b);
double ca[4][2],cb[4][2],x0=1e300,x1=-1e300,z0=1e300,z1=-1e300;
corners(a,ra,ca);corners(b,rb,cb);
for(int k=0;k<4;k++) {
x0=ca[k][0]<x0 ? ca[k][0] : x0;x1=ca[k][0]>x1 ? ca[k][0] : x1;z0=ca[k][1]<z0 ? ca[k][1] : z0;z1=ca[k][1]>z1 ? ca[k][1] : z1;
x0=cb[k][0]<x0 ? cb[k][0] : x0;x1=cb[k][0]>x1 ? cb[k][0] : x1;z0=cb[k][1]<z0 ? cb[k][1] : z0;z1=cb[k][1]>z1 ? cb[k][1] : z1;
}
double na=0,nb=0,both=0;
for(double x=x0;x<=x1;x+=FOOTPRINT_STEP) for(double z=z0;z<=z1;z+=FOOTPRINT_STEP) {
double ax,az,bx,bz;local(a,ra,x,z,&ax,&az);local(b,rb,x,z,&bx,&bz);
int ia=dabs(ax)<=a->w/2. && dabs(az)<=a->d/2.,ib=dabs(bx)<=b->w/2. && dabs(bz)<=b->d/2.;
na+=ia;nb+=ib;both+=ia && ib;
}
*fa=both/(na>1 ? na : 1);*fb=both/(nb>1 ? nb : 1);
}
static int boxes_apart(const RVol *a, const RVol *b)
{
/* footprints whose bounding circles don't touch share nothing (skips the sampling, same result) */
double ra=droot((double)a->w*a->w+(double)a->d*a->d)/2,rb=droot((double)b->w*b->w+(double)b->d*b->d)/2;
double dx=a->cx-b->cx,dz=a->cz-b->cz;
return dx*dx+dz*dz>(ra+rb+.1)*(ra+rb+.1);
}
int rules_apply(const Room *room, const Prep *p, RVol *out, int *nout, Label *labels)
{
int n=room->nvols;const RVol *box=room->vols;
typedef struct { int i, j; double fa, fb; } Ov;
static Ov ov[MAX_VOLUMES*(MAX_VOLUMES-1)/2];
int nov=0,removed[MAX_VOLUMES]={0};
static char reason[MAX_VOLUMES][MAX_REASON];
for(int i=0;i<n;i++) for(int j=i+1;j<n;j++) {
if(boxes_apart(&box[i],&box[j])) continue;
double fa,fb;footprint_overlap(&box[i],&box[j],&fa,&fb);
if(fa>0 || fb>0) ov[nov++]=(Ov){i,j,fa,fb};
}
/* merged: a box that holds most of two boxes far apart covers several things */
for(int k=0;k<n;k++) {
int held[MAX_VOLUMES],nh=0;
for(int q=0;q<nov;q++) if(ov[q].i==k && ov[q].fb>=R_MERGE) held[nh++]=ov[q].j;
for(int q=0;q<nov;q++) if(ov[q].j==k && ov[q].fa>=R_MERGE) held[nh++]=ov[q].i;
for(int x=0;x<nh && !removed[k];x++) for(int y=0;y<nh && !removed[k];y++) {
const RVol *a=&box[held[x]],*b=&box[held[y]];
double dx=a->cx-b->cx,dz=a->cz-b->cz,d=dhypot(dx,dz);
if(held[x]<held[y] && d>=R_MERGE_APART) {
Buf t;buf_init(&t,1024);
put(&t,"holds a ");lower(&t,a->label);put(&t," and a ");lower(&t,b->label);put(&t," ");
put_fixed(&t,d,1);put(&t," m apart: several things merged");
set_text(reason[k],MAX_REASON,&t);buf_free(&t);removed[k]=1;
}
}
}
/* nested: mostly inside a bigger box with a lower top = part of it */
for(int q=0;q<nov;q++) for(int side=0;side<2;side++) {
int s=side ? ov[q].j : ov[q].i,g=side ? ov[q].i : ov[q].j;double f=side ? ov[q].fb : ov[q].fa;
const RVol *S=&box[s],*G=&box[g];
if(removed[s] || removed[g] || f<R_NESTED || (double)S->w*S->d>=(double)G->w*G->d || S->top>G->top+.05) continue;
Buf t;buf_init(&t,1024);
put_round(&t,f*100);put(&t,"% inside the bigger ");lower(&t,G->label);put(&t," box: part of it");
set_text(reason[s],MAX_REASON,&t);buf_free(&t);removed[s]=1;
}
int count=0;
for(int i=0;i<n;i++) {
Label *l=&labels[i];
memset(l,0,sizeof(*l));
copy(l->from,box[i].label,MAX_LABEL);l->box=box[i];l->wall=-1;
if(removed[i]) { copy(l->reason,reason[i],MAX_REASON);continue; }
Buf why;buf_init(&why,1024);
classify(room,p,&box[i],l->to,l->suggest,&why);
set_text(l->reason,MAX_REASON,&why);buf_free(&why);
if(l->suggest[0]) {
Near nw=nearest_wall(room,box[i].cx,box[i].cz);
l->wall=nw.i;l->along=(float)nw.t;
}
if(l->to[0]) { out[count]=box[i];copy(out[count].label,l->to,MAX_LABEL);count++; }
}
*nout=count;
return n;
}
+17 -2
View File
@@ -13,6 +13,8 @@
#define MAX_VOLUMES 128
#define MAX_TRIANGLES 200000
#define MAX_MESH_VERTICES 600000
#define MAX_HEADS 2000
#define MAX_ERASE 64
#define PI 3.14159265358979323846f
typedef struct { float x, y, z; } Vec;
@@ -29,8 +31,17 @@ typedef struct {
int used;
} Plane;
typedef struct { Vec p; float yaw, w, d, h; const char *label; } Volume;
typedef struct { float x, y, z, r; } Sphere;
/* Refinement + label rules (refine.c, rules.c); defaults = the owner's tuned settings (2026-10-18). */
typedef struct {
const char *input, *out, *hint;
int refine, walls, protect, label_rules, smooth, nerase; /* walls: 0 off, 1 snap, 2 panels */
float band, facing_deg, band_in, floor_in, ceil_in, top_strip, near, clip, clip_open, island, tops;
float tubes, tube_thick, walked;
Sphere erase[MAX_ERASE];
} Refine;
typedef struct {
const char *input, *out, *hint, *refine_only;
Refine rf;
u64 universe;
float voxel, ceiling_default, truncation, max_edge, grazing, range, depth_jump, depth_ratio, min_views;
float patch_residual;
@@ -56,10 +67,13 @@ typedef struct {
const char *warnings[24];
int nwarnings, hint_used;
u32 rng;
Vec heads[MAX_HEADS]; /* head path of tracked frames; the stride doubles whenever the buffer is full */
int nheads;
unsigned head_stride, head_frames;
Vec *vertices;
u32 *indices;
int nv, ni;
i64 t0, tread, tfuse, tmodel, tmesh;
i64 t0, tread, tfuse, tmodel, tmesh, trefine;
} Scan;
static inline float ab(float x) { return x < 0 ? -x : x; }
@@ -89,5 +103,6 @@ void finish_fusion(Scan *s);
const char *make_model(Scan *s);
const char *make_mesh(Scan *s);
int same(const char *a, const char *b);
int parse_number(char **cursor, float *value);
#endif
+6 -2
View File
@@ -12,6 +12,10 @@ BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)]
L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)]
TABLE = ((-.6, 0, -.25), (1.2, .75, .7))
COUCH = ((.6, 0, .95), (1.6, .65, .65))
# Clutter for the refinement tests: a thin pole floating in the air (a stereo streak) and a column where the user
# stood (a "ghost" of the user's own body or a passer-by in the depth stream).
TUBE = ((1.3, 1.35, -.5), (.12, .12, 1.4))
GHOST = ((.35, .45, 0), (.3, .9, .3))
ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128
ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0
@@ -104,7 +108,7 @@ def cast(eye, direction, polygon, ceiling, furniture, missing_wall):
def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004,
holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7,
yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview",
invalid_start=None, smoothing=3):
invalid_start=None, smoothing=3, extra=()):
"""Each frame has its own moving pose; no ground-truth room data is put in the dump."""
rng = random.Random(seed)
roomview = profile == "roomview"
@@ -113,7 +117,7 @@ def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise
invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start
polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE
ceiling = 2.6
furniture = [TABLE, COUCH] if objects and shape == "box" else []
furniture = ([TABLE, COUCH] if objects and shape == "box" else []) + list(extra)
rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2))
rays = list(fan(width, height, roomview))
with path.open("wb") as out:
+111 -48
View File
@@ -8,6 +8,7 @@ Quest builds are "installed" (the 8 largest).
from __future__ import annotations
import json
import math
import os
import random
import threading
@@ -30,61 +31,98 @@ def frame_state() -> dict:
return {}
def fake_room_mesh(room, cell: float = 0.25, seed: int = 7):
def fake_room_mesh(room, cell: float = 0.2, seed: int = 7):
"""A made-up scan of `room`: its floor, walls and furniture as noisy triangle grids, wound like a real scan's
(counter-clockwise seen from inside the room)."""
from frameport.mr.room import Mesh
(counter-clockwise seen from inside the room). Like a real scan it has holes where the headset sees nothing:
the floor under furniture that stands on it, and window glass."""
from frameport.mr.room import Mesh, plane_corners
from frameport.mr.view3d import box_corners
rnd = random.Random(seed)
verts, idx = [], []
def surface(o, u, v): # o + s*u + t*v, normal = u x v
def surface(o, u, v, noise=0.012, keep=None): # o + s*u + t*v, normal = u x v; keep(point) False = a hole
lu = max(1, round((sum(c * c for c in u) ** 0.5) / cell))
lv = max(1, round((sum(c * c for c in v) ** 0.5) / cell))
base = len(verts)
for i in range(lv + 1):
for j in range(lu + 1):
n = 0.015 if 0 < i < lv and 0 < j < lu else 0.0
n = noise if 0 < i < lv and 0 < j < lu else 0.0
verts.append(tuple(o[k] + u[k] * j / lu + v[k] * i / lv + rnd.uniform(-n, n) for k in range(3)))
for i in range(lv):
for j in range(lu):
mid = tuple(o[k] + u[k] * (j + 0.5) / lu + v[k] * (i + 0.5) / lv for k in range(3))
if keep is not None and not keep(mid):
continue
a = base + i * (lu + 1) + j
b, c, d = a + 1, a + lu + 1, a + lu + 2
idx.extend((a, b, d, a, d, c))
def inside(vol, x, z): # (x, z) on the box's footprint
a = math.radians(vol.yaw_deg)
dx, dz = x - vol.cx, z - vol.cz
lx, lz = math.cos(a) * dx - math.sin(a) * dz, math.sin(a) * dx + math.cos(a) * dz
return abs(lx) < vol.w / 2 and abs(lz) < vol.d / 2
y0 = room.floor
pts = room.outline
xs, zs = [x for x, _ in pts], [z for _, z in pts]
surface((min(xs), y0, max(zs)), (max(xs) - min(xs), 0, 0), (0, 0, min(zs) - max(zs))) # floor, facing up
standing = [v for v in room.volumes if v.top_y - v.h - y0 < 0.05 and v.h > 0.3] # on the floor, no gap below
windows = [plane_corners(p) for p in room.planes if p.label == "WINDOW_FRAME"]
def floor_seen(p):
return not any(inside(v, p[0], p[2]) for v in standing)
def wall_seen(p): # not through a window's glass
for c in windows:
xs, ys, zs = [q[0] for q in c], [q[1] for q in c], [q[2] for q in c]
if (min(ys) + 0.05 < p[1] < max(ys) - 0.05 and min(xs) - 0.05 <= p[0] <= max(xs) + 0.05
and min(zs) - 0.05 <= p[2] <= max(zs) + 0.05):
return False
return True
xs, zs = [x for x, _ in room.outline], [z for _, z in room.outline]
surface((min(xs), y0, max(zs)), (max(xs) - min(xs), 0, 0), (0, 0, min(zs) - max(zs)), 0.008,
floor_seen) # floor, facing up
for w in room.walls:
if w.label == "INVISIBLE_WALL_FACE":
continue
surface((w.x0, w.y0, w.z0), (w.x1 - w.x0, 0, w.z1 - w.z0), (0, w.y1 - w.y0, 0)) # facing into the room
surface((w.x0, w.y0, w.z0), (w.x1 - w.x0, 0, w.z1 - w.z0), (0, w.y1 - w.y0, 0), 0.012,
wall_seen) # facing into the room
for vol in room.volumes:
c = box_corners(vol.cx, vol.cz, vol.yaw_deg, vol.w, vol.d)
top, bottom = vol.top_y, vol.top_y - vol.h
soft = 0.03 if vol.label in ("BED", "COUCH") else 0.015 # (a duvet isn't flat)
surface((c[0][0], top, c[0][1]), (c[3][0] - c[0][0], 0, c[3][1] - c[0][1]),
(c[1][0] - c[0][0], 0, c[1][1] - c[0][1])) # top, facing up
(c[1][0] - c[0][0], 0, c[1][1] - c[0][1]), soft) # top, facing up
for i in range(4):
a, b = c[i], c[(i + 1) % 4]
surface((a[0], bottom, a[1]), (0, top - bottom, 0), (b[0] - a[0], 0, b[1] - a[1])) # facing out
surface((a[0], bottom, a[1]), (0, top - bottom, 0), (b[0] - a[0], 0, b[1] - a[1]), 0.012) # facing out
return Mesh(vertices=verts, indices=idx)
def fake_room():
"""The pretend Frame's scanned room: a 5 x 4 m living room with a couch, a table, a TV and shelves."""
from frameport.mr.room import Volume, box_room
"""The pretend Frame's scanned room, made up from scratch (no real scan): a 3.6 x 3.2 m bedroom with a bed and a
nightstand under a wall-mounted TV, a closet, a desk with two monitors under the window, a door, a lamp and a
plant. Walls: 0 = the bed's (-Z, forward), 1 = right (+X), 2 = behind (+Z), 3 = the desk's and the door's (-X);
the 3D view looks at walls 0 and 3."""
from frameport.mr.room import Volume, box_room, plane_on_wall
room = box_room(5, 4, 2.6)
room = box_room(3.6, 3.2, 2.5)
room.source, room.universe, room.time = "scan", 7, int(SHOTS_AT)
room.walls[1].label = "INVISIBLE_WALL_FACE" # an open side (a doorway the scan closed)
room.walls = [type(w)(f"{0xa0 + i:032x}", w.x0, w.z0, w.x1, w.z1, w.y0, w.y1, w.label)
for i, w in enumerate(room.walls)] # (fixed ids: the same room in every render)
room.uuid, room.floor_uuid, room.ceiling_uuid = (f"{0xf0 + i:032x}" for i in range(3))
vid = iter(f"{i:032x}" for i in range(1, 99))
room.volumes = [Volume(next(vid), "COUCH", -1.2, 1.3, 0.85, 0, 2.2, 0.9, 0.85),
Volume(next(vid), "TABLE", -0.9, 0.1, 0.45, 0, 1.1, 0.6, 0.45),
Volume(next(vid), "SCREEN", -1.0, -1.85, 1.5, 0, 1.4, 0.12, 0.8),
Volume(next(vid), "STORAGE", 1.9, -1.4, 1.8, 90, 1.2, 0.4, 1.8),
Volume(next(vid), "TABLE", 1.6, 1.2, 0.75, 20, 1.2, 0.7, 0.75)]
room.volumes = [Volume(next(vid), "BED", 0.6, -0.58, 0.55, 0, 1.45, 2.05, 0.55),
Volume(next(vid), "SCREEN", 0.6, -1.55, 1.78, 0, 1.12, 0.07, 0.66), # TV above the bed
Volume(next(vid), "TABLE", -1.44, -0.25, 0.75, 90, 1.4, 0.7, 0.75), # desk
Volume(next(vid), "SCREEN", -1.6, -0.56, 1.2, 82, 0.6, 0.06, 0.36), # monitors on it
Volume(next(vid), "SCREEN", -1.6, 0.06, 1.2, 98, 0.6, 0.06, 0.36),
Volume(next(vid), "STORAGE", -1.25, -1.3, 2.1, 0, 1.0, 0.6, 2.1), # closet
Volume(next(vid), "TABLE", -0.42, -1.38, 0.52, 0, 0.45, 0.4, 0.52), # nightstand
Volume(next(vid), "LAMP", -0.42, -1.42, 0.9, 0, 0.2, 0.2, 0.38),
Volume(next(vid), "PLANT", 1.56, -1.37, 0.95, 0, 0.36, 0.36, 0.95)] # in the corner
room.planes = [plane_on_wall(room, 3, 0.17, 0.0, 0.85, 2.05, "DOOR_FRAME", f"{0xd0:032x}"),
plane_on_wall(room, 3, 0.58, 1.22, 1.05, 1.05, "WINDOW_FRAME", f"{0xd1:032x}")]
return room
@@ -99,6 +137,7 @@ class FakeRoom:
self.home = home
self.scan: dict = {"state": "none"}
self.polls = 0
self.mesh_triangles = 0
if ROOM_DIR:
from pathlib import Path
@@ -106,13 +145,17 @@ 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
@@ -130,37 +173,57 @@ 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): what the label rules decided for the scanner's boxes, door / closet suggestions
# 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": "COUCH", "to": "COUCH", "reason": "seat top 0.85 m, 2.2 m long by a wall: a couch",
"box": {"w": 2.2, "d": 0.9, "h": 0.85}},
{"from": "COUCH", "to": "TABLE", "reason": "top at 0.45 m and only 1.1 m long: a low table, not a couch",
"box": {"w": 1.1, "d": 0.6, "h": 0.45}},
{"from": "TABLE", "to": "SCREEN", "reason": "thin, raised 0.7 m, parallel to the wall, flat (1 cm): a screen",
"box": {"w": 1.4, "d": 0.12, "h": 0.8}},
{"from": "STORAGE", "to": "STORAGE", "reason": "1.8 m high and 0.4 m deep by a wall: storage",
"box": {"w": 1.2, "d": 0.4, "h": 1.8}},
{"from": "TABLE", "to": "TABLE", "reason": "top at 0.75 m: a table", "box": {"w": 1.2, "d": 0.7, "h": 0.75}},
{"from": "OTHER", "to": None, "reason": "0.6 m behind the wall: outside the room",
"box": {"w": 0.5, "d": 0.4, "h": 0.9}},
{"from": "TABLE", "to": None, "reason": "holds a couch and a table 1.4 m apart: several things merged",
"box": {"w": 3.1, "d": 1.6, "h": 0.8}},
{"from": "STORAGE", "to": None, "suggest": "door", "reason": "2.05 m high, 12 cm thin, flat on the wall",
"box": {"w": 0.95, "d": 0.12, "h": 2.05}}]
SUGGESTIONS = [{"kind": "door", "box": {"cx": 2.45, "cz": 0.9, "top_y": 2.05, "yaw_deg": 90, "w": 0.95, "d": 0.12,
"h": 2.05}, "wall": 1, "along": 0.72,
"reason": "A tall, thin slab on the wall, 0.95 m wide: a door, or a closet's front"}]
{"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 {})}
removed = int(5200 * s["walked"]) + int(9000 * s["island"]) + (2400 if s["walls"] == "panels" else 600)
# scan.json's report keys (docs/mr/ROOM_FORMAT.md)
return {"triangles": 48210, "kept": 48210 - removed, "structural": 21480 if s["walls"] != "off" else 0,
"near_walls": 310, "outside": 1840, "small_pieces": int(9000 * s["island"]), "thin_pieces": 212,
"erased": 0, "walked": int(5200 * s["walked"]), "tops": 640,
"panel_triangles": 96 if s["walls"] == "panels" else 0, "truncated": 0, "walked_skipped": False,
"floor_y": 0.0, "ceiling_y": 2.6, "boxes": 7, "relabelled": 2, "removed_boxes": 2, "suggestions": 1,
"plane_shifts": [{"kind": "wall", "wall": 0, "shift": 0.02}]}
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
+12 -1
View File
@@ -399,6 +399,17 @@ def _long_upload(session, package: str, seconds: float = 30.0, after: float = 2.
app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install"))
def _open_room(session) -> None:
"""The Room tab with the pretend Frame's scanned room loaded and drawn (3D view, cards)."""
fakes.open_room(session.app)
def _room_editing(session, which: str = "box") -> None:
"""The Room tab's editing cards scrolled into view (fakes.show_room_editing: cleanup, more, box = a box selected,
doors, suggestion = the door suggestion taken)."""
fakes.show_room_editing(session.app, which)
def _press_keys(session, text: str, gap: float = 0.28) -> None:
"""Keys typed on the Type on Frame tab (the pretend keyboard), one by one."""
view = session.app.keyboard_view
@@ -414,4 +425,4 @@ HOOKS = {"fake_install": _fake_install, "wait_jobs": _wait_jobs, "held_install":
"frame_asks": _frame_asks,
"easter_eggs": _easter_eggs, "fps_dip": _fps_dip, "perfect_pacing": _perfect_pacing,
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys,
"monitor_settled": _monitor_settled}
"monitor_settled": _monitor_settled, "open_room": _open_room, "room_editing": _room_editing}
+1 -1
View File
@@ -10,7 +10,7 @@ const site = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const repo = resolve(site, '..');
const SHOTS = ['library.png', 'game.png', 'monitor.png', 'live-view.png', 'files.png', 'type-on-frame.png',
'screenshots.png', 'install-progress.png', 'patches.png'];
'screenshots.png', 'install-progress.png', 'patches.png', 'room.png'];
const PUBLIC = {
'docs/images': ['tour-teaser.webp'],
'docs/media': ['frameport-tour.mp4', 'frameport-tour.jpg', 'frameport-install.mp4', 'frameport-install.jpg'],
+2
View File
@@ -13,6 +13,7 @@ import keys from '../assets/media/type-on-frame.png';
import screenshots from '../assets/media/screenshots.png';
import progress from '../assets/media/install-progress.png';
import patches from '../assets/media/patches.png';
import room from '../assets/media/room.png';
import { url } from '../data/site';
interface Props { games: MockGame[] }
@@ -21,6 +22,7 @@ const shots = [
{ img: library, title: 'Library', text: 'Your games with their artwork. Search, filter, install several at once.' },
{ img: game, title: 'Game page', text: 'One click to install or play. Patches under Customize.' },
{ img: progress, title: 'Installing', text: 'Each stage, with speed and time left.' },
{ img: room, title: 'Room (experimental)', text: 'Your scanned room in 3D for mixed-reality games. Clean up the scan, fix the labels.' },
{ img: monitor, title: 'Monitor', text: "Frame rate, load, temperature and battery, live." },
{ img: live, title: 'Live view', text: 'What the headset shows, with sound, in your browser.' },
{ img: screenshots, title: 'Screenshots', text: 'Your headset screenshots, by game and day.' },
+46 -8
View File
@@ -112,9 +112,10 @@ class RoomView:
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))
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(view, padding=T.S3, col={"xs": 12, "lg": 8})
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…"))]))
@@ -137,8 +138,10 @@ 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: as tall as the 3D view's card (its last card takes what's left),
# every card with the same padding
# 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=T.S4),
C.card(ft.Column([C.section(tr("Scan"), self.scan_box,
@@ -146,10 +149,11 @@ class RoomView:
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,
ft.Container(expand=True), self._tester()], spacing=T.S2),
padding=T.S4, expand=True),
self.level_gap, self._tester()], spacing=T.S2),
padding=T.S4),
], spacing=T.S4, col={"xs": 12, "lg": 4}, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
height=T.px(CANVAS_HEIGHT) + 2 * T.S3 + 2)
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
@@ -309,6 +313,40 @@ 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:
+11 -3
View File
@@ -324,8 +324,7 @@ class LabelsCard:
@staticmethod
def _size(box: dict) -> str:
try:
return tr("{w}×{d}×{h} m").format(w=f"{float(box['w']):.1f}", d=f"{float(box['d']):.1f}",
h=f"{float(box['h']):.1f}")
return size_text(float(box["w"]), float(box["d"]), float(box["h"]))
except (KeyError, TypeError, ValueError):
return ""
@@ -400,6 +399,15 @@ def _panel(rows: list[ft.Control]) -> ft.Container:
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"),
@@ -529,7 +537,7 @@ class FurnitureEditor:
# ------------------------------------------------------------------ lists
@staticmethod
def _box_size(v) -> str:
return tr("{w}×{d}×{h} m").format(w=f"{v.w:.1f}", d=f"{v.d:.1f}", h=f"{v.h:.1f}")
return size_text(v.w, v.d, v.h)
def _plane_info(self, i: int) -> str:
p = self.room.planes[i]
+7
View File
@@ -372,3 +372,10 @@ class _local_dir:
def __exit__(self, *a):
return False
def test_box_sizes_keep_thin_sizes_readable():
from frameport.ui.views.room_edit import metres, size_text
assert metres(0.03) == "0.03" and metres(0.07) == "0.07" and metres(0.5) == "0.5" and metres(2.05) == "2.0"
assert size_text(1.6, 1.1, 0.03) == "1.6×1.1×0.03 m"
+241 -8
View File
@@ -54,9 +54,9 @@ def run(scanner, dump, directory, *args, success=True):
return report
def room(directory):
def room(directory, name="room.txt"):
records = {}
lines = (directory / "room.txt").read_text().splitlines()
lines = (directory / name).read_text().splitlines()
assert lines[0] == "frameport-room 1"
for line in lines:
if not line or line.startswith("#"):
@@ -95,8 +95,8 @@ def assert_walls(polygon, expected):
(a[1] + b[1]) / 2 + .04 * dx / length, polygon)
def mesh(directory):
data = (directory / "room-mesh.bin").read_bytes()
def mesh(directory, name="room-mesh.bin"):
data = (directory / name).read_bytes()
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
assert (magic, version) == (b"FPRM", 1)
assert 0 < ni // 3 <= 200_000 and ni % 3 == 0
@@ -115,7 +115,8 @@ def test_box_room_geometry_furniture_and_wire_format(scanner, dumps, tmp_path):
assert report["coverage_percent"] == 100
assert abs(report["floor_y"]) < .05
assert abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path)
# The scanner's own output is kept as the raw files; room.txt + room-mesh.bin are refined (tests further down).
records = room(tmp_path, "room-raw.txt")
assert records["universe"] == [["18446744073709551615"]]
assert records["source"] == [["scan"]]
assert_walls(outline(records), synth.BOX_OUTLINE)
@@ -131,8 +132,12 @@ def test_box_room_geometry_furniture_and_wire_format(scanner, dumps, tmp_path):
ids = [row[0] for kind in ("room", "floor", "ceiling", "wall", "volume", "mesh") for row in records[kind]]
assert len(ids) == len(set(ids))
assert all(re.fullmatch("[0-9a-f]{32}", value) for value in ids)
vertices, indices = mesh(tmp_path)
assert records["mesh"][0][1:] == ["room-mesh.bin", str(len(vertices)), str(len(indices))]
vertices, indices = mesh(tmp_path, "room-mesh-raw.bin")
assert records["mesh"][0][1:] == ["room-mesh-raw.bin", str(len(vertices)), str(len(indices))]
refined = room(tmp_path)
assert [row[0] for row in refined["room"] + refined["floor"] + refined["wall"]] == \
[row[0] for row in records["room"] + records["floor"] + records["wall"]] # same anchors as the raw room
assert refined["mesh"][0][1:3] == ["room-mesh.bin", str(len(mesh(tmp_path)[0]))]
# Normals on the structural surfaces must face the room interior.
correct = checked = 0
for i in range(0, len(indices), 3):
@@ -350,11 +355,14 @@ def test_roomview_style_scan_retains_room_and_furniture(scanner, roomview_dump,
assert report["far_vertices"] > 1000
assert report["detected_walls"] == 4
assert abs(report["floor_y"]) < .05 and abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path)
records = room(tmp_path, "room-raw.txt")
assert_walls(outline(records), synth.BOX_OUTLINE)
assert any(row[1] == "TABLE" and abs(float(row[4]) - .75) < .05 for row in records["volume"])
assert any(row[1] == "COUCH" for row in records["volume"])
mesh(tmp_path, "room-mesh-raw.bin")
mesh(tmp_path)
assert report["heads"] == 28 # the tracked frames' head path, for the walked-volume pass
assert len(json.loads((tmp_path / "scan.json").read_text())["heads"]) == 28
def test_depth_discontinuity_is_rejected_below_absolute_edge_limit(scanner, dumps, tmp_path):
@@ -388,3 +396,228 @@ def test_small_ceiling_patch_does_not_override_default(scanner, dumps, tmp_path)
report = run(scanner, path, tmp_path / "out", "--ceiling-default", "2.7")
assert "ceiling not seen" in report["warnings"]
assert abs(report["ceiling_y"] - report["floor_y"] - 2.7) < .001
# --- Refinement + label rules (docs/mr/scan.md "Refinement") ---------------------------------------------------------
def refine_only(scanner, directory, *args, success=True):
proc = subprocess.run([str(scanner), "--refine-only", str(directory), "--json", *args],
text=True, capture_output=True, timeout=15)
assert proc.returncode == (0 if success else 2), proc.stdout + proc.stderr
report = json.loads(proc.stdout)
assert report["ok"] is success
return report
def centroids(directory, name="room-mesh.bin"):
vertices, indices = mesh(directory, name)
return [[sum(vertices[indices[i + k]][a] for k in range(3)) / 3 for a in range(3)]
for i in range(0, len(indices), 3)]
def in_box(c, box, pad=.06):
(cx, bottom, cz), (w, h, d) = box
return abs(c[0] - cx) <= w / 2 + pad and bottom - pad <= c[1] <= bottom + h + pad and abs(c[2] - cz) <= d / 2 + pad
def masked(text, mesh_name=None):
text = re.sub(r"(?m)^time \d+", "time T", re.sub(r"[0-9a-f]{32}", "U", text))
return text.replace(f" {mesh_name} ", " MESH ") if mesh_name else text
@pytest.fixture(scope="module")
def clutter_dump(tmp_path_factory):
path = tmp_path_factory.mktemp("clutter") / "clutter.fprs"
synth.render(path, profile="legacy", extra=[synth.TUBE, synth.GHOST])
return path
@pytest.fixture(scope="module")
def box_scan(scanner, dumps, tmp_path_factory):
out = tmp_path_factory.mktemp("box-scan") / "room"
return out, run(scanner, dumps["box"], out)
def test_floating_tube_and_walked_through_ghost_are_removed(scanner, clutter_dump, tmp_path):
report = run(scanner, clutter_dump, tmp_path / "default")["refine"]
raw, refined = centroids(tmp_path / "default", "room-mesh-raw.bin"), centroids(tmp_path / "default")
for box in (synth.TUBE, synth.GHOST):
assert sum(in_box(c, box) for c in raw) > 100 # the scanner meshed both
assert not any(in_box(c, box) for c in refined)
assert report["thin_pieces"] > 100 and report["walked"] > 100
# Each piece goes for its own reason: without the walked volume the ghost stays, without tubes the pole stays.
run(scanner, clutter_dump, tmp_path / "unwalked", "--walked", "0")
assert sum(in_box(c, synth.GHOST) for c in centroids(tmp_path / "unwalked")) > 100
run(scanner, clutter_dump, tmp_path / "untubed", "--tubes", "0")
assert sum(in_box(c, synth.TUBE) for c in centroids(tmp_path / "untubed")) > 100
# Real furniture survives: the table top is flattened onto its box, not removed.
assert any(in_box(c, synth.TABLE) and abs(c[1] - .75) < .06 for c in refined)
def test_panels_wind_into_the_room_on_the_reestimated_planes(box_scan):
out, report = box_scan
refine = report["refine"]
assert refine["panel_triangles"] == 12 # four seen walls + floor and ceiling (two ears each)
vertices, indices = mesh(out)
records = room(out)
ceiling = float(records["ceiling"][0][1])
assert abs(ceiling - refine["ceiling_y"]) < 1e-4 and abs(ceiling - 2.6) < .05
assert all(float(w[6]) == ceiling for w in records["wall"]) # wall tops follow the re-estimated ceiling
panels = len(indices) // 3 - refine["panel_triangles"]
for t in range(panels, len(indices) // 3):
a, b, c = (vertices[indices[3 * t + k]] for k in range(3))
u, v = [b[k] - a[k] for k in range(3)], [c[k] - a[k] for k in range(3)]
normal = (u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0])
centroid = [(a[k] + b[k] + c[k]) / 3 for k in range(3)]
assert sum(n * (o - p) for n, o, p in zip(normal, (0, 1.3, 0), centroid, strict=True)) > 0
ys = {round(p[1], 4) for p in (a, b, c)}
assert ys <= {round(refine["floor_y"], 4), round(ceiling, 4)}
def test_refine_only_reproduces_the_scan_and_refine_zero_is_the_old_output(scanner, dumps, box_scan, tmp_path):
out, report = box_scan
copy = tmp_path / "copy"
shutil.copytree(out, copy)
for name in ("room.txt", "room-mesh.bin"):
(copy / name).unlink()
again = refine_only(scanner, copy)
assert again["refine"] == report["refine"]
for name in ("room.txt", "room-mesh.bin", "scan.json", "room-raw.txt", "room-mesh-raw.bin"):
assert (copy / name).read_bytes() == (out / name).read_bytes(), name
# --refine 0 writes what the scanner wrote before refinement existed: its raw output, byte for byte.
plain = tmp_path / "plain"
run(scanner, dumps["box"], plain, "--refine", "0")
assert (plain / "room-mesh.bin").read_bytes() == (out / "room-mesh-raw.bin").read_bytes()
assert masked((plain / "room.txt").read_text(), "room-mesh.bin") == \
masked((out / "room-raw.txt").read_text(), "room-mesh-raw.bin")
# ... and switching refinement back on later gives the default output again (same anchors as its own raw room).
refine_only(scanner, plain)
assert (plain / "room-mesh.bin").read_bytes() == (out / "room-mesh.bin").read_bytes()
assert masked((plain / "room.txt").read_text()) == masked((out / "room.txt").read_text())
def test_scan_json_holds_settings_report_and_heads(box_scan):
out, report = box_scan
scan = json.loads((out / "scan.json").read_text())
assert scan["version"] == 1 and len(scan["heads"]) == report["heads"] > 0
assert all(len(h) == 3 for h in scan["heads"])
assert scan["refine"] == {"refine": 1, "walls": "panels", "band": .35, "facing_deg": 60, "band_in": .12,
"floor_in": .08, "ceil_in": .25, "top_strip": .5, "near": .03, "clip": .05,
"clip_open": .1, "island": .2, "smooth": 8, "tops": .1, "protect": 0, "tubes": 3,
"tube_thick": .15, "walked": .4, "label_rules": 1, "erase": []}
summary = dict(report["refine"])
assert summary.pop("output_triangles") == len(mesh(out)[1]) // 3 and summary.pop("output_volumes") == 2
assert summary == scan["report"]
assert scan["report"]["kept"] + scan["report"]["panel_triangles"] == len(mesh(out)[1]) // 3
assert sorted((label["from"], label["to"]) for label in scan["labels"]) == [("COUCH", "TABLE"), ("TABLE", "TABLE")]
assert scan["suggestions"] == []
def test_head_path_is_decimated_to_two_thousand_points(scanner, dumps, tmp_path):
data = dumps["one"].read_bytes()
empty = b"".join(struct.pack("<d7fII", i, i * .001, 1.6, 0, 0, 0, 0, 1, 0, 0) for i in range(2500))
dump = tmp_path / "long.fprs"
dump.write_bytes(data + empty)
report = run(scanner, dump, tmp_path / "out", "--min-views", "1") # one frame with depth, 2500 without
heads = json.loads((tmp_path / "out" / "scan.json").read_text())["heads"]
assert 1000 <= report["heads"] == len(heads) <= 2000
assert heads[-1][0] > 2 # still spans the whole recording
def test_erase_spheres_remove_the_scan_inside_them(scanner, box_scan, tmp_path):
copy = tmp_path / "copy"
shutil.copytree(box_scan[0], copy)
report = refine_only(scanner, copy, "--erase", "-0.6,0.75,-0.25,0.3;1.5,1.0,-1.0,0.2")["refine"]
assert report["erased"] > 0
assert not any((c[0] + .6) ** 2 + (c[1] - .75) ** 2 + (c[2] + .25) ** 2 <= .09 for c in centroids(copy))
assert json.loads((copy / "scan.json").read_text())["refine"]["erase"] == [[-.6, .75, -.25, .3], [1.5, 1, -1, .2]]
ROOM_IDS = iter(f"{i:032x}" for i in range(1, 1000))
def volume(label, cx, cz, top, yaw, w, d, h):
return f"volume {next(ROOM_IDS)} {label} {cx} {cz} {top} {yaw} {w} {d} {h}\n"
def test_label_rules_on_an_older_room_folder(scanner, tmp_path):
"""Merged and nested boxes and boxes behind a wall go, shapes are relabelled, a door slab becomes a suggestion.
The folder has no raw files (an older scan): its room.txt + mesh are taken as the unrefined scan."""
walls = [(-2, -1.5, 2, -1.5), (2, -1.5, 2, 1.5), (2, 1.5, -2, 1.5), (-2, 1.5, -2, -1.5)]
plane = f"plane {next(ROOM_IDS)} DOOR_FRAME 0.0000 1.0000 1.4900 0.0000 0.0000 0.0000 1.0000 0.9000 2.0000\n"
text = (f"frameport-room 1\nuniverse 7\nsource scan\ntime 1\nroom {next(ROOM_IDS)}\nfloor {next(ROOM_IDS)} 0\n"
f"ceiling {next(ROOM_IDS)} 2.6\noutline 4 -2 -1.5 2 -1.5 2 1.5 -2 1.5\n"
+ "".join(f"wall {next(ROOM_IDS)} {a} {b} {c} {d} 0 2.6\n" for a, b, c, d in walls)
+ volume("TABLE", -1.2, -.5, .75, 0, 1.0, .6, .75)
+ volume("COUCH", .6, -.5, .5, 0, 1.6, .8, .5)
+ volume("OTHER", -.3, -.5, .8, 0, 3.0, 1.0, .8) # holds the table and the couch: merged
+ volume("BED", .6, .85, .55, 0, 2.0, 1.1, .55)
+ volume("OTHER", .6, .85, .45, 0, .5, .4, .45) # inside the bed: part of it
+ volume("OTHER", 1.85, -.8, 1.6, -90, 1.0, .06, .6) # a flat panel along the x = 2 wall: a screen
+ volume("SCREEN", -1.0, -1.45, 2.0, 0, .9, .1, 2.0) # a tall slab by the z = -1.5 wall: a door?
+ volume("STORAGE", 2.6, 0, 1.5, 0, .5, .5, 1.5) # behind a wall
+ plane)
vertices, indices = [], []
def quad(a, b, c, d):
base = len(vertices)
vertices.extend((a, b, c, d))
indices.extend((base, base + 1, base + 2, base, base + 2, base + 3))
for i in range(18): # the screen: a flat 0.9 x 0.5 m grid at x = 1.85
for j in range(10):
z, y = -1.25 + i * .05, 1.05 + j * .05
quad((1.85, y, z), (1.85, y, z + .05), (1.85, y + .05, z + .05), (1.85, y + .05, z))
for i in range(38): # some floor
for j in range(28):
x, z = -1.9 + i * .1, -1.4 + j * .1
quad((x, 0, z), (x, 0, z + .1), (x + .1, 0, z + .1), (x + .1, 0, z))
text += f"mesh {next(ROOM_IDS)} room-mesh.bin {len(vertices)} {len(indices)}\n"
mesh_bytes = (struct.pack("<4sIII", b"FPRM", 1, len(vertices), len(indices))
+ b"".join(struct.pack("<3f", *v) for v in vertices) + struct.pack(f"<{len(indices)}I", *indices))
folder = tmp_path / "room"
folder.mkdir()
(folder / "room.txt").write_text(text)
(folder / "room-mesh.bin").write_bytes(mesh_bytes)
report = refine_only(scanner, folder)
assert "no raw files; the current room was taken as the unrefined scan" in report["warnings"]
assert "no head path; walked volume skipped" in report["warnings"]
assert (folder / "room-mesh-raw.bin").read_bytes() == mesh_bytes
scan = json.loads((folder / "scan.json").read_text())
decisions = [(label["from"], label["to"], label["reason"]) for label in scan["labels"]]
assert decisions == [
("TABLE", "TABLE", "top 0.75 m"),
("COUCH", "COUCH", "seat-height top 0.50 m, 1.6 m long"),
("OTHER", None, "holds a table and a couch 1.8 m apart: several things merged"),
("BED", "BED", "2.2 m², 2.0 m long, top 0.55 m"),
("OTHER", None, "100% inside the bigger bed box: part of it"),
("OTHER", "SCREEN", "thin, raised, screen-shaped, flat (0 cm)"),
("SCREEN", None, "tall thin slab from the floor (2.00 m): a door or closet door?"),
("STORAGE", None, "centre 60 cm outside the room (behind a wall)"),
]
assert scan["suggestions"] == [{"kind": "door", "box": {"cx": -1, "cz": -1.45, "top_y": 2, "yaw_deg": 0,
"w": .9, "d": .1, "h": 2}, "wall": 0, "along": .25,
"reason": decisions[6][2]}]
raw = room(folder, "room-raw.txt")
assert raw["mesh"][0][1] == "room-mesh-raw.bin" and len(raw["volume"]) == 8
refined = room(folder)
assert [row[1] for row in refined["volume"]] == ["TABLE", "COUCH", "BED", "SCREEN"]
assert refined["universe"] == [["7"]] and refined["plane"] == [plane.split()[1:]] # unknown records stay
assert report["refine"]["removed_boxes"] == 4 and report["refine"]["relabelled"] == 1
assert report["refine"]["suggestions"] == 1
first = {name: (folder / name).read_bytes() for name in ("room.txt", "room-mesh.bin", "scan.json")}
refine_only(scanner, folder) # now from the raw files: the same result
assert first == {name: (folder / name).read_bytes() for name in first}
refine_only(scanner, folder, "--label-rules", "0")
assert [row[1] for row in room(folder)["volume"]] == [
"TABLE", "COUCH", "OTHER", "BED", "OTHER", "OTHER", "SCREEN", "STORAGE"]
@pytest.mark.parametrize("args", [("--walls", "shell"), ("--smooth", "1.5"), ("--erase", "1,2,3"), ("--refine", "2"),
("--band", "2"), ("--walked", ".01"), ("--erase", "0,0,0,-1")])
def test_invalid_refinement_options_are_json_errors(scanner, dumps, tmp_path, args):
run(scanner, dumps["one"], tmp_path / "out", *args, success=False)
def test_refine_only_needs_a_room(scanner, tmp_path):
report = refine_only(scanner, tmp_path, success=False)
assert "room-raw.txt" in report["error"]