Skip to content

Commit d97c04c

Browse files
committed
fix: trim is never a free body, and the export checks that the scene loads clean
Found by running the pipeline with `--author-steps 20`. A short authoring session wrote `Skirting0` with `rests_on: Floor0` and no `attached_to`, and `rests_on` short-circuits every other test — so 86 kg of trim ringing the room was emitted as a FREE body, 40 mm inside the door lining. MuJoCo read the overlap as stored energy and threw it 158 mm; the scene failed the stability gate on that one body. The fully authored room escaped only because the author got as far as writing `attached_to: Room_Shell`, which is to say the room was one interrupted session away from unusable with nothing to warn anyone. Two changes, one specific and one general. TRIM, alongside the existing SCENERY set: skirting, trunking, coving, architrave and the rest are nailed to the fabric and run inside the walls they trim. They are never free bodies, and unlike SCENERY that holds at any height — trunking sits at 0.95 m. Pinned whatever the layout claims, and reported as `trim_pinned`. And the backstop that would have caught it whatever it was called: the export now compiles the file it just wrote, steps it once, and records whether it starts clean. `loads_clean` and `initial_overlaps` go in the report and the simulate stage refuses to call a scene that starts interpenetrating a quiet success. Best-effort and non-fatal — a scene that cannot be loaded is still written, because a file you can inspect beats no file at all. Office-Elliott authored at 20 steps: FAIL (22 contacts, -39.6 mm, ejected 158 mm) -> PASS (0 contacts, 0.0 mm, no warnings, nothing escaped). The fully authored room re-exports unchanged and also reports `loads_clean: true`.
1 parent 97f49ad commit d97c04c

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

src/litereality_agent/pipeline/simulate/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ def run(context: RunContext, options: dict) -> StageResult:
112112
if names:
113113
warnings.append(f"{len(names)} object(s) with {note}: {', '.join(map(str, names[:6]))}")
114114

115+
# A SCENE THAT STARTS INTERPENETRATING IS NOT SIM-READY, HOWEVER WELL IT EXPORTED. Overlap at
116+
# t=0 is stored energy the solver has to discharge, so the first thing an episode does is throw
117+
# furniture. This is the one check that speaks for the whole file rather than for one object.
118+
if report.get("loads_clean") is False:
119+
worst = report.get("initial_overlaps") or []
120+
detail = "; ".join(f"{o['bodies']} {o['mm']}mm" for o in worst[:3])
121+
warnings.append(f"scene starts interpenetrating in {len(worst)} place(s): {detail}")
122+
elif report.get("loads_clean") is None and report.get("load_check_error"):
123+
warnings.append(f"could not load the exported scene to check it: "
124+
f"{report['load_check_error']}")
125+
115126
# THE HEADLINE NUMBER, SAID WHETHER OR NOT ANYTHING FAILED. Every warning above fires on a
116127
# named object going wrong; none of them fires on the ordinary case of an authored room whose
117128
# fixtures never had a package to begin with, and that case is the majority of the colliders.

src/litereality_agent/room_ops/export/mujoco_scene.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@
108108
# a wall-mounted shelf that happens to be named one, and that is a different thing.
109109
SCENERY_FLOOR_GAP = 0.08
110110

111+
# TRIM IS PART OF THE BUILDING, NOT AN OBJECT IN IT. Skirting, trunking and coving are nailed to
112+
# the fabric and run the length of a room, geometrically INSIDE the walls they trim. They can never
113+
# be free bodies, and unlike SCENERY that is true at any height — trunking sits at 0.95 m.
114+
#
115+
# This is a set rather than an inference because the inference cannot reach them. `hung` is only
116+
# consulted when an object has neither `attached_to` nor `rests_on`, so a single authored
117+
# `rests_on: Floor0` is enough to make one free with no further test — and skirting genuinely does
118+
# sit on the floor, so the claim is not even wrong. Office-Elliott authored under a short step
119+
# budget produced exactly that: `Skirting0`, 86 kg of trim ringing the room, emitted as a free body
120+
# 40 mm inside the door lining. MuJoCo read the overlap as stored energy and threw it 158 mm, and
121+
# the scene failed the stability gate on that one body alone. The fully authored room escaped it
122+
# only because the author got far enough to write `attached_to: Room_Shell` — which is to say the
123+
# room was one interrupted session away from being unusable, with nothing to warn anyone.
124+
TRIM = {"skirting", "baseboard", "trunking", "coving", "cornice", "architrave", "dado",
125+
"beading", "moulding", "molding", "threshold", "picture_rail", "chair_rail"}
126+
111127
# Materials whose NAME says they are see-through. glTF carries the pane as an ordinary opaque
112128
# texture — Blender's transmission does not survive the export — so a window arrives as a solid
113129
# painted panel and the room has no daylight in it. The name is the only surviving evidence that
@@ -1180,8 +1196,13 @@ def structural_material(handle: str, fallback: str) -> str:
11801196
< SCENERY_FLOOR_GAP)
11811197
if scenery:
11821198
stats.setdefault("scenery", []).append(handle)
1199+
# Trim is static whatever the layout says about it — including an authored `rests_on`,
1200+
# which is the one claim that otherwise skips every other test above.
1201+
trim = not hanging and _in_category(category, TRIM)
1202+
if trim and not (rec.get("attached_to") or hung):
1203+
stats.setdefault("trim_pinned", []).append(handle)
11831204
static = (not hanging and (bool(rec.get("attached_to")) or bool(hung)
1184-
or category in OPENING or bool(moving) or scenery))
1205+
or category in OPENING or bool(moving) or scenery or trim))
11851206
body_attrs = {"name": handle, "pos": " ".join(f"{v:.4f}" for v in centre)}
11861207
# A free object hangs off the world; anything fixed to the structure hangs off the ROOM, so
11871208
# it travels with the walls when they move instead of being left behind in mid-air.
@@ -1652,6 +1673,34 @@ def _look_from(name, eye_pt, look_at, fovy):
16521673
# report counts what was emitted; this one is the only one that says how much of it was
16531674
# invented here. It has to be computed rather than inferred by a reader subtracting two fields,
16541675
# because the honest answer on an authored room is well under half and nothing else says so.
1676+
# DOES THE SCENE WE JUST WROTE ACTUALLY LOAD CLEAN? Everything above reasons about the room
1677+
# from the layout; this is the only step that asks MuJoCo. A body emitted free that starts
1678+
# inside the structure is stored energy — the solver reads the overlap as a compressed spring
1679+
# and ejects it — and until now the first thing to notice was the stability gate, long after
1680+
# the export had reported success. Compiling the file here costs about a second and turns that
1681+
# into a number in the report. Deliberately non-fatal and best-effort: a scene that cannot be
1682+
# loaded here is still written out, because a file you can inspect beats no file at all.
1683+
try:
1684+
import mujoco # noqa: PLC0415 — optional, and only at load-check time
1685+
1686+
_m = mujoco.MjModel.from_xml_path(str(xml))
1687+
_d = mujoco.MjData(_m)
1688+
mujoco.mj_forward(_m, _d)
1689+
_bn = lambda g: mujoco.mj_id2name( # noqa: E731
1690+
_m, mujoco.mjtObj.mjOBJ_BODY, _m.geom_bodyid[g]) or "?"
1691+
overlaps = {}
1692+
for _c in range(_d.ncon):
1693+
con = _d.contact[_c]
1694+
if con.dist < -0.005:
1695+
pair = " <-> ".join(sorted((_bn(con.geom1), _bn(con.geom2))))
1696+
overlaps[pair] = min(overlaps.get(pair, 0.0), float(con.dist))
1697+
stats["loads_clean"] = not overlaps
1698+
stats["initial_overlaps"] = [{"bodies": k, "mm": round(v * 1000, 1)}
1699+
for k, v in sorted(overlaps.items(), key=lambda kv: kv[1])]
1700+
except Exception as exc: # noqa: BLE001 — a check is not the export
1701+
stats["loads_clean"] = None
1702+
stats["load_check_error"] = f"{type(exc).__name__}: {exc}"
1703+
16551704
derived = int(stats["colliders"]) - int(stats.get("sidecar_colliders", 0))
16561705
stats["derived_colliders"] = max(0, derived)
16571706
stats["sidecar_coverage"] = (round(stats.get("sidecar_colliders", 0) / stats["colliders"], 3)

0 commit comments

Comments
 (0)