XXXpilar commited on
Commit
e6f80f3
·
verified ·
1 Parent(s): 41c9893

FastFill v4 base (step 6552): LoRA adapter, geometry heads, tokenizer, training code, predict.py

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. README.md +88 -0
  3. checkpoint/backbone/README.md +206 -0
  4. checkpoint/backbone/adapter_config.json +48 -0
  5. checkpoint/backbone/adapter_model.safetensors +3 -0
  6. checkpoint/checkpoint_manifest.json +14 -0
  7. checkpoint/geometry_model.pt +3 -0
  8. checkpoint/model_config.json +35 -0
  9. checkpoint/training_manifest.json +303 -0
  10. code/fastfill/README.md +342 -0
  11. code/fastfill/RELEASE_v3.2.md +54 -0
  12. code/fastfill/__init__.py +0 -0
  13. code/fastfill/adapters/__init__.py +57 -0
  14. code/fastfill/adapters/__main__.py +3 -0
  15. code/fastfill/adapters/ase.py +46 -0
  16. code/fastfill/adapters/hssd200.py +424 -0
  17. code/fastfill/adapters/il3d_3dfront.py +394 -0
  18. code/fastfill/adapters/il3d_synthetic.py +150 -0
  19. code/fastfill/adapters/interiorgs.py +369 -0
  20. code/fastfill/adapters/internscenes.py +183 -0
  21. code/fastfill/adapters/internscenes_3rscan.py +12 -0
  22. code/fastfill/adapters/internscenes_arkit.py +14 -0
  23. code/fastfill/adapters/internscenes_mp3d.py +24 -0
  24. code/fastfill/adapters/internscenes_scannet.py +10 -0
  25. code/fastfill/adapters/m3dlayout_infinigen.py +54 -0
  26. code/fastfill/adapters/mansionworld.py +234 -0
  27. code/fastfill/adapters/multiscan.py +120 -0
  28. code/fastfill/adapters/optiscene_holodeck.py +45 -0
  29. code/fastfill/adapters/sage10k.py +255 -0
  30. code/fastfill/adapters/scan2cad.py +96 -0
  31. code/fastfill/adapters/scannetpp.py +45 -0
  32. code/fastfill/adapters/scenesmith.py +129 -0
  33. code/fastfill/adapters/spatialgen.py +19 -0
  34. code/fastfill/adapters/spatiallm.py +177 -0
  35. code/fastfill/adapters/structured3d.py +130 -0
  36. code/fastfill/adapters/unified.py +112 -0
  37. code/fastfill/anchors.py +239 -0
  38. code/fastfill/build.py +623 -0
  39. code/fastfill/download.py +116 -0
  40. code/fastfill/download_full.sh +27 -0
  41. code/fastfill/evaluate.py +230 -0
  42. code/fastfill/inspect_ir.py +87 -0
  43. code/fastfill/interface.py +641 -0
  44. code/fastfill/merge_lora.py +73 -0
  45. code/fastfill/requirements.txt +12 -0
  46. code/fastfill/scene.py +379 -0
  47. code/fastfill/serve.py +188 -0
  48. code/fastfill/split.py +106 -0
  49. code/fastfill/tests/test_adapter_repairs.py +491 -0
  50. code/fastfill/tests/test_build_repairs.py +211 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ tokenizer/tokenizer.json filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: mixed-non-commercial-research
4
+ base_model: Qwen/Qwen3-8B
5
+ library_name: peft
6
+ tags:
7
+ - 3d-scene-layout
8
+ - indoor-scenes
9
+ - furniture-placement
10
+ - qwen3
11
+ - lora
12
+ ---
13
+
14
+ # FastFill (v4, base): object-conditioned indoor room layout
15
+
16
+ FastFill lays out the furniture of one room in a **single forward pass**. Given:
17
+ - the room's floor polygon, doors and windows, and fixed objects;
18
+ - the list of objects to place, with categories and optional descriptions;
19
+ - optional constraints;
20
+
21
+ it predicts, for **every** object:
22
+ - a size `[depth, width, height]` in metres;
23
+ - a bottom-centre position `[x, y, z]` in metres;
24
+ - a yaw: the direction the object's front faces, counter-clockwise from +x.
25
+
26
+ This repository holds what you need beyond the base model:
27
+ - the LoRA adapter on Qwen3-8B;
28
+ - the geometry decoder and heads;
29
+ - the tokenizer;
30
+ - the exact FastFill code the model was trained with.
31
+
32
+ **Download `Qwen/Qwen3-8B` and run it.** Nothing else is needed.
33
+
34
+ ## Use
35
+
36
+ ```bash
37
+ pip install torch # the build for your CUDA / platform
38
+ pip install -r requirements.txt
39
+ python predict.py example_condition.json # fetches Qwen/Qwen3-8B from the Hub
40
+ python predict.py example_condition.json --qwen /path/to/Qwen3-8B # or a local copy
41
+ ```
42
+
43
+ In Python:
44
+
45
+ ```python
46
+ import json, predict
47
+ model, tokenizer, max_length = predict.load(qwen="Qwen/Qwen3-8B", device="cuda")
48
+ condition = json.load(open("example_condition.json"))
49
+ layout = predict.predict_layout(model, tokenizer, condition, max_length=max_length, device="cuda",
50
+ grid_decode="spread")
51
+ ```
52
+
53
+ **Hardware:** the bf16 base model needs about 16 GB of GPU memory. One room takes well under a second on a GH200.
54
+
55
+ ## Input and output
56
+
57
+ - **Input:** a `fastfill.v2` condition. See `example_condition.json` and `code/fastfill/v2/schema.py`.
58
+ - `room`: `floor_polygon_xy_m`, `floor_z_m`, `height_m`, `openings` (doors and windows), `fixed_objects`.
59
+ - `objects`: `id`, `category`, optional `description` and `support_parent`.
60
+ - `constraints`: `against_wall`, `near`, `faces`, `between`, ...
61
+ - **Output:** `{"schema_version": "fastfill.v2", "objects": [{"id", "target_size_local_m", "bottom_center_m", "yaw_rad"}, ...]}`, one entry per requested object.
62
+
63
+ ## Model
64
+
65
+ | | |
66
+ |---|---|
67
+ | Base model | Qwen3-8B, frozen, bf16 |
68
+ | Adapter | LoRA rank 8, alpha 16, on q/k/v/o projections |
69
+ | Decoder and heads | 2-layer object decoder (dim 128, 4 heads)<br>size head (log-space)<br>yaw: 12 bins plus a residual<br>position: 16×16 grid cell plus a residual |
70
+ | Training | `main4-cell05-v4fixed-e5-base`: 6,552 steps (5 epochs)<br>effective batch 24, lr 1e-4 with cosine schedule<br>max length 8,192 tokens, up to 256 objects per room |
71
+ | Data | FastFill v4 layout data (v2-fixed build)<br>indoor rooms from about 20 public sources |
72
+ | Code | `code/fastfill`: training commit `26939bc`<br>the model, batch, evaluate, io and schema modules are byte-identical to it |
73
+
74
+ `checkpoint/training_manifest.json` records the training run (data hashes, full configuration).
75
+
76
+ ## Results (test300: 300 held-out rooms, stratified by source)
77
+
78
+ | Decoding | Position error (m) | Size error (log) | Yaw error (rad) | Out of bounds | Blocks a door | Collisions per room | Constraints met | Clean rooms |
79
+ |---|---|---|---|---|---|---|---|---|
80
+ | spread | 3.11 | **0.321** | 0.593 | 10.0% | 11.7% | 3.65 | 37.7% | 11.1% |
81
+
82
+ **Read the raw output as a draft. It is not a guaranteed-valid layout.** Sizes are the model's strongest output, and they are what our downstream workflow uses. Positions often collide, block doors or break constraints. Pair the model with a geometric checker or repair step before using a layout in simulation.
83
+
84
+ ## Limitations
85
+
86
+ - **One room at a time.** The model reads furniture boxes only, not meshes or appearance.
87
+ - **No validity guarantee.** Collisions, out-of-bounds objects and blocked doors occur, as shown in the table above.
88
+ - **Licence.** The training data mixes sources released for non-commercial research; use this model under the same terms. The base model, Qwen3-8B, is Apache-2.0.
checkpoint/backbone/README.md ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: /lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/models/Qwen3-8B
3
+ library_name: peft
4
+ tags:
5
+ - base_model:adapter:/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/models/Qwen3-8B
6
+ - lora
7
+ - transformers
8
+ ---
9
+
10
+ # Model Card for Model ID
11
+
12
+ <!-- Provide a quick summary of what the model is/does. -->
13
+
14
+
15
+
16
+ ## Model Details
17
+
18
+ ### Model Description
19
+
20
+ <!-- Provide a longer summary of what this model is. -->
21
+
22
+
23
+
24
+ - **Developed by:** [More Information Needed]
25
+ - **Funded by [optional]:** [More Information Needed]
26
+ - **Shared by [optional]:** [More Information Needed]
27
+ - **Model type:** [More Information Needed]
28
+ - **Language(s) (NLP):** [More Information Needed]
29
+ - **License:** [More Information Needed]
30
+ - **Finetuned from model [optional]:** [More Information Needed]
31
+
32
+ ### Model Sources [optional]
33
+
34
+ <!-- Provide the basic links for the model. -->
35
+
36
+ - **Repository:** [More Information Needed]
37
+ - **Paper [optional]:** [More Information Needed]
38
+ - **Demo [optional]:** [More Information Needed]
39
+
40
+ ## Uses
41
+
42
+ <!-- Address questions around how the model is intended to be used, including the foreseeable users of the model and those affected by the model. -->
43
+
44
+ ### Direct Use
45
+
46
+ <!-- This section is for the model use without fine-tuning or plugging into a larger ecosystem/app. -->
47
+
48
+ [More Information Needed]
49
+
50
+ ### Downstream Use [optional]
51
+
52
+ <!-- This section is for the model use when fine-tuned for a task, or when plugged into a larger ecosystem/app -->
53
+
54
+ [More Information Needed]
55
+
56
+ ### Out-of-Scope Use
57
+
58
+ <!-- This section addresses misuse, malicious use, and uses that the model will not work well for. -->
59
+
60
+ [More Information Needed]
61
+
62
+ ## Bias, Risks, and Limitations
63
+
64
+ <!-- This section is meant to convey both technical and sociotechnical limitations. -->
65
+
66
+ [More Information Needed]
67
+
68
+ ### Recommendations
69
+
70
+ <!-- This section is meant to convey recommendations with respect to the bias, risk, and technical limitations. -->
71
+
72
+ Users (both direct and downstream) should be made aware of the risks, biases and limitations of the model. More information needed for further recommendations.
73
+
74
+ ## How to Get Started with the Model
75
+
76
+ Use the code below to get started with the model.
77
+
78
+ [More Information Needed]
79
+
80
+ ## Training Details
81
+
82
+ ### Training Data
83
+
84
+ <!-- This should link to a Dataset Card, perhaps with a short stub of information on what the training data is all about as well as documentation related to data pre-processing or additional filtering. -->
85
+
86
+ [More Information Needed]
87
+
88
+ ### Training Procedure
89
+
90
+ <!-- This relates heavily to the Technical Specifications. Content here should link to that section when it is relevant to the training procedure. -->
91
+
92
+ #### Preprocessing [optional]
93
+
94
+ [More Information Needed]
95
+
96
+
97
+ #### Training Hyperparameters
98
+
99
+ - **Training regime:** [More Information Needed] <!--fp32, fp16 mixed precision, bf16 mixed precision, bf16 non-mixed precision, fp16 non-mixed precision, fp8 mixed precision -->
100
+
101
+ #### Speeds, Sizes, Times [optional]
102
+
103
+ <!-- This section provides information about throughput, start/end time, checkpoint size if relevant, etc. -->
104
+
105
+ [More Information Needed]
106
+
107
+ ## Evaluation
108
+
109
+ <!-- This section describes the evaluation protocols and provides the results. -->
110
+
111
+ ### Testing Data, Factors & Metrics
112
+
113
+ #### Testing Data
114
+
115
+ <!-- This should link to a Dataset Card if possible. -->
116
+
117
+ [More Information Needed]
118
+
119
+ #### Factors
120
+
121
+ <!-- These are the things the evaluation is disaggregating by, e.g., subpopulations or domains. -->
122
+
123
+ [More Information Needed]
124
+
125
+ #### Metrics
126
+
127
+ <!-- These are the evaluation metrics being used, ideally with a description of why. -->
128
+
129
+ [More Information Needed]
130
+
131
+ ### Results
132
+
133
+ [More Information Needed]
134
+
135
+ #### Summary
136
+
137
+
138
+
139
+ ## Model Examination [optional]
140
+
141
+ <!-- Relevant interpretability work for the model goes here -->
142
+
143
+ [More Information Needed]
144
+
145
+ ## Environmental Impact
146
+
147
+ <!-- Total emissions (in grams of CO2eq) and additional considerations, such as electricity usage, go here. Edit the suggested text below accordingly -->
148
+
149
+ Carbon emissions can be estimated using the [Machine Learning Impact calculator](https://mlco2.github.io/impact#compute) presented in [Lacoste et al. (2019)](https://arxiv.org/abs/1910.09700).
150
+
151
+ - **Hardware Type:** [More Information Needed]
152
+ - **Hours used:** [More Information Needed]
153
+ - **Cloud Provider:** [More Information Needed]
154
+ - **Compute Region:** [More Information Needed]
155
+ - **Carbon Emitted:** [More Information Needed]
156
+
157
+ ## Technical Specifications [optional]
158
+
159
+ ### Model Architecture and Objective
160
+
161
+ [More Information Needed]
162
+
163
+ ### Compute Infrastructure
164
+
165
+ [More Information Needed]
166
+
167
+ #### Hardware
168
+
169
+ [More Information Needed]
170
+
171
+ #### Software
172
+
173
+ [More Information Needed]
174
+
175
+ ## Citation [optional]
176
+
177
+ <!-- If there is a paper or blog post introducing the model, the APA and Bibtex information for that should go in this section. -->
178
+
179
+ **BibTeX:**
180
+
181
+ [More Information Needed]
182
+
183
+ **APA:**
184
+
185
+ [More Information Needed]
186
+
187
+ ## Glossary [optional]
188
+
189
+ <!-- If relevant, include terms and calculations in this section that can help readers understand the model or model card. -->
190
+
191
+ [More Information Needed]
192
+
193
+ ## More Information [optional]
194
+
195
+ [More Information Needed]
196
+
197
+ ## Model Card Authors [optional]
198
+
199
+ [More Information Needed]
200
+
201
+ ## Model Card Contact
202
+
203
+ [More Information Needed]
204
+ ### Framework versions
205
+
206
+ - PEFT 0.21.0
checkpoint/backbone/adapter_config.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "alora_invocation_tokens": null,
3
+ "alpha_pattern": {},
4
+ "arrow_config": null,
5
+ "auto_mapping": null,
6
+ "base_model_name_or_path": "Qwen/Qwen3-8B",
7
+ "bias": "none",
8
+ "corda_config": null,
9
+ "ensure_weight_tying": false,
10
+ "eva_config": null,
11
+ "exclude_modules": null,
12
+ "fan_in_fan_out": false,
13
+ "inference_mode": true,
14
+ "init_lora_weights": true,
15
+ "kasa_config": null,
16
+ "layer_replication": null,
17
+ "layers_pattern": null,
18
+ "layers_to_transform": null,
19
+ "loftq_config": {},
20
+ "lora_alpha": 16,
21
+ "lora_bias": false,
22
+ "lora_dropout": 0.0,
23
+ "lora_ga_config": null,
24
+ "megatron_config": null,
25
+ "megatron_core": "megatron.core",
26
+ "modules_to_save": null,
27
+ "monteclora_config": null,
28
+ "peft_type": "LORA",
29
+ "peft_version": "0.21.0",
30
+ "qalora_group_size": 16,
31
+ "r": 8,
32
+ "rank_pattern": {},
33
+ "revision": null,
34
+ "target_modules": [
35
+ "o_proj",
36
+ "q_proj",
37
+ "v_proj",
38
+ "k_proj"
39
+ ],
40
+ "target_parameters": null,
41
+ "task_type": "FEATURE_EXTRACTION",
42
+ "trainable_token_indices": null,
43
+ "use_bdlora": null,
44
+ "use_dora": false,
45
+ "use_qalora": false,
46
+ "use_rslora": false,
47
+ "velora_config": null
48
+ }
checkpoint/backbone/adapter_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3a32ae4c96f6c47365116a7fae31c59b14e102baa825f496bc0291a229f19b1c
3
+ size 30707464
checkpoint/checkpoint_manifest.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "max_length": 8192,
3
+ "data_sha256": "11d978325d91cd59a19e2a7a02f78b1afc923b96c40344eeaff413373498e60e",
4
+ "validation_data_sha256": "ba8c26010f7cfe2b424821a977afff4e22c44b2b514b827ef80c1f4ed891eca3",
5
+ "config_sha256": "dfa6c4110345c3126eb9ce2e8f29d5490acc8515d682175cc2b368b404652268",
6
+ "implementation_sha256": "a6ed917fe7e073d31c854483c5f3d4e40d6c7b2f4571fb77bce43bd98aefdf5a",
7
+ "backbone": {
8
+ "path": "/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/models/Qwen3-8B",
9
+ "revision": null,
10
+ "config_sha256": "f7c4eadfbbf522470667b797a3c89be2524832d2d599797248dc304fff447c30"
11
+ },
12
+ "tokenizer_sha256": "26ef71c7d8e22c3ba858cc760b86da45c755cc6842fb5c2dd14ad4db73e99852",
13
+ "step": 6552
14
+ }
checkpoint/geometry_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f75fe0b391524f5b93ce772eaec3c697fc411f00de429f0d0cd4fc2733ec9651
3
+ size 4773845
checkpoint/model_config.json ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backbone": "Qwen/Qwen3-8B",
3
+ "decoder_dim": 128,
4
+ "decoder_heads": 4,
5
+ "decoder_layers": 2,
6
+ "decoder_ffn_multiplier": 4,
7
+ "dropout": 0.0,
8
+ "yaw_bins": 12,
9
+ "max_objects": 256,
10
+ "size_reference": [
11
+ 1.0,
12
+ 1.0,
13
+ 1.0
14
+ ],
15
+ "size_log_limit": 10.0,
16
+ "residual_mode": "tanh",
17
+ "position_head": "grid_residual",
18
+ "position_grid": 16,
19
+ "train_backbone": false,
20
+ "lora_rank": 8,
21
+ "lora_alpha": 16,
22
+ "lora_dropout": 0.0,
23
+ "lora_target_modules": [
24
+ "q_proj",
25
+ "k_proj",
26
+ "v_proj",
27
+ "o_proj"
28
+ ],
29
+ "backbone_dtype": "bfloat16",
30
+ "local_files_only": false,
31
+ "tiny_hidden_size": 64,
32
+ "tiny_layers": 1,
33
+ "pose_conditioning": false,
34
+ "support_head": false
35
+ }
checkpoint/training_manifest.json ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "data_path": "/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/data/v4fixed/train.jsonl",
3
+ "data_sha256": "11d978325d91cd59a19e2a7a02f78b1afc923b96c40344eeaff413373498e60e",
4
+ "code_commit": "26939bc46bc57c5a97a154a8b7f7511a68fcba4f",
5
+ "code_dirty": true,
6
+ "implementation_files_sha256": {
7
+ "fastfill/v2/__init__.py": "fa67d3676eafd0076d102f2b16c4f49f40cedbe56c68fb414bc469b2e79c6050",
8
+ "fastfill/v2/adapters.py": "d204a1b483f03a1199a8183da565843bf39b274de4eefb7448afa78b594bb4ae",
9
+ "fastfill/v2/anchor_probe.py": "ccd7a2e869eede2d19fdb082ba7310c108cd0313a29b2b775a36b96fd0285c46",
10
+ "fastfill/v2/audit.py": "6d6cababd3fbec6d1ede58981561aa27b968c9d115ad07738bf5b269059e520b",
11
+ "fastfill/v2/autorun.py": "e6826078fee1c774f9e8e1854a22d1cfc2ae309fdd0289ec1cf73b081e289d62",
12
+ "fastfill/v2/batch.py": "575a31ac2d82725700f030a0498ea8ab7efc15fc1e5c87cbf53153846171d7fa",
13
+ "fastfill/v2/bbox_visualization.py": "65fb0898b1f65f22fa6e9ab4b95b6c0b9369efc22349bf5f267dce1cc5026a09",
14
+ "fastfill/v2/boxes.py": "ef0c763fd6584f5b44a2ad8d1976a3f3568b1abf57b8fdcfab562e9c0f384284",
15
+ "fastfill/v2/cell_costs.py": "11b1b47e8afea53c3e6a6e12a0ed4b1ce8c23b054471c8a835ac66e16d3555a1",
16
+ "fastfill/v2/cohort.py": "7882b18ab09d7443fc854c6ed58100f94247a3cffda8201501f5cf74018ad138",
17
+ "fastfill/v2/data.py": "042379618ad979bc51b00dc18f3e31ceafe2b90fc06a0d5215b3fa10cce729e3",
18
+ "fastfill/v2/direct_layout.py": "c2230ce930310cceb06d78e4dc2ccee0ab364638a39a2245bd34f08ea02cd801",
19
+ "fastfill/v2/evaluate.py": "795a4b60fcca896f61a38f4f6d5cc06bebdb033f8ef37493f29b99b81fdf61de",
20
+ "fastfill/v2/geometry.py": "156e3ee5fda5f9d6afc95f2cffda75c760884b25617822df794b97bedc0dae5c",
21
+ "fastfill/v2/io.py": "79dd349e8cf4e1e7e842d998fc7190ab792267a28b2142811208692c4c3533b1",
22
+ "fastfill/v2/legacy_bridge.py": "ac62037fb3899f684a24bf9d685d13ed9d3f4ca73875fff37b1edd0f337ffced",
23
+ "fastfill/v2/legacy_build.py": "aa7f0fbf474707b68187423e1b22c28aacc6b86c0f7a6e0e7eec73a8d5d4328f",
24
+ "fastfill/v2/legacy_evidence.py": "a81e5700b356e5723e591dd95c4721cc28a2ad35786e24730cf8c9e6c0326a85",
25
+ "fastfill/v2/legacy_verify.py": "3988ed6f89cf3a34c9d0a04310c53cd20fca908eee10a2afe286a9aa7832d527",
26
+ "fastfill/v2/llm_baseline.py": "bc50fa57e9250fe86442ed3981c0e9eef4d6388a7303245df0324e0cd97be459",
27
+ "fastfill/v2/llm_structured.py": "b0c8f8d1707e392923b49d25f2a4463af8c7a96f548066d34ceaa8b588431dc0",
28
+ "fastfill/v2/llm_workflow.py": "155aff6ec182c169f098651e68b5a767e23389328c6cbe990ab06907bc59255f",
29
+ "fastfill/v2/losses.py": "d36d5a55fcc2bf69d06da7e2d40bb057c36f87ea6d65413fd451dd75cda65785",
30
+ "fastfill/v2/masked_layout.py": "42cec23abe568dcf826b6fe74c902f06375812eb398c10179613185f74fba950",
31
+ "fastfill/v2/matching.py": "3ae60074cd184cc3adce3526a7c4582687730c13c018f6a270dc51d49b26dceb",
32
+ "fastfill/v2/minimal_data.py": "bbaa70f46e948167040c3215c29bb2c14a65247e3a75dd1000ee78c85116b975",
33
+ "fastfill/v2/model.py": "9a615425f6eea41b6d268964fc09e532cc2c18fbc11397d2da9a20ebf699544c",
34
+ "fastfill/v2/multisource_data.py": "43c9097979ac410c4ce04a9e2b7c63459f4b26a1bf524ac225219af0b3c7452c",
35
+ "fastfill/v2/multisource_verify.py": "881cba1ec548e53010772df685ef9bab2608abeaa6cdad85e3239f2861519230",
36
+ "fastfill/v2/objective.py": "f13067db9932940272122ea4320a7eeeea44fe42dbd5c30e320f49c725eefc49",
37
+ "fastfill/v2/predict.py": "55f2859836b02fe5f21887ab7141b8f4d9aeab0c3405d97080d2a2e93fb0a46b",
38
+ "fastfill/v2/qualified_data.py": "97f298299ac9791282d7712d7b8588f449af738adb833f9e526626d1c1ec0640",
39
+ "fastfill/v2/regularizers.py": "8314c07895ae1d39f42e9daf2e906fded13025d2dd9e299eb71651cedc14aaa1",
40
+ "fastfill/v2/remask.py": "5b3ddadffc53f0e95c1993a805637d2cac3a8b7949aa0b3d96101aa3a63c5818",
41
+ "fastfill/v2/review_data.py": "59c86ec6e5679f442c6a16fb1257febe016310c02fa136d8aff91942afac3184",
42
+ "fastfill/v2/roomgenbench.py": "e3cdc050fd86c02243e600bfbd5dfb9c3c57af17c028887647d126dcaf47aea8",
43
+ "fastfill/v2/runtime.py": "8bd0c2d7e12531fd6805ce0caaa348be94402e950cf91465ffc22f8ddb4f0993",
44
+ "fastfill/v2/schema.py": "d02610ebc6ca57df03f8426c592b50b7b7d7c634f7c3656c41cbd8bddf09a881",
45
+ "fastfill/v2/serve.py": "66839f204f4f0a5f12fc794a91e0ea151a9e988f8bf39efa9c046eeccb8bb0b3",
46
+ "fastfill/v2/size_range.py": "0ad2e26ea419068f0e92349dd0718ee3c733028711cae2822432d0f4d5b48165",
47
+ "fastfill/v2/support.py": "ccd5e6eada67c724111d619f254d3a78b3e33dbe881bf1fd9c0ca27882ca03c1",
48
+ "fastfill/v2/text_sft.py": "0dd88d1403a421545942f3aa063a17389dcb914837ee58444ddc58d4bb3a63da",
49
+ "fastfill/v2/train.py": "b3dd2e37f8d9cfc5b4c984a46642c84e0cd4a2d23375f3004b562bcb8afebbce",
50
+ "fastfill/v2/validation.py": "87ed462441f1a74a0da4c6a16cdca45ee5e6dfd7155239f0a348179e26c99f59",
51
+ "fastfill/v2/verify_bundle.py": "812c66c8544567157a3b01e75c84491d6b9886ab75d9826c3a010d97cf30f47a"
52
+ },
53
+ "implementation_sha256": "a6ed917fe7e073d31c854483c5f3d4e40d6c7b2f4571fb77bce43bd98aefdf5a",
54
+ "packages": {
55
+ "torch": "2.13.0+cu126",
56
+ "transformers": "5.14.1",
57
+ "peft": "0.21.0",
58
+ "scipy": "1.15.2",
59
+ "accelerate": "1.15.0"
60
+ },
61
+ "validation_data_path": "/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/data/v4fixed/validation.jsonl",
62
+ "validation_data_sha256": "ba8c26010f7cfe2b424821a977afff4e22c44b2b514b827ef80c1f4ed891eca3",
63
+ "backbone": {
64
+ "path": "/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/models/Qwen3-8B",
65
+ "revision": null,
66
+ "config_sha256": "f7c4eadfbbf522470667b797a3c89be2524832d2d599797248dc304fff447c30"
67
+ },
68
+ "tokenizer_sha256": "26ef71c7d8e22c3ba858cc760b86da45c755cc6842fb5c2dd14ad4db73e99852",
69
+ "config": {
70
+ "model": {
71
+ "backbone": "/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/models/Qwen3-8B",
72
+ "decoder_dim": 128,
73
+ "decoder_heads": 4,
74
+ "decoder_layers": 2,
75
+ "decoder_ffn_multiplier": 4,
76
+ "dropout": 0.0,
77
+ "yaw_bins": 12,
78
+ "max_objects": 256,
79
+ "size_reference": [
80
+ 1.0,
81
+ 1.0,
82
+ 1.0
83
+ ],
84
+ "size_log_limit": 10.0,
85
+ "residual_mode": "tanh",
86
+ "position_head": "grid_residual",
87
+ "position_grid": 16,
88
+ "train_backbone": false,
89
+ "lora_rank": 8,
90
+ "lora_alpha": 16,
91
+ "lora_dropout": 0.0,
92
+ "lora_target_modules": [
93
+ "q_proj",
94
+ "k_proj",
95
+ "v_proj",
96
+ "o_proj"
97
+ ],
98
+ "backbone_dtype": "bfloat16",
99
+ "local_files_only": true,
100
+ "tiny_hidden_size": 64,
101
+ "tiny_layers": 1
102
+ },
103
+ "loss": {
104
+ "position": 1.0,
105
+ "size": 0.6,
106
+ "yaw_cls": 0.08,
107
+ "yaw_reg": 2.0,
108
+ "box": 0.0,
109
+ "box_operator": "bev_oriented_giou_convex_hull",
110
+ "hungarian": true,
111
+ "alpha_position": 1.0,
112
+ "alpha_size": 1.0,
113
+ "collision": 0.0,
114
+ "boundary": 0.0,
115
+ "position_type": "l1",
116
+ "size_type": "l1",
117
+ "smooth_l1_beta": 1.0,
118
+ "position_cell": 0.5,
119
+ "position_residual": 0.4
120
+ },
121
+ "training": {
122
+ "steps": 6552,
123
+ "batch_size": 1,
124
+ "learning_rate": 0.0001,
125
+ "gradient_accumulation_steps": 24,
126
+ "max_length": 8192,
127
+ "seed": 42,
128
+ "clip_grad_norm": 1.0,
129
+ "mixed_precision": "bf16",
130
+ "cpu": false,
131
+ "cpu_threads": 1,
132
+ "checkpoint_every": 500,
133
+ "validate_every": 500,
134
+ "resume": null,
135
+ "export_model_every_checkpoint": true
136
+ },
137
+ "optimizer": {
138
+ "warmup_steps": 197,
139
+ "schedule": "cosine",
140
+ "decoder_lr": 0.0001
141
+ },
142
+ "augmentation": {
143
+ "rotate90": true,
144
+ "mirror": false,
145
+ "shuffle_objects": true,
146
+ "drop_constraints_p": 0.3,
147
+ "drop_support_p": 0.2,
148
+ "category_only_description_p": 0.5,
149
+ "minimal_form_p": 0.5
150
+ },
151
+ "validation": {
152
+ "exclude_flags": [
153
+ "oob_objects",
154
+ "fixed_collision",
155
+ "overlapping_furniture"
156
+ ]
157
+ }
158
+ },
159
+ "config_sha256": "dfa6c4110345c3126eb9ce2e8f29d5490acc8515d682175cc2b368b404652268",
160
+ "resolved_config": {
161
+ "model": {
162
+ "backbone": "/lus/lfs1aip2/projects/u6uv/shanliantian.u6uv/fastfill-v4/models/Qwen3-8B",
163
+ "decoder_dim": 128,
164
+ "decoder_heads": 4,
165
+ "decoder_layers": 2,
166
+ "decoder_ffn_multiplier": 4,
167
+ "dropout": 0.0,
168
+ "yaw_bins": 12,
169
+ "max_objects": 256,
170
+ "size_reference": [
171
+ 1.0,
172
+ 1.0,
173
+ 1.0
174
+ ],
175
+ "size_log_limit": 10.0,
176
+ "residual_mode": "tanh",
177
+ "position_head": "grid_residual",
178
+ "position_grid": 16,
179
+ "train_backbone": false,
180
+ "lora_rank": 8,
181
+ "lora_alpha": 16,
182
+ "lora_dropout": 0.0,
183
+ "lora_target_modules": [
184
+ "q_proj",
185
+ "k_proj",
186
+ "v_proj",
187
+ "o_proj"
188
+ ],
189
+ "backbone_dtype": "bfloat16",
190
+ "local_files_only": true,
191
+ "tiny_hidden_size": 64,
192
+ "tiny_layers": 1,
193
+ "pose_conditioning": false,
194
+ "support_head": false
195
+ },
196
+ "loss": {
197
+ "position": 1.0,
198
+ "size": 0.6,
199
+ "yaw_cls": 0.08,
200
+ "yaw_reg": 2.0,
201
+ "box": 0.0,
202
+ "box_operator": "bev_oriented_giou_convex_hull",
203
+ "hungarian": true,
204
+ "alpha_position": 1.0,
205
+ "alpha_size": 1.0,
206
+ "collision": 0.0,
207
+ "boundary": 0.0,
208
+ "position_type": "l1",
209
+ "size_type": "l1",
210
+ "smooth_l1_beta": 1.0,
211
+ "position_cell": 0.5,
212
+ "position_residual": 0.4,
213
+ "expected_geometry": 0.0,
214
+ "door_clearance_m": 0.8,
215
+ "support": 0.0
216
+ },
217
+ "optimizer": {
218
+ "warmup_steps": 197,
219
+ "schedule": "cosine",
220
+ "decoder_lr": 0.0001
221
+ },
222
+ "augmentation": {
223
+ "rotate90": true,
224
+ "mirror": false,
225
+ "shuffle_objects": true,
226
+ "drop_constraints_p": 0.3,
227
+ "drop_support_p": 0.2,
228
+ "category_only_description_p": 0.5,
229
+ "minimal_form_p": 0.5,
230
+ "given_pose_p": 0.0,
231
+ "given_pose_corrupt_p": 0.0,
232
+ "given_pose_noise_m": 0.0,
233
+ "given_pose_yaw_noise_deg": 0.0,
234
+ "given_pose_size_noise": 0.0
235
+ },
236
+ "validation": {
237
+ "exclude_flags": [
238
+ "oob_objects",
239
+ "fixed_collision",
240
+ "overlapping_furniture"
241
+ ]
242
+ },
243
+ "training": {
244
+ "steps": 6552,
245
+ "batch_size": 1,
246
+ "learning_rate": 0.0001,
247
+ "gradient_accumulation_steps": 24,
248
+ "max_length": 8192,
249
+ "seed": 42,
250
+ "clip_grad_norm": 1.0,
251
+ "mixed_precision": "bf16",
252
+ "cpu": false,
253
+ "cpu_threads": 1,
254
+ "checkpoint_every": 500,
255
+ "validate_every": 500,
256
+ "export_model_every_checkpoint": true
257
+ },
258
+ "max_samples": null,
259
+ "dry_run": false
260
+ },
261
+ "augmentation": {
262
+ "rotate90": true,
263
+ "mirror": false,
264
+ "shuffle_objects": true,
265
+ "drop_constraints_p": 0.3,
266
+ "drop_support_p": 0.2,
267
+ "category_only_description_p": 0.5,
268
+ "minimal_form_p": 0.5,
269
+ "given_pose_p": 0.0,
270
+ "given_pose_corrupt_p": 0.0,
271
+ "given_pose_noise_m": 0.0,
272
+ "given_pose_yaw_noise_deg": 0.0,
273
+ "given_pose_size_noise": 0.0
274
+ },
275
+ "world_size": 4,
276
+ "supervised_samples": 125356,
277
+ "rejected_samples": 431,
278
+ "validation_samples": 7158,
279
+ "validation_minimal_samples": 3737,
280
+ "resumed_from": null,
281
+ "resumed_at_step": 0,
282
+ "resumed_with_changes": {},
283
+ "resume_unverified": [],
284
+ "selection_metric": {
285
+ "name": "validation.minimal.geometry_objective",
286
+ "lower_is_better": true,
287
+ "definition": "weighted geometry objective on the minimal (three-field) projection of the validation rows; collapse (BEV overlap rate + central-quarter fraction) is reported beside it; ground_truth anchors collapse_matched (the same objects), never minimised on its own (a degenerate wall-hugging layout scores 0)",
288
+ "ground_truth": {
289
+ "objects": 50320,
290
+ "pairs": 1655488,
291
+ "bev_overlap_pairs": 4719,
292
+ "same_category_pairs": 121517,
293
+ "stacked_same_category_pairs": 2721,
294
+ "nearest_wall_distance_sum_m": 27679.385389234172,
295
+ "central_quarter_objects": 6549,
296
+ "out_of_room_objects": 0,
297
+ "footprint_outside_objects": 116,
298
+ "floor_objects": 21839,
299
+ "door_blocking_objects": 0,
300
+ "score": 0.13299757782457322
301
+ }
302
+ }
303
+ }
code/fastfill/README.md ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastFill v1:单次调用的对象条件布局模型
2
+
3
+ 仓库入口与版本选择以 [根 README](../README.md) 为准;当前已核实的限制见 [known issues](../docs/known-issues.md)。本页保留 v3/v3.1 的实验说明,数据版本与代码版本须分别记录。
4
+
5
+ v3.2 修复与独立数据版本的说明见 [RELEASE_v3.2.md](RELEASE_v3.2.md)。保留 v3/v3.1 作为原有实验数据;运行中的训练不要切换数据目录。版本号描述数据版本,模型文本协议仍为 FastFill v1。
6
+
7
+ 一句话:给定房型、真实房间几何(含已固定在房间里的门、窗、柱子、楼梯等),以及**全部**待摆对象(每件的局部实际尺寸,可选描述与空间约束),模型**一次生成**所有对象的数值位置、yaw 和支撑关系。
8
+
9
+ $$F_\theta(R, G, O, C_{\text{layout}}) \rightarrow L$$
10
+
11
+ 2026-09-24 晚改版(数据 v2):不再设物体数上限,落地家具 + 放在家具顶面上的小物件全部保留(含 SAGE),倾斜、悬空的家具修正后保留;门、窗、柱子、楼梯等结构件和没有名字的落地箱子作为 `fixed`(固定件)告诉模型、不摆;挂墙、吊顶、柜子里的东西不摆也不再拒收房间。40960 只是模型的序列长度上限,训练参数由实测数据决定(§4)。
12
+
13
+ 整体框架沿用 OptiScene:对象由上游给定,模型只负责摆放,单轮 Qwen3-8B + LoRA SFT。
14
+ 在此基础上修正了 OptiScene 已核实的几处缺陷:
15
+ - 房间只给面积 → 改为给地面多边形;
16
+ - h/w/d 轴序混乱 → 改为局部尺寸,并约定 +X 为正面;
17
+ - 同描述物体按列表位置对应 → 每件物体一个 id;
18
+ - 训练与推理用的 prompt 不一致 → 改为同一个 prompt。
19
+
20
+ 范围和协议以用户冻结的 FastFill v1 定稿为准,接口见附录 B,实现在 `interface.py`。
21
+
22
+ ## 1. 流水线与文件
23
+
24
+ ```
25
+ HF 导出(22 源) ──download──▶ 本地汇总 JSON ──adapters/*──▶ IR(统一约定) ──build──▶ train/dev/test ──train──▶ LoRA
26
+ │ │
27
+ *_rooms.jsonl(评测用 IR) merge_lora ─▶ merged
28
+ └──────── evaluate ◀───────┘
29
+ WorldEdge 请求(附录 B) ──interface.request_to_room──▶ 模型一次生成 ──interface.placements_from_text──▶ placements(yaw_rad)
30
+ ```
31
+
32
+ | 文件 | 作用 |
33
+ |---|---|
34
+ | `download.py` / `download_full.sh` | 下载导出数据。前者默认只下汇总文件(33 GB),后者用 `hf download` 逐个数据集下全量 |
35
+ | `adapters/*.py` | 每个数据源一份,把各自的约定转成统一 IR。每份都附有证据:front test、kits 源码、20 间房逐字段核对 |
36
+ | `scene.py` | IR 约定、SYSTEM_PROMPT、序列化、canonical 排序和 id、`parse`(永不抛异常) |
37
+ | `anchors.py` | 剔除结构件(`fixed_geometry` 挑出要告诉模型的固定件),推断落地、放在他物上、挂墙、吊顶四类锚点,保证支撑关系闭合 |
38
+ | `build.py` | 过滤和拒收规则、固定件、取整对齐、跨源去重、防泄漏划分、增强、约束抽取 |
39
+ | `split.py` | 用 house/scan、别名、家具指纹、布局指纹做并查集,按连通分量整体划分 |
40
+ | `validate.py` | 越界、支撑、分桶碰撞、OOR 检查,以及约束检查 `holds` |
41
+ | `train.py` | LoRA SFT:只监督答案,批量分词,按长度分组,支持 `--resume` 和 `--dry_run` |
42
+ | `evaluate.py` | 生成并打分,所有请求都计入统计,分布内和 held-out 分开报告,每项都附 GT 对照 |
43
+ | `interface.py` | 附录 B 请求与模型文本之间的双向转换(输出 `position_m` / `yaw_rad` / `support_parent`) |
44
+ | `serve.py` | OpenAI 兼容服务端点:给 EmbodiedGen 的 `FastFillBackend` 调用,内部走 `interface.py` |
45
+ | `tools/qa_report.py`、`tools/ablation_check.py` | 重建后的 QA(泄漏、闭合、约束、参考布局合法率、固定件统计)和两版只差约束的核对 |
46
+ | `merge_lora.py` | 合并 LoRA,保留训练保存的 tokenizer、原始及续训 manifest,并写出 transformers 4.x/5.x 兼容字段 |
47
+
48
+ ## 2. 统一标准(所有数据源进训练前都转成这一套)
49
+
50
+ **坐标与位姿**
51
+ - 单位是米,右手系,Z 轴向上。
52
+ - 使用房间局部坐标,边界 AABB 的左下角为 (0,0),地面 z=0。
53
+ - 位置取 bbox **底面中心**。
54
+ - 尺寸是对象**局部坐标**下的 extent `[sx,sy,sz]`,不是世界坐标下的 AABB。
55
+ - 局部 +X 是**语义正面**:训练源须通过正面约定核查;单个对象缺少可靠正面时,按 `no_front` 规则作为固定件或过滤,不要求模型预测它的 yaw。
56
+ - yaw 是绕 +Z 从房间 +X 转到对象正面的角度。
57
+ - 接口层和 IR 用弧度(`yaw_rad`)。
58
+ - 模型��本里写整数度,因为 `90` 是精确值且只占 2 个 token;`1.570796` 不精确,还要多 3–6 个 token。
59
+ - 取整误差 ≤0.5°,对应 1 m 处约 0.9 cm。
60
+
61
+ **房间与对象范围**
62
+ - 边界:`polygon` 表示墙体,`hull` 表示扫描地面的凸包。只由物体范围拼出的代理边界不进训练。顶点数不设上限。
63
+ - 待摆对象:落地物体,加上放在其他物体顶面上的物体(带 `on`),所有源一视同仁(SAGE 的桌面小物件也算)。**物体数不设上限**,只要求每间房至少 1 件可摆的物体(只有 1 件的保留并标 `single_object`);唯一的长度限制是模型的 40960 token(超长样本在训练时整条丢弃、不截断,`train.py --dry_run` 会把它们逐条列出来)。
64
+ - 不摆、也不拒收房间的物体:挂墙(画、镜子、挂柜……)、吊顶(吊灯类不管源数据标在多高一律按吊顶)、放在柜子里或不在支撑物顶面上的东西、床上的被子毯子。
65
+ - **固定件 `fixed`**:已经在房间里、模型不摆但必须避开的箱子,连同位姿一起告诉模型(`fixed` 列表:id、size、pos、yaw)。五类(`stats.json` 里按 `fixed:<类>` 计数):
66
+ - `structure`(`anchors.fixed_geometry`):门、窗、柱子、楼梯、电梯井、壁炉、暖气片、隔断、栏杆、低梁等结构件,底部低于 1.5 m 的。贴面和无占地的构件(地面、天花、踢脚线、线板、墙板、插座、地漏……)、窗帘百叶纱帘、占房间 ≥80% 的板块不算;`wall` 盒子只有在墙线房间里伸进房间(离墙 10 cm 以外 ≥0.05 m²,即隔断)才算,扫描凸包房间里的 `wall` 一律不算(墙本来就在凸包里)。
67
+ - `generic`:没有名字的落地箱子(`unknown`、`otherprop`、`object`……):它们确实占着地方,但没有名字就没法作为待摆对象(id 为 `object_k`)。
68
+ - `wall`:源数据标成挂墙、但箱子底就在地面上的东西(一整排从地面起的壁柜)。
69
+ - `floating`:墙线(设计师)房间里悬空 ≤0.5 m、下面没有东西撑着的可挂墙家具(浴室柜、悬浮电视柜、搁架):那是挂墙件,不摆,但要避开,保留它真实的高度。扫描源(InternScenes、MultiScan、Scan2CAD)的 z 有噪声,这类家具一律贴地摆放。
70
+ - `no_front`:源数据不给正面朝向的可摆物体(InteriorGS 的定制柜):答案要每件的 yaw,所以把它当嵌入式固定件告诉模型,房间保留(标 `no_front_fixed`),不再整间拒收。
71
+ - 隔断板(`partition panel`、divider、screen)按结构件处理,不当贴面。
72
+ - 统一过滤(`build._keep_fixed`,服务端 `interface.py` 同一套):顶面低于 15 cm 的不算(地垫、踢脚、门把手这类);沉到地面以下的截到地面;整个在房间外的丢;占房间 ≥50% 的丢。**结构件(柱子、楼梯、门、窗……)永远保留**,即使参考布局里有家具压在上面(那会作为参考布局自己的固定件碰撞记入评测的 GT 值);**非结构件**(无名箱子、落地的挂墙件、悬空挂墙件、无朝向件)被已摆物体盖住 ≥50% 时才丢(那是同一件家具的第二个标注框)。
73
+ - 固定件和待摆对象共用一个 id 命名空间(固定件先编号),答案里只出现待摆对象。
74
+ - 倾斜的箱子按"用它的 yaw 的直立包围盒"保留(InternScenes 的 adapter 把倾斜框换成其 8 个角点的直立包围盒;扫描源里主要是书、枕头、杯子、篮子等小件)。落地箱子沉入地面 ≤0.3 m 的贴到地面保留,更深的丢弃;下面没有东西撑着、悬空 ≤0.5 m 的家具:不能挂墙的(床、沙发、桌椅、冰箱……)和扫描房间里的一律贴到地面,设计师房间里的可挂墙家具按 `floating` 固定件处理。放在家具顶面上的画框、镜子按"放在上面"保留,挂在墙上的不摆。
75
+ - **硬过滤(只拒收没法构造训练目标的样本,2026-09-25 起)**:源整体不保证正面朝向;有物体缺包围盒(看不见的障碍);数值不是有限数,或**要摆的**物体尺寸 ≤0(不摆的零厚度贴纸、地垫不再拒收整间房);同一房间里 id 重复;没有边界多边形/凸包,或多边形无效;一件待摆物体都没有。
76
+ - **质量标记(房间保留,写在每条样本的 `flags` 里,`stats.json` 按 `flag:<名>` 计数;`--reject_flagged <名...>` 可以把选中的标记改回拒收)**:`oob_objects`(参考布局有物体出墙 >10 cm 的件数)、`hidden_m2` / `hidden_obstacle`(被丢掉、又不是固定件或贴面的落地箱子露出的地面 m²,≥0.3 时标 hidden_obstacle;主要是楼梯间)、`small_area_m2`(<1 m²)、`room_filling_object`(一件非地毯物体占房间 ≥80%)、`overlapping_furniture`(同类家具大面积重叠或大件硬家具穿插)、`single_object`、`z_snapped`(离地或沉入超过 10 cm 被贴地的件数;每件被贴地���物体都在 IR 里保留原始 z `z_src`)、`tilted`(倾斜换成直立包围盒的件数)。这些是"这条样本的某个字段可不可靠"的标签,不是对数据集的评价;训练时按标签加权或排除是采样层的事。
77
+ - 每个 split 里完全相同的房间(边界相同,且每件物体和固定件的类别、尺寸、位姿都一一相同,允许整体转 90°)只保留一份(3D-FRONT/SpatialLM 同一设计重复最多 26 次;`--keep_duplicates` 可关)。
78
+ - 不再在构建阶段截断大源(`--cap` 默认为空)。采样层在 `train.py`:`--exclude_flags` **默认去掉 `oob_objects`、`overlapping_furniture`、`fixed_collision` 三类**(参考答案是服务端会拒绝的布局:物体出墙 >10 cm;同一件家具重复穿插;家具穿过门、柱子、楼梯等非窗固定件),数据里仍保留,传 `--exclude_flags` 不带值就全用;`--source_weight 源=w` 让某个源的每条样本平均用 w 次(0.5 减半、2 加倍,按 uid 稳定)。启动时打印每个源的条数和去掉的条数;`QA.json` 的 `train rows used by default` 给出默认子集的参考答案合法率。
79
+
80
+ **缺失字段与取整**
81
+ - 源数据里缺失的字段一律省略,不伪造。房型、层高缺失时直接不写这个键,训练时也会随机丢掉这两个字段。
82
+ - 物体高过房间层高时,丢弃层高字段。
83
+ - 类别只做可追溯的归一化,原始标签保留在 IR 里;3D-FUTURE 的 'Corner/Side Table'、'Three-Seat / Multi-seat Sofa' 和 Infinigen 的 *factory 名字单独映射成 side table / three seat sofa 等。房型归一化时会去掉实例编号、楼层号和公寓号。
84
+ - 所有校验、约束和评测都用**模型实际看到的取整值**:位置和尺寸取到厘米,yaw 取整数度。生成的约束须在这些参考值上成立;参考布局仍可能有已标记的几何问题,合法率由 `QA.json` 实测,不保证全部样本为 100%。
85
+
86
+ ## 3. 数据(v3,2026-09-25 构建:`/Volumes/harddisk/fastfill_data/v3` 和 `v3.1`;HF 数据集 `liantian/fastfill-v3`)
87
+
88
+ train 144,140 / dev 8,167 / test 8,647(另有 dev_constrained、test_constrained:每间 1–4 条在参考布局上成立的约束)。
89
+ - v3 = 无约束基线 (R,G,O)→L;v3.1 = 同一批房间,其中 42,743 条训练样本带 1–4 条约束 (R,G,O,C)→L。两版 dev/test 逐字节相同(True);`tools/ablation_check.py`:去掉 constraints 后两版训练样本差异 0 条。
90
+ - `QA.json`:泄漏(train 与 dev/test 之间 group、别名、家具指纹、布局指纹、held-out 源)全部为 0;答案恰好摆了输入里的全部物体 144,140/144,140;固定件出现在答案里 0 次;约束在参考布局上成立 18662/18662(dev)、19785/19785(test)。
91
+ - 参考布局本身的合法率(dev):93.2%。默认训练子集(去掉 `oob_objects`、`overlapping_furniture`、`fixed_collision`)124,836 条,参考答案通过越界、支撑及层高检查的有 124,834 条;另 2 条的支撑距离因厘米取整落到 5 cm 容差边界,旧版严格 contains 判为失败。该比例不衡量源包围盒正确性、语义质量或全部碰撞。模型指标一律和同一批房间的 GT 值对比。
92
+ - token(Qwen3 分词器,`train.py --dry_run`):训练集共 1.86 亿 token;单条 P50 896、P90 2,226、P95 3,440、P99 6,142、最长 135,965;≤4096 的 96.6%,≤8192 的 99.69%。每间待摆物体 P50 6、P90 28、P95 46、P99 84、最长 2,080;固定件 P50 2、P90 7、P95 8、P99 12、最长 70。
93
+ - **超过 40960 的训练样本 9 条**:`interiorgs:0044_839926::room_0`(InteriorGS,2080 件,135,965 token);`interiorgs:0045_839925::room_0`(InteriorGS,680 件,43,739 token);`interiorgs:0134_840039::room_0`(InteriorGS,1274 件,80,252 token);`interiorgs:0141_840177::room_0`(InteriorGS,1575 件,99,434 token);`interiorgs:0175_840149::room_0`(InteriorGS,1229 件,67,984 token);`interiorgs:0179_840114::room_0`(InteriorGS,1164 件,75,057 token);`interiorgs:0394_840186::room_0`(InteriorGS,983 件,57,821 token);`interiorgs:0408_840112::room_0`(InteriorGS,904 件,57,764 token);`MansionWorld::mansionworld/public_entertainment_4f_200_fp001#0/F2_event_hall`(MansionWorld,628 件,44,392 token)。`train.py` 加载时整条跳过、不截断。dev 最长 16,584、test 最长 27,156。
94
+ - 固定件共 477,666 个,分布在 135,205 间写出的房间里(占 84%):structure 441,532、generic 22,040、floating 8,800、no_front 5,128、wall 166。
95
+ - v3 修复了 v2 中的 MansionWorld 桌面物件、InteriorGS 门窗、部分倾斜框、3D-FRONT 精确边界和补门等问题。后续审核仍发现 Structured3D/HSSD/MultiScan 的倾斜框转换及 InteriorGS 上游归房遗漏,修复见 v3.2。v2 / v2.1 的 test 集不同,数字不可直接比较。
96
+
97
+ 各源房间数(train / dev / test)、单间最多待摆物体、固定件数、带固定件的房间数(都按写出的房间计):
98
+
99
+ | 训练源 | train / dev / test | 最多物体 | 固定件 | 带固定件的房间 |
100
+ |---|---|---|---|---|
101
+ | SpatialLM | 42,068 / 2,239 / 2,461 | 43 | 156,103 | 46,334 |
102
+ | MansionWorld | 19,396 / 1,153 / 1,313 | 628 | 86,316 | 21,762 |
103
+ | IL3D_3dfront | 18,054 / 939 / 872 | 27 | 39,427 | 13,567 |
104
+ | Structured3D | 12,700 / 793 / 747 | 181 | 73,356 | 14,238 |
105
+ | InternScenes_gen | 12,552 / 828 / 660 | 140 | 59,378 | 14,040 |
106
+ | SAGE-10k | 8,977 / 503 / 489 | 189 | 12,991 | 9,969 |
107
+ | OptiScene_holodeck | 8,946 / 497 / 503 | 21 | 5 | 5 |
108
+ | IL3D_synthetic | 6,277 / 349 / 316 | 20 | 1,096 | 999 |
109
+ | InteriorGS | 4,867 / 268 / 311 | 2080 | 23,508 | 5,347 |
110
+ | InternScenes_arkit | 4,528 / 280 / 229 | 63 | 9,486 | 4,012 |
111
+ | HSSD200 | 1,876 / 86 / 138 | 623 | 3,529 | 1,345 |
112
+ | InternScenes_scannet | 1,309 / 80 / 104 | 90 | 4,194 | 1,257 |
113
+ | InternScenes_mp3d | 1,258 / 89 / 18 | 112 | 5,170 | 1,247 |
114
+ | InternScenes_3rscan | 1,200 / 61 / 70 | 160 | 2,588 | 915 |
115
+ | MultiScan | 114 / 2 / 6 | 80 | 344 | 101 |
116
+ | Scan2CAD | 18 / 0 / 1 | 32 | 0 | 0 |
117
+ | SceneSmith(只评测) | 0 / 0 / 364 | 152 | 26 | 25 |
118
+ | SpatialGen(只评测) | 0 / 0 / 45 | 25 | 149 | 42 |
119
+
120
+ 扫描 188,670 间。硬过滤拒收(间):object_count 19,606、boundary 722、boundary_shape 142、incomplete_objects 113。
121
+ - `object_count`:没有一件可摆的物体(只有墙上、顶上的东西或没有名字的箱子),没有训练答案。
122
+ - `boundary` / `boundary_shape`:没有边界或边界多边形无效。`incomplete_objects`:有物体没有包围盒(HSSD 的可动柜门、冰箱等),会成为看不见的障碍。`bad_numbers`:数值非法,或要摆的物体尺寸 ≤0。
123
+ - 另外,每个 split 里完全相同的房间只留一份(duplicate_layout 6,137 间),跨源同一场景只留一个源(dedup_dropped 996 间)。
124
+
125
+ 质量标记(写出的房间里,按标记计):tilted 14,073、single_object 13,344、fixed_collision 11,918、oob_objects 10,959、z_snapped 5,059、no_front_fixed 3,480、hidden_m2 2,443、room_filling_object 2,193、overlapping_furniture 1,989、hidden_obstacle 1,257、small_area_m2 59。
126
+
127
+ **数据集取舍:Excel 的 22 个 → 下载保留 16 个(11 个产生训练房间、2 个只做评测、3 个是辅助数据)→ 删除 6 个**
128
+
129
+ 每个保留的数据集都有实际用途(下面"用在哪"一栏可在 `adapters/*.py` 里对应核实)。房间数见上面 §3 的表。
130
+
131
+ *A. 产生训练房间(11 个下载 → 16 个训练源)*
132
+
133
+ | Excel 数据集 | 训练源 | 为什么用 |
134
+ |---|---|---|
135
+ | SpatialLM | SpatialLM | 设计师户型,官方墙线多边形,房型 100% 有值 |
136
+ | IL3D | IL3D_3dfront、IL3D_synthetic | 3D-FRONT 设计房间的规范版本(类别、尺寸完整);程序合成房间 |
137
+ | OptiScene | OptiScene_holodeck | 参考项目的数据,只用 Holodeck 生成部分(其中 3D-FRONT 部分与 IL3D 重复) |
138
+ | InternScenes | InternScenes_gen;arkit / scannet / mp3d / 3rscan | Infinigen 生成房间(每间都有门窗固定件);四个真实扫描子集,OBB 带语义正面 |
139
+ | MansionWorld | MansionWorld | 程序生成楼宇,多边形边界,房型 100% |
140
+ | SAGE-10k | SAGE-10k | Agent 生成的单房间,落地家具和桌面小物件都用(v2 起) |
141
+ | Structured3D | Structured3D | 官方房间多边形和层高 |
142
+ | InteriorGS | InteriorGS | 官方房间 profile 多边形 |
143
+ | HSSD-200 | HSSD200 | 设计场景的官方区域多边形;它的 `asset_catalog.csv` 还是 IL3D_synthetic 计算物体网格边界必需的 |
144
+ | SceneCAD & Scan2CAD | Scan2CAD | ScanNet 扫描 + CAD 对齐得到的朝向 |
145
+ | MultiScan | MultiScan | 真实扫描,标注了正面向量;多数房间没有可用边界(boundary)被拒 |
146
+
147
+ *B. 只做评测(held-out,从不进训练)*
148
+
149
+ | Excel 数据集 | test | 为什么用 |
150
+ |---|---|---|
151
+ | SceneSmith | 364 | 训练里没有的生成器,测泛化 |
152
+ | SpatialGen | 45 | 同上 |
153
+
154
+ *C. 辅助数据:不产生房间,但删掉会让别的数据变错*
155
+
156
+ | Excel 数据集 | 被谁用 | 删掉的后果 |
157
+ |---|---|---|
158
+ | 3D-FRONT | IL3D_3dfront:把 IL3D 按房型合并的房间拆回真实源房间,并取真实地面边界和层高 | 约 1,300 个 IL3D 房间会混着 2–10 个房间的物体,边界也不对 |
159
+ | 3RScan | InternScenes_3rscan:reference / rescan 对应表 | 同一空间的多次重扫描可能分到 train 和 test 两边(泄漏) |
160
+ | ARKitScenes | InternScenes_arkit:video → visit 对应表 | 同一次 visit 的多段扫描可能分到 train 和 test 两边(泄漏) |
161
+
162
+ 这三个本身不直接进训练:3D-FRONT 原始导出类别和尺寸大面积缺失,已由 IL3D 版本覆盖;3RScan、ARKitScenes 原始导出没有房间边界,房间来自 InternScenes 版本。
163
+
164
+ *D. 删除(6 个)*
165
+
166
+ | Excel 数据集 | 删除原因 |
167
+ |---|---|
168
+ | HM3DSem | 没有 yaw,尺寸是世界坐标 AABB;region 不是房间 |
169
+ | MetaScenes | 没有 yaw(世界坐标 AABB);场景是 ScanNet,已由 InternScenes_scannet 覆盖 |
170
+ | ScanNet | 没有 yaw;1,513 个扫描已由 InternScenes_scannet(带正面)覆盖 |
171
+ | ScanNet++ | 包围盒没有正面信息(2026-09-24 用数据复核,3 种方法均在对照数据集上先验证有效):X 轴就是较短的水平边(97–100% 的盒子,对照数据集约 50%),即按边长排序而不是按正面;贴墙的一侧在 ±X/±Y 四个方向各约 25%(对照数据集 88–98% 是背面 −X 贴墙);椅子相对桌子的朝向四个方向均匀(n=2,908)。另外 221 个 apartment 未拆成房间,边界是矩形代理 |
172
+ | ASE | 约 60% 的实例没有框 |
173
+ | M3DLayout | 3D-FRONT、Matterport 部分是重复;Infinigen 部分的边界就是家具范围(3000/3000),不是真实房间 |
174
+
175
+ - 删除依据来自各数据集的 HF README 和本地实测(front test、边界检查)。ASE、ScanNet++、M3DLayout_infinigen 转好的 IR 仍保留在 `/Volumes/harddisk/fastfill_ir/`,以后可以直接加进 build;`fastfill.adapters` 遇到已删除的源会跳过并提示。
176
+ - 保留的 16 个数据集已逐文件对照 HuggingFace 核对完整(含 MultiScan 的网格和贴图,共 83 GB)。重新下载用 `fastfill/download_full.sh <目录> <名字...>`(需要 zsh;名字写错或 30 次重试都失败会报错退出)。
177
+
178
+ **许可证(按各数据集本地 README / LICENSE 核对,2026-09-24)**
179
+
180
+ | 训练源 | 许可 / 条款 |
181
+ |---|---|
182
+ | SpatialLM | CC BY-NC 4.0 |
183
+ | IL3D_3dfront | IL3D 卡片写 Apache-2.0,但内容是 3D-FRONT:受 3D-FRONT 使用协议约束,只能科研、不能商用、不能用结果提供对外服务、不能分发数据(§3.2、§3.3.1–3.3.5) |
184
+ | IL3D_synthetic | 用 HSSD 资产布置,按 HSSD:CC BY-NC 4.0 |
185
+ | HSSD200 | CC BY-NC 4.0 |
186
+ | InternScenes(gen / arkit / scannet / mp3d / 3rscan) | CC BY-NC-SA 4.0,另加各上游扫描数据集的条款 |
187
+ | MultiScan | CC BY-NC 4.0(需申请) |
188
+ | Structured3D | 仅非商业科研,不得再分发 |
189
+ | InteriorGS | 需申请,按 interiorgs-terms-of-use |
190
+ | MansionWorld | CC BY 4.0,但 HF 下载时要勾选"仅非商业科研" |
191
+ | SAGE-10k、OptiScene_holodeck | Apache-2.0(约占训练集 17%) |
192
+ | Scan2CAD | 本地 README 未写许可;ScanNet 条款为非商业 |
193
+ | SpatialGen(评测) | CC BY-NC 4.0 |
194
+ | SceneSmith(评测) | Apache-2.0 |
195
+
196
+ 结论:所有源都允许非商业科研,混在一起训练做内部研究可以;模型继承全部限制,**只能内部非商业使用**。公开权重有冲突(InternScenes 的 ShareAlike 与 3D-FRONT、Structured3D 的禁止分发条款),真要发布需要去掉 InternScenes 或 IL3D_3dfront 重训,并先做法律核查。
197
+
198
+ **防泄漏**
199
+ - 以下四种关系任意一种相连的房间,整体划入同一个 split:同一 house 或 scan、同一次 visit、相同家具指纹、相同布局指纹(旋转 90° 视为同一布局,换资产也算)。InternScenes_gen 按房子编号分组(同一个编号出现在不同房型文件夹里就是同一栋房子的不同房间)。
200
+ - 在写出的文件上复核过:train 与 dev/test 之间 group、布局指纹、家具指纹的重合数都是 0;held-out 源在 train 中为 0 行。
201
+
202
+ ## 4. 在服务器上训练
203
+
204
+ **环境(CUDA 12.8)**:用 node03 上已验证的组合(torch 2.10 cu128、vLLM 0.19.0、transformers 4.57.6),版本固定在 `requirements-cu128.txt`(在 HF 仓库的 `code/` 下)。训练、合并、推理必须在同一个环境里完成。
205
+
206
+ **历史 v3/v3.1 实验快照**在 HF 数据集 `liantian/fastfill-v3`:`code/`、`v3/`、`v3.1/`;v3.2 使用独立的 `code_v3.2/`、`v3.2/`。复现已有实验使用相应冻结快照。当前 GitHub 安装与入口命令见 [根 README](../README.md)。下面的服务器说明保留原 v3 实验路径。
207
+
208
+ ```bash
209
+ conda create -n fastfill python=3.11 -y && conda activate fastfill # 已有 node03 的 fastfill 环境就跳过这两行
210
+ pip install torch==2.10.0 torchvision==0.25.0 torchaudio==2.10.0 --index-url https://download.pytorch.org/whl/cu128
211
+ pip install -U "huggingface_hub>=0.36" && hf auth login # 用一个 read token
212
+ hf download liantian/fastfill-v3 --repo-type dataset --local-dir ~/fastfill-v3
213
+ cd ~/fastfill-v3/code && pip install -r requirements-cu128.txt
214
+ python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available(), torch.cuda.device_count())"
215
+ python -m fastfill.scene && python -m fastfill.validate && python -m fastfill.interface # 自检
216
+ hf download Qwen/Qwen3-8B --local-dir ~/models/Qwen3-8B
217
+ python -m fastfill.train --model ~/models/Qwen3-8B --data ~/fastfill-v3/v3 --out /tmp/x --dry_run # 用真实分词器逐条统计(见下)
218
+ ```
219
+
220
+ **先测数据,再定参数**(2026-09-24 决定:让数据决定训练参数,不让训练参数决定删哪些家具)
221
+ - `--max_len 40960`(默认)只是模型的序列上限,不是显存设置:`train.py` 按每批里最长的那条补齐,显存由数据决定。超过 40960 的样本整条丢弃、不截断。
222
+ - `--dry_run`:用 Qwen 分��器对 train/dev/test 逐条分词,打印每条 token 数、答案 token 数、每间房物体数和固定件数的 P50/P90/P95/P99/最大值,各长度桶的条数,以及超过 `--max_len` 的样本清单(uid、源、token 数、物体数)。超长的极少数拿出来单独讨论,不自动删。
223
+ - `--mem_test 8192,16384,32768,max`:在 1 张卡上(`CUDA_VISIBLE_DEVICES=0`)按训练的真实配置各跑一步(`--bs` 行、按 collator 的方式右填充:第 0 行是该长度,其余行是它的一半,所以 attention mask 也和训练时一样被实体化;梯度检查点和训练一致)。先用 512 token 做一次预热步,再读取 static(权重 + LoRA 参数/梯度 + AdamW 状态);表中每行给出 peak 和这一步新增的部分(激活 / logits / attention mask);某个长度 OOM 时打印后跳到下一个。`max` = train 与 dev 中最长的一行(Trainer 每 `--save_steps` 会在 dev 上评估一次;表前先打印 train / dev 各自最长行的 token 数),数字 = 该长度的合成序列(全部 token 都监督,是上界)。用你打算训练的 `--bs` 跑它,再定 grad_accum。
224
+ ```bash
225
+ CUDA_VISIBLE_DEVICES=0 python -m fastfill.train --model ~/models/Qwen3-8B --data ~/fastfill-v3/v3 --out /tmp/x --bs 1 --mem_test 8192,16384,32768,max
226
+ CUDA_VISIBLE_DEVICES=0 python -m fastfill.train --model ~/models/Qwen3-8B --data ~/fastfill-v3/v3 --out /tmp/x --bs 2 --mem_test 8192,16384,max
227
+ ```
228
+ - 评估固定 `per_device_eval_batch_size=1`;`--eval_max_len N` 把 dev 里超过 N token 的行排除出评估集(打印排除数量,此时 eval loss 只覆盖剩下的子集),N 按 mem_test 的表来选(最长样本见 §3)。
229
+
230
+ **训练**:有效 batch = bs × grad_accum × GPU 数,保持在 32 左右。换 GPU 数量时要同步调整 grad_accum;bs 按 `--mem_test` 的结果定(下面的 bs=4 是 v1 数据 ≤2.8k token 时的值)。
231
+
232
+ ```bash
233
+ # 8 卡
234
+ PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True torchrun --nproc_per_node 8 -m fastfill.train \
235
+ --model ~/models/Qwen3-8B --data ~/fastfill-v3/v3 --out outputs/ff-v3 \
236
+ --bs 1 --grad_accum 4 --lr 1e-4 --epochs 2 --save_steps 500 # bs 按 --mem_test 的结果改:bs 2 -> grad_accum 2,bs 4 -> 1
237
+ # 只用 1–7 号卡:前面加 CUDA_VISIBLE_DEVICES=1,2,3,4,5,6,7,并改 --nproc_per_node 7(有效 batch 28 也可以)
238
+ # 单卡:CUDA_VISIBLE_DEVICES=0 python -m fastfill.train ... --grad_accum 8 --save_steps 1000
239
+ # (不指定卡时,单进程会把所有卡用成 DataParallel,又慢又占 0 号卡显存)
240
+ # 中断后续训:同一条命令末尾加 --resume。在第 2 个 epoch 中途断开时,该 epoch 剩余部分的数据顺序会变
241
+ # (HF 的已知行为,最多约 1/4 样本在这个 epoch 多训或少训一次),可以接受
242
+ ```
243
+
244
+ **资源估算**(v1 数据、bs=4 时的解析估算,v2 的样本更长,以 `--mem_test` 实测为准)
245
+ - 2 个 epoch 的用时:8×H100 约 1.5–2 h;8×A100 约 3–4 h(token 总量比 v1 多 39%)。
246
+ - 每个进程都会先在 CPU 上加载一份 16 GB 的权重,8 卡时主机内存峰值约 170 GB。
247
+
248
+ **合并与评测**
249
+
250
+ ```bash
251
+ python -m fastfill.merge_lora --base_model_path ~/models/Qwen3-8B --lora_path outputs/ff-v3/final --output_path outputs/ff-v3-merged
252
+ python -m fastfill.evaluate --gt-only --rooms ~/fastfill-v3/v3/test_rooms.jsonl --out eval/gt # GT 参考线
253
+ python -m fastfill.evaluate --model outputs/ff-v3-merged --rooms ~/fastfill-v3/v3/test_rooms.jsonl --out eval/ff-v3
254
+ python -m fastfill.evaluate --model outputs/ff-v3-merged --rooms ~/fastfill-v3/v3/test_constrained_rooms.jsonl --out eval/ff-v3-cons
255
+ ```
256
+
257
+ **看评测结果时注意**
258
+ - 优先装 vLLM;走 HF generate 时设 `CUDA_VISIBLE_DEVICES=0`,否则 8B 模型会被切到多张卡上串行跑;`--batch` 默认 1(40,960 上下文下多于一行的 KV cache 放不进一张卡)。
259
+ - prompt 长度 ≥ `--max_len` 的房间不送模型,记为 `prompt_too_long`,按失败请求计(valid False、约束全 False、parsed False),其余房间照常评。
260
+ - `metrics.json` 的 `in_dist` 和 `held_out` 分开看,碰撞指标一律和同一批房间的 `*_gt` 对比,不要和 1.0 比。
261
+ - 合法(valid)= 不出界、支撑正确、不顶破天花板(物体顶 ≤ 层高 + 5 cm;层高未知时不查)。
262
+ - 越界率、碰撞率、OOR 只在完整解析的答案上算;一个完整答案都没有时显示 `null`(未知),不是 0。
263
+ - **模型指标只看原始输出**(`valid_rate` 等)。`*_repaired` 是接口把物体推回墙内之后的结果,属于确定性后处理,不算模型能力;`inside_1mm_rate` 是按后端 1 mm 标准的贴墙合格率,原始和修复后分开报。
264
+ - **v3(无约束版)训练时没有见过约束输入**,所以 test_constrained 上的约束满足率是"无约束监督基线(零样本)",不能当作约束能力的结论。
265
+ - `rooms_with_fixed_collision`(和 `_gt`)是摆放和固定件重叠的房间比例,对照同��批房间的 GT 值看;序列长度用 `--max_len`(默认 40960,vLLM 的 max_model_len),提示词本身就超过它的房间记为 `prompt_too_long` 失败,不会让整轮评测中断。
266
+
267
+ **起服务接 WorldEdge(EmbodiedGen `FastFillBackend`)**
268
+
269
+ ```bash
270
+ python -m fastfill.serve --model outputs/ff-v3.1-merged --port 8001 # vLLM,贪心解码,一次处理一个房间;--max_len 40960
271
+ # EmbodiedGen 那边:WORLDEDGE_FASTFILL_URL=http://<node03>:8001/v1 WORLDEDGE_FASTFILL_MODEL=<服务名,默认是 checkpoint 目录名>
272
+ ```
273
+
274
+ - 后端把附录 B 请求的 JSON 原样作为 user 消息发过来;`serve.py` 用 `interface.py` 转成模型文本,模型输出后再转回 `placements`(`position_m` / `yaw_rad` / `support_parent`),放在回复的 content 里。
275
+ - 以下情况返回 HTTP 422、不返回布局,由调用方(Harness)决定怎么办:请求不合法(数值非法、坐标系不是米/Z 向上/弧度、重复 id、未知 anchor、约束引用自己或成环、约束引用固定件的 id 等);prompt 超过 `--max_len`(`prompt_too_long`);模型输出解析不了(包括漏摆或多摆物体);修复后仍验收不过(出界、悬空、顶破天花板、有物体超出墙面 1 mm、任何一条 hard 约束不成立)。评测里对应的指标是 `accepted_rate`(同样要求不穿过任何固定件)。FastFill 要么把全部物体合法摆好,要么明确失败,**绝不自己丢物体**。放不下的清单也走这条路。
276
+ - 训练数据里物体常常穿墙几厘米(test 参考答案 39.8% 的房间 > 1 mm,OptiScene_holodeck 系统性 4.5 cm),而后端只容忍 1 mm。所以落地物体穿墙或穿进 keepout 不超过 10 cm 时,`interface.py` 会把它推回墙内,放在它上面的东西一起移动。这是**确定性修复,不算模型成功**:回复里同时给 `raw_placements`(模型原始提案)、`placements`(修复后)和 `validation.raw` / `validation.repaired`(各自的验收结果,含每条约束是否满足),日志和实验三者分开记。
277
+ - 约束字段(这就是后端文档里说的"FastFill v1 文档")。**每个谓词只有一份实现:`validate.holds`**。数据里抽约束、模型 prompt 的定义文字、评测、服务端验收都调用它,所以训练、条件、验收三处语义一致;别的系统要判断 FastFill 约束是否满足,读回复里的 `validation.*.constraints`,不要自己重新实现一遍。
278
+
279
+ | type | 字段 | 定义(`validate.holds`) |
280
+ |---|---|---|
281
+ | `faces` | `subject`, `target` | subject 的正面方向与"subject 中心 → target 中心"的夹角 ≤ 30° |
282
+ | `between` | `subject`, `anchors: [a, b]` | a、b 中心连线与 subject 的旋转 footprint 相交 |
283
+ | `supported_by` | `subject`, `parent`, `surface: "top"` | subject 的支撑父物体就是 parent(放在它顶面) |
284
+ | `against_wall` | `subject` | subject 旋转 footprint 的某一整条边,每一点离边界都 ≤ 0.1 m |
285
+ | `near` | `subject`, `target`, `max_gap_m` | 两个**旋转后 footprint 之间的最短距离** ≤ max_gap_m(间隙,不是中心距离);训练见过 0.1 / 0.2 / 0.3 / 0.5 |
286
+ | `keepout` | `polygon_xy`, `hard` | `hard=true`(默认):外扩 0.08 m 后从地面多边形里挖掉;被禁放区围出来的 < 0.5 m² 小死角当作不可用地面。`hard=false`:只报告满足情况,不切地面、不新增模型条件 |
287
+
288
+ - 别的系统里叫 near 但定义不同的关系(例如"中心距 ≤ 1.5 m"),**不能**直接改个字段名当作 FastFill 的 `near` 发过来:两者是不同的谓词,需要调用方自己决定怎么对应。
289
+
290
+ - 物体上带 `"anchor": "wall"` 或 `"ceiling"` 的(镜子、挂画、吊灯),v1 不摆,放进 `unsupported` 返回;引用它们的约束也进 `unsupported`,其余物体照常摆。
291
+ - 回复带 `model`(服务名,默认 checkpoint 目录名)和 `checkpoint`(绝对路径);EmbodiedGen 按 `WORLDEDGE_FASTFILL_MODEL` 缓存回复,所以两边名字要一致。422 的回复也带模型原文 `raw_text` 和 `unsupported`。没有可摆物体的请求直接回 200、不调用模型。
292
+ - 训练里从没出现过的约束形式(扫描凸包房间里的 `against_wall`、主语没有语义正面的 `faces`、涉及放在别的物体上的对象的 `near`/`faces`/`between` 等)照样检查,但在 `validation` 里标 `"trained": false`。
293
+ - 禁放区离墙不到 1 cm 时贴到墙上(避免厘米取整后留下一条自相交的细缝);取整后仍然折叠的切口返回 `keepout_not_representable`。
294
+ - 评测的 `metrics.json` 对合法率、验收率、贴墙 1 mm 合格率、出界率都给出同一批房间的参考布局值(`*_gt`)。
295
+ - 请求里可选的 `room.fixed_geometry`:已经在房间里、不能动的箱子(门、窗、柱子、楼梯,或者留在原地的家具),每项 `{"id","category"?,"size_xyz_m","position_m"(底面中心),"yaw_rad"?}`,按 `fixed` 给模型;和它们重叠的摆放记在 `validation.*.fixed_collisions`;其中**穿过门、柱子、楼梯等非窗固定件的**(`fixed_blocking`)判为验收失败(422)。家具挡窗只报告(窗框伸进房间 5 cm,贴墙的柜子、床头常常压到它);家具之间的包围盒重叠(椅子塞桌下)也只报告。推回墙内的修复会连同放在上面的东西一起检查,不会把杯子推进固定柜体。按训练数据同样归一化:|z| ≤ 0.10 归零;底部 ≥ 1.5 m、占地 ≥ 房间一半、或完全在墙外的固定件不给模型,在 `unsupported` 里以 `fixed_ignored` 报告;推回墙内的修复不会把物体更深地压进固定件(找不到不加深重叠的移动就原地不动,由验收判定)。约束不能引用固定件。EmbodiedGen 目前不发这个字段(门口用 keepout),加不加由后端负责人定。
296
+ - 所有约束都按"必须满足"发给模型;验收时 `hard` 默认 true,失败则 422;`"hard": false` 的约束只报告结果,不导致失败。
297
+ - `against_wall` 按真实墙线验收,不算禁放区的切口(模型会把切口看成墙,所以请求里同时有禁放区和 against_wall 时,模型偶尔会因此失败)。
298
+ - keepout 把房间切成两块或在中间留出孤岛时,请求直接报错(`keepout_not_representable`)。
299
+
300
+ ## 5. 模型输入输出(真实样本,ARKit 扫描卧室,1 扇窗 + 2 扇门是固定件,6 件待摆,其中 2 盏灯放在柜子上)
301
+
302
+ ```json
303
+ user: {"boundary_type":"hull","boundary":[[0,0.04],[0.04,0],[5.66,0],[5.7,0.04],[5.7,2.93],[5.66,2.97],[0.04,2.97],[0,2.93]],
304
+ "fixed":[{"id":"window_1","size":[0.11,1.16,1.23],"pos":[0.2,1.5,0.89],"yaw":356},{"id":"door_1","size":[0.05,0.86,2.1],"pos":[5.33,2.74,0],"yaw":274},
305
+ {"id":"door_2","size":[0.03,0.79,2.03],"pos":[5.52,1.39,0],"yaw":179}],
306
+ "objects":[{"id":"bed_1","size":[2.07,1.63,0.61]},{"id":"cabinet_1","size":[0.5,0.84,0.77]},{"id":"cabinet_2","size":[0.52,0.42,0.54]},
307
+ {"id":"cabinet_3","size":[0.53,0.41,0.52]},{"id":"lamp_1","size":[0.29,0.2,0.33]},{"id":"lamp_2","size":[0.21,0.18,0.34]}]}
308
+ assistant: {"placements":[{"id":"bed_1","pos":[2.31,1.13,0],"yaw":91},{"id":"cabinet_1","pos":[4.2,2.6,0],"yaw":269},{"id":"cabinet_2","pos":[3.38,0.36,0],"yaw":91},
309
+ {"id":"cabinet_3","pos":[1.26,0.4,0],"yaw":91},{"id":"lamp_1","on":"cabinet_2","pos":[3.38,0.25,0.54],"yaw":90},{"id":"lamp_2","on":"cabinet_3","pos":[1.23,0.24,0.52],"yaw":89}]}
310
+ ```
311
+
312
+ - `fixed`(可选):已经在房间里、不能动的箱子,带 size / pos / yaw,模型不摆它们、只避开;答案里不出现。
313
+ - 对象按占地面积从大到小排列,id 是 `类别_序号`(固定件先编号,和待摆对象共用命名空间),答案顺序与输入一致。
314
+ - 可选的 `desc` 取资产描述的第一句,已去掉摆放从句,训练时整间房以 50% 的概率丢弃。
315
+ - 可选的 `constraints` 有五种:`["on",a,b]`、`["faces",a,b]`、`["near",a,b,d]`、`["against_wall",a]`、`["between",a,b,c]`。
316
+
317
+ ## 6. 重建数据 / 下一步
318
+
319
+ ```bash
320
+ python -m fastfill.download --out $DATA # 汇总文件 33 GB(全量:fastfill/download_full.sh $DATA)
321
+ python -m fastfill.adapters --root $DATA --out $IR # 21 个 adapter → IR(约 20 万个房间)
322
+ python -m fastfill.build --ir $IR --out data/fastfill_v3 \
323
+ --sources SpatialLM IL3D_3dfront InteriorGS HSSD200 Structured3D InternScenes_arkit InternScenes_mp3d InternScenes_scannet \
324
+ InternScenes_3rscan Scan2CAD MultiScan OptiScene_holodeck IL3D_synthetic SAGE-10k MansionWorld InternScenes_gen SpatialGen SceneSmith
325
+ # v3.1(约束监督):同一条命令加 --constraint_frac 0.3,输出到另一个目录;test 文件不变,可以与 v3 配对比较
326
+ python -m fastfill.tools.qa_report $IR data/fastfill_v3 # -> QA.json(泄漏、闭合、约束、参考合法率、固定件)
327
+ python -m fastfill.tools.ablation_check data/fastfill_v3 data/fastfill_v3.1
328
+ ```
329
+
330
+ **已知限制**
331
+ - **序列长度**:唯一的硬上限是模型的 40960 token;超长样本训练时整条丢弃(`--dry_run` 列出清单)。v3/v3.1 单间待摆物体最多为 InteriorGS 的 2,080 件;超长样本不会被截断后训练。
332
+ - **门窗来源**:SpatialLM、3D-FRONT、MansionWorld、InteriorGS 来自导出里每个房间的 `doors` / `windows`(位置、宽高、窗台高;门厚取自门框轮廓,其余按 0.10 m;被拆成几块的门和飘窗合并成一个,低于 15 cm 的窗台板丢掉;3D-FRONT 只挂在隔壁房间名下的门按几何补给它通向的每个房间,约 62% 的房间有门);Structured3D 来自 `room_context.doors` / `windows`;SAGE-10k 的门来自每个场景的 `layout_*.json`;InternScenes、HSSD、MultiScan、扫描源用自带的门窗标注框(HSSD 元数据生成的门窗框按网格包围盒重新定中心)。Holodeck、IL3D_synthetic 的导出里没有门窗。门窗框放在墙线上,厚墙里(离地面多边形 0.3 m 以内)的也保留;飘窗凸出墙外、不碰墙线的不给模型。
333
+ - **服务端边界**:当前 `clean_boundary` 会迭代厘米取整和简化直到稳定,以保持重复规范化的一致性。历史版本行为应以其冻结代码为准。
334
+ - **MansionWorld 桌面物件**(v3 起读入):导出里有、并和原始 floor JSON 对上的都进来了;AI2-THOR 自带、导出时就没有尺寸的约 2.5 万件(笔记本电脑、盆栽、书……)没有来源尺寸,不编造,不摆。
335
+ - **不在支撑物顶面上的东西**(SAGE 带书架的书桌桌面、柜子里、搁板层上:底部离父物体包围盒顶面超过 5 cm)不摆也不作为固定件:答案格式规定 `on` 的 z 就是父物体的顶面,表示不了它们。SAGE 里约 37% 的带 place_id 的小物件属于这种情况。
336
+ - **支撑关系**:v3/v3.1 统计为 380,269 条源关系、216,025 条推断关系;v3.2 为 380,269 / 216,032。源关系与 bbox 推断都不等于已验证的物理稳定性。
337
+ - **边界**:IL3D_3dfront 在 v3 起用 3D-FRONT 的精确地面多边形(无效时退回地板凸包,`meta` 里记录用了哪种)。
338
+ - **房型标签**:3D-FRONT 自身有房型标错的噪声。
339
+ - **朝向**:InternScenes 存在源正面近乎竖直却仍参与水平 yaw 监督的已确认实例;全库影响比例尚未量化,详见 [known issues](../docs/known-issues.md)。
340
+ - **碰撞检查**:只在 bbox 级别,结果须对照 GT 参考线。
341
+ - **尚未支持**:对称物体 yaw 等价类的训练;把 keepout 也表示成固定件(现在仍是从地面多边形里挖掉)。
342
+ - **许可**:见第 3 节许可证表。训练出的模型只能用于内部非商业研究;不能公开权重,也不能对外提供服务。
code/fastfill/RELEASE_v3.2.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastFill v3.2 release
2
+
3
+ Verification: code repairs, full dataset build, independent scan and exact project QA replay passed. The local publication receipt records GitHub commit `5ab744d3de42f0ffc7eadd692cac90dd18d8e392` and Hugging Face commit `d264159e7095e4ce817d46c2fa0bd0cf5dc3d28d`. Repository consolidation preserves these release identifiers; see [versions](../docs/versions.md).
4
+
5
+ v3.2 is a separately rebuilt, constrained dataset derived from v3.1 (constraint probability 0.3, the same 18 selected sources). v3 and v3.1 remain immutable baselines for existing training jobs. Do not change a dataset in the middle of a run.
6
+
7
+ ## Changes
8
+
9
+ - Re-express tilted Structured3D, HSSD200 and MultiScan boxes as enclosing upright boxes using their rotated geometry.
10
+ - Propagate unknown horizontal fronts for 245 upright MultiScan objects with vertically annotated fronts; their pose and size are unchanged. Separately, 120 tilted MultiScan objects receive corrected envelopes.
11
+ - Recover InteriorGS objects that the upstream export left unassigned only when their source box center belongs to exactly one valid repaired room polygon; record recovery decisions and unresolved cases.
12
+ - Protect builds against empty/missing inputs, malformed room geometry and partial writes. The output directory must be new or empty; completed output is published by an atomic directory rename.
13
+ - Align support tolerance boundaries and singular/plural window handling; enforce API anchor requirements, keep soft keepouts soft, and bound HTTP request reads.
14
+ - Validate written-file hashes, training constraints and actual IR-to-message reproduction in QA; compare ablation metadata too.
15
+ - Preserve per-prompt generation limits during HF evaluation and adapter tokenizer/run provenance during merging. Download failures return a failing exit code.
16
+
17
+ The API now accepts only positive Content-Length values up to 1 MiB; invalid lengths return 400 and oversized requests return 413. Anchor requirements are checked on returned placements; violations return 422. Soft keepouts report satisfaction without altering the floor or adding unseen conditioning fields to the model prompt.
18
+
19
+ The text protocol and system prompt remain unchanged. Runtime validation changes can be used with existing weights. New geometry and recovered rooms mean v3.2 is not a constraint-only ablation of v3; compare models on the same versioned evaluation inputs.
20
+
21
+ ## Existing-run interpretation
22
+
23
+ The September 27 evidence identifies 21 retained erroneous boxes across the three adapters. Joining that complete list to the saved v3/v3.1 row index gives 15 boxes (including fixed context boxes) in 10 default-selected training rooms, plus one affected Structured3D test room. This contradicts the claim that only one training object is affected or all affected HSSD rows are excluded.
24
+
25
+ The old 124,834 / 124,836 figure measures the implemented out-of-bounds, support and ceiling checks. It is not a general data-purity rate and does not test raw box correctness or all collisions. The known issues do not by themselves justify discarding an ongoing run; the actual server configuration was not inspected here.
26
+
27
+ ## Reproduction
28
+
29
+ Use new writable directories for IR and data; preserve the original versions. The release records source IR hashes, code hashes, build arguments, and every output file hash in MANIFEST.json. Source geometry changes can change deduplication and connected split components; a release comparison must enumerate those changes before cross-version benchmark claims.
30
+
31
+ ```bash
32
+ python -m pytest -q -p no:cacheprovider fastfill/tests
33
+ python -m fastfill.merge_lora --base_model_path BASE --lora_path RUN/final --output_path MERGED
34
+ ```
35
+
36
+ Regression suite: 138 tests and 90 subtests passed, including the tiny real CPU merge, mocked HTTP handlers, source-envelope fixtures and existing synthetic self-checks. Combined line coverage of the 13 changed/new production modules: 90.8% (2,075 / 2,285 executable lines). This is not whole-repository coverage.
37
+
38
+ ## Dataset verification
39
+
40
+ The rebuild scanned 188,670 rooms across 18 sources. Four adapters were rerun for 32,125 IR rooms; the other 14 selected IR files match the v3.1 input hashes. Output: 144,150 train / 8,167 dev / 8,647 test. The default truthy-flag filter selects 124,843 training rows before token-length filtering or source weighting.
41
+
42
+ Compared with v3.1, 25 existing train rows and one test row changed; 10 train rows were added and none removed. Split membership is unchanged. Eight recovered InteriorGS rooms pass the default flag filter, while one existing HSSD room now fails it; the net default-selected increase is seven. No new dev/test source group or alias overlaps the old training split. Dev inputs are unchanged; compare old and new models on a common version of the corrected test input.
43
+
44
+ The independent scanner uses no project imports. It checked all 25 IR/processed JSONLs, 383,016 rows and 2,251,366,019 bytes. All 15 leakage intersections (five keys across three split pairs) are zero. All 98,808 train, 18,662 dev and 19,785 test constraints hold. Written schemas, reference closure, support cycles, finite numbers and manifest hashes passed. All 124,843 default-selected references pass the scanner's out-of-bounds/support/ceiling checks; this does not assert mesh-level correctness or absence of all collisions. Raw IR retains 90 invalid boundaries, 99 nonpositive sizes and 19 clockwise boundaries; the written schemas pass after preparation.
45
+
46
+ All 20 original v3/v3.1 files match their pre-build hashes. The release code snapshot matches all 41 implementation hashes in MANIFEST.json. The separate publisher's seven offline tests passed. Its publication receipt records status `published`; the later consolidation audit could not independently access the remote services.
47
+
48
+ The cached Qwen3-8B tokenizer (revision b968826d9c46dd6066d109eabc6255188de91218, offline only) checked all 36 added/modified SFT rows: 575–3,470 tokens including the answer end token, with none above the default 40,960-token limit. This is a targeted length check, not a full-corpus token census; no model weights were loaded.
49
+
50
+ Project QA reproduced all 160,964 saved SFT rows exactly from IR (system/user/assistant messages and metadata), with zero mandatory failures. This replay verifies persisted rows; it does not independently rerun the global deduplication and split-selection algorithm. The separate scanner and version comparison check the resulting splits and identities.
51
+
52
+ ## Subsequent audit
53
+
54
+ Request-read timeouts and InternScenes near-vertical semantic-front flags remain open issues; see [known issues](../docs/known-issues.md). The recorded geometry checks and release hashes do not establish that all defects are resolved.
code/fastfill/__init__.py ADDED
File without changes
code/fastfill/adapters/__init__.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source adapters: exported dataset files -> IR rooms (see fastfill/scene.py).
2
+
3
+ Each module in this package defines
4
+ SOURCE = "<name>" # output file name <name>.jsonl
5
+ def load(root) -> iterable # yields IR room dicts; root = download dir of fastfill.download
6
+
7
+ python -m fastfill.adapters --root /Volumes/harddisk/3D_Room_Collections --out /Volumes/harddisk/fastfill_ir [--only IL3D]
8
+ """
9
+ import argparse
10
+ import importlib
11
+ import json
12
+ import os
13
+ import pkgutil
14
+
15
+
16
+ def registry():
17
+ mods = {}
18
+ for m in pkgutil.iter_modules(__path__):
19
+ if m.name.startswith("_") or m.name == "unified":
20
+ continue
21
+ mod = importlib.import_module(f"{__name__}.{m.name}")
22
+ if hasattr(mod, "SOURCE") and hasattr(mod, "load"):
23
+ mods[mod.SOURCE] = mod
24
+ return mods
25
+
26
+
27
+ def main():
28
+ ap = argparse.ArgumentParser()
29
+ ap.add_argument("--root", required=True)
30
+ ap.add_argument("--out", required=True)
31
+ ap.add_argument("--only", nargs="*", default=None)
32
+ a = ap.parse_args()
33
+ os.makedirs(a.out, exist_ok=True)
34
+ for name, mod in sorted(registry().items()):
35
+ if a.only and name not in a.only:
36
+ continue
37
+ n = 0
38
+ tmp = f"{a.out}/{name}.jsonl.tmp"
39
+ try:
40
+ with open(tmp, "w") as f:
41
+ for room in mod.load(a.root):
42
+ f.write(json.dumps(room, ensure_ascii=False) + "\n")
43
+ n += 1
44
+ except FileNotFoundError as e: # dataset not downloaded (or deleted): keep any existing IR file
45
+ os.remove(tmp)
46
+ print(f"{name}: skipped, input not found: {e.filename}", flush=True)
47
+ continue
48
+ if n == 0: # an empty input file must not wipe a good IR file
49
+ os.remove(tmp)
50
+ print(f"{name}: skipped, 0 rooms read; existing IR kept", flush=True)
51
+ continue
52
+ os.replace(tmp, f"{a.out}/{name}.jsonl")
53
+ print(f"{name}: {n} rooms", flush=True)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
code/fastfill/adapters/__main__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from fastfill.adapters import main
2
+
3
+ main()
code/fastfill/adapters/ase.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aria Synthetic Environments (ASE_exported, 8,373 train scenes / 16,375 wall-ring rooms).
2
+
3
+ Export (kits/convert_ase.py): Z-up meters, scene-global; furniture_position = ATEK ts_world_object translation
4
+ = OBB center; furniture_size = object_dimensions full extents along local X/Y/Z; rotation = ZYX Euler of the
5
+ ATEK rotation matrix, z = yaw of local +X. Boundary = closed ring of make_wall endpoints (thickness 0);
6
+ the 53 open chains were closed by a guessed straight edge -> dropped here. Front = local -Y (beds touch the
7
+ wall with +Y). Category: original object_instances_to_classes string, else the ATEK category name of the
8
+ same instance (kept in meta). room_type in the export is inferred from furniture -> kept in meta only.
9
+ Limitation: GT exists only for instances seen in the selected ATEK frames (~40% of each scene's instances).
10
+ """
11
+ import os
12
+
13
+ from fastfill.adapters.unified import convert_room, iter_records
14
+
15
+ SOURCE = "ASE"
16
+
17
+
18
+ def _atek_name(f):
19
+ for rec in (f.get("source_fields") or {}).get("obb3_gt", {}).values():
20
+ if rec.get("category_names"):
21
+ return rec["category_names"][0]
22
+ return None
23
+
24
+
25
+ def load(root):
26
+ path = os.path.join(root, "BillLin66__3D_Room_Collections/ASE_exported/ase.jsonl")
27
+ for scene in iter_records(path):
28
+ sid = scene["scene_id"] # ase_<split>_<7-digit index>
29
+ for room in scene["rooms"]:
30
+ furn, n_atek = [], 0
31
+ for f in room["furniture"]:
32
+ if f["furniture_category"] is None:
33
+ name = _atek_name(f)
34
+ if name and name != "other":
35
+ f = {**f, "furniture_category": name}
36
+ n_atek += 1
37
+ furn.append(f)
38
+ open_chain = bool(room["room_boundary"]) and len(room["room_boundary"]) != len(room["source_fields"]["make_wall"])
39
+ r = {**room, "furniture": furn, "room_type": None,
40
+ "room_boundary": None if open_chain else room["room_boundary"]}
41
+ yield convert_room(r, source=SOURCE, uid=room["room_id"], group=f"ase:{sid[len('ase_'):]}",
42
+ subset=scene["source_subset"], boundary_type="polygon", center_z=True,
43
+ front_offset_deg=-90,
44
+ meta={"room_type_inferred": room["room_type"], "n_category_from_atek": n_atek,
45
+ "open_chain_dropped": open_chain, "front_known": True,
46
+ "objects_partial": True})
code/fastfill/adapters/hssd200.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HSSD-200 (XXXpilar/hssd_clean rebuild of hssd-hab): 168 designer scenes, 2,351 annotated regions.
2
+
3
+ Source (README / dataset_manifest.json): meters, Y-up, right-handed, scene-global. objects.csv gives the tight
4
+ OBB of every rigid instance: obb_center (box center), obb_half_extents (already scaled) along the asset .glb
5
+ axes, obb_rotation_wxyz (world <- glb). regions.csv gives the official region polygon in XZ, floor/ceiling height
6
+ and the human region label. Converted here to Z-up by (x, y, z) -> (x, -z, y), z measured from the region floor.
7
+
8
+ Object frame: the glb axis that maps to world up is the asset's up. Wall-contact statistics (front test on
9
+ beds/toilets/cabinets/wardrobes, also per template) show two authoring frames: up=+Y with front=+Z, and the
10
+ same frame turned -90 deg about X (up=-Z, front=+Y). Other up axes (33 objects: 24 standing books, 5 stools, 2
11
+ upside-down carpets ...) have no known front: yaw along a horizontal glb axis, front_known False (build shows them as
12
+ built-in fixed boxes); objects whose no axis is vertical (94: books, boxes, pillows) are tilted. Their upright
13
+ envelope retains the center and the projected front of the nearest recognized authoring frame; an unrecognized
14
+ frame keeps a geometric heading with front_known=False.
15
+ Articulated instances (3,626 cabinets, wardrobes, fridges, ...) and 14 rigid rows of templates without a box have no box
16
+ and no region in the rebuild; they are assigned to regions by their translation and boxed from their model (URDF, else
17
+ asset_catalog / .glb bounds; see _rebuild, tagged box_src). 3,290 are the articulated copy of a boxed rigid row at the
18
+ same place (_twin: 3,062 of the same template, 93 a sibling template of the same category, 135 of another category) ->
19
+ dropped, counted in meta; 336 are kept. Only rows with no model stay incomplete, so build rejects their room: 1
20
+ articulated (render path '.ao_config.json.glb', no URDF) and the 13 rigid rows of templates 3759 / 3760 / 5804 (absent
21
+ from the source, outside every region). is_architectural instances (doors, windows, stairs ...) are kept as "structure"
22
+ objects (never placed; a floor staircase rejects the room in build); boxless ones are dropped. Category 'unknown*'
23
+ (lexicon id 0) or empty is kept as the label 'unknown*' / 'unknown' (was: incomplete, rejecting 125 rooms): the box is
24
+ real, build shows it as a generic fixed box or ignores it when raised.
25
+ Boxes lying wholly below the region floor belong to a lower storey without a region at that XY (the source assigns by
26
+ XY alone) -> dropped, counted in meta.
27
+ Openings boxed from metadata sit off their mesh in the rebuild (half a height too high, some shifted along the wall);
28
+ their centre is recomputed from the opening .glb bounds (see _center).
29
+ """
30
+ import csv
31
+ import functools
32
+ import itertools
33
+ import json
34
+ import math
35
+ import os
36
+ import struct
37
+ import xml.etree.ElementTree as ET
38
+ from collections import defaultdict
39
+
40
+ import numpy as np
41
+ from shapely.geometry import MultiPoint, Point, Polygon
42
+
43
+ from fastfill.adapters.internscenes import tilted_bbox
44
+ from fastfill.adapters.unified import convert_room
45
+
46
+ SOURCE = "HSSD200"
47
+ T = np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]]) # Y-up -> Z-up
48
+ FRONT = {(1, 1): (2, 1), (2, -1): (1, 1)} # (up axis, sign) -> (front axis, sign) in glb frame
49
+
50
+
51
+ def _rot(q):
52
+ w, x, y, z = q
53
+ return np.array([[1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y)],
54
+ [2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x)],
55
+ [2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)]])
56
+
57
+
58
+ @functools.lru_cache(maxsize=None)
59
+ def _glb_bounds(path):
60
+ """(lo, hi) of a .glb in its asset frame: every mesh primitive's POSITION min/max through the node transforms
61
+ (corners of each primitive box, so exact without node rotation, as in all 841 opening .glbs). None if absent."""
62
+ if not os.path.isfile(path):
63
+ return None
64
+ with open(path, "rb") as fh:
65
+ fh.seek(12) # 12-byte header, then the JSON chunk (length, type)
66
+ g = json.loads(fh.read(struct.unpack("<I4s", fh.read(8))[0]))
67
+ pts = []
68
+
69
+ def walk(i, M):
70
+ nd = g["nodes"][i]
71
+ L = np.eye(4)
72
+ if "matrix" in nd:
73
+ L = np.array(nd["matrix"], float).reshape(4, 4).T # column-major
74
+ else:
75
+ x, y, z, w = nd.get("rotation", [0, 0, 0, 1])
76
+ L[:3, :3] = _rot([w, x, y, z]) * nd.get("scale", [1, 1, 1])
77
+ L[:3, 3] = nd.get("translation", [0, 0, 0])
78
+ M = M @ L
79
+ for p in g["meshes"][nd["mesh"]]["primitives"] if "mesh" in nd else []:
80
+ a = g["accessors"][p["attributes"]["POSITION"]]
81
+ pts.append(np.array(list(itertools.product(*zip(a["min"], a["max"])))) @ M[:3, :3].T + M[:3, 3])
82
+ for c in nd.get("children", []):
83
+ walk(c, M)
84
+
85
+ for i in g["scenes"][g.get("scene", 0)]["nodes"]:
86
+ walk(i, np.eye(4))
87
+ pts = np.vstack(pts)
88
+ return pts.min(0), pts.max(0)
89
+
90
+
91
+ def _center(r):
92
+ """Box centre of a boxed row in the source (Y-up) frame.
93
+
94
+ bbox_source 'metadata' rows (the 2,956 boxed openings: doors, windows, gates; no other category) were boxed by
95
+ the rebuild from the opening metadata as base-at-origin assets (asset_catalog local_bbox y in [0, H], README
96
+ convention 3), so obb_center = translation + R (s * (0, H/2, 0)). Their .glb meshes are not: most are centred on
97
+ the origin (door 204-0: y in [-1.1, 1.1]), some are not centred at all (window 3759-0: y in [-0.6, 0.9]; door
98
+ 3762-0: x in [-0.51, 2.37]). The box centre is translation + R (s * (lo + hi) / 2), [lo, hi] the mesh bounds
99
+ (r["mesh_bounds"], from _glb_bounds; s * (hi - lo) / 2 = obb_half_extents on all 2,956 rows within 2 mm).
100
+ Moved: every row by ~H/2 down from obb_center; vs. plain translation, 586 rows by > 5 cm vertically (3759-0
101
+ +0.15 m, 3760-x -0.10 m, 3756-x -0.32 m) and 3762-0 doors 0.93 m along the wall. Check against the stage walls
102
+ (no stage vertex inside the box shrunk 2 cm in-plane, +-0.3 m deep): doors 98.3% / windows 97.5% clear, vs 83% /
103
+ 75% for translation and 3% / 7% for obb_center; door bottoms p10..p90 +0.02 m above the region floor (438 of 467
104
+ within 5 cm, the rest in another storey's region). Without mesh_bounds (.glb missing) the translation is used,
105
+ counted in the room meta.
106
+ Mesh-sourced rows (all furniture, stairs, fences, the 26 mesh-boxed openings) keep obb_center: box already right."""
107
+ if r.get("bbox_source") != "metadata" or r.get("box_src"): # (rebuilt boxes: see _rebuild)
108
+ return json.loads(r["obb_center"])
109
+ t = np.array(json.loads(r["translation"]))
110
+ if r.get("mesh_bounds") is None:
111
+ return t
112
+ lo, hi = r["mesh_bounds"]
113
+ return t + _rot(json.loads(r["obb_rotation_wxyz"])) @ (np.array(json.loads(r["non_uniform_scale"])) * (lo + hi) / 2)
114
+
115
+
116
+ def _pose(o):
117
+ """URDF <origin xyz rpy> (rpy about fixed x, y, z) -> 4x4; identity if absent."""
118
+ M = np.eye(4)
119
+ if o is not None:
120
+ r, p, y = (float(v) for v in o.get("rpy", "0 0 0").split())
121
+ M[:3, :3] = _rot([math.cos(y / 2), 0, 0, math.sin(y / 2)]) @ _rot([math.cos(p / 2), 0, math.sin(p / 2), 0]) \
122
+ @ _rot([math.cos(r / 2), math.sin(r / 2), 0, 0])
123
+ M[:3, 3] = [float(v) for v in o.get("xyz", "0 0 0").split()]
124
+ return M
125
+
126
+
127
+ @functools.lru_cache(maxsize=None)
128
+ def _urdf_bounds(path):
129
+ """(lo, hi) of an articulated model in its URDF root frame with every joint at its origin (closed: 3,609 of the
130
+ 3,626 instances have all joint positions 0, the rest < 2e-5): each link's visual .glb bounds through the joint
131
+ chain. The links are Z-up exports and 1,508 of the 1,509 URDFs turn them Y-up in the root joint (rpy -90 deg about
132
+ x), so the root frame is the scene's Y-up frame with the model's origin. None if the URDF or a mesh is missing, or
133
+ the root joint does not turn link +Z to +Y (kitchenette0002: its 6 instances are yawed Y-up like their rigid copies,
134
+ so that model would lie on its back)."""
135
+ if not os.path.isfile(path):
136
+ return None
137
+ x = ET.parse(path).getroot()
138
+ up = {j.find("child").get("link"): (j.find("parent").get("link"), _pose(j.find("origin"))) for j in x.iter("joint")}
139
+ root = next(link.get("name") for link in x.iter("link") if link.get("name") not in up)
140
+ if not all(np.allclose(M[:3, 2], [0, 1, 0], atol=1e-3) for parent, M in up.values() if parent == root):
141
+ return None
142
+ pose = lambda link: pose(up[link][0]) @ up[link][1] if link in up else np.eye(4)
143
+ pts = []
144
+ for link in x.iter("link"):
145
+ for v in link.findall("visual"):
146
+ if v.find("geometry/mesh") is None:
147
+ continue
148
+ b = _glb_bounds(os.path.join(os.path.dirname(path), v.find("geometry/mesh").get("filename")))
149
+ if b is None:
150
+ return None
151
+ M = pose(link.get("name")) @ _pose(v.find("origin"))
152
+ pts.append(np.array(list(itertools.product(*zip(*b)))) @ M[:3, :3].T + M[:3, 3])
153
+ pts = np.vstack(pts)
154
+ return pts.min(0), pts.max(0)
155
+
156
+
157
+ def _rebuild(r, lo, hi, src):
158
+ """Box a boxless row in place from its model bounds [lo, hi] in the frame its rotation applies to: centre
159
+ translation + R (s * (lo + hi) / 2), half extents |s * (hi - lo)| / 2, box_src = src.
160
+ Articulated rows: the URDF root frame (_urdf_bounds). Their translation_origin says 'COM' but the translation is
161
+ the root origin (the root link has no mass): on the 1,480 unit-scale rows of up=+Y templates with a same-template
162
+ rigid copy within 10 cm, the rebuilt centre minus the copy's obb_center equals the two translations' difference
163
+ within 1 cm for 99.0%, the world half extents agree within 1 cm for 99.4%. For templates authored up=-Z the render
164
+ .glb (asset_catalog bounds) is not that frame: it is Z-up and centred, the URDF links are re-exported (Cabinet0004:
165
+ .glb z +-0.72, URDF base link z 0..1.45), so catalog bounds would lay their 467 yawed articulated rows on their
166
+ back; on 118 of them with an overlapping unit-scale rigid copy the URDF box has the copy's extents (p90 diff 0,
167
+ catalog p50 0.18 m) and centre (horizontal p50 2 cm; vertically the source places them 0.1-0.3 m off each other).
168
+ 26 articulated rows are in the render .glb frame (asset_catalog): 20 carry the rigid copy's up=-Z rotation (root +Y
169
+ not vertical) and 6 are kitchenette0002 (see _urdf_bounds); so are the rigid rows (asset_catalog, else the .glb).
170
+ Kept rebuilt boxes pass the wall-contact front test like source boxes (IR floor cabinets, wardrobes, dressers,
171
+ fridges, nightstands within 15 cm of a wall: back to the wall 136 of 141 vs 2,581 of 2,714); of the 326 in rooms,
172
+ 219 stand within 5 cm of the floor and 94 are above 0.5 m (wall units)."""
173
+ s = np.array(json.loads(r["non_uniform_scale"]))
174
+ c = np.array(json.loads(r["translation"])) + _rot(json.loads(r["obb_rotation_wxyz"])) @ (s * (lo + hi) / 2)
175
+ r.update(obb_center=json.dumps(c.tolist()), obb_half_extents=json.dumps((abs(s * (hi - lo)) / 2).tolist()),
176
+ box_src=src)
177
+
178
+
179
+ def _model(r, base, catalog):
180
+ """(lo, hi, src) of a boxless row's model in the frame its rotation applies to (see _rebuild), or None: the URDF
181
+ when the row's rotation keeps its root +Y up, else the asset_catalog bounds, else the render .glb."""
182
+ if r["urdf_path"] and abs(_rot(json.loads(r["obb_rotation_wxyz"]))[1, 1]) > 0.999:
183
+ b = _urdf_bounds(os.path.join(base, "meshes", r["urdf_path"]))
184
+ if b:
185
+ return (*b, "urdf")
186
+ b = catalog.get(r["template_name"])
187
+ if b:
188
+ return (*b, "catalog")
189
+ b = _glb_bounds(os.path.join(base, "meshes", r["render_asset"]))
190
+ return b and (*b, "glb")
191
+
192
+
193
+ def _box(r):
194
+ """(XZ footprint, y0, y1) of a boxed row's OBB in the source frame (footprint = hull of the 8 corners)."""
195
+ p = np.array(list(itertools.product((-1, 1), repeat=3))) * json.loads(r["obb_half_extents"])
196
+ p = p @ _rot(json.loads(r["obb_rotation_wxyz"])).T + _center(r)
197
+ return MultiPoint(p[:, [0, 2]].tolist()).convex_hull, p[:, 1].min(), p[:, 1].max()
198
+
199
+
200
+ def _twin(a, b, rel):
201
+ """True if boxed row b (from _box) is the piece of furniture that rebuilt box a enters again: they share over 30% of
202
+ the smaller one's volume (as build's overlapping_furniture test: two solid pieces cannot) and, by how b's row
203
+ relates to a's (rel, see _rel): 'model' (same template): always, the rigid row may be scaled down (8 rows at
204
+ 0.23-0.33 of the volume: Cullen Chest, Carla Wardrobe, ...); 'category' (same category): unless b is a small part
205
+ inside a (under a third of its volume); 'other' (b not architectural, of a category the source ships articulated
206
+ models of): only at similar size (volumes within 3x); None: never.
207
+ The articulated copy differs from its rigid row in scale (articulated rows are unit scale), pivot, translation
208
+ (same-size kitchen units 0.2-0.5 m apart: Oven 0.37 m) and often template (Raleigh Wide / Tall Dresser), then often
209
+ in category too ('Kitchen tall cabinet' wardrobe vs '... with oven' cabinet, Gretna Base Cabinet Double
210
+ kitchen_lower_cabinet vs Gretna Drawers cabinet, Melbourne vs Xena 3 Door Wardrobe). Of the 479 rebuilt boxes the
211
+ same-category test keeps, 150 share over 30% with a similar-size box of another category (3.1% of the rigid rows of
212
+ the articulated categories); 'other' takes 135 (rigid rows 2.5%). The 13 left share with a plant, lamp, TV, toilet,
213
+ cooker hood, basket, toy bin or child's bed. In build output the placed rebuilt objects that share over 30% with a
214
+ similar-size placed object of another category go from 135 of 360 to 9 of 227 (source objects 173 of 5,911). Also
215
+ taken: a desk and two islands whose partner is a chair / stools / a wine cart tucked under them (seat and cart have
216
+ articulated models: storage benches, trolleys)."""
217
+ if rel is None:
218
+ return False
219
+ (fa, a0, a1), (fb, b0, b1) = a, b
220
+ va, vb = fa.area * (a1 - a0), fb.area * (b1 - b0)
221
+ return fa.intersection(fb).area * max(0.0, min(a1, b1) - max(a0, b0)) > 0.3 * min(va, vb) and \
222
+ (rel == "model" or vb >= va / 3 and (rel == "category" or vb <= 3 * va))
223
+
224
+
225
+ def _rel(r, o, kinds):
226
+ """How boxed row o relates to boxless row r, for _twin (kinds: the categories of articulated rows). Not the asset
227
+ name: collections share it ('Champagne' bed and nightstand, 'Kitchen Furniture Collection' modules)."""
228
+ if o["template_name"] == r["template_name"]:
229
+ return "model"
230
+ if o["category"] == r["category"]:
231
+ return "category"
232
+ return "other" if o["category"] in kinds and o["is_architectural"] != "True" else None
233
+
234
+
235
+ def _furniture(r, floor):
236
+ """objects.csv row -> unified-schema furniture dict (Z-up, center z above the region floor)."""
237
+ cat = r["category"] or "unknown" # lexicon id 0 ('unknown', 'unknown_wall') or none: no name
238
+ f = {"furniture_category": cat, "furniture_instance_id": f"{r['scene_id']}:{r['instance_id']}",
239
+ "furniture_position": None, "furniture_rotation": None, "furniture_size": None}
240
+ if not r["obb_center"]:
241
+ return f # articulated / missing template: no box
242
+ C = T @ _rot(json.loads(r["obb_rotation_wxyz"]))
243
+ ext = 2 * np.array(json.loads(r["obb_half_extents"]))
244
+ c = T @ np.array(_center(r))
245
+ k = int(np.argmax(abs(C[2])))
246
+ f["furniture_position"] = {"x": c[0], "y": c[1], "z": c[2] - floor}
247
+ fr = FRONT.get((k, int(np.sign(C[2, k]))))
248
+ if fr is None: # unknown authoring frame: the box is known, its front not
249
+ fr, f["front_known"] = (min({0, 1, 2} - {k}), 1), False
250
+ if abs(C[2, k]) < 0.999: # no vertical axis: tilted
251
+ a, s = fr
252
+ order = [a, 3 - a - k, k]
253
+ R = C[:, order] * np.array([s, 1, 1])
254
+ size, yaw, _, _ = tilted_bbox(R, ext[order])
255
+ return {**f, "furniture_rotation": {"x": None, "y": None, "z": yaw},
256
+ "furniture_size": dict(zip(("width", "length", "height"), size)),
257
+ "rot_wxyz_yup": json.loads(r["obb_rotation_wxyz"])}
258
+ a, s = fr
259
+ o = 3 - a - k
260
+ d = s * C[:, a]
261
+ f["furniture_rotation"] = {"x": 0, "y": 0, "z": math.degrees(math.atan2(d[1], d[0]))}
262
+ f["furniture_size"] = {"width": float(ext[a]), "length": float(ext[o]), "height": float(ext[k])}
263
+ return f
264
+
265
+
266
+ def load(root):
267
+ base = os.path.join(root, "XXXpilar__hssd_clean")
268
+ regions = {}
269
+ for g in csv.DictReader(open(os.path.join(base, "regions.csv"))):
270
+ g["poly"] = [[x, -z] for x, z in json.loads(g["poly_loop_xz"])]
271
+ regions[(g["scene_id"], g["region_id"])] = g
272
+ by_scene = defaultdict(list)
273
+ for key, g in regions.items():
274
+ by_scene[key[0]].append((key, Polygon(g["poly"]), float(g["floor_height"]), float(g["ceiling_height"])))
275
+
276
+ bounds = {k["template_name"]: (np.array(json.loads(k["local_bbox_min"])), np.array(json.loads(k["local_bbox_max"])))
277
+ for k in csv.DictReader(open(os.path.join(base, "asset_catalog.csv"))) if k["local_bbox_min"]}
278
+ rows, boxed = list(csv.DictReader(open(os.path.join(base, "objects.csv")))), defaultdict(list)
279
+ kinds = {r["category"] for r in rows if r["instance_kind"] == "articulated"}
280
+ for r in rows:
281
+ if r["bbox_source"] == "metadata" and r["obb_center"]:
282
+ r["mesh_bounds"] = _glb_bounds(os.path.join(base, "meshes", r["render_asset"]))
283
+ if r["obb_center"]:
284
+ boxed[r["scene_id"]].append((_box(r), r))
285
+ furn, n_arch, n_below, n_nob, n_twin = defaultdict(list), *(defaultdict(int) for _ in range(4))
286
+ for r in rows:
287
+ boxless, twin = not r["obb_center"], False
288
+ if boxless and (m := _model(r, base, bounds)):
289
+ _rebuild(r, *m)
290
+ a = _box(r)
291
+ twin = any(_twin(a, b, _rel(r, o, kinds)) for b, o in boxed[r["scene_id"]])
292
+ if r["region_source"] in ("inside", "nearest"):
293
+ keys = [(r["scene_id"], r["region_id"])]
294
+ # the source assigns by XY only when one polygon matches: boxes wholly below this region's
295
+ # floor stand on a lower storey that has no region there -> not in this room (aabb shifted like _center)
296
+ if r["obb_center"] and json.loads(r["aabb_max"])[1] - (json.loads(r["obb_center"])[1] - _center(r)[1]) \
297
+ < float(regions[keys[0]]["floor_height"]) - 0.05:
298
+ n_below[keys[0]] += 1
299
+ continue
300
+ elif boxless: # no region in the source: locate by translation
301
+ x, y, z = json.loads(r["translation"])
302
+ keys = [k for k, p, fl, ce in by_scene[r["scene_id"]] if p.contains(Point(x, -z)) and fl - 0.3 <= y <= ce]
303
+ else:
304
+ continue # outdoor props outside every region
305
+ for key in keys:
306
+ if twin: # its rigid copy carries the box already
307
+ n_twin[key] += 1
308
+ continue
309
+ n_nob[key] += "mesh_bounds" in r and r["mesh_bounds"] is None
310
+ f = _furniture(r, float(regions[key]["floor_height"]))
311
+ if r["is_architectural"] == "True":
312
+ # kept as "structure" (never placed) so build sees floor stairs; without a box it cannot
313
+ # be checked and would read as an incomplete object: dropped and counted, as before
314
+ if f["furniture_rotation"] is None:
315
+ n_arch[key] += 1
316
+ continue
317
+ f["structure"] = True
318
+ f["box_src"] = r.get("box_src")
319
+ furn[key].append(f)
320
+
321
+ for key, g in regions.items():
322
+ room = {"room_id": f"{key[0]}:{key[1]}", "room_type": g["room_type"], "room_boundary": g["poly"],
323
+ "room_height": float(g["extrusion_height"]), "furniture": furn[key]}
324
+ yield convert_room(room, source=SOURCE, uid=f"{SOURCE}:{key[0]}:{key[1]}", group=f"hssd:{key[0]}",
325
+ boundary_type="polygon", center_z=True,
326
+ extra=lambda f: {**({"rot_wxyz_yup": f["rot_wxyz_yup"]} if "rot_wxyz_yup" in f else {}),
327
+ **({"structure": True} if f.get("structure") else {}),
328
+ **({"box_src": f["box_src"]} if f.get("box_src") else {}),
329
+ **({"front_known": False} if f.get("front_known") is False else {})},
330
+ meta={"region_name": g["region_name"], "floor_height": float(g["floor_height"]),
331
+ "n_architectural_dropped": n_arch[key], "n_below_floor_dropped": n_below[key],
332
+ "n_articulated_twin_dropped": n_twin[key],
333
+ "n_opening_no_mesh_bounds": n_nob[key], "front_known": True})
334
+
335
+
336
+ if __name__ == "__main__":
337
+ # glb frame turned -90 deg about X (up=-Z), yawed 90 deg in the scene: front must point to world +Y (Z-up)
338
+ q_x = [math.cos(math.pi / 4), math.sin(math.pi / 4), 0, 0] # Rx(+90): glb -Z -> +Y up
339
+ R = _rot([math.cos(math.pi / 4), 0, math.sin(math.pi / 4), 0]) @ _rot(q_x) # then yaw 90 about +Y
340
+ w = math.sqrt(1 + np.trace(R)) / 2
341
+ q = [w, (R[2, 1] - R[1, 2]) / (4 * w), (R[0, 2] - R[2, 0]) / (4 * w), (R[1, 0] - R[0, 1]) / (4 * w)]
342
+ row = {"category": "toilet", "scene_id": "s", "instance_id": "0", "obb_center": "[1, 0.4, -2]",
343
+ "obb_half_extents": "[0.2, 0.35, 0.4]", "obb_rotation_wxyz": json.dumps(q)}
344
+ f = _furniture(row, 0.0)
345
+ # canonical front +Z yawed 90 about +Y -> Habitat +X -> Z-up +X; sizes: front 0.7, side 0.4, up 0.8
346
+ assert abs(f["furniture_rotation"]["z"]) < 1e-6, f
347
+ assert np.allclose([f["furniture_size"][k] for k in ("width", "length", "height")], [0.7, 0.4, 0.8]), f
348
+ assert np.allclose([f["furniture_position"][k] for k in "xyz"], [1, 2, 0.4]), f
349
+ # metadata-boxed opening: obb_center is one half height too high, translation is the true centre
350
+ door = {"category": "door", "scene_id": "s", "instance_id": "1", "bbox_source": "metadata",
351
+ "obb_center": "[1, 2.2, -2]", "translation": "[1, 1.1, -2]", "obb_half_extents": "[0.5, 1.1, 0.1]",
352
+ "obb_rotation_wxyz": "[1, 0, 0, 0]"}
353
+ f = _furniture(door, 0.0)
354
+ assert np.allclose([f["furniture_position"][k] for k in "xyz"], [1, 2, 1.1]) and f["furniture_size"]["height"] == 2.2, f
355
+ # ... unless its mesh bounds are known: mesh x in [-0.5, 2.5] (centre +1.0), y in [-0.6, 1.6] (centre +0.5),
356
+ # mirrored in x (s = -1) and yawed 90 deg about +Y: offset R (s * c) = Ry90 (-1, 0.5, 0) = (0, 0.5, 1) Y-up
357
+ door.update(mesh_bounds=(np.array([-0.5, -0.6, -0.1]), np.array([2.5, 1.6, 0.1])), non_uniform_scale="[-1, 1, 1]",
358
+ obb_half_extents="[1.5, 1.1, 0.1]", obb_rotation_wxyz=json.dumps([math.cos(math.pi / 4), 0,
359
+ math.sin(math.pi / 4), 0]))
360
+ f = _furniture(door, 0.0)
361
+ assert np.allclose([f["furniture_position"][k] for k in "xyz"], [1, 1, 1.6]), f # (1, 1.6, -1) Y-up
362
+ # _glb_bounds: minimal .glb, one node translated (+1, 0, 0) and scaled 2 in y over a unit-cube accessor
363
+ import shutil
364
+ import tempfile
365
+ tmp = tempfile.mkdtemp()
366
+ js = json.dumps({"scenes": [{"nodes": [0]}], "nodes": [{"mesh": 0, "translation": [1, 0, 0], "scale": [1, 2, 1]}],
367
+ "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}]}],
368
+ "accessors": [{"min": [-0.5, 0, -0.5], "max": [0.5, 1, 0.5]}]}).encode()
369
+ with open(os.path.join(tmp, "body.glb"), "wb") as fh:
370
+ fh.write(struct.pack("<4sII", b"glTF", 2, 20 + len(js)) + struct.pack("<I4s", len(js), b"JSON") + js)
371
+ lo, hi = _glb_bounds(os.path.join(tmp, "body.glb"))
372
+ assert np.allclose(lo, [0.5, 0, -0.5]) and np.allclose(hi, [1.5, 2, 0.5]), (lo, hi)
373
+ # _urdf_bounds: root joint Rx(-90) turns the Z-up link frame Y-up, (x, y, z) -> (x, z, -y): base link x 0.5..1.5,
374
+ # y -0.5..0.5, z -2..0; a child link on a joint 1 up the base's +Z = root +Y: y 0.5..1.5. Union y -0.5..1.5
375
+ urdf = lambda rpy, mesh: f"""<robot><link name="root"/><joint name="r" type="fixed">
376
+ <origin rpy="{rpy}" xyz="0 0 0"/><parent link="root"/><child link="base"/></joint><link name="base"><visual>
377
+ <origin xyz="0 0 0" rpy="0 0 0"/><geometry><mesh filename="{mesh}"/></geometry></visual><collision><geometry>
378
+ <box size="1 1 1"/></geometry></collision></link><joint name="d" type="revolute"><origin xyz="0 0 1"/>
379
+ <parent link="base"/><child link="door"/></joint><link name="door"><visual><geometry>
380
+ <mesh filename="body.glb"/></geometry></visual></link></robot>"""
381
+ for name, rpy, mesh in (("ok", -math.pi / 2, "body.glb"), ("flat", 0, "body.glb"), ("miss", -math.pi / 2, "x.glb")):
382
+ with open(os.path.join(tmp, name + ".urdf"), "w") as fh:
383
+ fh.write(urdf(f"{rpy} 0 0", mesh))
384
+ lo, hi = _urdf_bounds(os.path.join(tmp, "ok.urdf"))
385
+ assert np.allclose(lo, [0.5, -0.5, -2]) and np.allclose(hi, [1.5, 1.5, 0]), (lo, hi)
386
+ assert _urdf_bounds(os.path.join(tmp, "flat.urdf")) is None and _urdf_bounds(os.path.join(tmp, "miss.urdf")) is None
387
+ shutil.rmtree(tmp)
388
+ # _rebuild: articulated cabinet, origin at its bottom centre, yawed 90 deg about +Y (front +Z -> +X), box from the
389
+ # model bounds; _center keeps the rebuilt centre even for a 'metadata' row; _furniture then as for rigid rows
390
+ y90 = json.dumps([math.cos(math.pi / 4), 0, math.sin(math.pi / 4), 0])
391
+ cab = {"category": "", "scene_id": "s", "instance_id": "2", "bbox_source": "metadata", "obb_center": "",
392
+ "translation": "[1, 0, -2]", "obb_rotation_wxyz": y90, "non_uniform_scale": "[1, 1, 1]"}
393
+ _rebuild(cab, np.array([-0.3, 0, -0.2]), np.array([0.3, 0.9, 0.2]), "urdf")
394
+ assert np.allclose(_center(cab), [1, 0.45, -2]) and cab["box_src"] == "urdf", cab
395
+ f = _furniture(cab, 0.0)
396
+ assert f["furniture_category"] == "unknown" and abs(f["furniture_rotation"]["z"]) < 1e-6, f # '' -> 'unknown'
397
+ assert np.allclose([f["furniture_size"][k] for k in ("width", "length", "height")], [0.4, 0.6, 0.9]), f
398
+ assert np.allclose([f["furniture_position"][k] for k in "xyz"], [1, 2, 0.45]), f
399
+ # unknown authoring frame (glb +X up): box kept, yaw along glb +Y (-> Y-up -X -> Z-up yaw 180), front_known False
400
+ book = {**row, "category": "unknown_wall", "obb_rotation_wxyz": json.dumps([math.cos(math.pi / 4), 0, 0,
401
+ math.sin(math.pi / 4)])}
402
+ f = _furniture(book, 0.0)
403
+ assert f["front_known"] is False and f["furniture_category"] == "unknown_wall", f
404
+ assert abs(f["furniture_rotation"]["z"] % 360 - 180) < 1e-6, f
405
+ assert np.allclose([f["furniture_size"][k] for k in ("width", "length", "height")], [0.7, 0.8, 0.4]), f
406
+ # _twin: a same-size unit 0.37 m off (38% shared) is the same piece; a neighbour touching it or a small part
407
+ # inside it is not, unless it is the same model (scaled-down rigid row); across categories only at similar size
408
+ box = lambda x, h: _box({"obb_center": json.dumps([x, h, 0]), "obb_half_extents": json.dumps([0.3, h, 0.3]),
409
+ "obb_rotation_wxyz": "[1, 0, 0, 0]"})
410
+ small = _box({"obb_center": "[0, 0.1, 0]", "obb_half_extents": "[0.1, 0.1, 0.1]",
411
+ "obb_rotation_wxyz": "[1, 0, 0, 0]"})
412
+ assert _twin(box(0, 0.45), box(0.37, 0.45), "category") and not _twin(box(0, 0.45), box(0.6, 0.45), "category")
413
+ assert not _twin(box(0, 0.45), small, "category") and _twin(small, box(0, 0.45), "category")
414
+ assert _twin(box(0, 0.45), small, "model") and _twin(box(0, 0.45), box(0.37, 0.45), "other")
415
+ assert not _twin(box(0, 0.45), small, "other") and not _twin(small, box(0, 0.45), "other")
416
+ assert not _twin(box(0, 0.45), box(0, 0.45), None)
417
+ # _rel: same model > same category > another category with articulated models (not architectural) > none
418
+ cab = {"template_name": "t1", "asset_name": "Gretna Base", "category": "kitchen_lower_cabinet"}
419
+ rel = lambda **o: _rel(cab, {**cab, "template_name": "t2", "asset_name": "", "is_architectural": "False", **o},
420
+ {"cabinet", "kitchen_lower_cabinet"})
421
+ assert rel(template_name="t1") == "model" and rel(asset_name="Gretna Base") == "category"
422
+ assert rel(category="cabinet") == "other" and rel(category="cabinet", asset_name="Gretna Base") == "other"
423
+ assert rel(category="plant") is None and rel(category="cabinet", is_architectural="True") is None
424
+ print("hssd200.py self-check ok")
code/fastfill/adapters/il3d_3dfront.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IL3D, 3D-FRONT subset (20,274 designer rooms): the canonical copy of 3D-FRONT rooms.
2
+
3
+ Verified conventions (scratch checks against IL3D's own USDZ meshes and the 3D-FRONT export):
4
+ - position = the 3D-FUTURE asset origin (IL3D copies the raw 3D-FRONT `pos`); every sampled mesh has its origin
5
+ at the bottom center (y_min = 0, x/z centered), so it is the IR bottom center. XY are room-local (IL3D moved each
6
+ room so its own floor mesh AABB starts at 0).
7
+ - size: the layout `bbox` is NOT usable as-is: for int-scale placements it is [x, z, y] of the mesh, otherwise
8
+ [x, y, z] unscaled, and the IL3D export reads it as [w, h, l] for all. assets.json meta_data width/length/height
9
+ equal the mesh extents along x / y(up) / z for all sampled assets, and the official USD script applies `scale` in
10
+ the asset frame, so the placed local extents are meta x |scale|.
11
+ - rotation: the export's furniture_rotation.z = yaw of mesh +X (from the full rotateXYZ matrix; tilted -> x/y null).
12
+ 3D-FUTURE meshes face +Z (y-up) = IR local -Y, hence front_offset_deg = -90. Only x-mirrors (scale x < 0) occur,
13
+ which do not flip the front.
14
+ - tilted placements (76, x/y null; 2 of them also z null): the export keeps the full world-from-local Z-up matrix
15
+ (source_fields.rotation_matrix_zup; = the raw 3D-FRONT quaternion within 0.014 for all 76, IL3D rounds to whole
16
+ degrees), local X / Y / Z = mesh x / -z / y(up) = placed_size's width / length / height, origin = bottom centre.
17
+ `untilt` turns them into upright boxes by the internscenes.canon rules (see there): 44 (tilt p10/p50/p90 7/11/31
18
+ deg) become the yaw-aligned bounding box of their 8 corners (flagged tilted, tilt_deg), 32 (22 lying on a side, 10
19
+ tilted <= 3 deg) the exact box with permuted sizes; the 2 z-null ones (a 1 mm beam) no longer make 2 rooms incomplete.
20
+ - rooms: IL3D groups a house's objects by room TYPE, so 1,300 IL3D "rooms" merge objects of 2-10 source rooms
21
+ (e.g. three bedrooms in one), and its single floor rectangle may belong to yet another room. Every IL3D object is
22
+ matched to its 3D-FRONT export placement (same asset id and position under one per-room frame shift), which gives
23
+ its true source room; each IL3D room is split into one IR room per source room.
24
+ - boundary / height: the exact floor polygon of the source room: the union of its Floor-mesh triangles from the raw
25
+ house JSON (data/3D-FRONT/<house>.json, same [x, y, z] -> [x, -z] frame as the export), moved into IL3D's frame by
26
+ the shift, boundary_type "polygon". The export's room_boundary is only the convex hull of those vertices (its README:
27
+ "not the exact floor plan"). Its area is within 5% of the source room size for 13,496 of the 13,859 rooms that state
28
+ one (the hull: 8,085). Gaps and spikes narrower than SPIKE_M (seams between meshes, zero-width slivers of
29
+ degenerate triangles, up to 12 cm long) are closed / cut off, pieces and holes < ARTEFACT_M2 dropped; a floor that
30
+ is still several pieces (21 rooms), has a real hole (20, stair voids), or whose ring is invalid as is or on the
31
+ model's cm grid (`cm_valid`, 4) keeps the export hull, boundary_type "hull" (meta.boundary_from says which).
32
+ Height: the export's floor-to-ceiling height (IL3D's supplemented height when the export has none and the room is
33
+ IL3D's own floor room).
34
+ - anchors: assets.json placement flags are per ASSET, so they are used only when a single flag agrees with z.
35
+ - doors / windows: the 3D-FRONT export lists them per source room (room["doors"] / ["windows"]: position = opening
36
+ centre, width, height, sill_height (windows), profile = the door's floor footprint [[x, y] x4] incl. the frame
37
+ depth, or the window's vertical quad [[x, y, z] x4]); emitted as "structure" boxes in IL3D's frame (export minus
38
+ the frame shift): local X along the wall = width (the profile side closest to it gives the wall direction), depth =
39
+ door footprint area / width, 0.10 m for windows, bottom = sill_height or centre - height / 2. The export lists a
40
+ door on only one of the two rooms it joins (1 of 5,999 door positions in the first 1,500 houses is on two lists),
41
+ so a door listed on another room of the house is added when it opens into this one (`on_edge`, tagged listed_on):
42
+ 10,768 doors, rooms with a door 7,470 -> 13,465 of 21,832; doors > 0.3 m inside the boundary 5,165 -> 19.
43
+ The export splits one door / bay window into several mesh pieces (its README: "the entry count is not the number
44
+ of real doors/windows"): pieces lower than MIN_H (sill slabs) are dropped, and a piece whose footprint is >= half
45
+ covered by a bigger piece of the same kind is merged into it (dropped), so each opening is shown once.
46
+ """
47
+ import collections
48
+ import json
49
+ import math
50
+ import os
51
+
52
+ import numpy as np
53
+ from shapely.geometry import LinearRing, Polygon
54
+ from shapely.ops import unary_union
55
+
56
+ from fastfill.adapters.internscenes import tilted_bbox, upright
57
+ from fastfill.adapters.unified import _num, convert_room, iter_records
58
+ from fastfill.scene import clean_boundary, footprint
59
+
60
+ OPENING_T = 0.10
61
+ MIN_H = 0.15 # = build.FLAT_Z: a lower piece is a sill slab, not an opening
62
+ ARTEFACT_M2 = 0.01 # floor pieces / holes smaller than this are triangulation slivers (seen: <= 0.0005 m2)
63
+ SPIKE_M = 0.02 # narrower gaps / spikes (mesh seams, degenerate triangles) do not survive the model's cm grid
64
+ DOOR_TOL = 0.05 # a door footprint ends within 5 cm of the floor edges it opens onto
65
+ LEVEL_TOL = 0.3 # own doors start at the room's floor (98% exactly, else <= 0.2 m steps); storeys are >2 m apart
66
+
67
+ SOURCE = "IL3D_3dfront"
68
+ IL3D = "imChuling__3D_Room_Collections/IL3D_exported"
69
+ F3D = "BillLin66__3D_Room_Collections/3DFront_exported/3dfront.jsonl"
70
+ HOUSES = "BillLin66__3D_Room_Collections/3DFront_exported/data/3D-FRONT"
71
+
72
+
73
+ def load_assets(root):
74
+ with open(os.path.join(root, IL3D, "IL3D/assets.json")) as f:
75
+ return {a["model_id"]: a for a in json.load(f)}
76
+
77
+
78
+ def placed_size(f, asset):
79
+ """Placed extents along mesh x, z, y = IR local X, Y, Z (before the front offset); None if unknown."""
80
+ m = (asset or {}).get("meta_data") or {}
81
+ s = f["source_fields"]["layout_object"].get("scale")
82
+ if not (s and len(s) == 3 and all(isinstance(m.get(k), (int, float)) for k in ("width", "length", "height"))):
83
+ return None
84
+ return {"width": m["width"] * abs(s[0]), "length": m["height"] * abs(s[2]), "height": m["length"] * abs(s[1])}
85
+
86
+
87
+ def flag_anchor(f):
88
+ fl = {k for k, v in (f.get("placement_flags") or {}).items() if v}
89
+ z = (f.get("furniture_position") or {}).get("z")
90
+ if z is None or len(fl) != 1:
91
+ return None
92
+ if fl == {"on_floor"} and z < 0.05:
93
+ return "floor"
94
+ if fl == {"on_ceiling"} and z > 1.0:
95
+ return "ceiling"
96
+ if fl == {"on_wall"} and z >= 0.05:
97
+ return "wall"
98
+ return None
99
+
100
+
101
+ def il3d_extra(f):
102
+ if f.get("structure"):
103
+ return {"structure": True, **({"listed_on": f["listed_on"]} if f.get("listed_on") else {})}
104
+ return {"anchor": flag_anchor(f), "desc": f.get("description"),
105
+ **({"tilt_deg": f["tilt_deg"]} if "tilt_deg" in f else {})}
106
+
107
+
108
+ def untilt(f):
109
+ """Tilted placement (rotation x/y null) -> the same dict as an upright box (module doc for the matrix): a local axis
110
+ within internscenes.TILT_TOL_DEG of vertical -> the exact box with permuted sizes, rotation {0, 0, yaw}; otherwise
111
+ the yaw-aligned bounding box of its 8 corners, rotation {None, None, yaw} (convert_room flags it tilted) and
112
+ tilt_deg. The box centre is kept; position = the new box's bottom centre."""
113
+ r, p, s = f.get("furniture_rotation") or {}, f.get("furniture_position"), f.get("furniture_size")
114
+ R = f["source_fields"].get("rotation_matrix_zup")
115
+ if (r.get("x") == 0 and r.get("y") == 0) or not (p and all(_num(p.get(k)) for k in "xyz") and s and R):
116
+ return f
117
+ R, S = np.asarray(R, dtype=float), [s["width"], s["length"], s["height"]]
118
+ c = np.array([p["x"], p["y"], p["z"]]) + R[:, 2] * S[2] / 2
119
+ up = upright(R, S)
120
+ if up:
121
+ (w, l, h), yaw, _ = up
122
+ rot, extra = {"x": 0.0, "y": 0.0, "z": yaw}, {}
123
+ else:
124
+ (w, l, h), yaw, _, tilt = tilted_bbox(R, S)
125
+ rot, extra = {"x": None, "y": None, "z": yaw}, {"tilt_deg": tilt}
126
+ return {**f, "furniture_position": {"x": float(c[0]), "y": float(c[1]), "z": float(c[2]) - h / 2},
127
+ "furniture_rotation": rot, "furniture_size": {"width": w, "length": l, "height": h}, **extra}
128
+
129
+
130
+ def export_rooms(root):
131
+ """house uid -> [(source room id, hull in house frame, height, [(jid, x, y), ...], (doors, windows))]."""
132
+ out = collections.defaultdict(list)
133
+ for line in open(os.path.join(root, F3D)):
134
+ d = json.loads(line)
135
+ r = d["room"]
136
+ pts = [(f["source_fields"]["jid"], f["furniture_position"]["x"], f["furniture_position"]["y"])
137
+ for f in r["furniture"] if f["source_fields"].get("jid") and f["furniture_position"]]
138
+ out[d["house_uid"]].append((d["provenance"]["source_room_id"], r["room_boundary"], r["room_height"], pts,
139
+ (r.get("doors") or [], r.get("windows") or [])))
140
+ return out
141
+
142
+
143
+ def floor_polygon(tris):
144
+ """Floor triangles [(x, y) x3] -> the exact floor ring [[x, y], ...], or None unless it is one hole-free polygon."""
145
+ u = unary_union([t for t in map(Polygon, tris) if t.area > 0])
146
+ e = SPIKE_M / 2 # mitre closing then opening: exact except for features narrower than SPIKE_M
147
+ u = u.buffer(e, join_style="mitre").buffer(-2 * e, join_style="mitre").buffer(e, join_style="mitre")
148
+ parts = [g for g in getattr(u, "geoms", [u]) if g.area >= ARTEFACT_M2]
149
+ if len(parts) != 1 or any(Polygon(r).area >= ARTEFACT_M2 for r in parts[0].interiors):
150
+ return None
151
+ ring = [[round(x, 6), round(y, 6)] for x, y in Polygon(parts[0].exterior).simplify(1e-4).exterior.coords[:-1]]
152
+ return ring if len(ring) >= 3 and Polygon(ring).is_valid else None
153
+
154
+
155
+ def cm_valid(b):
156
+ """The boundary as the model sees it (convert_room's min-corner frame, then scene.clean_boundary) is valid."""
157
+ x0, y0 = min(p[0] for p in b), min(p[1] for p in b)
158
+ return clean_boundary([[x - x0, y - y0] for x, y in b]) is not None
159
+
160
+
161
+ def floor_polygons(path):
162
+ """Raw 3D-FRONT house JSON -> {source room id (instanceid): (floor_polygon of its Floor meshes, floor z)}, house
163
+ frame; (None, None) without Floor meshes."""
164
+ with open(path) as f:
165
+ h = json.load(f)
166
+ mesh = {m.get("uid"): m for m in h.get("mesh") or []}
167
+ out = {}
168
+ for r in (h.get("scene") or {}).get("room") or []:
169
+ tris, zs = [], []
170
+ for c in r.get("children") or []:
171
+ m = mesh.get(c.get("ref"))
172
+ if m and m.get("type") == "Floor": # mesh children carry identity transforms (as the export assumes)
173
+ v, fc = m["xyz"], m["faces"]
174
+ p = [(v[i], -v[i + 2]) for i in range(0, len(v) - 2, 3)]
175
+ tris += [(p[fc[i]], p[fc[i + 1]], p[fc[i + 2]]) for i in range(0, len(fc) - 2, 3)]
176
+ zs += v[1::3]
177
+ out[r.get("instanceid")] = (floor_polygon(tris) if tris else None, min(zs) if zs else None)
178
+ return out
179
+
180
+
181
+ def on_edge(door, ring, floor_z):
182
+ """True if the door starts at this room's floor level (not another storey of the house) and its footprint runs
183
+ along the room's floor edge for >= half its width, i.e. it opens into the room. On 1,571 sampled rooms 877 of 881
184
+ own doors pass; other rooms' doors cover either >= 0.9 of their width (the far side of the same wall) or <= 0.2
185
+ (a corner touch)."""
186
+ pr, p, h = [q[:2] for q in door.get("profile") or []], door.get("position") or {}, door.get("height")
187
+ if len(pr) < 3 or not (_num(door.get("width")) and _num(p.get("z")) and _num(h) and _num(floor_z)):
188
+ return False
189
+ if abs(p["z"] - h / 2 - floor_z) > LEVEL_TOL:
190
+ return False
191
+ fp = Polygon(pr).buffer(DOOR_TOL, join_style="mitre")
192
+ return LinearRing(ring).intersection(fp).length >= 0.5 * door["width"]
193
+
194
+
195
+ def openings(doors_windows, shift, prefix):
196
+ """Export doors / windows of one source room -> (structure furniture dicts in IL3D's frame, n_skipped).
197
+ rotation z = wall direction + 90 so that convert_room(front_offset_deg=-90) gives yaw = wall direction and
198
+ size [width, depth, height]."""
199
+ out, skipped = [], 0
200
+ for kind, items in zip(("door", "window"), doors_windows):
201
+ boxes = []
202
+ for k, o in enumerate(items):
203
+ p, w, h, pr = o.get("position"), o.get("width"), o.get("height"), o.get("profile") or []
204
+ xy = [q[:2] for q in pr]
205
+ segs = [(a, b) for a, b in zip(xy, xy[1:] + xy[:1]) if math.dist(a, b) > 1e-3]
206
+ if not (p and all(_num(p.get(c)) for c in "xyz") and _num(w) and _num(h) and w > 0 and h > 0 and segs):
207
+ skipped += 1
208
+ continue
209
+ a, b = min(segs, key=lambda s: abs(math.dist(*s) - w)) # the side along the wall
210
+ depth = Polygon(xy).area / w if len(pr[0]) == 2 and len(xy) >= 3 else 0.0
211
+ z = o["sill_height"] if _num(o.get("sill_height")) else p["z"] - h / 2
212
+ if h < MIN_H:
213
+ skipped += 1
214
+ continue
215
+ ang = math.atan2(b[1] - a[1], b[0] - a[0])
216
+ d = depth if depth > 0.01 else OPENING_T
217
+ fp = Polygon(footprint({"pos": [p["x"], p["y"]], "size": [w, d], "yaw": ang}))
218
+ boxes.append((fp.area * h, fp, {
219
+ "furniture_category": kind, "furniture_instance_id": f"{prefix}::{kind}_{k}", "structure": True,
220
+ **({"listed_on": o["listed_on"]} if o.get("listed_on") else {}),
221
+ "furniture_position": {"x": p["x"] - shift[0], "y": p["y"] - shift[1], "z": z},
222
+ "furniture_rotation": {"x": 0.0, "y": 0.0, "z": math.degrees(ang) + 90},
223
+ "furniture_size": {"width": d, "length": w, "height": h}}))
224
+ kept = [] # biggest piece first; a piece half inside the kept ones is the same opening
225
+ for _, fp, f in sorted(boxes, key=lambda t: -t[0]):
226
+ if kept and unary_union([g for g, _ in kept]).intersection(fp).area >= 0.5 * fp.area:
227
+ skipped += 1
228
+ continue
229
+ kept.append((fp, f))
230
+ out += [f for _, f in kept]
231
+ return out, skipped
232
+
233
+
234
+ def frame_shift(furniture, pts, tol=0.02):
235
+ """Shift s with export_pos = il3d_pos + s supported by the most shared placements; (s, votes) or (None, 0)."""
236
+ pairs = [((f["furniture_position"]["x"], f["furniture_position"]["y"]), pts[f["furniture_asset_id"]])
237
+ for f in furniture if f.get("furniture_position") and f.get("furniture_asset_id") in pts]
238
+ best, votes = None, 0
239
+ for p, cands in pairs:
240
+ for q in cands:
241
+ s = (q[0] - p[0], q[1] - p[1])
242
+ n = sum(any(abs(c[0] - a[0] - s[0]) < tol and abs(c[1] - a[1] - s[1]) < tol for c in cs) for a, cs in pairs)
243
+ if n > votes:
244
+ best, votes = s, n
245
+ return best, votes
246
+
247
+
248
+ def split_by_source_room(furniture, rooms, own=None, tol=0.02):
249
+ """-> (shift, {export room index: [furniture]}, [unassigned furniture])."""
250
+ pts = collections.defaultdict(list)
251
+ for k, r in enumerate(rooms):
252
+ for jid, x, y in r[3]:
253
+ pts[jid].append((x, y, k))
254
+ shift, _ = frame_shift(furniture, {j: [(x, y) for x, y, _ in v] for j, v in pts.items()}, tol)
255
+ hits = []
256
+ for f in furniture:
257
+ p = f.get("furniture_position")
258
+ hits.append({k for x, y, k in pts.get(f.get("furniture_asset_id"), ())
259
+ if shift and p and abs(x - p["x"] - shift[0]) < tol and abs(y - p["y"] - shift[1]) < tol})
260
+ # a placement listed in several source rooms goes to the room most of this room's objects come from,
261
+ # ties to IL3D's own room
262
+ votes = collections.Counter(k for h in hits for k in h)
263
+ for k, r in enumerate(rooms):
264
+ votes[k] += 0.5 * (r[0] == own)
265
+ parts, rest = collections.defaultdict(list), []
266
+ for f, h in zip(furniture, hits):
267
+ (parts[max(sorted(h), key=votes.__getitem__)] if h else rest).append(f)
268
+ return shift, parts, rest
269
+
270
+
271
+ def load(root):
272
+ assets = load_assets(root)
273
+ exp = export_rooms(root)
274
+ floors = {}
275
+ for scene in iter_records(os.path.join(root, IL3D, "3dfront.json")):
276
+ for room in scene["rooms"]:
277
+ sf = room["source_fields"]
278
+ house = sf["original_house_file"][:-len(".json")]
279
+ if house not in floors:
280
+ path = os.path.join(root, HOUSES, house + ".json")
281
+ floors[house] = floor_polygons(path) if os.path.exists(path) else {}
282
+ furniture = []
283
+ for f in room["furniture"]:
284
+ f = dict(f)
285
+ a = assets.get(f.get("furniture_asset_id"))
286
+ # 3D-FUTURE category; for the ~12k assets without one (IL3D label "Others": potted plants, fridges,
287
+ # pianos ...) the asset's own assets.json meta_data category
288
+ f["furniture_category"] = (f.get("asset_category") or ((a or {}).get("meta_data") or {}).get("category")
289
+ or f.get("furniture_category"))
290
+ f["furniture_size"] = placed_size(f, a)
291
+ furniture.append(f)
292
+ rooms = exp.get(house, [])
293
+ shift, parts, rest = split_by_source_room(furniture, rooms, sf.get("original_roomId"))
294
+ if len(parts) == 1: # unassigned objects (NaN position) can only belong to the one source room
295
+ parts = {k: v + rest for k, v in parts.items()}
296
+ for k, fl in (parts or {None: furniture}).items():
297
+ srid, hull, height = rooms[k][:3] if k is not None else (None, None, None)
298
+ if height is None and srid == sf.get("original_roomId"):
299
+ height = room.get("room_height")
300
+ poly, floor_z = floors[house].get(srid, (None, None))
301
+ if poly and not cm_valid([[x - shift[0], y - shift[1]] for x, y in poly]):
302
+ poly = None
303
+ ring = poly or hull
304
+ boundary = [[x - shift[0], y - shift[1]] for x, y in ring] if ring else None
305
+ uid = f"il3d:{scene['scene_id']}" + (f"::{srid}" if len(parts) > 1 else "")
306
+ ops, n_skip = [], 0
307
+ if k is not None and len(rooms[k]) > 4:
308
+ doors, windows = rooms[k][4]
309
+ doors = doors + [{**o, "listed_on": r[0]} for j, r in enumerate(rooms) if j != k and ring
310
+ for o in r[4][0] if on_edge(o, ring, floor_z)]
311
+ ops, n_skip = openings((doors, windows), shift, uid)
312
+ ir = convert_room({**room, "furniture": [untilt(f) for f in fl] + ops, "room_boundary": boundary,
313
+ "room_height": height},
314
+ source=SOURCE, uid=uid, group=f"3dfront:{house.lower()}", subset="3D-FRONT",
315
+ boundary_type="polygon" if poly else "hull", front_offset_deg=-90, extra=il3d_extra,
316
+ meta={"house": house, "source_room_id": srid, "il3d_room_id": sf.get("original_roomId"),
317
+ "il3d_scene_id": scene["scene_id"], "split_from_il3d_room": len(parts) > 1,
318
+ "boundary_from": "3dfront_floor_mesh_polygon" if poly else "3dfront_export_floor_hull",
319
+ "front_known": True, "n_openings": len(ops), "n_openings_skipped": n_skip,
320
+ "n_doors_listed_on_neighbour": sum("listed_on" in o for o in ops),
321
+ "size_from": "assets.json mesh extents x layout scale"})
322
+ if len(parts) > 1 and rest: # objects whose source room is unknown: the split rooms are incomplete
323
+ ir["meta"]["n_incomplete"] += len(rest)
324
+ yield ir
325
+
326
+
327
+ if __name__ == "__main__":
328
+ f = {"furniture_position": {"x": 1.0, "y": 2.0, "z": 0.0}, "furniture_asset_id": "a",
329
+ "placement_flags": {"on_floor": True}, "source_fields": {"layout_object": {"scale": [-2, 1, 1]}}}
330
+ assert placed_size(f, {"meta_data": {"width": 1, "length": 0.5, "height": 3}}) == {"width": 2, "length": 3, "height": 0.5}
331
+ assert flag_anchor(f) == "floor"
332
+ g = {**f, "furniture_position": {"x": 3.0, "y": 2.0, "z": 0.0}, "furniture_asset_id": "b"}
333
+ h = {**f, "furniture_position": None}
334
+ assert frame_shift([f, g], {"a": [(6.0, 7.0), (0.0, 0.0)], "b": [(8.0, 7.0)]}) == ((5.0, 5.0), 2)
335
+ shift, parts, rest = split_by_source_room([f, g, h], [("r0", None, None, [("a", 6.0, 7.0)]),
336
+ ("r1", None, None, [("b", 8.0, 7.0), ("a", 0.0, 0.0)])])
337
+ assert shift == (5.0, 5.0) and parts == {0: [f], 1: [g]} and rest == [h]
338
+ # a door on a wall running +Y (footprint 0.9 along the wall, 0.24 deep) and a window on a wall running +X
339
+ door = {"position": {"x": 6.0, "y": 7.45, "z": 1.05}, "width": 0.9, "height": 2.1, "sill_height": None,
340
+ "profile": [[5.88, 7.0], [5.88, 7.9], [6.12, 7.9], [6.12, 7.0]]}
341
+ win = {"position": {"x": 8.0, "y": 5.0, "z": 1.65}, "width": 1.2, "height": 1.5, "sill_height": 0.9,
342
+ "profile": [[7.4, 5.0, 0.9], [7.4, 5.0, 2.4], [8.6, 5.0, 2.4], [8.6, 5.0, 0.9]]}
343
+ pane = {**win, "profile": [[7.5, 5.0, 0.9], [7.5, 5.0, 2.4], [8.5, 5.0, 2.4], [8.5, 5.0, 0.9]], "width": 1.0}
344
+ sill = {**win, "height": 0.05}
345
+ ops, skip = openings(([door], [pane, win, sill, {"position": None}]), (5.0, 5.0), "t")
346
+ assert skip == 3 and [o["furniture_category"] for o in ops] == ["door", "window"], ops # pane merged, sill dropped
347
+ assert ops[1]["furniture_size"]["length"] == 1.2
348
+ ir = convert_room({"furniture": ops, "room_boundary": [[0, 0], [4, 0], [4, 3], [0, 3]]}, source="t", uid="t", group="g",
349
+ boundary_type="hull", front_offset_deg=-90, extra=il3d_extra)
350
+ d, w = ir["objects"]
351
+ assert d["structure"] and abs(d["size"][0] - 0.9) < 1e-9 and abs(d["size"][1] - 0.24) < 1e-6 and d["size"][2] == 2.1, d
352
+ assert abs(d["pos"][0] - 1) < 1e-9 and abs(d["pos"][1] - 2.45) < 1e-9 and abs(d["pos"][2]) < 1e-9, d
353
+ assert abs(d["yaw"] - math.pi / 2) < 1e-9 and w["size"] == [1.2, OPENING_T, 1.5] and w["pos"][2] == 0.9 and abs(w["yaw"]) < 1e-9, w
354
+ # the same door on the neighbour's list is merged into the own one; a neighbour-only door keeps its provenance
355
+ nb = {**door, "position": {**door["position"], "y": 8.45}, "profile": [[5.88, 8.0], [5.88, 8.9], [6.12, 8.9], [6.12, 8.0]]}
356
+ ops, skip = openings(([door, {**door, "listed_on": "r1"}, {**nb, "listed_on": "r1"}], []), (5.0, 5.0), "t")
357
+ assert skip == 1 and [o.get("listed_on") for o in ops] == [None, "r1"], ops
358
+ ir = convert_room({"furniture": ops, "room_boundary": [[0, 0], [4, 0], [4, 3], [0, 3]]}, source="t", uid="t", group="g",
359
+ boundary_type="polygon", front_offset_deg=-90, extra=il3d_extra)
360
+ assert "listed_on" not in ir["objects"][0] and ir["objects"][1]["listed_on"] == "r1"
361
+ # exact floor: an L from two meshes with a 0.05 mm seam and a sliver -> one 6-vertex ring; a real hole or two
362
+ # separate floors -> None (the hull is kept)
363
+ rect = lambda x0, y0, x1, y1: [((x0, y0), (x1, y0), (x1, y1)), ((x0, y0), (x1, y1), (x0, y1))]
364
+ ring = floor_polygon(rect(0, 0, 4, 2) + rect(0, 2.00005, 2, 4) + [((5, 5), (5.01, 5), (5, 5.01))])
365
+ assert len(ring) == 6 and abs(Polygon(ring).area - 12) < 1e-3 and Polygon(ring).is_valid, ring
366
+ # a 5 mm slit between two meshes and a 12 cm zero-width spike (both collapse on the cm grid) -> the 4 x 3 room
367
+ ring = floor_polygon(rect(0, 0, 2, 3) + rect(2.005, 0, 4, 3) + [((4, 1), (4.12, 1.0005), (4, 1.001))])
368
+ assert len(ring) == 4 and abs(Polygon(ring).area - 12) < 1e-6, ring
369
+ assert floor_polygon(rect(0, 0, 3, 1) + rect(0, 2, 3, 3) + rect(0, 1, 1, 2) + rect(2, 1, 3, 2)) is None
370
+ assert floor_polygon(rect(0, 0, 1, 1) + rect(2, 0, 3, 1)) is None
371
+ # room il3d:b68c68fc: a vertex lands on another edge once rounded to cm in the room frame
372
+ assert cm_valid(ring) and not cm_valid([[5.076, 3.18], [5.076, 0], [6, 0], [6, 4], [5.12, 4], [5.12, 3.14], [5, 3.26], [5, 3.18]])
373
+ # a door on the far side of this room's wall opens into it; a door that only touches its corner does not
374
+ room, dz = [[0, 0], [4, 0], [4, 3], [0, 3]], {"position": {"x": 0, "y": 0, "z": 1.05}, "height": 2.1}
375
+ assert on_edge({**dz, "width": 0.9, "profile": [[1, 3], [1.9, 3], [1.9, 3.24], [1, 3.24]]}, room, 0.0)
376
+ assert not on_edge({**dz, "width": 0.9, "profile": [[1, 3], [1.9, 3], [1.9, 3.24], [1, 3.24]]}, room, -2.9) # upstairs
377
+ assert not on_edge({**dz, "width": 0.9, "profile": [[4, 3], [4.24, 3], [4.24, 3.9], [4, 3.9]]}, room, 0.0)
378
+ # tilted placement (Z-up matrix, 30 deg about local X) -> yaw-aligned bounding box, flagged tilted; lying flat
379
+ # (90 deg about X: local Y vertical) -> the exact box with permuted sizes, not tilted; yaw-only -> unchanged
380
+ rx = lambda a: [[1, 0, 0], [0, math.cos(a), -math.sin(a)], [0, math.sin(a), math.cos(a)]]
381
+ t = {"furniture_category": "vase", "furniture_position": {"x": 1.0, "y": 1.0, "z": 0.5},
382
+ "furniture_rotation": {"x": None, "y": None, "z": 0.0}, "furniture_size": {"width": 1.0, "length": 0.2, "height": 0.1},
383
+ "source_fields": {"rotation_matrix_zup": rx(math.pi / 6)}}
384
+ c30, s30 = math.cos(math.pi / 6), math.sin(math.pi / 6)
385
+ lying = untilt({**t, "source_fields": {"rotation_matrix_zup": rx(math.pi / 2)}})
386
+ assert untilt({**t, "furniture_rotation": {"x": 0.0, "y": 0.0, "z": 0.0}})["furniture_position"]["z"] == 0.5
387
+ ir = convert_room({"furniture": [untilt(t), lying], "room_boundary": [[0, 0], [3, 0], [3, 3], [0, 3]]}, source="t",
388
+ uid="t", group="g", front_offset_deg=-90, extra=il3d_extra)
389
+ o, q = ir["objects"]
390
+ close = lambda a, b: all(abs(x - y) < 1e-9 for x, y in zip(a, b))
391
+ assert o["tilted"] and abs(o["tilt_deg"] - 30) < 1e-9 and close(o["size"], [0.2 * c30 + 0.1 * s30, 1.0, 0.2 * s30 + 0.1 * c30]), o
392
+ assert close(o["pos"], [1, 1 - 0.05 * s30, 0.5 - 0.1 * s30]) and abs(o["yaw"] - 1.5 * math.pi) < 1e-9, o # centre kept
393
+ assert not q["tilted"] and "tilt_deg" not in q and close(q["size"], [0.1, 1.0, 0.2]) and close(q["pos"], [1, 0.95, 0.4]), q
394
+ print("il3d_3dfront self-check ok")
code/fastfill/adapters/il3d_synthetic.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IL3D "Synthetic Data" subset: 6,954 generated single rooms furnished with HSSD assets (IL3D's own supplement).
2
+
3
+ Conventions (checked against IL3D's USDZ meshes, see il3d_3dfront.py for the shared parts):
4
+ - size = assets.json mesh extents x |layout scale| (the layout bbox is the unscaled mesh extent).
5
+ - position = the asset origin, which for HSSD meshes is NOT always the bottom center (hanging lamps, wall shelves).
6
+ The mesh bounds come from the HSSD-200 asset catalog (XXXpilar__hssd_clean/asset_catalog.csv, same GLB frame as
7
+ IL3D's USDZ, checked on 79 assets) plus EXTRA_BOUNDS for the 54 assets it lacks.
8
+ - boundary = the generated rectangular floor (exact for these rooms); no room height in the source.
9
+ - category = the asset's category (furniture_category = asset_category = layout category for all 47,837 objects); the
10
+ box is the asset mesh the generator substituted, so its category is what the box is (a "bed" slot filled with a
11
+ 3.0 x 0.11 x 1.18 m railing mesh stays a railing). The layout's own label (object_name, e.g. "dryer-0") is kept
12
+ as layout_name. Only when the asset category is the generic "furniture" (988 objects, HSSD label "Others": washers,
13
+ dryers, dishwashers, shelving) the layout label names the object better, so it becomes the category (instance
14
+ suffix dropped) and the asset category is kept as asset_category. Of those, 1 ("floor_drain_cover" on a washer
15
+ mesh) is thereby shown as a fixed structure box instead of placed. Against the mesh's HSSD synset (catalog
16
+ wnsynsetkey) the name agrees for 449, differs for 453 and the mesh has none for 86; the differences are mostly
17
+ look-alike swaps ("dryer" on 209 washing-machine placements, "washing_machine"/"dryer" on 78 of a "DISHWASHER HOBART"
18
+ mesh, shelving names on a bathroom rack filed as handcart.n.01). The name is used anyway: it is the layout's label
19
+ of the slot whose pose is the target, the size already is the mesh's, and a name-agrees-with-synset rule would put
20
+ 539 back to "furniture".
21
+ """
22
+ import csv
23
+ import json
24
+ import math
25
+ import os
26
+ import re
27
+
28
+ from fastfill.adapters.il3d_3dfront import IL3D, il3d_extra, load_assets, placed_size
29
+ from fastfill.adapters.unified import convert_room, iter_records
30
+
31
+ SOURCE = "IL3D_synthetic"
32
+ FRONT_OFFSET_DEG = -90 # HSSD mesh +Z -> IR local -Y (front test)
33
+ GENERIC_ASSET = "furniture" # an asset category that names nothing: the layout's object_name is used instead
34
+
35
+ # mesh bounds (m, y-up asset frame) of the 54 IL3D HSSD assets absent from the HSSD-200 catalog, read from
36
+ # IL3D HSSD.zip USDZ (extents match assets.json meta for all 54)
37
+ EXTRA_BOUNDS = {
38
+ "08d386c25702f0613120b54e2b0615b4e4d198f7": ([-0.828425, 0.000115, -0.215669], [0.828425, 1.464155, 0.215669]),
39
+ "0a3e0a10ec97c41a36939e89b0591d2cfe5e41e1": ([-0.6, -0.0, -0.651672], [0.6, 0.882244, 0.651672]),
40
+ "103a70c6e8d54ac0cfc0fe28d66a8bea6b7c9144": ([-0.762062, -0.000101, -0.766263], [0.762062, 0.939839, 0.766263]),
41
+ "1b583238d6351379093ac7ac1df38699568bb4ec": ([-0.60996, -0.0, -0.2159], [0.609961, 0.787413, 0.2159]),
42
+ "1cdcc88935c2babba66677fa1d95aee0e6a410f3": ([-0.395, -0.0, -0.165], [0.395, 0.7, 0.165]),
43
+ "1e4e58bf53e51df27beeb774b5d70818de124068": ([-0.323064, 0.0, -0.397458], [0.323064, 0.924784, 0.397458]),
44
+ "252ea1f9106ada162cf68bb61c71efd4a62d4c87": ([-0.701118, -0.0, -0.16], [0.701119, 1.474944, 0.16]),
45
+ "28f1e0bae95e70346996067a45e4e32c2e1b7d75": ([-1.106139, -0.002024, -0.792466], [1.106139, 0.854505, 0.792466]),
46
+ "2a6fa282275e6ddb1fc35ebdc6a9ef91ae93a8ae": ([-0.45, 1.21079, -0.153132], [0.45, 2.274057, 0.153132]),
47
+ "31a2b3d52aafa438f763b1d4a6b60297b95fa4e4": ([-0.250502, -3e-06, -0.089994], [0.250502, 0.161684, 0.089994]),
48
+ "36b072f5c0db91a6b77fb0bfeaef686ee23a37f9": ([-1.338968, -0.008765, -1.339009], [1.339019, 2.507386, 1.338978]),
49
+ "381f06fc43fe78e832a882769242e022dea8c81e": ([-0.751624, -0.000244, -0.799559], [0.747077, 1.023986, 0.798994]),
50
+ "38da5efc3b4c3c250718e555cf68439746c71c65": ([-0.8, -0.0, -0.578804], [0.8, 0.75, 0.578804]),
51
+ "3a886310f67f7cca9d6ea34b5b1b28bc43f282f5": ([-0.69831, 0.0, -0.521203], [0.69831, 1.040641, 0.521203]),
52
+ "3a8afefa6b4a735293520f5bd14f3ec9d456a004": ([-1.82, -0.0, -0.251171], [1.819989, 1.97001, 0.251171]),
53
+ "3dca32a89b68041131bf3b0db5a07b80ad26c0c7": ([-0.53, 0.0, -0.225], [0.53, 1.9, 0.225]),
54
+ "3e8fb8993fed7051cb6da2ee1001d1cba046aaf8": ([-0.466968, -6.3e-05, -0.629036], [0.659181, 0.798143, 0.629036]),
55
+ "46e08ada1af32862e55de18a583e8f60e7fa7917": ([-0.803414, -0.00243, -0.299899], [0.713549, 0.850902, 0.30151]),
56
+ "47a95d3baa393487ba90ce9c90509cff0c9bb4e6": ([-0.43042, -0.0, -0.1524], [0.430419, 1.067658, 0.1524]),
57
+ "4d36e3731f127abfb07f1d970239d6d9f21e1b3e": ([-0.703974, -0.0, -0.173289], [0.703974, 0.741946, 0.17329]),
58
+ "4d6310a9ffa5b50e7dc45940cf724ba1fa3ffddd": ([-1.850866, -0.015017, -0.288937], [1.850866, 2.696201, 0.288936]),
59
+ "51824f2ecd931fbed1389e715e8184354c6f62d4": ([-0.55, -0.004554, -0.127143], [0.55, 0.452, 0.065389]),
60
+ "520a96262a7843c84a8cbebc172e70830d71c2db": ([-0.185151, -0.0, -0.158055], [0.185151, 1.602131, 0.158055]),
61
+ "5be064296d6117e1151e58b8f858b9147b6279a9": ([-0.64419, -9e-05, -0.175091], [0.644901, 1.667616, 0.175091]),
62
+ "605f233dd70c9a66d02914f1420d964642bdba81": ([-1.07618, -0.0, -1.382074], [1.07618, 0.836965, 1.382071]),
63
+ "62fd5aea80235288d1896ddab73cdcef97a6a585": ([-0.630063, -0.0, -0.536025], [0.630063, 0.859829, 0.536025]),
64
+ "6cf17da9a4062b73cafe548996b92e167d023a9a": ([-0.562498, -0.0, -0.510002], [0.562497, 0.9, 0.510002]),
65
+ "7718968f80499672922d0434c8c617b0902f0bad": ([-0.810003, 0.0, -0.6125], [0.810003, 0.717001, 0.612501]),
66
+ "777d1748a60d9b3ae52ad799aa0e215fd22715c6": ([-0.461928, -1.1e-05, -0.207056], [0.452472, 0.7493, 0.199345]),
67
+ "799439ef082a442613ddcadd2603aeeeb477b13e": ([-0.7, 0.00325, -0.7], [0.7, 0.45887, 0.7]),
68
+ "79d4fe8642358909adaa07a8b05793bcdd7f4450": ([-0.701118, -0.0, -0.16], [0.701119, 1.474944, 0.16]),
69
+ "8597e0af462124aef9ec06dde13f45bb2b223a74": ([-0.5, -7.7e-05, -0.614043], [0.5, 1.09571, 0.611347]),
70
+ "88ed586afa70ba6619fb122f205253a4a9d74aaa": ([-0.305, -0.0, -0.165], [0.305, 1.830585, 0.165]),
71
+ "92bcbbdf57f3668c4b6192b9de9dfd95640de0d1": ([-0.95, 0.000194, -0.225], [0.95, 1.060194, 0.225001]),
72
+ "9a1c224cd11be35a5c28347de7868674f56789da": ([-0.15, -0.002598, -0.45], [0.15, 0.3, 0.45]),
73
+ "a375e44843d9ca2e2dda1bae8b65edbc8b84bbf0": ([-0.776006, 3.5e-05, -0.775123], [0.775905, 0.978471, 0.775655]),
74
+ "a59f443c419c8514ecface071b4fd67bd44b4471": ([-0.415, -0.0, -0.1925], [0.415, 1.45, 0.192501]),
75
+ "a60150783b79e0fb4cb3d938483f718585c52bda": ([-0.51, -0.0, -0.215], [0.51, 1.83, 0.215]),
76
+ "a7323daeac0ded5ebafdb6231be3932e88a5b7f3": ([-0.45085, -0.0, -0.333766], [0.45085, 1.638814, 0.333766]),
77
+ "b827ad39b820d4908a4222dc95dd4b63ecf6c0a6": ([-0.45, 1.21079, -0.153132], [0.45, 2.274053, 0.153132]),
78
+ "ba28803b05660ca87ad0650276988f02dce1081e": ([-0.774645, -0.0, -0.774677], [0.774645, 0.914617, 0.774677]),
79
+ "bc58cc78e7268f25da1dbee0f989c25c7e3d1108": ([-1.82, -0.0, -0.2975], [1.820001, 1.898, 0.2975]),
80
+ "bf70431f51b8e06f2d7ef70916682dcd943553b8": ([-0.71163, -0.0, -0.580984], [0.716089, 0.99339, 0.3]),
81
+ "c184f9647c5788e29d55dbf622bae24513ec48ec": ([-0.1775, -0.0, -0.145], [0.1775, 1.310004, 0.145]),
82
+ "c4492609d027c557c5dc1ab8fc83bdd2e7617e12": ([-0.449618, -0.0, -0.183844], [0.449618, 1.52999, 0.150187]),
83
+ "ca6bc7678efa7a97f59ee6a650217a5492b3a0f8": ([-0.770001, -0.0, -0.764999], [0.770001, 0.7, 0.764999]),
84
+ "cee999e8b57589a4862425c588838f28bcc56f3b": ([-0.702015, -1e-06, -1.076095], [0.702015, 0.890897, 1.076095]),
85
+ "d724fb718897f8200e6203a094369dc5e6c45ca2": ([-0.550002, 0.0, -0.45], [0.550002, 0.77, 0.45]),
86
+ "dd56f602c6c29a97bbff71f4e48f07454ec97b62": ([-0.521833, 0.001956, -0.773037], [0.521833, 0.385286, 0.773038]),
87
+ "efb3a025089c14264fde9b03c67d7353fb6759c5": ([-0.5334, -0.0, -0.1524], [0.5334, 1.57988, 0.167796]),
88
+ "f8261de437a1f1f23263e4df0deb0a493ed52387": ([-0.530916, -0.0, -0.3048], [0.530916, 0.662856, 0.3048]),
89
+ "ff0d0a7fe6e2a685cf03bf6ef2e32ae44d79372d": ([-0.45, 1.21079, -0.153132], [0.45, 2.274057, 0.153132]),
90
+ "ff587a04fe3412b83b8fc870a7216374e70506c4": ([-0.828425, 0.000114, -0.215669], [0.828425, 1.473051, 0.215669]),
91
+ "xxxxbc6e2459xae45x49d0x86c4xe27c2b0767a7": ([-0.399916, 0.0, -0.2032], [0.399916, 1.942959, 0.2032]),
92
+ }
93
+
94
+
95
+ def load_catalog(root):
96
+ with open(os.path.join(root, "XXXpilar__hssd_clean/asset_catalog.csv")) as f:
97
+ return {r["template_name"]: (json.loads(r["local_bbox_min"]), json.loads(r["local_bbox_max"]))
98
+ for r in csv.DictReader(f) if r["local_bbox_min"]} | EXTRA_BOUNDS
99
+
100
+
101
+ def bottom_center(f, bounds):
102
+ """Asset-origin position -> bottom center of the placed box (IR frame), or None."""
103
+ p, rot = f.get("furniture_position"), f.get("furniture_rotation") or {}
104
+ if not (p and bounds and rot.get("z") is not None):
105
+ return None
106
+ (x0, y0, z0), (x1, y1, z1) = bounds
107
+ s = f["source_fields"]["layout_object"]["scale"]
108
+ cx, cz, by = s[0] * (x0 + x1) / 2, s[2] * (z0 + z1) / 2, min(s[1] * y0, s[1] * y1)
109
+ yaw = math.radians(rot["z"]) # yaw of mesh +X; mesh +Z is IR local -Y
110
+ c, n = math.cos(yaw), math.sin(yaw)
111
+ return {"x": p["x"] + c * cx + n * cz, "y": p["y"] + n * cx - c * cz, "z": p["z"] + by}
112
+
113
+
114
+ def labels(f):
115
+ """-> (IR category, extra IR fields): the layout label is always kept; it is the category only for GENERIC_ASSET."""
116
+ cat, name = f.get("furniture_category"), f.get("object_name")
117
+ if cat == GENERIC_ASSET and name:
118
+ return re.sub(r"-\d+$", "", name), {"layout_name": name, "asset_category": cat}
119
+ return cat, {"layout_name": name} if name else {}
120
+
121
+
122
+ def load(root):
123
+ assets, catalog = load_assets(root), load_catalog(root)
124
+ for scene in iter_records(os.path.join(root, IL3D, "synthetic_data.json")):
125
+ for room in scene["rooms"]:
126
+ furniture = []
127
+ for f in room["furniture"]:
128
+ f = dict(f)
129
+ f["furniture_size"] = placed_size(f, assets.get(f.get("furniture_asset_id")))
130
+ f["furniture_position"] = bottom_center(f, catalog.get(f.get("furniture_asset_id")))
131
+ f["furniture_category"], f["_labels"] = labels(f)
132
+ furniture.append(f)
133
+ yield convert_room({**room, "furniture": furniture}, source=SOURCE, uid=f"il3d:{scene['scene_id']}",
134
+ group=f"il3d:{scene['scene_id']}", subset="Synthetic Data", boundary_type="polygon",
135
+ front_offset_deg=FRONT_OFFSET_DEG, extra=lambda f: {**il3d_extra(f), **f["_labels"]},
136
+ meta={"il3d_scene_id": scene["scene_id"], "front_known": True,
137
+ "position_from": "asset origin + HSSD-200 catalog mesh bounds",
138
+ "size_from": "assets.json mesh extents x layout scale"})
139
+
140
+
141
+ if __name__ == "__main__":
142
+ f = {"furniture_position": {"x": 1.0, "y": 1.0, "z": 2.0}, "furniture_rotation": {"z": 90.0},
143
+ "source_fields": {"layout_object": {"scale": [1, 2, 1]}}}
144
+ b = bottom_center(f, ([-0.5, -0.5, 0.0], [0.5, 0.0, 0.4])) # hanging: origin at top, box centered at z=+0.2
145
+ assert all(abs(b[k] - v) < 1e-9 for k, v in {"x": 1.2, "y": 1.0, "z": 1.0}.items()), b
146
+ assert labels({"furniture_category": "furniture", "object_name": "clothes_dryer-2"}) == (
147
+ "clothes_dryer", {"layout_name": "clothes_dryer-2", "asset_category": "furniture"})
148
+ assert labels({"furniture_category": "balcony_railing", "object_name": "bed-0"}) == ("balcony_railing", {"layout_name": "bed-0"})
149
+ assert labels({"furniture_category": "furniture"}) == ("furniture", {})
150
+ print("il3d_synthetic self-check ok")
code/fastfill/adapters/interiorgs.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InteriorGS (Manycore designer scenes, 1,000 houses / 6,607 rooms). View used: interiorgs.json only (full
2
+ scenes, every room once); json_single.json + multiroom/*.json repackage the same rooms and are ignored.
3
+
4
+ Upstream room assignment can lose objects at repeated/degenerate profile edges. For scene.unassigned_objects,
5
+ recover only a unique room covering the mean of the eight source_fields.bounding_box corners, using the same
6
+ conservative ring repair as conversion. Existing assignments stay authoritative. Shared edges, overlapping rooms,
7
+ outside centers, invalid geometry and duplicate IDs remain excluded with IDs/reasons in meta.membership_recovery;
8
+ no nearest-room assignment. A missing/invalid room polygon prevents proving uniqueness, so recovery is withheld.
9
+
10
+ Export (aggregate_scenes.py): meters, Z-up, scene-global XY, room_boundary = structure.rooms[].profile (floor
11
+ polygon), room_height from the room_box. The exported furniture_rotation/size are longest-edge geometry
12
+ ([0, 180) yaw, no front), so this adapter re-derives pose from source_fields.bounding_box instead:
13
+ corners 0-3 are the bottom face in a fixed local order and edge c1-c2 is the object's BACK (front test on all
14
+ 1,000 scenes: bed 0.85, toilet 0.94, painting 0.90 touch the wall with c1-c2, c3-c0 ~0.00), so
15
+ front = midpoint(c3, c0) - midpoint(c1, c2); IR size = [|c0c1| (depth, along front), |c1c2|, height].
16
+ Caveat: custom cabinetry (labels wardrobe / cabinet / wall cabinet) does not follow this frame (wall side ~uniform),
17
+ so their yaw is only reliable up to the box axes: those objects get front_known=False.
18
+ Boxes whose top face is shifted from the bottom face or whose bottom is not level (7,355, all rectangular) go
19
+ through internscenes.upright / tilted_bbox as internscenes.canon does, with local X = c0 - c1 (front), Y = c2 - c1,
20
+ Z = c4 - c0: 3,704 are upright boxes with permuted axes (corners 0-3 on a side face: strip lights, wall panels,
21
+ lying boxes) and 33 lean < 5 deg, re-expressed as yaw-only boxes (yaw = heading of local X); the other 3,618 (cigars
22
+ racked at 30 deg, leaning books, storage racks) become the yaw-aligned bounding box of their 8 corners, tilted
23
+ (rotation x/y None) with "tilt_deg". Bottom = box centre - height / 2. When local X is the (near-)vertical axis (2,683
24
+ boxes: vents, ceiling mouldings, wall designs, lying food boxes) yaw is the heading of local Y and front_known=False:
25
+ the front points up or down, so the source gives no horizontal front.
26
+ 123 boxes are level but upside down (local Z = c4 - c0 points down: 103 rotated, 20 mirrored; wall designs, vents,
27
+ paintings, hanging wine glasses): same path, so height is the real one, not the ~0 of c0-c3 minus themselves, and yaw
28
+ stays the heading of c0 - c1 (the world image of local +X, i.e. of the front, whichever way the box was flipped).
29
+ Doors / windows: scene["openings"] (= structure.holes, profile = vertical polygon in the wall, scene Z; every room
30
+ floor is at z = 0) of type DOOR / WINDOW, matched to a room geometrically, not through room.doors/windows (most
31
+ openings have room_indices []): the profile lies on the room's wall line (the ring is the wall's inner face) or on
32
+ the wall's centre line or far face, so an opening belongs to every room with a ring edge parallel to it (profile
33
+ spread < PAR_TOL off the edge line), between WALL_TOL inside and thickness + WALL_TOL outside it, covering more than
34
+ half its width (a door in a shared wall goes to both rooms). Emitted as "structure" boxes on that edge (the wall line):
35
+ local X along the wall = the profile's extent along the edge (the export's width = max(X span, Y span) is wrong for
36
+ oblique openings), OPENING_T across it (as the other sources' openings; the export's thickness is the wall's),
37
+ bottom = min profile z, height = profile z span. 5,714 / 5,716 doors and 6,211 / 6,273 windows match a room (4,554 doors
38
+ and 233 windows two); 50 of the unmatched windows are flat chords across curved walls (ring = 0.2 m segments), not
39
+ handled. HOLE / OPENING types (passages without a leaf) are not emitted. The export repeats some holes (0293_840751
40
+ holes 15-18 are one window; 0395_840187 holes 23/24 are one door on both faces of a 6 cm wall): a box more than half
41
+ inside a bigger one of its kind on the same edge is skipped (32 boxes in 29 rooms: 27 doors, 5 windows;
42
+ meta.n_openings_repeated).
43
+ Wall rings that are invalid only through a corner overshoot (the ring crosses itself around a few-cm lobe), a zero-area
44
+ spur or a self-touching vertex (169 of the 171 invalid rings, area change <= 0.06 m2) are repaired with buffer(0) when
45
+ it gives one hole-free polygon within REPAIR_TOL of the ring's shoelace area (meta.boundary_repaired); other invalid
46
+ rings get boundary_type None.
47
+ """
48
+ import math
49
+ import os
50
+ import re
51
+ from collections import Counter
52
+
53
+ import numpy as np
54
+ from shapely.geometry import Point, Polygon, box as rect
55
+ from shapely.ops import unary_union
56
+
57
+ from fastfill.adapters.internscenes import tilted_bbox, upright
58
+ from fastfill.adapters.unified import _num, convert_room, iter_records, signed_area
59
+ from fastfill.scene import footprint, head
60
+
61
+ SOURCE = "InteriorGS"
62
+ GROUP = {"0755_840824": "structured3d:scene_01482", # same design as a Structured3D house (room boundary + furniture match)
63
+ # duplicated InteriorGS houses that split.py's content_key does not link (rooms differ by a few objects)
64
+ "0565_840943": "interiorgs:0478_840944", "0684_841508": "interiorgs:0521_840344"}
65
+ TILT_TOL = 0.02 # m
66
+ OPENING_T, PAR_TOL, WALL_TOL = 0.10, 0.02, 0.05 # m
67
+ REPAIR_TOL = 0.01 # relative area change allowed when repairing a wall ring
68
+ # Only the generic custom-cabinetry labels: TV / basin / shoe / wine / display / storage cabinets follow the corner
69
+ # order (back edge c1-c2 on the wall 0.51-0.82, front edge c3-c0 <= 0.10), plain wardrobe / cabinet / wall cabinet do not
70
+ UNRELIABLE_FRONT = re.compile(r"^(wardrobe|cabinet|wall cabinet)s?( doors)?$", re.I)
71
+
72
+
73
+ def _pose(f):
74
+ """Export furniture -> unified-schema furniture with front-aware yaw/size, or None fields if no bbox."""
75
+ bb = (f.get("source_fields") or {}).get("bounding_box")
76
+ if not bb or len(bb) != 8:
77
+ return {**f, "furniture_rotation": None}
78
+ c = [(p["x"], p["y"], p["z"]) for p in bb]
79
+ bz = [p[2] for p in c[:4]]
80
+ if (max(bz) - min(bz) > TILT_TOL or c[4][2] < c[0][2] - TILT_TOL or # bottom not level, or local Z down
81
+ max(math.hypot(c[i][0] - c[i + 4][0], c[i][1] - c[i + 4][1]) for i in range(4)) > TILT_TOL):
82
+ return _upright(f, np.array(c))
83
+ fx = (c[3][0] + c[0][0] - c[1][0] - c[2][0]) / 2
84
+ fy = (c[3][1] + c[0][1] - c[1][1] - c[2][1]) / 2
85
+ return {**f,
86
+ "furniture_position": {"x": sum(p[0] for p in c[:4]) / 4, "y": sum(p[1] for p in c[:4]) / 4, "z": min(bz)},
87
+ "furniture_rotation": {"x": 0.0, "y": 0.0, "z": math.degrees(math.atan2(fy, fx))},
88
+ "furniture_size": {"width": math.hypot(fx, fy),
89
+ "length": math.hypot(c[2][0] - c[1][0], c[2][1] - c[1][1]),
90
+ "height": max(p[2] for p in c) - min(bz)}}
91
+
92
+
93
+ def _upright(f, c):
94
+ """8 corners (array 8x3) of a box whose bottom face is not level -> the upright box (permuted axes) or the
95
+ yaw-aligned bounding box of the corners (tilted: rotation x/y None, "tilt_deg"), as internscenes.canon."""
96
+ e = np.array([c[0] - c[1], c[2] - c[1], c[4] - c[0]]).T # local X (front), Y, Z edges as columns
97
+ size = np.linalg.norm(e, axis=0)
98
+ R = e / np.maximum(size, 1e-12)
99
+ up = upright(R, size)
100
+ if up is None:
101
+ (sx, sy, sz), yaw, k, tilt = tilted_bbox(R, size)
102
+ rot, extra = None, {"tilt_deg": tilt}
103
+ else:
104
+ (sx, sy, sz), yaw, k = up
105
+ rot, extra = 0.0, {}
106
+ x, y, z = c.mean(axis=0)
107
+ return {**f, "furniture_position": {"x": x, "y": y, "z": z - sz / 2},
108
+ "furniture_rotation": {"x": rot, "y": rot, "z": yaw},
109
+ "furniture_size": {"width": sx, "length": sy, "height": sz}, "vertical_axis": k, **extra}
110
+
111
+
112
+ def openings(scene_openings, ring, prefix):
113
+ """Scene DOOR / WINDOW openings in this room's walls -> (structure furniture on the wall line (see module doc),
114
+ n_repeated). Rotation z = edge direction, size [extent along the edge, OPENING_T, height] (front_offset_deg 0).
115
+ The export repeats some holes (exact profile copies, or the same hole on both faces of a thin wall, a few cm
116
+ apart): biggest first, an opening more than half inside the kept ones of its kind on the same edge (in the wall
117
+ plane: along-edge x z) is a repeat and is skipped, as il3d_3dfront.openings does."""
118
+ b = [tuple(p[:2]) for p in ring]
119
+ if signed_area(b) < 0:
120
+ b = b[::-1]
121
+ cand = []
122
+ for o in scene_openings:
123
+ kind = {"DOOR": "door", "WINDOW": "window"}.get(o.get("opening_type"))
124
+ pr, t = o.get("profile") or [], o.get("thickness")
125
+ if not (kind and pr and _num(t)):
126
+ continue
127
+ best = None
128
+ for i, ((x0, y0), (x1, y1)) in enumerate(zip(b, b[1:] + b[:1])):
129
+ L = math.hypot(x1 - x0, y1 - y0)
130
+ if L < 1e-9:
131
+ continue
132
+ ux, uy = (x1 - x0) / L, (y1 - y0) / L
133
+ s = [(p[0] - x0) * ux + (p[1] - y0) * uy for p in pr] # along the edge
134
+ n = [(p[1] - y0) * ux - (p[0] - x0) * uy for p in pr] # across it, > 0 inside the (CCW) room
135
+ ov = min(max(s), L) - max(min(s), 0)
136
+ if max(n) - min(n) < PAR_TOL and min(n) >= -t - WALL_TOL and max(n) <= WALL_TOL and \
137
+ ov > 0.5 * (max(s) - min(s)) and (best is None or ov > best[0]):
138
+ best = (ov, i, x0, y0, ux, uy, min(s), max(s))
139
+ if best is None:
140
+ continue
141
+ _, i, x0, y0, ux, uy, s0, s1 = best
142
+ z0, z1 = min(p[2] for p in pr), max(p[2] for p in pr)
143
+ cand.append((kind, i, rect(s0, z0, s1, z1), {
144
+ "furniture_category": kind, "furniture_instance_id": f"{prefix}::hole_{o['source_hole_index']}",
145
+ "structure": True,
146
+ "furniture_position": {"x": x0 + ux * (s0 + s1) / 2, "y": y0 + uy * (s0 + s1) / 2, "z": z0},
147
+ "furniture_rotation": {"x": 0.0, "y": 0.0, "z": math.degrees(math.atan2(uy, ux))},
148
+ "furniture_size": {"width": s1 - s0, "length": OPENING_T, "height": z1 - z0}}))
149
+ kept, keep = {}, set()
150
+ for kind, i, r, f in sorted(cand, key=lambda t: -t[2].area):
151
+ ks = kept.setdefault((kind, i), [])
152
+ if not (ks and unary_union(ks).intersection(r).area > 0.5 * r.area):
153
+ ks.append(r)
154
+ keep.add(id(f))
155
+ out = [f for *_, f in cand if id(f) in keep] # source hole order
156
+ return out, len(cand) - len(out)
157
+
158
+
159
+ def repair_ring(ring):
160
+ """Invalid wall ring -> (buffer(0) exterior, meta) when that is one hole-free polygon within REPAIR_TOL of the
161
+ ring's shoelace area (corner overshoot, spur, self-touching vertex); else (ring, {})."""
162
+ if not ring or len(ring) < 3 or Polygon(ring).is_valid:
163
+ return ring, {}
164
+ a, q = abs(signed_area([p[:2] for p in ring])), Polygon(ring).buffer(0)
165
+ if q.geom_type != "Polygon" or q.interiors or not abs(q.area - a) < REPAIR_TOL * a:
166
+ return ring, {}
167
+ return [list(p) for p in q.exterior.coords[:-1]], {"boundary_repaired": "buffer(0)",
168
+ "boundary_area_change_m2": round(q.area - a, 4)}
169
+
170
+
171
+ def drop_labelled(objs):
172
+ """IR objects minus the opening boxes the source already has as a labelled furniture box: a 'window' / 'door' box
173
+ of the same head noun that overlaps the opening's footprint and covers more than half of its wall-plane rectangle
174
+ (along the wall x z). 207 windows + 4 doors in 127 rooms: mostly boxes centred on the same wall line 0.13 m deep,
175
+ 7 window panes 2-5 cm thin a few cm off the line; a bay-window platform labelled 'window' below the opening does
176
+ not count. The source's own box stays (meta.n_openings_dropped)."""
177
+ own = [(head(o), Polygon(footprint(o)), o) for o in objs if not o.get("structure") and head(o) in ("door", "window")]
178
+
179
+ def covered(o):
180
+ fp, ux, uy = Polygon(footprint(o)), math.cos(o["yaw"]), math.sin(o["yaw"])
181
+ c = o["pos"][0] * ux + o["pos"][1] * uy
182
+ for h, p, q in own:
183
+ if h != o["category"] or not p.intersection(fp).area > 0:
184
+ continue
185
+ s = [x * ux + y * uy for x, y in p.exterior.coords]
186
+ ds = min(max(s), c + o["size"][0] / 2) - max(min(s), c - o["size"][0] / 2)
187
+ dz = min(q["pos"][2] + q["size"][2], o["pos"][2] + o["size"][2]) - max(q["pos"][2], o["pos"][2])
188
+ if ds > 0 and dz > 0 and ds * dz > 0.5 * o["size"][0] * o["size"][2]:
189
+ return True
190
+ return False
191
+ return [o for o in objs if not (o.get("structure") and covered(o))]
192
+
193
+
194
+ def _extra(f):
195
+ if f.get("structure"):
196
+ return {"structure": True}
197
+ unknown = UNRELIABLE_FRONT.search(f["furniture_category"]) or f.get("vertical_axis") == 0 # front up / down
198
+ x = {"front_known": False} if unknown else {}
199
+ return {**x, "tilt_deg": f["tilt_deg"]} if "tilt_deg" in f else x
200
+
201
+
202
+ def _membership_id(f):
203
+ """Scene-local export identity, with the raw instance/index metadata as fallbacks."""
204
+ fields = f.get("source_fields") or {}
205
+ for prefix, value in (("", f.get("furniture_instance_id")), ("ins:", fields.get("ins_id")),
206
+ ("source_index:", f.get("source_index"))):
207
+ if value is not None and value != "":
208
+ return prefix + str(value)
209
+ return None
210
+
211
+
212
+ def _membership_polygon(room):
213
+ ring = room.get("room_boundary")
214
+ if not isinstance(ring, list) or len(ring) < 3 or not all(
215
+ isinstance(p, (list, tuple)) and len(p) >= 2 and all(_num(v) for v in p[:2]) for p in ring):
216
+ return None
217
+ fixed, _ = repair_ring([p[:2] for p in ring])
218
+ poly = Polygon(fixed)
219
+ return poly if poly.is_valid and not poly.is_empty and poly.area > 0 else None
220
+
221
+
222
+ def _membership_center(f):
223
+ corners = (f.get("source_fields") or {}).get("bounding_box")
224
+ if not isinstance(corners, list) or len(corners) != 8 or not all(
225
+ isinstance(p, dict) and all(_num(p.get(k)) for k in "xyz") for p in corners):
226
+ return None
227
+ return Point(*(sum(p[k] / 8 for p in corners) for k in "xy"))
228
+
229
+
230
+ def _membership_rooms(scene):
231
+ """Return (room, recovery metadata) pairs without modifying the export or inventing room membership."""
232
+ rooms, pending = scene["rooms"], scene.get("unassigned_objects") or []
233
+ if not pending:
234
+ return [(r, {}) for r in rooms]
235
+ polygons = [_membership_polygon(r) for r in rooms]
236
+ assigned = {_membership_id(f) for r in rooms for f in r.get("furniture") or []}
237
+ counts = Counter(_membership_id(f) for f in pending)
238
+
239
+ def locate(f):
240
+ identity = _membership_id(f)
241
+ if identity is None:
242
+ return None, "missing_id"
243
+ if identity in assigned:
244
+ return None, "already_assigned"
245
+ if counts[identity] > 1:
246
+ return None, "duplicate_id"
247
+ center = _membership_center(f)
248
+ if center is None:
249
+ return None, "invalid_bbox"
250
+ if any(p is None for p in polygons):
251
+ return None, "invalid_boundary"
252
+ hits = [i for i, p in enumerate(polygons) if p.covers(center)]
253
+ return (hits[0], None) if len(hits) == 1 else (None, "ambiguous" if hits else "outside")
254
+
255
+ decisions = [(f, _membership_id(f) or f"unassigned:{i}", *locate(f)) for i, f in enumerate(pending)]
256
+ reasons = sorted({reason for _, _, _, reason in decisions if reason and reason != "already_assigned"})
257
+ common = {"method": "unique_bbox_center", "scene_unassigned": len(pending),
258
+ "scene_recovered": sum(reason is None for _, _, _, reason in decisions),
259
+ "scene_already_assigned": [key for _, key, _, reason in decisions if reason == "already_assigned"],
260
+ "scene_unresolved": {reason: [key for _, key, _, why in decisions if why == reason] for reason in reasons}}
261
+ return [({**room, "furniture": list(room.get("furniture") or []) + [f for f, _, j, _ in decisions if j == i]},
262
+ {"membership_recovery": {**common, "recovered_ids": [key for _, key, j, _ in decisions if j == i]}})
263
+ for i, room in enumerate(rooms)]
264
+
265
+
266
+ def load(root):
267
+ path = os.path.join(root, "imChuling__3D_Room_Collections/InteriorGS_exported/interiorgs.json")
268
+ for scene in iter_records(path):
269
+ sid = scene["scene_id"]
270
+ for room, recovery in _membership_rooms(scene):
271
+ ring, fix = repair_ring(room["room_boundary"])
272
+ furn = [_pose(f) for f in room["furniture"]]
273
+ ops, n_rep = openings(scene.get("openings") or [], ring or [], sid)
274
+ furn += ops
275
+ ir = convert_room({**room, "room_boundary": ring, "furniture": furn},
276
+ source=SOURCE, uid=f"interiorgs:{sid}::{room['room_id']}", group=GROUP.get(sid, f"interiorgs:{sid}"),
277
+ boundary_type="polygon", extra=_extra,
278
+ meta={"scene_id": sid, "front_known": True, "front_source": "bbox corner order",
279
+ "room_type_available": False, "view": "interiorgs.json",
280
+ "n_permuted_axes": sum(f.get("vertical_axis", 2) != 2 for f in furn),
281
+ "n_openings_repeated": n_rep, **fix, **recovery})
282
+ objs = drop_labelled(ir["objects"])
283
+ ir["meta"]["n_openings_dropped"] = len(ir["objects"]) - len(objs)
284
+ ir["objects"] = objs
285
+ b = ir["boundary"]
286
+ if b and not Polygon(b).is_valid: # self-intersecting source profile: cannot prove in-bounds
287
+ ir["boundary_type"] = None
288
+ ir["meta"]["boundary_invalid"] = True
289
+ yield ir
290
+
291
+
292
+ if __name__ == "__main__":
293
+ # bed-like box whose back edge c1-c2 lies on the wall x=0 (clockwise corners): front = +X, depth 0.6, width 2
294
+ f = {"source_fields": {"bounding_box": [{"x": x, "y": y, "z": z} for z in (0.0, 1.0) for x, y in
295
+ ((0.6, -1.0), (0.0, -1.0), (0.0, 1.0), (0.6, 1.0))]}}
296
+ p = _pose(f)
297
+ assert abs(p["furniture_rotation"]["z"]) < 1e-9 and p["furniture_rotation"]["x"] == 0.0
298
+ assert abs(p["furniture_size"]["width"] - 0.6) < 1e-9 and abs(p["furniture_size"]["length"] - 2.0) < 1e-9
299
+ assert p["furniture_position"] == {"x": 0.3, "y": 0.0, "z": 0.0}
300
+
301
+ from fastfill.adapters.internscenes import rot_zxy
302
+
303
+ def box(ctr, R, size): # export corner order: c0 - c1 = local X, c2 - c1 = local Y, c4 - c0 = local Z
304
+ loc = [(sx, sy, sz) for sz in (-1, 1) for sx, sy in ((1, -1), (-1, -1), (-1, 1), (1, 1))]
305
+ pts = np.asarray(ctr) + (np.array(loc) * np.asarray(size) / 2) @ np.asarray(R).T
306
+ return {"source_fields": {"bounding_box": [dict(zip("xyz", q)) for q in pts.tolist()]}}
307
+ close = lambda a, b, tol=1e-6: all(abs(x - y) < tol for x, y in zip(a, b))
308
+ pose = lambda p: ([p["furniture_position"][k] for k in "xyz"], p["furniture_rotation"],
309
+ [p["furniture_size"][k] for k in ("width", "length", "height")])
310
+ # book lying flat, its local X (front) vertical: upright box with permuted axes, yaw = heading of local Y
311
+ X, Y = np.array([0.0, 0, 1]), np.array([math.cos(0.5), math.sin(0.5), 0])
312
+ R = np.stack([X, Y, np.cross(X, Y)], axis=1)
313
+ pos, rot, size = pose(_pose(box([1, 2, 0.8], R, [0.3, 0.2, 0.05])))
314
+ assert rot["x"] == 0.0 and abs(rot["z"] - math.degrees(0.5)) < 1e-6 and close(size, [0.2, 0.05, 0.3]), (rot, size)
315
+ assert close(pos, [1, 2, 0.65])
316
+ assert _extra({"furniture_category": "book", **_pose(box([1, 2, 0.8], R, [0.3, 0.2, 0.05]))}) == {"front_known": False}
317
+ # level box turned upside down (180 deg about local X: corners 4-7 below 0-3): real height, bottom, front kept
318
+ p = _pose(box([1, 2, 1.5], np.diag([1.0, -1, -1]), [0.8, 0.4, 0.6]))
319
+ pos, rot, size = pose(p)
320
+ assert rot["x"] == 0.0 and abs(rot["z"]) < 1e-6 and close(size, [0.8, 0.4, 0.6]) and close(pos, [1, 2, 1.2]), p
321
+ assert _extra({"furniture_category": "wall design", **p}) == {}
322
+ # truly tilted (30 deg about local X, yaw 90): yaw-aligned bounding box of the 8 corners, flagged via x/y None
323
+ p = _pose(box([0, 0, 1], rot_zxy(math.pi / 2, math.radians(30), 0), [1.0, 0.4, 0.2]))
324
+ pos, rot, size = pose(p)
325
+ c, s_ = math.cos(math.radians(30)), math.sin(math.radians(30))
326
+ assert rot["x"] is None and abs(rot["z"] - 90) < 1e-6 and abs(p["tilt_deg"] - 30) < 1e-6, (rot, p)
327
+ assert close(size, [1.0, 0.4 * c + 0.2 * s_, 0.4 * s_ + 0.2 * c]) and close(pos, [0, 0, 1 - size[2] / 2])
328
+
329
+ # 4 x 3 room at (10, 20), ring given clockwise; wall thickness 0.24 / 0.12
330
+ ring = [[10, 20], [10, 23], [14, 23], [14, 20]]
331
+ quad = lambda a, b, z0, z1: [[*a, z1], [*b, z1], [*b, z0], [*a, z0]]
332
+ ops = [{"opening_type": "DOOR", "source_hole_index": 0, "thickness": 0.24, # on the far face of the south wall
333
+ "profile": quad((11.9, 19.76), (11.0, 19.76), 0.0, 2.1)},
334
+ {"opening_type": "WINDOW", "source_hole_index": 1, "thickness": 0.12, # on the east wall's centre line
335
+ "profile": quad((14.06, 21.0), (14.06, 22.2), 0.9, 2.3)},
336
+ {"opening_type": "DOOR", "source_hole_index": 2, "thickness": 0.12, # the next room's wall, 1 m away
337
+ "profile": quad((11.0, 19.0), (11.9, 19.0), 0.0, 2.1)},
338
+ {"opening_type": "DOOR", "source_hole_index": 3, "thickness": 0.12, # perpendicular to the west wall
339
+ "profile": quad((9.5, 21.0), (9.9, 21.0), 0.0, 2.1)},
340
+ {"opening_type": "HOLE", "source_hole_index": 4, "thickness": 0.24, # passage: not emitted
341
+ "profile": quad((11.0, 23.0), (12.0, 23.0), 0.0, 2.1)}]
342
+ rep = [{**ops[0], "source_hole_index": 5}, # exact repeat of hole 0
343
+ {**ops[0], "source_hole_index": 6, "profile": quad((11.0, 19.82), (11.9, 19.82), 0.0, 2.1)}, # other face
344
+ {**ops[1], "source_hole_index": 7, "profile": quad((14.06, 21.0), (14.06, 22.2), 2.4, 2.7)}] # window above
345
+ op, n_rep = openings(ops + rep, ring, "s")
346
+ assert n_rep == 2 and [f["furniture_instance_id"] for f in op] == ["s::hole_0", "s::hole_1", "s::hole_7"], op
347
+ ir = convert_room({"room_id": "r", "room_boundary": ring, "furniture": op[:2]}, source=SOURCE,
348
+ uid="t", group="g", boundary_type="polygon", extra=_extra)
349
+ d, w = ir["objects"]
350
+ assert d["id"] == "s::hole_0" and d["category"] == "door" and d["structure"] and not d["tilted"], d
351
+ assert close(d["size"], [0.9, OPENING_T, 2.1]) and close(d["pos"], [1.45, 0, 0]) and abs(d["yaw"]) < 1e-9, d
352
+ assert w["category"] == "window" and w["structure"], w
353
+ assert close(w["size"], [1.2, OPENING_T, 1.4]) and close(w["pos"], [4, 1.6, 0.9]) and abs(w["yaw"] - math.pi / 2) < 1e-9, w
354
+ # the source's own 'window' box on the same wall line (0.13 m deep) replaces the opening box; a lamp does not
355
+ own = {"id": "s::ins_1", "category": "window", "size": [1.2, 0.13, 1.4], "pos": [4, 1.6, 0.9], "yaw": math.pi / 2}
356
+ lamp = {**own, "id": "s::ins_2", "category": "lamp"}
357
+ assert drop_labelled([d, w, own, lamp]) == [d, own, lamp] and drop_labelled([d, w, lamp]) == [d, w, lamp]
358
+ pane = {**own, "size": [0.02, 1.5, 1.6], "pos": [3.97, 1.5, 0.8], "yaw": 0.0} # thin pane 3 cm off the line
359
+ sill = {**own, "size": [1.2, 0.5, 0.4], "pos": [3.8, 1.6, 0.0]} # platform below the window
360
+ assert drop_labelled([w, pane]) == [pane] and drop_labelled([w, sill]) == [w, sill]
361
+
362
+ # wall ring overshooting a corner (crosses itself around a 2.4 x 6.5 cm lobe): repaired, area kept; a bow-tie is not
363
+ spur = [[2.912, 0], [2.888, 0], [2.888, 1.678], [0, 1.678], [0, 0.065], [2.912, 0.065]]
364
+ fixed, m = repair_ring(spur)
365
+ assert not Polygon(spur).is_valid and Polygon(fixed).is_valid and m["boundary_repaired"] == "buffer(0)", m
366
+ assert abs(Polygon(fixed).area - abs(signed_area(spur))) < REPAIR_TOL * abs(signed_area(spur))
367
+ bow = [[0, 0], [1, 1], [1, 0], [0, 1]]
368
+ assert repair_ring(bow) == (bow, {}) and repair_ring(ring) == (ring, {})
369
+ print("interiorgs.py self-check ok")
code/fastfill/adapters/internscenes.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InternScenes layouts. This module = InternScenes-Gen (SOURCE InternScenes_gen) plus the shared reader for the
2
+ Real2Sim re-layouts of real scans (modules internscenes_{scannet,3rscan,arkit,mp3d}).
3
+
4
+ Export (kits/extract_internscenes*_json.py): meters, Z-up, bbox = [x, y, z, sx, sy, sz, a_z, a_x, a_y] with
5
+ position = box CENTER, sizes along the box local axes, ZXY Euler (R = Rz(a_z) Rx(a_x) Ry(a_y), EmbodiedScan /
6
+ pytorch3d convention). Floor is at z = 0.
7
+ Boundary: Real2Sim = 2D convex hull of the floor mesh (-> "hull"); Gen = boundary_points.json floor polygon.
8
+ Some boxes are stored with a 90/180 deg X/Y rotation (Y-up assets, books lying flat); they are upright boxes
9
+ with permuted axes and are re-expressed as yaw-only boxes (`upright`). Boxes tilted by more than TILT_TOL_DEG
10
+ are replaced by the yaw-aligned bounding box of their 8 corners (`tilted_bbox`: true footprint, height and bottom,
11
+ yaw = heading of the local X axis) with rotation x/y = None so convert_room flags them tilted; "tilt_deg" keeps
12
+ the angle between the local axis closest to vertical and world Z.
13
+ """
14
+ import csv
15
+ import io
16
+ import json
17
+ import math
18
+ import os
19
+ import zipfile
20
+
21
+ import numpy as np
22
+ from shapely.geometry import Polygon
23
+
24
+ from fastfill.adapters.unified import convert_room, iter_records
25
+
26
+ SOURCE = "InternScenes_gen"
27
+ DIR = "imChuling__3D_Room_Collections/InternScenes_exported"
28
+ TILT_TOL_DEG = 5.0
29
+ MAX_SIZE_M = 20.0
30
+ # ponytail: one offset for all Real2Sim/Gen assets, set from the inspect_ir front test (see module docstrings)
31
+ FRONT_OFFSET_DEG = 0.0
32
+
33
+
34
+ def rot_zxy(az, ax, ay):
35
+ cz, sz, cx, sx, cy, sy = math.cos(az), math.sin(az), math.cos(ax), math.sin(ax), math.cos(ay), math.sin(ay)
36
+ rz = np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]])
37
+ rx = np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
38
+ ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
39
+ return rz @ rx @ ry
40
+
41
+
42
+ def upright(R, size, tol_deg=TILT_TOL_DEG):
43
+ """World-from-local rotation + local sizes -> (sizes [sx, sy, sz], yaw_deg, k) of the same box as an upright
44
+ box, k = index of the source local axis that is vertical; None if no local axis is within tol of vertical.
45
+ The new local X is the source local X when it stays horizontal (keeps its front), else the source local Y."""
46
+ R = np.asarray(R, dtype=float)
47
+ k = int(np.argmax(np.abs(R[2])))
48
+ if abs(R[2, k]) < math.cos(math.radians(tol_deg)):
49
+ return None
50
+ hx, hy = [i for i in range(3) if i != k] # k=2: (0,1) k=1: (0,2) k=0: (1,2)
51
+ v = R[:, hx]
52
+ return [size[hx], size[hy], size[k]], math.degrees(math.atan2(v[1], v[0])), k
53
+
54
+
55
+ def tilted_bbox(R, size):
56
+ """World-from-local rotation + local sizes of a box that is NOT upright within tolerance -> (sizes [w, l, h] of
57
+ the yaw-aligned bounding box of its 8 corners, yaw_deg, k, tilt_deg); k = local axis closest to vertical, tilt =
58
+ its angle from world Z; yaw = heading of local X, or of local Y when X is the near-vertical axis (as upright()
59
+ does: X's small horizontal part is then only the lean direction, and a box aligned to it is up to 3x too big)."""
60
+ R = np.asarray(R, dtype=float)
61
+ k = int(np.argmax(np.abs(R[2])))
62
+ a = next((c for c in ((R[:2, 1], R[:2, 0]) if k == 0 else (R[:2, 0], R[:2, 1])) if np.hypot(*c) >= 1e-6), (1.0, 0.0))
63
+ yaw = math.atan2(a[1], a[0])
64
+ D = np.array([[math.cos(yaw), math.sin(yaw), 0], [-math.sin(yaw), math.cos(yaw), 0], [0, 0, 1]]) # u, v, z
65
+ ext = np.abs(D @ R) @ np.asarray(size, dtype=float) # max - min of the (origin-symmetric) corners along u, v, z
66
+ return ext.tolist(), math.degrees(yaw), k, math.degrees(math.acos(min(1.0, abs(R[2, k]))))
67
+
68
+
69
+ def canon(f):
70
+ """Exported furniture -> same dict with an upright {x:0,y:0,z:yaw} rotation and permuted sizes when the box is
71
+ upright within tolerance; otherwise its yaw-aligned bounding box (`tilted_bbox`) with {x:None,y:None,z:yaw}
72
+ (convert_room flags it tilted) and "tilt_deg". The centre is unchanged either way."""
73
+ b = f["source_fields"]["bbox"]
74
+ if max(b[3:6]) > MAX_SIZE_M: # corrupt box (2 Gen beds are 1910 m / 803014 m tall)
75
+ return {**f, "furniture_size": None}
76
+ R = rot_zxy(b[6], b[7], b[8])
77
+ up = upright(R, b[3:6])
78
+ if up is None:
79
+ (sx, sy, sz), yaw, k, tilt = tilted_bbox(R, b[3:6])
80
+ rot, extra = {"x": None, "y": None, "z": yaw}, {"tilt_deg": tilt}
81
+ else:
82
+ (sx, sy, sz), yaw, k = up
83
+ rot, extra = {"x": 0.0, "y": 0.0, "z": yaw}, {}
84
+ return {**f, "furniture_size": {"width": sx, "length": sy, "height": sz}, "furniture_rotation": rot,
85
+ "vertical_axis": k, **extra}
86
+
87
+
88
+ def convert(scene, source, uid, group, boundary_type, meta):
89
+ room = scene["rooms"][0]
90
+ room = {**room, "furniture": [canon(f) for f in room["furniture"]]}
91
+ ir = convert_room(room, source=source, uid=uid, group=group, subset=scene.get("source_subset"),
92
+ boundary_type=boundary_type, center_z=True, front_offset_deg=FRONT_OFFSET_DEG,
93
+ meta={"scene_id": scene["scene_id"], "source_layout": scene["source_layout"],
94
+ "source_dataset": scene["source_dataset"], "is_true_room": room.get("is_true_room"),
95
+ "tilt_tol_deg": TILT_TOL_DEG, "front_known": True,
96
+ "n_permuted_axes": sum(f.get("vertical_axis", 2) != 2 for f in room["furniture"]),
97
+ **meta})
98
+ return ir
99
+
100
+
101
+ def load_real(root, fname, source, group_of, extra_meta=None):
102
+ for s in iter_records(os.path.join(root, DIR, fname)):
103
+ yield convert(s, source, uid=f"{source}::{s['scene_id']}", group=group_of(s), boundary_type="hull",
104
+ meta=(extra_meta(s) if extra_meta else {}))
105
+
106
+
107
+ def rscan_reference(root):
108
+ """3RScan scan id (reference or rescan) -> reference scan id."""
109
+ m = json.load(open(os.path.join(root, "imChuling__3D_Room_Collections/3RScan_exported/source_metadata/3RScan.json")))
110
+ ref = {}
111
+ for env in m:
112
+ ref[env["reference"]] = env["reference"]
113
+ for sc in env.get("scans", []):
114
+ ref[sc["reference"]] = env["reference"]
115
+ return ref
116
+
117
+
118
+ def arkit_visits(root):
119
+ """ARKitScenes video_id -> visit_id (None when 'NA')."""
120
+ z = zipfile.ZipFile(os.path.join(root, "imChuling__3D_Room_Collections/ARKitScenes_exported/source.zip"))
121
+ rows = csv.DictReader(io.TextIOWrapper(z.open("source/metadata.csv"), encoding="utf-8"))
122
+ return {r["video_id"]: (None if r["visit_id"] in ("", "NA") else r["visit_id"]) for r in rows}
123
+
124
+
125
+ def load(root):
126
+ """Gen regions: the floor is not at z = 0 (0.10-0.15 m, floor slab); every region has >= 1 door and all
127
+ doors of a region share one bottom height, so z is shifted by the median door bottom."""
128
+ for s in iter_records(os.path.join(root, DIR, "internscenes_gen_exported.json")):
129
+ rt, rid = s["rooms"][0]["room_type"], s["source_scene_name"]
130
+ # boundary_points are wall samples every 0.7 m with the first point repeated: keep the corners only
131
+ # (median 27 -> 6 vertices; every polygon is valid, max shift 1 cm)
132
+ poly = Polygon(s["rooms"][0]["room_boundary"]).simplify(0.01)
133
+ s["rooms"][0]["room_boundary"] = [list(c) for c in poly.exterior.coords[:-1]]
134
+ ir = convert(s, SOURCE, uid=f"{SOURCE}::{rt}/{rid}", group=f"internscenes:gen/{rid}", # one id = one generated house across room-type folders
135
+ boundary_type="polygon", meta={"asset_source": "Infinigen", "boundary_simplify_m": 0.01})
136
+ doors = sorted(o["pos"][2] for o in ir["objects"] if o["category"] == "door")
137
+ fz = doors[len(doors) // 2] if doors else None
138
+ for o in ir["objects"]:
139
+ o["pos"][2] -= fz or 0.0
140
+ ir["meta"].update(floor_z=fz, floor_z_source="median door bottom" if doors else None)
141
+ yield ir
142
+
143
+
144
+ if __name__ == "__main__":
145
+ # book lying flat: local X vertical -> upright box, height = source width
146
+ R = rot_zxy(0.3, 0.0, math.pi / 2)
147
+ size, yaw, k = upright(R, [0.03, 0.2, 0.3])
148
+ assert k == 0 and size == [0.2, 0.3, 0.03] and abs(yaw - math.degrees(0.3) - 90) < 1e-6, (size, yaw, k)
149
+ # Y-up chair stored with a_x = -90: local Y vertical, local X keeps the yaw
150
+ size, yaw, k = upright(rot_zxy(0.0, -math.pi / 2, math.radians(40)), [0.7, 0.8, 0.6])
151
+ assert k == 1 and size == [0.7, 0.6, 0.8] and abs(yaw + 40) < 1e-6, (size, yaw)
152
+ assert upright(rot_zxy(0.0, math.radians(10), 0.0), [1, 1, 1]) is None
153
+
154
+ # canon: sizes [1.0, 0.2, 0.1], ZXY angles in degrees, centre z
155
+ S = [1.0, 0.2, 0.1]
156
+
157
+ def rec(ax, ay, az=0.0, z=0.0):
158
+ return {"furniture_category": "box", "furniture_position": {"x": 1.0, "y": 1.0, "z": z},
159
+ "source_fields": {"bbox": [1.0, 1.0, z, *S, math.radians(az), math.radians(ax), math.radians(ay)]}}
160
+
161
+ def sz(c):
162
+ return [c["furniture_size"][key] for key in ("width", "length", "height")]
163
+
164
+ # (1) upright box yawed 30 deg: unchanged sizes, yaw 30
165
+ c = canon(rec(0, 0, az=30))
166
+ assert sz(c) == S and abs(c["furniture_rotation"]["z"] - 30) < 1e-9 and "tilt_deg" not in c, c
167
+ # (2) tilted 30 deg about X: yaw-aligned bounding box, yaw 0
168
+ s30, c30 = math.sin(math.radians(30)), math.cos(math.radians(30))
169
+ h, l = 0.2 * s30 + 0.1 * c30, 0.2 * c30 + 0.1 * s30 # 0.1866, 0.2232
170
+ c = canon(rec(30, 0))
171
+ assert np.allclose(sz(c), [1.0, l, h]) and abs(c["furniture_rotation"]["z"]) < 1e-9, c
172
+ assert c["furniture_rotation"]["x"] is None and c["vertical_axis"] == 2 and abs(c["tilt_deg"] - 30) < 1e-9, c
173
+ # (3) lying flat (a_x = 90): permuted upright box via upright(), not the tilted path
174
+ c = canon(rec(90, 0))
175
+ assert np.allclose(sz(c), [1.0, 0.1, 0.2]) and c["vertical_axis"] == 1 and "tilt_deg" not in c, c
176
+ assert c["furniture_rotation"]["x"] == 0.0 and abs(c["furniture_rotation"]["z"]) < 1e-9, c
177
+ # (4) tilted box centred at z = 0.2 -> convert_room bottom = 0.2 - h/2, flagged tilted
178
+ scene = {"scene_id": "s", "source_layout": "l", "source_dataset": "d",
179
+ "rooms": [{"room_type": "t", "room_boundary": [[0, 0], [4, 0], [4, 4], [0, 4]], "furniture": [rec(30, 0, z=0.2)]}]}
180
+ ir = convert(scene, "t", uid="u", group="g", boundary_type="polygon", meta={})
181
+ o = ir["objects"][0]
182
+ assert o["tilted"] and abs(o["pos"][2] - (0.2 - h / 2)) < 1e-9 and ir["meta"]["n_tilted"] == 1, (o, ir["meta"])
183
+ print("internscenes.py self-check ok")
code/fastfill/adapters/internscenes_3rscan.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InternScenes Real2Sim re-layout of 3RScan scans (conventions: see internscenes.py). Group = 3RScan reference scan."""
2
+ from fastfill.adapters.internscenes import load_real, rscan_reference
3
+
4
+ SOURCE = "InternScenes_3rscan"
5
+
6
+
7
+ def load(root):
8
+ ref = rscan_reference(root)
9
+ yield from load_real(root, "3rscan_exported.json", SOURCE,
10
+ lambda s: f"3rscan:{ref.get(s['source_scene_name'], s['source_scene_name'])}",
11
+ lambda s: {"scan_id": s["source_scene_name"],
12
+ "is_reference_scan": ref.get(s["source_scene_name"]) == s["source_scene_name"]})
code/fastfill/adapters/internscenes_arkit.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InternScenes Real2Sim re-layout of ARKitScenes videos (conventions: see internscenes.py).
2
+ Group = arkit:<video_id>; several videos of one visit (same venue) are listed in meta.group_aliases."""
3
+ from fastfill.adapters.internscenes import arkit_visits, load_real
4
+
5
+ SOURCE = "InternScenes_arkit"
6
+
7
+
8
+ def load(root):
9
+ visit = arkit_visits(root)
10
+ yield from load_real(root, "arkitscenes_exported.json", SOURCE,
11
+ lambda s: "arkit:" + s["source_scene_name"],
12
+ lambda s: {"video_id": s["source_scene_name"], "visit_id": visit.get(s["source_scene_name"]),
13
+ "group_aliases": [f"arkit_visit:{visit[s['source_scene_name']]}"]
14
+ if visit.get(s["source_scene_name"]) else []})
code/fastfill/adapters/internscenes_mp3d.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """InternScenes Real2Sim re-layout of Matterport3D regions (conventions: see internscenes.py).
2
+ One record = one Matterport region (a room-level unit). Group = mp3d:<house hash>.
3
+ InternScenes ships 96 regions twice within a house (e.g. 1LXtFkjw3qL region2 == region29: identical boundary,
4
+ same object ids/categories, boxes within 1 cm); only the first copy is emitted (1696 -> 1600 records)."""
5
+ import numpy as np
6
+
7
+ from fastfill.adapters.internscenes import load_real
8
+
9
+ SOURCE = "InternScenes_mp3d"
10
+
11
+
12
+ def load(root):
13
+ seen = {}
14
+ for ir in load_real(root, "matterport3d_exported.json", SOURCE,
15
+ lambda s: "mp3d:" + s["source_layout"].split("/")[2],
16
+ lambda s: {"region": s["source_scene_name"]}):
17
+ key = (ir["group"], tuple((o["id"].rsplit(":", 1)[1], o["category"]) for o in ir["objects"]))
18
+ arr = np.array([o["size"] + o["pos"] for o in ir["objects"]], dtype=float)
19
+ bnd = np.array(ir["boundary"], dtype=float)
20
+ if any(b.shape == bnd.shape and a.shape == arr.shape and np.allclose(b, bnd, atol=0.01)
21
+ and np.allclose(a, arr, atol=0.01) for a, b in seen.get(key, [])):
22
+ continue
23
+ seen.setdefault(key, []).append((arr, bnd))
24
+ yield ir
code/fastfill/adapters/internscenes_scannet.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """InternScenes Real2Sim re-layout of ScanNet v2 scans (conventions: see internscenes.py).
2
+ room_type = ScanNet sceneType. Group = scannet:sceneXXXX (rescans _NN share it)."""
3
+ from fastfill.adapters.internscenes import load_real
4
+
5
+ SOURCE = "InternScenes_scannet"
6
+
7
+
8
+ def load(root):
9
+ yield from load_real(root, "scannet_exported.json", SOURCE,
10
+ lambda s: "scannet:" + s["source_scene_name"].rsplit("_", 1)[0])
code/fastfill/adapters/m3dlayout_infinigen.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M3DLayout, Infinigen subset (procedural houses, one room per record). Aux only: boundary is a proxy.
2
+
3
+ Export (extract_m3dlayout_json.py): source Y-up (location = OBB center, size = half extents, rotation = yaw about +Y)
4
+ -> furniture_position = (x, -z, y - sy) = BOTTOM center, size = (2sx, 2sz, 2sy), rotation.z = degrees(yaw);
5
+ the layout format is yaw-only (detail objects: 1 of 19,643 tilted > 5.7 deg in a 300-scene sample).
6
+ XY origin = furniture AABB SW corner; room_boundary = that AABB (object_extent_aabb) -> boundary_type "proxy";
7
+ room_height is an object-extent proxy too -> None. The layout frame is the Infinigen scene mirrored in Y
8
+ (layout_y = C - native_y, checked against the detail objects); a mirrored room is still a valid layout.
9
+ Front = layout local -Y (front test vs the proxy rectangle: toilet +Y 0.84 / -Y 0.00, bed 0.73 / 0.00).
10
+ room_type = M3DLayout's explicit-keyword parse of the source description (None / 'unknown' -> None).
11
+ Records without layout furniture (detail-only) are skipped; repeated (scene_id, layout) records are emitted once.
12
+ """
13
+ import os
14
+
15
+ from fastfill.adapters.unified import convert_room, iter_records
16
+
17
+ SOURCE = "M3DLayout_infinigen"
18
+ DIR = "imChuling__3D_Room_Collections/M3DLayout_exported"
19
+
20
+
21
+ def furniture(f):
22
+ """Layout furniture -> unified schema: yaw-only format means upright; snake_case category -> words."""
23
+ return {**f, "furniture_category": f["furniture_category"].replace("_", " "),
24
+ "furniture_rotation": {**f["furniture_rotation"], "x": 0, "y": 0}}
25
+
26
+
27
+ def records(root, name):
28
+ """(scene, room) for records that have layout furniture, each distinct (scene_id, layout) once."""
29
+ seen = set()
30
+ for sc in iter_records(os.path.join(root, DIR, name)):
31
+ room = sc["rooms"][0]
32
+ if not room["furniture"]:
33
+ continue
34
+ key = (sc["scene_id"], tuple(sorted((f["furniture_category"], round(f["furniture_position"]["x"], 3),
35
+ round(f["furniture_position"]["y"], 3), round(f["furniture_position"]["z"], 3),
36
+ round(f["furniture_rotation"]["z"], 1)) for f in room["furniture"])))
37
+ if key in seen:
38
+ continue
39
+ seen.add(key)
40
+ yield sc, room
41
+
42
+
43
+ def room_type(room):
44
+ return None if room.get("room_type") in (None, "unknown") else room["room_type"].replace("_", " ")
45
+
46
+
47
+ def load(root):
48
+ for sc, room in records(root, "infinigen.json"):
49
+ yield convert_room({**room, "room_height": None, "room_type": room_type(room),
50
+ "furniture": [furniture(f) for f in room["furniture"]]},
51
+ source=SOURCE, uid=sc["sample_id"], group="infinigen:" + sc["scene_id"].split("_", 1)[1],
52
+ subset=sc["source_subset"], boundary_type="proxy", front_offset_deg=-90,
53
+ meta={"scene_id": sc["scene_id"], "front_known": True, "mirrored_frame": True,
54
+ "room_type_source": room.get("room_type_source"), "height_source": "dropped_proxy"})
code/fastfill/adapters/mansionworld.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MansionWorld (AI2-THOR / Objathor procedural buildings), room view json_room.json (27,884 rooms, 1,000 building
2
+ variants of 300 floor plans; building/floor views are the same rooms regrouped).
3
+
4
+ Export (kits/convert_mansionworld.py, enrich_wall_objects.py): meters, Z-up, (x, z)_yup -> (x, -z), room polygon
5
+ recentred on its area centroid. Floor objects: position = THOR position (asset pivot; footprint center within 5 cm),
6
+ z = 0 (bottom), size = padded cm footprint minus 0.10 m, de-rotated (yaws are multiples of 90), height = 2 * center y.
7
+ Yaw = THOR rotation.y, which the (x, -z) map turns into CCW yaw about +Z of asset local +X; front = Unity +Z = local -Y;
8
+ a full THOR rotation (x, y, z) is rot_zxy(y, x, -z) in this frame (Unity applies z, x, y; checked to 0.0).
9
+ Exception: the single custom asset of `toilet_suite` (a stall) is authored backwards: its +Y side touches the wall
10
+ and its paired toilet_suite_door lies on the room side, so it gets +180 deg.
11
+ Wall objects: z = bottom; xy were recentred on the vertex mean instead of the area centroid (bug), corrected here;
12
+ width/length had the floor-footprint padding subtracted although wall footprints are unpadded (bug), added back here.
13
+ Surface (small) objects: source_fields.surface_groups (one per parent floor object, parent id = support_surfaces
14
+ parent_object_id) -> anchor "object", parent = that id, IR id "<small>|<parent>" (a third of the small names repeat
15
+ under another parent of the same room; the 22 exported objects whose raw id repeats get "#k"). Room xy = parent
16
+ position + R(parent yaw) position_local and yaw = parent yaw + yaw_deg_local, both equal to the raw position /
17
+ rotation.y (3,355 objects, max diff 0.0).
18
+ The export keeps only rotation.y; the raw floor_*.json small_objects (same download, joined by "<small>|<parent>"
19
+ within the room) give x/z: 92.2 % of 201,302 are 0, the rest exact 90-deg axis flips (pens, notebooks, phones, frames
20
+ lying flat), none at another angle. Flipped boxes become upright boxes with permuted sizes (internscenes.upright;
21
+ counted in meta.n_permuted_axes); their source front is vertical, so their yaw is only a box orientation. The export
22
+ bottom (support height_m + z_local = raw center y - exported height / 2) is corrected by (exported - true height) / 2:
23
+ flipped bottoms then match the nearest upright neighbour on the same parent to 0.2 mm median (was 6.6 cm). Surface
24
+ objects without a raw record are not emitted (meta.surface_objects_excluded). build.prep keeps the ones whose bottom
25
+ is within 5 cm of the parent box top (the rest stand on shelves / inside furniture).
26
+ Desc: the export's Objathor asset description of floor, wall and surface objects is passed as "desc".
27
+ Room type: every raw floor has exactly one roomType "corridor" room (3,719 of 3,719 floors), its open hub, whatever
28
+ it holds (living_dining_room, open_office_area, master_bedroom, hub_room, ...); for these the room name (room_id minus
29
+ "F<n>_" = raw portable_source_id, 27,884 of 27,884) is the room type. The source value is kept in meta.room_type_src.
30
+ Doors / windows: room["doors"] / ["windows"] = {center_xy, width_m, ...} from the raw doorSegment / windowSegment,
31
+ already in the room's recentred frame (all door centres lie on the polygon ring). Emitted as "structure" boxes:
32
+ local X along the wall = opening width, 0.10 m across the wall, yaw = CCW direction of the nearest polygon edge.
33
+ Windows: z = sill_height_m, height = height_m (from the raw holePolygon). Doors: the export dropped the hole height;
34
+ raw holePolygon / door-database.json give 2.07-2.13 m for every door asset, so DOOR_H = 2.1 (clearance_depth_m is
35
+ the swing clearance, not a thickness). Openings > 0.15 m off the ring (1.8 % of windows: same-named room on another
36
+ floor, matched by the converter with the F<n>_ prefix stripped) are dropped and counted in meta.n_openings_dropped.
37
+ """
38
+ import collections
39
+ import functools
40
+ import json
41
+ import math
42
+ import os
43
+ import re
44
+
45
+ from shapely.geometry import LineString, Point
46
+
47
+ from fastfill.adapters.internscenes import rot_zxy, upright
48
+ from fastfill.adapters.unified import _num, convert_room, iter_records, signed_area
49
+
50
+ SOURCE = "MansionWorld"
51
+ DIR = "imChuling__3D_Room_Collections/MansionWorld_exported"
52
+ FLOOR_PREFIX = re.compile(r"^F\d+_")
53
+ PAD, MIN_DIM = 0.10, 0.01 # enrich_wall_objects.py FOOTPRINT_PADDING_M / MIN_DIM_M
54
+ DOOR_H, OPENING_T, OFF_WALL = 2.1, 0.10, 0.15
55
+
56
+
57
+ def fix(f, vx, vy):
58
+ f = dict(f)
59
+ if f["source_fields"].get("mount_type") == "wall" and f.get("furniture_position"):
60
+ p = f["furniture_position"]
61
+ f["furniture_position"] = {**p, "x": p["x"] + vx, "y": p["y"] + vy}
62
+ # enrich_wall_objects.py also subtracts the 0.10 m floor-footprint padding, but wall footprints are unpadded
63
+ # (raw sample: footprint == annotation bbox for 1,652/1,652). Values clamped to 0.01 stay (true extent <= 0.11).
64
+ s = f.get("furniture_size")
65
+ if s:
66
+ f["furniture_size"] = {**s, **{k: s[k] + PAD for k in ("width", "length") if s[k] > MIN_DIM + 1e-6}}
67
+ if f["furniture_category"] == "toilet_suite" and f.get("furniture_rotation"):
68
+ f["furniture_rotation"] = {**f["furniture_rotation"], "z": f["furniture_rotation"]["z"] + 180}
69
+ if f["furniture_category"].startswith("vertical_core_"): # stair / elevator cores: let the structure filter see them
70
+ f["furniture_category"] = f["furniture_category"].replace("_", " ")
71
+ return f
72
+
73
+
74
+ def openings(room):
75
+ """Doors / windows -> (furniture dicts on the wall line, n_dropped). Rotation z = wall direction + 90 so that
76
+ convert_room(front_offset_deg=-90) yields yaw = wall direction and size = [width_m, OPENING_T, height]."""
77
+ b = room.get("room_boundary") or []
78
+ if len(b) < 3:
79
+ return [], 0
80
+ if signed_area(b) < 0:
81
+ b = b[::-1]
82
+ edges = [LineString(s) for s in zip(b, b[1:] + b[:1])]
83
+ out, dropped = [], 0
84
+ for kind, key, items in (("door", "door_id", room.get("doors")), ("window", "window_id", room.get("windows"))):
85
+ for e in items or []:
86
+ x, y = e.get("center_xy") or (None, None)
87
+ z, h = (0.0, DOOR_H) if kind == "door" else (e.get("sill_height_m"), e.get("height_m"))
88
+ edge = min(edges, key=lambda ln: ln.distance(Point(x, y))) if _num(x) and _num(y) else None
89
+ if not all(_num(v) for v in (x, y, e.get("width_m"), z, h)) or edge.distance(Point(x, y)) > OFF_WALL:
90
+ dropped += 1
91
+ continue
92
+ (x0, y0), (x1, y1) = edge.coords
93
+ out.append({"furniture_category": kind, "furniture_instance_id": e[key], "structure": True,
94
+ "furniture_position": {"x": x, "y": y, "z": z},
95
+ "furniture_rotation": {"x": 0.0, "y": 0.0, "z": math.degrees(math.atan2(y1 - y0, x1 - x0)) + 90},
96
+ "furniture_size": {"width": OPENING_T, "length": e["width_m"], "height": h}})
97
+ return out, dropped
98
+
99
+
100
+ @functools.lru_cache(maxsize=2) # json_room.json is grouped by building
101
+ def raw_rotations(root, building):
102
+ """{room id: {"<small>|<parent>": [raw rotation {x, y, z}, ...]}} from the building's floor_*.json small_objects
103
+ (id "<small>|<parent> (<room>)"; roomId may lack the F<n>_ prefix, which is unique per floor once stripped)."""
104
+ out, d = {}, os.path.join(root, DIR, "data/MansionWorld/mansionworld", building)
105
+ for fn in os.listdir(d):
106
+ if fn.startswith("floor_") and fn.endswith(".json"):
107
+ with open(os.path.join(d, fn)) as fh:
108
+ raw = json.load(fh)
109
+ rid = {FLOOR_PREFIX.sub("", r["id"]): r["id"] for r in raw["rooms"]}
110
+ for s in raw.get("small_objects") or []:
111
+ r = rid.get(FLOOR_PREFIX.sub("", s.get("roomId", "")))
112
+ if r:
113
+ out.setdefault(r, {}).setdefault(s["id"].split(" (", 1)[0], []).append(s["rotation"])
114
+ return out
115
+
116
+
117
+ def surface(room, rots):
118
+ """surface_groups -> (furniture dicts with "parent", n without a raw rotation); rots = raw_rotations()[room id].
119
+ Positions / yaws are composed from the parent's unmodified export pose (see module doc)."""
120
+ furn = {f["furniture_instance_id"]: f for f in room["furniture"]}
121
+ tops = {t["surface_id"]: t for t in room["source_fields"].get("support_surfaces") or []}
122
+ out, missing, seen = [], 0, collections.Counter()
123
+ for g in room["source_fields"].get("surface_groups") or []:
124
+ top = tops[g["surface_id"]]
125
+ pid = top["parent_object_id"]
126
+ pp, pyaw = furn[pid]["furniture_position"], furn[pid]["furniture_rotation"]["z"]
127
+ c, s = math.cos(math.radians(pyaw)), math.sin(math.radians(pyaw))
128
+ for o in g["objects"]:
129
+ key = f"{o['object_id']}|{pid}"
130
+ seen[key] += 1
131
+ rs = rots.get(key, [])
132
+ if len(rs) < seen[key]:
133
+ missing += 1
134
+ continue
135
+ r = rs[seen[key] - 1]
136
+ R = rot_zxy(*(math.radians(v) for v in (pyaw + o["yaw_deg_local"], r["x"], -r["z"])))
137
+ (sx, sy, sz), yaw, k = upright(R, o["dimensions"])
138
+ (lx, ly), h = o["position_local"], o["dimensions"][2]
139
+ out.append({"furniture_category": o["category"], "parent": pid, "description": o.get("description"),
140
+ "furniture_instance_id": key if seen[key] == 1 else f"{key}#{seen[key]}",
141
+ "furniture_position": {"x": pp["x"] + c * lx - s * ly, "y": pp["y"] + s * lx + c * ly,
142
+ "z": top["height_m"] + o["z_local"] + (h - sz) / 2},
143
+ "furniture_rotation": {"x": 0.0, "y": 0.0, "z": yaw},
144
+ "furniture_size": {"width": sx, "length": sy, "height": sz}, "vertical_axis": k})
145
+ return out, missing
146
+
147
+
148
+ def extra(f):
149
+ if f.get("structure"):
150
+ return {"structure": True}
151
+ if f.get("parent"):
152
+ return {"anchor": "object", "parent": f["parent"], "desc": f.get("description")}
153
+ wall = f["source_fields"].get("mount_type") == "wall"
154
+ return {"anchor": "wall" if wall else "floor", "desc": f.get("description")}
155
+
156
+
157
+ def to_ir(room, scene, rots):
158
+ prov = scene["provenance"]
159
+ building = prov["source_house_id"]
160
+ b = room.get("room_boundary") or []
161
+ vx, vy = (sum(p[0] for p in b) / len(b), sum(p[1] for p in b) / len(b)) if b else (0.0, 0.0)
162
+ boxes, n_dropped = openings(room)
163
+ smalls, missing = surface(room, rots)
164
+ rt = room.get("room_type")
165
+ return convert_room(
166
+ {**room, "furniture": [fix(f, vx, vy) for f in room["furniture"]] + smalls + boxes,
167
+ "room_type": FLOOR_PREFIX.sub("", room["room_id"]) if rt == "corridor" else rt}, source=SOURCE,
168
+ uid=f"MansionWorld::{scene['scene_id']}", group="mansionworld:" + building.split("#")[0],
169
+ boundary_type="polygon", center_z=False, front_offset_deg=-90, extra=extra,
170
+ meta={"building": building, "room_id": room["room_id"], "room_type_src": rt,
171
+ "geometry_hash": prov.get("geometry_hash"), "front_known": True, "n_openings_dropped": n_dropped,
172
+ "surface_objects_excluded": missing, "n_permuted_axes": sum(f["vertical_axis"] != 2 for f in smalls),
173
+ "license_note": "CC BY 4.0 + HF gate: non-commercial research"})
174
+
175
+
176
+ def load(root):
177
+ for scene in iter_records(os.path.join(root, DIR, "json_room.json")):
178
+ rots = raw_rotations(root, scene["provenance"]["source_house_id"])
179
+ for room in scene["rooms"]:
180
+ yield to_ir(room, scene, rots.get(room["room_id"], {}))
181
+
182
+
183
+ if __name__ == "__main__":
184
+ from fastfill.anchors import fixed_geometry
185
+ scene = {"scene_id": "s", "provenance": {"source_house_id": "b#0"}}
186
+ room = {"room_id": "r", "room_type": "office", "room_height": 3.2, "furniture": [],
187
+ "room_boundary": [[-1, -1], [1, -1], [1, 1], [-1, 1]], "source_fields": {},
188
+ "doors": [{"door_id": "d", "center_xy": [1.0, 0.0], "width_m": 0.9, "clearance_depth_m": 0.9}],
189
+ "windows": [{"window_id": "w", "center_xy": [0.0, 1.0], "width_m": 1.2, "sill_height_m": 0.9, "height_m": 1.2},
190
+ {"window_id": "far", "center_xy": [-5.0, 1.0], "width_m": 1.2, "sill_height_m": 0.9, "height_m": 1.2}]}
191
+ ir = to_ir(room, scene, {})
192
+ d, w = ir["objects"]
193
+ assert d["category"] == "door" and d["structure"] and d["size"] == [0.9, OPENING_T, DOOR_H], d
194
+ assert d["pos"] == [2.0, 1.0, 0.0] and abs(d["yaw"] - math.pi / 2) < 1e-9, d # right wall runs +Y (CCW)
195
+ assert w["size"] == [1.2, OPENING_T, 1.2] and w["pos"] == [1.0, 2.0, 0.9] and abs(w["yaw"] - math.pi) < 1e-9, w
196
+ assert ir["meta"]["n_openings_dropped"] == 1 and ir["meta"]["n_incomplete"] == 0
197
+ assert [o["id"] for o in fixed_geometry(ir)] == ["d", "w"]
198
+ assert ir["room_type"] == "office" and ir["meta"]["room_type_src"] == "office"
199
+
200
+ # surface objects: desk (THOR yaw 90) at (0.5, 0.2) holding an upright cup, a notebook stored Z-up and flipped
201
+ # x = 90 (exported dims [w, d, h] = [0.2, 0.02, 0.3]: its 0.02 side is the vertical one) and two "pen-0" whose
202
+ # raw id repeats; a pen on a shelf has no raw record. Export bottom = height_m + z_local = raw y - dims[2] / 2.
203
+ desk = {"furniture_category": "desk", "furniture_instance_id": "desk-0", "description": "A wooden desk.",
204
+ "furniture_position": {"x": 0.5, "y": 0.2, "z": 0.0}, "furniture_rotation": {"x": 0, "y": 0, "z": 90},
205
+ "furniture_size": {"width": 1.2, "length": 0.6, "height": 0.75}, "source_fields": {"mount_type": "floor"}}
206
+ obj = lambda n, dims, loc, yl, zl: {"object_id": n, "category": n[:-2], "dimensions": dims, "position_local": loc,
207
+ "yaw_deg_local": yl, "z_local": zl, "description": f"A {n[:-2]}."}
208
+ room = {**room, "room_id": "F2_living_dining_room", "room_type": "corridor", "doors": [], "windows": [],
209
+ "furniture": [desk], "source_fields": {
210
+ "support_surfaces": [{"surface_id": "desk-0/top", "parent_object_id": "desk-0", "height_m": 0.75}],
211
+ "surface_groups": [{"surface_id": "desk-0/top", "objects": [
212
+ obj("cup-0", [0.1, 0.08, 0.12], [0.2, 0.0], 10.0, 0.0),
213
+ obj("notebook-0", [0.2, 0.02, 0.3], [-0.3, 0.1], 0.0, -0.14),
214
+ obj("pen-0", [0.14, 0.01, 0.01], [0.0, 0.0], 0.0, 0.0),
215
+ obj("pen-0", [0.14, 0.01, 0.01], [0.1, 0.0], 0.0, 0.0),
216
+ obj("pen-1", [0.14, 0.01, 0.01], [0.0, 0.0], 0.0, -0.4)]}]}}
217
+ rots = {"cup-0|desk-0": [{"x": 0, "y": 100, "z": 0}], "notebook-0|desk-0": [{"x": 90, "y": 90, "z": 0}],
218
+ "pen-0|desk-0": [{"x": 0, "y": 90, "z": 0}] * 2}
219
+ ir = to_ir(room, scene, rots)
220
+ assert ir["room_type"] == "living_dining_room" and ir["meta"]["room_type_src"] == "corridor", ir["room_type"]
221
+ assert ir["meta"]["surface_objects_excluded"] == 1 and ir["meta"]["n_permuted_axes"] == 1, ir["meta"]
222
+ by = {o["id"]: o for o in ir["objects"]}
223
+ assert list(by) == ["desk-0", "cup-0|desk-0", "notebook-0|desk-0", "pen-0|desk-0", "pen-0|desk-0#2"], list(by)
224
+ assert by["desk-0"]["anchor"] == "floor" and by["desk-0"]["desc"] == "A wooden desk."
225
+ close = lambda u, v: all(abs(x - y) < 1e-9 for x, y in zip(u, v))
226
+ cup, nb = by["cup-0|desk-0"], by["notebook-0|desk-0"]
227
+ assert (cup["anchor"], cup["parent"], cup["desc"], cup["tilted"]) == ("object", "desk-0", "A cup.", False), cup
228
+ # R(90) (0.2, 0) = (0, 0.2): room (0.5, 0.4) -> AABB-shifted by (-1, -1); yaw 90 + 10 - 90 (front offset); d, w, h
229
+ assert close(cup["pos"], [1.5, 1.4, 0.75]) and abs(cup["yaw"] - math.radians(10)) < 1e-9, cup
230
+ assert close(cup["size"], [0.08, 0.1, 0.12]), cup
231
+ # R(90) (-0.3, 0.1) = (-0.1, -0.3); lying flat: true height 0.02, bottom 0.61 + (0.3 - 0.02) / 2 = 0.75
232
+ assert close(nb["pos"], [1.4, 0.9, 0.75]) and close(nb["size"], [0.3, 0.2, 0.02]), nb
233
+ assert abs(math.sin(nb["yaw"])) < 1e-9 < math.cos(nb["yaw"]) and nb["parent"] == "desk-0", nb # heading of local X
234
+ print("mansionworld.py self-check ok")
code/fastfill/adapters/multiscan.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MultiScan (XXXpilar/multiscan-clean repackaging): 273 scans of 117 real spaces, one scan = one space.
2
+
3
+ objects.csv: obb_center (box CENTER), obb_half_extents (HALF sizes along the box axes), obb_axes = 9 floats =
4
+ the three box axes one after another (world directions), plus annotated `front` / `up` vectors; `up` is box
5
+ axis 1 for 10,957/10,957 objects and `front` is one of the box axes. Z-up meters, scan-local frame whose floor
6
+ is NOT at z = 0. regions.csv floor_height is the TOP of the floor-slab AABB (slabs are 0.07-0.46 m thick scan
7
+ noise) and furniture bottoms sit a median 0.09 m below it, so the floor level used is the median bottom of the
8
+ objects that relations.csv marks rests_on_floor (>= 1 per scan; geometric relation of the repackaging).
9
+ Boundary: regions.csv poly_loop is the convex hull of the floor-slab OBBs, but floors are annotated only where
10
+ the scan saw them (22% of in-scope objects stick out of it by a median 0.24 m). The boundary used is the convex
11
+ hull of poly_loop + the wall-segment endpoints (structure only) -> "hull" when every floor-standing
12
+ non-architectural object fits inside it (+5 cm); otherwise the room gets boundary_type "proxy" with that hull
13
+ extended by those objects' footprints (object extent, cannot prove in-bounds validity).
14
+ height = ceiling_height - floor level, only when height_reliable. Group = multiscan:<scene_id> (rescans _NN share it).
15
+ """
16
+ import csv
17
+ import json
18
+ import math
19
+ import os
20
+
21
+ import numpy as np
22
+ from shapely.geometry import MultiPoint, Polygon
23
+
24
+ from fastfill.adapters.internscenes import TILT_TOL_DEG, tilted_bbox, upright
25
+ from fastfill.adapters.unified import convert_room
26
+ from fastfill.scene import footprint
27
+
28
+ SOURCE = "MultiScan"
29
+ DIR = "XXXpilar__multiscan-clean"
30
+
31
+
32
+ def box(o):
33
+ """objects.csv row -> unified furniture dict (bottom-center position, upright yaw from the annotated front)."""
34
+ axes = np.array(json.loads(o["obb_axes"]), dtype=float).reshape(3, 3) # rows = box axes
35
+ size = [2 * v for v in json.loads(o["obb_half_extents"])]
36
+ c = json.loads(o["obb_center"])
37
+ fr = np.array(json.loads(o["front"]), dtype=float)
38
+ d = axes @ fr
39
+ f = int(np.argmax(np.abs(d)))
40
+ order = [f] + [i for i in range(3) if i != f] # front axis first = local +X
41
+ R = np.stack([axes[i] * (np.sign(d[f]) if i == f else 1) for i in order], axis=1)
42
+ up = upright(R, [size[i] for i in order])
43
+ if up is None: # tilted: keep a non-zero tilt
44
+ sz, yaw, k, tilt = tilted_bbox(R, [size[i] for i in order])
45
+ rot = {"x": tilt, "y": 0.0, "z": yaw}
46
+ else:
47
+ sz, yaw, k = up
48
+ rot = {"x": 0.0, "y": 0.0, "z": yaw}
49
+ bottom = c[2] - sz[2] / 2
50
+ return {"furniture_category": o["category"].replace("_", " "), "furniture_instance_id": f"o{o['object_id']}",
51
+ "furniture_position": {"x": c[0], "y": c[1], "z": bottom}, "furniture_rotation": rot,
52
+ "furniture_size": {"width": sz[0], "length": sz[1], "height": sz[2]},
53
+ "front_vertical": k == 0,
54
+ "structure": o["is_architectural"] == "True" or o["is_opening"] == "True"}
55
+
56
+
57
+ def boundary(g, furn, tol=0.05):
58
+ """(polygon, type, n_outside) - see module docstring."""
59
+ walls = json.loads(g["wall_segments"] or "[]")
60
+ pts = json.loads(g["poly_loop"]) + [w[k] for w in walls for k in ("start", "end")]
61
+ hull = MultiPoint([tuple(p) for p in pts]).convex_hull
62
+ fps = [Polygon(footprint({"pos": [f["furniture_position"]["x"], f["furniture_position"]["y"]],
63
+ "size": [f["furniture_size"]["width"], f["furniture_size"]["length"]],
64
+ "yaw": math.radians(f["furniture_rotation"]["z"])}))
65
+ for f in furn if not f["structure"] and f["furniture_position"]["z"] < tol]
66
+ out = [fp for fp in fps if not hull.buffer(tol).contains(fp)]
67
+ if out:
68
+ hull = MultiPoint(list(hull.exterior.coords) + [c for fp in out for c in fp.exterior.coords]).convex_hull
69
+ return [list(c) for c in hull.exterior.coords[:-1]], ("proxy" if out else "hull"), len(out)
70
+
71
+
72
+ def load(root):
73
+ base = os.path.join(root, DIR)
74
+ scans = {r["scan_id"]: r for r in csv.DictReader(open(os.path.join(base, "scans.csv")))}
75
+ objs, on_floor = {}, {}
76
+ for o in csv.DictReader(open(os.path.join(base, "objects.csv"))):
77
+ objs.setdefault(o["scan_id"], []).append(o)
78
+ for r in csv.DictReader(open(os.path.join(base, "relations.csv"))):
79
+ if r["relation"] == "rests_on_floor":
80
+ on_floor.setdefault(r["scan_id"], set()).add(r["subject_id"])
81
+ for g in csv.DictReader(open(os.path.join(base, "regions.csv"))):
82
+ sid, s = g["scan_id"], scans[g["scan_id"]]
83
+ bottoms = [json.loads(o["aabb_min"])[2] for o in objs.get(sid, []) if o["object_id"] in on_floor.get(sid, ())]
84
+ fz = float(np.median(bottoms)) if bottoms else float(g["floor_height"])
85
+ furn = [box(o) for o in objs.get(sid, [])]
86
+ for f in furn:
87
+ f["furniture_position"]["z"] -= fz
88
+ poly, btype, n_out = boundary(g, furn)
89
+ # source flags is_architectural (wall/floor/ceiling/beam/pillar/windowsill/...) and is_opening are kept as
90
+ # "structure": build never places them but must see them (a floor pillar rejects the room)
91
+ room = {"room_id": sid, "room_type": s["room_type"].lower(), "room_boundary": poly,
92
+ # extrusion_height = ceiling - floor_height (slab top); measure from the floor level used for z
93
+ "room_height": float(g["ceiling_height"]) - fz if g["height_reliable"] == "True" else None,
94
+ "furniture": furn}
95
+ yield convert_room(room, source=SOURCE, uid=f"{SOURCE}::{sid}", group=f"multiscan:{s['scene_id']}",
96
+ boundary_type=btype, extra=lambda f: {
97
+ **({"structure": True} if f["structure"] else {}),
98
+ **({"front_known": False} if f["front_vertical"] else {})},
99
+ meta={"scan_id": sid, "scene_id": s["scene_id"], "device": s["device"],
100
+ "floor_z": fz, "floor_z_source": "median bottom of rests_on_floor objects" if bottoms
101
+ else "regions.floor_height", "floor_height": float(g["floor_height"]), "poly_source": g["poly_source"],
102
+ "boundary_source": "convex hull of floor slabs + wall segments"
103
+ + (" + floor objects outside it" if n_out else ""), "n_floor_objects_outside_structure": n_out,
104
+ "height_reliable": g["height_reliable"] == "True", "front_known": True,
105
+ "front_source": "annotated front vector", "tilt_tol_deg": TILT_TOL_DEG,
106
+ "n_front_vertical": sum(f["front_vertical"] for f in furn)})
107
+
108
+
109
+ if __name__ == "__main__":
110
+ # bag from scene_00000_00: up = axis 1, front = axis 2 -> yaw of the front vector, size [2*h2, 2*h0, 2*h1]
111
+ row = {"obb_axes": "[0.9861942396120026, 0.16559263798884283, 0, 0, 0, 1, 0.16559263798884283, -0.9861942396120026, 0]",
112
+ "obb_half_extents": "[0.126, 0.166, 0.0795]", "obb_center": "[0, 0, 1]", "category": "shoe_box",
113
+ "front": "[0.16559263798884283, -0.9861942396120026, 0]", "object_id": "2",
114
+ "is_architectural": "False", "is_opening": "False"}
115
+ b = box(row)
116
+ assert b["furniture_category"] == "shoe box" and b["furniture_rotation"]["x"] == 0
117
+ assert abs(b["furniture_rotation"]["z"] - math.degrees(math.atan2(-0.98619, 0.16559))) < 1e-3
118
+ assert [round(v, 3) for v in b["furniture_size"].values()] == [0.159, 0.252, 0.332]
119
+ assert abs(b["furniture_position"]["z"] - (1 - 0.166)) < 1e-9
120
+ print("multiscan.py self-check ok")
code/fastfill/adapters/optiscene_holodeck.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OptiScene / 3D-SynthPlace, HOLODECK subset: 9,977 generated single rooms (Objaverse assets, floor objects only).
2
+
3
+ Raw fields (source_fields.layout_object, see OptiScene/README.md and gprompt.py): Unity/AI2-THOR frame = y-up,
4
+ LEFT-handed; position {x, y, z} m with y = 0 for every object (bottom) and x/z = box center; rotation.y = yaw in
5
+ degrees (clockwise seen from above); bbox = [height, width, depth] cm along the mesh y, x, z.
6
+ LH (x, y, z) -> RH Z-up (x, z, y) is a proper change of frame (what the export stored), but the yaw sign flips:
7
+ yaw of mesh +X in the IR = -rotation.y. Holodeck meshes face mesh -Z = IR local -Y -> front_offset_deg = -90.
8
+ The export's furniture_rotation.z (= +rotation.y) and size mapping are therefore not used.
9
+ """
10
+ import os
11
+
12
+ from fastfill.adapters.unified import convert_room, iter_records
13
+
14
+ SOURCE = "OptiScene_holodeck"
15
+ OPTI = "imChuling__3D_Room_Collections/OptiScene_exported"
16
+
17
+
18
+ def raw_furniture(f):
19
+ lo = f["source_fields"]["layout_object"]
20
+ p, r, b = lo.get("position") or {}, lo.get("rotation") or {}, lo.get("bbox")
21
+ return {**f,
22
+ "furniture_position": {"x": p.get("x"), "y": p.get("z"), "z": p.get("y")},
23
+ "furniture_rotation": {"x": r.get("x", 0), "y": r.get("z", 0),
24
+ "z": -r["y"] if isinstance(r.get("y"), (int, float)) else None},
25
+ "furniture_size": {"width": b[1] / 100, "length": b[2] / 100, "height": b[0] / 100} if b and len(b) == 3 else None}
26
+
27
+
28
+ def load(root):
29
+ for scene in iter_records(os.path.join(root, OPTI, "holodeck.json")):
30
+ for room in scene["rooms"]:
31
+ yield convert_room({**room, "furniture": [raw_furniture(f) for f in room["furniture"]]},
32
+ source=SOURCE, uid=f"optiscene:{scene['scene_id']}",
33
+ group=f"holodeck:{scene['scene_id']}", subset="HOLODECK", boundary_type="polygon",
34
+ front_offset_deg=-90, extra=lambda f: {"desc": f.get("description")},
35
+ meta={"source_file": scene["provenance"]["source_file"], "front_known": True,
36
+ "size_note": "Holodeck bbox, padded ~11 cm in width/depth (see report)"})
37
+
38
+
39
+ if __name__ == "__main__":
40
+ f = {"source_fields": {"layout_object": {"position": {"x": 1, "y": 0, "z": 2}, "rotation": {"x": 0, "y": 90, "z": 0},
41
+ "bbox": [75, 77, 174]}}}
42
+ g = raw_furniture(f)
43
+ assert g["furniture_position"] == {"x": 1, "y": 2, "z": 0} and g["furniture_rotation"]["z"] == -90
44
+ assert g["furniture_size"] == {"width": 0.77, "length": 1.74, "height": 0.75}
45
+ print("optiscene_holodeck self-check ok")
code/fastfill/adapters/sage10k.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SAGE-10k (agentic generation, TRELLIS assets): 9,987 single-room scenes + 13 multi-room scenes (40 rooms).
2
+
3
+ Aggregate json_single.json / json_multi_split.json (aggregate_scenes.py): meters, Z-up, room_boundary = ordered wall
4
+ centreline endpoints (all rooms are 4-corner rectangles), position = bottom center (floor objects z = 0, equals the
5
+ cm footprint center of placement_constraints), rotation = Euler deg applied as Rz Ry Rx (kits/tex_utils_local.py),
6
+ dimensions = local X/Y/Z extents. Front = local +Y (front test), so front_offset_deg = 90.
7
+ Support: the aggregate drops place_id; it is read from the per-scene layout_*.json when present
8
+ (floor | wall | <instance id>), otherwise anchors are left to fastfill.anchors.
9
+ Doors: the aggregate drops walls, so doors come from the per-scene layout (same frame): centre = wall start +
10
+ position_on_wall * (end - start), bottom = wall base z, extent width x wall thickness x height (kits/tex_utils_local.py
11
+ create_door_mesh). Emitted as structure boxes. Layout `windows` are empty in the export; windows exist only as
12
+ furniture objects, which are kept as they are.
13
+ Descriptions: every layout object has one ("description"); passed through as desc when the layout is id-aligned.
14
+ Tilt: floor/wall objects are exactly upright; objects resting on objects carry x/y tilt from the physics check.
15
+ Tilt <= TILT_DEG is treated as settling noise (zeroed); an axis permutation within TILT_DEG (1,237 objects turned
16
+ 90 deg onto a side) is an exact upright box. A larger tilt becomes the yaw-aligned upright bounding box of the 8
17
+ corners (`upright_box`), flagged tilted, with tilt_deg and the source rotation rot_src kept. The rotation is about the
18
+ object origin = bottom centre of its local box (SAGE server code, EmbodiedGen/gentask_baseline/sage/server:
19
+ objects/object_generation.py centres x/y and sets min z = 0; the physics check returns that origin's transform,
20
+ isaac_sim_mcp_extension/extension.py, scipy 'xyz' = Rz Ry Rx).
21
+ Support (`rest`): yet the stored pose keeps the origin on the support whatever the tilt, so the rotated box sinks in:
22
+ over 11,855 tilted (>= 5 deg) children whose parent top is confirmed by an untilted sibling, origin - top has median
23
+ -0.3..+0.4 cm in every lift bin up to 12 cm (corr 0.07), and a wine bottle lying on a hutch has its axis on the hutch
24
+ top. The box is kept whole (the source's extents) and stood on its support: its bottom is raised to the place_id
25
+ parent's top, or to the origin (the stored contact height) when the parent top is higher (a shelf or recess inside the
26
+ parent's box); "z_lift" records the rise. Build's 5 cm top-surface test then sees the source's contact height, not a
27
+ corner that sank 4-9 cm into the desk. Boxes turned exactly onto a side rest on their lowest face (90% of 107 on
28
+ confirmed tops), so min(parent top, origin) leaves them as they are.
29
+ """
30
+ import collections
31
+ import json
32
+ import math
33
+ import os
34
+
35
+ import numpy as np
36
+
37
+ from fastfill.adapters.internscenes import tilted_bbox, upright
38
+ from fastfill.adapters.unified import convert_room, iter_records
39
+
40
+ SOURCE = "SAGE-10k"
41
+ TILT_DEG = 2.0
42
+
43
+
44
+ def tilt_deg(rot):
45
+ return math.degrees(math.acos(max(-1.0, min(1.0, math.cos(math.radians(rot["x"])) * math.cos(math.radians(rot["y"]))))))
46
+
47
+
48
+ def rot_zyx(r):
49
+ """Euler deg {x, y, z} -> world-from-local R = Rz Ry Rx."""
50
+ (cx, sx), (cy, sy), (cz, sz) = [(math.cos(math.radians(r[a])), math.sin(math.radians(r[a]))) for a in "xyz"]
51
+ return np.array([[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]]) @ np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]) @ \
52
+ np.array([[1, 0, 0], [0, cx, -sx], [0, sx, cx]])
53
+
54
+
55
+ def upright_box(f, cut=None):
56
+ """Tilted furniture (Euler deg Rz Ry Rx about the bottom centre of its local box, size = local X/Y/Z extents,
57
+ front = local +Y; SAGE and SceneSmith) -> the same dict as an upright box, front passed as local X to the
58
+ internscenes rules (yaw = front heading, or local X heading when the front is the near-vertical axis):
59
+ a local axis within TILT_DEG of vertical -> the exact box with permuted sizes (`upright`), rotation {0, 0, yaw - 90},
60
+ not tilted; otherwise the yaw-aligned bounding box of the 8 corners (as `tilted_bbox`) minus the part below `cut`
61
+ (the height below which the source says the object has nothing: its support), rotation {x: None, y: None,
62
+ z: yaw - 90} so that convert_room(front_offset_deg=90) gives yaw back and flags it tilted. Position = bottom centre
63
+ of the new box; "_tilt" = provenance (rot_src, and tilt_deg for tilted boxes)."""
64
+ r, p, s = f.get("furniture_rotation"), f.get("furniture_position"), f.get("furniture_size")
65
+ if not (r and p and s):
66
+ return f
67
+ R, S, o = rot_zyx(r)[:, [1, 0, 2]], [s["length"], s["width"], s["height"]], np.array([p["x"], p["y"], p["z"]])
68
+ up = upright(R, S, TILT_DEG)
69
+ if up:
70
+ (l, w, h), yaw, _ = up
71
+ c = o + R[:, 2] * S[2] / 2 # box centre
72
+ return {**f, "furniture_position": {"x": float(c[0]), "y": float(c[1]), "z": float(c[2]) - h / 2},
73
+ "furniture_rotation": {"x": 0, "y": 0, "z": yaw - 90},
74
+ "furniture_size": {"width": w, "length": l, "height": h}, "_tilt": {"rot_src": dict(r)}}
75
+ _, yaw, _, tilt = tilted_bbox(R, S)
76
+ C = np.array([[x, y, z] for x in (-S[0] / 2, S[0] / 2) for y in (-S[1] / 2, S[1] / 2) for z in (0, S[2])]) @ R.T
77
+ z0 = cut - o[2] if cut is not None and C[:, 2].min() < cut - o[2] < C[:, 2].max() else C[:, 2].min()
78
+ edges = [(i, i | b) for i in range(8) for b in (1, 2, 4) if not i & b]
79
+ P = [c for c in C if c[2] >= z0] + [C[i] + (C[j] - C[i]) * (z0 - C[i, 2]) / (C[j, 2] - C[i, 2])
80
+ for i, j in edges if (C[i, 2] - z0) * (C[j, 2] - z0) < 0]
81
+ D = np.array([[math.cos(math.radians(yaw)), math.sin(math.radians(yaw)), 0],
82
+ [-math.sin(math.radians(yaw)), math.cos(math.radians(yaw)), 0], [0, 0, 1]]) # u, v, z rows
83
+ lo, hi = (np.array(P) @ D.T).min(0), (np.array(P) @ D.T).max(0)
84
+ b = o + D.T @ [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, lo[2]] # bottom centre
85
+ return {**f, "furniture_position": {"x": float(b[0]), "y": float(b[1]), "z": float(b[2])},
86
+ "furniture_rotation": {"x": None, "y": None, "z": yaw - 90},
87
+ "furniture_size": {"width": float(hi[1] - lo[1]), "length": float(hi[0] - lo[0]),
88
+ "height": float(hi[2] - lo[2])},
89
+ "_tilt": {"tilt_deg": tilt, "rot_src": dict(r)}}
90
+
91
+
92
+ def rest(f, parent):
93
+ """upright_box(f) stood on its support (module doc): its bottom raised to min(place_id parent's top, origin z), never
94
+ lowered. The box as it is when the parent is missing or not upright (its box top is no surface)."""
95
+ g = upright_box(f)
96
+ if parent is None or tilt_deg(parent["furniture_rotation"]) > TILT_DEG:
97
+ return g
98
+ s = min(parent["furniture_position"]["z"] + parent["furniture_size"]["height"], f["furniture_position"]["z"])
99
+ b = g["furniture_position"]["z"]
100
+ if b >= s:
101
+ return g
102
+ return {**g, "furniture_position": {**g["furniture_position"], "z": s}, "_tilt": {**g["_tilt"], "z_lift": s - b}}
103
+
104
+
105
+ def doors(lroom):
106
+ """Layout doors as unified-schema furniture. convert_room(front_offset_deg=90) swaps width/length and adds 90 deg,
107
+ so width=thickness / length=door width / rotation=wall-90 come out as size [door width, thickness, height] with
108
+ yaw = wall direction (long side along the wall)."""
109
+ walls = {w["id"]: w for w in lroom.get("walls") or []}
110
+ out = []
111
+ for d in lroom.get("doors") or []:
112
+ w = walls.get(d.get("wall_id"))
113
+ if w is None:
114
+ continue
115
+ s, e, t = w["start_point"], w["end_point"], d["position_on_wall"]
116
+ out.append({"furniture_category": "door", "furniture_instance_id": d["id"], "_ir": {"structure": True},
117
+ "furniture_position": {"x": s["x"] + (e["x"] - s["x"]) * t, "y": s["y"] + (e["y"] - s["y"]) * t,
118
+ "z": s["z"]},
119
+ "furniture_rotation": {"x": 0, "y": 0,
120
+ "z": math.degrees(math.atan2(e["y"] - s["y"], e["x"] - s["x"])) - 90},
121
+ "furniture_size": {"width": w.get("thickness") or 0.10, "length": d["width"], "height": d["height"]}})
122
+ return out
123
+
124
+
125
+ def load(root):
126
+ base = os.path.join(root, "liantian__3D_Room_Scene_Collections/SAGE10k")
127
+ sdir = os.path.join(base, "sage10k/scenes")
128
+ layouts = {d.split("_", 2)[-1]: os.path.join(sdir, d, d.split("_", 2)[-1] + ".json")
129
+ for d in (os.listdir(sdir) if os.path.isdir(sdir) else [])}
130
+ for fn in ("json_single.json", "json_multi_split.json"):
131
+ for rec in iter_records(os.path.join(base, fn)):
132
+ sid, k = rec["scene_id"], rec["room_index"]
133
+ fids = [f["furniture_instance_id"] for f in rec["furniture"]]
134
+ count = collections.Counter(fids)
135
+ place, lroom = None, {}
136
+ if sid in layouts and os.path.exists(layouts[sid]):
137
+ with open(layouts[sid]) as fh:
138
+ lroom = json.load(fh)["rooms"][k]
139
+ if [o["id"] for o in lroom["objects"]] == fids:
140
+ place = [o.get("place_id") for o in lroom["objects"]]
141
+ n_noise = 0
142
+ furn, seen = [], collections.Counter()
143
+ for j, f in enumerate(rec["furniture"]):
144
+ p = place[j] if place else None
145
+ if p in ("floor", "wall"):
146
+ sup = {"anchor": p}
147
+ elif count.get(p) == 1:
148
+ sup = {"anchor": "object", "parent": p}
149
+ else:
150
+ sup = {} # no evidence, dangling or repeated parent id: inferred downstream
151
+ if place is not None and lroom["objects"][j].get("description"):
152
+ sup["desc"] = lroom["objects"][j]["description"]
153
+ f = {**f, "_ir": sup}
154
+ seen[fids[j]] += 1
155
+ if seen[fids[j]] > 1: # raw ids repeat for copied small objects (663 rooms): keep ids unique
156
+ f["furniture_instance_id"] = f"{fids[j]}#{seen[fids[j]]}"
157
+ r = f.get("furniture_rotation")
158
+ if r and (r["x"] or r["y"]):
159
+ if tilt_deg(r) <= TILT_DEG:
160
+ f["furniture_rotation"] = {"x": 0, "y": 0, "z": r["z"]}
161
+ n_noise += 1
162
+ else:
163
+ f = rest(f, rec["furniture"][fids.index(p)] if sup.get("parent") else None)
164
+ furn.append(f)
165
+ furn += doors(lroom)
166
+
167
+ yield convert_room(
168
+ {**rec, "furniture": furn, "room_boundary": [[q["x"], q["y"]] for q in rec.get("room_boundary") or []]},
169
+ source=SOURCE, uid=f"SAGE-10k::{sid}::{k}", group=f"sage:{sid}",
170
+ subset="multi" if fn == "json_multi_split.json" else "single", boundary_type="polygon",
171
+ center_z=False, front_offset_deg=90, extra=lambda f: {**f["_ir"], **f.get("_tilt", {})},
172
+ meta={"scene_id": sid, "room_index": k, "front_known": True, "tilt_threshold_deg": TILT_DEG,
173
+ "n_tilt_noise_zeroed": n_noise, "anchor_source": "place_id" if place is not None else "inferred",
174
+ "building_style": rec.get("building_style")})
175
+
176
+
177
+ if __name__ == "__main__":
178
+ # 4 x 3 room at (10, 20); door on the bottom wall (+X direction), door on the right wall (+Y direction)
179
+ walls = [{"id": f"w{i}", "start_point": {"x": a[0], "y": a[1], "z": 0.0}, "end_point": {"x": b[0], "y": b[1], "z": 0.0},
180
+ "thickness": 0.1} for i, (a, b) in enumerate([((10, 20), (14, 20)), ((14, 20), (14, 23))])]
181
+ lroom = {"walls": walls, "doors": [{"id": "d0", "wall_id": "w0", "position_on_wall": 0.5, "width": 0.9, "height": 2.1},
182
+ {"id": "d1", "wall_id": "w1", "position_on_wall": 0.25, "width": 0.8, "height": 2.0},
183
+ {"id": "dx", "wall_id": "missing", "position_on_wall": 0.5, "width": 1, "height": 2}]}
184
+ ir = convert_room({"room_boundary": [[10, 20], [14, 20], [14, 23], [10, 23]], "furniture": doors(lroom)},
185
+ source=SOURCE, uid="t", group="g", boundary_type="polygon", center_z=False, front_offset_deg=90,
186
+ extra=lambda f: f["_ir"])
187
+ a, b = ir["objects"]
188
+ close = lambda u, v: all(abs(x - y) < 1e-6 for x, y in zip(u, v))
189
+ assert a["category"] == "door" and a["structure"] and not a["tilted"], a
190
+ assert close(a["pos"], [2, 0, 0]) and close(a["size"], [0.9, 0.1, 2.1]) and abs(math.sin(a["yaw"])) < 1e-9, a
191
+ assert close(b["pos"], [4, 0.75, 0]) and close(b["size"], [0.8, 0.1, 2.0]) and abs(b["yaw"] - math.pi / 2) < 1e-9, b
192
+
193
+ # tilted box -> upright bounding box. Size [1.0, 0.2, 0.1] (front = local +Y), origin (1, 1, 0.5), 30 deg about X:
194
+ # IR size [along front, width, height] = [0.2 c + 0.1 s, 1.0, 0.2 s + 0.1 c], yaw 90, bottom centre (1, 0.975, 0.45)
195
+ def ir_obj(f, cut=None):
196
+ return convert_room({"room_boundary": [[0, 0], [9, 0], [9, 9], [0, 9]], "furniture": [upright_box(f, cut)]},
197
+ source=SOURCE, uid="t", group="g", boundary_type="polygon", center_z=False,
198
+ front_offset_deg=90, extra=lambda f: {**f["_ir"], **f.get("_tilt", {})})["objects"][0]
199
+
200
+ def rec(r, S, p=(1, 1, 0.5)):
201
+ return {"furniture_category": "book", "furniture_position": dict(zip("xyz", p)), "furniture_rotation": r,
202
+ "furniture_size": dict(zip(("width", "length", "height"), S)), "_ir": {"desc": "a book"}}
203
+ s30, c30 = 0.5, math.cos(math.radians(30))
204
+ x30 = rec({"x": 30, "y": 0, "z": 0}, [1.0, 0.2, 0.1])
205
+ o = ir_obj(x30)
206
+ assert o["tilted"] and close(o["size"], [0.2 * c30 + 0.1 * s30, 1.0, 0.2 * s30 + 0.1 * c30]), o
207
+ assert abs(o["yaw"] - math.pi / 2) < 1e-9 and close(o["pos"], [1, 0.975, 0.45]) and o["desc"] == "a book", o
208
+ assert abs(o["tilt_deg"] - 30) < 1e-9 and o["rot_src"] == {"x": 30, "y": 0, "z": 0}, o
209
+ # resting on a support at the origin (0.5) or 2 cm below it: the corners under it are cut, footprint unchanged
210
+ for cut in (0.5, 0.48):
211
+ o = ir_obj(x30, cut)
212
+ assert close(o["size"], [0.2 * c30 + 0.1 * s30, 1.0, 0.1 * s30 + 0.1 * c30 + 0.5 - cut]), o
213
+ assert close(o["pos"], [1, 0.975, cut]) and o["tilted"], o
214
+ assert close(ir_obj(x30, 0.3)["pos"], [1, 0.975, 0.45]) and close(ir_obj(x30, 0.9)["pos"], [1, 0.975, 0.45]) # not through it
215
+ # standing on the support: raised to a parent top at/below the origin, to the origin under a higher top (also when
216
+ # rolled onto its side), never lowered, not raised under a tilted or missing parent
217
+ par = lambda t, r=None: {"furniture_position": {"x": 1, "y": 1, "z": t - 0.4}, "furniture_size": {"height": 0.4},
218
+ "furniture_rotation": r or {"x": 0, "y": 0, "z": 10}}
219
+ rolled, side = rec({"x": 70, "y": 0, "z": 0}, [1.0, 0.2, 0.1]), rec({"x": 90, "y": 0, "z": 0}, [1.0, 0.2, 0.1])
220
+ for f, pa, z in ((x30, par(0.48), 0.48), (x30, par(0.53), 0.5), (x30, par(0.3), 0.45), (x30, None, 0.45),
221
+ (x30, par(0.48, {"x": 20, "y": 0, "z": 0}), 0.45), (rolled, par(0.53), 0.5),
222
+ (rolled, par(0.48), 0.48), (side, par(0.4), 0.4)): # exact side box on its lowest face: as is
223
+ g, h = rest(f, pa), upright_box(f)
224
+ assert g["furniture_size"] == h["furniture_size"] and g["furniture_position"]["x"] == h["furniture_position"]["x"], g
225
+ assert abs(g["furniture_position"]["z"] - z) < 1e-9, (f, pa, g)
226
+ assert abs(g["_tilt"].get("z_lift", 0) - (z - h["furniture_position"]["z"])) < 1e-9, g
227
+ # 90 deg about X (front vertical): exact upright box, sizes [1.0, 0.1, 0.2], yaw 0, centre (1, 0.95, 0.5), not tilted
228
+ o = ir_obj(rec({"x": 90, "y": 0, "z": 0}, [1.0, 0.2, 0.1]))
229
+ assert not o["tilted"] and "tilt_deg" not in o and o["rot_src"]["x"] == 90, o
230
+ assert close(o["size"], [1.0, 0.1, 0.2]) and close(o["pos"], [1, 0.95, 0.4]) and abs(math.sin(o["yaw"])) < 1e-9, o
231
+ # any rotation and cut: the IR box contains every point of the source box above the cut, is tight to it (points
232
+ # sampled on the 12 edges, where the clipped box's vertices lie), its bottom is the cut, and its +X is the front's
233
+ # heading unless the front (local Y) is the near-vertical axis
234
+ rng = np.random.default_rng(0)
235
+ edges = [(i, i | b) for i in range(8) for b in (1, 2, 4) if not i & b]
236
+ t = np.linspace(0, 1, 401)[:, None]
237
+ for k in range(400):
238
+ r, S = dict(zip("xyz", rng.uniform(-180, 180, 3))), rng.uniform(0.02, 1.0, 3)
239
+ R = rot_zyx(r)
240
+ W = np.array([[x, y, z] for x in (-S[0] / 2, S[0] / 2) for y in (-S[1] / 2, S[1] / 2) for z in (0, S[2])]) @ R.T
241
+ cut = 0.7 + rng.uniform(W[:, 2].min(), W[:, 2].max()) if k % 2 else None
242
+ o = ir_obj(rec(r, S, (4, 4, 0.7)), cut)
243
+ if not o["tilted"]:
244
+ continue # an axis within TILT_DEG of vertical: exact box, checked above
245
+ E = np.vstack([W[i] + t * (W[j] - W[i]) for i, j in edges]) + [4, 4, 0.7]
246
+ E = E[E[:, 2] >= (cut if cut is not None else -1.0)]
247
+ cy, sy = math.cos(o["yaw"]), math.sin(o["yaw"])
248
+ L = (E - o["pos"]) @ np.array([[cy, sy, 0], [-sy, cy, 0], [0, 0, 1]]).T
249
+ lo, hi = np.array([-o["size"][0] / 2, -o["size"][1] / 2, 0]), np.array([o["size"][0] / 2, o["size"][1] / 2, o["size"][2]])
250
+ assert (L.min(0) > lo - 1e-9).all() and (L.max(0) < hi + 1e-9).all(), (r, S, cut, o)
251
+ assert np.allclose(L.min(0), lo, atol=3e-3) and np.allclose(L.max(0), hi, atol=3e-3), (r, S, cut, o)
252
+ assert cut is None or abs(o["pos"][2] - cut) < 1e-9, (r, S, cut, o)
253
+ if np.argmax(np.abs(R[2])) != 1:
254
+ assert abs((math.atan2(R[1, 1], R[0, 1]) - o["yaw"] + math.pi) % (2 * math.pi) - math.pi) < 1e-9, (r, o)
255
+ print("sage10k.py self-check ok")
code/fastfill/adapters/scan2cad.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scan2CAD CAD alignments on ScanNet v2 scans (SceneCAD_Scan2CAD_exported). Auxiliary tier.
2
+
3
+ Export (kits aggregate_scenecad_scan2cad.py): meters, Z-up, ScanNet axisAlignment-applied scan frame.
4
+ scan2cad_alignments[].furniture_position = oriented CAD bbox CENTER (position_type), size = full CAD bbox
5
+ extents along the object's canonical Z-up axes [2*bx*sx, 2*bz*sz, 2*by*sy], rotation = Rz*Ry*Rx Euler of
6
+ rotation_matrix_zup_scan_local, whose columns are the object's local X / Y / Z (local +Y = ShapeNet -Z).
7
+ Front: ShapeNet canonical front = CAD -Z = local +Y (front test: at -90 beds/bookshelves touched the wall with +X), so front_offset_deg = +90.
8
+
9
+ Boundary: the SceneCAD floor plane polygon (human layout annotation, 994 scans) -> "polygon"; otherwise the
10
+ exported convex hull of ScanNet floor vertices, which misses occluded floor (38% of CAD footprints leave it vs 14%
11
+ for the SceneCAD polygon, mostly < 10 cm wall penetration) and so is typed "proxy".
12
+ Limitations (why B_aux): one record = one whole ScanNet scan (is_true_room=false), and only the ~30% of objects
13
+ that were matched to a ShapeNet CAD are present.
14
+ Scan data noise: per-object tilt median 3 deg (9-DoF fit); tilts <= MAX_TILT_DEG are treated as upright.
15
+ Bottoms scatter +-10 cm around the floor, so z is measured from the per-scan median bottom of floor-standing
16
+ categories and bottoms within FLOOR_SNAP of it are set to 0. Only bottoms within FLOOR_BAND of z = 0 vote: the
17
+ axisAlignment frame already puts the floor there (SceneCAD floor plane z p1..p99 = -0.20..0.07), and an unbanded
18
+ median followed stacked/wall-mounted CADs (scene0074_01: one shelf at 2.0 m -> floor_z 2.0; 47 scans |floor_z| > 0.2). CAD fits with an axis < 1 mm (a trs.scale axis of
19
+ 1e-14..1e-8, 75 objects, mostly displays) have no usable size: their size is dropped, so the room counts as incomplete.
20
+ """
21
+ import math
22
+ import os
23
+ import statistics
24
+
25
+ from shapely.geometry import Polygon
26
+
27
+ from fastfill.adapters.unified import convert_room, iter_records
28
+
29
+ SOURCE = "Scan2CAD"
30
+ PATH = "imChuling__3D_Room_Collections/SceneCAD_Scan2CAD_exported/scenecad_scan2cad_combined.json"
31
+ MAX_TILT_DEG = 10.0 # p95 of |tilt| over all 14,225 alignments is 9.7 deg
32
+ FLOOR_SNAP = 0.15
33
+ FLOOR_BAND = 0.25
34
+ FLOOR_CATS = {"chair", "table", "sofa", "bed", "bookshelf", "file_cabinet", "trash_can", "washer",
35
+ "dishwasher", "stove", "piano", "bench"}
36
+
37
+
38
+ def tilt_deg(a):
39
+ return math.degrees(math.acos(max(-1.0, min(1.0, a["source_fields"]["rotation_matrix_zup_scan_local"][2][2]))))
40
+
41
+
42
+ def shell_floor(shell):
43
+ """XY ring of the single horizontal SceneCAD plane at the bottom of the shell, or None."""
44
+ if not shell:
45
+ return None
46
+ v = shell["vertices_zup_m"]
47
+ zmin = min(p[2] for p in v)
48
+ lows = []
49
+ for q in shell["planes"]:
50
+ a, b, c = (v[i] for i in q[:3])
51
+ n = [(b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]),
52
+ (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]),
53
+ (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])]
54
+ if abs(n[2]) > 0.9 * math.hypot(*n) and sum(v[i][2] for i in q) / len(q) < zmin + 0.3:
55
+ lows.append([[v[i][0], v[i][1]] for i in q])
56
+ if len(lows) != 1 or not Polygon(lows[0]).is_valid:
57
+ return None
58
+ return lows[0]
59
+
60
+
61
+ def load(root):
62
+ for s in iter_records(os.path.join(root, PATH)):
63
+ room = s["rooms"][0]
64
+ al = room["scan2cad_alignments"]
65
+ if not al: # scenecad_only scans: shell without objects
66
+ continue
67
+ bottoms = [a["furniture_position"]["z"] - a["furniture_size"]["height"] / 2 for a in al
68
+ if a["furniture_category"] in FLOOR_CATS and tilt_deg(a) <= MAX_TILT_DEG]
69
+ bottoms = [b for b in bottoms if abs(b) < FLOOR_BAND]
70
+ floor = statistics.median(bottoms) if bottoms else None
71
+ furn, snapped = [], 0
72
+ for a in al:
73
+ p, r = a["furniture_position"], a["furniture_rotation"]
74
+ z = p["z"] - a["furniture_size"]["height"] / 2 - (floor or 0.0)
75
+ if floor is not None and abs(z) < FLOOR_SNAP:
76
+ z, snapped = 0.0, snapped + 1
77
+ upright = tilt_deg(a) <= MAX_TILT_DEG
78
+ degenerate = min(a["furniture_size"].values()) < 1e-3
79
+ furn.append({**a, "furniture_size": None if degenerate else a["furniture_size"], "furniture_position": {"x": p["x"], "y": p["y"], "z": z},
80
+ "furniture_rotation": {"x": 0 if upright else r["x"], "y": 0 if upright else r["y"], "z": r["z"]}})
81
+ sid = s["scene_id"]
82
+ poly = shell_floor(room["scene_shell"])
83
+ yield convert_room(
84
+ {**room, "furniture": furn, "room_boundary": poly or room["room_boundary"]}, source=SOURCE, uid=f"{SOURCE}::{sid}", group=f"scannet:{sid.rsplit('_', 1)[0]}",
85
+ subset=s.get("source_subset"), boundary_type="polygon" if poly else "proxy", center_z=False, front_offset_deg=90,
86
+ extra=lambda f: {"sym": f["source_fields"]["scan2cad_annotation"]["sym"]},
87
+ meta={"scene_id": sid, "front_known": True, "is_true_room": False, "boundary_source": "scenecad_floor_plane" if poly else room["room_boundary"] and "scannet_floor_vertex_convex_hull",
88
+ "source_coverage": s["provenance"]["source_coverage"], "floor_z": floor, "n_floor_snapped": snapped})
89
+
90
+
91
+ if __name__ == "__main__":
92
+ shell = {"vertices_zup_m": [[0, 0, 0], [4, 0, 0], [4, 3, 0], [0, 3, 0], [0, 0, 2.5], [4, 0, 2.5]],
93
+ "planes": [[0, 1, 2, 3], [0, 1, 5, 4]]} # floor quad + one wall
94
+ assert shell_floor(shell) == [[0, 0], [4, 0], [4, 3], [0, 3]]
95
+ assert shell_floor({**shell, "planes": [[0, 1, 5, 4]]}) is None
96
+ print("scan2cad.py self-check ok")
code/fastfill/adapters/scannetpp.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ScanNet++ v2 instance OBBs (ScanNet++_exported). Auxiliary tier: NO semantic front.
2
+
3
+ Export (kits/convert_scannetpp.py): meters, Z-up, scene-local; furniture_position = OBB centroid; size = OBB
4
+ axesLengths reordered so the axis closest to world Z is local Z (frame kept right-handed); rotation = Rz*Ry*Rx
5
+ Euler of that frame. The OBBs are fitted per instance, so local X is an arbitrary horizontal box axis: yaw is
6
+ the box orientation modulo the box symmetry, not a facing direction (meta front_known=False on every room).
7
+ Boundary = XY AABB of all floor-OBB cross-sections (a superset of the floor; whole apartments stay one record).
8
+ Tilts <= MAX_TILT_DEG (vertical_axis_dot_z) are treated as upright; z is measured from the per-scene 5th percentile
9
+ bottom of all upright boxes and bottoms within FLOOR_SNAP of it are set to 0. (A median over "floor" categories
10
+ followed wall cabinets/shelves: b73f5cdc41 floor 1.46 m; against the annotated thin floor OBBs it was > 0.2 m off in
11
+ 108/748 scenes, the p5 bottom in 5/772.)
12
+ """
13
+ import math
14
+ import os
15
+
16
+ from fastfill.adapters.unified import convert_room, iter_records
17
+
18
+ SOURCE = "ScanNetpp"
19
+ PATH = "BillLin66__3D_Room_Collections/ScanNet++_exported/scannetpp.jsonl"
20
+ MAX_TILT_DEG = 10.0
21
+ FLOOR_SNAP = 0.10
22
+ COS_TILT = math.cos(math.radians(MAX_TILT_DEG))
23
+
24
+
25
+ def load(root):
26
+ for s in iter_records(os.path.join(root, PATH)):
27
+ room = s["rooms"][0]
28
+ upright = [f["source_fields"]["vertical_axis_dot_z"] >= COS_TILT for f in room["furniture"]]
29
+ bottoms = sorted(f["furniture_position"]["z"] - f["furniture_size"]["height"] / 2
30
+ for f, u in zip(room["furniture"], upright) if u)
31
+ floor = bottoms[len(bottoms) // 20] if bottoms else None
32
+ furn, snapped = [], 0
33
+ for f, u in zip(room["furniture"], upright):
34
+ p, r = f["furniture_position"], f["furniture_rotation"]
35
+ z = p["z"] - f["furniture_size"]["height"] / 2 - (floor or 0.0)
36
+ if floor is not None and abs(z) < FLOOR_SNAP:
37
+ z, snapped = 0.0, snapped + 1
38
+ furn.append({**f, "furniture_position": {"x": p["x"], "y": p["y"], "z": z},
39
+ "furniture_rotation": {"x": 0 if u else r["x"], "y": 0 if u else r["y"], "z": r["z"]}})
40
+ sid = s["scene_id"].removeprefix("scannetpp_")
41
+ yield convert_room(
42
+ {**room, "furniture": furn}, source=SOURCE, uid=f"{SOURCE}::{sid}", group=f"scannetpp:{sid}",
43
+ subset=s.get("source_subset"), boundary_type="hull", center_z=False,
44
+ meta={"scene_id": sid, "front_known": False, "is_true_room": False,
45
+ "boundary_source": room["room_boundary"] and "floor_obb_xy_aabb", "floor_z": floor, "n_floor_snapped": snapped})
code/fastfill/adapters/scenesmith.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SceneSmith example scenes (179 Room scenes + 31 Houses split into 185 rooms). Eval-only: too small, and
2
+ they are the paper's evaluation scenes (prompts 0-99 are the SceneEval-100 benchmark).
3
+
4
+ Export (kits/scenesmith_aggregate.py): meters, Z-up, furniture_position = DMD model-frame origin, rotation =
5
+ AngleAxis -> XYZ Euler deg (R = Rz Ry Rx), size = asset glTF AABB in the Z-up asset frame (x, y, z).
6
+ Asset origin, checked on geometry_metadata AABBs (not in the aggregate): furniture/manipuland = bottom center;
7
+ wall_mounted = back face (bbox y in [0, L]), vertical center; ceiling_mounted = top center.
8
+ Front = local +Y (wall-mounted assets extend into +Y; front test on beds/wardrobes).
9
+ Boundary = floor-plan AABB; every room has exactly north/south/east/west walls, so it is the real rectangle.
10
+ Free bodies are physics-settled: x/y tilt <= TILT_DEG is settling noise and is zeroed; an axis permutation within
11
+ TILT_DEG is an exact upright box; a larger tilt (only furniture and manipulands, whose origin is the bottom centre)
12
+ becomes the yaw-aligned upright bounding box of the 8 corners (sage10k.upright_box: same rotation order, origin and
13
+ front), flagged tilted, with tilt_deg and rot_src kept. Unlike SAGE the boxes rest on their lowest corner (1,135 tilted
14
+ manipulands over furniture tops: lowest corner - top median -0.1..-1.1 cm in every lift bin), so they are not moved.
15
+ Composite members are cut at the bottom of what holds them (scenesmith agent_utils/room.py to_drake_directive): a fill
16
+ item `<name>_f<k>_<i>` is inside the container `<name>_f<k>_c` of the same room, a stack member `<name>_s<k>_<i>` sits
17
+ on `_s<k>_<i-1>`, so nothing of it is below that holder's bottom; yet 205 of 865 tilted fill items and 11 of 278 tilted
18
+ stack members reach below it (round fruit, markers leaning in cups, a knife on a cutting board). The holder is listed first
19
+ (1,199 of 1,199 fill items, 1,079 of 1,079 stack members), so fastfill.anchors, which visits boxes by bottom height,
20
+ settles the holder before them instead of standing it on what it holds.
21
+ Openings: the export has no doors, and its 910 windows carry no location: every window position is the room frame
22
+ origin (= boundary centre, z = 0; the window glTF AABB is centred on its own origin and the placing pose lives in the
23
+ room_geometry SDF, which is not exported). Only the window count is kept (meta.n_windows_unlocated), no box is invented.
24
+ """
25
+ import math
26
+ import os
27
+ import re
28
+
29
+ from fastfill.adapters.sage10k import upright_box
30
+ from fastfill.adapters.unified import convert_room, iter_records
31
+
32
+ SOURCE = "SceneSmith"
33
+ TILT_DEG = 2.0
34
+ ANCHOR = {"furniture": "floor", "wall_mounted": "wall", "ceiling_mounted": "ceiling"} # manipuland: inferred
35
+
36
+
37
+ def tilt_deg(rot):
38
+ return math.degrees(math.acos(max(-1.0, min(1.0, math.cos(math.radians(rot["x"])) * math.cos(math.radians(rot["y"]))))))
39
+
40
+
41
+ def fix(f, cut=None):
42
+ """Origin -> bottom center, settling tilt -> upright, larger tilt -> upright bounding box cut at `cut` (the
43
+ bottom of the composite member holding it). Returns (furniture dict, noise_zeroed)."""
44
+ rot, p, s, g = f.get("furniture_rotation"), f.get("furniture_position"), f.get("furniture_size"), f["source_fields"]["asset_group"]
45
+ if not (rot and p and s):
46
+ return f, False
47
+ p = dict(p)
48
+ yaw = math.radians(rot["z"])
49
+ if g == "wall_mounted":
50
+ p["x"] -= math.sin(yaw) * s["length"] / 2
51
+ p["y"] += math.cos(yaw) * s["length"] / 2
52
+ p["z"] -= s["height"] / 2
53
+ elif g == "ceiling_mounted":
54
+ p["z"] -= s["height"]
55
+ f = {**f, "furniture_position": p}
56
+ if not (rot["x"] or rot["y"]):
57
+ return f, False
58
+ if tilt_deg(rot) <= TILT_DEG:
59
+ return {**f, "furniture_rotation": {"x": 0, "y": 0, "z": rot["z"]}}, True
60
+ return upright_box(f, cut), False
61
+
62
+
63
+ def fix_room(furniture):
64
+ """fix() over one room in order, each composite member cut at the bottom of its holder (module doc; the export
65
+ lists the holder first): fill item `_f<k>_<i>` -> container `_f<k>_c`, stack member `_s<k>_<i>` -> `_s<k>_<i-1>`."""
66
+ fixed, done = [], {}
67
+ for f in furniture:
68
+ m = re.search(r"_([fs]\d+)_(c|\d+)$", f.get("furniture_instance_id") or "")
69
+ g, i = m.groups() if m else (None, None)
70
+ k = (g, "c") if m and g[0] == "f" and i != "c" else (g, str(int(i) - 1)) if m and g[0] == "s" and i != "0" \
71
+ else None
72
+ h = done.get(k)
73
+ fixed.append(fix(f, h["furniture_position"]["z"] if h else None))
74
+ if m:
75
+ done[g, i] = fixed[-1][0]
76
+ return fixed
77
+
78
+
79
+ def load(root):
80
+ base = os.path.join(root, "imChuling__3D_Room_Collections/SceneSmith_exported")
81
+ for fn in ("json_single.json", "multiroom/multiroom_split.json"):
82
+ for scene in iter_records(os.path.join(base, fn)):
83
+ parent = scene["parent_scene_id"]
84
+ for room in scene["rooms"]:
85
+ fixed = fix_room(room["furniture"])
86
+ room = {**room, "furniture": [f for f, _ in fixed]}
87
+ ir = convert_room(
88
+ room, source=SOURCE, uid=f"SceneSmith::{scene['scene_id']}", group=f"scenesmith:{parent}",
89
+ subset=scene["provenance"]["subset"], boundary_type="polygon", center_z=False,
90
+ front_offset_deg=90,
91
+ extra=lambda f: {"anchor": ANCHOR.get(f["source_fields"]["asset_group"]),
92
+ "asset_group": f["source_fields"]["asset_group"], **f.get("_tilt", {})},
93
+ meta={"eval_only": True, "front_known": True, "file": fn, "scene_id": scene["scene_id"],
94
+ "parent_scene_id": parent, "tilt_threshold_deg": TILT_DEG,
95
+ "n_tilt_noise_zeroed": sum(z for _, z in fixed), "room_type_source": "dmd_room_frame",
96
+ "n_windows_unlocated": len(room.get("windows") or [])})
97
+ yield ir
98
+
99
+
100
+ if __name__ == "__main__":
101
+ def rec(g, rot, p=(1.0, 1.0, 0.0), S=(1.0, 0.2, 0.1)):
102
+ return {"furniture_category": "x", "source_fields": {"asset_group": g}, "furniture_position": dict(zip("xyz", p)),
103
+ "furniture_rotation": rot, "furniture_size": dict(zip(("width", "length", "height"), S))}
104
+ # settling noise (1 deg) -> zeroed, sizes kept
105
+ f, z = fix(rec("manipuland", {"x": 1.0, "y": 0.0, "z": 30.0}))
106
+ assert z and f["furniture_rotation"] == {"x": 0, "y": 0, "z": 30.0} and f["furniture_size"]["length"] == 0.2, f
107
+ # 30 deg about X -> upright bounding box [0.2 c + 0.1 s, 1.0, 0.2 s + 0.1 c] (front first), yaw 90, flagged tilted
108
+ f, z = fix(rec("manipuland", {"x": 30.0, "y": 0.0, "z": 0.0}, p=(1.0, 1.0, 0.5)))
109
+ ir = convert_room({"room_boundary": [[0, 0], [4, 0], [4, 4], [0, 4]], "furniture": [f]}, source=SOURCE, uid="t",
110
+ group="g", boundary_type="polygon", front_offset_deg=90, extra=lambda f: f.get("_tilt", {}))
111
+ o, c30 = ir["objects"][0], math.cos(math.radians(30))
112
+ assert not z and o["tilted"] and abs(o["tilt_deg"] - 30) < 1e-9 and abs(o["yaw"] - math.pi / 2) < 1e-9, o
113
+ assert all(abs(a - b) < 1e-9 for a, b in zip(o["size"] + o["pos"], [0.2 * c30 + 0.05, 1.0, 0.1 + 0.1 * c30,
114
+ 1.0, 0.975, 0.45])), o
115
+ # wall-mounted: back-face / vertical-centre origin -> bottom centre (yaw 90: +Y local = -X world)
116
+ f, _ = fix(rec("wall_mounted", {"x": 0, "y": 0, "z": 90.0}, p=(2.0, 1.0, 1.5), S=(0.6, 0.1, 0.4)))
117
+ assert all(abs(f["furniture_position"][k] - v) < 1e-9 for k, v in zip("xyz", (1.95, 1.0, 1.3))), f
118
+ # tilted composite members are cut at their holder's bottom (0.48): the fill item of container f2 and stack member
119
+ # s1_1 on s1_0; not an item of a group without a holder here (f3), a stack base, a plain manipuland
120
+ box = rec("manipuland", {"x": 0, "y": 0, "z": 0}, p=(1.0, 1.0, 0.48))
121
+ item = rec("manipuland", {"x": 30.0, "y": 0, "z": 0}, p=(1.0, 1.0, 0.5))
122
+ ids = ("s::bowl_0_f2_c", "s::apple_0_f2_0", "s::apple_1_f3_0", "s::pear_0", "s::board_0_s1_0", "s::knife_0_s1_1",
123
+ "s::book_0_s4_0")
124
+ got = [f for f, _ in fix_room([{**g, "furniture_instance_id": i}
125
+ for g, i in zip((box, item, item, item, box, item, item), ids)])]
126
+ assert all(abs(g["furniture_position"]["z"] - z) < 1e-9
127
+ for g, z in zip(got, (0.48, 0.48, 0.45, 0.45, 0.48, 0.48, 0.45))), got
128
+ assert abs(got[1]["furniture_size"]["height"] - (0.05 + 0.1 * c30 + 0.02)) < 1e-9, got[1]
129
+ print("scenesmith.py self-check ok")
code/fastfill/adapters/spatialgen.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SpatialGen public test set (48 designer rooms). Eval-only: the source release is a test split.
2
+
3
+ Export: Z-up meters, scene-local, furniture_position = box center, size = scaled local XYZ extents,
4
+ yaw about +Z (deg). Boundary = floor polygon. Front = local -Y (front test: beds/wardrobes touch the wall with +Y).
5
+ """
6
+ import os
7
+
8
+ from fastfill.adapters.unified import convert_room, iter_records
9
+
10
+ SOURCE = "SpatialGen"
11
+
12
+
13
+ def load(root):
14
+ path = os.path.join(root, "BillLin66__3D_Room_Collections/SpatialGen_exported/spatialgen.jsonl")
15
+ for scene in iter_records(path):
16
+ for room in scene["rooms"]:
17
+ yield convert_room(room, source=SOURCE, uid=room["room_id"], group=f"spatialgen:{scene['scene_id']}",
18
+ subset="test", boundary_type="polygon", center_z=True,
19
+ front_offset_deg=-90, meta={"eval_only": True, "front_known": True})
code/fastfill/adapters/spatiallm.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SpatialLM-Dataset layouts (Manycore/Kujiale designer houses: 12,328 scenes, 54,775 rooms).
2
+ View used: spatiallm.json only (scene records, every room once); json_room.json and multiroom/*.json are
3
+ re-packagings of the same rooms (split_views.py) and are ignored.
4
+
5
+ Export (aggregate_spatiallm.py): source Bbox(category, px,py,pz, angle_z, sx,sy,sz) -> furniture_position = box
6
+ CENTER, furniture_size = full scale along local x/y/z, rotation.z = degrees(angle_z); the format is yaw-only, so
7
+ the exported x/y = null mean "not tilted" (set to 0 here). Meters, Z-up, scene-global XY.
8
+ room_boundary = ring of wall start points (wall_polygon). 2152 of the 54,376 rings are not valid polygons; 2080 of them
9
+ are only a zero-area spur, a wall overshooting a corner (the source wall chain crosses itself and closes a sliver lobe:
10
+ scene_000060::room_3, 2.5 x 6.5 cm) or a vertex touching the ring, and are repaired (`repair`, recorded in
11
+ meta.boundary_repaired; ring moved by median 6 cm, p90 12 cm, max 1.2 m for a zero-area spur; dropped lobes p99
12
+ 0.05 m2, max 0.24 m2, holding no placeable object centre; kept with the lobe, 3 of the 314 repaired rooms build flags
13
+ oob_objects would lose that flag). Doors/windows keep their source pose: 2 of the 8,792 in repaired rooms stood on a
14
+ dropped spur/lobe wall and now sit 5 / 12 cm outside the ring, no footprint inside it (scene_011725::room_2,
15
+ scene_002826::room_4); the other 170,616 door/window centres in polygon rooms are within 3 cm of their ring. The
16
+ other 72 fall apart into several polygons (63) or change area by >= 1% (9): boundary_type None.
17
+ Front = local -Y (front test: toilets/beds touch the wall with local +Y), same as the other Manycore exports.
18
+ Openings: room.doors / room.windows = [{wall_index, position{x,y,z} (CENTER of the opening, on the wall line),
19
+ width (along the wall), height, sill_height (windows only; doors null)}], walls[wall_index] = {start, end, thickness}
20
+ (thickness is always 0.0 in the export). Emitted as "structure" boxes width x 0.10 m x height with yaw = wall
21
+ direction; center z passes through center_z (door bottom = 0 for 99.7%, the rest are real raised openings).
22
+
23
+ Cross-export duplicates (same Manycore design; exact floor area / boundary edges + identical pairwise distances of
24
+ floor furniture, see the g2-layout-mixed assessment): scenes holding a SpatialGen test room are dropped (SpatialGen
25
+ is eval-only), scenes matching a Structured3D / InteriorGS house take that house's group key.
26
+ """
27
+ import math
28
+ import os
29
+
30
+ from shapely.geometry import Polygon
31
+ from shapely.validation import explain_validity
32
+
33
+ from fastfill.adapters.unified import _num, convert_room, iter_records, signed_area
34
+
35
+ SOURCE = "SpatialLM"
36
+ SPATIALGEN_DUP = {f"scene_{n:06d}" for n in (
37
+ 646, 692, 701, 713, 734, 738, 773, 792, 793, 806, 828, 832, 1569, 1585, 1591, 1599, 1601, 1610, 1612, 1758,
38
+ 4021, 4053, 7537, 7629, 7989, 8143, 8147, 8729, 8815, 11483,
39
+ # review pass: boundary-free match (>= 3 floor objects with identical dims at identical relative positions under
40
+ # one axis-aligned rigid map, >= half of the smaller set) or same floor area +-0.1 m2 with >= 70% object match
41
+ 82, 537, 644, 667, 671, 740, 745, 822, 827, 841, 843, 1579, 1606, 1608, 1624, 1626, 1725, 1745, 4027, 4099,
42
+ 4111, 4112, 4165, 5211, 6058, 6252, 7362, 7478, 7498, 7622, 7729, 7736, 9248, 9360, 9771, 10350, 10394, 11383,
43
+ 11482, 12106,
44
+ 4359)} # pre-launch review: design twin of spatialgen_scene_00039::room_0
45
+ GROUP = {"scene_003636": "structured3d:scene_00729", "scene_007839": "structured3d:scene_02605",
46
+ "scene_009460": "structured3d:scene_01438", "scene_011931": "structured3d:scene_00402",
47
+ "scene_012118": "structured3d:scene_01556", "scene_004716": "interiorgs:0304_840560",
48
+ "scene_011878": "interiorgs:0553_840921",
49
+ # review pass: same floor area to 0.01 m2 and 4-6 floor objects with identical dims at identical relative positions
50
+ "scene_005720": "interiorgs:0785_841229", "scene_006253": "interiorgs:0536_840884",
51
+ "scene_010373": "interiorgs:0817_841276"}
52
+
53
+
54
+ WALL_THICKNESS = 0.10 # the export's wall thickness is 0.0 everywhere
55
+ REPAIR_TOL = 0.01 # a repaired wall ring keeps the ring's shoelace area to within 1%
56
+
57
+
58
+ def repair(ring):
59
+ """Invalid wall ring -> the exterior of Polygon(ring).buffer(0) when that is ONE polygon without holes whose area
60
+ is within REPAIR_TOL of the ring's shoelace area (the floor the walls enclose is then well defined: buffer(0) only
61
+ drops the spur / sliver lobe), else None (several parts, or the area changes: the room is not the ring's).
62
+ Not make_valid: it keeps a crossed corner's sliver lobe as a second polygon (one polygon for 1457 of the 2152)."""
63
+ p, a = Polygon(ring).buffer(0), abs(signed_area(ring))
64
+ if p.geom_type == "Polygon" and not p.interiors and abs(p.area - a) < REPAIR_TOL * a:
65
+ return [list(q) for q in p.exterior.coords[:-1]]
66
+
67
+
68
+ def openings(room, prefix):
69
+ """Doors and windows as unified-schema furniture in the room's frame. convert_room(front_offset_deg=-90)
70
+ swaps width/length and subtracts 90 deg, so width=thickness / length=opening width / rotation=wall+90 come out
71
+ as size [opening width, thickness, height] with yaw = wall direction (long side along the wall)."""
72
+ walls = room.get("walls") or []
73
+ out = []
74
+ for kind in ("door", "window"):
75
+ for k, d in enumerate(room.get(kind + "s") or []):
76
+ wi = d.get("wall_index")
77
+ if not (isinstance(wi, int) and 0 <= wi < len(walls)):
78
+ continue
79
+ w = walls[wi]
80
+ ang = math.degrees(math.atan2(w["end"]["y"] - w["start"]["y"], w["end"]["x"] - w["start"]["x"]))
81
+ out.append({"furniture_category": kind, "furniture_instance_id": f"{prefix}::{kind}_{k}", "structure": True,
82
+ "furniture_position": d["position"], "furniture_rotation": {"x": 0, "y": 0, "z": ang + 90},
83
+ "furniture_size": {"width": w.get("thickness") or WALL_THICKNESS, "length": d["width"],
84
+ "height": d["height"]}})
85
+ return out
86
+
87
+
88
+ def load(root):
89
+ path = os.path.join(root, "imChuling__3D_Room_Collections/SpatialLM_exported/spatiallm.json")
90
+ for scene in iter_records(path):
91
+ sid = scene["scene_id"]
92
+ if sid in SPATIALGEN_DUP:
93
+ continue
94
+ for room in scene["rooms"]:
95
+ furn = [{**f, "furniture_category": f["furniture_category"].replace("_", " "),
96
+ "furniture_rotation": {**f["furniture_rotation"], "x": 0, "y": 0}}
97
+ for f in room["furniture"]]
98
+ furn += openings(room, f"{sid}::{room['room_id']}")
99
+ ring = room["room_boundary"]
100
+ usable = ring and len(ring) >= 3 and all(_num(v) for pt in ring for v in pt[:2]) # convert_room's test
101
+ fix = repair(ring) if usable and not Polygon(ring).is_valid else None
102
+ ir = convert_room({**room, "room_boundary": fix or ring, "furniture": furn}, source=SOURCE,
103
+ uid=f"spatiallm:{sid}::{room['room_id']}",
104
+ group=GROUP.get(sid, f"spatiallm:{sid}"), boundary_type="polygon", center_z=True, front_offset_deg=-90,
105
+ extra=lambda f: {"structure": True} if f.get("structure") else {},
106
+ meta={"scene_id": sid, "front_known": True, "source_splits": room.get("source_splits"),
107
+ "view": "spatiallm.json"})
108
+ if fix: # provenance: the source ring in the room frame, its defect, both areas
109
+ x0, y0 = min(x for x, _ in fix), min(y for _, y in fix)
110
+ ir["meta"]["boundary_repaired"] = {
111
+ "method": "buffer(0)", "defect": explain_validity(Polygon(ring)).split("[")[0],
112
+ "area_src": round(abs(signed_area(ring)), 4), "area": round(Polygon(fix).area, 4),
113
+ "ring_src": [[x - x0, y - y0] for x, y in ring]}
114
+ b = ir["boundary"]
115
+ if b and not Polygon(b).is_valid: # wall ring crosses/touches itself: cannot prove in-bounds
116
+ ir["boundary_type"] = None
117
+ ir["meta"]["boundary_invalid"] = True
118
+ yield ir
119
+
120
+
121
+ if __name__ == "__main__":
122
+ # 4 x 3 room at (10, 20); door centred on the bottom wall (+X direction), window on the right wall (+Y direction)
123
+ room = {"room_id": "r", "room_type": "bedroom", "room_height": 2.6, "furniture": [],
124
+ "room_boundary": [[10, 20], [14, 20], [14, 23], [10, 23]],
125
+ "walls": [{"wall_index": i, "start": {"x": a[0], "y": a[1], "z": 0}, "end": {"x": b[0], "y": b[1], "z": 0},
126
+ "height": 2.6, "thickness": 0.0}
127
+ for i, (a, b) in enumerate([([10, 20], [14, 20]), ([14, 20], [14, 23]), ([14, 23], [10, 23]),
128
+ ([10, 23], [10, 20])])],
129
+ "doors": [{"wall_index": 0, "position": {"x": 12, "y": 20, "z": 1.05}, "width": 0.9, "height": 2.1,
130
+ "sill_height": None}],
131
+ "windows": [{"wall_index": 1, "position": {"x": 14, "y": 21.5, "z": 1.6}, "width": 1.2, "height": 1.4,
132
+ "sill_height": 0.9}]}
133
+ ir = convert_room({**room, "furniture": openings(room, "s::r")}, source=SOURCE, uid="t", group="g",
134
+ boundary_type="polygon", center_z=True, front_offset_deg=-90,
135
+ extra=lambda f: {"structure": True} if f.get("structure") else {})
136
+ d, w = ir["objects"]
137
+ close = lambda a, b: all(abs(x - y) < 1e-6 for x, y in zip(a, b))
138
+ assert d["category"] == "door" and d["structure"] and not d["tilted"], d
139
+ assert close(d["size"], [0.9, 0.10, 2.1]) and close(d["pos"], [2, 0, 0]) and abs(d["yaw"]) < 1e-9, d
140
+ assert w["category"] == "window" and w["structure"], w
141
+ assert close(w["size"], [1.2, 0.10, 1.4]) and close(w["pos"], [4, 1.5, 0.9]) and abs(w["yaw"] - math.pi / 2) < 1e-9, w
142
+
143
+ # wall rings through load() (iter_records stubbed with one scene): a zero-area spur at the min-x side (the frame
144
+ # comes from the repaired ring), the real scene_000060::room_3 corner overshoot (2.5 x 6.5 cm lobe), two squares
145
+ # touching at a vertex (two parts), a bow-tie (shoelace 0), a loop touching the ring from inside (a hole the
146
+ # exterior would fill) and a loop inside traced the same way (shoelace 17, buffer(0) 16): the first two are
147
+ # repaired and stay "polygon", the other four get boundary_type None; a 2-point ring and a ring with a null
148
+ # coordinate reach convert_room untouched (boundary None) instead of raising in Polygon()
149
+ chair = {"furniture_category": "chair", "furniture_instance_id": "c", "furniture_rotation": {"x": None, "y": None, "z": 0},
150
+ "furniture_position": {"x": 11, "y": 21, "z": 0.4}, "furniture_size": {"width": 0.5, "length": 0.5, "height": 0.8}}
151
+ rings = {"spur": [[10, 20], [14, 20], [14, 23], [10, 23], [10, 22], [9.5, 22], [10, 22]],
152
+ "overshoot": [[6.1625, -0.275], [6.1375, -0.275], [6.1375, 1.4025], [3.25, 1.4025], [3.25, -0.21],
153
+ [6.1625, -0.21]],
154
+ "touch": [[0, 0], [1, 0], [1, 1], [2, 1], [2, 2], [1, 2], [1, 1], [0, 1]],
155
+ "bowtie": [[0, 0], [2, 2], [2, 0], [0, 2]],
156
+ "hole": [[0, 0], [4, 0], [4, 4], [2, 4], [3, 3], [1, 3], [2, 4], [0, 4]],
157
+ "twice": [[0, 0], [4, 0], [4, 4], [2, 4], [2, 3], [1, 2], [3, 2], [2, 3], [2, 4], [0, 4]],
158
+ "short": [[0, 0], [1, 0]], "null": [[0, 0], [1, 0], [None, 1]]}
159
+ iter_records = lambda path: [{"scene_id": "s", "rooms": [{"room_id": k, "room_boundary": r, "furniture": [chair]}
160
+ for k, r in rings.items()]}]
161
+ irs = {ir["meta"]["room_id"]: ir for ir in load("")}
162
+ for k in ("spur", "overshoot"):
163
+ ir, rep = irs[k], irs[k]["meta"]["boundary_repaired"]
164
+ P = Polygon(ir["boundary"])
165
+ assert ir["boundary_type"] == "polygon" and P.is_valid and P.bounds[:2] == (0, 0), ir
166
+ assert rep["defect"] in ("Self-intersection", "Ring Self-intersection") and abs(rep["area"] - P.area) < 1e-3, rep
167
+ assert abs(rep["area"] - rep["area_src"]) < REPAIR_TOL * rep["area_src"] and len(rep["ring_src"]) == len(rings[k])
168
+ assert close(irs["spur"]["objects"][0]["pos"], [1, 1, 0]) and abs(Polygon(irs["spur"]["boundary"]).area - 12) < 1e-9
169
+ assert min(x for x, _ in irs["spur"]["meta"]["boundary_repaired"]["ring_src"]) == -0.5
170
+ assert abs(irs["overshoot"]["meta"]["boundary_repaired"]["area"] - 2.8875 * 1.6125) < 1e-4
171
+ for k in ("touch", "bowtie", "hole", "twice"):
172
+ assert irs[k]["boundary_type"] is None and irs[k]["meta"]["boundary_invalid"], irs[k]
173
+ assert "boundary_repaired" not in irs[k]["meta"] and irs[k]["boundary"] is not None
174
+ for k in ("short", "null"):
175
+ assert irs[k]["boundary"] is None and irs[k]["boundary_type"] is None and len(irs[k]["objects"]) == 1, irs[k]
176
+ assert not {"boundary_repaired", "boundary_invalid"} & set(irs[k]["meta"]), irs[k]
177
+ print("spatiallm.py self-check ok")
code/fastfill/adapters/structured3d.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured3D (imChuling export, structured3d.jsonl: 22,894 rooms / 3,488 houses, official train/val/test).
2
+
3
+ Export (kits/convert_structured3d.py): meters, Z-up, room-local (floor polygon centroid, z from floor_z);
4
+ position = bbox_3d centroid (box center); the exported size/yaw come from a heuristic axis canonicalization
5
+ (row most aligned with world X), which discards the model frame. The raw bbox_3d fields are kept per object:
6
+ raw_basis rows = box axes in world, raw_coeffs = half extents (mm) along those rows. Row 2 is the model's up;
7
+ wall-contact statistics on beds / TVs / pictures / toilets put the back on raw +Y, so front = raw -Y.
8
+ Objects whose raw row 2 is not world +Z (~31k, almost all 'unknown'/'otherprop') are tilted relative to their
9
+ model pose -> tilted=True (raw_basis kept), boxed by the upright envelope of their rotated corners. Yaw follows
10
+ raw -Y when horizontal; when that front is near vertical, use raw X and mark front_known=False.
11
+ Category 'unknown' (41%: occluded in every view, or the corrupt
12
+ perspective_full_09.zip for scenes 1600-1799) keeps its box with category "unknown": build.prep drops it as a
13
+ generic label and rejects the room only if dropped floor boxes leave a real hole (hidden_floor_obstacle).
14
+ room_type 'undefined' -> None.
15
+ Boundary = floor polygon traced from annotation_3d junctions/lines.
16
+ Doors/windows: room_context.doors/windows (same room frame, position = opening centre on the wall centreline,
17
+ ~0.1 m outside the floor polygon; width, height, no direction/thickness) -> structure boxes, see `openings`.
18
+ """
19
+ import math
20
+ import os
21
+
22
+ import numpy as np
23
+
24
+ from fastfill.adapters.internscenes import tilted_bbox
25
+ from fastfill.adapters.unified import convert_room, iter_records
26
+
27
+ SOURCE = "Structured3D"
28
+
29
+
30
+ def _furniture(o):
31
+ B = np.array(o["raw_basis"], float)
32
+ ext = 2 * np.abs(np.array(o["raw_coeffs"], float)) / 1000
33
+ f = {"furniture_category": o["category"],
34
+ "furniture_instance_id": str(o["instance_id"]), "furniture_position": o["position"],
35
+ "furniture_size": {"width": ext[1], "length": ext[0], "height": ext[2]}, "raw_basis": o["raw_basis"]}
36
+ if B[2, 2] < 0.999: # model up is not world up
37
+ R = np.stack([-B[1], B[0], B[2]], axis=1) # semantic front first
38
+ size, yaw, k, _ = tilted_bbox(R, ext[[1, 0, 2]])
39
+ return {**f, "furniture_size": dict(zip(("width", "length", "height"), size)),
40
+ "furniture_rotation": {"x": None, "y": None, "z": yaw},
41
+ **({"front_known": False} if k == 0 else {})}
42
+ front = -B[1]
43
+ return {**f, "furniture_rotation": {"x": 0, "y": 0, "z": math.degrees(math.atan2(front[1], front[0]))}}
44
+
45
+
46
+ OPENING_T = 0.10 # opening box thickness (the export gives none)
47
+ SAME_M = 0.05 # an object box centred this close (xy and z) to an opening, same size, is that opening's box
48
+
49
+
50
+ def _edge_deg(poly, x, y):
51
+ """Direction (deg) of the floor-polygon edge nearest to (x, y)."""
52
+ def dist(a, b):
53
+ dx, dy = b[0] - a[0], b[1] - a[1]
54
+ t = min(1.0, max(0.0, ((x - a[0]) * dx + (y - a[1]) * dy) / (dx * dx + dy * dy or 1)))
55
+ return math.hypot(x - a[0] - t * dx, y - a[1] - t * dy)
56
+ a, b = min(zip(poly, poly[1:] + poly[:1]), key=lambda e: dist(*e))
57
+ return math.degrees(math.atan2(b[1] - a[1], b[0] - a[0]))
58
+
59
+
60
+ def openings(rc, objs):
61
+ """room_context doors/windows -> (structure furniture, objs without the boxes they replace). Box = [width along
62
+ the wall, OPENING_T, height] centred at the given position (z = centre -> bottom = sill), yaw = nearest floor
63
+ edge. ~15% of openings already carry an object box (unknown / otherstructure, same centre and size): that box is
64
+ replaced by the labelled opening instead of duplicated."""
65
+ out, dup = [], set()
66
+ for kind in ("door", "window"):
67
+ for k, d in enumerate(rc.get(kind + "s") or []):
68
+ p, wh = d["position"], sorted([d["width"], d["height"]])
69
+ for i, o in enumerate(objs):
70
+ q, ext = o["position"], sorted(2 * abs(c) / 1000 for c in o["raw_coeffs"])[1:]
71
+ if math.hypot(q["x"] - p["x"], q["y"] - p["y"]) < SAME_M and abs(q["z"] - p["z"]) < SAME_M \
72
+ and all(abs(u - v) < 0.3 for u, v in zip(ext, wh)):
73
+ dup.add(i)
74
+ out.append({"furniture_category": kind, "furniture_instance_id": f"{kind}_{k}", "structure": True,
75
+ "furniture_position": p,
76
+ "furniture_rotation": {"x": 0, "y": 0, "z": _edge_deg(rc["floor_polygon"], p["x"], p["y"])},
77
+ "furniture_size": {"width": d["width"], "length": OPENING_T, "height": d["height"]}})
78
+ return out, [o for i, o in enumerate(objs) if i not in dup]
79
+
80
+
81
+ def convert(rec):
82
+ rc, prov = rec["room_context"], rec["provenance"]
83
+ ops, objs = openings(rc, rec["layout"]["floor_layout"]["objects"])
84
+ room = {"room_id": rc["room_id"], "room_type": None if rc["room_type"] == "undefined" else rc["room_type"],
85
+ "room_boundary": rc["floor_polygon"], "room_height": rc["ceiling_height_m"],
86
+ "furniture": [_furniture(o) for o in objs] + ops}
87
+ return convert_room(room, source=SOURCE, uid=rec["sample_id"], group=f"structured3d:{prov['source_house_id']}",
88
+ subset=prov["split"], boundary_type="polygon", center_z=True,
89
+ extra=lambda f: {"structure": True} if f.get("structure") else
90
+ {**({"raw_basis": f["raw_basis"]} if f["furniture_rotation"]["x"] is None else {}),
91
+ **({"front_known": False} if f.get("front_known") is False else {})},
92
+ meta={"front_known": True, "room_origin_house_m": rec["room_origin_house_m"],
93
+ "n_nearest_assigned": sum(o.get("assignment_method") == "nearest" for o in objs),
94
+ "n_unknown_category": sum(o["category"] == "unknown" for o in objs)})
95
+
96
+
97
+ def load(root):
98
+ path = os.path.join(root, "imChuling__3D_Room_Collections/Structured3D_exported/structured3d.jsonl")
99
+ for rec in iter_records(path):
100
+ yield convert(rec)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ # bed rotated 90 deg: raw row0 = world +Y, row1 = world -X (back on raw +Y -> world -X) -> front world +X, yaw 0
105
+ f = _furniture({"category": "bed", "instance_id": 1, "position": {"x": 0, "y": 0, "z": 0.5},
106
+ "raw_basis": [[0, 1, 0], [-1, 0, 0], [0, 0, 1]], "raw_coeffs": [800, 1000, 500]})
107
+ assert abs(f["furniture_rotation"]["z"]) < 1e-9 and f["furniture_size"]["width"] == 2.0, f
108
+ f = _furniture({"category": "unknown", "instance_id": 2, "position": {"x": 0, "y": 0, "z": 1},
109
+ "raw_basis": [[0, 0, -1], [0, 1, 0], [1, 0, 0]], "raw_coeffs": [1, 1, 1]})
110
+ assert f["furniture_category"] == "unknown" and f["furniture_rotation"]["x"] is None
111
+ # 4 x 3 room; door on the bottom wall already boxed as 'otherstructure' (replaced, not duplicated), window on the
112
+ # right wall (x = 4) 0.1 m outside the floor, centre z 1.5, height 1.4 -> sill 0.8
113
+ rec = {"sample_id": "t", "provenance": {"source_house_id": 0, "split": "train"}, "room_origin_house_m": [0, 0, 0],
114
+ "room_context": {"room_id": "r", "room_type": "undefined", "ceiling_height_m": 2.8,
115
+ "floor_polygon": [[0, 0], [4, 0], [4, 3], [0, 3]],
116
+ "doors": [{"position": {"x": 1, "y": -0.06, "z": 1.1}, "width": 0.7, "height": 2.2}],
117
+ "windows": [{"position": {"x": 4.1, "y": 1.5, "z": 1.5}, "width": 1.6, "height": 1.4}]},
118
+ "layout": {"floor_layout": {"objects": [
119
+ {"category": "otherstructure", "instance_id": 9, "position": {"x": 1, "y": -0.06, "z": 1.1},
120
+ "raw_basis": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "raw_coeffs": [350, 60, 1100]}]}}}
121
+ ir = convert(rec)
122
+ door, win = ir["objects"]
123
+ close = lambda a, b: all(abs(x - y) < 1e-6 for x, y in zip(a, b))
124
+ assert door["category"] == "door" and door["structure"] and close(door["pos"], [1, -0.06, 0]), door
125
+ assert close(door["size"], [0.7, OPENING_T, 2.2]) and abs(door["yaw"]) < 1e-9, door
126
+ assert win["category"] == "window" and close(win["pos"], [4.1, 1.5, 0.8]) and close(win["size"], [1.6, OPENING_T, 1.4])
127
+ assert abs(win["yaw"] - math.pi / 2) < 1e-9 and not win["tilted"], win
128
+ from fastfill.anchors import fixed_geometry
129
+ assert {o["category"] for o in fixed_geometry(ir)} == {"door", "window"}
130
+ print("structured3d.py self-check ok")
code/fastfill/adapters/unified.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared reader for the common export schema used by most collections:
2
+ scene{scene_id, provenance, rooms[{room_id, room_type, room_boundary, room_boundary_type, room_height,
3
+ furniture[{furniture_category, furniture_instance_id, furniture_position{x,y,z},
4
+ furniture_rotation{x,y,z} (deg), furniture_size{width,length,height}, source_fields}]}]}.
5
+
6
+ Conventions differ per source (box center vs bottom, which local axis is the front, boundary quality),
7
+ so each adapter passes them explicitly to `convert_room`.
8
+ """
9
+ import json
10
+ import math
11
+
12
+ import ijson
13
+
14
+
15
+ def iter_records(path):
16
+ """Scene records from a .jsonl or a top-level JSON array, streamed (some files are >8 GB)."""
17
+ if path.endswith(".jsonl"):
18
+ with open(path) as f:
19
+ for line in f:
20
+ if line.strip():
21
+ yield json.loads(line)
22
+ else:
23
+ with open(path, "rb") as f:
24
+ yield from ijson.items(f, "item", use_float=True)
25
+
26
+
27
+ def signed_area(poly):
28
+ return sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(poly, poly[1:] + poly[:1])) / 2
29
+
30
+
31
+ def _num(v):
32
+ return isinstance(v, (int, float)) and math.isfinite(v)
33
+
34
+
35
+ def convert_room(room, *, source, uid, group, subset=None, boundary_type=None, center_z=False,
36
+ front_offset_deg=0.0, size_keys=("width", "length", "height"), extra=None, meta=None):
37
+ """One exported room -> IR room.
38
+
39
+ boundary_type IR boundary type for this source ("polygon" | "hull" | "proxy" | None)
40
+ center_z True if furniture_position.z is the box center (converted to bottom)
41
+ front_offset_deg angle of the object's semantic front in its exported local frame, measured CCW from
42
+ local +X; the IR frame is rotated so the front becomes local +X (sizes swap for +-90)
43
+ size_keys keys of furniture_size along exported local X, Y, Z
44
+ extra(f) optional per-furniture dict merged into the IR object (anchor, parent, desc, ...)
45
+ Objects with a missing category/size/position/yaw are counted in meta.n_incomplete; tilted objects
46
+ (rotation x/y null or non-zero) get tilted=True. fastfill.build rejects rooms with incomplete objects
47
+ and rooms whose in-scope objects are tilted.
48
+ """
49
+ objs, n_tilted, n_incomplete = [], 0, 0
50
+ swap = round(front_offset_deg) % 180 == 90
51
+ for k, f in enumerate(room.get("furniture") or []):
52
+ s, p, r = f.get("furniture_size"), f.get("furniture_position"), f.get("furniture_rotation")
53
+ if not (f.get("furniture_category") and s and p and r):
54
+ n_incomplete += 1
55
+ continue
56
+ size = [s.get(key) for key in size_keys]
57
+ pos = [p.get("x"), p.get("y"), p.get("z")]
58
+ if not all(_num(v) for v in size + pos) or not _num(r.get("z")):
59
+ n_incomplete += 1
60
+ continue
61
+ tilted = r.get("x") != 0 or r.get("y") != 0
62
+ n_tilted += tilted
63
+ if center_z:
64
+ pos[2] -= size[2] / 2
65
+ if swap:
66
+ size[0], size[1] = size[1], size[0]
67
+ o = {"id": f.get("furniture_instance_id") or f"obj_{k}", "category": f["furniture_category"],
68
+ "size": size, "pos": pos, "yaw": math.radians(r["z"] + front_offset_deg) % (2 * math.pi),
69
+ "anchor": None, "parent": None, "tilted": tilted}
70
+ if extra:
71
+ o.update(extra(f))
72
+ objs.append(o)
73
+
74
+ boundary = room.get("room_boundary")
75
+ if boundary and len(boundary) >= 3 and all(_num(v) for pt in boundary for v in pt[:2]):
76
+ boundary = [[float(pt[0]), float(pt[1])] for pt in boundary]
77
+ if signed_area(boundary) < 0:
78
+ boundary = boundary[::-1]
79
+ x0, y0 = min(p[0] for p in boundary), min(p[1] for p in boundary)
80
+ boundary = [[x - x0, y - y0] for x, y in boundary]
81
+ else:
82
+ boundary, boundary_type = None, None
83
+ x0 = min((o["pos"][0] for o in objs), default=0.0)
84
+ y0 = min((o["pos"][1] for o in objs), default=0.0)
85
+ for o in objs:
86
+ o["pos"] = [o["pos"][0] - x0, o["pos"][1] - y0, o["pos"][2]]
87
+
88
+ height = room.get("room_height")
89
+ return {"uid": uid, "source": source, "subset": subset, "group": group,
90
+ "room_type": room.get("room_type"), "boundary": boundary, "boundary_type": boundary_type,
91
+ "height": height if _num(height) else None, "objects": objs,
92
+ "meta": {"n_tilted": n_tilted, "n_incomplete": n_incomplete, "room_id": room.get("room_id"),
93
+ **(meta or {})}}
94
+
95
+
96
+ if __name__ == "__main__":
97
+ room = {"room_id": "r", "room_type": "bedroom", "room_boundary": [[1, 1], [1, 4], [5, 4], [5, 1]],
98
+ "furniture": [
99
+ {"furniture_category": "bed", "furniture_instance_id": "b",
100
+ "furniture_position": {"x": 2, "y": 2, "z": 0.5}, "furniture_rotation": {"x": 0, "y": 0, "z": 0},
101
+ "furniture_size": {"width": 2.0, "length": 1.6, "height": 1.0}},
102
+ {"furniture_category": "lamp", "furniture_position": {"x": 2, "y": 2, "z": 0.5},
103
+ "furniture_rotation": {"x": None, "y": None, "z": 10}, "furniture_size": {"width": 1, "length": 1, "height": 1}},
104
+ {"furniture_category": None}]}
105
+ ir = convert_room(room, source="t", uid="t::r", group="g", boundary_type="polygon", center_z=True,
106
+ front_offset_deg=-90)
107
+ assert [0.0, 0.0] in ir["boundary"] and signed_area(ir["boundary"]) > 0
108
+ bed = ir["objects"][0]
109
+ assert bed["pos"] == [1.0, 1.0, 0.0] and bed["size"] == [1.6, 2.0, 1.0]
110
+ assert abs(bed["yaw"] - math.radians(270)) < 1e-9
111
+ assert ir["meta"]["n_tilted"] == 1 and ir["meta"]["n_incomplete"] == 1
112
+ print("unified.py self-check ok")
code/fastfill/anchors.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Object scope for training: drop structure, infer support anchors, keep a support-closed subset.
2
+
3
+ anchor = "floor" | "object" | "wall" | "ceiling" | "fixed". Adapters set anchor/parent from source evidence when the
4
+ source has it (placement flags, explicit parent ids); otherwise `infer_anchors` derives them from boxes:
5
+ ceiling hanging categories (pendant, chandelier, ...) whatever their z or source anchor (one at z=0 is
6
+ misplaced), or top within 10 cm of the room height above 1 m
7
+ floor bottom at most FLOOR_Z = 10 cm above z=0 (sunk boxes too, so build.prep can drop and count them), or
8
+ floor furniture (bed, table, sofa, ...) at most FURNITURE_FLOOR_Z = 50 cm up with nothing under it
9
+ (annotation z-noise, boxes that start above the legs; build.prep snaps them to the floor)
10
+ object bottom within 5 cm of the top of a floor/object box whose footprint contains its center;
11
+ seats, beds and rugs are never parents (bbox top = backrest / headboard; a rug is not a surface)
12
+ wall wall art (painting, mirror, ...) with no support below, or anything else elevated without one
13
+ fixed floor furniture that can hang (cabinet, shelf, vanity...) floating <= 50 cm with nothing below in a
14
+ designer (polygon) room: a wall-hung unit, out of scope to place, shown to the model as a fixed box
15
+ Box-level contact is weak evidence of support; build.py counts evidence and inferred supports separately.
16
+ `fixed_geometry` picks the structure boxes the model is told about (never placed): doors, windows, columns, stairs,
17
+ fireplaces, radiators, partitions, low beams... below FIXED_MAX_Z; surface finishes, sockets/drains, curtains and
18
+ slabs covering most of the room are left out, and a "wall" box only counts when it stands inside the room.
19
+ """
20
+ import math
21
+ import re
22
+
23
+ from shapely.geometry import Point, Polygon
24
+
25
+ from fastfill.scene import footprint, head, norm_cat
26
+
27
+ FLOOR_Z = 0.10 # bottoms up to 10 cm above the floor count as standing on it (build.prep snaps them to z=0)
28
+ # Strong architectural words mark structure anywhere in the name; floor/wall/ceiling/column are usually modifiers
29
+ # (plant_floor, floor lamp, wall_shelving_unit, locker_column) and mark structure only in purely architectural
30
+ # names ("wall", "Wall design", "floor mold").
31
+ STRUCTURE = re.compile(r"\b(door|window|curtain|drape|blind|beam|pillar|stair|staircase|stairway|stairwell|railing|rail|"
32
+ r"handrail|banister|guardrail|balustrade|baseboard|radiator|opening|doorframe|partition|fireplace|"
33
+ r"chimney|otherstructure|structure|doorway|arch|archway|bulkhead|niche|fence|step|ramp|elevator|duct|"
34
+ r"drain|pipe|socket|outlet|tile)s?\b")
35
+ # furniture whose name borrows a structure word: fireplace_console, pillar_candle, clothes rail, portable_partition_panel
36
+ NOT_STRUCTURE = re.compile(r"\b(lamp|light|fan|cabinet|shelf|mat|rug|towel|chair|table|console|stool|candle|figurine|box|"
37
+ r"plant|planter|basket|display|station|clothes|freestanding|portable|ladder|bench|sofa|couch|"
38
+ r"bed|wardrobe|desk|seat|bookcase|bookshelf|dresser|sideboard|ottoman)s?\b")
39
+ SURFACE = r"(wall|floor|ceiling|column)s?"
40
+ ARCH = (r"(panel|paneling|design|combination|mold|molding|moulding|background|trim|skirting|board|tile|cladding|covering|"
41
+ r"decoration|sticker|styling|decorative|decor|structural|support|concrete|low|half|suspended)s?")
42
+ HANGING = re.compile(r"\b(pendant|chandelier|ceiling|downlight|hanging)s?\b")
43
+ NOT_HANGING = re.compile(r"\b(chair|stool|seat|swing)\b") # a hanging chair stands on its own frame
44
+ # soft covers draped over a bed or sofa (their boxes cover the bed): not objects to place, not floor obstacles
45
+ SOFT_COVERS = {"bedding", "comforter", "blanket", "quilt", "duvet", "bedspread", "bedcover", "coverlet"}
46
+ # floor furniture whose box bottom sits up to FURNITURE_FLOOR_Z high with nothing under it stands on the floor
47
+ # (annotation z-noise, boxes that start above the legs); without this it fell through to 'wall' and vanished
48
+ FLOOR_FURNITURE = {"bed", "sofa", "couch", "loveseat", "sectional", "table", "desk", "chair", "armchair", "stool", "bench",
49
+ "ottoman", "cabinet", "wardrobe", "dresser", "bookcase", "bookshelf", "sideboard", "nightstand",
50
+ "fridge", "refrigerator", "oven", "stove", "dishwasher", "washer", "dryer", "piano", "tub",
51
+ "bathtub", "toilet", "console", "stand", "chest", "armoire", "shelf", "cupboard"}
52
+ FURNITURE_FLOOR_Z = 0.50 # floating up to here with nothing below: a floor piece (snapped to z=0 by build.prep)
53
+ FURNITURE_SUNK_Z = 0.30 # any floor box sunk up to here: same (deeper boxes belong to a lower storey)
54
+ # furniture that cannot hang on a wall: floating with nothing below it is on the floor with a noisy z anywhere
55
+ GROUND_ONLY = {"bed", "sofa", "couch", "loveseat", "sectional", "chair", "armchair", "table", "desk", "fridge",
56
+ "refrigerator", "oven", "stove", "dishwasher", "stool", "bench", "ottoman", "piano", "tub", "bathtub",
57
+ "washer", "dryer", "wardrobe", "armoire", "bookcase", "dresser"}
58
+ SOFT_HANGINGS = {"curtain", "curtains", "drape", "drapes", "blind", "blinds", "shade", "shades", "screening", "valance"}
59
+ # structure that has no footprint of its own: finishes, fixtures, the room's own slabs
60
+ FINISH = {"floor", "ceiling", "tile", "tiles", "baseboard", "skirting", "trim", "mold", "molding", "moulding", "panel",
61
+ "paneling", "board", "boards", "cladding", "covering", "socket", "outlet", "drain", "pipe", "pipes", "design",
62
+ "decoration", "sticker", "background", "wallpaper"}
63
+ FIXED_MAX_Z = 1.5 # structure whose bottom is higher does not shape the floor layout (ceiling beams, ducts)
64
+ DIVIDERS = {"partition", "divider", "screen"} # a "partition panel" is an obstacle, not a wall finish
65
+ # scans: box z is noisy, so floating floor furniture is on the floor (not a wall-hung unit), whatever the boundary type
66
+ SCANNED = {"Scan2CAD", "MultiScan", "InternScenes_arkit", "InternScenes_scannet", "InternScenes_mp3d",
67
+ "InternScenes_3rscan"}
68
+
69
+
70
+ def hanging(category):
71
+ nc = norm_cat(category)
72
+ return bool(HANGING.search(nc)) and not NOT_HANGING.search(nc)
73
+
74
+
75
+ WALL_ART = re.compile(r"\b(painting|picture|poster|artwork|art|mirror|whiteboard|blackboard|frame)s?\b")
76
+ FLAT = {"rug", "carpet", "mat", "doormat", "bathmat", "placemat", "playmat", "floormat", "yogamat", "exercisemat",
77
+ "fitnessmat"}
78
+ FLAT |= {w + "s" for w in FLAT}
79
+ NO_TOP = {"sofa", "couch", "loveseat", "sectional", "bed", "armchair", "chair", "bench"} | FLAT # not a support surface
80
+
81
+
82
+ def is_structure(category):
83
+ c = re.sub(r"[^a-z]+", " ", (category or "").lower())
84
+ if NOT_STRUCTURE.search(c):
85
+ return False
86
+ return bool(STRUCTURE.search(c)) or (bool(re.search(rf"\b{SURFACE}\b", c))
87
+ and not re.sub(rf"\b({SURFACE}|{ARCH})\b", " ", c).strip())
88
+
89
+
90
+ def infer_anchors(room, tol=0.05):
91
+ objs = room["objects"]
92
+ fps = {o["id"]: Polygon(footprint(o)) for o in objs}
93
+ height = room.get("height")
94
+ hull = room.get("boundary_type") == "hull" or room.get("source") in SCANNED
95
+ for o in sorted(objs, key=lambda o: o["pos"][2]):
96
+ if hanging(o["category"]): # before the floor rule and any source anchor: a chandelier at z=0 is misplaced
97
+ o["anchor"] = "ceiling"
98
+ continue
99
+ if o.get("anchor"):
100
+ continue
101
+ z, nc = o["pos"][2], norm_cat(o["category"])
102
+ if z <= FLOOR_Z: # one-sided: sunk boxes stay floor so build.prep drops and counts them
103
+ o["anchor"] = "floor"
104
+ continue
105
+ c = Point(o["pos"][0], o["pos"][1])
106
+ below = [p for p in objs if p is not o and p.get("anchor") in ("floor", "object") and head(p) not in NO_TOP
107
+ and abs(p["pos"][2] + p["size"][2] - z) < tol and fps[p["id"]].contains(c)]
108
+ if below: # a frame standing on a dresser is on it; art above a sofa is not (NO_TOP)
109
+ o["anchor"], o["parent"] = "object", max(below, key=lambda p: p["pos"][2] + p["size"][2])["id"]
110
+ o["anchor_inferred"] = True
111
+ elif WALL_ART.search(nc):
112
+ o["anchor"] = "wall"
113
+ elif z <= FURNITURE_FLOOR_Z and head(o) in FLOOR_FURNITURE:
114
+ # nothing under it: on the floor with a noisy z (scans; anything that cannot hang), or a wall-hung
115
+ # unit in a designer room (vanity, floating TV console): shown as fixed, not placed
116
+ o["anchor"] = "floor" if hull or head(o) in GROUND_ONLY else "fixed"
117
+ elif height and z + o["size"][2] > height - 2 * tol and z > 1.0:
118
+ o["anchor"] = "ceiling"
119
+ else:
120
+ o["anchor"] = "wall"
121
+ return room
122
+
123
+
124
+ def fixed_geometry(room, band=0.10, intrusion=0.05):
125
+ """Structure boxes to show the model as immovable (see module doc). Returns copies with tilted/anchor cleared.
126
+ Which of them survive next to the reference layout (covered, outside, slabs) is build.prep's decision."""
127
+ out = []
128
+ shell = Polygon(room["boundary"]).buffer(0) if room.get("boundary") else None
129
+ inner = shell.buffer(-band) if shell is not None else None
130
+ hull = room.get("boundary_type") == "hull"
131
+ for o in room["objects"]:
132
+ if not structural(o):
133
+ continue
134
+ words, h = set(norm_cat(o["category"]).split()), head(o)
135
+ finish = (h in FINISH or h.rstrip("s") in FINISH) and not words & DIVIDERS
136
+ if finish or words & SOFT_HANGINGS or o["pos"][2] >= FIXED_MAX_Z:
137
+ continue
138
+ fp = Polygon(footprint(o))
139
+ if shell is not None and fp.area >= 0.8 * shell.area: # a slab under another name
140
+ continue
141
+ # a "wall" box is the room's own wall unless it stands inside a polygon room (a partition); in a hull room
142
+ # the walls stand inside the hull by construction, so none of them is a partition
143
+ if h == "wall" and (hull or inner is None or fp.intersection(inner).area < intrusion):
144
+ continue
145
+ out.append({k: v for k, v in o.items() if k not in ("anchor", "parent", "tilted", "anchor_inferred")})
146
+ return out
147
+
148
+
149
+ def structural(o):
150
+ """Source-flagged architecture, or a structure name on something that does not stand on another object (a
151
+ power 'outlet' strip or a lever 'arch' file on a desk is an object, not architecture)."""
152
+ return bool(o.get("structure")) or (is_structure(o["category"]) and not o.get("parent"))
153
+
154
+
155
+ def annotate(room):
156
+ """Structure and soft covers removed, anchors inferred: every remaining box with anchor/parent set."""
157
+ objs = [dict(o) for o in room["objects"] if not (structural(o) or head(o) in SOFT_COVERS)]
158
+ return infer_anchors({**room, "objects": objs})["objects"]
159
+
160
+
161
+ def closed(objs, keep=("floor", "object")):
162
+ """Objects whose anchor is in `keep` and whose support chain is entirely kept, in input order."""
163
+ kept, ids = [], set()
164
+ for o in sorted(objs, key=lambda o: o["pos"][2]): # parents sit lower than children
165
+ if o["anchor"] in keep and (o.get("parent") is None or o["parent"] in ids):
166
+ kept.append(o)
167
+ ids.add(o["id"])
168
+ order = {o["id"]: k for k, o in enumerate(objs)}
169
+ return sorted(kept, key=lambda o: order[o["id"]])
170
+
171
+
172
+ def scope_objects(room, keep=("floor", "object")):
173
+ """Structure removed, anchors inferred, objects outside `keep` dropped together with their dependants."""
174
+ return {**room, "objects": closed(annotate(room), keep)}
175
+
176
+
177
+ if __name__ == "__main__":
178
+ room = {"height": 2.6, "objects": [
179
+ {"id": "desk", "category": "desk", "size": [1.2, .6, .75], "pos": [1, 1, 0], "yaw": 0},
180
+ {"id": "lamp", "category": "table lamp", "size": [.2, .2, .4], "pos": [1.2, 1, .75], "yaw": 0},
181
+ {"id": "pic", "category": "painting", "size": [.8, .05, .6], "pos": [1, 0, 1.4], "yaw": 0},
182
+ {"id": "cl", "category": "pendant lamp", "size": [.5, .5, .3], "pos": [2, 2, 2.0], "yaw": 0},
183
+ {"id": "sofa", "category": "sofa", "size": [2, .9, .8], "pos": [3, 3, 0], "yaw": 0},
184
+ {"id": "art", "category": "picture frame", "size": [.6, .05, .5], "pos": [3, 3, .8], "yaw": 0},
185
+ {"id": "vase", "category": "vase", "size": [.1, .1, .2], "pos": [1, 0, 2.0], "yaw": 0, "anchor": "object", "parent": "pic"},
186
+ {"id": "win", "category": "window", "size": [1, .1, 1.2], "pos": [3, 0, .9], "yaw": 0}]}
187
+ s = scope_objects(room)
188
+ assert [o["id"] for o in s["objects"]] == ["desk", "lamp", "sofa"], s
189
+ assert s["objects"][1]["parent"] == "desk" and s["objects"][1]["anchor"] == "object"
190
+ a = {o["id"]: o["anchor"] for o in infer_anchors({**room, "objects": [dict(o) for o in room["objects"]]})["objects"]}
191
+ assert a["pic"] == "wall" and a["cl"] == "ceiling" and a["art"] == "wall", a # art above a sofa is not "on" it
192
+ assert is_structure("window") and is_structure("door(screen door, screen)") and not is_structure("floor lamp")
193
+ for c in ("wall", "floor", "Wall design", "ceiling combination", "floor mold", "otherstructure", "room_partition",
194
+ "radiator", "support_column", "vertical core stair 1", "door(screen door, screen)"):
195
+ assert is_structure(c), c
196
+ for c in ("plant_floor", "floor_plant_large", "locker_column", "floor_bookshelf", "bookshelf_wall_unit",
197
+ "floor-standing air purifier", "wall_hung_toilet", "floor lamp", "wall_cabinet", "ceiling fan", "wall_mirror"):
198
+ assert not is_structure(c), c
199
+ sofa = {"id": "s", "category": "sofa", "size": [2, .9, .8], "pos": [1, 1, 0.07], "yaw": 0}
200
+ rug = {"id": "r", "category": "rug", "size": [3, 2, .02], "pos": [1, 1, 0], "yaw": 0}
201
+ got = {o["id"]: (o["anchor"], o.get("parent")) for o in infer_anchors({"objects": [dict(sofa), dict(rug)]})["objects"]}
202
+ assert got == {"s": ("floor", None), "r": ("floor", None)}, got # 7 cm up = standing, not on the rug
203
+ fx = {"boundary": [[0, 0], [5, 0], [5, 4], [0, 4]], "objects": [
204
+ {"id": "d", "category": "door", "size": [.9, .1, 2.1], "pos": [2, 0, 0], "yaw": 0},
205
+ {"id": "w", "category": "window", "size": [1.2, .1, 1.3], "pos": [4.98, 2, .9], "yaw": math.pi / 2},
206
+ {"id": "c", "category": "support_column", "size": [.3, .3, 2.8], "pos": [2.5, 2, 0], "yaw": 0, "structure": True},
207
+ {"id": "b", "category": "beam", "size": [5, .3, .3], "pos": [2.5, 2, 2.5], "yaw": 0},
208
+ {"id": "f", "category": "floor", "size": [5, 4, .02], "pos": [2.5, 2, 0], "yaw": 0},
209
+ {"id": "k", "category": "curtain", "size": [1.5, .1, 2.4], "pos": [4.95, 2, 0], "yaw": 0},
210
+ {"id": "s", "category": "socket", "size": [.08, .02, .08], "pos": [1, 0.01, .3], "yaw": 0},
211
+ {"id": "w1", "category": "wall", "size": [4, .1, 2.8], "pos": [2.5, 0.05, 0], "yaw": 0},
212
+ {"id": "w2", "category": "wall", "size": [2, .1, 2.8], "pos": [2.5, 2, 0], "yaw": 0},
213
+ {"id": "p", "category": "wall panels", "size": [3, .05, 2.8], "pos": [1.5, 0.02, 0], "yaw": 0},
214
+ {"id": "sc", "category": "window screening", "size": [1.5, .05, 2.4], "pos": [2, 3.98, 0], "yaw": 0},
215
+ {"id": "t", "category": "table", "size": [1.2, .6, .75], "pos": [1, 1, 0], "yaw": 0}]}
216
+ assert [o["id"] for o in fixed_geometry(fx)] == ["d", "w", "c", "w2"], fixed_geometry(fx)
217
+ assert [o["id"] for o in fixed_geometry({**fx, "boundary_type": "hull"})] == ["d", "w", "c"] # hull: walls are not partitions
218
+ assert not is_structure("step ladder") and is_structure("styling column") and is_structure("otherstructure")
219
+ assert is_structure("elevator shaft") and is_structure("banister") and not is_structure("floor lamp")
220
+ assert not is_structure("elevator_lobby_bench") and is_structure("stair_railing") and is_structure("bedroom_door")
221
+ panel = {"boundary": fx["boundary"], "objects": [{"id": "pp", "category": "low_partition_panel", "size": [.3, 1, 1.7],
222
+ "pos": [2.5, 2, 0], "yaw": 0}, {"id": "wp", "category": "wall panels",
223
+ "size": [3, .05, 2.8], "pos": [1.5, 0.02, 0], "yaw": 0}]}
224
+ assert [o["id"] for o in fixed_geometry(panel)] == ["pp"] # a partition panel is fixed, a wall panel is not
225
+ # a frame standing on a dresser is on it; a wall-hung cabinet floats -> fixed in a polygon room, floor in a hull
226
+ dr = {"id": "dr", "category": "dresser", "size": [1, .5, .8], "pos": [1, 1, 0], "yaw": 0}
227
+ fr = {"id": "fr", "category": "picture frame", "size": [.2, .05, .25], "pos": [1, 1, .8], "yaw": 0}
228
+ cb = {"id": "cb", "category": "bathroom cabinet", "size": [.6, .4, .5], "pos": [3, 3, .35], "yaw": 0}
229
+ bd = {"id": "bd", "category": "bed", "size": [2, 1.6, .5], "pos": [3, 1, .35], "yaw": 0}
230
+ got = {o["id"]: (o["anchor"], o.get("parent")) for o in annotate({"boundary_type": "polygon", "objects": [dr, fr, cb, bd]})}
231
+ assert got == {"dr": ("floor", None), "fr": ("object", "dr"), "cb": ("fixed", None), "bd": ("floor", None)}, got
232
+ got = {o["id"]: o["anchor"] for o in annotate({"boundary_type": "hull", "objects": [dict(cb)]})}
233
+ assert got == {"cb": "floor"}, got
234
+ assert annotate({"source": "Scan2CAD", "boundary_type": "polygon", "objects": [dict(cb)]})[0]["anchor"] == "floor"
235
+ strip = {"id": "ps", "category": "power_outlet_strip", "size": [.3, .06, .04], "pos": [1, 1, .8], "yaw": 0,
236
+ "anchor": "object", "parent": "dr"}
237
+ assert [o["id"] for o in annotate({"objects": [dict(dr), strip]})] == ["dr", "ps"] and not fixed_geometry(
238
+ {"boundary": fx["boundary"], "objects": [dict(dr), strip]})
239
+ print("anchors.py self-check ok")
code/fastfill/build.py ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """IR rooms -> filtered, deduplicated, leakage-safe SFT files (spec: FastFill I/O design review, 2026-09-24).
2
+
3
+ python -m fastfill.build --ir /Volumes/harddisk/fastfill_ir --out data/fastfill_v1 [--sources ...] [--constraint_frac 0.3]
4
+
5
+ Per room: prep (reject or clean, see `prep`; structure and unlabelled floor boxes become `fixed` context, never
6
+ placed) -> cross-source dedup by group -> split by group/content
7
+ -> train: rot90 augmentation -> canonical order/ids -> optional constraints (S2) -> field dropout -> messages
8
+ dev/test: canonical -> messages (no augmentation), IR written to {dev,test}_rooms.jsonl for fastfill.evaluate,
9
+ test_constrained_rooms.jsonl adds 1-4 GT-true constraints per test room (rooms without any are skipped).
10
+ Eval-only sources (meta.eval_only) go to test with their whole split component (split.assign_splits).
11
+ stats.json counts every rejection reason, quality flag, dropped object and inferred vs evidence support per source.
12
+ Rejections are only what makes a training target impossible (no verified front, missing boxes, bad numbers, no
13
+ boundary, no placeable object); everything else (reference objects through walls, same-category overlaps, tiny rooms,
14
+ unexplained dropped floor area, single-object rooms, snapped or tilted boxes) is a FLAG written next to the sample.
15
+ """
16
+ import argparse
17
+ import collections
18
+ import hashlib
19
+ import json
20
+ import math
21
+ import os
22
+ import random
23
+ import re
24
+ import tempfile
25
+ from contextlib import ExitStack
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+ import shapely
30
+ from shapely import STRtree
31
+ from shapely.geometry import LineString, Point, Polygon
32
+ from shapely.ops import unary_union
33
+
34
+ from fastfill.anchors import (DIVIDERS, FINISH, FLAT, FLOOR_Z, FURNITURE_SUNK_Z, SOFT_COVERS, SOFT_HANGINGS, WALL_ART,
35
+ annotate, closed, fixed_geometry, hanging, is_structure)
36
+ from fastfill.scene import (GENERIC, canonical, clean_boundary, footprint, head, messages, norm_cat, num, rot90,
37
+ shown_size, slug)
38
+ from fastfill.split import assign_splits
39
+ from fastfill.validate import check, holds, support_ok
40
+
41
+ # dropped boxes that do not block the floor: openings and wall/floor finishes (plant_floor -> plant is not exempt)
42
+ FLUSH = re.compile(r"^(doors?|doorframes?|windows?|curtains?|drapes?|blinds?|frames?|openings?|baseboards?|rails?|"
43
+ r"railings?|radiators?|beams?|shades?|screening|designs?|molds?|moldings?|panels?|paneling|combination|"
44
+ r"walls?|floors?|ceilings?|skirting|trim|tiles?|boards?)$")
45
+
46
+
47
+ WALL_BAND_M = 0.10 # a dropped box within this band of the boundary keeps to the walls
48
+ THICK_WALL_M = 0.30 # structure (doors, windows) may stand this far outside the floor polygon, inside the wall
49
+ MIN_AREA_M2 = 0.5 # a floor smaller than this holds no furniture: a broken (mm-scale) boundary, rejected
50
+ INTRUSION_M2 = 0.05 # ... unless more than this much of it stands further inside the room
51
+ # GT duplicate rule: soft/decor heads, lamps and composites are exempt
52
+ SOFT_HEADS = {"plant", "plants", "flower", "flowers", "pillow", "pillows", "cushion", "towel", "book", "books",
53
+ "ornament", "decoration", "vase", "toy", "toys", "clothes", "bag", "lamp", "light"}
54
+ COMPOSITE = re.compile(r"\b(combination|l shaped|composite|kids)\b")
55
+
56
+
57
+ def _words(category):
58
+ return re.sub(r"[^a-z]+", " ", (category or "").lower()).split()
59
+
60
+
61
+ def _flush(o, inner, polygon):
62
+ """A dropped floor box that leaves no hole: a flat covering, a soft cover or curtain, or a structural
63
+ opening/finish ('bed frame' is not). In a polygon room (the boundary is the walls) such a box must also keep to
64
+ the walls, under INTRUSION_M2 inside `inner` (the room shrunk by WALL_BAND_M): an open door leaf, a shower
65
+ enclosure or a 'wall' box standing in the room is an obstacle whatever its label. Source-flagged architecture
66
+ (stairs/pillars are shown as fixed boxes when they qualify) needs only the geometry there. In a hull room (a scan's
67
+ floor hull, walls stand inside it) the name decides, flagged boxes may also pass by geometry."""
68
+ w = _words(o["category"])
69
+ if head(o) in FLAT or head(o) in SOFT_COVERS or SOFT_HANGINGS & set(w) or \
70
+ ((head(o) in FINISH or head(o).rstrip("s") in FINISH) and not DIVIDERS & set(w)):
71
+ return True
72
+ stays = Polygon(footprint(o)).intersection(inner).area < INTRUSION_M2
73
+ if o.get("structure"):
74
+ return stays if polygon else \
75
+ (any(FLUSH.match(x) for x in w) and not {"partition", "screen", "clothes"} & set(w)) or stays
76
+ if not (polygon is False or stays):
77
+ return False
78
+ while len(w) > 1 and w[-1] in ("floor", "wall", "ceiling"): # placement suffix: plant_floor, bookshelf_wall
79
+ w.pop()
80
+ return bool(w) and is_structure(o["category"]) and not {"partition", "screen", "clothes"} & set(w) \
81
+ and bool(FLUSH.match(w[-1]))
82
+
83
+
84
+ FRONT = {"chair", "armchair", "seat", "sofa", "couch", "loveseat", "bed", "desk", "tv", "television", "monitor", "toilet",
85
+ "piano"}
86
+ PRIORITY = ["on", "between", "faces", "against_wall", "near"] # availability in rooms, rarest first
87
+
88
+
89
+ def _drop_with_dependants(objs, bad):
90
+ bad = set(bad)
91
+ while True:
92
+ more = {o["id"] for o in objs if o.get("parent") in bad} - bad
93
+ if not more:
94
+ return [o for o in objs if o["id"] not in bad]
95
+ bad |= more
96
+
97
+
98
+ FLAT_Z = 0.15 # a box lower than this that is not placed is a floor covering, not an obstacle
99
+
100
+
101
+ def _finite(o):
102
+ return all(isinstance(o.get(k), (list, tuple)) and len(o[k]) == 3 for k in ("size", "pos")) and \
103
+ all(_finite_number(v) for v in list(o["size"]) + list(o["pos"]) + [o.get("yaw")])
104
+
105
+
106
+ def _finite_number(value):
107
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
108
+ return False
109
+ try:
110
+ return math.isfinite(value)
111
+ except OverflowError:
112
+ return False
113
+
114
+
115
+ def _room_geometry_error(room):
116
+ boundary = room.get("boundary")
117
+ if not isinstance(boundary, (list, tuple)) or len(boundary) < 3 or \
118
+ any(not isinstance(p, (list, tuple)) or len(p) != 2 for p in boundary):
119
+ return "boundary_shape"
120
+ if not all(_finite_number(v) for p in boundary for v in p):
121
+ return "bad_numbers"
122
+ if len({tuple(p) for p in boundary}) < 3:
123
+ return "boundary_shape"
124
+ height = room.get("height")
125
+ if height is not None and (not _finite_number(height) or height <= 0):
126
+ return "bad_numbers"
127
+ return None
128
+
129
+
130
+ def _keep_fixed(fixed, objs, shell, cover=0.5, slab=0.5):
131
+ """Fixed boxes worth showing next to the reference layout: touching the room, not a slab; a box whose top is
132
+ below FLAT_Z is a covering or trim, not an obstacle; a box sunk below the floor is cut at the floor. A NON-structure
133
+ box (unlabelled, wall-anchored, wall-hung, no-front) mostly covered by a placed object is that object's second
134
+ annotation and is dropped; a structure box (column, stair, door, window...) is real whatever stands on it, so it
135
+ is always kept (a reference layout through it shows up as a GT fixed collision)."""
136
+ kept = [(Polygon(footprint(o)), o["pos"][2], o["pos"][2] + o["size"][2]) for o in objs]
137
+ near = shell.buffer(WALL_BAND_M)
138
+ wall = shell.buffer(THICK_WALL_M) # a door or window sits in the wall: up to half a thick wall outside the floor
139
+ out = []
140
+ for f in fixed:
141
+ z0, z1 = f["pos"][2], f["pos"][2] + f["size"][2]
142
+ if z1 < FLAT_Z:
143
+ continue
144
+ if z0 < -FLOOR_Z:
145
+ f, z0 = {**f, "z_src": z0, "pos": [f["pos"][0], f["pos"][1], 0.0], "size": [f["size"][0], f["size"][1], z1]}, 0.0
146
+ fp = Polygon(footprint(f))
147
+ if fp.area <= 0 or fp.area >= slab * shell.area or \
148
+ not fp.intersects(wall if f.get("fixed_kind") == "structure" else near):
149
+ continue
150
+ over = [p for p, lo, hi in kept if min(z1, hi) - max(z0, lo) > 0.05]
151
+ if f.get("fixed_kind") != "structure" and over and unary_union(over).intersection(fp).area >= cover * fp.area:
152
+ continue
153
+ out.append(f)
154
+ return out
155
+
156
+
157
+ def prep(room, a):
158
+ """IR room -> (clean room, None) or (None, reject reason). The clean room carries `fixed`: boxes the model is
159
+ told about with their pose and never places (each tagged fixed_kind for stats):
160
+ structure doors, windows, columns, stairs... (anchors.fixed_geometry)
161
+ generic unlabelled boxes ('unknown', 'otherprop', 'object') standing on the floor
162
+ wall wall-anchored items whose box stands on the floor (a wall cabinet run from z=0)
163
+ floating wall-hung floor furniture in designer rooms (anchor "fixed" in anchors.infer_anchors)
164
+ no_front placeable boxes whose front the source does not give (InteriorGS custom cabinetry): the answer needs
165
+ every object's yaw, so they are shown as built-in instead of rejecting the room (flag no_front_fixed)
166
+ all of them filtered by _keep_fixed. Tilted boxes are kept as upright boxes with their yaw; floor boxes sunk
167
+ up to 30 cm and floor furniture floating up to 50 cm with nothing below (scans, or anything that cannot hang)
168
+ are snapped to the floor; wall / ceiling / inside-cabinet items are dropped without rejecting the room."""
169
+ meta = room.get("meta", {})
170
+ if meta.get("front_known") is not True:
171
+ return None, "front_unknown"
172
+ if meta.get("n_incomplete"): # objects without a box would be invisible obstacles
173
+ return None, "incomplete_objects"
174
+ if len({o["id"] for o in room["objects"]}) != len(room["objects"]):
175
+ return None, "duplicate_source_ids"
176
+ if room.get("boundary_type") not in a.boundary_types or not room.get("boundary"):
177
+ return None, "boundary"
178
+ if reason := _room_geometry_error(room):
179
+ return None, reason
180
+ if not all(_finite(o) for o in room["objects"]):
181
+ return None, "bad_numbers"
182
+ fixed = [{**o, "fixed_kind": "structure"} for o in fixed_geometry(room)]
183
+ allobj = annotate(room) # structure and soft covers out, anchors inferred
184
+ objs = closed(allobj, a.source_anchors.get(room["source"], a.anchors))
185
+ generic = {o["id"] for o in objs if GENERIC.match(norm_cat(o["category"])) or not norm_cat(o["category"])}
186
+ sunk = {o["id"] for o in objs if not o.get("parent") and o["pos"][2] < -FURNITURE_SUNK_Z}
187
+ objs = _drop_with_dependants(objs, generic | sunk)
188
+ byid = {o["id"]: o for o in objs} # keep only top-surface support
189
+ objs = _drop_with_dependants(objs, [
190
+ o["id"] for o in objs if o.get("parent") and (
191
+ (p := byid.get(o["parent"])) is None or not support_ok(o, p))])
192
+ nofront = {o["id"] for o in objs if o.get("front_known") is False}
193
+ objs = _drop_with_dependants(objs, nofront) # shown as fixed below (with what stands on them dropped)
194
+ if any(min(o["size"]) < 0 for o in objs): # only what is placed must have a size (0 = flat, shown as 1 cm)
195
+ return None, "bad_numbers"
196
+ kept = {o["id"] for o in objs}
197
+ children = {o["id"] for o in allobj if o.get("parent")}
198
+ # every other box that stands on the floor (or is a wall-hung unit, anchor "fixed") is told as fixed
199
+ for o in allobj:
200
+ nc = norm_cat(o["category"])
201
+ if o["id"] in kept or o["id"] in children or o.get("parent") or o["size"][2] < FLAT_Z:
202
+ continue
203
+ if not (-FURNITURE_SUNK_Z <= o["pos"][2] <= FLOOR_Z or o["anchor"] == "fixed" or o["id"] in nofront):
204
+ continue
205
+ if hanging(o["category"]) or WALL_ART.search(nc) or head(o) in SOFT_COVERS:
206
+ continue
207
+ kind = "generic" if o["id"] in generic else "no_front" if o["id"] in nofront else \
208
+ "floating" if o["anchor"] == "fixed" else "wall"
209
+ fixed.append({**{k: v for k, v in o.items() if k not in ("anchor", "parent", "tilted", "anchor_inferred")},
210
+ "fixed_kind": kind})
211
+ shell = Polygon(room["boundary"]).buffer(0)
212
+ fixed = _keep_fixed(fixed, objs, shell)
213
+ shown = kept | {f["id"] for f in fixed}
214
+ # a dropped box that stood on the floor and is neither shown nor an opening / flush finish leaves an unexplained
215
+ # hole in the reference layout; counted: its part on this room's floor that no placed object covers
216
+ inner = shell.buffer(-WALL_BAND_M)
217
+ # what explains a dropped box: a placed object or a fixed box standing on it (a rug does not)
218
+ covered = unary_union([Polygon(footprint(o)) for o in objs if not o.get("parent") and o["size"][2] >= FLAT_Z]
219
+ + [Polygon(footprint(f)) for f in fixed if f["size"][2] >= FLAT_Z])
220
+ polygon = room.get("boundary_type") == "polygon" # (a rug does not explain a hole in the layout)
221
+ low = lambda o: o["pos"][2] <= FLOOR_Z and o["pos"][2] + o["size"][2] >= FLAT_Z # stands on / sticks out of it
222
+ hidden = sum(Polygon(footprint(o)).intersection(shell).difference(covered).area for o in room["objects"]
223
+ if o["id"] not in shown and o["id"] not in children and not o.get("parent") and low(o)
224
+ and not hanging(o["category"])
225
+ and not WALL_ART.search(norm_cat(o["category"])) and not _flush(o, inner, polygon))
226
+ if len(objs) < a.min_objects:
227
+ return None, "object_count"
228
+ # snap to the DISPLAYED numbers (cm, integer degrees) so checks, constraints and eval see what the model sees;
229
+ # z: floor 0, child = parent top; fixed boxes keep their own z (a window sill), floor-level ones go to 0
230
+ rounded = lambda o: {**o, "size": shown_size(o), "yaw": math.radians(round(math.degrees(o["yaw"])) % 360)}
231
+ objs = [{**rounded(o), **({"z_src": o["pos"][2]} if not o.get("parent") and abs(o["pos"][2]) > 1e-9 else {})}
232
+ for o in objs] # provenance: the source z of a floor box snapped to 0
233
+ fixed = [{**rounded(o), "pos": [num(o["pos"][0]), num(o["pos"][1]),
234
+ 0 if -FLOOR_Z <= o["pos"][2] <= FLOOR_Z else num(o["pos"][2])]} for o in fixed]
235
+ byid = {o["id"]: o for o in objs}
236
+ done = set()
237
+
238
+ def z_of(o):
239
+ if o["id"] not in done:
240
+ p = byid.get(o.get("parent"))
241
+ o["pos"] = [num(o["pos"][0]), num(o["pos"][1]), num(z_of(p) + p["size"][2]) if p else 0]
242
+ done.add(o["id"])
243
+ return o["pos"][2]
244
+ for o in objs:
245
+ z_of(o)
246
+ b = clean_boundary(room["boundary"])
247
+ if b is None or (a.max_vertices and len(b) > a.max_vertices) or Polygon(b).area < MIN_AREA_M2:
248
+ return None, "boundary_shape"
249
+ out = {**room, "boundary": b, "objects": objs, "fixed": fixed}
250
+ # quality FLAGS (the room is kept; every flag is counted in stats.json and written next to the sample so a
251
+ # training sampler can weight or exclude it; --reject_flagged turns chosen flags back into rejections)
252
+ flags = {}
253
+ area = Polygon(b).area
254
+ geometry = check(out, oob_tol=a.oob_tol)
255
+ oob = geometry["oob"]
256
+ if oob: # reference objects crossing the walls by more than oob_tol
257
+ flags["oob_objects"] = len(oob)
258
+ if hidden > 0: # unexplained floor area of dropped boxes (see above)
259
+ flags["hidden_m2"] = round(hidden, 2)
260
+ if hidden >= a.hidden_max:
261
+ flags["hidden_obstacle"] = True
262
+ if area < 1.0:
263
+ flags["small_area_m2"] = round(area, 2)
264
+ if any(not o.get("parent") and head(o) not in FLAT and o["size"][2] >= 0.5
265
+ and o["size"][0] * o["size"][1] >= 0.8 * area for o in objs): # composite built-in boxing the room
266
+ flags["room_filling_object"] = True
267
+ if _bad_overlap(objs): # copies of one wardrobe through each other
268
+ flags["overlapping_furniture"] = True
269
+ through = geometry["fixed_blocking"] # reuse the same box intersections for fixed obstacles
270
+ if through:
271
+ flags["fixed_collision"] = len(through)
272
+ if len(objs) == 1:
273
+ flags["single_object"] = True
274
+ snapped = sum(abs(o.get("z_src") or 0) > FLOOR_Z for o in objs) # beyond ordinary floor noise
275
+ if snapped:
276
+ flags["z_snapped"] = snapped
277
+ if any(o.get("tilted") for o in objs):
278
+ flags["tilted"] = sum(bool(o.get("tilted")) for o in objs)
279
+ if nofront:
280
+ flags["no_front_fixed"] = len(nofront)
281
+ hit = [k for k in a.reject_flagged if k in flags]
282
+ if hit:
283
+ return None, "flagged:" + hit[0]
284
+ out["meta"] = {**out.get("meta", {}), "flags": flags}
285
+ h = out.get("height")
286
+ if h and any(o["pos"][2] + o["size"][2] > h + 0.05 for o in objs): # height contradicts the boxes: unknown
287
+ out = {**out, "height": None, "meta": {**out["meta"], "height_dropped": True}}
288
+ elif h:
289
+ out = {**out, "height": num(h)} # the value the model is shown
290
+ return out, None
291
+
292
+
293
+ def _bad_overlap(objs):
294
+ """Copies of the same furniture that pass through each other (3D box overlap over 30% of the smaller footprint):
295
+ a wardrobe or bookcase entered twice in the source. Different categories are not judged here: at box level an
296
+ appliance built into a cabinet run, a loft bed over its desk or an L-sofa's box over its table look the same as a
297
+ real interpenetration, so those stay collisions reported by evaluate against the GT rate."""
298
+ fl = [o for o in objs if not o.get("parent") and head(o) not in FLAT | SOFT_HEADS
299
+ and not COMPOSITE.search(norm_cat(o["category"]))]
300
+ fps = [Polygon(footprint(o)) for o in fl]
301
+ for i, a in enumerate(fl):
302
+ for j in range(i + 1, len(fl)):
303
+ b = fl[j]
304
+ if norm_cat(a["category"]) != norm_cat(b["category"]):
305
+ continue
306
+ if min(a["pos"][2] + a["size"][2], b["pos"][2] + b["size"][2]) - max(a["pos"][2], b["pos"][2]) <= 0.05:
307
+ continue
308
+ small, big = sorted((fps[i].area, fps[j].area))
309
+ if fps[i].intersection(fps[j]).area > 0.3 * small and small >= big / 3:
310
+ return True
311
+ return False
312
+
313
+
314
+ def exact_key(room):
315
+ """Same boundary and the same objects and fixed boxes (category, size, pose) up to a 90-degree turn."""
316
+ keys = []
317
+ for k in range(4):
318
+ r = rot90(room, k)
319
+ pose = lambda o, c: (c, tuple(o["size"]), num(o["pos"][0]), num(o["pos"][1]), num(o["pos"][2]),
320
+ round(math.degrees(o["yaw"])) % 360)
321
+ keys.append(repr((r["boundary"], sorted(pose(o, norm_cat(o["category"])) for o in r["objects"]),
322
+ sorted(pose(o, slug(o["category"])) for o in r.get("fixed") or []))))
323
+ return hashlib.sha1(min(keys).encode()).hexdigest()
324
+
325
+
326
+ def dedup(rooms):
327
+ """Same group in several sources (e.g. a ScanNet scan in Scan2CAD and InternScenes): keep the source whose
328
+ rooms of that group keep more objects; ties go to a polygon boundary, then source name."""
329
+ by = collections.defaultdict(lambda: collections.defaultdict(list))
330
+ for r in rooms:
331
+ by[r["group"]][r["source"]].append(r)
332
+ keep, dropped = [], collections.Counter()
333
+ for g, srcs in by.items():
334
+ best = max(srcs, key=lambda s: (sum(len(r["objects"]) for r in srcs[s]),
335
+ any(r["boundary_type"] == "polygon" for r in srcs[s]), s))
336
+ keep += srcs[best]
337
+ for s, rs in srcs.items():
338
+ if s != best:
339
+ dropped[s] += len(rs)
340
+ return keep, dropped
341
+
342
+
343
+ def rng_for(uid, salt):
344
+ return random.Random(int(hashlib.sha1(f"{salt}:{uid}".encode()).hexdigest(), 16))
345
+
346
+
347
+ def extract_constraints(room, rng, max_k=4):
348
+ """1..max_k relations that hold on the reference layout, one per type first (round robin, rarest type first).
349
+ Only a few relations are given, never the full relation graph: the model must still lay out everything else."""
350
+ b = room["boundary"]
351
+ objs = room["objects"]
352
+ fps = {o["id"]: Polygon(footprint(o)) for o in objs}
353
+ cand = collections.defaultdict(list)
354
+ objs = [o for o in objs if head(o) not in FLAT and o["size"][2] >= 0.05] # rugs/flat boxes: not meaningful arguments
355
+ idx = {o["id"]: o for o in objs} # built once, not in every holds() call
356
+ for o in objs:
357
+ if o.get("parent"):
358
+ cand["on"].append(["on", o["id"], o["parent"]])
359
+ if room["boundary_type"] == "polygon" and not o.get("parent") and holds(["against_wall", o["id"]], objs, b, idx):
360
+ cand["against_wall"].append(["against_wall", o["id"]])
361
+ for x in objs:
362
+ for y in objs:
363
+ if x is y or x.get("parent") or y.get("parent"):
364
+ continue
365
+ if head(x) in FRONT and not fps[x["id"]].intersects(fps[y["id"]]) and \
366
+ holds(["faces", x["id"], y["id"]], objs, b, idx):
367
+ cand["faces"].append(["faces", x["id"], y["id"]])
368
+ if x["id"] < y["id"]:
369
+ gap = fps[x["id"]].distance(fps[y["id"]])
370
+ d = next((d for d in (0.1, 0.2, 0.3, 0.5) if d >= gap), None)
371
+ small = min(fps[x["id"]].area, fps[y["id"]].area)
372
+ if small > 0 and fps[x["id"]].intersection(fps[y["id"]]).area > 0.3 * small:
373
+ continue # a collision or a duplicate annotation, not a "near" relation
374
+ if d is not None and holds(["near", x["id"], y["id"], d], objs, b, idx): # the one checker decides
375
+ cand["near"].append(["near", x["id"], y["id"], d])
376
+ floor = [o for o in objs if not o.get("parent")]
377
+ cat = [norm_cat(o["category"]) for o in floor]
378
+ # only footprints the p-q segment can touch are tested (STRtree bbox query, then the same checks in floor order):
379
+ # the same candidates as testing every m, without the O(n^3) sweep (a 965-book library room took hours)
380
+ # the checks of holds(["between", m, p, q]) (segment p-q crosses m's footprint, not only touching it) and of the
381
+ # trivial-endpoint skip, evaluated vectorized on those candidates; same results, same floor order
382
+ geo = np.array([fps[m["id"]] for m in floor], dtype=object)
383
+ tree = STRtree(list(geo))
384
+ for i, p in enumerate(floor):
385
+ for j in range(i + 1, len(floor)):
386
+ if cat[i] != cat[j]:
387
+ continue
388
+ q = floor[j]
389
+ seg = LineString([p["pos"][:2], q["pos"][:2]])
390
+ ks = np.sort(tree.query(seg))
391
+ if not len(ks):
392
+ continue
393
+ g = geo[ks]
394
+ ok = ~shapely.intersects(g, Point(p["pos"][:2])) & ~shapely.intersects(g, Point(q["pos"][:2])) \
395
+ & shapely.intersects(g, seg) & ~shapely.touches(g, seg) & (ks != i) & (ks != j)
396
+ for k in ks[ok]:
397
+ cand["between"].append(["between", floor[k]["id"], p["id"], q["id"]])
398
+ types = [t for t in PRIORITY if cand[t]] # rarest types first, so all five are covered
399
+ for t in types:
400
+ rng.shuffle(cand[t])
401
+ k, out = rng.randint(1, max_k), []
402
+ while len(out) < k and any(cand[t] for t in types):
403
+ for t in types:
404
+ if cand[t] and len(out) < k:
405
+ out.append(cand[t].pop())
406
+ return out
407
+
408
+
409
+ def count_constraints(st, split, room, cons):
410
+ """stats: constraints per type; 'on' split by evidence (support given by the source vs box-contact inference)."""
411
+ objs = {o["id"]: o for o in room["objects"]}
412
+ for c in cons or []:
413
+ t = c[0]
414
+ if t == "on":
415
+ t += "(inferred)" if objs[c[1]].get("anchor_inferred") else "(source)"
416
+ st[f"{split}_constraints:{t}"] += 1
417
+
418
+
419
+ def main():
420
+ ap = argparse.ArgumentParser()
421
+ ap.add_argument("--ir", required=True, help="dir of <source>.jsonl from fastfill.adapters")
422
+ ap.add_argument("--out", required=True, help="new dataset directory (must be absent or empty)")
423
+ ap.add_argument("--sources", nargs="*", default=None, help="default: every file in --ir")
424
+ ap.add_argument("--min_objects", type=int, default=1, help="rooms with fewer placeable objects have no target")
425
+ ap.add_argument("--max_vertices", type=int, default=0, help="reject rooms with more boundary vertices (0: no limit)")
426
+ ap.add_argument("--reject_flagged", nargs="*", default=[],
427
+ help="quality flags that reject a room instead of being recorded (oob_objects hidden_obstacle "
428
+ "small_area_m2 room_filling_object overlapping_furniture single_object)")
429
+ ap.add_argument("--keep_duplicates", action="store_true", help="keep exact duplicate layouts inside a split")
430
+ ap.add_argument("--boundary_types", nargs="*", default=["polygon", "hull"])
431
+ ap.add_argument("--anchors", nargs="*", default=["floor", "object"], help="object anchors kept (see anchors.py)")
432
+ ap.add_argument("--oob_tol", type=float, default=0.10, help="flag oob_objects: reference objects outside boundary+tol")
433
+ ap.add_argument("--hidden_max", type=float, default=0.3, help="flag hidden_obstacle: m2 of unexplained dropped floor boxes")
434
+ ap.add_argument("--cap", nargs="*", default=[], help="SOURCE=N: keep N train rooms of SOURCE (hash-sampled); "
435
+ "default none: balance sources at sampling time, not here")
436
+ ap.add_argument("--source_anchors", nargs="*", default=[], help="SOURCE=a,b: anchors kept for that source")
437
+ ap.add_argument("--dev", type=float, default=0.05)
438
+ ap.add_argument("--test", type=float, default=0.05)
439
+ ap.add_argument("--constraint_frac", type=float, default=0.0, help="share of train rooms given as S2 (v1.1: 0.3)")
440
+ ap.add_argument("--no_rot90", action="store_true")
441
+ ap.add_argument("--field_dropout", type=float, default=0.2, help="train: drop room_type / height each with p")
442
+ ap.add_argument("--desc_dropout", type=float, default=0.5, help="train: drop all desc of a room with p")
443
+ a = ap.parse_args()
444
+ a.source_anchors = {k: v.split(",") for k, v in (x.split("=") for x in a.source_anchors)}
445
+ caps = {k: int(v) for k, v in (x.split("=") for x in a.cap)}
446
+
447
+ files = _input_files(a, ap)
448
+ _validate_destination(a.out)
449
+ # Hash only implementation modules, excluding local audit/test artifacts.
450
+ here = Path(__file__).resolve().parent
451
+ code_files = _code_files(here)
452
+ code_sha = {str(p.relative_to(here)): _sha256(p) for p in code_files}
453
+ ir_sha = {p.name: _sha256(p) for p in files}
454
+ rooms, stats = [], collections.defaultdict(collections.Counter)
455
+ for f in files:
456
+ for line in _json_lines(f):
457
+ r0 = line
458
+ st = stats[r0["source"]]
459
+ r, why = prep(r0, a)
460
+ st["scanned"] += 1
461
+ if why:
462
+ st["rejected:" + why] += 1
463
+ continue
464
+ rooms.append({**r, "meta": {**r["meta"], "n_src_objects": len(r0["objects"])}})
465
+ print(f"prepared {f.name}: {len(rooms)} retained rooms so far", flush=True)
466
+ if not rooms:
467
+ ap.error("no usable rooms; output was not changed")
468
+ rooms, dropped = dedup(rooms)
469
+ for s, n in dropped.items():
470
+ stats[s]["dedup_dropped"] += n
471
+
472
+ splits = assign_splits(rooms, a.dev, a.test)
473
+ # exact duplicates (same boundary, and the same objects and fixed boxes at the same poses, up to a 90-degree
474
+ # turn) stay one room per split: 3D-FRONT/SpatialLM repeat whole designs up to 26 times
475
+ first = {}
476
+ for i in sorted(range(len(rooms)), key=lambda i: hashlib.sha1(rooms[i]["uid"].encode()).hexdigest()):
477
+ key = (splits[i], exact_key(rooms[i]))
478
+ if key in first and not a.keep_duplicates:
479
+ stats[rooms[i]["source"]]["duplicate_layout"] += 1
480
+ splits[i] = "duplicate"
481
+ else:
482
+ first[key] = i
483
+ for src, n in caps.items(): # hash order, not file order (files are sorted by type)
484
+ idx = sorted((i for i, r in enumerate(rooms) if r["source"] == src and splits[i] == "train"),
485
+ key=lambda i: hashlib.sha1(rooms[i]["uid"].encode()).hexdigest())
486
+ for i in idx[n:]:
487
+ splits[i] = "capped"
488
+ if not any(s in ("train", "dev", "test") for s in splits):
489
+ ap.error("no rooms remain after filtering; output was not changed")
490
+ destination = Path(a.out).absolute()
491
+ destination.parent.mkdir(parents=True, exist_ok=True)
492
+ with tempfile.TemporaryDirectory(prefix=f".{destination.name}-", dir=destination.parent) as staging:
493
+ _write_dataset(rooms, splits, stats, a, Path(staging), code_sha, ir_sha)
494
+ if any(_sha256(p) != ir_sha[p.name] for p in files):
495
+ raise RuntimeError("input IR changed during build; output was not published")
496
+ dependencies = {"__init__.py", "build.py", "anchors.py", "scene.py", "split.py", "validate.py"}
497
+ current_code = {str(p.relative_to(here)): _sha256(p) for p in _code_files(here)}
498
+ if any(current_code.get(name) != code_sha.get(name) for name in dependencies):
499
+ raise RuntimeError("implementation changed during build; output was not published")
500
+ # Serving/training tools do not participate in the build. Record their release-time versions,
501
+ # retaining the start snapshot so concurrent unrelated edits are not attributed to data generation.
502
+ manifest_path = Path(staging) / "MANIFEST.json"
503
+ with manifest_path.open(encoding="utf-8") as f:
504
+ manifest = json.load(f)
505
+ with manifest_path.open("w", encoding="utf-8") as f:
506
+ json.dump({**manifest, "code_sha256": current_code, "build_start_code_sha256": code_sha},
507
+ f, indent=1, allow_nan=False)
508
+ _validate_destination(destination)
509
+ os.rename(staging, destination)
510
+ for k, v in sorted(stats.items()):
511
+ print(f"{k:24s} {dict(v)}")
512
+
513
+
514
+ def _write_dataset(rooms, splits, stats, a, directory, code_sha, ir_sha):
515
+ with ExitStack() as stack:
516
+ open_output = lambda name: stack.enter_context(open(directory / f"{name}.jsonl", "w", encoding="utf-8"))
517
+ outs = {s: open_output(s) for s in ("train", "dev", "test")}
518
+ ir_outs = {s: open_output(s) for s in
519
+ ("dev_rooms", "test_rooms", "dev_constrained_rooms", "test_constrained_rooms")}
520
+ for r, s in zip(rooms, splits):
521
+ st = stats[r["source"]]
522
+ if s in ("capped", "duplicate"):
523
+ st["capped"] += s == "capped"
524
+ continue
525
+ # composition of what is WRITTEN (after dedup, duplicates and caps)
526
+ st["height_dropped"] += bool(r.get("meta", {}).get("height_dropped"))
527
+ st["objects_dropped"] += r["meta"]["n_src_objects"] - len(r["objects"]) - len(r["fixed"])
528
+ st["fixed_items"] += len(r["fixed"])
529
+ for f in r["fixed"]:
530
+ st["fixed:" + f["fixed_kind"]] += 1
531
+ st["rooms_with_fixed"] += bool(r["fixed"])
532
+ st["objects_max"] = max(st["objects_max"], len(r["objects"]))
533
+ for k in r["meta"]["flags"]:
534
+ st["flag:" + k] += 1
535
+ st["supports_inferred"] += sum(bool(o.get("anchor_inferred")) for o in r["objects"] if o.get("parent"))
536
+ st["supports_evidence"] += sum(not o.get("anchor_inferred") for o in r["objects"] if o.get("parent"))
537
+ if s == "train":
538
+ # one random stream per purpose: --constraint_frac changes nothing but the constraints (clean ablation:
539
+ # v1.1 minus its constraints is v1.0 byte for byte)
540
+ aug, s2, drop = (rng_for(r["uid"], k) for k in ("aug", "s2", "dropout"))
541
+ c = canonical(r if a.no_rot90 else rot90(r, aug.randrange(4)))
542
+ cons = extract_constraints(c, s2) if s2.random() < a.constraint_frac else None
543
+ st["train_S2" if cons else "train_S1"] += 1
544
+ count_constraints(st, "train", c, cons)
545
+ if drop.random() < a.field_dropout:
546
+ c = {**c, "room_type": None}
547
+ if drop.random() < a.field_dropout:
548
+ c = {**c, "height": None}
549
+ m = messages(c, cons, with_desc=drop.random() >= a.desc_dropout)
550
+ else:
551
+ c = canonical(r)
552
+ st[s] += 1
553
+ m = messages(c)
554
+ ir_outs[f"{s}_rooms"].write(json.dumps(c, ensure_ascii=False, allow_nan=False) + "\n")
555
+ cons = extract_constraints(c, rng_for(r["uid"], "eval"))
556
+ if cons:
557
+ count_constraints(st, s, c, cons)
558
+ ir_outs[f"{s}_constrained_rooms"].write(json.dumps({**c, "constraints": cons}, ensure_ascii=False, allow_nan=False) + "\n")
559
+ outs[s].write(json.dumps({"uid": r["uid"], "source": r["source"], "flags": r["meta"]["flags"], "messages": m},
560
+ ensure_ascii=False, allow_nan=False) + "\n")
561
+ with open(directory / "stats.json", "w", encoding="utf-8") as f:
562
+ json.dump({k: dict(v) for k, v in sorted(stats.items())}, f, indent=1, allow_nan=False)
563
+ manifest = {"args": vars(a),
564
+ "files": {p.name: {"sha256": _sha256(p), "lines": _line_count(p)}
565
+ for p in sorted(directory.iterdir()) if p.suffix == ".jsonl" or p.name == "stats.json"},
566
+ "code_sha256": code_sha, "ir_sha256": ir_sha}
567
+ with open(directory / "MANIFEST.json", "w", encoding="utf-8") as f:
568
+ json.dump(manifest, f, indent=1, allow_nan=False)
569
+
570
+
571
+ def _sha256(path):
572
+ digest = hashlib.sha256()
573
+ with open(path, "rb") as f:
574
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
575
+ digest.update(chunk)
576
+ return digest.hexdigest()
577
+
578
+
579
+ def _line_count(path):
580
+ with open(path, "rb") as f:
581
+ return sum(1 for _ in f)
582
+
583
+
584
+ def _code_files(here):
585
+ return sorted(p for p in here.rglob("*.py")
586
+ if not any(part.startswith(("review", ".", "__pycache__")) or part == "tests"
587
+ for part in p.relative_to(here).parts[:-1]))
588
+
589
+
590
+ def _json_lines(path):
591
+ with open(path, encoding="utf-8") as f:
592
+ for line_number, line in enumerate(f, 1):
593
+ try:
594
+ yield json.loads(line)
595
+ except json.JSONDecodeError as exc:
596
+ raise ValueError(f"{path}:{line_number}: invalid JSON: {exc.msg}") from exc
597
+
598
+
599
+ def _input_files(a, ap):
600
+ if not Path(a.ir).is_dir():
601
+ ap.error(f"IR directory does not exist: {a.ir}")
602
+ files = sorted(Path(a.ir).glob("*.jsonl"))
603
+ if a.sources is not None:
604
+ missing = set(a.sources) - {p.stem for p in files}
605
+ if missing:
606
+ ap.error("missing IR sources: " + ", ".join(sorted(missing)))
607
+ files = [p for p in files if p.stem in a.sources]
608
+ if not files or any(p.stat().st_size == 0 for p in files):
609
+ ap.error("selected IR files must exist and be nonempty")
610
+ if any(not 0 <= x <= 1 for x in (a.dev, a.test, a.constraint_frac, a.field_dropout, a.desc_dropout)) or \
611
+ a.dev + a.test > 1:
612
+ ap.error("split/dropout/constraint fractions must be in [0, 1], with dev + test <= 1")
613
+ return files
614
+
615
+
616
+ def _validate_destination(path):
617
+ path = Path(path)
618
+ if path.is_symlink() or (path.exists() and (not path.is_dir() or any(path.iterdir()))):
619
+ raise FileExistsError(f"refusing to replace existing dataset: {path}; choose a new version directory")
620
+
621
+
622
+ if __name__ == "__main__":
623
+ main()
code/fastfill/download.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Download the exported 3D room collections.
2
+
3
+ Default = aggregate files only (files at most 1 level below each export folder: the merged
4
+ json/jsonl exports, README, kits). --full adds per-scene copies and raw sources (~90k small files).
5
+ Files are fetched one by one with per-file backoff and skipped when already present with the right
6
+ size, so an interrupted run resumes; the Hub rate-limits bursts of small files (HTTP 429/499).
7
+
8
+ Usage:
9
+ python -m fastfill.download --out /Volumes/harddisk/3D_Room_Collections
10
+ python -m fastfill.download --out DIR --only IL3D 3D-FRONT --full [--with-meshes]
11
+ """
12
+ import argparse
13
+ import fnmatch
14
+ import os
15
+ import time
16
+ from concurrent.futures import ThreadPoolExecutor
17
+
18
+ from huggingface_hub import HfApi, hf_hub_download
19
+ from huggingface_hub.hf_api import RepoFile
20
+
21
+ # name -> (repo_id, folder inside the repo or "" for the whole repo); from excel/3D_Room_Scene_Collection.xlsx
22
+ SOURCES = {
23
+ "SAGE-10k": ("liantian/3D_Room_Scene_Collections", "SAGE10k"),
24
+ "M3DLayout": ("imChuling/3D_Room_Collections", "M3DLayout_exported"),
25
+ "InteriorGS": ("imChuling/3D_Room_Collections", "InteriorGS_exported"),
26
+ "MetaScenes": ("imChuling/3D_Room_Collections", "MetaScenes_exported"),
27
+ "IL3D": ("imChuling/3D_Room_Collections", "IL3D_exported"),
28
+ "SceneSmith": ("imChuling/3D_Room_Collections", "SceneSmith_exported"),
29
+ "MansionWorld": ("imChuling/3D_Room_Collections", "MansionWorld_exported"),
30
+ "OptiScene": ("imChuling/3D_Room_Collections", "OptiScene_exported"),
31
+ "InternScenes": ("imChuling/3D_Room_Collections", "InternScenes_exported"),
32
+ "Structured3D": ("imChuling/3D_Room_Collections", "Structured3D_exported"),
33
+ "3RScan": ("imChuling/3D_Room_Collections", "3RScan_exported"),
34
+ "ARKitScenes": ("imChuling/3D_Room_Collections", "ARKitScenes_exported"),
35
+ "ScanNet": ("imChuling/3D_Room_Collections", "ScanNet_exported"),
36
+ "SceneCAD_Scan2CAD": ("imChuling/3D_Room_Collections", "SceneCAD_Scan2CAD_exported"),
37
+ "SpatialLM": ("imChuling/3D_Room_Collections", "SpatialLM_exported"),
38
+ "3D-FRONT": ("BillLin66/3D_Room_Collections", "3DFront_exported"),
39
+ "ScanNet++": ("BillLin66/3D_Room_Collections", "ScanNet++_exported"),
40
+ "SpatialGen": ("BillLin66/3D_Room_Collections", "SpatialGen_exported"),
41
+ "ASE": ("BillLin66/3D_Room_Collections", "ASE_exported"),
42
+ "HM3DSem": ("BillLin66/3D_Room_Collections", "HM3DSem_exported"),
43
+ "HSSD-200": ("XXXpilar/hssd_clean", ""),
44
+ "MultiScan": ("XXXpilar/multiscan-clean", ""),
45
+ }
46
+
47
+ # meshes, textures, images and per-vertex segment archives: not needed for text layout training
48
+ HEAVY = ["*.glb", "*.png", "*.jpg", "*.jpeg", "*.gif", "*.obj", "*.ply", "*.npz", "*.tar", "*.mtl", "*.urdf",
49
+ "*segments_anno.zip", "*keyframes.zip"]
50
+
51
+
52
+ def select(files, folder, full, with_meshes):
53
+ """Pick repo paths for one export folder."""
54
+ pre = f"{folder}/" if folder else ""
55
+ # whole-repo sources (HSSD, MultiScan) keep their layout json one level deeper: scenes/<id>/scene.json
56
+ max_depth = 2 if folder == "" else 1
57
+ out = []
58
+ for p in files:
59
+ if not p.startswith(pre):
60
+ continue
61
+ rel = p[len(pre):]
62
+ if not with_meshes and any(fnmatch.fnmatch(p, pat) for pat in HEAVY):
63
+ continue
64
+ if full or rel.count("/") <= max_depth:
65
+ out.append(p)
66
+ return out
67
+
68
+
69
+ def main():
70
+ ap = argparse.ArgumentParser()
71
+ ap.add_argument("--out", required=True)
72
+ ap.add_argument("--only", nargs="*", default=None, help="subset of SOURCES names")
73
+ ap.add_argument("--full", action="store_true", help="also per-scene/raw files deeper in the folder")
74
+ ap.add_argument("--with-meshes", action="store_true")
75
+ ap.add_argument("--workers", type=int, default=8)
76
+ a = ap.parse_args()
77
+ by_repo = {}
78
+ for name in a.only or SOURCES:
79
+ repo, folder = SOURCES[name]
80
+ by_repo.setdefault(repo, []).append((name, folder))
81
+ api = HfApi()
82
+ failed_count = 0
83
+ for repo, items in by_repo.items():
84
+ # one listing per repo: listing a large repo takes minutes
85
+ sizes = {f.path: f.size for f in api.list_repo_tree(repo, repo_type="dataset", recursive=True)
86
+ if isinstance(f, RepoFile)}
87
+ local = f"{a.out}/{repo.replace('/', '__')}"
88
+ for name, folder in items:
89
+ wanted = select(sizes, folder, a.full, a.with_meshes)
90
+ todo = [p for p in wanted if not (os.path.isfile(f"{local}/{p}") and os.path.getsize(f"{local}/{p}") == sizes[p])]
91
+ print(f"== {name}: {repo}/{folder} ({len(wanted)} files, {len(todo)} to fetch, "
92
+ f"{sum(sizes[p] for p in todo) / 1e9:.2f} GB)", flush=True)
93
+ with ThreadPoolExecutor(a.workers) as ex:
94
+ failed = [p for p, ok in zip(todo, ex.map(lambda p: fetch(repo, p, local), todo)) if not ok]
95
+ failed_count += len(failed)
96
+ print(f"== {name}: done" if not failed else f"== {name}: {len(failed)} files failed, rerun to resume",
97
+ flush=True)
98
+ if failed_count:
99
+ raise SystemExit(1)
100
+
101
+
102
+ def fetch(repo, path, local, tries=8):
103
+ """One file with backoff: the Hub answers bursts of small-file requests with 429/499/5xx."""
104
+ for k in range(tries):
105
+ try:
106
+ hf_hub_download(repo, path, repo_type="dataset", local_dir=local)
107
+ return True
108
+ except Exception as e: # noqa: BLE001 network/HTTP errors of several libraries
109
+ if k == tries - 1:
110
+ print(f" failed {path}: {str(e)[:120]}", flush=True)
111
+ return False
112
+ time.sleep(min(30 * 2 ** k, 600))
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
code/fastfill/download_full.sh ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/zsh
2
+ # Full download of the exported 3D room collections with `hf download`, one dataset after another (needs zsh).
3
+ # fastfill/download_full.sh OUT_DIR [NAME ...] (names as in fastfill/download.py SOURCES; default: all 22)
4
+ # Re-runnable: files already in OUT_DIR are skipped. A dataset is retried until `hf download` exits cleanly,
5
+ # because the Hub answers folders with tens of thousands of small files with transient 429/499 errors.
6
+ # Exits 1 on an unknown name or when a dataset still fails after 30 attempts.
7
+ OUT=${1:?usage: download_full.sh OUT_DIR [NAME ...]}; shift
8
+ OUT=${OUT:A} # absolute before the cd below
9
+ HF=${HF_BIN:-hf}; PY=${PYTHON:-python}
10
+ cd "${0:A:h}/.." || exit 1
11
+ known=($($PY -c 'from fastfill.download import SOURCES; print(*SOURCES)')) || exit 1
12
+ for n in "$@"; do (( ${known[(Ie)$n]} )) || { echo "unknown dataset: $n (known: $known)"; exit 1; }; done
13
+ failed=()
14
+ $PY -c 'from fastfill.download import SOURCES
15
+ for n, (r, f) in SOURCES.items(): print(n, r, f or "-")' | while read name repo folder; do
16
+ if (( $# )) && [[ ${@[(Ie)$name]} -eq 0 ]]; then continue; fi
17
+ inc=(); [[ $folder != "-" ]] && inc=(--include "$folder/*")
18
+ echo "== $name: $repo/$folder"
19
+ ok=0
20
+ for i in {1..30}; do
21
+ $HF download $repo --repo-type dataset $inc --local-dir "$OUT/${repo/\//__}" --max-workers 4 >/dev/null && { ok=1; break; }
22
+ echo "== $name: attempt $i failed, retrying in 60s"; sleep 60
23
+ done
24
+ if (( ok )); then echo "== $name: done"; else echo "== $name: FAILED after 30 attempts"; failed+=($name); fi
25
+ done
26
+ (( ${#failed} )) && { echo "== FAILED: $failed"; exit 1; }
27
+ echo "== FULL DOWNLOAD FINISHED"
code/fastfill/evaluate.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate layouts for held-out rooms and score them.
2
+
3
+ python -m fastfill.evaluate --model outputs/ff-v1-merged --rooms data/fastfill_v1/test_rooms.jsonl --out eval/ff-v1
4
+ python -m fastfill.evaluate --gt-only --rooms data/fastfill_v1/test_rooms.jsonl --out eval/gt # reference layouts
5
+ python -m fastfill.evaluate # self-check
6
+
7
+ Rooms come from fastfill.build (already canonical: ids and order as the model saw them in training).
8
+ Every request is counted: unparsable or incomplete outputs, and prompts of --max_len tokens or more (prompt_too_long,
9
+ never generated), are invalid and fail all their constraints (no retries, no filtering); identical requests are scored
10
+ once. Metrics are reported per source and pooled separately for
11
+ in-distribution sources and held-out (eval-only) sources.
12
+ Validity, 1 mm containment, acceptance, per-object and collision rates and OOR are reported next to their GT value
13
+ (`*_gt`): the reference layout of the same rooms scored exactly like the model's answer (repair and serve.py's
14
+ acceptance included), over the same rows (all requests, or the complete answers for the per-object and collision
15
+ rates). Constraints hold on the reference by construction, and it always parses.
16
+ Uses vLLM when installed, else HF generate (up to --batch rows of equal token length). Greedy decoding; each answer may use the context left after its prompt
17
+ (--max_len, the model's sequence limit), so nothing is truncated below what training saw.
18
+ """
19
+ import argparse
20
+ import collections
21
+ import copy
22
+ import itertools
23
+ import json
24
+ import os
25
+ import sys
26
+
27
+ from shapely.geometry import Polygon
28
+
29
+ from fastfill.interface import snap_inside
30
+ from fastfill.scene import apply, footprint, gt_placements, messages, parse, prompt_text
31
+ from fastfill.validate import check, holds
32
+
33
+ BUCKETS = ("main", "severe", "tuck", "contained", "fixed")
34
+
35
+
36
+ def generate(model, prompts, max_len, batch):
37
+ """One text per prompt; None for a prompt of max_len tokens or more (nothing fits after it and vLLM raises on it),
38
+ which score() records as prompt_too_long."""
39
+ res = [None] * len(prompts)
40
+ try:
41
+ from vllm import LLM, SamplingParams
42
+ llm = LLM(model=model, max_model_len=max_len)
43
+ tok = llm.get_tokenizer()
44
+ fit = [(i, max_len - len(tok(p)["input_ids"])) for i, p in enumerate(prompts)]
45
+ fit = [(i, n) for i, n in fit if n > 0]
46
+ outs = llm.generate([prompts[i] for i, _ in fit], [SamplingParams(temperature=0, max_tokens=n) for _, n in fit])
47
+ for (i, _), o in zip(fit, outs):
48
+ res[i] = o.outputs[0].text
49
+ except ImportError:
50
+ import torch
51
+ from transformers import AutoModelForCausalLM, AutoTokenizer
52
+ tok = AutoTokenizer.from_pretrained(model, padding_side="left")
53
+ m = AutoModelForCausalLM.from_pretrained(model, dtype=torch.bfloat16, device_map="auto")
54
+ lengths = sorted((len(tok(p, add_special_tokens=False)["input_ids"]), i) for i, p in enumerate(prompts))
55
+ # A shared generation limit is safe only for equal-length prompts; padding otherwise steals short rows' budget.
56
+ for length, group in itertools.groupby(lengths, key=lambda row: row[0]):
57
+ if length >= max_len:
58
+ continue
59
+ fit = [i for _, i in group]
60
+ for k in range(0, len(fit), batch):
61
+ idx = fit[k:k + batch]
62
+ enc = tok([prompts[i] for i in idx], return_tensors="pt", padding=True, add_special_tokens=False).to(m.device)
63
+ out = m.generate(**enc, max_new_tokens=max_len - length, do_sample=False)
64
+ for i, t in zip(idx, tok.batch_decode(out[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)):
65
+ res[i] = t
66
+ return res
67
+
68
+
69
+ def geometry(room):
70
+ c = check(room)
71
+ return {"valid": c["valid"], "oob_objects": len(c["oob"]), "support_fail": len(c["support_fail"]),
72
+ "ceiling_objects": len(c["ceiling"]), "oor": c["oor"], "coll_fixed": len(c["fixed_collisions"]),
73
+ **{f"coll_{k}": len(c["collisions"][k]) for k in BUCKETS[:4]}}
74
+
75
+
76
+ def score(room, text, error=None):
77
+ """`text` None scores the reference layout (--gt-only, qa_report); `error` (prompt_too_long) marks a request the
78
+ model never answered, counted as a failed request like an unparsable answer. r["gt"] = judge() of the reference."""
79
+ ids = [o["id"] for o in room["objects"]]
80
+ gt = judge(room, gt_placements(room))
81
+ r = {"uid": room["uid"], "source": room["source"], "group": room["group"], "n": len(ids),
82
+ "held_out": bool(room.get("meta", {}).get("eval_only")), "gt": gt}
83
+ pl, err = ({}, error) if error else parse(text, ids) if text is not None else (None, None)
84
+ r["error"] = err
85
+ r["parsed"] = not error and err not in ("truncated_or_bad_json", "bad_json")
86
+ cons = room.get("constraints") or []
87
+ if err is not None: # a failed request fails every constraint it was asked to satisfy
88
+ return {**r, "valid": False, "constraints": [[c[0], False] for c in cons],
89
+ "constraints_repaired": [[c[0], False] for c in cons]}
90
+ return {**r, **(gt if text is None else judge(room, pl))}
91
+
92
+
93
+ def judge(room, pl):
94
+ """One complete answer (parse() form) scored raw, and after the interface's <= 10 cm push back inside (reported
95
+ apart: the raw proposal is the model's result)."""
96
+ cons = room.get("constraints") or []
97
+ pred = apply(room, pl)
98
+ r = geometry(pred)
99
+ r["constraints"] = [[c[0], holds(c, pred["objects"], room["boundary"])] for c in cons]
100
+ floor = Polygon(room["boundary"])
101
+ rep = copy.deepcopy(pl)
102
+ snap_inside(rep, room, floor, cons, room["boundary"]) # never breaks a constraint that held (all are hard)
103
+ fixed = apply(room, rep)
104
+ c = check(fixed)
105
+ r["inside_1mm"], r["inside_1mm_repaired"] = inside(pred, floor), inside(fixed, floor)
106
+ r["valid_repaired"] = c["valid"]
107
+ r["constraints_repaired"] = [[k[0], holds(k, fixed["objects"], room["boundary"])] for k in cons]
108
+ r["repaired_objects"] = sum(rep[i]["pos"] != pl[i]["pos"] for i in pl)
109
+ # what serve.py accepts: valid after repair, every object within 1 mm of the walls, nothing through a non-window
110
+ # fixed box, every constraint holding
111
+ r["accepted"] = c["valid"] and r["inside_1mm_repaired"] and not c["fixed_blocking"] \
112
+ and all(v for _, v in r["constraints_repaired"])
113
+ return r
114
+
115
+
116
+ def inside(room, floor, tol=1e-3):
117
+ """Every object, on others too, inside the boundary within 1 mm (EmbodiedGen's containment rule)."""
118
+ grown = floor.buffer(tol)
119
+ return all(grown.covers(Polygon(footprint(o))) for o in room["objects"])
120
+
121
+
122
+ def summarize(rows):
123
+ def agg(rs):
124
+ n = len(rs)
125
+ if not n:
126
+ return None
127
+ ok = [r for r in rs if r["error"] is None]
128
+ objs = sum(r["n"] for r in ok)
129
+ s = {"rooms": n, "groups": len({r["group"] for r in rs}),
130
+ "parse_rate": sum(r["parsed"] for r in rs) / n, "complete_rate": len(ok) / n,
131
+ "errors": dict(collections.Counter(r["error"] for r in rs if r["error"]))}
132
+ # over every request (a failed one counts as failing); *_repaired after the interface's deterministic repair
133
+ # (not model skill); accepted = serve.py's 200, repair included; GT: the reference layouts of the same rooms
134
+ for k, key in (("valid_rate", "valid"), ("valid_rate_repaired", "valid_repaired"),
135
+ ("inside_1mm_rate", "inside_1mm"), ("inside_1mm_rate_repaired", "inside_1mm_repaired"),
136
+ ("accepted_rate", "accepted")):
137
+ s[k] = sum(r.get(key, False) for r in rs) / n
138
+ s[f"{k}_gt"] = sum(r["gt"][key] for r in rs) / n
139
+ # geometry is measured on complete answers only; with none it is unknown (None), not a perfect 0
140
+ rate = lambda num, den: num / den if den else None
141
+ for k, key in (("repaired_object_rate", "repaired_objects"), ("oob_object_rate", "oob_objects"),
142
+ ("support_fail_rate", "support_fail"), ("ceiling_object_rate", "ceiling_objects")):
143
+ s[k] = rate(sum(r[key] for r in ok), objs)
144
+ s[f"{k}_gt"] = rate(sum(r["gt"][key] for r in ok), objs)
145
+ for k in BUCKETS: # share of rooms with a collision of this kind: model vs GT on the same (complete) rooms
146
+ name = "fixed_collision" if k == "fixed" else k
147
+ s[f"rooms_with_{name}"] = rate(sum(r[f"coll_{k}"] > 0 for r in ok), len(ok))
148
+ s[f"rooms_with_{name}_gt"] = rate(sum(r["gt"][f"coll_{k}"] > 0 for r in ok), len(ok))
149
+ s["mean_oor"] = rate(sum(r["oor"] for r in ok), len(ok))
150
+ s["mean_oor_gt"] = rate(sum(r["gt"]["oor"] for r in ok), len(ok))
151
+ by_c, by_r = collections.defaultdict(list), collections.defaultdict(list)
152
+ for r in rs: # every request, failed ones included
153
+ for t, v in r.get("constraints", []):
154
+ by_c[t].append(v)
155
+ for t, v in r.get("constraints_repaired", []):
156
+ by_r[t].append(v)
157
+ s["constraint_satisfaction"] = {t: sum(v) / len(v) for t, v in sorted(by_c.items())}
158
+ s["constraint_satisfaction_repaired"] = {t: sum(v) / len(v) for t, v in sorted(by_r.items())}
159
+ return s
160
+ by = collections.defaultdict(list)
161
+ for r in rows:
162
+ by[r["source"]].append(r)
163
+ # eval-only sources (SpatialGen, SceneSmith) are out of distribution: never pooled with the rest
164
+ return {"in_dist": agg([r for r in rows if not r["held_out"]]), "held_out": agg([r for r in rows if r["held_out"]]),
165
+ **{k: agg(v) for k, v in sorted(by.items())}}
166
+
167
+
168
+ def main():
169
+ ap = argparse.ArgumentParser()
170
+ ap.add_argument("--rooms", required=True, help="dev_rooms.jsonl / test_rooms.jsonl from fastfill.build")
171
+ ap.add_argument("--out", required=True)
172
+ ap.add_argument("--model", help="merged checkpoint dir or HF id")
173
+ ap.add_argument("--gt-only", action="store_true", help="score the reference layouts themselves")
174
+ ap.add_argument("--max_len", type=int, default=40960, help="model sequence limit; an answer may use what its prompt leaves")
175
+ ap.add_argument("--batch", type=int, default=1, help="HF generate only: prompts per forward pass (at 40,960 context "
176
+ "the KV cache of more than one row does not fit on one GPU)")
177
+ ap.add_argument("--limit", type=int, default=None)
178
+ a = ap.parse_args()
179
+
180
+ uniq = {} # identical requests (same room text and constraints) are scored once
181
+ for line in open(a.rooms):
182
+ r = json.loads(line)
183
+ uniq.setdefault(messages(r, r.get("constraints"), with_target=False)[1]["content"], r)
184
+ rooms = list(uniq.values())[:a.limit]
185
+ if a.gt_only:
186
+ texts = [None] * len(rooms)
187
+ else:
188
+ from transformers import AutoTokenizer
189
+ tok = AutoTokenizer.from_pretrained(a.model)
190
+ prompts = [prompt_text(tok, messages(r, r.get("constraints"), with_target=False)) for r in rooms]
191
+ texts = generate(a.model, prompts, a.max_len, a.batch)
192
+ # a None text after generation is a prompt the model could not take (>= --max_len tokens): a failed request
193
+ rows = [score(r, t, "prompt_too_long" if t is None and not a.gt_only else None) for r, t in zip(rooms, texts)]
194
+ os.makedirs(a.out, exist_ok=True)
195
+ with open(f"{a.out}/predictions.jsonl", "w") as f:
196
+ for r, t in zip(rows, texts):
197
+ f.write(json.dumps({**r, "output": t}, ensure_ascii=False) + "\n")
198
+ summary = summarize(rows)
199
+ json.dump(summary, open(f"{a.out}/metrics.json", "w"), indent=1)
200
+ print(json.dumps({k: summary[k] for k in ("in_dist", "held_out")}, indent=1))
201
+
202
+
203
+ if __name__ == "__main__":
204
+ if sys.argv[1:]:
205
+ main()
206
+ else:
207
+ from fastfill.scene import target_json
208
+ # the reference chair stands 3 cm through the south wall facing a box 28.5 deg off its front: not inside 1 mm,
209
+ # repaired by (-4, +3) cm (the shortest push would break the faces constraint), then accepted
210
+ room = {"uid": "u", "source": "S", "group": "g", "boundary": [[0, 0], [4, 0], [4, 3], [0, 3]],
211
+ "boundary_type": "polygon", "height": 2.8, "fixed": [], "constraints": [["faces", "chair_1", "box_1"]],
212
+ "objects": [{"id": "chair_1", "category": "chair", "size": [.5, .5, .9], "pos": [1, .22, 0], "yaw": 0.0},
213
+ {"id": "box_1", "category": "box", "size": [.06, .06, .3], "pos": [1.35, .03, 0], "yaw": 0.0}]}
214
+ gt = score(room, None)
215
+ assert not gt["inside_1mm"] and gt["inside_1mm_repaired"] and gt["repaired_objects"] == 1 and gt["accepted"], gt
216
+ assert all(gt[k] == v for k, v in gt["gt"].items()) and score(room, target_json(room))["accepted"]
217
+ # a model answer 20 cm through the west wall (oob, not repaired, faces lost) and a prompt that did not fit
218
+ off = target_json({**room, "objects": [{**room["objects"][0], "pos": [.05, 1.5, 0]}, room["objects"][1]]})
219
+ rows = [gt, score(room, off), score(room, None, "prompt_too_long")]
220
+ assert rows[1]["oob_objects"] == 1 and rows[1]["gt"] == gt["gt"] and rows[2]["gt"] == gt["gt"]
221
+ s = summarize(rows)["in_dist"]
222
+ want = {"valid_rate": (1 / 3, 1), "accepted_rate": (1 / 3, 1), "inside_1mm_rate": (0, 0),
223
+ "inside_1mm_rate_repaired": (1 / 3, 1), "valid_rate_repaired": (1 / 3, 1),
224
+ "oob_object_rate": (1 / 4, 0), "repaired_object_rate": (1 / 4, 2 / 4), "support_fail_rate": (0, 0)}
225
+ assert all((s[k], s[f"{k}_gt"]) == v for k, v in want.items()), s
226
+ assert s["constraint_satisfaction"] == {"faces": 1 / 3} and s["rooms_with_main_gt"] == 0, s
227
+ # --gt-only: every rate equals its GT
228
+ s = summarize([gt])["in_dist"]
229
+ assert all(s[k] == s[f"{k}_gt"] for k in s if f"{k}_gt" in s), s
230
+ print("evaluate.py self-check ok")
code/fastfill/inspect_ir.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Diagnostics for IR files: counts, GT geometry checks, and the front-direction test.
2
+
3
+ python -m fastfill.inspect_ir /Volumes/harddisk/fastfill_ir/IL3D.jsonl [--limit 5000]
4
+
5
+ Front test: for wall-hugging categories (bed, sofa, wardrobe, toilet, ...) the side of the footprint that
6
+ touches the boundary should be the back, i.e. local -X under the IR convention (front = local +X).
7
+ A different dominant side means the adapter's front_offset_deg is wrong.
8
+ """
9
+ import argparse
10
+ import collections
11
+ import json
12
+ import math
13
+ import re
14
+
15
+ from shapely.geometry import Point, Polygon
16
+
17
+ from fastfill.validate import check
18
+
19
+ WALL_HUGGERS = re.compile(r"\b(bed|sofa|couch|wardrobe|closet|toilet|dresser|bookcase|bookshelf|tv[ _]?stand|"
20
+ r"refrigerator|fridge|sideboard|nightstand|armoire)\b", re.I)
21
+
22
+
23
+ def wall_side(o, ring, tol=0.15):
24
+ """Which local axis side (+X, -X, +Y, -Y) of the footprint lies on the boundary, or None."""
25
+ c, s = math.cos(o["yaw"]), math.sin(o["yaw"])
26
+ hx, hy = o["size"][0] / 2, o["size"][1] / 2
27
+ x, y = o["pos"][0], o["pos"][1]
28
+ best, name = tol, None
29
+ for lab, (dx, dy) in {"+X": (hx, 0), "-X": (-hx, 0), "+Y": (0, hy), "-Y": (0, -hy)}.items():
30
+ d = ring.distance(Point(x + c * dx - s * dy, y + s * dx + c * dy))
31
+ if d < best:
32
+ best, name = d, lab
33
+ return name
34
+
35
+
36
+ def main():
37
+ ap = argparse.ArgumentParser()
38
+ ap.add_argument("files", nargs="+")
39
+ ap.add_argument("--limit", type=int, default=None)
40
+ a = ap.parse_args()
41
+ for path in a.files:
42
+ n, nobj, btypes, rtypes, cats = 0, [], collections.Counter(), collections.Counter(), collections.Counter()
43
+ meta = collections.Counter()
44
+ geo = collections.Counter()
45
+ sides = collections.defaultdict(collections.Counter)
46
+ for line in open(path):
47
+ r = json.loads(line)
48
+ n += 1
49
+ nobj.append(len(r["objects"]))
50
+ btypes[r.get("boundary_type")] += 1
51
+ rtypes[r.get("room_type")] += 1
52
+ meta["rooms_with_tilted"] += bool(r["meta"].get("n_tilted"))
53
+ meta["rooms_with_incomplete"] += bool(r["meta"].get("n_incomplete"))
54
+ for o in r["objects"]:
55
+ cats[o["category"]] += 1
56
+ if r.get("boundary") and r["objects"] and (a.limit is None or geo["rooms"] < a.limit):
57
+ c = check(r)
58
+ geo["rooms"] += 1
59
+ geo["objects"] += c["n"]
60
+ geo["valid"] += c["valid"]
61
+ geo["oob_objects"] += len(c["oob"])
62
+ geo["rooms_with_collision"] += bool(c["collisions"]["main"])
63
+ geo["oor_sum"] += c["oor"]
64
+ ring = Polygon(r["boundary"]).exterior
65
+ for o in r["objects"]:
66
+ m = WALL_HUGGERS.search(o["category"] or "")
67
+ if m:
68
+ sides[m.group(1).lower()][wall_side(o, ring)] += 1
69
+ nobj.sort()
70
+ q = lambda f: nobj[min(int(f * len(nobj)), len(nobj) - 1)] if nobj else None
71
+ print(f"== {path}\nrooms {n} objects/room p10 {q(.1)} p50 {q(.5)} p90 {q(.9)} max {q(1)}")
72
+ print("boundary_type", dict(btypes), "\nmeta", dict(meta))
73
+ print("room_type top", rtypes.most_common(12))
74
+ print("category top", cats.most_common(25), f"({len(cats)} distinct)")
75
+ if geo["rooms"]:
76
+ g = geo
77
+ print(f"GT geometry on {g['rooms']} rooms: valid {g['valid'] / g['rooms']:.3f} "
78
+ f"oob_object_rate {g['oob_objects'] / max(g['objects'], 1):.3f} "
79
+ f"rooms_with_main_collision {g['rooms_with_collision'] / g['rooms']:.3f} mean_oor {g['oor_sum'] / g['rooms']:.3f}")
80
+ print("front test (side touching the wall; expect -X):")
81
+ for k, v in sorted(sides.items(), key=lambda kv: -sum(kv[1].values()))[:10]:
82
+ tot = sum(v.values())
83
+ print(f" {k:12s} n={tot:6d} " + " ".join(f"{s}:{v[s] / tot:.2f}" for s in ("-X", "+X", "-Y", "+Y", None)))
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
code/fastfill/interface.py ADDED
@@ -0,0 +1,641 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """WorldEdge request (scope doc, Appendix B) <-> FastFill model text.
2
+
3
+ room, ctx = request_to_room(request)
4
+ prompt = prompt_text(tok, messages(room, room["constraints"], with_target=False))
5
+ ... one generation call ...
6
+ result = placements_from_text(text, room, ctx)
7
+
8
+ Model side (scene.py): room frame with the boundary AABB min corner at (0, 0), object local +X = semantic front,
9
+ integer-degree yaw inside the text. Interface side: the request's own frame (boundary_xy, floor_z), position_m of
10
+ the box bottom center, yaw_rad about +Z of the object's own local frame, support_parent = floor_id or the id of the
11
+ object it stands on, support_surface = "top". Objects anchored to walls or ceilings ("anchor": "wall" | "ceiling")
12
+ and fronts that are not along a horizontal local axis are returned as unsupported instead of being sent to the model,
13
+ and so is any constraint that names one of them.
14
+ Floor/object anchors are enforced after generation and reported in validation.constraints as ["anchor", id, kind],
15
+ with hard=true, trained=false, model_conditioned=false: the existing model protocol cannot express them.
16
+ room.fixed_geometry (optional): boxes already in the room that nothing may be placed on or through (doors, windows,
17
+ columns, stairs, furniture that stays), each {"id", "category"?, "size_xyz_m", "position_m" (bottom center),
18
+ "yaw_rad"?, "fixed_kind"?}; shown to the model as `fixed`, never placed, and overlaps with them are reported in validation
19
+ (fixed_collisions), and any such overlap with a non-window box fails the layout (fixed_blocking, serve.py 422). Normalized as build.prep shaped training: |z| <= FLOOR_Z is snapped to
20
+ 0; a box starting at or above FIXED_MAX_Z, covering half the room or more, or standing clear of the walls is not shown
21
+ and is reported in unsupported as "fixed_ignored". The wall band is 0.30 m for fixed_kind="structure", otherwise
22
+ 0.10 m; if fixed_kind is omitted, anchors.is_structure(category) selects the band. An explicit kind takes precedence.
23
+ A constraint may not name a fixed box (v1 relates objects to place).
24
+
25
+ layout_constraints (Appendix B names). Relational constraints are shown to the model as must-hold; after repair a failed
26
+ "hard" one (the default; "hard": false marks a soft one, which is only reported) fails the layout:
27
+ {"type": "faces", "subject", "target"} {"type": "between", "subject", "anchors": [a, b]}
28
+ {"type": "supported_by", "subject", "parent", "surface": "top"} {"type": "against_wall", "subject"}
29
+ {"type": "near", "subject", "target", "max_gap_m"} footprint gap, NOT centre distance (trained on 0.1-0.5)
30
+ {"type": "keepout", "polygon_xy": [[x, y], ...]} hard: cut out of the floor, padded by KEEPOUT_PAD
31
+ Soft keepouts leave the floor unchanged. They are reported as ["keepout", polygon_xy] in request coordinates,
32
+ with hard=false, trained=false, model_conditioned=false; the existing model protocol has no advisory region field.
33
+ A soft keepout holds when no placed footprint overlaps it by more than 1e-9 square meters (touching is allowed).
34
+ against_wall is accepted against the real walls, not keepout cuts, although the model sees a cut as wall.
35
+ A padded keepout that stops less than WALL_SNAP short of a wall (or of another keepout) is snapped to it: the strip
36
+ left between them is dropped from the floor the model sees (cm rounding folded one under 5 mm into a self-intersection,
37
+ a bad_boundary 422, for 35 of 20,000 EmbodiedGen-shaped requests); floor cut off behind such a gap is a pocket, and a
38
+ gap that alone would cut off POCKET_M2 or more is left open. A cut that still folds under cm rounding (a gap under 5 mm
39
+ to a large corner, a keepout crossing a wall by under 5 mm) is keepout_not_representable, not bad_boundary.
40
+ Forms training never contains (build.extract_constraints) are still shown and checked as asked, but reported with
41
+ "trained": false in validation: against_wall in a hull room, faces whose subject's category head is not in
42
+ build.FRONT, against_wall / near / faces / between naming an object that stands on another, between with anchors of
43
+ two categories, an argument (of supported_by: the subject) that is a FLAT covering or under 5 cm tall.
44
+ Rejected with ValueError (HTTP 422 in serve.py): malformed or non-finite numbers, a coordinate_system other than
45
+ m / right / Z / bbox_bottom_center / rad, duplicate ids, unknown anchors, non-positive sizes or height, constraints
46
+ naming unknown objects, fixed boxes or themselves, 'on' cycles or two parents, a keepout that splits the room or leaves an
47
+ island (enclosed pockets under POCKET_M2 are treated as unusable floor).
48
+ """
49
+ import copy
50
+ import math
51
+
52
+ from shapely.affinity import translate
53
+ from shapely.errors import ShapelyError
54
+ from shapely.geometry import Point, Polygon
55
+ from shapely.ops import nearest_points, unary_union
56
+
57
+ from fastfill.anchors import FIXED_MAX_Z, FLAT, FLOOR_Z, is_structure
58
+ from fastfill.build import FRONT, THICK_WALL_M, WALL_BAND_M
59
+
60
+ FLAT_Z = 0.15 # = build.FLAT_Z: a sunk fixed box whose top stays under this is not shown (checked in __main__)
61
+ from fastfill.scene import apply, canonical, clean_boundary, footprint, head, norm_cat, num, parse
62
+ from fastfill.validate import check, holds, support_contains
63
+
64
+ HULL_SOURCES = {"convex_hull", "hull", "scan_hull", "floor_hull"}
65
+ KEEPOUT_PAD = 0.08 # < validate.check's oob tolerance (0.10) minus cm rounding: repair onto the keepout edge stays valid
66
+ POCKET_M2 = 0.5 # free floor enclosed by keepouts and walls smaller than this is dropped, not a reason to refuse
67
+ WALL_SNAP = 0.01 # a strip of floor narrower than this left by a padded keepout (at a wall or keepout) is dropped
68
+ ANCHORS = {None, "floor", "object", "wall", "ceiling"}
69
+ COORDS = {"units": "m", "handedness": "right", "up_axis": "Z", "position_reference": "bbox_bottom_center",
70
+ "yaw_units": "rad"}
71
+ SNAP_TOL = 0.10 # training layouts cross walls by up to this much (40% of test rooms by > 1 mm); EmbodiedGen allows 1 mm
72
+ SNAP_GRID = sorted(((dx / 100, dy / 100) for dx in range(-10, 11) for dy in range(-10, 11) if dx * dx + dy * dy <= 100),
73
+ key=lambda d: d[0] ** 2 + d[1] ** 2)
74
+
75
+
76
+ def request_to_room(req):
77
+ """-> (canonical model room with constraints, ctx for mapping the answer back). Raises ValueError on bad input."""
78
+ try:
79
+ return _request_to_room(req)
80
+ except (KeyError, TypeError, IndexError, AttributeError, OverflowError, ShapelyError) as e:
81
+ raise ValueError(f"bad_request: {e!r}") from e
82
+
83
+
84
+ def _finite(v, lo=None, above=None):
85
+ if not (isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) and abs(v) < 1e4
86
+ and (lo is None or v >= lo) and (above is None or v > above)):
87
+ raise ValueError(f"bad_number: {v!r}")
88
+ return float(v)
89
+
90
+
91
+ def _text(v, optional=True):
92
+ if not (isinstance(v, str) or (optional and v is None)):
93
+ raise ValueError(f"bad_text: {v!r}")
94
+ return v
95
+
96
+
97
+ def _request_to_room(req):
98
+ rm = req["room"]
99
+ for k, v in (req.get("coordinate_system") or {}).items():
100
+ if k in COORDS and v != COORDS[k]:
101
+ raise ValueError(f"unsupported coordinate_system {k}={v!r} (FastFill speaks {COORDS})")
102
+ cons_in = req.get("layout_constraints") or []
103
+ pts = rm["boundary_xy"]
104
+ keepout_constraints = [c for c in cons_in if c["type"] == "keepout"]
105
+ for c in keepout_constraints:
106
+ if not isinstance(c.get("hard", True), bool):
107
+ raise ValueError(f"malformed constraint: {c}")
108
+ for p in [q for poly in [pts, *(c["polygon_xy"] for c in keepout_constraints)] for q in poly]:
109
+ _finite(p[0]), _finite(p[1])
110
+ for c in keepout_constraints:
111
+ poly = Polygon(c["polygon_xy"])
112
+ if not poly.is_valid or poly.is_empty or poly.area <= 0:
113
+ raise ValueError("bad_keepout")
114
+ keepouts = [c["polygon_xy"] for c in keepout_constraints if c.get("hard", True)]
115
+ if rm.get("height_m") is not None:
116
+ _finite(rm["height_m"], above=0)
117
+ _text(rm.get("room_type"))
118
+ walls = Polygon(pts) # against_wall is checked against these, not against keepout cuts
119
+ floor = walls # what answers are snapped into: the walls minus the keepouts themselves
120
+ if keepouts: # cut out, padded, from what the model sees: it treats a keepout as wall
121
+ if not floor.is_valid:
122
+ raise ValueError("bad_boundary")
123
+ cut = unary_union([Polygon(k) for k in keepouts])
124
+ seen = floor.difference(cut.buffer(KEEPOUT_PAD, join_style="mitre"))
125
+ # opening by WALL_SNAP / 2: strips under WALL_SNAP wide vanish, everything wider comes back edge for edge; the
126
+ # intersection clips the mitre corners that re-grow past a slanted wall where a strip vanished. A gap whose
127
+ # closing alone would cut off POCKET_M2 or more stays open (9 of 20,000 EmbodiedGen-shaped requests: two
128
+ # padded keepouts 5-10 mm apart in front of a corner): the corner behind it stays floor, as without the snap
129
+ snap = seen.intersection(seen.buffer(-WALL_SNAP / 2, join_style="mitre").buffer(WALL_SNAP / 2, join_style="mitre"))
130
+ big = lambda g: sum(q.area >= POCKET_M2 for q in getattr(g, "geoms", [g]))
131
+ seen = seen if big(snap) > 1 and big(seen) == 1 else snap
132
+ parts = sorted(getattr(seen, "geoms", [seen]), key=lambda g: -g.area)
133
+ pockets = [g for g in parts[1:] if g.area < POCKET_M2]
134
+ if seen.is_empty or len(parts) - len(pockets) != 1 or parts[0].interiors:
135
+ raise ValueError("keepout_not_representable") # fills or splits the room, or leaves an island
136
+ seen = parts[0]
137
+ floor = floor.difference(unary_union([cut, *pockets]))
138
+ pts = seen.exterior.coords[:-1]
139
+ x0, y0 = (min(p[i] for p in pts) for i in (0, 1))
140
+ boundary = clean_boundary([[x - x0, y - y0] for x, y in pts])
141
+ if boundary is None: # walls that round cleanly: the cut folds under cm rounding (a sub-5 mm gap or wall mouth)
142
+ wx, wy = walls.bounds[:2]
143
+ ok = keepouts and clean_boundary([[x - wx, y - wy] for x, y in walls.exterior.coords[:-1]])
144
+ raise ValueError("keepout_not_representable" if ok else "bad_boundary")
145
+ ctx = {"x0": x0, "y0": y0, "floor_z": _finite(rm.get("floor_z", 0.0)),
146
+ "floor_id": _text(rm.get("floor_id", "floor"), optional=False),
147
+ "floor": translate(floor, -x0, -y0), "walls": [[x - x0, y - y0] for x, y in walls.exterior.coords[:-1]],
148
+ "soft_keepouts": [(copy.deepcopy(c["polygon_xy"]), translate(Polygon(c["polygon_xy"]), -x0, -y0))
149
+ for c in keepout_constraints if not c.get("hard", True)],
150
+ "front_k": {}, "order": [], "unsupported": []}
151
+ fixed, fixed_ids = [], set()
152
+ near, wall = walls.buffer(WALL_BAND_M), walls.buffer(THICK_WALL_M)
153
+ for f in rm.get("fixed_geometry") or []:
154
+ _text(f["id"], optional=False), _text(f.get("category"))
155
+ kind = _text(f.get("fixed_kind"))
156
+ structural = kind == "structure" if kind is not None else is_structure(f.get("category"))
157
+ sx, sy, sz = (_finite(v, above=0) for v in f["size_xyz_m"])
158
+ x, y, z = (_finite(v) for v in f["position_m"])
159
+ yaw = _finite(f.get("yaw_rad", 0.0)) % (2 * math.pi)
160
+ fixed_ids.add(f["id"])
161
+ z -= ctx["floor_z"]
162
+ fp = Polygon(footprint({"pos": [x, y], "size": [sx, sy], "yaw": yaw}))
163
+ # as build.prep / anchors.fixed_geometry shaped training: nothing starting at FIXED_MAX_Z or above, no slab
164
+ # covering half the room, nothing clear of the walls; a floor-level bottom is z=0
165
+ if z + sz < FLAT_Z: # a covering or trim (or wholly under the floor): not shown, as in build._keep_fixed
166
+ ctx["unsupported"].append({"id": f["id"], "reason": "fixed_ignored"})
167
+ continue
168
+ if z < -FLOOR_Z: # sunk below the floor: cut at the floor
169
+ sz, z = z + sz, 0.0
170
+ if z >= FIXED_MAX_Z or fp.area >= 0.5 * walls.area or not fp.intersects(wall if structural else near):
171
+ ctx["unsupported"].append({"id": f["id"], "reason": "fixed_ignored"})
172
+ continue
173
+ fixed.append({"id": f["id"], "ext_id": f["id"], "category": f.get("category") or "structure",
174
+ "size": [sx, sy, sz], "pos": [x - x0, y - y0, 0.0 if abs(z) <= FLOOR_Z else z], "yaw": yaw})
175
+ ids = [o["id"] for o in req["objects_to_place"]] + [f["id"] for f in rm.get("fixed_geometry") or []]
176
+ if len(set(ids)) < len(ids) or ctx["floor_id"] in ids:
177
+ raise ValueError("duplicate_object_id")
178
+ objs = []
179
+ for o in req["objects_to_place"]:
180
+ _text(o["id"], optional=False), _text(o["category"], optional=False), _text(o.get("desc"))
181
+ sx, sy, sz = (_finite(v, above=0) for v in o["size_xyz_m"])
182
+ if o.get("anchor") not in ANCHORS:
183
+ raise ValueError(f"unknown anchor: {o.get('anchor')!r}")
184
+ fx, fy, fz = (_finite(v) for v in (o.get("semantic_front_local") or [1, 0, 0]))
185
+ phi, h = math.atan2(fy, fx), math.hypot(fx, fy)
186
+ k = round(phi / (math.pi / 2)) % 4
187
+ if o.get("anchor") in ("wall", "ceiling"):
188
+ ctx["unsupported"].append({"id": o["id"], "reason": "unsupported_anchor"})
189
+ continue
190
+ if h < 1e-9 or abs(fz) > 1e-3 * h or abs(math.remainder(phi - k * math.pi / 2, 2 * math.pi)) > 1e-3:
191
+ ctx["unsupported"].append({"id": o["id"], "reason": "front_not_axis_aligned"})
192
+ continue
193
+ if k % 2: # front along local Y: the model's local X is that axis
194
+ sx, sy = sy, sx
195
+ ctx["front_k"][o["id"]] = k
196
+ ctx["order"].append(o["id"])
197
+ objs.append({"id": o["id"], "ext_id": o["id"], "category": o["category"], "size": [sx, sy, sz],
198
+ "desc": o.get("desc"), "anchor": o.get("anchor")})
199
+ room = canonical({"room_type": rm.get("room_type"), "boundary": boundary, "height": rm.get("height_m"),
200
+ "boundary_type": "hull" if str(rm.get("boundary_source")).lower() in HULL_SOURCES else "polygon",
201
+ "fixed": fixed, "objects": objs})
202
+ m = {o["ext_id"]: o["id"] for o in room["objects"]}
203
+ skipped = {u["id"] for u in ctx["unsupported"]}
204
+ cons, ctx["checks"] = [], [] # what the model is shown / what the answer is accepted against
205
+ for c in cons_in:
206
+ t = c["type"]
207
+ if t == "keepout":
208
+ continue
209
+ s = c["subject"]
210
+ anchors = c.get("anchors", [])
211
+ if not isinstance(anchors, list) or not isinstance(c.get("hard", True), bool):
212
+ raise ValueError(f"malformed constraint: {c}")
213
+ refs = [s] + [c[k] for k in ("target", "parent") if k in c] + anchors
214
+ if any(r in fixed_ids for r in refs):
215
+ raise ValueError(f"constraint names a fixed box (v1 constraints only relate objects to place): {c}")
216
+ if any(r not in m and r not in skipped for r in refs):
217
+ raise ValueError(f"constraint on an unknown object: {c}")
218
+ if len(set(refs)) < len(refs):
219
+ raise ValueError(f"constraint names the same object twice: {c}")
220
+ if any(r in skipped for r in refs): # the rest of the room is still placed
221
+ ctx["unsupported"].append({"constraint": c, "reason": "references_unsupported_object"})
222
+ continue
223
+ if t == "faces":
224
+ k = ["faces", m[s], m[c["target"]]]
225
+ elif t == "between" and len(c["anchors"]) == 2:
226
+ k = ["between", m[s]] + [m[a] for a in c["anchors"]]
227
+ elif t == "supported_by" and c.get("surface", "top") == "top":
228
+ k = ["on", m[s], m[c["parent"]]]
229
+ elif t == "near":
230
+ k = ["near", m[s], m[c["target"]], _finite(c["max_gap_m"], lo=0)]
231
+ elif t == "against_wall":
232
+ k = ["against_wall", m[s]]
233
+ else:
234
+ raise ValueError(f"unsupported constraint: {c}")
235
+ # the model sees cm (near rounded DOWN, so meeting what it sees meets the request); acceptance uses the
236
+ # request's own number; hard=false constraints are reported but do not fail the layout
237
+ cons.append(k[:3] + [num(math.floor(k[3] * 100 + 1e-9) / 100)] if t == "near" else k)
238
+ ctx["checks"].append((k, c.get("hard", True)))
239
+ parent = {}
240
+ for k in cons:
241
+ if k[0] == "on":
242
+ if parent.setdefault(k[1], k[2]) != k[2]:
243
+ raise ValueError(f"{k[1]} is asked to stand on two objects")
244
+ for x in parent: # an 'on' cycle can never hold
245
+ seen, cur = {x}, parent[x]
246
+ while cur in parent:
247
+ if cur in seen:
248
+ raise ValueError(f"'on' cycle through {cur}")
249
+ seen.add(cur)
250
+ cur = parent[cur]
251
+ room["constraints"] = cons
252
+ return room, ctx
253
+
254
+
255
+ def snap_inside(pl, room, floor, cons=(), walls=None):
256
+ """Move each object that crosses `floor` (model frame) by at most SNAP_TOL back inside, with what stands on it.
257
+ Lower objects first, so a child is judged after its parent moved; a child is only moved as far as its centre
258
+ stays over its parent (validate.check's support rule), else it is left for validation to reject. A move is never
259
+ taken if it would deepen the overlap of the object OR of anything standing on it (the whole support subtree
260
+ moves with it) with a fixed box of the room (room["fixed"], at that object's height).
261
+ The hard constraints `cons` (validate.holds, against_wall against `walls`) that held before the repair hold after
262
+ it: if the plain pass breaks one, the repair is redone refusing every move that breaks one of them. The plain pass
263
+ goes first because a constraint may fail only between two moves (three chairs pushed off one wall in turn: the
264
+ between through the middle one fails until it moves too). Without this, pushing chairs off walls broke 7 GT-true
265
+ constraints in 8,254 test_constrained rooms (faces 4, against_wall 2, near 1)."""
266
+ size = {o["id"]: o["size"] for o in room["objects"]}
267
+ lay = lambda q: {j: {"pos": p["pos"], "yaw": p["yaw"], "size": size[j], "parent": p["on"]} for j, p in q.items()}
268
+ held = [c for c in cons if holds(c, None, walls, lay(pl))]
269
+ raw = copy.deepcopy(pl)
270
+ _push(pl, room, floor, size, [], walls)
271
+ if not all(holds(c, None, walls, lay(pl)) for c in held):
272
+ pl.update(raw)
273
+ _push(pl, room, floor, size, held, walls)
274
+
275
+
276
+ def _push(pl, room, floor, size, held, walls):
277
+ inside = floor.buffer(1e-3) # validation()'s tolerance
278
+ fixed = room.get("fixed") or []
279
+ kids = {}
280
+ for i, p in pl.items():
281
+ if p["on"]:
282
+ kids.setdefault(p["on"], []).append(i)
283
+ for i in sorted(pl, key=lambda i: pl[i]["pos"][2]):
284
+ p = pl[i]
285
+ box = lambda dx, dy: Polygon(footprint({"pos": [p["pos"][0] + dx, p["pos"][1] + dy], "size": size[i], "yaw": p["yaw"]}))
286
+ if inside.covers(box(0, 0)):
287
+ continue
288
+ q = pl.get(p["on"])
289
+ seat = {**q, "size": size[p["on"]]} if q else None
290
+ push = [0.0, 0.0] # per axis, the largest move any outside corner needs to reach the boundary
291
+ for c in box(0, 0).exterior.coords[:-1]:
292
+ if not floor.covers(Point(c)):
293
+ q = nearest_points(floor.boundary, Point(c))[0]
294
+ for k, d in enumerate((q.x - c[0], q.y - c[1])):
295
+ if abs(d) > abs(push[k]):
296
+ push[k] = d
297
+ # the object and everything standing on it move together (parse() rejects support cycles); fixed boxes at
298
+ # each one's height (validate.check's rule): no move may deepen any of their overlaps
299
+ tree, todo = [], [i]
300
+ while todo:
301
+ j = todo.pop()
302
+ tree.append(j)
303
+ todo += kids.get(j, [])
304
+ at = lambda j, dx, dy: Polygon(footprint({"pos": [pl[j]["pos"][0] + dx, pl[j]["pos"][1] + dy],
305
+ "size": size[j], "yaw": pl[j]["yaw"]}))
306
+ blocks = {j: unary_union([Polygon(footprint(f)) for f in fixed if min(
307
+ pl[j]["pos"][2] + size[j][2], f["pos"][2] + f["size"][2]) - max(pl[j]["pos"][2], f["pos"][2]) > 0.05])
308
+ for j in tree}
309
+ worst = {j: at(j, 0, 0).intersection(blocks[j]).area + 1e-6 for j in tree}
310
+ mine = [c for c in held if set(tree) & set(c[1:])]
311
+
312
+ def keeps(dx, dy): # every held constraint on the subtree still holds after the move
313
+ ix = {j: {"pos": [q["pos"][0] + dx, q["pos"][1] + dy, q["pos"][2]] if j in tree else q["pos"], "yaw": q["yaw"],
314
+ "size": size[j], "parent": q["on"]} for j, q in pl.items()} if mine else {}
315
+ return all(holds(c, None, walls, ix) for c in mine)
316
+ # the nearest-boundary push, its axis parts, then any move within SNAP_TOL (1 cm grid, shortest first)
317
+ # (a nearest-boundary push over SNAP_TOL is dropped, the grid still tries every move within SNAP_TOL: a box
318
+ # stuck at a re-entrant corner may need a long diagonal push but a short move along one axis)
319
+ for dx, dy in [c for c in (tuple(push), (push[0], 0), (0, push[1])) if math.hypot(*c) <= SNAP_TOL] + SNAP_GRID:
320
+ if inside.covers(box(dx, dy)) and all(at(j, dx, dy).intersection(blocks[j]).area <= worst[j] for j in tree) \
321
+ and (seat is None or support_contains(seat, [p["pos"][0] + dx, p["pos"][1] + dy])) and keeps(dx, dy):
322
+ for j in tree:
323
+ pl[j]["pos"] = [pl[j]["pos"][0] + dx, pl[j]["pos"][1] + dy, pl[j]["pos"][2]]
324
+ break
325
+
326
+
327
+ def placements_from_text(text, room, ctx):
328
+ """Model answer -> {"placements": after repair, "raw_placements": as the model proposed, "validation":
329
+ {"raw": ..., "repaired": ...}, "error": code | None, "unsupported": [...]}, placements in request order.
330
+ Repair = floor objects crossing a wall or a keepout by at most SNAP_TOL moved back inside (snap_inside); it is
331
+ deterministic post-processing, so the raw proposal and its validation are always returned next to it."""
332
+ ids = [o["id"] for o in room["objects"]]
333
+ pl, err = parse(text, ids)
334
+ if err is not None:
335
+ return {"placements": [], "raw_placements": [], "validation": None, "error": err,
336
+ "unsupported": ctx["unsupported"]}
337
+ raw = copy.deepcopy(pl)
338
+ snap_inside(pl, room, ctx["floor"], [k for k, hard in ctx["checks"] if hard], ctx["walls"])
339
+ return {"placements": _to_request(pl, room, ctx), "raw_placements": _to_request(raw, room, ctx),
340
+ "validation": {"raw": validation(raw, room, ctx), "repaired": validation(pl, room, ctx)},
341
+ "error": None, "unsupported": ctx["unsupported"]}
342
+
343
+
344
+ def _to_request(pl, room, ctx):
345
+ model2ext = {o["id"]: o["ext_id"] for o in room["objects"]}
346
+ by_ext = {}
347
+ for mid, p in pl.items():
348
+ ext = model2ext[mid]
349
+ yaw = (p["yaw"] - ctx["front_k"][ext] * math.pi / 2) % (2 * math.pi) # model +X (front) -> asset frame
350
+ item = {"id": ext, "position_m": [p["pos"][0] + ctx["x0"], p["pos"][1] + ctx["y0"], p["pos"][2] + ctx["floor_z"]],
351
+ "yaw_rad": yaw, "support_parent": model2ext[p["on"]] if p["on"] else ctx["floor_id"]}
352
+ if p["on"]:
353
+ item["support_surface"] = "top"
354
+ by_ext[ext] = item
355
+ return [by_ext[i] for i in ctx["order"]]
356
+
357
+
358
+ def trained(k, lay):
359
+ """False for a constraint form build.extract_constraints never makes, so the model never saw it (v2.1 train: 0
360
+ against_wall in 5,117 constrained hull rooms; no faces with a non-FRONT subject; no against_wall / near / faces /
361
+ between with an argument on another object; no between with anchors of two categories; no argument (of 'on': the
362
+ subject) that is FLAT or under 5 cm tall). Reported, not refused: the caller decides. Thresholds (near 0.1-0.5) and
363
+ whether the relation held on a reference layout are not forms and are not checked here."""
364
+ o = {x["id"]: x for x in lay["objects"]}
365
+ args = [o[i] for i in k[1:2 if k[0] == "on" else None] if i in o]
366
+ return not ((k[0] == "against_wall" and lay["boundary_type"] != "polygon")
367
+ or (k[0] == "faces" and head(o[k[1]]) not in FRONT)
368
+ or (k[0] != "on" and any(x.get("parent") for x in args))
369
+ or any(head(x) in FLAT or x["size"][2] < 0.05 for x in args)
370
+ or (k[0] == "between" and norm_cat(o[k[2]]["category"]) != norm_cat(o[k[3]]["category"])))
371
+
372
+
373
+ def validation(pl, room, ctx):
374
+ """The canonical checks (validate.check / validate.holds) on a placement, with request ids. ok = valid (inside
375
+ boundary + 0.1, supported, under the ceiling), every object (on others too) within 1 mm of the walls minus the
376
+ keepouts, and every hard constraint holding with the request's own numbers against the real walls.
377
+ An overlap with a fixed box other than a window (validate.check fixed_blocking: a column, a door, a stair) also
378
+ fails ok: those are physical obstacles; furniture in front of a window is reported in fixed_collisions only.
379
+ Collisions between placed objects are reported, not gated (a chair tucked under a table is a
380
+ box overlap; the reference layouts themselves have ~10% rooms with a severe one)."""
381
+ ext = {o["id"]: o["ext_id"] for o in room["objects"] + (room.get("fixed") or [])}
382
+ lay = apply(room, pl)
383
+ c = check(lay)
384
+ grown = ctx["floor"].buffer(1e-3)
385
+ outside = [ext[o["id"]] for o in lay["objects"] if not grown.covers(Polygon(footprint(o)))]
386
+ cons = [{"constraint": [k[0]] + [ext.get(v, v) for v in k[1:]], "hard": hard,
387
+ "holds": holds(k, lay["objects"], ctx["walls"]), "trained": trained(k, lay)} for k, hard in ctx["checks"]]
388
+ cons += [{"constraint": ["anchor", ext[o["id"]], o["anchor"]], "hard": True,
389
+ "holds": bool(o.get("parent")) == (o["anchor"] == "object"),
390
+ "trained": False, "model_conditioned": False}
391
+ for o in lay["objects"] if o.get("anchor") in ("floor", "object")]
392
+ cons += [{"constraint": ["keepout", pts], "hard": False,
393
+ "holds": all(Polygon(footprint(o)).intersection(zone).area <= 1e-9 for o in lay["objects"]),
394
+ "trained": False, "model_conditioned": False} for pts, zone in ctx["soft_keepouts"]]
395
+ return {"ok": c["valid"] and not outside and not c["fixed_blocking"] and all(x["holds"] for x in cons if x["hard"]),
396
+ "oob": [ext[i] for i in c["oob"]],
397
+ "support_fail": [ext[i] for i in c["support_fail"]], "ceiling": [ext[i] for i in c["ceiling"]],
398
+ "outside_1mm": outside, "collisions": {k: [[ext[a], ext[b]] for a, b in v] for k, v in c["collisions"].items()},
399
+ "fixed_collisions": [[ext[a], ext[b]] for a, b in c["fixed_collisions"]],
400
+ "fixed_blocking": [[ext[a], ext[b]] for a, b in c["fixed_blocking"]], "constraints": cons}
401
+
402
+
403
+ if __name__ == "__main__":
404
+ import json
405
+
406
+ from fastfill.scene import messages, target_json
407
+ from fastfill.validate import holds
408
+
409
+ req = { # scope doc Appendix B, room moved to (10, 20) and one chair modelled with its front along local +Y
410
+ "room": {"id": "new_room", "room_type": "meeting room", "boundary_xy": [[10, 20], [14, 20], [14, 24], [10, 24]],
411
+ "boundary_source": "specified_polygon", "floor_id": "room_floor", "floor_z": 0.0, "height_m": 2.8},
412
+ "objects_to_place": [
413
+ {"id": "table_1", "category": "table", "size_xyz_m": [1.4, 0.8, 0.75]},
414
+ {"id": "chair_1", "category": "chair", "size_xyz_m": [0.55, 0.5, 0.9], "semantic_front_local": [1, 0, 0]},
415
+ {"id": "chair_2", "category": "chair", "size_xyz_m": [0.5, 0.55, 0.9], "semantic_front_local": [0, 1, 0]},
416
+ {"id": "cup_1", "category": "cup", "size_xyz_m": [0.08, 0.08, 0.1]},
417
+ {"id": "clock_1", "category": "clock", "size_xyz_m": [0.3, 0.05, 0.3], "anchor": "wall"}],
418
+ "layout_constraints": [
419
+ {"type": "faces", "subject": "chair_1", "target": "chair_2", "hard": True},
420
+ {"type": "faces", "subject": "chair_2", "target": "chair_1", "hard": True},
421
+ {"type": "between", "subject": "table_1", "anchors": ["chair_1", "chair_2"], "hard": True},
422
+ {"type": "supported_by", "subject": "cup_1", "parent": "table_1", "surface": "top", "hard": True}]}
423
+ room, ctx = request_to_room(req)
424
+ assert [o["id"] for o in room["objects"]] == ["table_1", "chair_1", "chair_2", "cup_1"], room["objects"]
425
+ assert room["objects"][2]["size"] == [0.55, 0.5, 0.9] # chair_2 re-framed: front axis first
426
+ assert ctx["unsupported"] == [{"id": "clock_1", "reason": "unsupported_anchor"}]
427
+ assert room["constraints"][3] == ["on", "cup_1", "table_1"]
428
+ # the model answers in its own frame: chair_1 at x=0.9 facing +X, chair_2 at x=3.1 facing -X, cup on the table
429
+ ans = {"table_1": ([2, 2, 0], 0), "chair_1": ([0.9, 2, 0], 0), "chair_2": ([3.1, 2, 0], math.pi),
430
+ "cup_1": ([1.7, 2, 0.75], 0)}
431
+ placed = {**room, "objects": [{**o, "pos": ans[o["id"]][0], "yaw": ans[o["id"]][1],
432
+ "parent": "table_1" if o["id"] == "cup_1" else None} for o in room["objects"]]}
433
+ assert all(holds(c, placed["objects"], placed["boundary"]) for c in room["constraints"])
434
+ text = target_json(placed)
435
+ assert "constraints" in messages(room, room["constraints"], with_target=False)[1]["content"]
436
+ out = placements_from_text(text, room, ctx)
437
+ got = {p["id"]: p for p in out["placements"]}
438
+ assert out["error"] is None and [p["id"] for p in out["placements"]] == ["table_1", "chair_1", "chair_2", "cup_1"]
439
+ assert got["chair_1"]["position_m"] == [10.9, 22, 0] and got["chair_1"]["yaw_rad"] == 0
440
+ assert abs(got["chair_2"]["yaw_rad"] - math.pi / 2) < 1e-9 # front on local +Y: asset turned by 90 deg
441
+ assert got["cup_1"]["support_parent"] == "table_1" and got["cup_1"]["support_surface"] == "top"
442
+ assert got["table_1"]["support_parent"] == "room_floor" and "support_surface" not in got["table_1"]
443
+ assert placements_from_text("not json", room, ctx)["error"] == "bad_json"
444
+ # table 3 cm through the east wall (x up to 4.03): moved back inside, the cup on it moves along; 20 cm is left alone
445
+ over = {**placed, "objects": [{**o, "pos": [3.33 if o["id"] == "table_1" else 3.0 if o["id"] == "cup_1" else o["pos"][0],
446
+ *o["pos"][1:]]} for o in placed["objects"]]}
447
+ got = {p["id"]: p["position_m"] for p in placements_from_text(target_json(over), room, ctx)["placements"]}
448
+ assert abs(got["table_1"][0] - 13.3) < 1e-9 and abs(got["cup_1"][0] - 12.97) < 1e-9, got
449
+ far = {**over, "objects": [{**o, "pos": [3.5, *o["pos"][1:]]} if o["id"] == "table_1" else o for o in over["objects"]]}
450
+ assert placements_from_text(target_json(far), room, ctx)["placements"][0]["position_m"][0] == 13.5
451
+
452
+ # a doorway keepout on the west wall becomes a notch (padded) in the floor the model sees; the frame is unchanged
453
+ door = {"type": "keepout", "polygon_xy": [[10, 21.5], [11, 21.5], [11, 22.5], [10, 22.5]]}
454
+ room2, ctx2 = request_to_room({**req, "layout_constraints": req["layout_constraints"] + [door]})
455
+ assert (ctx2["x0"], ctx2["y0"]) == (10, 20) and [0, 1.42] in room2["boundary"] and [1.08, 2.58] in room2["boundary"]
456
+ assert room2["constraints"] == room["constraints"]
457
+ # a constraint naming an unsupported object is skipped and reported; the rest of the room is still placed
458
+ hang = {"type": "near", "subject": "clock_1", "target": "table_1", "max_gap_m": 0.5}
459
+ room3, ctx3 = request_to_room({**req, "layout_constraints": [hang]})
460
+ assert room3["constraints"] == [] and ctx3["unsupported"][1] == {"constraint": hang, "reason": "references_unsupported_object"}
461
+ up = {**req, "objects_to_place": [{**req["objects_to_place"][0], "semantic_front_local": [0, 0, 1]}], "layout_constraints": []}
462
+ assert request_to_room(up)[1]["unsupported"] == [{"id": "table_1", "reason": "front_not_axis_aligned"}]
463
+ bad = [{**req, "objects_to_place": req["objects_to_place"] * 2}, # duplicate ids
464
+ {**req, "layout_constraints": [{"type": "near", "id": "cup_1", "target": "table_1", "max_distance_m": 1.5}]},
465
+ {**req, "layout_constraints": [{"type": "between", "subject": "table_1", "anchors": ["chair_1"]}]},
466
+ {**req, "room": {**req["room"], "floor_z": None}},
467
+ {**req, "objects_to_place": [{**req["objects_to_place"][0], "size_xyz_m": [1.4, -0.8, 0.75]}]},
468
+ {**req, "objects_to_place": [{**req["objects_to_place"][0], "size_xyz_m": [1.4, float("nan"), 0.75]}]},
469
+ {**req, "layout_constraints": [{**door, "polygon_xy": [[9, 19], [15, 19], [15, 25], [9, 25]]}]}] # fills the room
470
+ obj0 = req["objects_to_place"][0]
471
+ bad += [{**req, "layout_constraints": [{"type": "supported_by", "subject": "cup_1", "parent": "cup_1"}]}, # self
472
+ {**req, "layout_constraints": [{"type": "supported_by", "subject": "cup_1", "parent": "table_1"},
473
+ {"type": "supported_by", "subject": "table_1", "parent": "cup_1"}]}, # cycle
474
+ {**req, "layout_constraints": [{"type": "faces", "subject": "chair_1", "target": "chair_2", "hard": "false"}]},
475
+ {**req, "layout_constraints": [{"type": "between", "subject": "table_1", "anchors": "cc"}]},
476
+ {**req, "coordinate_system": {"units": "cm"}},
477
+ {**req, "objects_to_place": [{**obj0, "anchor": "banana"}]},
478
+ {**req, "objects_to_place": req["objects_to_place"] + [{"id": "x", "category": "clock", "size_xyz_m": [-1, 1, 1],
479
+ "anchor": "wall"}]},
480
+ {**req, "room": {**req["room"], "room_type": 7}},
481
+ {**req, "room": {**req["room"], "height_m": 0}},
482
+ {**req, "objects_to_place": [{**obj0, "size_xyz_m": [1e200, 1, 1]}]},
483
+ {**req, "room": {**req["room"], "floor_z": float("inf")}},
484
+ {**req, "room": {**req["room"], "height_m": float("nan")}},
485
+ {**req, "layout_constraints": [{"type": "near", "subject": "cup_1", "target": "table_1", "max_gap_m": -0.1}]}]
486
+ for b in bad:
487
+ try:
488
+ request_to_room(b)
489
+ raise AssertionError(b)
490
+ except ValueError:
491
+ pass
492
+
493
+ # near: the model is shown the threshold rounded down to cm, the answer is accepted against the request's own
494
+ # number; a failed hard constraint fails the layout (after repair), a failed soft one is only reported
495
+ near = lambda gap, hard=True: {"type": "near", "subject": "chair_1", "target": "chair_2", "max_gap_m": gap, "hard": hard}
496
+ r5, c5 = request_to_room({**req, "layout_constraints": [near(0.126)]})
497
+ assert r5["constraints"] == [["near", "chair_1", "chair_2", 0.12]] and c5["checks"][0][0][3] == 0.126
498
+ apart = target_json(placed) # chairs 1.7 m apart: fails any near <= 0.5
499
+ v = placements_from_text(apart, *request_to_room({**req, "layout_constraints": [near(0.1)]}))["validation"]
500
+ assert not v["repaired"]["ok"] and v["repaired"]["constraints"][0]["holds"] is False, v
501
+ v = placements_from_text(apart, *request_to_room({**req, "layout_constraints": [near(0.1, hard=False)]}))["validation"]
502
+ assert v["repaired"]["ok"], v
503
+ # a cup hanging 3 cm over the east wall from a table flush with it: the cup is pushed in, staying on the table
504
+ edge = {**placed, "objects": [{**o, "pos": [3.3, 2, 0] if o["id"] == "table_1" else [3.99, 2, 0.75] if o["id"] == "cup_1"
505
+ else o["pos"]} for o in placed["objects"]]}
506
+ out = placements_from_text(target_json(edge), room, ctx)
507
+ cup = {p["id"]: p for p in out["placements"]}["cup_1"]["position_m"]
508
+ assert out["validation"]["raw"]["outside_1mm"] == ["cup_1"] and out["validation"]["repaired"]["ok"], out["validation"]
509
+ assert abs(cup[0] - 13.96) < 1e-9, cup
510
+ # fixed geometry: a column at (12, 22) is shown to the model in the room frame, never placed, and a table on it is
511
+ # reported (not refused); a fixed id that repeats an object id is a duplicate
512
+ col = {"id": "col_1", "category": "column", "size_xyz_m": [0.3, 0.3, 2.8], "position_m": [12, 22, 0]}
513
+ room6, ctx6 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": [col]}, "layout_constraints": []})
514
+ assert room6["fixed"][0]["id"] == "column_1" and room6["fixed"][0]["pos"] == [2, 2, 0], room6["fixed"]
515
+ assert json.loads(messages(room6, [], with_target=False)[1]["content"])["fixed"] == [
516
+ {"id": "column_1", "size": [0.3, 0.3, 2.8], "pos": [2, 2, 0], "yaw": 0}]
517
+ out6 = placements_from_text(target_json(placed), room6, ctx6) # table_1 centred on the column
518
+ assert not out6["validation"]["repaired"]["ok"] and out6["validation"]["repaired"]["fixed_collisions"] == [["table_1", "col_1"]]
519
+ # the repair moves a table with the cup on it; a move that would push the CUP into a fixed upper cabinet (above the
520
+ # table top, at the cup's height) is not taken, so the table stays 3 cm through the wall and the layout fails
521
+ cab = {"id": "cab_1", "category": "cabinet", "size_xyz_m": [0.4, 0.6, 1.0], "position_m": [12.75, 22, 0.78]}
522
+ room9, ctx9 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": [cab]}, "layout_constraints": []})
523
+ moved = {"table_1": [3.33, 2, 0], "cup_1": [3.0, 2, 0.75], "chair_2": [1.5, 3.5, 0]}
524
+ lay9 = {**placed, "objects": [{**o, "pos": moved.get(o["id"], o["pos"])} for o in placed["objects"]]}
525
+ out9 = placements_from_text(target_json(lay9), room9, ctx9)
526
+ got = {p["id"]: p["position_m"] for p in out9["placements"]}
527
+ assert abs(got["table_1"][0] - 13.33) < 1e-9 and abs(got["cup_1"][0] - 13.0) < 1e-9, got
528
+ assert not out9["validation"]["repaired"]["fixed_collisions"] and "table_1" in out9["validation"]["repaired"]["outside_1mm"]
529
+ for b in ({**col, "id": "table_1"}, {**col, "size_xyz_m": [0, 0.3, 2.8]}): # duplicate id; zero size
530
+ try:
531
+ request_to_room({**req, "room": {**req["room"], "fixed_geometry": [b]}})
532
+ raise AssertionError(b)
533
+ except ValueError:
534
+ pass
535
+ # a constraint may not name a fixed box: refused with its own message
536
+ try:
537
+ request_to_room({**req, "room": {**req["room"], "fixed_geometry": [col]},
538
+ "layout_constraints": [{"type": "near", "subject": "table_1", "target": "col_1", "max_gap_m": 0.5}]})
539
+ raise AssertionError("constraint on a fixed box accepted")
540
+ except ValueError as e:
541
+ assert "fixed box" in str(e), e
542
+ # training-side normalization: |z| <= FLOOR_Z snapped to 0; a box starting at FIXED_MAX_Z or above, covering half
543
+ # the room, or standing clear of it is not shown and reported as fixed_ignored
544
+ fx = lambda **kw: {**col, **kw}
545
+ room8, ctx8 = request_to_room({**req, "layout_constraints": [], "room": {**req["room"], "floor_z": 1.0, "fixed_geometry": [
546
+ fx(id="door", position_m=[12, 20.5, 1.06]), fx(id="beam", size_xyz_m=[4, 0.3, 0.3], position_m=[12, 22, 2.5]),
547
+ fx(id="slab", size_xyz_m=[3, 3, 0.1], position_m=[12, 22, 1]), fx(id="far", position_m=[30, 30, 1])]}})
548
+ assert [f["ext_id"] for f in room8["fixed"]] == ["door"] and room8["fixed"][0]["pos"] == [2, 0.5, 0], room8["fixed"]
549
+ assert [u["id"] for u in ctx8["unsupported"] if u["reason"] == "fixed_ignored"] == ["beam", "slab", "far"], ctx8["unsupported"]
550
+ # repair never deepens the overlap with a fixed box: a table lying along the east wall 3 cm out (yaw 90, x 3.23..4.03)
551
+ # with its west edge 8 cm over a column at (3.3, 2) is left where it is (every move within SNAP_TOL pushes it further
552
+ # in, so it stays outside_1mm and serve.py refuses the layout); the same table clear of the column is moved back
553
+ col2 = {**col, "position_m": [13.3, 22, 0]}
554
+ room7, ctx7 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": [col2]}, "layout_constraints": []})
555
+ side = lambda y: {**placed, "objects": [{**o, "pos": [3.63, y, o["pos"][2]], "yaw": math.pi / 2 if o["id"] == "table_1" else o["yaw"]}
556
+ if o["id"] in ("table_1", "cup_1") else o for o in placed["objects"]]}
557
+ out7 = placements_from_text(target_json(side(2.0)), room7, ctx7)
558
+ got = {p["id"]: p["position_m"] for p in out7["placements"]}
559
+ assert abs(got["table_1"][0] - 13.63) < 1e-9 and out7["validation"]["repaired"]["outside_1mm"] == ["table_1"], out7["validation"]
560
+ assert ["table_1", "col_1"] in out7["validation"]["repaired"]["fixed_collisions"]
561
+ got = {p["id"]: p["position_m"] for p in placements_from_text(target_json(side(2.9)), room7, ctx7)["placements"]}
562
+ assert abs(got["table_1"][0] - 13.6) < 1e-9 and abs(got["cup_1"][0] - 13.6) < 1e-9, got
563
+ # a column sunk 0.5 m below the floor is cut at the floor (height 2.3); a box sunk under the floor is not shown
564
+ from fastfill.build import FLAT_Z as BUILD_FLAT_Z
565
+ assert FLAT_Z == BUILD_FLAT_Z
566
+ sunk = [{"id": "c9", "category": "column", "size_xyz_m": [0.3, 0.3, 2.8], "position_m": [12, 22, -0.5]},
567
+ {"id": "u9", "category": "unknown", "size_xyz_m": [1, 1, 0.4], "position_m": [12.5, 22.5, -0.6]}]
568
+ r7, c7 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": sunk}, "layout_constraints": []})
569
+ assert [(f["ext_id"], f["pos"][2], f["size"][2]) for f in r7["fixed"]] == [("c9", 0.0, 2.3)], r7["fixed"]
570
+ assert {"id": "u9", "reason": "fixed_ignored"} in c7["unsupported"]
571
+ # objects are ordered by cm-rounded sizes clamped at 1 cm, as in training (a 3 mm side sorts as 1 cm)
572
+ thin = {**req, "objects_to_place": [{"id": "mat", "category": "mat", "size_xyz_m": [1.0, 0.003, 0.9]},
573
+ {"id": "pad", "category": "mat", "size_xyz_m": [0.5, 0.01, 0.9]}],
574
+ "layout_constraints": []}
575
+ assert [o["ext_id"] for o in request_to_room(thin)[0]["objects"]] == ["mat", "pad"]
576
+ # re-entrant corner of an L-shaped room: the nearest-boundary push is diagonal and long, a 6 cm move along -x works
577
+ ell = Polygon([(0, 0), (4, 0), (4, 2), (2, 2), (2, 4), (0, 4)])
578
+ plc = {"b": {"pos": [1.76, 2.22, 0], "yaw": 0.0, "on": None}}
579
+ snap_inside(plc, {"objects": [{"id": "b", "size": [0.6, 0.6, 0.8]}]}, ell)
580
+ assert plc["b"]["pos"][:2] == [1.7, 2.22] or abs(plc["b"]["pos"][0] - 1.7) < 1e-9, plc
581
+ # the repair never breaks a hard constraint that held: a chair 3 cm through the south wall faces a box 28.5 deg off
582
+ # its front; the shortest push (0, +3 cm) turns that to 32 deg (> 30), so (-4, +3) cm is taken instead
583
+ sq, two = Polygon([(0, 0), (4, 0), (4, 3), (0, 3)]), {"objects": [{"id": "c", "size": [.5, .5, .9]}, {"id": "b", "size": [.06, .06, .3]}]}
584
+ lay = lambda q: [{"id": i, "pos": q[i]["pos"], "yaw": 0.0, "size": s["size"]} for i, s in zip("cb", two["objects"])]
585
+ for cons, want in (([], [1, .25]), ([["faces", "c", "b"]], [.96, .25])):
586
+ plc = {"c": {"pos": [1, .22, 0], "yaw": 0.0, "on": None}, "b": {"pos": [1.35, .03, 0], "yaw": 0.0, "on": None}}
587
+ assert holds(["faces", "c", "b"], lay(plc), sq.exterior.coords[:-1])
588
+ snap_inside(plc, two, sq, cons, sq.exterior.coords[:-1])
589
+ assert all(abs(u - v) < 1e-9 for u, v in zip(plc["c"]["pos"], want)), plc
590
+ assert holds(["faces", "c", "b"], lay(plc), sq.exterior.coords[:-1]) == bool(cons)
591
+ # two chairs and a side table between them, all 5 cm through the east wall: the between fails after the second chair
592
+ # moves and holds again once the table moves, so the plain pass is kept (a move-by-move guard left chair b outside)
593
+ row = {"objects": [{"id": "a", "size": [.7, .7, .9]}, {"id": "b", "size": [.7, .7, .9]}, {"id": "s", "size": [.38, .38, .6]}]}
594
+ plc = {i: {"pos": [x, y, 0], "yaw": 0.0, "on": None} for i, x, y in (("a", 3.7, 1), ("b", 3.7, 3), ("s", 3.86, 2))}
595
+ sq4 = Polygon([(0, 0), (4, 0), (4, 4), (0, 4)])
596
+ snap_inside(plc, row, sq4, [["between", "s", "a", "b"]], sq4.exterior.coords[:-1])
597
+ assert all(abs(plc[i]["pos"][0] - x) < 1e-9 for i, x in (("a", 3.65), ("b", 3.65), ("s", 3.81))), plc
598
+ # a padded keepout ending 3 mm short of the east wall (the finding's 1.48 m room) snaps to it; 2 cm short stays
599
+ ko = lambda x1: {"room": {"boundary_xy": [[0, 0], [1.48, 0], [1.48, 3.48], [0, 3.48]]},
600
+ "objects_to_place": [{"id": "a", "category": "chair", "size_xyz_m": [0.5, 0.5, 0.9]}],
601
+ "layout_constraints": [{"type": "keepout", "polygon_xy": [[.495, -.2], [x1, -.2], [x1, 1], [.495, 1]]}]}
602
+ assert [1.48, 1.08] in request_to_room(ko(1.48 - KEEPOUT_PAD - 0.003))[0]["boundary"]
603
+ assert [1.46, 1.08] in request_to_room(ko(1.48 - KEEPOUT_PAD - 0.02))[0]["boundary"]
604
+ # two padded keepouts 7.5 mm apart in front of a 1.08 x 0.62 m corner (EmbodiedGen-shaped eg#173): the gap stays open
605
+ # and the corner stays floor; 2.9 mm apart the gap folds under cm rounding, as does a keepout crossing a 45-degree
606
+ # wall by 3 mm: keepout_not_representable, not bad_boundary (the walls themselves round cleanly)
607
+ pair = lambda x1: {"room": {"boundary_xy": [[0, 0], [4.5, 0], [4.5, 3], [0, 3]]}, "objects_to_place": ko(0)["objects_to_place"],
608
+ "layout_constraints": [{"type": "keepout", "polygon_xy": [[3.5, 1.1], [4.5, 1.1], [4.5, 2.3], [3.5, 2.3]]},
609
+ {"type": "keepout", "polygon_xy": [[2.1, 2.4], [x1, 2.4], [x1, 3], [2.1, 3]]}]}
610
+ assert [4.5, 3] in request_to_room(pair(3.3325))[0]["boundary"]
611
+ mouth = {"room": {"boundary_xy": [[0, 0], [4, 0], [4, 4], [1, 4], [0, 3]]}, "objects_to_place": ko(0)["objects_to_place"],
612
+ "layout_constraints": [{"type": "keepout", "polygon_xy": [[.58, 2.08], [1.42, 2.08], [1.42, 3.423], [.58, 3.423]]}]}
613
+ for b in (pair(3.3371), mouth):
614
+ try:
615
+ request_to_room(b)
616
+ raise AssertionError(b)
617
+ except ValueError as e:
618
+ assert str(e) == "keepout_not_representable", e
619
+ # forms training never has are checked as asked but flagged: faces from a round table (no FRONT head), against_wall
620
+ # in a hull room, near from a cup that stands on the table; faces / between / on of the Appendix-B request are trained
621
+ assert all(c["trained"] for c in placements_from_text(text, room, ctx)["validation"]["repaired"]["constraints"])
622
+ hull = {"room": {**req["room"], "boundary_source": "scan_hull"}, "objects_to_place": [
623
+ {"id": "rt", "category": "round table", "size_xyz_m": [1, 1, .75]}, {"id": "sh", "category": "shelf", "size_xyz_m": [1, .4, 1.8]},
624
+ {"id": "cup", "category": "cup", "size_xyz_m": [.08, .08, .1]}], "layout_constraints": [
625
+ {"type": "faces", "subject": "rt", "target": "sh"}, {"type": "against_wall", "subject": "sh"},
626
+ {"type": "near", "subject": "cup", "target": "sh", "max_gap_m": 0.5}]}
627
+ rh, ch = request_to_room(hull)
628
+ ans = '{"placements":[{"id":"round_table_1","pos":[2,2,0],"yaw":270},{"id":"shelf_1","pos":[2,0.2,0],"yaw":0},' \
629
+ '{"id":"cup_1","on":"round_table_1","pos":[2,2,0.75],"yaw":0}]}' # the cup 1.56 m from the shelf
630
+ v = placements_from_text(ans, rh, ch)["validation"]["repaired"]
631
+ assert [(c["trained"], c["holds"]) for c in v["constraints"]] == [(False, True), (False, True), (False, False)], v
632
+ assert not v["ok"] and not v["outside_1mm"] and not v["oob"], v
633
+ # build never relates a rug, a sub-5 cm box or (except by 'on') an object standing on another
634
+ lay = {"boundary_type": "polygon", "objects": [
635
+ {"id": "s", "category": "sofa", "size": [2, .9, .8]}, {"id": "r", "category": "rug", "size": [2, 1.5, .01]},
636
+ {"id": "l", "category": "lamp", "size": [.3, .3, .5], "parent": "s"},
637
+ {"id": "k", "category": "coaster", "size": [.1, .1, .01], "parent": "s"}]}
638
+ assert [trained(k, lay) for k in (["near", "s", "r", .5], ["faces", "s", "r"], ["against_wall", "l"], ["on", "k", "s"],
639
+ ["on", "l", "s"], ["against_wall", "s"])] == [False] * 4 + [True] * 2
640
+ print("interface.py self-check ok")
641
+ print(json.dumps(out["placements"][2]))
code/fastfill/merge_lora.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Merge a FastFill adapter, retaining its tokenizer and run provenance.
2
+
3
+ python -m fastfill.merge_lora --base_model_path BASE --lora_path RUN/final --output_path MERGED
4
+
5
+ Uses the local adapter's saved tokenizer when present, otherwise the base tokenizer.
6
+ The nearest run_manifest.json selects the run; its numbered resume manifests travel with it.
7
+ """
8
+ import argparse
9
+ import json
10
+ import re
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import torch
15
+ from peft import PeftModel
16
+ from transformers import AutoModelForCausalLM, AutoTokenizer
17
+
18
+
19
+ def save_compatibility(output_path):
20
+ """Keep the existing Transformers 5 -> 4 artifact compatibility rewrites."""
21
+ cfg_path = Path(output_path) / "config.json"
22
+ tok_path = Path(output_path) / "tokenizer_config.json"
23
+ cfg = json.loads(cfg_path.read_text())
24
+ tc = json.loads(tok_path.read_text())
25
+ # Transformers 4 needs top-level rope_theta and a list named additional_special_tokens.
26
+ theta = (cfg.get("rope_parameters") or {}).get("rope_theta")
27
+ compatible_cfg = {"rope_theta": theta, **cfg} if theta is not None else cfg
28
+ compatible_tc = ({**{k: v for k, v in tc.items() if k != "extra_special_tokens"},
29
+ "additional_special_tokens": tc["extra_special_tokens"]}
30
+ if isinstance(tc.get("extra_special_tokens"), list) else tc)
31
+ cfg_path.write_text(json.dumps(compatible_cfg, indent=2) + "\n")
32
+ tok_path.write_text(json.dumps(compatible_tc, indent=2, ensure_ascii=False) + "\n")
33
+
34
+
35
+ def copy_run_manifests(lora_path, output_path):
36
+ """Prefer an adapter-local run; do not mix in another run from its parent."""
37
+ adapter = Path(lora_path)
38
+ for run in (adapter, adapter.parent):
39
+ if not (run / "run_manifest.json").is_file():
40
+ continue
41
+ for manifest in run.iterdir():
42
+ if manifest.is_file() and re.fullmatch(r"run_manifest(?:\.resume[0-9]+)?\.json", manifest.name):
43
+ shutil.copyfile(manifest, Path(output_path) / manifest.name)
44
+ break
45
+
46
+
47
+ def apply_lora(model_name_or_path, output_path, lora_path):
48
+ tokenizer_source = lora_path if (Path(lora_path) / "tokenizer_config.json").is_file() else model_name_or_path
49
+ tokenizer = AutoTokenizer.from_pretrained(tokenizer_source)
50
+ print(f"Loading the base model from {model_name_or_path}")
51
+ base = AutoModelForCausalLM.from_pretrained(model_name_or_path, dtype=torch.bfloat16)
52
+ print(f"Loading the LoRA adapter from {lora_path}")
53
+ lora_model = PeftModel.from_pretrained(base, lora_path, torch_dtype=torch.bfloat16)
54
+ print("Applying the LoRA")
55
+ model = lora_model.merge_and_unload()
56
+ print(f"Saving the target model to {output_path}")
57
+ model.save_pretrained(output_path)
58
+ tokenizer.save_pretrained(output_path)
59
+ save_compatibility(output_path)
60
+ copy_run_manifests(lora_path, output_path)
61
+
62
+
63
+ def main():
64
+ parser = argparse.ArgumentParser(description=__doc__)
65
+ parser.add_argument("--base_model_path", required=True)
66
+ parser.add_argument("--lora_path", required=True)
67
+ parser.add_argument("--output_path", required=True)
68
+ args = parser.parse_args()
69
+ apply_lora(args.base_model_path, args.output_path, args.lora_path)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
code/fastfill/requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastFill v1. Tested: transformers 4.57.6 (the recorded node03 stack used vLLM 0.19.0),
2
+ # 5.14.1 and 5.17.0; peft 0.14-0.21 (4.57.6 + peft 0.21.0 ran the full pipeline). Install torch for the server CUDA first, then: pip install -r fastfill/requirements.txt
3
+ # Train, merge and serve in ONE environment.
4
+ transformers>=4.56
5
+ peft>=0.14
6
+ accelerate>=1.1
7
+ huggingface_hub>=0.36
8
+ numpy
9
+ shapely>=2.0
10
+ ijson
11
+ # optional, faster evaluation: vllm
12
+ # optional, multi-GPU full fine-tuning: deepspeed
code/fastfill/scene.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Canonical room IR and the text protocol FastFill v1 is trained on.
2
+
3
+ IR record (one JSON line per room), all lengths in meters, right-handed Z-up, room-local frame
4
+ with the boundary AABB min corner at (0, 0):
5
+ uid, source, subset, group provenance; `group` = leakage identity (house / scan)
6
+ room_type str | None
7
+ boundary, boundary_type [[x, y], ...] CCW | None ; "polygon" | "hull" | "proxy" | None
8
+ height float | None
9
+ objects: [{id, category, size: [sx, sy, sz], pos: [x, y, z], yaw, anchor, parent, desc, tilted}]
10
+ size extents along the object's local X/Y/Z (before yaw)
11
+ pos bottom center of the object box
12
+ yaw radians about +Z, from room +X to the object's local +X = its semantic front
13
+ anchor "floor" | "wall" | "ceiling" | "object" | None ; parent = id of supporting object | None
14
+ fixed [{id, category, size, pos, yaw}] (build output only): immovable boxes already in the room that the
15
+ model is told about but never places (doors, windows, columns, stairs, unlabelled floor boxes)
16
+ meta front_known (True only when the adapter verified the front convention), n_incomplete, eval_only, ...
17
+
18
+ Model I/O (spec: fastfill I/O design review, 2026-09-24; fixed geometry added 2026-09-24), one turn, compact JSON:
19
+ system SYSTEM_PROMPT (constant for training, evaluation and inference)
20
+ user {"room_type"?, "boundary_type", "boundary", "height"?, "fixed"?: [{id, size, pos, yaw}],
21
+ "objects": [{id, size, desc?}], "constraints"?}
22
+ assistant {"placements": [{"id", "on"?, "pos": [x, y, z], "yaw": int deg}, ...]}
23
+ Fixed items and objects are listed by footprint area (largest first) with ids slug(category)_k over one shared
24
+ namespace; the answer lists the objects only, in their listed order.
25
+ Compared with OptiScene: polygon instead of area, one id per instance instead of "N description" groups,
26
+ local-axis size with a front convention instead of h/w/d, integer degrees instead of 2-decimal radians,
27
+ no design-rule meta prompt, no reasoning block (no reasoning data exists), explicit support ("on").
28
+ """
29
+ import json
30
+ import math
31
+ import re
32
+
33
+ from shapely.geometry import Polygon
34
+ from shapely.geometry.polygon import orient
35
+
36
+ SYSTEM_PROMPT = (
37
+ "You are a room layout model. Place every listed object in the room; do not add, drop or resize objects.\n"
38
+ "Frame: meters, right-handed, Z up, floor z=0. boundary: floor polygon [[x,y],...], counter-clockwise; "
39
+ "boundary_type \"polygon\" = walls, \"hull\" = convex hull of a scanned floor (the real floor may be smaller). "
40
+ "height: ceiling height. A missing key means unknown.\n"
41
+ "fixed: boxes already in the room that cannot move (doors, windows, columns, stairs, ...), each with id, size, "
42
+ "pos and yaw as below; keep placements clear of them and do not block doors; they are not part of the answer.\n"
43
+ "Object: id, size [sx,sy,sz] along its local X,Y,Z (local +X = its front), optional desc.\n"
44
+ "Output only JSON {\"placements\":[{\"id\":id,\"on\":id,\"pos\":[x,y,z],\"yaw\":deg},...]}, "
45
+ "every id once, in the listed order. on: the object it stands on, z = that object's top; "
46
+ "omit on for the floor, z = 0. pos: bottom center of the box. "
47
+ "yaw: integer degrees in [0,360), counter-clockwise from room +X to the object's front.\n"
48
+ "constraints (optional, all must hold): [\"on\",a,b]; [\"faces\",a,b] a's front within 30 degrees of the "
49
+ "direction to b's center; [\"near\",a,b,d] footprint gap at most d; [\"against_wall\",a] one whole side of "
50
+ "a's footprint within 0.1 of a wall; [\"between\",a,b,c] the segment between the centers of b and c crosses a."
51
+ )
52
+
53
+ HASH = re.compile(r"(?=[a-f]*\d)[0-9a-f]{8}\b") # SAGE asset hashes: glassnightstand5c8f77c6
54
+ GENERIC = re.compile(r"^(other\w*|objects?|unknown)( |$)") # labels that say nothing: shown as "object"
55
+ UNKNOWN_ROOM = {"misc", "other", "other room", "unknown", "undefined", "none"}
56
+ REL = re.compile(r"[,;]?\s*\b(positioned|placed|resting|located|situated|sitting on|standing on|lying on|leaning "
57
+ r"against|next to|beside|in front of|on top of|on a flat surface|on the floor)\b.*$", re.I)
58
+ DANGLING = {"a", "an", "the", "and", "or", "with", "of", "in", "on", "to", "for", "at", "by", "featuring", "its"}
59
+
60
+
61
+ # labels whose first synonym is a bare modifier ('corner', 'three seat') or an Infinigen factory name
62
+ ALIASES = {"corner/side table": "side table", "three-seat / multi-seat sofa": "three seat sofa",
63
+ "three-seat / multi-person sofa": "three seat sofa", "coffeetablefactory": "coffee table",
64
+ "sidetablefactory": "side table", "boxcomforterfactory": "comforter", "comforterfactory": "comforter"}
65
+
66
+
67
+ def norm_cat(c):
68
+ """Traceable category normalization (raw label stays in IR): first synonym, words only, no hash/instance no."""
69
+ c = (c or "").lower()
70
+ c = ALIASES.get(re.sub(r"\(.*", "", c).strip(), c)
71
+ c = re.split(r"[,(/]", c)[0]
72
+ c = " ".join(re.sub(r"[^a-z0-9]+", " ", c).split())
73
+ c = " ".join(HASH.sub(" ", c).split()).split(" or ")[0]
74
+ return re.sub(r"(?<=[a-z])(?:\s*\d+)+$", "", c)
75
+
76
+
77
+ def head(o):
78
+ """Head noun of an object's category (last word), used by anchor rules and validator buckets."""
79
+ w = norm_cat(o["category"]).split()
80
+ return w[-1] if w else ""
81
+
82
+
83
+ ROOM_WORDS = {"livingroom": "living room", "diningroom": "dining room", "livingdiningroom": "living dining room",
84
+ "masterbedroom": "master bedroom", "secondbedroom": "second bedroom", "kidsroom": "kids room",
85
+ "storageroom": "storage room", "laundryroom": "laundry room", "elderlyroom": "elderly room"}
86
+
87
+
88
+ def norm_room_type(t):
89
+ if not t:
90
+ return None
91
+ t = re.sub(r"\s*/\s*", "/", " ".join(t.lower().replace("_", " ").split())).rstrip(".").strip()
92
+ t = " ".join(ROOM_WORDS.get(w, w) for w in t.split())
93
+ t = re.sub(r"(?:\s+\d+)+$", "", t) # "meeting room 1" -> "meeting room"
94
+ # floor / apartment / capacity tags of MansionWorld: "restroom f1", "apartment 2 storage", "meeting room 4p"
95
+ t = " ".join(re.sub(r"\b(?:f\d+|apt\d+|apartment \d+|\d+p)\b", " ", t).split())
96
+ t = re.sub(r"\brestrooms\b", "restroom", t)
97
+ return None if not t or t in UNKNOWN_ROOM else t
98
+
99
+
100
+ def num(x):
101
+ """2-decimal number as shown to the model; integral values without '.0', no '-0'."""
102
+ v = round(float(x), 2) + 0.0
103
+ return int(v) if v == int(v) else v
104
+
105
+
106
+ def shown_size(o):
107
+ return [max(num(v), 0.01) for v in o["size"]]
108
+
109
+
110
+ def short_desc(d, max_words=16):
111
+ """First sentence, no article, no placement clause (scene context, not asset), cut at a clause boundary."""
112
+ s = re.split(r"(?<=[a-z])\.\s", d.strip())[0].rstrip(".")
113
+ s = re.sub(r"^(a|an|the)\s+", "", s, flags=re.I)
114
+ w = REL.sub("", s).strip(" ,;").split()
115
+ if len(w) > max_words:
116
+ w = w[:max_words]
117
+ cut = [i for i, x in enumerate(w) if i >= 4 and (x.endswith(",") or x.lower() in ("with", "featuring", "and"))]
118
+ if cut:
119
+ w = w[:cut[-1] + 1] if w[cut[-1]].endswith(",") else w[:cut[-1]]
120
+ while w and w[-1].lower().strip(",") in DANGLING:
121
+ w.pop()
122
+ return " ".join(w).strip(" ,;")
123
+
124
+
125
+ def _clean_once(b):
126
+ p = Polygon([(round(x, 2), round(y, 2)) for x, y in b]).simplify(0.01, preserve_topology=True)
127
+ if not p.is_valid and p.area > 0: # cm rounding can pinch a valid ring into a self-touch: repair if harmless
128
+ q = p.buffer(0)
129
+ if q.geom_type == "Polygon" and not q.interiors and abs(q.area - p.area) < 0.01 * p.area:
130
+ p = q
131
+ if not p.is_valid or p.area <= 0:
132
+ return None
133
+ pts = list(orient(p, 1.0).exterior.coords)[:-1]
134
+ k = min(range(len(pts)), key=lambda i: (pts[i][0] ** 2 + pts[i][1] ** 2, pts[i]))
135
+ return [[num(x), num(y)] for x, y in pts[k:] + pts[:k]]
136
+
137
+
138
+ def clean_boundary(b):
139
+ """cm rounding, duplicate/collinear removal (<=1 cm deviation), CCW, start at the vertex nearest (0,0).
140
+ Repeated until nothing changes (simplify never drops a ring's start vertex, and the start moves), so it is
141
+ idempotent: a request echoing a clean boundary keeps it."""
142
+ out = _clean_once(b)
143
+ for _ in range(8):
144
+ nxt = _clean_once(out) if out else None
145
+ if nxt is None or nxt == out:
146
+ break
147
+ out = nxt
148
+ return out
149
+
150
+
151
+ def rot90(room, k):
152
+ """Exact augmentation on cm-rounded rooms: rotate about the origin by k*90 deg, shift back to the min corner.
153
+ Run BEFORE canonical."""
154
+ if k % 4 == 0:
155
+ return room
156
+ c, s = [(1, 0), (0, 1), (-1, 0), (0, -1)][k % 4]
157
+ rot = lambda x, y: (c * x - s * y, s * x + c * y)
158
+ b = [rot(x, y) for x, y in room["boundary"]]
159
+ mx, my = min(p[0] for p in b), min(p[1] for p in b)
160
+ b = [[num(x - mx), num(y - my)] for x, y in b] # rotation keeps CCW; only the start vertex moves
161
+ s0 = min(range(len(b)), key=lambda i: (b[i][0] ** 2 + b[i][1] ** 2, b[i]))
162
+ def turn(o):
163
+ x, y = rot(*o["pos"][:2])
164
+ return {**o, "pos": [num(x - mx), num(y - my), o["pos"][2]], "yaw": (o["yaw"] + k * math.pi / 2) % (2 * math.pi)}
165
+ out = {**room, "boundary": b[s0:] + b[:s0], "objects": [turn(o) for o in room["objects"]]}
166
+ if room.get("fixed"):
167
+ out["fixed"] = [turn(o) for o in room["fixed"]]
168
+ return out
169
+
170
+
171
+ def slug(category):
172
+ nc = norm_cat(category)
173
+ return "object" if not nc or GENERIC.match(nc) else nc.replace(" ", "_")
174
+
175
+
176
+ def canonical(room):
177
+ """Order by footprint area desc (ties: category, size); identical instances by (z, x, y) when poses exist
178
+ (training), else keep input order (inference; the sort is stable). ids become slug(norm_cat)_k, counted over
179
+ the fixed items first, then the objects, so no id is shared between the two lists."""
180
+ def key(o):
181
+ s = [max(round(v, 2), 0.01) for v in o["size"]] # = shown_size (training sizes are already shown_size)
182
+ k = (-round(s[0] * s[1], 4), norm_cat(o["category"]), [-v for v in s])
183
+ return k + ((round(o["pos"][2], 2), round(o["pos"][0], 2), round(o["pos"][1], 2)) if "pos" in o else ())
184
+ fixed = sorted(room.get("fixed") or [], key=key)
185
+ objs = sorted(room["objects"], key=key)
186
+ count, new = {}, {}
187
+ for o in fixed + objs:
188
+ sl = slug(o["category"])
189
+ count[sl] = count.get(sl, 0) + 1
190
+ new[o["id"]] = f"{sl}_{count[sl]}"
191
+ out = {**room, "objects": [{**o, "id": new[o["id"]], "parent": new.get(o.get("parent"))} for o in objs]}
192
+ if fixed:
193
+ out["fixed"] = [{**o, "id": new[o["id"]]} for o in fixed]
194
+ return out
195
+
196
+
197
+ def user_json(room, constraints=None, with_desc=True):
198
+ d = {}
199
+ rt = norm_room_type(room.get("room_type"))
200
+ if rt:
201
+ d["room_type"] = rt
202
+ d["boundary_type"] = room["boundary_type"]
203
+ d["boundary"] = room["boundary"]
204
+ if room.get("height"):
205
+ d["height"] = num(room["height"])
206
+ if room.get("fixed"):
207
+ d["fixed"] = [{"id": o["id"], "size": shown_size(o), "pos": [num(v) for v in o["pos"]],
208
+ "yaw": int(round(math.degrees(o["yaw"]))) % 360} for o in room["fixed"]]
209
+ objs = []
210
+ for o in room["objects"]:
211
+ e = {"id": o["id"], "size": shown_size(o)}
212
+ desc = short_desc(o["desc"]) if with_desc and o.get("desc") else ""
213
+ if desc: # a desc that is only a placement clause shortens to ""
214
+ e["desc"] = desc
215
+ objs.append(e)
216
+ d["objects"] = objs
217
+ if constraints:
218
+ d["constraints"] = constraints
219
+ return json.dumps(d, ensure_ascii=False, separators=(",", ":"))
220
+
221
+
222
+ def target_json(room):
223
+ out = []
224
+ for o in room["objects"]:
225
+ p = {"id": o["id"]}
226
+ if o.get("parent"):
227
+ p["on"] = o["parent"]
228
+ p["pos"] = [num(v) for v in o["pos"]]
229
+ p["yaw"] = int(round(math.degrees(o["yaw"]))) % 360
230
+ out.append(p)
231
+ return json.dumps({"placements": out}, ensure_ascii=False, separators=(",", ":"))
232
+
233
+
234
+ def messages(room, constraints=None, with_desc=True, with_target=True):
235
+ m = [{"role": "system", "content": SYSTEM_PROMPT},
236
+ {"role": "user", "content": user_json(room, constraints, with_desc)}]
237
+ if with_target:
238
+ m.append({"role": "assistant", "content": target_json(room)})
239
+ return m
240
+
241
+
242
+ def prompt_text(tok, msgs):
243
+ """Chat-template prompt ending right where the assistant answer starts (Qwen3 thinking off)."""
244
+ return tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
245
+
246
+
247
+ def _isnum(v):
248
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
249
+
250
+
251
+ MAX_COORD = 1e3
252
+
253
+
254
+ def parse(text, ids):
255
+ """Never raises. -> (placements {id: {pos, yaw (rad), on}}, error | None).
256
+ error: truncated_or_bad_json | bad_json | bad_item | bad_number | duplicate_id | missing_ids | extra_ids |
257
+ bad_support_ref (unknown, self or cyclic 'on')."""
258
+ s = text.split("</think>", 1)[-1] if isinstance(text, str) else ""
259
+ i = s.find("{")
260
+ try:
261
+ items = json.JSONDecoder().raw_decode(s, i)[0]["placements"] if i >= 0 else None
262
+ except (ValueError, KeyError, TypeError, IndexError, RecursionError):
263
+ return {}, "truncated_or_bad_json"
264
+ if not isinstance(items, list):
265
+ return {}, "bad_json"
266
+ out, err = {}, None
267
+ for p in items:
268
+ pos, yaw, on = (p.get("pos"), p.get("yaw"), p.get("on")) if isinstance(p, dict) else (None, None, None)
269
+ if not (isinstance(p, dict) and isinstance(p.get("id"), str) and isinstance(pos, list) and len(pos) == 3
270
+ and all(map(_isnum, pos)) and _isnum(yaw) and (on is None or isinstance(on, str))):
271
+ err = err or "bad_item"
272
+ continue
273
+ try:
274
+ vals = [float(v) for v in pos + [yaw]] # OverflowError on absurdly long integers
275
+ except OverflowError:
276
+ vals = [math.inf]
277
+ if not all(math.isfinite(v) and abs(v) < MAX_COORD for v in vals): # 1 km: nothing in a room is further
278
+ err = err or "bad_number"
279
+ continue
280
+ if p["id"] in out:
281
+ err = err or "duplicate_id"
282
+ continue
283
+ out[p["id"]] = {"pos": vals[:3], "yaw": math.radians(vals[3] % 360), "on": on}
284
+ if err is None and set(out) != set(ids):
285
+ err = "missing_ids" if set(ids) - set(out) else "extra_ids"
286
+ if err is None:
287
+ for pid, p in out.items():
288
+ seen, cur = {pid}, p["on"]
289
+ while cur is not None:
290
+ if cur not in out or cur in seen:
291
+ return out, "bad_support_ref"
292
+ seen.add(cur)
293
+ cur = out[cur]["on"]
294
+ return out, err
295
+
296
+
297
+ def gt_placements(room):
298
+ """Reference layout in parse() form (evaluate --gt-only)."""
299
+ return {o["id"]: {"pos": o["pos"], "yaw": o["yaw"], "on": o.get("parent")} for o in room["objects"]}
300
+
301
+
302
+ def apply(room, pl):
303
+ """Predicted layout in IR form; parents come ONLY from the prediction; missing objects are dropped."""
304
+ return {**room, "objects": [{**o, "pos": pl[o["id"]]["pos"], "yaw": pl[o["id"]]["yaw"],
305
+ "parent": pl[o["id"]]["on"]} for o in room["objects"] if o["id"] in pl]}
306
+
307
+
308
+ def footprint(o):
309
+ """Oriented footprint corners [(x, y)] x4 of an upright object."""
310
+ c, s = math.cos(o["yaw"]), math.sin(o["yaw"])
311
+ hx, hy = o["size"][0] / 2, o["size"][1] / 2
312
+ x, y = o["pos"][0], o["pos"][1]
313
+ return [(x + c * dx - s * dy, y + s * dx + c * dy) for dx, dy in ((hx, hy), (-hx, hy), (-hx, -hy), (hx, -hy))]
314
+
315
+
316
+ if __name__ == "__main__":
317
+ assert norm_cat("toilet, can, commode") == "toilet" and norm_cat("ceiling lamp(traffic light)") == "ceiling lamp"
318
+ assert norm_cat("pen_1") == "pen" == norm_cat("pen2") and norm_cat("2 seat sofa") == "2 seat sofa"
319
+ assert norm_cat("Lounge Chair / Cafe Chair") == "lounge chair" and norm_cat("King-size Bed") == "king size bed"
320
+ assert norm_cat("glassnightstand5c8f77c6") == "glassnightstand" and norm_cat("plant_or_flower_pot") == "plant"
321
+ assert norm_room_type("Bedroom / Hotel") == "bedroom/hotel" and norm_room_type("Misc.") is None
322
+ assert norm_room_type("LivingRoom") == "living room" == norm_room_type("living_room")
323
+ assert norm_room_type("Meeting Room 1") == "meeting room"
324
+ assert parse('{"placements":[{"id":"a","pos":[' + '9' * 400 + ',1,0],"yaw":0}]}', ["a"])[1] == "bad_number"
325
+ assert short_desc("A wooden table positioned next to a rectangular object. It is old.") == "wooden table"
326
+ assert num(0.0) == 0 and num(-0.001) == 0 and num(2.704) == 2.7
327
+ assert clean_boundary([[0, 0], [2, 0], [4, 0], [4, 3], [4, 3], [0, 3]]) == [[0, 0], [4, 0], [4, 3], [0, 3]]
328
+ wob = [[0, 0], [2.004, 0.006], [4, 0], [4.006, 1.504], [4, 3], [0, 3]] # near-collinear after cm rounding
329
+ assert clean_boundary(clean_boundary(wob)) == clean_boundary(wob)
330
+
331
+ ok = '{"placements":[{"id":"desk_1","pos":[1,1,0],"yaw":90},{"id":"lamp_1","on":"desk_1","pos":[1,1,0.75],"yaw":0}]}'
332
+ ids = ["desk_1", "lamp_1"]
333
+ pl, err = parse("<think>\n\n</think>\n\n" + ok + "\nnote {x}", ids)
334
+ assert err is None and abs(pl["desk_1"]["yaw"] - math.pi / 2) < 1e-9 and pl["lamp_1"]["on"] == "desk_1"
335
+ for bad, want in (('"on":"lamp_1"', "bad_support_ref"), ('"on":"sofa_1"', "bad_support_ref"),
336
+ ('"on":["desk_1"]', "bad_item"), ('"on":{"id":"desk_1"}', "bad_item")):
337
+ assert parse(ok.replace('"on":"desk_1"', bad), ids)[1] == want, bad
338
+ assert parse(ok.replace('"yaw":0}', '"yaw":NaN}'), ids)[1] == "bad_number"
339
+ assert parse(ok.replace('"yaw":90', '"yaw":true'), ids)[1] == "bad_item"
340
+ assert parse(ok.replace('"pos":[1,1,0]', '"pos":"110"'), ids)[1] == "bad_item"
341
+ assert parse('{"placements":' + "[" * 30000 + "]" * 30000 + "}", ids)[1] in ("truncated_or_bad_json", "bad_item")
342
+ assert parse(ok[:70], ids)[1] == "truncated_or_bad_json" and parse(None, ids)[1] == "bad_json"
343
+ assert parse('{"placements":[{"id":"desk_1","pos":[1,1,0],"yaw":0}]}', ids)[1] == "missing_ids"
344
+
345
+ room = {"room_type": "Bed_Room", "boundary_type": "polygon", "boundary": [[0, 0], [4, 0], [4, 3], [0, 3]], "height": 2.7,
346
+ "objects": [{"id": s, "category": "chair", "size": [.5, .5, .9], "pos": [x, y, 0], "yaw": 0}
347
+ for s, x, y in (("a", 1, 1), ("b", 3, 1), ("c", 2, 2))]
348
+ + [{"id": "d", "category": "desk", "size": [1.2, .6, .75], "pos": [2, .3, 0], "yaw": math.pi / 2},
349
+ {"id": "l", "category": "lamp", "size": [.2, .2, .4], "pos": [2, .3, .75], "yaw": 0, "parent": "d"}]}
350
+ r4 = room
351
+ for k in range(4): # rot90 before canonical keeps the (z, x, y) numbering of identical chairs
352
+ c = canonical(rot90(room, k))
353
+ chairs = [o for o in c["objects"] if o["category"] == "chair"]
354
+ assert [o["pos"][:2] for o in chairs] == sorted(o["pos"][:2] for o in chairs), k
355
+ r4 = rot90(r4, 1)
356
+ assert r4["boundary"] == room["boundary"] and [o["pos"] for o in r4["objects"]] == [o["pos"] for o in room["objects"]]
357
+ c = canonical(room)
358
+ assert [o["id"] for o in c["objects"]] == ["desk_1", "chair_1", "chair_2", "chair_3", "lamp_1"]
359
+ m = messages(c)
360
+ assert json.loads(m[1]["content"])["room_type"] == "bed room" and "fixed" not in json.loads(m[1]["content"])
361
+ # fixed boxes: shared id namespace (a fixed chair takes chair_1), shown with pose, rotated with the room, not answered
362
+ fx = {**room, "fixed": [{"id": "col", "category": "column", "size": [.3, .3, 2.7], "pos": [3.5, 2.5, 0], "yaw": 0},
363
+ {"id": "fc", "category": "chair", "size": [.5, .5, .9], "pos": [.5, .5, 0], "yaw": 0},
364
+ {"id": "u", "category": "unknown", "size": [1, .4, .8], "pos": [2, 2.8, 0], "yaw": math.pi / 2}]}
365
+ cf = canonical(fx)
366
+ assert [o["id"] for o in cf["fixed"]] == ["object_1", "chair_1", "column_1"], cf["fixed"]
367
+ assert [o["id"] for o in cf["objects"]] == ["desk_1", "chair_2", "chair_3", "chair_4", "lamp_1"]
368
+ uj = json.loads(user_json(cf))
369
+ assert uj["fixed"][1] == {"id": "chair_1", "size": [0.5, 0.5, 0.9], "pos": [0.5, 0.5, 0], "yaw": 0} and "fixed" not in target_json(cf)
370
+ r1 = rot90(fx, 1) # (3.5, 2.5) turned by 90 deg about the origin -> (-2.5, 3.5) -> shifted
371
+ assert r1["fixed"][0]["pos"] == [0.5, 3.5, 0] and abs(r1["fixed"][0]["yaw"] - math.pi / 2) < 1e-9, r1["fixed"][0]
372
+ assert slug("otherprop") == "object" == slug("") == slug("Objects") and slug("floor lamp") == "floor_lamp"
373
+ pl, err = parse(m[2]["content"], [o["id"] for o in c["objects"]])
374
+ assert err is None and pl["lamp_1"]["on"] == "desk_1" and pl["lamp_1"]["pos"] == [2.0, 0.3, 0.75]
375
+ assert apply(c, pl)["objects"][-1]["parent"] == "desk_1"
376
+ fp = footprint(c["objects"][0]) # desk rotated 90 deg: 0.6 wide in x, 1.2 deep in y
377
+ xs, ys = [p[0] for p in fp], [p[1] for p in fp]
378
+ assert abs(max(xs) - min(xs) - 0.6) < 1e-9 and abs(max(ys) - min(ys) - 1.2) < 1e-9
379
+ print("scene.py self-check ok")
code/fastfill/serve.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenAI-compatible endpoint that speaks the WorldEdge request (scope doc Appendix B, fields in interface.py).
2
+
3
+ python -m fastfill.serve --model outputs/ff-v1-merged --port 8001
4
+ # EmbodiedGen side: WORLDEDGE_FASTFILL_URL=http://<host>:8001/v1 WORLDEDGE_FASTFILL_MODEL=ff-v1-merged
5
+ python -m fastfill.serve # self-check (no model)
6
+
7
+ The last user message is the request JSON (the body's "model" field is not read). The reply's content is
8
+ placements_from_text's JSON ({"placements": [{"id", "position_m", "yaw_rad", "support_parent", "support_surface"?}], "raw_placements": [...],
9
+ "validation": {"raw", "repaired"}, "error": null, "unsupported": [...]}) plus "model" (--name, default the checkpoint
10
+ directory's name) and "checkpoint" (its absolute path): EmbodiedGen caches that content under its own
11
+ WORLDEDGE_FASTFILL_MODEL, so set that to the served name, and a cached layout still says which checkpoint made it.
12
+ A request with nothing to place (no objects, or all anchored to a wall or ceiling) is answered 200 with no placements
13
+ and its unsupported list, without calling the model (training never has an empty room).
14
+ HTTP 422 and no layout when the request is rejected (a prompt of --max_len tokens or more included: prompt_too_long),
15
+ the answer does not parse (a missing or extra object included), or the repaired layout still fails the canonical
16
+ checks: FastFill places every object validly or fails back to the caller, it never drops one. Every error body carries
17
+ model and checkpoint; a 422 after generation also the model's text (raw_text) and the unsupported list.
18
+ Greedy decoding, one request at a time.
19
+ The JSON body requires a positive Content-Length of at most MAX_REQUEST_BYTES (1 MiB). Missing/invalid lengths
20
+ and malformed or excessively nested JSON receive 400; oversized bodies receive 413 before any body read.
21
+ """
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from http.server import BaseHTTPRequestHandler, HTTPServer
28
+
29
+ from fastfill.interface import placements_from_text, request_to_room
30
+ from fastfill.scene import messages, prompt_text
31
+
32
+ MAX_REQUEST_BYTES = 1024 * 1024
33
+
34
+
35
+ class PromptTooLong(ValueError):
36
+ """Raised by generate() for a prompt of max_len tokens or more (vLLM cannot start on it): answered with 422."""
37
+
38
+
39
+ def answer(req, generate):
40
+ """Request dict -> (HTTP status, body). generate(model messages) -> model text, or raises PromptTooLong."""
41
+ try:
42
+ room, ctx = request_to_room(req)
43
+ except ValueError as e:
44
+ return 422, {"error": {"message": str(e), "type": "invalid_request_error"}}
45
+ if not room["objects"]: # the empty answer is the only valid one: not generated
46
+ return 200, placements_from_text('{"placements":[]}', room, ctx)
47
+ try:
48
+ text = generate(messages(room, room["constraints"], with_target=False))
49
+ except PromptTooLong as e:
50
+ return 422, {"error": {"message": str(e), "type": "invalid_request_error", "unsupported": ctx["unsupported"]}}
51
+ out = placements_from_text(text, room, ctx)
52
+ if out["error"]:
53
+ return 422, {"error": {"message": f"model answer rejected: {out['error']}", "type": "invalid_layout",
54
+ "raw_text": text, "unsupported": out["unsupported"]}}
55
+ if not out["validation"]["repaired"]["ok"]: # every object must be placed validly; else the caller decides
56
+ return 422, {"error": {"message": "layout fails validation even after repair", "type": "invalid_layout",
57
+ "raw_placements": out["raw_placements"], "validation": out["validation"],
58
+ "raw_text": text, "unsupported": out["unsupported"]}}
59
+ return 200, out
60
+
61
+
62
+ def make_handler(generate, name, checkpoint):
63
+ class Handler(BaseHTTPRequestHandler):
64
+ def do_POST(self):
65
+ if not self.path.endswith("/chat/completions"):
66
+ return self.reply(404, {"error": {"message": "POST .../v1/chat/completions"}})
67
+ try:
68
+ length = int(self.headers["Content-Length"])
69
+ if length <= 0:
70
+ raise ValueError("Content-Length must be positive")
71
+ if length > MAX_REQUEST_BYTES:
72
+ return self.reply(413, {"error": {"message": f"body exceeds {MAX_REQUEST_BYTES} bytes",
73
+ "type": "invalid_request_error"}})
74
+ body = json.loads(self.rfile.read(length))
75
+ req = json.loads(body["messages"][-1]["content"])
76
+ except (ValueError, KeyError, IndexError, TypeError, RecursionError) as e:
77
+ return self.reply(400, {"error": {"message": f"bad body: {e!r}", "type": "invalid_request_error"}})
78
+ try:
79
+ status, out = answer(req, generate)
80
+ except Exception as e: # a bug or a dead model: say so instead of dropping the connection
81
+ return self.reply(500, {"error": {"message": repr(e), "type": "server_error"}})
82
+ if status == 200:
83
+ content = json.dumps({**out, "model": name, "checkpoint": checkpoint})
84
+ out = {"id": f"fastfill-{time.time_ns()}", "object": "chat.completion", "created": int(time.time()),
85
+ "model": name, "choices": [{"index": 0, "finish_reason": "stop",
86
+ "message": {"role": "assistant", "content": content}}],
87
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}}
88
+ self.reply(status, out)
89
+
90
+ def reply(self, status, obj):
91
+ if status != 200:
92
+ obj["error"].update(model=name, checkpoint=checkpoint)
93
+ data = json.dumps(obj).encode()
94
+ self.send_response(status)
95
+ self.send_header("Content-Type", "application/json")
96
+ self.send_header("Content-Length", str(len(data)))
97
+ self.end_headers()
98
+ self.wfile.write(data)
99
+
100
+ return Handler
101
+
102
+
103
+ def main():
104
+ ap = argparse.ArgumentParser()
105
+ ap.add_argument("--model", required=True, help="merged checkpoint dir")
106
+ ap.add_argument("--name", help="model name echoed in replies (default: the checkpoint directory's name)")
107
+ ap.add_argument("--host", default="0.0.0.0")
108
+ ap.add_argument("--port", type=int, default=8001)
109
+ ap.add_argument("--max_len", type=int, default=40960, help="model sequence limit; the answer may use what the prompt leaves")
110
+ a = ap.parse_args()
111
+ from vllm import LLM, SamplingParams
112
+ llm = LLM(model=a.model, max_model_len=a.max_len)
113
+ tok = llm.get_tokenizer()
114
+
115
+ def generate(msgs):
116
+ p = prompt_text(tok, msgs)
117
+ n = len(tok(p)["input_ids"])
118
+ if n >= a.max_len: # vLLM raises on such a prompt: refuse it as a bad request instead
119
+ raise PromptTooLong(f"prompt_too_long: {n} tokens, limit {a.max_len}")
120
+ sp = SamplingParams(temperature=0, max_tokens=a.max_len - n)
121
+ return llm.generate([p], sp, use_tqdm=False)[0].outputs[0].text
122
+ ckpt = os.path.abspath(a.model) if os.path.isdir(a.model) else a.model # a dir, or a hub id as given
123
+ name = a.name or os.path.basename(os.path.normpath(ckpt))
124
+ print(f"FastFill endpoint: http://{a.host}:{a.port}/v1 model {name} = {ckpt}", flush=True)
125
+ HTTPServer((a.host, a.port), make_handler(generate, name, ckpt)).serve_forever()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ if sys.argv[1:]:
130
+ main()
131
+ else: # self-check: answer() and the HTTP wrapper, a stub model
132
+ import threading
133
+ import urllib.error
134
+ import urllib.request
135
+ calls = []
136
+
137
+ def stub(text):
138
+ def gen(msgs):
139
+ calls.append(json.loads(msgs[1]["content"]))
140
+ if text is None:
141
+ raise PromptTooLong("prompt_too_long: 50000 tokens, limit 40960")
142
+ return text
143
+ return gen
144
+ req = {"room": {"boundary_xy": [[0, 0], [4, 0], [4, 4], [0, 4]], "height_m": 2.8,
145
+ "fixed_geometry": [{"id": "col_1", "category": "column", "size_xyz_m": [0.3, 0.3, 2.8],
146
+ "position_m": [3.5, 3.5, 0]}]},
147
+ "objects_to_place": [{"id": "table_1", "category": "table", "size_xyz_m": [1.4, 0.8, 0.75]},
148
+ {"id": "cup_1", "category": "cup", "size_xyz_m": [0.08, 0.08, 0.1]},
149
+ {"id": "clock_1", "category": "clock", "size_xyz_m": [0.3, 0.05, 0.3], "anchor": "wall"}]}
150
+ good = '{"placements":[{"id":"table_1","pos":[2,2,0],"yaw":0},{"id":"cup_1","on":"table_1","pos":[2,2,0.75],"yaw":0}]}'
151
+ clock = [{"id": "clock_1", "reason": "unsupported_anchor"}]
152
+ st, out = answer(req, stub(good))
153
+ assert st == 200 and len(calls) == 1 and out["validation"]["repaired"]["ok"] and out["unsupported"] == clock, out
154
+ # nothing placeable: no objects, or only wall / ceiling items -> 200, empty placements, the model never called
155
+ for objs, unsup in (([], []), (req["objects_to_place"][2:], clock)):
156
+ st, out = answer({**req, "objects_to_place": objs}, stub("never"))
157
+ assert st == 200 and len(calls) == 1 and out["placements"] == [] and out["unsupported"] == unsup, out
158
+ assert out["error"] is None and out["validation"]["repaired"]["ok"], out
159
+ # 422 after generation keeps the model's text and the unsupported list: bad JSON, a missing object, a layout
160
+ # that fails after repair (table through the column), a prompt over the limit (no text)
161
+ on_col = good.replace("[2,2,0]", "[3.4,3.4,0]").replace("[2,2,0.75]", "[3.4,3.4,0.75]")
162
+ for text, kind in (("{not json", "invalid_layout"), (good.split(',{"id":"cup_1"')[0] + "]}", "invalid_layout"),
163
+ (on_col, "invalid_layout"), (None, "invalid_request_error")):
164
+ st, out = answer(req, stub(text))
165
+ e = out["error"]
166
+ assert st == 422 and e["type"] == kind and e["unsupported"] == clock and e.get("raw_text") == text, out
167
+ assert out["error"]["message"].startswith("prompt_too_long")
168
+ assert answer(req, stub(on_col))[1]["error"]["validation"]["repaired"]["fixed_blocking"] == [["table_1", "col_1"]]
169
+ # over HTTP: every reply names the served model and checkpoint (200 in the content EmbodiedGen caches)
170
+ texts = [good, "{not json"]
171
+ srv = HTTPServer(("127.0.0.1", 0), make_handler(lambda m: texts.pop(0), "ff-test", "/ckpt/ff-test"))
172
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
173
+
174
+ def post(obj):
175
+ body = json.dumps({"model": "fastfill", "messages": [{"role": "user", "content": json.dumps(obj)}]}).encode()
176
+ try:
177
+ with urllib.request.urlopen(f"http://127.0.0.1:{srv.server_port}/v1/chat/completions", body) as f:
178
+ return f.status, json.load(f)
179
+ except urllib.error.HTTPError as f:
180
+ return f.code, json.load(f)
181
+ st, rep = post(req)
182
+ content = json.loads(rep["choices"][0]["message"]["content"])
183
+ assert st == 200 and rep["model"] == "ff-test" and (content["model"], content["checkpoint"]) == ("ff-test", "/ckpt/ff-test")
184
+ st, rep = post(req)
185
+ assert st == 422 and rep["error"]["model"] == "ff-test" and rep["error"]["raw_text"] == "{not json", rep
186
+ assert post({"room": {}})[0] == 422 and post({"room": {}})[1]["error"]["checkpoint"] == "/ckpt/ff-test"
187
+ srv.shutdown()
188
+ print("serve.py self-check ok")
code/fastfill/split.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Leakage-safe train/dev/test assignment.
2
+
3
+ Rooms are linked when they share
4
+ - a `group` (same house / scan across exports),
5
+ - a meta.group_aliases entry (e.g. several scans of one ARKit visit),
6
+ - a furniture content key (3D-FRONT reuses whole furniture sets across houses; exports repackage rooms),
7
+ - a layout key (same boundary and object poses up to a 90-degree rotation, whatever the assets).
8
+ Each connected component goes to one split, chosen by a stable hash; a component holding an eval-only room
9
+ (meta.eval_only) goes to test as a whole.
10
+ """
11
+ import hashlib
12
+ import math
13
+
14
+ from fastfill.scene import num, rot90
15
+
16
+ # Procedurally generated sources share one asset library across all plans: equal furniture sets there are not
17
+ # repackaged rooms, and linking them fuses thousands of rooms into one component.
18
+ GROUP_ONLY = {"MansionWorld"}
19
+
20
+
21
+ def content_key(room, step=0.05):
22
+ """Rotation/translation-invariant fingerprint: multiset of sorted object dims (5 cm grid)."""
23
+ if len(room["objects"]) < 3 or room.get("source") in GROUP_ONLY:
24
+ return None
25
+ dims = sorted(tuple(sorted(round(v / step) for v in o["size"])) for o in room["objects"])
26
+ return hashlib.sha1(repr(dims).encode()).hexdigest()
27
+
28
+
29
+ def layout_key(room):
30
+ """Asset-independent design fingerprint: boundary + poses (cm, integer degrees), min over 4 rotations."""
31
+ if not room.get("boundary"):
32
+ return None
33
+ keys = []
34
+ for k in range(4):
35
+ r = rot90(room, k)
36
+ poses = sorted((num(o["pos"][0]), num(o["pos"][1]), round(math.degrees(o["yaw"])) % 360) for o in r["objects"])
37
+ keys.append(hashlib.sha1(repr((r["boundary"], poses)).encode()).hexdigest())
38
+ return min(keys)
39
+
40
+
41
+ def assign_splits(rooms, dev=0.05, test=0.05):
42
+ """rooms: list of IR dicts -> list of 'train' | 'dev' | 'test' (same order)."""
43
+ parent = {}
44
+
45
+ def find(x):
46
+ while parent.setdefault(x, x) != x:
47
+ parent[x] = parent[parent[x]]
48
+ x = parent[x]
49
+ return x
50
+
51
+ def union(a, b):
52
+ ra, rb = find(a), find(b)
53
+ if ra != rb:
54
+ parent[max(ra, rb)] = min(ra, rb)
55
+
56
+ for r in rooms:
57
+ g = "g:" + r["group"]
58
+ find(g)
59
+ for key in ("c:%s" % content_key(r), "l:%s" % layout_key(r)):
60
+ if not key.endswith("None"):
61
+ union(g, key)
62
+ for al in r.get("meta", {}).get("group_aliases") or []:
63
+ union(g, "a:" + al)
64
+ held_out = {find("g:" + r["group"]) for r in rooms if r.get("meta", {}).get("eval_only")}
65
+ # a component is hashed by its smallest GROUP name, not by its union-find root (which may be a furniture or layout
66
+ # key): the split of a house then does not depend on object content, and a prep change does not move houses
67
+ name = {}
68
+ for r in rooms:
69
+ c = find("g:" + r["group"])
70
+ name[c] = min(name.get(c, "g:" + r["group"]), "g:" + r["group"])
71
+ out = []
72
+ for r in rooms:
73
+ c = find("g:" + r["group"])
74
+ h = int(hashlib.sha1(name[c].encode()).hexdigest(), 16) % 10000 / 10000
75
+ out.append("test" if c in held_out or h < test else "dev" if h < test + dev else "train")
76
+ return out
77
+
78
+
79
+ if __name__ == "__main__":
80
+ def room(group, sizes, poses=None, boundary=((0, 0), (5, 0), (5, 4), (0, 4)), source="S", meta=None):
81
+ poses = poses or [(1 + i, 1, 0) for i in range(len(sizes))]
82
+ return {"group": group, "source": source, "boundary": [list(p) for p in boundary], "meta": meta or {},
83
+ "objects": [{"size": s, "pos": [x, y, 0], "yaw": math.radians(d)} for s, (x, y, d) in zip(sizes, poses)]}
84
+
85
+ a = room("house1", [[2, 1.6, .5], [.5, .5, .6], [.5, .5, .6]])
86
+ b = room("house2", [[1.6, 2, .5], [.5, .5, .6], [.5, .6, .5]]) # same furniture, other house, axes permuted
87
+ c = room("house1", [[1, 1, 1]]) # same house, different room
88
+ v1 = room("vid1", [[1, 2, 3]], meta={"group_aliases": ["visit:9"]})
89
+ v2 = room("vid2", [[3, 2, 1.5]], meta={"group_aliases": ["visit:9"]}) # two videos of one visit
90
+ t1 = room("h_t1", [[1, 1, 1], [2, 2, 2]], [(1, 1, 0), (3, 2, 90)])
91
+ t2 = room("h_t2", [[.9, .9, 1.2], [2, 2, 1]], [(3, 1, 90), (2, 3, 180)], # same design stored rotated, new assets
92
+ boundary=((0, 0), (4, 0), (4, 5), (0, 5)))
93
+ e1 = room("eval1", [[1, 1, 1], [1, 2, 1], [2, 2, 2]], meta={"eval_only": True})
94
+ e2 = room("twin", [[1, 1, 1], [2, 1, 1], [2, 2, 2]]) # content twin of an eval-only room
95
+ m1 = room("mw1", [[1, 1, 1]] * 3, source="MansionWorld")
96
+ m2 = room("mw2", [[1, 1, 1]] * 3, [(2, 2, 0), (3, 2, 0), (4, 3, 0)], source="MansionWorld")
97
+ rooms = [a, b, c, v1, v2, t1, t2, e1, e2, m1, m2] + [
98
+ room(f"h{i}", [[i / 10 + .1, 1, 1]] * 3, [(1 + i / 1000, 1, 0), (2, 2, 0), (3, 3, 0)]) for i in range(2000)]
99
+ s = assign_splits(rooms)
100
+ assert s[0] == s[1] == s[2] and s[3] == s[4] and s[5] == s[6], s[:7]
101
+ assert s[7] == s[8] == "test", s[7:9]
102
+ assert content_key(m1) is None and layout_key(m1) != layout_key(m2) and layout_key(t1) == layout_key(t2)
103
+ frac = {k: s.count(k) / len(s) for k in ("train", "dev", "test")}
104
+ assert 0.85 < frac["train"] < 0.95 and 0.02 < frac["test"] < 0.08, frac
105
+ assert assign_splits(rooms) == s
106
+ print("split.py self-check ok", frac)
code/fastfill/tests/test_adapter_repairs.py ADDED
@@ -0,0 +1,491 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded adapter regressions; fixtures are self-contained, with no corpus/audit files read.
2
+
3
+ The 21 source boxes below are the complete retained >2 cm error examples in the
4
+ 2026-09-27 adapter evidence (11 Structured3D, 8 HSSD200, 2 MultiScan).
5
+ """
6
+ import copy
7
+ import csv
8
+ import io
9
+ import itertools
10
+ import json
11
+ import math
12
+ from types import SimpleNamespace
13
+ from unittest.mock import patch
14
+
15
+ import numpy as np
16
+ import pytest
17
+
18
+ from fastfill.adapters import hssd200, interiorgs, multiscan, structured3d
19
+ from fastfill.adapters.unified import convert_room
20
+ from fastfill.build import prep
21
+
22
+
23
+ AUDITED_BOXES = [('Structured3D',
24
+ 'structured3d_00160_room_584/50',
25
+ {'category': 'unknown',
26
+ 'instance_id': 50,
27
+ 'position': {'x': 1.516548, 'y': 0.92628, 'z': 0.4325},
28
+ 'raw_basis': [[-0.9999999999999962, 8.742277644230737e-08, 0.0],
29
+ [3.8213709261743826e-15, 4.3711388286737763e-08, -0.9999999999999991],
30
+ [-8.742277644230729e-08, -0.9999999999999953, -4.3711388175715626e-08]],
31
+ 'raw_coeffs': [208.00000000000077, 32.50000365650003, 598.0000000000028]}),
32
+ ('Structured3D',
33
+ 'structured3d_00393_room_584/67',
34
+ {'category': 'unknown',
35
+ 'instance_id': 67,
36
+ 'position': {'x': 1.305684, 'y': 0.087636, 'z': 0.4325},
37
+ 'raw_basis': [[-0.9999999999999962, 8.742278110047815e-08, 0.0],
38
+ [3.821371129789495e-15, 4.3711388286737763e-08, -0.9999999999999991],
39
+ [-8.742278110047807e-08, -0.9999999999999953, -4.3711388175715626e-08]],
40
+ 'raw_coeffs': [209.00000000000077, 32.50000365650003, 548.0000000000027]}),
41
+ ('Structured3D',
42
+ 'structured3d_01159_room_232/100',
43
+ {'category': 'table',
44
+ 'instance_id': 100,
45
+ 'position': {'x': 0.44347, 'y': -2.189658, 'z': 0.3155},
46
+ 'raw_basis': [[4.371139006309477e-08, 3.821370999999997e-15, -0.9999999999999991],
47
+ [8.742277999999968e-08, -0.999999999999996, 2.2499312661442353e-22],
48
+ [-0.9999999999999953, -8.742277999999959e-08, -4.3711389952072466e-08]],
49
+ 'raw_coeffs': [35.00000000000003, 18.75000000000007, 12.452050000000058]}),
50
+ ('Structured3D',
51
+ 'structured3d_02687_room_670/53',
52
+ {'category': 'unknown',
53
+ 'instance_id': 53,
54
+ 'position': {'x': -0.730234, 'y': -1.066933, 'z': 0.3145},
55
+ 'raw_basis': [[1.0, 0.0, 0.0],
56
+ [0.0, -4.371138828673793e-08, -0.9999999999999991],
57
+ [-0.0, 0.9999999999999991, -4.3711388175715626e-08]],
58
+ 'raw_coeffs': [223.5, 32.50000365650003, 336.00000000000034]}),
59
+ ('Structured3D',
60
+ 'structured3d_02687_room_670/54',
61
+ {'category': 'unknown',
62
+ 'instance_id': 54,
63
+ 'position': {'x': -0.330234, 'y': -1.066933, 'z': 0.3145},
64
+ 'raw_basis': [[1.0, 0.0, 0.0],
65
+ [0.0, -4.371138828673793e-08, -0.9999999999999991],
66
+ [-0.0, 0.9999999999999991, -4.3711388175715626e-08]],
67
+ 'raw_coeffs': [173.5, 32.50000365650003, 336.00000000000034]}),
68
+ ('Structured3D',
69
+ 'structured3d_02687_room_670/55',
70
+ {'category': 'unknown',
71
+ 'instance_id': 55,
72
+ 'position': {'x': 0.019766, 'y': -1.066933, 'z': 0.3145},
73
+ 'raw_basis': [[1.0, 0.0, 0.0],
74
+ [0.0, -4.371138828673793e-08, -0.9999999999999991],
75
+ [-0.0, 0.9999999999999991, -4.3711388175715626e-08]],
76
+ 'raw_coeffs': [173.5, 32.50000365650003, 336.00000000000034]}),
77
+ ('Structured3D',
78
+ 'structured3d_03087_room_332351/56',
79
+ {'category': 'bed',
80
+ 'instance_id': 56,
81
+ 'position': {'x': 1.063394, 'y': 1.34462, 'z': 0.3805},
82
+ 'raw_basis': [[1.9984014443252818e-15, 4.371138999999996e-08, -0.9999999999999991],
83
+ [0.9999999999999991, -4.37113899041451e-08, 0.0],
84
+ [-4.3711389793122756e-08, -0.9999999999999981, -4.371139e-08]],
85
+ 'raw_coeffs': [90.66900000000008, 12.37120000000001, 15.869400000000033]}),
86
+ ('Structured3D',
87
+ 'structured3d_03155_room_309/16',
88
+ {'category': 'table',
89
+ 'instance_id': 16,
90
+ 'position': {'x': -0.60826, 'y': 4.415584, 'z': 0.2747},
91
+ 'raw_basis': [[-3.3306690738754696e-15, -4.371138999999996e-08, -0.9999999999999991],
92
+ [-0.9999999999999972, 7.549789999487077e-08, -1.1102230246251565e-16],
93
+ [7.549789999487071e-08, 0.9999999999999962, -4.3711390000000065e-08]],
94
+ 'raw_coeffs': [35.00000000000003, 18.750000000000053, 12.452050000000046]}),
95
+ ('Structured3D',
96
+ 'structured3d_03356_room_584/129',
97
+ {'category': 'table',
98
+ 'instance_id': 129,
99
+ 'position': {'x': 2.124656, 'y': -1.657159, 'z': 0.4325},
100
+ 'raw_basis': [[-4.371139117331779e-08, 0.9999999999999992, 0.0],
101
+ [4.3711388286737896e-08, 1.9106855776612683e-15, -0.9999999999999991],
102
+ [-0.9999999999999983, -4.3711390840250886e-08, -4.3711388175715626e-08]],
103
+ 'raw_coeffs': [209.0000000000002, 32.50000365650003, 273.0000000000005]}),
104
+ ('Structured3D',
105
+ 'structured3d_03356_room_584/133',
106
+ {'category': 'unknown',
107
+ 'instance_id': 133,
108
+ 'position': {'x': 1.409656, 'y': -0.555107, 'z': 0.4325},
109
+ 'raw_basis': [[-4.371139117331779e-08, -0.9999999999999992, 0.0],
110
+ [-4.3711388286737896e-08, 1.9106855776612683e-15, -0.9999999999999991],
111
+ [0.9999999999999983, -4.3711390840250886e-08, -4.3711388175715626e-08]],
112
+ 'raw_coeffs': [209.0000000000002, 32.50000365650003, 438.00000000000097]}),
113
+ ('Structured3D',
114
+ 'structured3d_03356_room_584/139',
115
+ {'category': 'unknown',
116
+ 'instance_id': 139,
117
+ 'position': {'x': 1.409656, 'y': -1.657159, 'z': 0.4325},
118
+ 'raw_basis': [[-4.371139117331779e-08, 0.9999999999999992, 0.0],
119
+ [4.3711388286737896e-08, 1.9106855776612683e-15, -0.9999999999999991],
120
+ [-0.9999999999999983, -4.3711390840250886e-08, -4.3711388175715626e-08]],
121
+ 'raw_coeffs': [209.0000000000002, 32.50000365650003, 438.00000000000097]}),
122
+ ('HSSD200',
123
+ 'HSSD200:102816627:3/102816627:267',
124
+ {'category': 'book',
125
+ 'scene_id': '102816627',
126
+ 'instance_id': '267',
127
+ 'obb_center': '[3.546474, 0.121647, -1.923595]',
128
+ 'obb_half_extents': '[0.047279, 0.12895, 0.102077]',
129
+ 'obb_rotation_wxyz': '[0.65328163, 0.6532815, -0.27059783, -0.27059788]'}),
130
+ ('HSSD200',
131
+ 'HSSD200:102816627:3/102816627:309',
132
+ {'category': 'book',
133
+ 'scene_id': '102816627',
134
+ 'instance_id': '309',
135
+ 'obb_center': '[3.546474, 1.921797, -1.923595]',
136
+ 'obb_half_extents': '[0.047279, 0.12895, 0.102077]',
137
+ 'obb_rotation_wxyz': '[0.65328163, 0.6532815, -0.27059783, -0.27059788]'}),
138
+ ('HSSD200',
139
+ 'HSSD200:103997781_171030978:3/103997781_171030978:185',
140
+ {'category': 'book',
141
+ 'scene_id': '103997781_171030978',
142
+ 'instance_id': '185',
143
+ 'obb_center': '[-2.296859, 0.115267, -4.154386]',
144
+ 'obb_half_extents': '[0.044784, 0.12895, 0.09669]',
145
+ 'obb_rotation_wxyz': '[0.65407459, 0.65248737, -0.27139045, -0.26980341]'}),
146
+ ('HSSD200',
147
+ 'HSSD200:104348082_171512994:0/104348082_171512994:150',
148
+ {'category': 'book',
149
+ 'scene_id': '104348082_171512994',
150
+ 'instance_id': '150',
151
+ 'obb_center': '[-9.489737, 0.149516, -1.599131]',
152
+ 'obb_half_extents': '[0.0562, 0.104913, 0.121338]',
153
+ 'obb_rotation_wxyz': '[0.60044965, 0.31731935, 0.21637841, -0.70139078]'}),
154
+ ('HSSD200',
155
+ 'HSSD200:104862501_172226556:5/104862501_172226556:165',
156
+ {'category': 'book',
157
+ 'scene_id': '104862501_172226556',
158
+ 'instance_id': '165',
159
+ 'obb_center': '[-4.624166, 0.121647, 4.894016]',
160
+ 'obb_half_extents': '[0.047279, 0.12895, 0.102077]',
161
+ 'obb_rotation_wxyz': '[0.65328142, 0.2705982, 0.27059834, -0.65328136]'}),
162
+ ('HSSD200',
163
+ 'HSSD200:104862501_172226556:5/104862501_172226556:207',
164
+ {'category': 'book',
165
+ 'scene_id': '104862501_172226556',
166
+ 'instance_id': '207',
167
+ 'obb_center': '[-3.159476, 0.121647, -0.588035]',
168
+ 'obb_half_extents': '[0.047279, 0.12895, 0.102077]',
169
+ 'obb_rotation_wxyz': '[0.65328163, 0.6532815, -0.27059783, -0.27059788]'}),
170
+ ('HSSD200',
171
+ 'HSSD200:106879005_174887124:4/106879005_174887124:98',
172
+ {'category': 'roof',
173
+ 'scene_id': '106879005_174887124',
174
+ 'instance_id': '98',
175
+ 'obb_center': '[-5.67073, 3.956208, -6.830123]',
176
+ 'obb_half_extents': '[6.5, 0.55117, 2.749985]',
177
+ 'obb_rotation_wxyz': '[0.97029571, -0.24192195, 0.0, 0.0]'}),
178
+ ('HSSD200',
179
+ 'HSSD200:107734158_175999998:14/107734158_175999998:448',
180
+ {'category': 'book',
181
+ 'scene_id': '107734158_175999998',
182
+ 'instance_id': '448',
183
+ 'obb_center': '[-9.930676, 0.157266, -2.633861]',
184
+ 'obb_half_extents': '[0.060269, 0.12895, 0.130123]',
185
+ 'obb_rotation_wxyz': '[-0.21313372, -0.70393206, 0.59736535, -0.31970036]'}),
186
+ ('MultiScan',
187
+ 'MultiScan::scene_00029_01/o43',
188
+ {'category': 'bag',
189
+ 'object_id': '43',
190
+ 'obb_center': '[-2.216006052445697, -3.198990954172932, -2.0838447429477776]',
191
+ 'obb_axes': '[-0.9391466459853527, 0.34151555401180256, -0.03702301584790649, 0.2533428178007576, '
192
+ '0.6157978529307632, -0.7460632821648807, -0.23199352146824048, -0.7100423442963621, '
193
+ '-0.6648450009610445]',
194
+ 'obb_half_extents': '[0.2190989368345001, 0.2549238904394131, 0.1397719718865565]',
195
+ 'front': '[0.23199352146824054, 0.7100423442963623, 0.6648450009610446]',
196
+ 'is_architectural': 'False',
197
+ 'is_opening': 'False'}),
198
+ ('MultiScan',
199
+ 'MultiScan::scene_00109_04/o31',
200
+ {'category': 'backpack',
201
+ 'object_id': '31',
202
+ 'obb_center': '[0.6333183612225775, 1.7997251504673377, -0.9412570338535274]',
203
+ 'obb_axes': '[-0.9961946980917455, -0.022557566113149824, -0.0841859828293692, -0.08715574274765812, '
204
+ '0.25783416049629937, 0.9622501868990583, -2.7755575615628914e-17, 0.9659258262890683, '
205
+ '-0.2588190451025206]',
206
+ 'obb_half_extents': '[0.19922853225692852, 0.20651165445598438, 0.11010368672622178]',
207
+ 'front': '[2.7755575615628914e-17, -0.9659258262890683, 0.2588190451025206]',
208
+ 'is_architectural': 'False',
209
+ 'is_opening': 'False'})]
210
+
211
+
212
+ def rotation(yaw, roll=0, pitch=0):
213
+ c, s = math.cos(yaw), math.sin(yaw)
214
+ a, b = math.cos(roll), math.sin(roll)
215
+ d, e = math.cos(pitch), math.sin(pitch)
216
+ return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) @ np.array(
217
+ [[1, 0, 0], [0, a, -b], [0, b, a]]) @ np.array([[d, 0, e], [0, 1, 0], [-e, 0, d]])
218
+
219
+
220
+ def quaternion_matrix(q):
221
+ # Independent vector/quaternion formula, applied to the three basis vectors.
222
+ w, v = q[0], np.asarray(q[1:])
223
+ return np.column_stack([e + 2 * np.cross(v, w * e + np.cross(v, e)) for e in np.eye(3)])
224
+
225
+
226
+ def corners(R, size, center):
227
+ return np.asarray(list(itertools.product((-0.5, 0.5), repeat=3))) * size @ R.T + center
228
+
229
+
230
+ def pose(f):
231
+ return (np.array([f['furniture_size'][k] for k in ('width', 'length', 'height')]),
232
+ np.array([f['furniture_position'][k] for k in 'xyz']),
233
+ math.radians(f['furniture_rotation']['z']))
234
+
235
+
236
+ def assert_envelope(f, points, center_z):
237
+ size, pos, yaw = pose(f)
238
+ local = (points - pos) @ rotation(yaw)
239
+ expected_lo = -size / 2 if center_z else np.array([-size[0] / 2, -size[1] / 2, 0])
240
+ np.testing.assert_allclose(local.min(0), expected_lo, atol=1e-8)
241
+ np.testing.assert_allclose(local.max(0), expected_lo + size, atol=1e-8)
242
+
243
+
244
+ @pytest.mark.parametrize('source,uid,raw', AUDITED_BOXES, ids=[r[1] for r in AUDITED_BOXES])
245
+ def test_all_audited_retained_boxes_enclose_rotated_corners(source, uid, raw):
246
+ before = copy.deepcopy(raw)
247
+ if source == 'Structured3D':
248
+ f = structured3d._furniture(raw)
249
+ R, size = np.asarray(raw['raw_basis']).T, 2 * np.abs(raw['raw_coeffs']) / 1000
250
+ center = np.array([raw['position'][k] for k in 'xyz'])
251
+ elif source == 'HSSD200':
252
+ f = hssd200._furniture(raw, 0)
253
+ R = hssd200.T @ quaternion_matrix(json.loads(raw['obb_rotation_wxyz']))
254
+ size = 2 * np.asarray(json.loads(raw['obb_half_extents']))
255
+ center = hssd200.T @ np.asarray(json.loads(raw['obb_center']))
256
+ else:
257
+ f = multiscan.box(raw)
258
+ R = np.asarray(json.loads(raw['obb_axes'])).reshape(3, 3).T
259
+ size = 2 * np.asarray(json.loads(raw['obb_half_extents']))
260
+ center = np.asarray(json.loads(raw['obb_center']))
261
+ assert_envelope(f, corners(R, size, center), source != 'MultiScan')
262
+ assert raw == before
263
+
264
+
265
+ def source_row(source, R, size, center, category='table', identity='1'):
266
+ """Encode one semantic-front X / up Z box in each source's actual axis convention."""
267
+ if source == 'Structured3D':
268
+ return {'category': category, 'instance_id': identity, 'position': dict(zip('xyz', center)),
269
+ 'raw_basis': [R[:, 1].tolist(), (-R[:, 0]).tolist(), R[:, 2].tolist()],
270
+ 'raw_coeffs': (np.asarray(size)[[1, 0, 2]] * 500).tolist()}
271
+ C = R[:, [1, 2, 0]] # asset +Z front, +Y up
272
+ row = {'category': category, 'obb_half_extents': json.dumps((np.asarray(size)[[1, 2, 0]] / 2).tolist())}
273
+ if source == 'HSSD200':
274
+ M = hssd200.T.T @ C
275
+ w = math.sqrt(1 + np.trace(M)) / 2
276
+ q = [w, (M[2, 1] - M[1, 2]) / (4 * w), (M[0, 2] - M[2, 0]) / (4 * w),
277
+ (M[1, 0] - M[0, 1]) / (4 * w)]
278
+ return {**row, 'scene_id': 'synthetic', 'instance_id': identity,
279
+ 'obb_center': json.dumps((hssd200.T.T @ center).tolist()), 'obb_rotation_wxyz': json.dumps(q)}
280
+ return {**row, 'object_id': identity, 'obb_center': json.dumps(list(center)),
281
+ 'obb_axes': json.dumps(C.T.flatten().tolist()), 'front': json.dumps(R[:, 0].tolist()),
282
+ 'is_architectural': 'False', 'is_opening': 'False'}
283
+
284
+
285
+ def furniture(source, row, floor=0):
286
+ if source == 'Structured3D':
287
+ return structured3d._furniture(row)
288
+ if source == 'HSSD200':
289
+ return hssd200._furniture(row, floor)
290
+ return multiscan.box(row)
291
+
292
+
293
+ @pytest.mark.parametrize('source', ['Structured3D', 'HSSD200', 'MultiScan'])
294
+ @pytest.mark.parametrize('yaw', [-1.2, 0.7, 2.1])
295
+ def test_tilt_preserves_horizontal_front_and_exact_center(source, yaw):
296
+ R, size, center = rotation(yaw, 0.4), np.array([1.4, 0.8, 0.5]), np.array([10., 20., 2.])
297
+ f = furniture(source, source_row(source, R, size, center))
298
+ assert_envelope(f, corners(R, size, center), source != 'MultiScan')
299
+ assert math.cos(pose(f)[2] - yaw) == pytest.approx(1)
300
+ assert f['furniture_rotation']['x'] != 0
301
+ assert f.get('front_known') is not False
302
+
303
+
304
+ @pytest.mark.parametrize('source', ['Structured3D', 'HSSD200', 'MultiScan'])
305
+ def test_upright_known_front_unchanged(source):
306
+ R, size, center = rotation(0.7), np.array([1.4, 0.8, 0.5]), np.array([10., 20., 2.])
307
+ f = furniture(source, source_row(source, R, size, center))
308
+ assert_envelope(f, corners(R, size, center), source != 'MultiScan')
309
+ np.testing.assert_allclose(pose(f)[0], size)
310
+ assert pose(f)[2] == pytest.approx(0.7)
311
+ assert f['furniture_rotation']['x'] == 0
312
+
313
+
314
+ @pytest.mark.parametrize('source', ['Structured3D', 'MultiScan'])
315
+ def test_vertical_front_has_geometry_but_no_claimed_heading(source):
316
+ R = rotation(0.7, pitch=math.pi / 2)
317
+ size, center = np.array([1.4, 0.8, 0.5]), np.array([10., 20., 2.])
318
+ f = furniture(source, source_row(source, R, size, center))
319
+ assert_envelope(f, corners(R, size, center), source != 'MultiScan')
320
+ assert f.get('front_known') is False or f.get('front_vertical') is True
321
+
322
+
323
+ ARGS = SimpleNamespace(boundary_types=['polygon', 'hull'], source_anchors={}, anchors=['floor', 'object'],
324
+ min_objects=1, max_vertices=0, oob_tol=.1, hidden_max=.3, reject_flagged=[])
325
+
326
+
327
+ def test_hssd_floor_origin_and_support_use_true_envelope():
328
+ R, size, floor = rotation(.7, .4), np.array([1.4, .8, .5]), 7.25
329
+ height = np.ptp(corners(R, size, np.zeros(3))[:, 2])
330
+ table = hssd200._furniture(source_row('HSSD200', R, size, np.array([12., 22., floor + height / 2])), floor)
331
+ book = hssd200._furniture(source_row('HSSD200', rotation(.7), np.array([.2, .2, .1]),
332
+ np.array([12., 22., floor + height + .05]), 'book', '2'), floor)
333
+ room = {'room_id': 'r', 'room_boundary': [[10, 20], [15, 20], [15, 25], [10, 25]],
334
+ 'room_height': 3, 'furniture': [table, book]}
335
+ ir = convert_room(room, source='HSSD200', uid='r', group='g', boundary_type='polygon', center_z=True,
336
+ meta={'front_known': True})
337
+ assert ir['objects'][0]['pos'] == pytest.approx([2, 2, 0])
338
+ built, why = prep(ir, ARGS)
339
+ assert why is None
340
+ child = next(o for o in built['objects'] if o['id'] == 'synthetic:2')
341
+ assert child['anchor'] == 'object' and child['parent'] == 'synthetic:1'
342
+ assert child['pos'][2] == round(height, 2)
343
+
344
+
345
+ def test_structured_false_floor_obstacle_is_removed_after_preparation():
346
+ raw = AUDITED_BOXES[0][2] # structured3d_00160_room_584 / instance 50
347
+ chair = source_row('Structured3D', rotation(0), np.array([.6, .6, 1.]),
348
+ np.array([6., 6., .5]), 'chair', 'control')
349
+ record = {'sample_id': 'bounded', 'provenance': {'source_house_id': 'test', 'split': 'train'},
350
+ 'room_origin_house_m': [0, 0, 0],
351
+ 'room_context': {'room_id': 'r', 'room_type': 'office', 'ceiling_height_m': 3,
352
+ 'floor_polygon': [[0, 0], [10, 0], [10, 10], [0, 10]]},
353
+ 'layout': {'floor_layout': {'objects': [raw, chair]}}}
354
+ ir = structured3d.convert(record)
355
+ false_obstacle = ir['objects'][0]
356
+ assert false_obstacle['pos'][2] == pytest.approx(.4, abs=1e-6)
357
+ assert false_obstacle['size'][2] == pytest.approx(.065, abs=1e-6)
358
+ assert false_obstacle['front_known'] is False
359
+ built, why = prep(ir, ARGS)
360
+ assert why is None
361
+ assert [o['id'] for o in built['objects']] == ['control']
362
+ assert not built['fixed']
363
+
364
+
365
+ def csv_stream(rows):
366
+ stream = io.StringIO()
367
+ writer = csv.DictWriter(stream, fieldnames=list(rows[0]))
368
+ writer.writeheader()
369
+ writer.writerows(rows)
370
+ stream.seek(0)
371
+ return stream
372
+
373
+
374
+ def test_hssd_load_keeps_tilt_envelope_floor_and_front_metadata():
375
+ floor = 7.25
376
+ R, size = rotation(.7, .4), np.array([1.4, .8, .5])
377
+ height = np.ptp(corners(R, size, np.zeros(3))[:, 2])
378
+ center = np.array([12., 22., floor + height / 2])
379
+ raw = source_row('HSSD200', R, size, center)
380
+ row = {**raw, 'instance_kind': 'rigid', 'bbox_source': 'mesh', 'region_id': '0',
381
+ 'region_source': 'inside', 'is_architectural': 'False',
382
+ 'aabb_max': json.dumps((corners(R, size, center) @ hssd200.T).max(0).tolist())}
383
+ region = {'scene_id': 'synthetic', 'region_id': '0', 'room_type': 'office', 'region_name': 'test',
384
+ 'poly_loop_xz': '[[10,-20],[15,-20],[15,-25],[10,-25]]',
385
+ 'floor_height': floor, 'ceiling_height': floor + 3, 'extrusion_height': 3}
386
+ files = {'objects.csv': [row], 'regions.csv': [region],
387
+ 'asset_catalog.csv': [{'template_name': 'unused', 'local_bbox_min': ''}]}
388
+ with patch('builtins.open', side_effect=lambda path: csv_stream(files[path.rsplit('/', 1)[-1]])):
389
+ ir, = hssd200.load('unused')
390
+ obj, = ir['objects']
391
+ assert obj['pos'] == pytest.approx([2, 2, 0])
392
+ assert obj['yaw'] == pytest.approx(.7)
393
+ assert obj['size'][2] == pytest.approx(height)
394
+ assert obj['tilted'] and 'rot_wxyz_yup' in obj
395
+ assert ir['meta']['floor_height'] == floor
396
+
397
+
398
+ def test_multiscan_load_uses_source_floor_and_propagates_unknown_front():
399
+ floor, R, size = -3.25, rotation(.7, pitch=math.pi / 2), np.array([.8, .6, .4])
400
+ row = {**source_row('MultiScan', R, size, np.array([12., 22., floor + .4])),
401
+ 'scan_id': 'scene_9_0', 'aabb_min': json.dumps([11, 21, floor])}
402
+ files = {'objects.csv': [row], 'relations.csv': [{'scan_id': 'scene_9_0', 'subject_id': '1',
403
+ 'relation': 'rests_on_floor'}],
404
+ 'scans.csv': [{'scan_id': 'scene_9_0', 'scene_id': 'scene_9', 'room_type': 'office', 'device': 'test'}],
405
+ 'regions.csv': [{'scan_id': 'scene_9_0', 'floor_height': floor + .2, 'ceiling_height': floor + 3,
406
+ 'height_reliable': 'True', 'poly_source': 'floor', 'wall_segments': '[]',
407
+ 'poly_loop': '[[10,20],[15,20],[15,25],[10,25]]'}]}
408
+ with patch('builtins.open', side_effect=lambda path: csv_stream(files[path.rsplit('/', 1)[-1]])):
409
+ ir, = multiscan.load('unused')
410
+ assert ir['height'] == 3
411
+ assert ir['meta']['floor_z'] == floor
412
+ assert ir['objects'][0]['pos'] == pytest.approx([2, 2, 0])
413
+ assert ir['objects'][0].get('front_known') is False
414
+
415
+
416
+ def interior_box(identity, xy, category='chair'):
417
+ pts = [[xy[0] + x, xy[1] + y, z] for z in (0., 1.)
418
+ for x, y in ((.2, -.2), (-.2, -.2), (-.2, .2), (.2, .2))]
419
+ return {'furniture_category': category, 'furniture_instance_id': identity,
420
+ 'furniture_position': {'x': 999, 'y': 999, 'z': 999},
421
+ 'source_fields': {'bounding_box': [dict(zip('xyz', p)) for p in pts]}}
422
+
423
+
424
+ def interior_room(identity, ring, furn=()):
425
+ return {'room_id': identity, 'room_boundary': ring, 'room_height': 3, 'furniture': list(furn)}
426
+
427
+
428
+ def load_interior(scene):
429
+ with patch.object(interiorgs, 'iter_records', return_value=iter([scene])):
430
+ return list(interiorgs.load('unused'))
431
+
432
+
433
+ def test_interior_recovers_only_unique_bbox_centers_without_mutating_assignments():
434
+ # Repeated closing/zero-length edges triggered the upstream ray-cast defect.
435
+ left = [[10, 20], [12, 20], [12, 22], [10, 22], [10, 22], [10, 20]]
436
+ right = [[12, 20], [14, 20], [14, 22], [12, 22]]
437
+ original = interior_box('already', [11, 21])
438
+ candidates = [interior_box('left', [11.5, 21]), interior_box('right', [13, 21]),
439
+ interior_box('shared', [12, 21]), interior_box('outside', [100, 100]),
440
+ interior_box('already', [13, 21]),
441
+ {**interior_box('invalid', [11, 21]), 'source_fields': {'bounding_box': []}}]
442
+ scene = {'scene_id': 'arbitrary-house', 'rooms': [interior_room('west', left, [original]),
443
+ interior_room('east', right)], 'unassigned_objects': candidates}
444
+ before = copy.deepcopy(scene)
445
+ rooms = load_interior(scene)
446
+ assert [[o['id'] for o in r['objects']] for r in rooms] == [['already', 'left'], ['right']]
447
+ assert rooms[0]['objects'][1]['pos'] == pytest.approx([1.5, 1, 0])
448
+ recovery = rooms[0]['meta']['membership_recovery']
449
+ assert recovery['scene_recovered'] == 2
450
+ assert recovery['scene_unresolved'] == {'ambiguous': ['shared'], 'outside': ['outside'], 'invalid_bbox': ['invalid']}
451
+ assert recovery['scene_already_assigned'] == ['already']
452
+ assert scene == before
453
+ assert all(prep(room, ARGS)[1] is None for room in rooms)
454
+
455
+
456
+ def test_interior_does_not_pick_first_overlapping_room_or_nearest_room():
457
+ ring = [[0, 0], [4, 0], [4, 4], [0, 4]]
458
+ scene = {'scene_id': 'overlap', 'rooms': [interior_room('a', ring), interior_room('b', ring)],
459
+ 'unassigned_objects': [interior_box('both', [1, 1]), interior_box('neither', [5, 5])]}
460
+ for r in load_interior(scene):
461
+ assert not r['objects']
462
+ assert r['meta']['membership_recovery']['scene_unresolved'] == {
463
+ 'ambiguous': ['both'], 'outside': ['neither']}
464
+
465
+
466
+ @pytest.mark.parametrize('bad', [None, [], [[0, 0], [1, 1], [1, 0], [0, 1]]])
467
+ def test_interior_invalid_boundary_prevents_unproven_unique_assignment(bad):
468
+ scene = {'scene_id': 'bad-ring', 'rooms': [interior_room('valid', [[0, 0], [4, 0], [4, 4], [0, 4]]),
469
+ interior_room('invalid', bad)],
470
+ 'unassigned_objects': [interior_box('uncertain', [1, 1])]}
471
+ for r in load_interior(scene):
472
+ assert not r['objects']
473
+ assert r['meta']['membership_recovery']['scene_unresolved'] == {'invalid_boundary': ['uncertain']}
474
+
475
+
476
+ def test_interior_duplicate_unassigned_ids_stay_unresolved():
477
+ scene = {'scene_id': 'duplicate', 'rooms': [interior_room('a', [[0, 0], [4, 0], [4, 4], [0, 4]])],
478
+ 'unassigned_objects': [interior_box('repeat', [1, 1]), interior_box('repeat', [2, 2])]}
479
+ r, = load_interior(scene)
480
+ assert not r['objects']
481
+ assert r['meta']['membership_recovery']['scene_unresolved'] == {'duplicate_id': ['repeat', 'repeat']}
482
+
483
+
484
+ def test_interior_assigned_only_scene_is_unchanged():
485
+ room = interior_room('a', [[10, 20], [14, 20], [14, 24], [10, 24]], [interior_box('kept', [12, 22])])
486
+ scene = {'scene_id': 'stable', 'rooms': [room]}
487
+ ir, = load_interior(scene)
488
+ assert ir == load_interior({**scene, 'unassigned_objects': []})[0]
489
+ assert 'membership_recovery' not in ir['meta']
490
+ assert ir['objects'][0]['pos'] == pytest.approx([2, 2, 0])
491
+ assert len(ir['objects']) == 1
code/fastfill/tests/test_build_repairs.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build failures must never replace a published dataset."""
2
+ import argparse
3
+ import copy
4
+ import json
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ import pytest
9
+
10
+ from fastfill import build
11
+
12
+
13
+ @pytest.fixture
14
+ def room():
15
+ return {"uid": "fixture:one", "source": "Fixture", "group": "fixture:one",
16
+ "meta": {"front_known": True}, "boundary_type": "polygon",
17
+ "boundary": [[0, 0], [5, 0], [5, 5], [0, 5]], "height": 3,
18
+ "objects": [{"id": "a", "category": "chair", "size": [1, 1, 1],
19
+ "pos": [2, 2, 0], "yaw": 0, "anchor": "floor", "parent": None}]}
20
+
21
+
22
+ def invoke(monkeypatch, ir, out, *extra):
23
+ monkeypatch.setattr(sys, "argv", ["build", "--ir", str(ir), "--out", str(out), *extra])
24
+ build.main()
25
+
26
+
27
+ def inputs(tmp_path, room):
28
+ ir = tmp_path / "ir"
29
+ ir.mkdir()
30
+ (ir / "Fixture.jsonl").write_text(json.dumps(room) + "\n")
31
+ return ir
32
+
33
+
34
+ @pytest.mark.parametrize("case", ["missing", "empty", "unknown_source", "rejected"])
35
+ def test_bad_input_preserves_existing_dataset(tmp_path, monkeypatch, room, case):
36
+ ir = inputs(tmp_path, room)
37
+ out = tmp_path / "existing"
38
+ out.mkdir()
39
+ target = out / "train.jsonl"
40
+ target.write_bytes(b"existing training data\n")
41
+ extra = []
42
+ if case == "missing":
43
+ ir = tmp_path / "missing"
44
+ elif case == "empty":
45
+ (ir / "Fixture.jsonl").write_text("")
46
+ elif case == "unknown_source":
47
+ extra = ["--sources", "Absent"]
48
+ else:
49
+ room["meta"]["front_known"] = False
50
+ (ir / "Fixture.jsonl").write_text(json.dumps(room) + "\n")
51
+ with pytest.raises((SystemExit, ValueError, FileExistsError)):
52
+ invoke(monkeypatch, ir, out, *extra)
53
+ assert target.read_bytes() == b"existing training data\n"
54
+ assert list(out.iterdir()) == [target]
55
+
56
+
57
+ def test_valid_input_does_not_overwrite_existing_dataset(tmp_path, monkeypatch, room):
58
+ ir = inputs(tmp_path, room)
59
+ out = tmp_path / "existing"
60
+ out.mkdir()
61
+ (out / "sentinel").write_bytes(b"preserve")
62
+ with pytest.raises((SystemExit, ValueError, FileExistsError)):
63
+ invoke(monkeypatch, ir, out)
64
+ assert (out / "sentinel").read_bytes() == b"preserve"
65
+
66
+
67
+ def test_failed_write_leaves_no_partial_output(tmp_path, monkeypatch, room):
68
+ ir = inputs(tmp_path, room)
69
+ out = tmp_path / "new-version"
70
+ def fail(*args, **kwargs):
71
+ raise RuntimeError("fixture serialization failure")
72
+ monkeypatch.setattr(build, "messages", fail)
73
+ with pytest.raises(RuntimeError, match="fixture serialization failure"):
74
+ invoke(monkeypatch, ir, out)
75
+ assert not out.exists()
76
+ assert not list(tmp_path.glob(".new-version-*"))
77
+
78
+
79
+ def test_build_writes_complete_manifest_and_does_not_mutate_input(tmp_path, monkeypatch, room):
80
+ ir = inputs(tmp_path, room)
81
+ before = (ir / "Fixture.jsonl").read_bytes()
82
+ out = tmp_path / "new-version"
83
+ invoke(monkeypatch, ir, out)
84
+ manifest = json.loads((out / "MANIFEST.json").read_text())
85
+ assert sum(manifest["files"][f"{s}.jsonl"]["lines"] for s in ["train", "dev", "test"]) == 1
86
+ assert set(manifest["ir_sha256"]) == {"Fixture.jsonl"}
87
+ # Audit/test directories are excluded. A production module such as
88
+ # v2/review_data.py is code and must remain fingerprinted.
89
+ assert not any(part.startswith("review") or part == "tests"
90
+ for p in manifest["code_sha256"] for part in Path(p).parts[:-1])
91
+ assert (ir / "Fixture.jsonl").read_bytes() == before
92
+
93
+
94
+ @pytest.mark.parametrize("case", ["empty_selection", "empty_file", "all_rejected", "malformed_json", "invalid_fraction"])
95
+ def test_invalid_build_does_not_create_destination(tmp_path, monkeypatch, room, case):
96
+ ir = inputs(tmp_path, room)
97
+ out = tmp_path / "new-version"
98
+ extra = []
99
+ if case == "empty_selection":
100
+ extra = ["--sources"]
101
+ elif case == "empty_file":
102
+ (ir / "Fixture.jsonl").write_text("")
103
+ elif case == "all_rejected":
104
+ room["meta"]["front_known"] = False
105
+ (ir / "Fixture.jsonl").write_text(json.dumps(room) + "\n")
106
+ elif case == "malformed_json":
107
+ (ir / "Fixture.jsonl").write_text("{broken\n")
108
+ else:
109
+ extra = ["--dev", ".8", "--test", ".8"]
110
+ with pytest.raises((SystemExit, ValueError)):
111
+ invoke(monkeypatch, ir, out, *extra)
112
+ assert not out.exists()
113
+
114
+
115
+ def test_ir_change_during_build_is_not_published(tmp_path, monkeypatch, room):
116
+ ir = inputs(tmp_path, room)
117
+ out = tmp_path / "new-version"
118
+ original_write = build._write_dataset
119
+ def change_input(*args):
120
+ original_write(*args)
121
+ (ir / "Fixture.jsonl").write_text("changed while building\n")
122
+ monkeypatch.setattr(build, "_write_dataset", change_input)
123
+ with pytest.raises(RuntimeError, match="IR changed during build"):
124
+ invoke(monkeypatch, ir, out)
125
+ assert not out.exists()
126
+
127
+
128
+ @pytest.mark.parametrize("module,aborts", [("validate.py", True), ("serve.py", False)])
129
+ def test_manifest_distinguishes_build_dependency_edits(tmp_path, monkeypatch, room, module, aborts):
130
+ ir = inputs(tmp_path, room)
131
+ out = tmp_path / "new-version"
132
+ original_sha = build._sha256
133
+ calls = 0
134
+ def edited_hash(path):
135
+ nonlocal calls
136
+ if str(path).endswith("/" + module):
137
+ calls += 1
138
+ if calls > 1:
139
+ return "0" * 64
140
+ return original_sha(path)
141
+ monkeypatch.setattr(build, "_sha256", edited_hash)
142
+ if aborts:
143
+ with pytest.raises(RuntimeError, match="implementation changed"):
144
+ invoke(monkeypatch, ir, out)
145
+ assert not out.exists()
146
+ else:
147
+ invoke(monkeypatch, ir, out)
148
+ manifest = json.loads((out / "MANIFEST.json").read_text())
149
+ assert manifest["code_sha256"][module] == "0" * 64
150
+ assert manifest["build_start_code_sha256"][module] != "0" * 64
151
+
152
+
153
+ @pytest.mark.parametrize("split", ["dev", "test"])
154
+ def test_eval_sidecars_roundtrip_and_constraints_hold(tmp_path, monkeypatch, room, split):
155
+ from fastfill.scene import messages
156
+ from fastfill.validate import holds
157
+ room["objects"] += [{"id": "b", "category": "table", "size": [.8, .8, .7],
158
+ "pos": [3, 2, 0], "yaw": 0, "anchor": "floor", "parent": None}]
159
+ ir = inputs(tmp_path, room)
160
+ out = tmp_path / "new-version"
161
+ monkeypatch.setattr(build, "assign_splits", lambda rooms, dev, test: [split] * len(rooms))
162
+ invoke(monkeypatch, ir, out)
163
+ row = json.loads((out / f"{split}.jsonl").read_text())
164
+ reference = json.loads((out / f"{split}_rooms.jsonl").read_text())
165
+ constrained = json.loads((out / f"{split}_constrained_rooms.jsonl").read_text())
166
+ assert row["messages"] == messages(reference)
167
+ assert constrained["constraints"]
168
+ assert all(holds(c, reference["objects"], reference["boundary"]) for c in constrained["constraints"])
169
+
170
+
171
+ def test_training_constraints_survive_augmentation_and_serialization(tmp_path, monkeypatch, room):
172
+ from fastfill.scene import parse
173
+ from fastfill.validate import holds
174
+ room["objects"] += [
175
+ {"id": "b", "category": "table", "size": [.8, .8, .7], "pos": [3, 2, 0],
176
+ "yaw": 0, "anchor": "floor", "parent": None},
177
+ {"id": "c", "category": "vase", "size": [.2, .2, .3], "pos": [3, 2, .7],
178
+ "yaw": 0, "anchor": "object", "parent": "b"}]
179
+ ir = inputs(tmp_path, room)
180
+ out = tmp_path / "new-version"
181
+ invoke(monkeypatch, ir, out, "--dev", "0", "--test", "0", "--constraint_frac", "1",
182
+ "--field_dropout", "0", "--desc_dropout", "0")
183
+ row = json.loads((out / "train.jsonl").read_text())
184
+ prompt = json.loads(row["messages"][1]["content"])
185
+ placements, error = parse(row["messages"][2]["content"], [o["id"] for o in prompt["objects"]])
186
+ assert error is None
187
+ objects = [{**o, "pos": placements[o["id"]]["pos"], "yaw": placements[o["id"]]["yaw"],
188
+ "parent": placements[o["id"]].get("on")} for o in prompt["objects"]]
189
+ assert prompt["constraints"]
190
+ assert any(c[0] == "on" for c in prompt["constraints"])
191
+ assert all(holds(c, objects, prompt["boundary"]) for c in prompt["constraints"])
192
+
193
+
194
+ @pytest.mark.parametrize("field,value", [
195
+ ("boundary", [[0, 0], [5, 0], [5, float("nan")], [0, 5]]),
196
+ ("boundary", [[0, 0], [5, 5]]),
197
+ ("boundary", [[0, 0], [0, 0], [0, 0]]),
198
+ ("boundary", [[0, 0], [5, 0, 0], [5, 5], [0, 5]]),
199
+ ("height", float("inf")), ("height", -2), ("height", "3"),
200
+ ("height", 10 ** 400),
201
+ ("boundary", [[0, 0], [10 ** 400, 0], [5, 5], [0, 5]]),
202
+ ])
203
+ def test_malformed_room_geometry_is_rejected(room, field, value):
204
+ args = argparse.Namespace(boundary_types=["polygon", "hull"], source_anchors={},
205
+ anchors=["floor", "object"], min_objects=1, max_vertices=0,
206
+ oob_tol=.1, hidden_max=.3, reject_flagged=[])
207
+ original = copy.deepcopy(room)
208
+ result, reason = build.prep({**room, field: value}, args)
209
+ assert result is None
210
+ assert reason in {"boundary_shape", "bad_numbers"}
211
+ assert room == original