File size: 48,155 Bytes
e6f80f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
"""WorldEdge request (scope doc, Appendix B) <-> FastFill model text.

    room, ctx = request_to_room(request)
    prompt = prompt_text(tok, messages(room, room["constraints"], with_target=False))
    ... one generation call ...
    result = placements_from_text(text, room, ctx)

Model side (scene.py): room frame with the boundary AABB min corner at (0, 0), object local +X = semantic front,
integer-degree yaw inside the text. Interface side: the request's own frame (boundary_xy, floor_z), position_m of
the box bottom center, yaw_rad about +Z of the object's own local frame, support_parent = floor_id or the id of the
object it stands on, support_surface = "top". Objects anchored to walls or ceilings ("anchor": "wall" | "ceiling")
and fronts that are not along a horizontal local axis are returned as unsupported instead of being sent to the model,
and so is any constraint that names one of them.
Floor/object anchors are enforced after generation and reported in validation.constraints as ["anchor", id, kind],
with hard=true, trained=false, model_conditioned=false: the existing model protocol cannot express them.
room.fixed_geometry (optional): boxes already in the room that nothing may be placed on or through (doors, windows,
columns, stairs, furniture that stays), each {"id", "category"?, "size_xyz_m", "position_m" (bottom center),
"yaw_rad"?, "fixed_kind"?}; shown to the model as `fixed`, never placed, and overlaps with them are reported in validation
(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
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
and is reported in unsupported as "fixed_ignored". The wall band is 0.30 m for fixed_kind="structure", otherwise
0.10 m; if fixed_kind is omitted, anchors.is_structure(category) selects the band. An explicit kind takes precedence.
A constraint may not name a fixed box (v1 relates objects to place).

layout_constraints (Appendix B names). Relational constraints are shown to the model as must-hold; after repair a failed
"hard" one (the default; "hard": false marks a soft one, which is only reported) fails the layout:
    {"type": "faces", "subject", "target"}         {"type": "between", "subject", "anchors": [a, b]}
    {"type": "supported_by", "subject", "parent", "surface": "top"}      {"type": "against_wall", "subject"}
    {"type": "near", "subject", "target", "max_gap_m"}   footprint gap, NOT centre distance (trained on 0.1-0.5)
    {"type": "keepout", "polygon_xy": [[x, y], ...]}      hard: cut out of the floor, padded by KEEPOUT_PAD
Soft keepouts leave the floor unchanged. They are reported as ["keepout", polygon_xy] in request coordinates,
with hard=false, trained=false, model_conditioned=false; the existing model protocol has no advisory region field.
A soft keepout holds when no placed footprint overlaps it by more than 1e-9 square meters (touching is allowed).
against_wall is accepted against the real walls, not keepout cuts, although the model sees a cut as wall.
A padded keepout that stops less than WALL_SNAP short of a wall (or of another keepout) is snapped to it: the strip
left between them is dropped from the floor the model sees (cm rounding folded one under 5 mm into a self-intersection,
a bad_boundary 422, for 35 of 20,000 EmbodiedGen-shaped requests); floor cut off behind such a gap is a pocket, and a
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
to a large corner, a keepout crossing a wall by under 5 mm) is keepout_not_representable, not bad_boundary.
Forms training never contains (build.extract_constraints) are still shown and checked as asked, but reported with
"trained": false in validation: against_wall in a hull room, faces whose subject's category head is not in
build.FRONT, against_wall / near / faces / between naming an object that stands on another, between with anchors of
two categories, an argument (of supported_by: the subject) that is a FLAT covering or under 5 cm tall.
Rejected with ValueError (HTTP 422 in serve.py): malformed or non-finite numbers, a coordinate_system other than
m / right / Z / bbox_bottom_center / rad, duplicate ids, unknown anchors, non-positive sizes or height, constraints
naming unknown objects, fixed boxes or themselves, 'on' cycles or two parents, a keepout that splits the room or leaves an
island (enclosed pockets under POCKET_M2 are treated as unusable floor).
"""
import copy
import math

from shapely.affinity import translate
from shapely.errors import ShapelyError
from shapely.geometry import Point, Polygon
from shapely.ops import nearest_points, unary_union

from fastfill.anchors import FIXED_MAX_Z, FLAT, FLOOR_Z, is_structure
from fastfill.build import FRONT, THICK_WALL_M, WALL_BAND_M

FLAT_Z = 0.15        # = build.FLAT_Z: a sunk fixed box whose top stays under this is not shown (checked in __main__)
from fastfill.scene import apply, canonical, clean_boundary, footprint, head, norm_cat, num, parse
from fastfill.validate import check, holds, support_contains

HULL_SOURCES = {"convex_hull", "hull", "scan_hull", "floor_hull"}
KEEPOUT_PAD = 0.08   # < validate.check's oob tolerance (0.10) minus cm rounding: repair onto the keepout edge stays valid
POCKET_M2 = 0.5      # free floor enclosed by keepouts and walls smaller than this is dropped, not a reason to refuse
WALL_SNAP = 0.01     # a strip of floor narrower than this left by a padded keepout (at a wall or keepout) is dropped
ANCHORS = {None, "floor", "object", "wall", "ceiling"}
COORDS = {"units": "m", "handedness": "right", "up_axis": "Z", "position_reference": "bbox_bottom_center",
          "yaw_units": "rad"}
SNAP_TOL = 0.10      # training layouts cross walls by up to this much (40% of test rooms by > 1 mm); EmbodiedGen allows 1 mm
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),
                   key=lambda d: d[0] ** 2 + d[1] ** 2)


def request_to_room(req):
    """-> (canonical model room with constraints, ctx for mapping the answer back). Raises ValueError on bad input."""
    try:
        return _request_to_room(req)
    except (KeyError, TypeError, IndexError, AttributeError, OverflowError, ShapelyError) as e:
        raise ValueError(f"bad_request: {e!r}") from e


def _finite(v, lo=None, above=None):
    if not (isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) and abs(v) < 1e4
            and (lo is None or v >= lo) and (above is None or v > above)):
        raise ValueError(f"bad_number: {v!r}")
    return float(v)


def _text(v, optional=True):
    if not (isinstance(v, str) or (optional and v is None)):
        raise ValueError(f"bad_text: {v!r}")
    return v


def _request_to_room(req):
    rm = req["room"]
    for k, v in (req.get("coordinate_system") or {}).items():
        if k in COORDS and v != COORDS[k]:
            raise ValueError(f"unsupported coordinate_system {k}={v!r} (FastFill speaks {COORDS})")
    cons_in = req.get("layout_constraints") or []
    pts = rm["boundary_xy"]
    keepout_constraints = [c for c in cons_in if c["type"] == "keepout"]
    for c in keepout_constraints:
        if not isinstance(c.get("hard", True), bool):
            raise ValueError(f"malformed constraint: {c}")
    for p in [q for poly in [pts, *(c["polygon_xy"] for c in keepout_constraints)] for q in poly]:
        _finite(p[0]), _finite(p[1])
    for c in keepout_constraints:
        poly = Polygon(c["polygon_xy"])
        if not poly.is_valid or poly.is_empty or poly.area <= 0:
            raise ValueError("bad_keepout")
    keepouts = [c["polygon_xy"] for c in keepout_constraints if c.get("hard", True)]
    if rm.get("height_m") is not None:
        _finite(rm["height_m"], above=0)
    _text(rm.get("room_type"))
    walls = Polygon(pts)                    # against_wall is checked against these, not against keepout cuts
    floor = walls                           # what answers are snapped into: the walls minus the keepouts themselves
    if keepouts:        # cut out, padded, from what the model sees: it treats a keepout as wall
        if not floor.is_valid:
            raise ValueError("bad_boundary")
        cut = unary_union([Polygon(k) for k in keepouts])
        seen = floor.difference(cut.buffer(KEEPOUT_PAD, join_style="mitre"))
        # opening by WALL_SNAP / 2: strips under WALL_SNAP wide vanish, everything wider comes back edge for edge; the
        # intersection clips the mitre corners that re-grow past a slanted wall where a strip vanished. A gap whose
        # closing alone would cut off POCKET_M2 or more stays open (9 of 20,000 EmbodiedGen-shaped requests: two
        # padded keepouts 5-10 mm apart in front of a corner): the corner behind it stays floor, as without the snap
        snap = seen.intersection(seen.buffer(-WALL_SNAP / 2, join_style="mitre").buffer(WALL_SNAP / 2, join_style="mitre"))
        big = lambda g: sum(q.area >= POCKET_M2 for q in getattr(g, "geoms", [g]))
        seen = seen if big(snap) > 1 and big(seen) == 1 else snap
        parts = sorted(getattr(seen, "geoms", [seen]), key=lambda g: -g.area)
        pockets = [g for g in parts[1:] if g.area < POCKET_M2]
        if seen.is_empty or len(parts) - len(pockets) != 1 or parts[0].interiors:
            raise ValueError("keepout_not_representable")      # fills or splits the room, or leaves an island
        seen = parts[0]
        floor = floor.difference(unary_union([cut, *pockets]))
        pts = seen.exterior.coords[:-1]
    x0, y0 = (min(p[i] for p in pts) for i in (0, 1))
    boundary = clean_boundary([[x - x0, y - y0] for x, y in pts])
    if boundary is None:    # walls that round cleanly: the cut folds under cm rounding (a sub-5 mm gap or wall mouth)
        wx, wy = walls.bounds[:2]
        ok = keepouts and clean_boundary([[x - wx, y - wy] for x, y in walls.exterior.coords[:-1]])
        raise ValueError("keepout_not_representable" if ok else "bad_boundary")
    ctx = {"x0": x0, "y0": y0, "floor_z": _finite(rm.get("floor_z", 0.0)),
           "floor_id": _text(rm.get("floor_id", "floor"), optional=False),
           "floor": translate(floor, -x0, -y0), "walls": [[x - x0, y - y0] for x, y in walls.exterior.coords[:-1]],
           "soft_keepouts": [(copy.deepcopy(c["polygon_xy"]), translate(Polygon(c["polygon_xy"]), -x0, -y0))
                             for c in keepout_constraints if not c.get("hard", True)],
           "front_k": {}, "order": [], "unsupported": []}
    fixed, fixed_ids = [], set()
    near, wall = walls.buffer(WALL_BAND_M), walls.buffer(THICK_WALL_M)
    for f in rm.get("fixed_geometry") or []:
        _text(f["id"], optional=False), _text(f.get("category"))
        kind = _text(f.get("fixed_kind"))
        structural = kind == "structure" if kind is not None else is_structure(f.get("category"))
        sx, sy, sz = (_finite(v, above=0) for v in f["size_xyz_m"])
        x, y, z = (_finite(v) for v in f["position_m"])
        yaw = _finite(f.get("yaw_rad", 0.0)) % (2 * math.pi)
        fixed_ids.add(f["id"])
        z -= ctx["floor_z"]
        fp = Polygon(footprint({"pos": [x, y], "size": [sx, sy], "yaw": yaw}))
        # as build.prep / anchors.fixed_geometry shaped training: nothing starting at FIXED_MAX_Z or above, no slab
        # covering half the room, nothing clear of the walls; a floor-level bottom is z=0
        if z + sz < FLAT_Z:        # a covering or trim (or wholly under the floor): not shown, as in build._keep_fixed
            ctx["unsupported"].append({"id": f["id"], "reason": "fixed_ignored"})
            continue
        if z < -FLOOR_Z:           # sunk below the floor: cut at the floor
            sz, z = z + sz, 0.0
        if z >= FIXED_MAX_Z or fp.area >= 0.5 * walls.area or not fp.intersects(wall if structural else near):
            ctx["unsupported"].append({"id": f["id"], "reason": "fixed_ignored"})
            continue
        fixed.append({"id": f["id"], "ext_id": f["id"], "category": f.get("category") or "structure",
                      "size": [sx, sy, sz], "pos": [x - x0, y - y0, 0.0 if abs(z) <= FLOOR_Z else z], "yaw": yaw})
    ids = [o["id"] for o in req["objects_to_place"]] + [f["id"] for f in rm.get("fixed_geometry") or []]
    if len(set(ids)) < len(ids) or ctx["floor_id"] in ids:
        raise ValueError("duplicate_object_id")
    objs = []
    for o in req["objects_to_place"]:
        _text(o["id"], optional=False), _text(o["category"], optional=False), _text(o.get("desc"))
        sx, sy, sz = (_finite(v, above=0) for v in o["size_xyz_m"])
        if o.get("anchor") not in ANCHORS:
            raise ValueError(f"unknown anchor: {o.get('anchor')!r}")
        fx, fy, fz = (_finite(v) for v in (o.get("semantic_front_local") or [1, 0, 0]))
        phi, h = math.atan2(fy, fx), math.hypot(fx, fy)
        k = round(phi / (math.pi / 2)) % 4
        if o.get("anchor") in ("wall", "ceiling"):
            ctx["unsupported"].append({"id": o["id"], "reason": "unsupported_anchor"})
            continue
        if h < 1e-9 or abs(fz) > 1e-3 * h or abs(math.remainder(phi - k * math.pi / 2, 2 * math.pi)) > 1e-3:
            ctx["unsupported"].append({"id": o["id"], "reason": "front_not_axis_aligned"})
            continue
        if k % 2:                                   # front along local Y: the model's local X is that axis
            sx, sy = sy, sx
        ctx["front_k"][o["id"]] = k
        ctx["order"].append(o["id"])
        objs.append({"id": o["id"], "ext_id": o["id"], "category": o["category"], "size": [sx, sy, sz],
                     "desc": o.get("desc"), "anchor": o.get("anchor")})
    room = canonical({"room_type": rm.get("room_type"), "boundary": boundary, "height": rm.get("height_m"),
                      "boundary_type": "hull" if str(rm.get("boundary_source")).lower() in HULL_SOURCES else "polygon",
                      "fixed": fixed, "objects": objs})
    m = {o["ext_id"]: o["id"] for o in room["objects"]}
    skipped = {u["id"] for u in ctx["unsupported"]}
    cons, ctx["checks"] = [], []            # what the model is shown / what the answer is accepted against
    for c in cons_in:
        t = c["type"]
        if t == "keepout":
            continue
        s = c["subject"]
        anchors = c.get("anchors", [])
        if not isinstance(anchors, list) or not isinstance(c.get("hard", True), bool):
            raise ValueError(f"malformed constraint: {c}")
        refs = [s] + [c[k] for k in ("target", "parent") if k in c] + anchors
        if any(r in fixed_ids for r in refs):
            raise ValueError(f"constraint names a fixed box (v1 constraints only relate objects to place): {c}")
        if any(r not in m and r not in skipped for r in refs):
            raise ValueError(f"constraint on an unknown object: {c}")
        if len(set(refs)) < len(refs):
            raise ValueError(f"constraint names the same object twice: {c}")
        if any(r in skipped for r in refs):         # the rest of the room is still placed
            ctx["unsupported"].append({"constraint": c, "reason": "references_unsupported_object"})
            continue
        if t == "faces":
            k = ["faces", m[s], m[c["target"]]]
        elif t == "between" and len(c["anchors"]) == 2:
            k = ["between", m[s]] + [m[a] for a in c["anchors"]]
        elif t == "supported_by" and c.get("surface", "top") == "top":
            k = ["on", m[s], m[c["parent"]]]
        elif t == "near":
            k = ["near", m[s], m[c["target"]], _finite(c["max_gap_m"], lo=0)]
        elif t == "against_wall":
            k = ["against_wall", m[s]]
        else:
            raise ValueError(f"unsupported constraint: {c}")
        # the model sees cm (near rounded DOWN, so meeting what it sees meets the request); acceptance uses the
        # request's own number; hard=false constraints are reported but do not fail the layout
        cons.append(k[:3] + [num(math.floor(k[3] * 100 + 1e-9) / 100)] if t == "near" else k)
        ctx["checks"].append((k, c.get("hard", True)))
    parent = {}
    for k in cons:
        if k[0] == "on":
            if parent.setdefault(k[1], k[2]) != k[2]:
                raise ValueError(f"{k[1]} is asked to stand on two objects")
    for x in parent:                        # an 'on' cycle can never hold
        seen, cur = {x}, parent[x]
        while cur in parent:
            if cur in seen:
                raise ValueError(f"'on' cycle through {cur}")
            seen.add(cur)
            cur = parent[cur]
    room["constraints"] = cons
    return room, ctx


def snap_inside(pl, room, floor, cons=(), walls=None):
    """Move each object that crosses `floor` (model frame) by at most SNAP_TOL back inside, with what stands on it.
    Lower objects first, so a child is judged after its parent moved; a child is only moved as far as its centre
    stays over its parent (validate.check's support rule), else it is left for validation to reject. A move is never
    taken if it would deepen the overlap of the object OR of anything standing on it (the whole support subtree
    moves with it) with a fixed box of the room (room["fixed"], at that object's height).
    The hard constraints `cons` (validate.holds, against_wall against `walls`) that held before the repair hold after
    it: if the plain pass breaks one, the repair is redone refusing every move that breaks one of them. The plain pass
    goes first because a constraint may fail only between two moves (three chairs pushed off one wall in turn: the
    between through the middle one fails until it moves too). Without this, pushing chairs off walls broke 7 GT-true
    constraints in 8,254 test_constrained rooms (faces 4, against_wall 2, near 1)."""
    size = {o["id"]: o["size"] for o in room["objects"]}
    lay = lambda q: {j: {"pos": p["pos"], "yaw": p["yaw"], "size": size[j], "parent": p["on"]} for j, p in q.items()}
    held = [c for c in cons if holds(c, None, walls, lay(pl))]
    raw = copy.deepcopy(pl)
    _push(pl, room, floor, size, [], walls)
    if not all(holds(c, None, walls, lay(pl)) for c in held):
        pl.update(raw)
        _push(pl, room, floor, size, held, walls)


def _push(pl, room, floor, size, held, walls):
    inside = floor.buffer(1e-3)             # validation()'s tolerance
    fixed = room.get("fixed") or []
    kids = {}
    for i, p in pl.items():
        if p["on"]:
            kids.setdefault(p["on"], []).append(i)
    for i in sorted(pl, key=lambda i: pl[i]["pos"][2]):
        p = pl[i]
        box = lambda dx, dy: Polygon(footprint({"pos": [p["pos"][0] + dx, p["pos"][1] + dy], "size": size[i], "yaw": p["yaw"]}))
        if inside.covers(box(0, 0)):
            continue
        q = pl.get(p["on"])
        seat = {**q, "size": size[p["on"]]} if q else None
        push = [0.0, 0.0]              # per axis, the largest move any outside corner needs to reach the boundary
        for c in box(0, 0).exterior.coords[:-1]:
            if not floor.covers(Point(c)):
                q = nearest_points(floor.boundary, Point(c))[0]
                for k, d in enumerate((q.x - c[0], q.y - c[1])):
                    if abs(d) > abs(push[k]):
                        push[k] = d
        # the object and everything standing on it move together (parse() rejects support cycles); fixed boxes at
        # each one's height (validate.check's rule): no move may deepen any of their overlaps
        tree, todo = [], [i]
        while todo:
            j = todo.pop()
            tree.append(j)
            todo += kids.get(j, [])
        at = lambda j, dx, dy: Polygon(footprint({"pos": [pl[j]["pos"][0] + dx, pl[j]["pos"][1] + dy],
                                                  "size": size[j], "yaw": pl[j]["yaw"]}))
        blocks = {j: unary_union([Polygon(footprint(f)) for f in fixed if min(
            pl[j]["pos"][2] + size[j][2], f["pos"][2] + f["size"][2]) - max(pl[j]["pos"][2], f["pos"][2]) > 0.05])
            for j in tree}
        worst = {j: at(j, 0, 0).intersection(blocks[j]).area + 1e-6 for j in tree}
        mine = [c for c in held if set(tree) & set(c[1:])]

        def keeps(dx, dy):              # every held constraint on the subtree still holds after the move
            ix = {j: {"pos": [q["pos"][0] + dx, q["pos"][1] + dy, q["pos"][2]] if j in tree else q["pos"], "yaw": q["yaw"],
                      "size": size[j], "parent": q["on"]} for j, q in pl.items()} if mine else {}
            return all(holds(c, None, walls, ix) for c in mine)
        # the nearest-boundary push, its axis parts, then any move within SNAP_TOL (1 cm grid, shortest first)
        # (a nearest-boundary push over SNAP_TOL is dropped, the grid still tries every move within SNAP_TOL: a box
        # stuck at a re-entrant corner may need a long diagonal push but a short move along one axis)
        for dx, dy in [c for c in (tuple(push), (push[0], 0), (0, push[1])) if math.hypot(*c) <= SNAP_TOL] + SNAP_GRID:
            if inside.covers(box(dx, dy)) and all(at(j, dx, dy).intersection(blocks[j]).area <= worst[j] for j in tree) \
                    and (seat is None or support_contains(seat, [p["pos"][0] + dx, p["pos"][1] + dy])) and keeps(dx, dy):
                for j in tree:
                    pl[j]["pos"] = [pl[j]["pos"][0] + dx, pl[j]["pos"][1] + dy, pl[j]["pos"][2]]
                break


def placements_from_text(text, room, ctx):
    """Model answer -> {"placements": after repair, "raw_placements": as the model proposed, "validation":
    {"raw": ..., "repaired": ...}, "error": code | None, "unsupported": [...]}, placements in request order.
    Repair = floor objects crossing a wall or a keepout by at most SNAP_TOL moved back inside (snap_inside); it is
    deterministic post-processing, so the raw proposal and its validation are always returned next to it."""
    ids = [o["id"] for o in room["objects"]]
    pl, err = parse(text, ids)
    if err is not None:
        return {"placements": [], "raw_placements": [], "validation": None, "error": err,
                "unsupported": ctx["unsupported"]}
    raw = copy.deepcopy(pl)
    snap_inside(pl, room, ctx["floor"], [k for k, hard in ctx["checks"] if hard], ctx["walls"])
    return {"placements": _to_request(pl, room, ctx), "raw_placements": _to_request(raw, room, ctx),
            "validation": {"raw": validation(raw, room, ctx), "repaired": validation(pl, room, ctx)},
            "error": None, "unsupported": ctx["unsupported"]}


def _to_request(pl, room, ctx):
    model2ext = {o["id"]: o["ext_id"] for o in room["objects"]}
    by_ext = {}
    for mid, p in pl.items():
        ext = model2ext[mid]
        yaw = (p["yaw"] - ctx["front_k"][ext] * math.pi / 2) % (2 * math.pi)   # model +X (front) -> asset frame
        item = {"id": ext, "position_m": [p["pos"][0] + ctx["x0"], p["pos"][1] + ctx["y0"], p["pos"][2] + ctx["floor_z"]],
                "yaw_rad": yaw, "support_parent": model2ext[p["on"]] if p["on"] else ctx["floor_id"]}
        if p["on"]:
            item["support_surface"] = "top"
        by_ext[ext] = item
    return [by_ext[i] for i in ctx["order"]]


def trained(k, lay):
    """False for a constraint form build.extract_constraints never makes, so the model never saw it (v2.1 train: 0
    against_wall in 5,117 constrained hull rooms; no faces with a non-FRONT subject; no against_wall / near / faces /
    between with an argument on another object; no between with anchors of two categories; no argument (of 'on': the
    subject) that is FLAT or under 5 cm tall). Reported, not refused: the caller decides. Thresholds (near 0.1-0.5) and
    whether the relation held on a reference layout are not forms and are not checked here."""
    o = {x["id"]: x for x in lay["objects"]}
    args = [o[i] for i in k[1:2 if k[0] == "on" else None] if i in o]
    return not ((k[0] == "against_wall" and lay["boundary_type"] != "polygon")
                or (k[0] == "faces" and head(o[k[1]]) not in FRONT)
                or (k[0] != "on" and any(x.get("parent") for x in args))
                or any(head(x) in FLAT or x["size"][2] < 0.05 for x in args)
                or (k[0] == "between" and norm_cat(o[k[2]]["category"]) != norm_cat(o[k[3]]["category"])))


def validation(pl, room, ctx):
    """The canonical checks (validate.check / validate.holds) on a placement, with request ids. ok = valid (inside
    boundary + 0.1, supported, under the ceiling), every object (on others too) within 1 mm of the walls minus the
    keepouts, and every hard constraint holding with the request's own numbers against the real walls.
    An overlap with a fixed box other than a window (validate.check fixed_blocking: a column, a door, a stair) also
    fails ok: those are physical obstacles; furniture in front of a window is reported in fixed_collisions only.
    Collisions between placed objects are reported, not gated (a chair tucked under a table is a
    box overlap; the reference layouts themselves have ~10% rooms with a severe one)."""
    ext = {o["id"]: o["ext_id"] for o in room["objects"] + (room.get("fixed") or [])}
    lay = apply(room, pl)
    c = check(lay)
    grown = ctx["floor"].buffer(1e-3)
    outside = [ext[o["id"]] for o in lay["objects"] if not grown.covers(Polygon(footprint(o)))]
    cons = [{"constraint": [k[0]] + [ext.get(v, v) for v in k[1:]], "hard": hard,
             "holds": holds(k, lay["objects"], ctx["walls"]), "trained": trained(k, lay)} for k, hard in ctx["checks"]]
    cons += [{"constraint": ["anchor", ext[o["id"]], o["anchor"]], "hard": True,
              "holds": bool(o.get("parent")) == (o["anchor"] == "object"),
              "trained": False, "model_conditioned": False}
             for o in lay["objects"] if o.get("anchor") in ("floor", "object")]
    cons += [{"constraint": ["keepout", pts], "hard": False,
              "holds": all(Polygon(footprint(o)).intersection(zone).area <= 1e-9 for o in lay["objects"]),
              "trained": False, "model_conditioned": False} for pts, zone in ctx["soft_keepouts"]]
    return {"ok": c["valid"] and not outside and not c["fixed_blocking"] and all(x["holds"] for x in cons if x["hard"]),
            "oob": [ext[i] for i in c["oob"]],
            "support_fail": [ext[i] for i in c["support_fail"]], "ceiling": [ext[i] for i in c["ceiling"]],
            "outside_1mm": outside, "collisions": {k: [[ext[a], ext[b]] for a, b in v] for k, v in c["collisions"].items()},
            "fixed_collisions": [[ext[a], ext[b]] for a, b in c["fixed_collisions"]],
            "fixed_blocking": [[ext[a], ext[b]] for a, b in c["fixed_blocking"]], "constraints": cons}


if __name__ == "__main__":
    import json

    from fastfill.scene import messages, target_json
    from fastfill.validate import holds

    req = {  # scope doc Appendix B, room moved to (10, 20) and one chair modelled with its front along local +Y
        "room": {"id": "new_room", "room_type": "meeting room", "boundary_xy": [[10, 20], [14, 20], [14, 24], [10, 24]],
                 "boundary_source": "specified_polygon", "floor_id": "room_floor", "floor_z": 0.0, "height_m": 2.8},
        "objects_to_place": [
            {"id": "table_1", "category": "table", "size_xyz_m": [1.4, 0.8, 0.75]},
            {"id": "chair_1", "category": "chair", "size_xyz_m": [0.55, 0.5, 0.9], "semantic_front_local": [1, 0, 0]},
            {"id": "chair_2", "category": "chair", "size_xyz_m": [0.5, 0.55, 0.9], "semantic_front_local": [0, 1, 0]},
            {"id": "cup_1", "category": "cup", "size_xyz_m": [0.08, 0.08, 0.1]},
            {"id": "clock_1", "category": "clock", "size_xyz_m": [0.3, 0.05, 0.3], "anchor": "wall"}],
        "layout_constraints": [
            {"type": "faces", "subject": "chair_1", "target": "chair_2", "hard": True},
            {"type": "faces", "subject": "chair_2", "target": "chair_1", "hard": True},
            {"type": "between", "subject": "table_1", "anchors": ["chair_1", "chair_2"], "hard": True},
            {"type": "supported_by", "subject": "cup_1", "parent": "table_1", "surface": "top", "hard": True}]}
    room, ctx = request_to_room(req)
    assert [o["id"] for o in room["objects"]] == ["table_1", "chair_1", "chair_2", "cup_1"], room["objects"]
    assert room["objects"][2]["size"] == [0.55, 0.5, 0.9]            # chair_2 re-framed: front axis first
    assert ctx["unsupported"] == [{"id": "clock_1", "reason": "unsupported_anchor"}]
    assert room["constraints"][3] == ["on", "cup_1", "table_1"]
    # 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
    ans = {"table_1": ([2, 2, 0], 0), "chair_1": ([0.9, 2, 0], 0), "chair_2": ([3.1, 2, 0], math.pi),
           "cup_1": ([1.7, 2, 0.75], 0)}
    placed = {**room, "objects": [{**o, "pos": ans[o["id"]][0], "yaw": ans[o["id"]][1],
                                   "parent": "table_1" if o["id"] == "cup_1" else None} for o in room["objects"]]}
    assert all(holds(c, placed["objects"], placed["boundary"]) for c in room["constraints"])
    text = target_json(placed)
    assert "constraints" in messages(room, room["constraints"], with_target=False)[1]["content"]
    out = placements_from_text(text, room, ctx)
    got = {p["id"]: p for p in out["placements"]}
    assert out["error"] is None and [p["id"] for p in out["placements"]] == ["table_1", "chair_1", "chair_2", "cup_1"]
    assert got["chair_1"]["position_m"] == [10.9, 22, 0] and got["chair_1"]["yaw_rad"] == 0
    assert abs(got["chair_2"]["yaw_rad"] - math.pi / 2) < 1e-9           # front on local +Y: asset turned by 90 deg
    assert got["cup_1"]["support_parent"] == "table_1" and got["cup_1"]["support_surface"] == "top"
    assert got["table_1"]["support_parent"] == "room_floor" and "support_surface" not in got["table_1"]
    assert placements_from_text("not json", room, ctx)["error"] == "bad_json"
    # 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
    over = {**placed, "objects": [{**o, "pos": [3.33 if o["id"] == "table_1" else 3.0 if o["id"] == "cup_1" else o["pos"][0],
                                                *o["pos"][1:]]} for o in placed["objects"]]}
    got = {p["id"]: p["position_m"] for p in placements_from_text(target_json(over), room, ctx)["placements"]}
    assert abs(got["table_1"][0] - 13.3) < 1e-9 and abs(got["cup_1"][0] - 12.97) < 1e-9, got
    far = {**over, "objects": [{**o, "pos": [3.5, *o["pos"][1:]]} if o["id"] == "table_1" else o for o in over["objects"]]}
    assert placements_from_text(target_json(far), room, ctx)["placements"][0]["position_m"][0] == 13.5

    # a doorway keepout on the west wall becomes a notch (padded) in the floor the model sees; the frame is unchanged
    door = {"type": "keepout", "polygon_xy": [[10, 21.5], [11, 21.5], [11, 22.5], [10, 22.5]]}
    room2, ctx2 = request_to_room({**req, "layout_constraints": req["layout_constraints"] + [door]})
    assert (ctx2["x0"], ctx2["y0"]) == (10, 20) and [0, 1.42] in room2["boundary"] and [1.08, 2.58] in room2["boundary"]
    assert room2["constraints"] == room["constraints"]
    # a constraint naming an unsupported object is skipped and reported; the rest of the room is still placed
    hang = {"type": "near", "subject": "clock_1", "target": "table_1", "max_gap_m": 0.5}
    room3, ctx3 = request_to_room({**req, "layout_constraints": [hang]})
    assert room3["constraints"] == [] and ctx3["unsupported"][1] == {"constraint": hang, "reason": "references_unsupported_object"}
    up = {**req, "objects_to_place": [{**req["objects_to_place"][0], "semantic_front_local": [0, 0, 1]}], "layout_constraints": []}
    assert request_to_room(up)[1]["unsupported"] == [{"id": "table_1", "reason": "front_not_axis_aligned"}]
    bad = [{**req, "objects_to_place": req["objects_to_place"] * 2},                                  # duplicate ids
           {**req, "layout_constraints": [{"type": "near", "id": "cup_1", "target": "table_1", "max_distance_m": 1.5}]},
           {**req, "layout_constraints": [{"type": "between", "subject": "table_1", "anchors": ["chair_1"]}]},
           {**req, "room": {**req["room"], "floor_z": None}},
           {**req, "objects_to_place": [{**req["objects_to_place"][0], "size_xyz_m": [1.4, -0.8, 0.75]}]},
           {**req, "objects_to_place": [{**req["objects_to_place"][0], "size_xyz_m": [1.4, float("nan"), 0.75]}]},
           {**req, "layout_constraints": [{**door, "polygon_xy": [[9, 19], [15, 19], [15, 25], [9, 25]]}]}]  # fills the room
    obj0 = req["objects_to_place"][0]
    bad += [{**req, "layout_constraints": [{"type": "supported_by", "subject": "cup_1", "parent": "cup_1"}]},   # self
            {**req, "layout_constraints": [{"type": "supported_by", "subject": "cup_1", "parent": "table_1"},
                                           {"type": "supported_by", "subject": "table_1", "parent": "cup_1"}]},  # cycle
            {**req, "layout_constraints": [{"type": "faces", "subject": "chair_1", "target": "chair_2", "hard": "false"}]},
            {**req, "layout_constraints": [{"type": "between", "subject": "table_1", "anchors": "cc"}]},
            {**req, "coordinate_system": {"units": "cm"}},
            {**req, "objects_to_place": [{**obj0, "anchor": "banana"}]},
            {**req, "objects_to_place": req["objects_to_place"] + [{"id": "x", "category": "clock", "size_xyz_m": [-1, 1, 1],
                                                                  "anchor": "wall"}]},
            {**req, "room": {**req["room"], "room_type": 7}},
            {**req, "room": {**req["room"], "height_m": 0}},
            {**req, "objects_to_place": [{**obj0, "size_xyz_m": [1e200, 1, 1]}]},
            {**req, "room": {**req["room"], "floor_z": float("inf")}},
            {**req, "room": {**req["room"], "height_m": float("nan")}},
            {**req, "layout_constraints": [{"type": "near", "subject": "cup_1", "target": "table_1", "max_gap_m": -0.1}]}]
    for b in bad:
        try:
            request_to_room(b)
            raise AssertionError(b)
        except ValueError:
            pass

    # near: the model is shown the threshold rounded down to cm, the answer is accepted against the request's own
    # number; a failed hard constraint fails the layout (after repair), a failed soft one is only reported
    near = lambda gap, hard=True: {"type": "near", "subject": "chair_1", "target": "chair_2", "max_gap_m": gap, "hard": hard}
    r5, c5 = request_to_room({**req, "layout_constraints": [near(0.126)]})
    assert r5["constraints"] == [["near", "chair_1", "chair_2", 0.12]] and c5["checks"][0][0][3] == 0.126
    apart = target_json(placed)                        # chairs 1.7 m apart: fails any near <= 0.5
    v = placements_from_text(apart, *request_to_room({**req, "layout_constraints": [near(0.1)]}))["validation"]
    assert not v["repaired"]["ok"] and v["repaired"]["constraints"][0]["holds"] is False, v
    v = placements_from_text(apart, *request_to_room({**req, "layout_constraints": [near(0.1, hard=False)]}))["validation"]
    assert v["repaired"]["ok"], v
    # a cup hanging 3 cm over the east wall from a table flush with it: the cup is pushed in, staying on the table
    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"
                                   else o["pos"]} for o in placed["objects"]]}
    out = placements_from_text(target_json(edge), room, ctx)
    cup = {p["id"]: p for p in out["placements"]}["cup_1"]["position_m"]
    assert out["validation"]["raw"]["outside_1mm"] == ["cup_1"] and out["validation"]["repaired"]["ok"], out["validation"]
    assert abs(cup[0] - 13.96) < 1e-9, cup
    # fixed geometry: a column at (12, 22) is shown to the model in the room frame, never placed, and a table on it is
    # reported (not refused); a fixed id that repeats an object id is a duplicate
    col = {"id": "col_1", "category": "column", "size_xyz_m": [0.3, 0.3, 2.8], "position_m": [12, 22, 0]}
    room6, ctx6 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": [col]}, "layout_constraints": []})
    assert room6["fixed"][0]["id"] == "column_1" and room6["fixed"][0]["pos"] == [2, 2, 0], room6["fixed"]
    assert json.loads(messages(room6, [], with_target=False)[1]["content"])["fixed"] == [
        {"id": "column_1", "size": [0.3, 0.3, 2.8], "pos": [2, 2, 0], "yaw": 0}]
    out6 = placements_from_text(target_json(placed), room6, ctx6)       # table_1 centred on the column
    assert not out6["validation"]["repaired"]["ok"] and out6["validation"]["repaired"]["fixed_collisions"] == [["table_1", "col_1"]]
    # the repair moves a table with the cup on it; a move that would push the CUP into a fixed upper cabinet (above the
    # table top, at the cup's height) is not taken, so the table stays 3 cm through the wall and the layout fails
    cab = {"id": "cab_1", "category": "cabinet", "size_xyz_m": [0.4, 0.6, 1.0], "position_m": [12.75, 22, 0.78]}
    room9, ctx9 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": [cab]}, "layout_constraints": []})
    moved = {"table_1": [3.33, 2, 0], "cup_1": [3.0, 2, 0.75], "chair_2": [1.5, 3.5, 0]}
    lay9 = {**placed, "objects": [{**o, "pos": moved.get(o["id"], o["pos"])} for o in placed["objects"]]}
    out9 = placements_from_text(target_json(lay9), room9, ctx9)
    got = {p["id"]: p["position_m"] for p in out9["placements"]}
    assert abs(got["table_1"][0] - 13.33) < 1e-9 and abs(got["cup_1"][0] - 13.0) < 1e-9, got
    assert not out9["validation"]["repaired"]["fixed_collisions"] and "table_1" in out9["validation"]["repaired"]["outside_1mm"]
    for b in ({**col, "id": "table_1"}, {**col, "size_xyz_m": [0, 0.3, 2.8]}):        # duplicate id; zero size
        try:
            request_to_room({**req, "room": {**req["room"], "fixed_geometry": [b]}})
            raise AssertionError(b)
        except ValueError:
            pass
    # a constraint may not name a fixed box: refused with its own message
    try:
        request_to_room({**req, "room": {**req["room"], "fixed_geometry": [col]},
                         "layout_constraints": [{"type": "near", "subject": "table_1", "target": "col_1", "max_gap_m": 0.5}]})
        raise AssertionError("constraint on a fixed box accepted")
    except ValueError as e:
        assert "fixed box" in str(e), e
    # training-side normalization: |z| <= FLOOR_Z snapped to 0; a box starting at FIXED_MAX_Z or above, covering half
    # the room, or standing clear of it is not shown and reported as fixed_ignored
    fx = lambda **kw: {**col, **kw}
    room8, ctx8 = request_to_room({**req, "layout_constraints": [], "room": {**req["room"], "floor_z": 1.0, "fixed_geometry": [
        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]),
        fx(id="slab", size_xyz_m=[3, 3, 0.1], position_m=[12, 22, 1]), fx(id="far", position_m=[30, 30, 1])]}})
    assert [f["ext_id"] for f in room8["fixed"]] == ["door"] and room8["fixed"][0]["pos"] == [2, 0.5, 0], room8["fixed"]
    assert [u["id"] for u in ctx8["unsupported"] if u["reason"] == "fixed_ignored"] == ["beam", "slab", "far"], ctx8["unsupported"]
    # 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)
    # 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
    # in, so it stays outside_1mm and serve.py refuses the layout); the same table clear of the column is moved back
    col2 = {**col, "position_m": [13.3, 22, 0]}
    room7, ctx7 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": [col2]}, "layout_constraints": []})
    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"]}
                                            if o["id"] in ("table_1", "cup_1") else o for o in placed["objects"]]}
    out7 = placements_from_text(target_json(side(2.0)), room7, ctx7)
    got = {p["id"]: p["position_m"] for p in out7["placements"]}
    assert abs(got["table_1"][0] - 13.63) < 1e-9 and out7["validation"]["repaired"]["outside_1mm"] == ["table_1"], out7["validation"]
    assert ["table_1", "col_1"] in out7["validation"]["repaired"]["fixed_collisions"]
    got = {p["id"]: p["position_m"] for p in placements_from_text(target_json(side(2.9)), room7, ctx7)["placements"]}
    assert abs(got["table_1"][0] - 13.6) < 1e-9 and abs(got["cup_1"][0] - 13.6) < 1e-9, got
    # 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
    from fastfill.build import FLAT_Z as BUILD_FLAT_Z
    assert FLAT_Z == BUILD_FLAT_Z
    sunk = [{"id": "c9", "category": "column", "size_xyz_m": [0.3, 0.3, 2.8], "position_m": [12, 22, -0.5]},
            {"id": "u9", "category": "unknown", "size_xyz_m": [1, 1, 0.4], "position_m": [12.5, 22.5, -0.6]}]
    r7, c7 = request_to_room({**req, "room": {**req["room"], "fixed_geometry": sunk}, "layout_constraints": []})
    assert [(f["ext_id"], f["pos"][2], f["size"][2]) for f in r7["fixed"]] == [("c9", 0.0, 2.3)], r7["fixed"]
    assert {"id": "u9", "reason": "fixed_ignored"} in c7["unsupported"]
    # objects are ordered by cm-rounded sizes clamped at 1 cm, as in training (a 3 mm side sorts as 1 cm)
    thin = {**req, "objects_to_place": [{"id": "mat", "category": "mat", "size_xyz_m": [1.0, 0.003, 0.9]},
                                        {"id": "pad", "category": "mat", "size_xyz_m": [0.5, 0.01, 0.9]}],
            "layout_constraints": []}
    assert [o["ext_id"] for o in request_to_room(thin)[0]["objects"]] == ["mat", "pad"]
    # re-entrant corner of an L-shaped room: the nearest-boundary push is diagonal and long, a 6 cm move along -x works
    ell = Polygon([(0, 0), (4, 0), (4, 2), (2, 2), (2, 4), (0, 4)])
    plc = {"b": {"pos": [1.76, 2.22, 0], "yaw": 0.0, "on": None}}
    snap_inside(plc, {"objects": [{"id": "b", "size": [0.6, 0.6, 0.8]}]}, ell)
    assert plc["b"]["pos"][:2] == [1.7, 2.22] or abs(plc["b"]["pos"][0] - 1.7) < 1e-9, plc
    # the repair never breaks a hard constraint that held: a chair 3 cm through the south wall faces a box 28.5 deg off
    # its front; the shortest push (0, +3 cm) turns that to 32 deg (> 30), so (-4, +3) cm is taken instead
    sq, two = Polygon([(0, 0), (4, 0), (4, 3), (0, 3)]), {"objects": [{"id": "c", "size": [.5, .5, .9]}, {"id": "b", "size": [.06, .06, .3]}]}
    lay = lambda q: [{"id": i, "pos": q[i]["pos"], "yaw": 0.0, "size": s["size"]} for i, s in zip("cb", two["objects"])]
    for cons, want in (([], [1, .25]), ([["faces", "c", "b"]], [.96, .25])):
        plc = {"c": {"pos": [1, .22, 0], "yaw": 0.0, "on": None}, "b": {"pos": [1.35, .03, 0], "yaw": 0.0, "on": None}}
        assert holds(["faces", "c", "b"], lay(plc), sq.exterior.coords[:-1])
        snap_inside(plc, two, sq, cons, sq.exterior.coords[:-1])
        assert all(abs(u - v) < 1e-9 for u, v in zip(plc["c"]["pos"], want)), plc
        assert holds(["faces", "c", "b"], lay(plc), sq.exterior.coords[:-1]) == bool(cons)
    # two chairs and a side table between them, all 5 cm through the east wall: the between fails after the second chair
    # moves and holds again once the table moves, so the plain pass is kept (a move-by-move guard left chair b outside)
    row = {"objects": [{"id": "a", "size": [.7, .7, .9]}, {"id": "b", "size": [.7, .7, .9]}, {"id": "s", "size": [.38, .38, .6]}]}
    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))}
    sq4 = Polygon([(0, 0), (4, 0), (4, 4), (0, 4)])
    snap_inside(plc, row, sq4, [["between", "s", "a", "b"]], sq4.exterior.coords[:-1])
    assert all(abs(plc[i]["pos"][0] - x) < 1e-9 for i, x in (("a", 3.65), ("b", 3.65), ("s", 3.81))), plc
    # 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
    ko = lambda x1: {"room": {"boundary_xy": [[0, 0], [1.48, 0], [1.48, 3.48], [0, 3.48]]},
                     "objects_to_place": [{"id": "a", "category": "chair", "size_xyz_m": [0.5, 0.5, 0.9]}],
                     "layout_constraints": [{"type": "keepout", "polygon_xy": [[.495, -.2], [x1, -.2], [x1, 1], [.495, 1]]}]}
    assert [1.48, 1.08] in request_to_room(ko(1.48 - KEEPOUT_PAD - 0.003))[0]["boundary"]
    assert [1.46, 1.08] in request_to_room(ko(1.48 - KEEPOUT_PAD - 0.02))[0]["boundary"]
    # 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
    # and the corner stays floor; 2.9 mm apart the gap folds under cm rounding, as does a keepout crossing a 45-degree
    # wall by 3 mm: keepout_not_representable, not bad_boundary (the walls themselves round cleanly)
    pair = lambda x1: {"room": {"boundary_xy": [[0, 0], [4.5, 0], [4.5, 3], [0, 3]]}, "objects_to_place": ko(0)["objects_to_place"],
                       "layout_constraints": [{"type": "keepout", "polygon_xy": [[3.5, 1.1], [4.5, 1.1], [4.5, 2.3], [3.5, 2.3]]},
                                              {"type": "keepout", "polygon_xy": [[2.1, 2.4], [x1, 2.4], [x1, 3], [2.1, 3]]}]}
    assert [4.5, 3] in request_to_room(pair(3.3325))[0]["boundary"]
    mouth = {"room": {"boundary_xy": [[0, 0], [4, 0], [4, 4], [1, 4], [0, 3]]}, "objects_to_place": ko(0)["objects_to_place"],
             "layout_constraints": [{"type": "keepout", "polygon_xy": [[.58, 2.08], [1.42, 2.08], [1.42, 3.423], [.58, 3.423]]}]}
    for b in (pair(3.3371), mouth):
        try:
            request_to_room(b)
            raise AssertionError(b)
        except ValueError as e:
            assert str(e) == "keepout_not_representable", e
    # forms training never has are checked as asked but flagged: faces from a round table (no FRONT head), against_wall
    # in a hull room, near from a cup that stands on the table; faces / between / on of the Appendix-B request are trained
    assert all(c["trained"] for c in placements_from_text(text, room, ctx)["validation"]["repaired"]["constraints"])
    hull = {"room": {**req["room"], "boundary_source": "scan_hull"}, "objects_to_place": [
        {"id": "rt", "category": "round table", "size_xyz_m": [1, 1, .75]}, {"id": "sh", "category": "shelf", "size_xyz_m": [1, .4, 1.8]},
        {"id": "cup", "category": "cup", "size_xyz_m": [.08, .08, .1]}], "layout_constraints": [
        {"type": "faces", "subject": "rt", "target": "sh"}, {"type": "against_wall", "subject": "sh"},
        {"type": "near", "subject": "cup", "target": "sh", "max_gap_m": 0.5}]}
    rh, ch = request_to_room(hull)
    ans = '{"placements":[{"id":"round_table_1","pos":[2,2,0],"yaw":270},{"id":"shelf_1","pos":[2,0.2,0],"yaw":0},' \
          '{"id":"cup_1","on":"round_table_1","pos":[2,2,0.75],"yaw":0}]}'       # the cup 1.56 m from the shelf
    v = placements_from_text(ans, rh, ch)["validation"]["repaired"]
    assert [(c["trained"], c["holds"]) for c in v["constraints"]] == [(False, True), (False, True), (False, False)], v
    assert not v["ok"] and not v["outside_1mm"] and not v["oob"], v
    # build never relates a rug, a sub-5 cm box or (except by 'on') an object standing on another
    lay = {"boundary_type": "polygon", "objects": [
        {"id": "s", "category": "sofa", "size": [2, .9, .8]}, {"id": "r", "category": "rug", "size": [2, 1.5, .01]},
        {"id": "l", "category": "lamp", "size": [.3, .3, .5], "parent": "s"},
        {"id": "k", "category": "coaster", "size": [.1, .1, .01], "parent": "s"}]}
    assert [trained(k, lay) for k in (["near", "s", "r", .5], ["faces", "s", "r"], ["against_wall", "l"], ["on", "k", "s"],
                                      ["on", "l", "s"], ["against_wall", "s"])] == [False] * 4 + [True] * 2
    print("interface.py self-check ok")
    print(json.dumps(out["placements"][2]))