diff --git a/README.md b/README.md index 4072235..3dbab10 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,9 @@ uv run litereality run scans/ --through seed uv run litereality stage author run/ --force --polish --live ``` -`--polish` adds object refinement, materials, and a model-driven quality pass on top of authoring. +`--polish` adds object refinement and materials on top of authoring. The model-driven quality +pass is separate — add `--quality-pass` for it — because it is the longest agent pass on a run and +nothing downstream reads its output. `--live` shows how everything is built in real time, alongside the agent's trace. With `--live` the viewer starts before the room exists and waits for it, so it works on a scene's first authoring run; it prints its url again once the first build lands. diff --git a/doc/Sim-Ready-intergration/Mujoco.md b/doc/Sim-Ready-intergration/Mujoco.md index 30a0288..8f9cca4 100644 --- a/doc/Sim-Ready-intergration/Mujoco.md +++ b/doc/Sim-Ready-intergration/Mujoco.md @@ -127,6 +127,20 @@ a table is a solid block — a chair tucked under it would be launched on the fi would be a filled cupboard. Every concave body is therefore several convex pieces, and the original mesh is kept as a visual-only geom. +### Driving it + +Every joint that came from a sidecar also gets an actuator, at the effort the asset itself stated — +45 N·m for that door leaf, 800 N for that desk lift. Without them the articulation is scenery: the +only way to open a door is to push it with another body, and a limit the recipe compiled has no +effect on anything. They are `motor` elements rather than position servos on purpose. A servo holds +a setpoint, so it would clamp every door shut at `ctrl=0` and a hung door would stop swinging when +the room was shaken; a motor applies exactly `ctrl` and nothing at rest, so the passive scene is +unchanged and the joint is merely now drivable. The velocity limit the sidecar carries has nowhere +to live in MJCF, so it is kept on the actuator's `user` field for a controller to read back. + +A joint recovered from the raw articulation extras gets no actuator, because nothing said what its +effort should be and inventing one is a guess. `export_report.json` lists what was actuated. + The room itself is a body on three slide joints and a yaw hinge, driven by stiff position servos. That is what makes `--shake` an earthquake rather than a change in the direction of gravity: the floor really accelerates under the furniture, and a hung door really swings because its frame is diff --git a/sanity.py b/sanity.py index 4ccafc9..9955efc 100755 --- a/sanity.py +++ b/sanity.py @@ -416,11 +416,30 @@ def harness_for(role: str) -> str: env=("LR_REFINE_PROVIDER", "claude", "other roles can stay on codex")) if "providers" in wanted: - print("── hosted models (OpenAI images · Claude reasoning) ──") + # CHECK THE PROVIDER THAT IS ACTUALLY SELECTED. This asked for an OpenAI key unconditionally, + # so a correctly configured Gemini setup failed sanity while the run itself would have been + # fine — and, worse, an OpenAI key present alongside `LR_IMAGE_PROVIDER=gemini` passed here + # and then failed on a missing Gemini key at the first object. + provider = (os.environ.get("LR_IMAGE_PROVIDER") or "openai").strip().lower() + model = os.environ.get("LR_OPENAI_IMAGE_MODEL") or "gpt-image-2" + if provider not in ("openai", "gemini"): + provider = "gemini" if model.lower().startswith("gemini") else "openai" + print(f"── hosted models ({provider} images · Claude reasoning) ──") + + if provider == "gemini": + if os.environ.get("GEMINI_API_KEY", ""): + ok(f"GEMINI_API_KEY set (image provider is gemini, " + f"{os.environ.get('LR_GEMINI_IMAGE_MODEL') or 'gemini-2.5-flash-image'})") + else: + fail("LR_IMAGE_PROVIDER=gemini but GEMINI_API_KEY is unset — every reference " + "image will fall back to the raw evidence sheet.", + env=("GEMINI_API_KEY", "", + "create at https://aistudio.google.com/apikey")) key = os.environ.get("OPENAI_API_KEY", "") - if not key: - fail("OPENAI_API_KEY unset — reference image-gen " - f"({os.environ.get('LR_OPENAI_IMAGE_MODEL') or 'gpt-image-2'}) will fail.", + if provider != "openai": + ok("OPENAI_API_KEY not required — images come from gemini") + elif not key: + fail(f"OPENAI_API_KEY unset — reference image-gen ({model}) will fail.", env=("OPENAI_API_KEY", "sk-", "create at https://platform.openai.com/api-keys")) else: try: diff --git a/scripts/simready/view_scene.py b/scripts/simready/view_scene.py new file mode 100644 index 0000000..e583a35 --- /dev/null +++ b/scripts/simready/view_scene.py @@ -0,0 +1,54 @@ +"""Open a LiteReality MuJoCo scene interactively. + +MuJoCo's managed viewer (`python -m mujoco.viewer`) crashes constructing its window on this macOS, +but the PASSIVE viewer works — so this owns the loop instead of handing it to MuJoCo. + + ./.venv/bin/mjpython scripts/simready/view_scene.py [scene.xml] # frozen: look, don't touch + ./.venv/bin/mjpython scripts/simready/view_scene.py [scene.xml] --physics # gravity on, things can be pushed + +Frozen is the default because it answers a different question: it shows the room exactly as the +exporter wrote it, with nothing settled, nudged or knocked over. Nothing is stepped, so a drag +cannot move anything and no contact force is ever resolved. +""" +import sys +import time +from pathlib import Path + +import mujoco +import mujoco.viewer + +DEFAULT = "run/Office-Elliott/realism_authoring/mujoco/scene.xml" +args = [a for a in sys.argv[1:] if not a.startswith("-")] +physics = "--physics" in sys.argv +scene = Path(args[0]) if args else Path(DEFAULT) + +model = mujoco.MjModel.from_xml_path(str(scene)) +data = mujoco.MjData(model) +drives = [j for j in (mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, n) + for n in ("room_x", "room_y", "room_z", "room_yaw")) if j >= 0] + +mujoco.mj_forward(model, data) +print(f"{scene}\n {model.nbody} bodies · {model.ngeom} geoms · {model.njnt} joints") +print(f" mode: {'PHYSICS — gravity on, bodies can be pushed' if physics else 'FROZEN — nothing is stepped, nothing can move'}") +print(" drag to orbit · scroll to zoom · double-click a body to select and name it") +print(" in the window: press F1 for help, Tab for the panel, ctrl-A to see contact forces") + +with mujoco.viewer.launch_passive(model, data) as viewer: + while viewer.is_running(): + t0 = time.time() + if physics: + for j in drives: + data.qpos[model.jnt_qposadr[j]] = 0.0 + data.qvel[model.jnt_dofadr[j]] = 0.0 + mujoco.mj_step(model, data) + else: + # Kinematics only. Poses stay exactly as exported; no force is ever integrated, so a + # mouse drag cannot displace anything and the scene cannot settle out from under you. + data.xfrc_applied[:] = 0.0 + data.qacc[:] = 0.0 + data.qvel[:] = 0.0 + mujoco.mj_forward(model, data) + viewer.sync() + wait = model.opt.timestep - (time.time() - t0) + if wait > 0: + time.sleep(wait) diff --git a/src/litereality_agent/agent/author.py b/src/litereality_agent/agent/author.py index c7905cf..be5518b 100644 --- a/src/litereality_agent/agent/author.py +++ b/src/litereality_agent/agent/author.py @@ -20,6 +20,7 @@ from __future__ import annotations +import json import os import time from pathlib import Path @@ -414,6 +415,16 @@ async def run(room: Path, surface_ref: Path, scan: Path, model: str, max_turns: flush=True) tr.think(f"[restored checkpoint] {broken}") broken = room_compiles(room) + # WHETHER THIS ROOM WAS FINISHED OR MERELY STOPPED. A budget landing is graceful and exits 0, + # so the stage above could not tell it apart from a room the model considered done — a + # half-authored room reported as a completed stage. The distinction is only knowable here, so + # it is written down rather than left in the log for someone to notice. + try: + (room.parent / ".author_result.json").write_text(json.dumps({ + "ended_early": ended_early, "calls": calls, "step_budget": step_budget, + }), encoding="utf-8") + except OSError: + pass # a summary that cannot be written is not fatal tr.end(calls=calls, cost_usd=cost, summary=result_text) print(f"\n== done {dt}s | calls={calls} {counts} | cost=${cost} ==\n", flush=True) if tr.ok: diff --git a/src/litereality_agent/cli.py b/src/litereality_agent/cli.py index c7d0145..7104411 100644 --- a/src/litereality_agent/cli.py +++ b/src/litereality_agent/cli.py @@ -105,23 +105,47 @@ def _require_scene_package(target: str, context: RunContext, stage: str) -> None def _author_options(args) -> dict: + # `--polish` deliberately does NOT include the model-driven quality pass. Refinement and + # materials each produce something the room did not have before; QC re-examines what the + # authoring pass already rendered and compared, so it is the longest agent pass on the run for + # the smallest marginal change — and nothing downstream reads its output. It stays available + # as `--quality-pass` for a room being prepared to be looked at rather than simulated. polish = getattr(args, "polish", False) - return { + opts = { "refine_objects": polish or getattr(args, "refine_objects", False), "materials": polish or getattr(args, "materials", False), - "quality_pass": polish or getattr(args, "quality_pass", False), + "quality_pass": getattr(args, "quality_pass", False), } + steps = getattr(args, "author_steps", None) + if steps: + opts["step_budget"] = steps + return opts def _add_author_options(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--polish", action="store_true", - help="run object refinement, materials, and model-driven QC after authoring", + help="run object refinement and materials after authoring " + "(add --quality-pass for the model-driven QC pass as well)", ) parser.add_argument("--refine-objects", action="store_true") parser.add_argument("--materials", action="store_true") - parser.add_argument("--quality-pass", action="store_true") + parser.add_argument( + "--quality-pass", + action="store_true", + help="model-driven QC pass — not included in --polish", + ) + # Exposed for SHORT RUNS, not lowered as a default. Authoring spends its opening steps reading + # the capture and measuring surfaces before it edits anything, so a small budget does not give + # a rougher room, it gives an unfinished one — on Office-Elliott the first edit landed at step + # 19 and the walls were not squared up until the sixties. A truncated run now says so in the + # stage summary, which is what makes a low number safe to ask for. + parser.add_argument( + "--author-steps", type=int, default=None, metavar="N", + help="tool-call budget for the authoring session (default 100); " + "a low value returns an unfinished room and says so", + ) def _simulate_options(args) -> dict: diff --git a/src/litereality_agent/models/object_generation/sim/properties.py b/src/litereality_agent/models/object_generation/sim/properties.py index 9883a29..485a4cb 100644 --- a/src/litereality_agent/models/object_generation/sim/properties.py +++ b/src/litereality_agent/models/object_generation/sim/properties.py @@ -38,7 +38,19 @@ # what gives a drawer front a believable mass of its own. OCCUPANCY_DENSITY = { "default": 45.0, - "chair": 14.0, "stool": 14.0, "table": 20.0, "desk": 22.0, + # 14.0 put a task chair at 7.2 kg and, after the room's fit rescaled it, 4.9 kg on the floor — + # against 12-15 kg for the real thing. Everything downstream inherits that: a body at a third + # of its weight takes a third of the push to send it across the room, which reads as the + # simulation being unstable when it is the mass that is wrong. Measured against real furniture + # over its own bounding box, a chair is 23-27 kg/m^3 whatever kind it is — + # task chair, castors + gas strut 0.49 m^3 13 kg -> 26.8 + # cantilever meeting chair 0.23 m^3 5.5 kg -> 23.5 + # wooden dining chair 0.21 m^3 5.0 kg -> 23.4 + # so one number serves them all, which is just as well: the category that reaches here is a + # bare "chair" with no statement of which sort it is. A stool is the exception and needs its + # own, because it is small enough that its fixed frame dominates its enclosed volume (0.12 m^3, + # 5 kg -> 41.7). + "chair": 25.0, "stool": 40.0, "table": 20.0, "desk": 22.0, "storage": 55.0, "cabinet": 55.0, "wardrobe": 50.0, "shelf": 45.0, "bed": 35.0, "sofa": 30.0, "refrigerator": 110.0, "dishwasher": 120.0, "oven": 120.0, "washer": 150.0, "radiator": 160.0, diff --git a/src/litereality_agent/pipeline/realism_authoring/author/__init__.py b/src/litereality_agent/pipeline/realism_authoring/author/__init__.py index 0a4f59b..9a971d3 100644 --- a/src/litereality_agent/pipeline/realism_authoring/author/__init__.py +++ b/src/litereality_agent/pipeline/realism_authoring/author/__init__.py @@ -1,5 +1,6 @@ """Run Room.py authoring and its optional polish passes.""" +import json import shutil from litereality_agent.pipeline.context import RunContext @@ -53,6 +54,24 @@ def run(context: RunContext, options: dict) -> StageResult: if rc: return result + # A ROOM THAT RAN OUT OF STEPS IS NOT A FINISHED ROOM. The step budget lands the session + # gracefully and exits 0, which is right — hitting it means "time's up", not "this is broken", + # and the work so far is kept. But it exits 0 through the same path as a room the model + # considered done, so without this the two are indistinguishable from outside and a truncated + # room is reported as a completed stage. + try: + summary = json.loads( + (context.authored_room.parent / ".author_result.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + summary = {} + if summary.get("ended_early"): + result.warnings.append( + f"authoring stopped on {summary['ended_early']} after " + f"{summary.get('calls', '?')} tool-calls (budget " + f"{summary.get('step_budget', '?')}) — the room is as far as it got, not finished. " + f"Raise it with --author-steps." + ) + passes: list[tuple[str, str, list[object]]] = [] if options.get("refine_objects"): refine_args: list[object] = [ diff --git a/src/litereality_agent/pipeline/scene_init/flow.py b/src/litereality_agent/pipeline/scene_init/flow.py index d6d2cde..a072417 100644 --- a/src/litereality_agent/pipeline/scene_init/flow.py +++ b/src/litereality_agent/pipeline/scene_init/flow.py @@ -665,6 +665,19 @@ def summarize(results: list[dict]) -> None: openings = r.get("openings", []) ok_op = sum(1 for o in openings if o["status"] == "ok") print(f" openings: {len(openings)} doors/windows ({ok_op} references ok)") + + # A REFERENCE THAT FAILED IS NOT A SMALLER NUMBER, IT IS A DIFFERENT PIPELINE. On the + # fallback path the object is built from the raw evidence sheet instead of a clean render, + # so the geometry is measurably worse — and the only thing that said so was `0 references + # ok`, which reads as a count rather than as an error. A missing API key silently degraded + # every object in the room and the run still reported success. + failed = [o for o in (list(r["objects"]) + list(openings)) if o["status"] != "ok"] + if failed: + reasons = {o.get("error") or o["status"] for o in failed} + print(f" ⚠ DEGRADED: {len(failed)} of {n_obj + len(openings)} references fell back " + f"to the raw evidence sheet — these objects are built from worse input") + for reason in sorted(reasons)[:3]: + print(f" {reason}") if "routing" in r: print( f" routing: {r['routing']['procedural']} procedural, {r['routing']['trellis']} trellis" diff --git a/src/litereality_agent/pipeline/simulate/__init__.py b/src/litereality_agent/pipeline/simulate/__init__.py index b9ca295..ed473e8 100644 --- a/src/litereality_agent/pipeline/simulate/__init__.py +++ b/src/litereality_agent/pipeline/simulate/__init__.py @@ -106,11 +106,36 @@ def run(context: RunContext, options: dict) -> StageResult: for key, note in (("no_sidecar", "no compiled physics"), ("unreadable_sidecar", "unreadable physics sidecar"), ("unplaceable", "physics could not be placed"), - ("placement_rejected", "physics rejected as mis-placed")): + ("placement_rejected", "physics rejected as mis-placed"), + ("authored_no_package", "authored into Room.py, so no object package")): names = report.get(key) or [] if names: warnings.append(f"{len(names)} object(s) with {note}: {', '.join(map(str, names[:6]))}") + # A SCENE THAT STARTS INTERPENETRATING IS NOT SIM-READY, HOWEVER WELL IT EXPORTED. Overlap at + # t=0 is stored energy the solver has to discharge, so the first thing an episode does is throw + # furniture. This is the one check that speaks for the whole file rather than for one object. + if report.get("loads_clean") is False: + worst = report.get("initial_overlaps") or [] + detail = "; ".join(f"{o['bodies']} {o['mm']}mm" for o in worst[:3]) + warnings.append(f"scene starts interpenetrating in {len(worst)} place(s): {detail}") + elif report.get("loads_clean") is None and report.get("load_check_error"): + warnings.append(f"could not load the exported scene to check it: " + f"{report['load_check_error']}") + + # THE HEADLINE NUMBER, SAID WHETHER OR NOT ANYTHING FAILED. Every warning above fires on a + # named object going wrong; none of them fires on the ordinary case of an authored room whose + # fixtures never had a package to begin with, and that case is the majority of the colliders. + # A scene where most of the physics was invented at export time is a worse scene, and a run + # that does not say so is reporting a success it has not earned. + coverage = report.get("sidecar_coverage") + if coverage is not None and coverage < 1.0: + warnings.append( + f"{report.get('derived_colliders', 0)} of {report.get('colliders', 0)} colliders " + f"({(1 - coverage) * 100:.0f}%) were derived at export time, not read from a compiled " + f"sidecar — mass, friction and pivots for those came from a category table" + ) + if options.get("shake"): # The report is the run's stdout, which `run_module` tees into the log. A video is asked # for because a number saying "Chair0 moved 0.42 m" is not reviewable and a clip is. @@ -124,6 +149,8 @@ def run(context: RunContext, options: dict) -> StageResult: details = {"scene": str(scene_dir(context, seed=seed) / "scene.xml"), "source": "seed" if seed else "authored", "from_sidecar": len(report.get("from_sidecar") or []), + "sidecar_coverage": report.get("sidecar_coverage"), + "derived_colliders": report.get("derived_colliders"), "bodies": {k: report.get(k) for k in ("structure", "free", "attached", "articulated", "colliders")}} return StageResult("simulate", StageStatus.COMPLETED, details=details, warnings=warnings) diff --git a/src/litereality_agent/room_ops/export/mujoco_scene.py b/src/litereality_agent/room_ops/export/mujoco_scene.py index ba4fc55..be7c984 100644 --- a/src/litereality_agent/room_ops/export/mujoco_scene.py +++ b/src/litereality_agent/room_ops/export/mujoco_scene.py @@ -108,6 +108,22 @@ # a wall-mounted shelf that happens to be named one, and that is a different thing. SCENERY_FLOOR_GAP = 0.08 +# TRIM IS PART OF THE BUILDING, NOT AN OBJECT IN IT. Skirting, trunking and coving are nailed to +# the fabric and run the length of a room, geometrically INSIDE the walls they trim. They can never +# be free bodies, and unlike SCENERY that is true at any height — trunking sits at 0.95 m. +# +# This is a set rather than an inference because the inference cannot reach them. `hung` is only +# consulted when an object has neither `attached_to` nor `rests_on`, so a single authored +# `rests_on: Floor0` is enough to make one free with no further test — and skirting genuinely does +# sit on the floor, so the claim is not even wrong. Office-Elliott authored under a short step +# budget produced exactly that: `Skirting0`, 86 kg of trim ringing the room, emitted as a free body +# 40 mm inside the door lining. MuJoCo read the overlap as stored energy and threw it 158 mm, and +# the scene failed the stability gate on that one body alone. The fully authored room escaped it +# only because the author got far enough to write `attached_to: Room_Shell` — which is to say the +# room was one interrupted session away from being unusable, with nothing to warn anyone. +TRIM = {"skirting", "baseboard", "trunking", "coving", "cornice", "architrave", "dado", + "beading", "moulding", "molding", "threshold", "picture_rail", "chair_rail"} + # Materials whose NAME says they are see-through. glTF carries the pane as an ordinary opaque # texture — Blender's transmission does not survive the export — so a window arrives as a solid # painted panel and the room has no daylight in it. The name is the only surviving evidence that @@ -608,7 +624,14 @@ def _object_physics(room: Path, rec: dict, room_scene, node_names, placed_mesh, source = rec.get("source_glb") if not source: - return None # authored in `Room.py` — it has no object package at all + # Authored straight into `Room.py` — a mug, a cable, a length of trunking — so there is no + # object package and never was one. That is expected, but it is NOT free: everything + # physical about this body is about to be invented from a category table and a CoACD run. + # It used to return here without recording anything, which made the difference invisible: + # an authored room reported an empty `no_sidecar` while more than half its colliders had + # been derived at export time, and the stage's warning is keyed on that list. + report.setdefault("authored_no_package", []).append(rec.get("handle") or "?") + return None name = Path(source).stem sim_dir = sim_assets.sidecar_dir(room, name) if sim_dir is None: @@ -781,6 +804,9 @@ def export(room: Path, out: Path | None = None, *, decompose: bool = True, ET.SubElement(mujoco, "compiler", angle="radian", meshdir="meshes", texturedir="meshes", autolimits="true") ET.SubElement(mujoco, "option", timestep="0.002", integrator="implicitfast") + # One user slot per actuator, so an authored joint can carry the velocity limit its recipe + # stated. MuJoCo rejects a `user` attribute outright unless the space for it is declared here. + ET.SubElement(mujoco, "size", nuser_actuator="1") # MuJoCo's offscreen framebuffer defaults to 640x480, and a Renderer larger than it raises # rather than downscaling. A room is worth looking at at more than VGA. visual = ET.SubElement(mujoco, "visual") @@ -983,6 +1009,7 @@ def export(room: Path, out: Path | None = None, *, decompose: bool = True, "skipped": [], "synthesised_lights": synthesised_lights} # A moving part overlaps the thing it moves within — that is what "fits" means, not a defect. excludes: list[tuple[str, str]] = [] + actuated: list[tuple[str, float, float]] = [] # (joint, effort, velocity) from a sidecar placed: list[tuple[str, "np.ndarray", "np.ndarray", bool]] = [] hangings: list[tuple[str, float]] = [] @@ -1169,8 +1196,13 @@ def structural_material(handle: str, fallback: str) -> str: < SCENERY_FLOOR_GAP) if scenery: stats.setdefault("scenery", []).append(handle) + # Trim is static whatever the layout says about it — including an authored `rests_on`, + # which is the one claim that otherwise skips every other test above. + trim = not hanging and _in_category(category, TRIM) + if trim and not (rec.get("attached_to") or hung): + stats.setdefault("trim_pinned", []).append(handle) static = (not hanging and (bool(rec.get("attached_to")) or bool(hung) - or category in OPENING or bool(moving) or scenery)) + or category in OPENING or bool(moving) or scenery or trim)) body_attrs = {"name": handle, "pos": " ".join(f"{v:.4f}" for v in centre)} # A free object hangs off the world; anything fixed to the structure hangs off the ROOM, so # it travels with the walls when they move instead of being left behind in mid-air. @@ -1307,7 +1339,8 @@ def emit(target, meshes_in, name_prefix, density, material=None, link=None, pivot = physics.pivots[part_name] spec = {"axis": physics.axes[part_name], "type": joint.type, "min": joint.limit_lower, "max": joint.limit_upper, - "damping": joint.damping, "friction": joint.friction} + "damping": joint.damping, "friction": joint.friction, + "effort": joint.effort, "velocity": joint.velocity} else: raw = joints[part_name] local_axis = list(raw["axis"]) @@ -1323,6 +1356,16 @@ def emit(target, meshes_in, name_prefix, density, material=None, link=None, range=f"{spec['min']:.4f} {spec['max']:.4f}", damping=f"{spec['damping']:.4g}", frictionloss=f"{spec['friction']:.4g}", armature="0.002") + # A JOINT NOTHING CAN DRIVE IS SCENERY. The sidecar states an effort limit — 45 N·m to + # swing this door, 800 N to raise that desk — compiled from the part the recipe built. + # It was read for nothing: the export emitted the joint and dropped the number, so the + # only way to open a door was to push it with another body. An actuator per authored + # joint is what makes the difference between a room you can look at and one a policy + # can act in. Recorded only when the sidecar SAID the effort; a joint recovered from + # the raw extras carries no such statement and inventing one would be a guess. + if "effort" in spec: + actuated.append((f"{handle}_{part_name}", float(spec["effort"]), + float(spec.get("velocity") or 0.0))) if part_link is not None: ET.SubElement(child, "inertial", pos=" ".join(f"{v:.6f}" for v in (part_link.com - pivot)), @@ -1385,7 +1428,13 @@ def emit(target, meshes_in, name_prefix, density, material=None, link=None, stats["hangings"] = [h for h, _d, _n in hangings] actuator = ET.SubElement(mujoco, "actuator") - for name in ("room_x", "room_y", "room_z"): + # `room_yaw` is driven on the same terms as the three slides. It was emitted as a joint but + # never given a servo, and `mujoco_shake` asks for `drive_room_yaw` by name — so the twist the + # joint exists for silently never happened, and the room was additionally left free to rotate + # about its vertical under whatever contact torque its contents applied. The gains carry over + # unchanged because the room's `diaginertia` about z is 50000, numerically equal to its mass, + # so kp=5e7 puts the rotational resonance at the same 5.0 Hz and kv damps it just as critically. + for name in ("room_x", "room_y", "room_z", "room_yaw"): # CRITICALLY DAMPED, AND STIFF ENOUGH TO STAY OUT OF THE WAY. The room is a 50 t body on a # position servo, which is a mass-spring: kp=2e7 put its natural frequency at 3.18 Hz and # kv=2e5 left it at a damping ratio of 0.10. The vertical drive runs at 1.93x the base @@ -1398,6 +1447,28 @@ def emit(target, meshes_in, name_prefix, density, material=None, link=None, ET.SubElement(actuator, "position", name=f"drive_{name}", joint=name, kp="5e7", kv="3.16e6") + # ONE MOTOR PER AUTHORED JOINT, AT THE EFFORT THE ASSET STATED. + # + # A `motor` rather than a `position` servo, deliberately. A servo holds a setpoint, so emitting + # one would clamp every door shut and every drawer closed at ctrl=0 — the scene would stop + # behaving the way it does today and a door would no longer swing when the room is shaken. A + # motor applies exactly `ctrl` and nothing at rest, so the passive dynamics are bit-for-bit + # what they were before this existed, and the only change is that the joint can now be driven. + # + # `ctrlrange` is the effort the recipe compiled, symmetric because these joints open and close. + # A door leaf that its own build says needs 45 N·m cannot be driven at 450 by a policy that + # discovers doing so is cheaper than opening it properly. + for joint_name, effort, velocity in actuated: + motor = ET.SubElement(actuator, "motor", name=f"act_{joint_name}", joint=joint_name, + gear="1", ctrllimited="true", + ctrlrange=f"{-abs(effort):.4g} {abs(effort):.4g}") + if velocity: + # MuJoCo has no per-actuator velocity limit, so this cannot be enforced here. It is the + # asset's own statement about how fast the part may move and the only lossless place to + # keep it is on the element itself, where a controller can read it back. + motor.set("user", f"{velocity:.6g}") + stats["actuated_joints"] = [j for j, _e, _v in actuated] + # A PICTURE DOES NOT FIGHT THE RAIL IT HANGS BESIDE. These frames are attached to WALLS, but # they are authored overlapping separate `PictureRail` fixtures at the same height — Picture1 # starts 23.6 mm inside PictureRail4. The rail shoves it out, the nail holds it back, and the @@ -1597,6 +1668,44 @@ def _look_from(name, eye_pt, look_at, fovy): xml = out / "scene.xml" ET.indent(mujoco, space=" ") xml.write_text(ET.tostring(mujoco, encoding="unicode"), encoding="utf-8") + + # WHAT FRACTION OF THIS SCENE'S PHYSICS THE ASSETS ACTUALLY STATED. Every other number in the + # report counts what was emitted; this one is the only one that says how much of it was + # invented here. It has to be computed rather than inferred by a reader subtracting two fields, + # because the honest answer on an authored room is well under half and nothing else says so. + # DOES THE SCENE WE JUST WROTE ACTUALLY LOAD CLEAN? Everything above reasons about the room + # from the layout; this is the only step that asks MuJoCo. A body emitted free that starts + # inside the structure is stored energy — the solver reads the overlap as a compressed spring + # and ejects it — and until now the first thing to notice was the stability gate, long after + # the export had reported success. Compiling the file here costs about a second and turns that + # into a number in the report. Deliberately non-fatal and best-effort: a scene that cannot be + # loaded here is still written out, because a file you can inspect beats no file at all. + try: + import mujoco # noqa: PLC0415 — optional, and only at load-check time + + _m = mujoco.MjModel.from_xml_path(str(xml)) + _d = mujoco.MjData(_m) + mujoco.mj_forward(_m, _d) + _bn = lambda g: mujoco.mj_id2name( # noqa: E731 + _m, mujoco.mjtObj.mjOBJ_BODY, _m.geom_bodyid[g]) or "?" + overlaps = {} + for _c in range(_d.ncon): + con = _d.contact[_c] + if con.dist < -0.005: + pair = " <-> ".join(sorted((_bn(con.geom1), _bn(con.geom2)))) + overlaps[pair] = min(overlaps.get(pair, 0.0), float(con.dist)) + stats["loads_clean"] = not overlaps + stats["initial_overlaps"] = [{"bodies": k, "mm": round(v * 1000, 1)} + for k, v in sorted(overlaps.items(), key=lambda kv: kv[1])] + except Exception as exc: # noqa: BLE001 — a check is not the export + stats["loads_clean"] = None + stats["load_check_error"] = f"{type(exc).__name__}: {exc}" + + derived = int(stats["colliders"]) - int(stats.get("sidecar_colliders", 0)) + stats["derived_colliders"] = max(0, derived) + stats["sidecar_coverage"] = (round(stats.get("sidecar_colliders", 0) / stats["colliders"], 3) + if stats["colliders"] else None) + (out / "export_report.json").write_text(json.dumps(stats, indent=2), encoding="utf-8") return xml diff --git a/src/litereality_agent/settings.py b/src/litereality_agent/settings.py index cf567d6..a5cb8fc 100644 --- a/src/litereality_agent/settings.py +++ b/src/litereality_agent/settings.py @@ -85,7 +85,12 @@ class LiteRealitySettings(BaseSettings): ) openai_api_key: SecretStr | None = Field(default=None, validation_alias="OPENAI_API_KEY") + gemini_api_key: SecretStr | None = Field(default=None, validation_alias="GEMINI_API_KEY") anthropic_api_key: SecretStr | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY") + # Which backend generates reference images. `models.env` documents it and `image_gen` reads it + # off the environment, but nothing carried it from `.env` INTO the subprocess that does the + # generating — so the documented Gemini path failed on a missing OpenAI key and fell back. + image_provider: str | None = Field(default=None, validation_alias="LR_IMAGE_PROVIDER") # Modal is the default execution runtime, so the app names carry the deployed defaults and # MODAL_PROFILE alone selects hosted execution. Override these only when a workspace deploys # the apps under different names. @@ -216,8 +221,10 @@ def as_environment(self) -> dict[str, str]: "MODAL_DINO_FUNCTION": self.modal_dino_function, "MODAL_ENVIRONMENT": self.modal_environment, } + values["LR_IMAGE_PROVIDER"] = self.image_provider secrets = { "OPENAI_API_KEY": self.openai_api_key, + "GEMINI_API_KEY": self.gemini_api_key, "ANTHROPIC_API_KEY": self.anthropic_api_key, # Exported so `modal deploy` and the model subprocesses authenticate from `.env` # without a ~/.modal.toml on the machine.