Brutal AI

Verifying AI-Generated 3D: The Pipeline Behind an Agent-Furnished Apartment

An agent furnished a 40 m² apartment in Blender. It looked right and was wrong at 100% zoom. This is the verification layer that made it shippable.

Brutal AI EngineeringTechnical deep dive11 min read

Summary

  • We let Gemini 4 Argon furnish a 40 m² apartment in Blender, headless on a rented GPU, and render it as an 8K walkthrough.
  • The first pass looked right and was wrong at 100% zoom: floating handles, hovering taps, chair legs above the floor, brackets mounted on top of shelves.
  • What made it shippable was a verification layer: a deterministic geometry gate, an automatic close-up of every object, a capped vision judge, and fixed rules on which check has authority.
  • Agent cost is an architecture decision. Keeping state in files and limiting sessions to 60 steps cut cost per session from about $90 to about $10 for the same output.
  • Domain experts still decide what a checker cannot: livability, fit-out conformity and local standards. We gave them direct access through an MCP server.

Architecture

Architecture diagram: a laptop starts runs on a cloud GPU pod (RTX 4090) that hosts the orchestrator, the Gemini 4 Argon agent loop, headless Blender 5.2, the verification layer, state in files, designer access over MCP and 8K panoramas, with Gemini API calls coming in and a static 360° walkthrough website as output.
Everything heavy runs on one GPU pod. The laptop only starts and monitors runs and can be closed.

Compute and persistence

A RunPod on-demand pod with an RTX 4090 (24 GB), at least 62 GB RAM, about $0.90 per hour. The container disk is wiped on every restart, so scenes, outputs, the Blender install and API keys live on an 80 GB volume. The boot command installs system libraries, links the volume into the expected paths and starts Blender in a restart loop. A separate autostart script restores keys, installs CA certificates (a fresh container has none, and HTTPS from Blender's Python fails silently without them), starts the designer stack and resumes any unfinished pipeline.

Blender as a server

Blender 5.2 runs headless with a small command server inside it, the same pattern as Blender MCP. One JSON message per TCP connection, authenticated with a shared token:

{"type": "execute_code", "params": {"code": "<bpy python>"}, "token": "..."}
→ {"status": "success", "result": "<stdout>"}

types: execute_code · put_file (base64, 24 MB chunks with append) · get_file · list_dir · info

The main server is single-threaded on purpose, because bpy is not thread-safe. That has a cost: a 10-minute render blocks everything behind it, including monitoring. A second, read-only thread on another port serves log tails and file reads, so status checks never queue behind work.

The agent loop

Gemini 4 Argon through generateContent with function calling and thinking set to high. Six tools: blender_exec, render_view (Cycles render, image returned inline), view_image, read_file, write_file, finish. The brief is a Markdown file with the task, a binding object specification and acceptance criteria.

Every session starts fresh. Continuity comes from files: the .blend scene saved after every step, and an append-only progress log with one line per finished step and the gate counts at that moment. A new session reads both and continues. An orchestrator runs up to four sessions per room until a report file exists, enforces a dollar cap parsed from the usage lines, and resumes after a restart.

Why the first pass fails

Before and after: a black basin tap hovering above the basin, then seated on the basin with the spout over the drain.
Hovering a few millimetres above the basin, then seated with the spout over the drain.

The agent's errors are not random. They come from how an LLM places objects:

  • Bounding-box placement. The model reasons about boxes and centres. Imported assets have their origin anywhere, so “put it on the worktop” lands a few millimetres high or low.
  • Group versus part. A table counts as touching the floor if any part does. Its top can rest on a vase while all four legs hover.
  • Scale. Library assets arrive in metres, centimetres or millimetres.
  • Logic. Brackets on top of a shelf, an empty switch frame, the wrong socket type. Geometrically valid, functionally wrong.
  • Wide-shot judging. A vision model rating the whole render approves all of the above, because none of it is visible at normal size.

The verification layer

1. Geometry gate

A Blender script checks one room (an xy box from the plan) after every group of changes and writes a JSON report. Objects are grouped into families by name, so the parts of a handle or a chair are judged together.

def family(name):   # sofa.001, sofa_leg_2 -> "sofa"
    return re.split(r"[_.](?:\d|leg|top|body|door|frame|rose|lever|...)", name.lower())[0]

Floating. Every family must touch something outside itself within 2 mm (nearest-point queries on BVH trees in both directions).

Not grounded. Group contact was not enough: it let table legs hover while the top touched a vase. Every part whose lowest point is under 12 cm is raycast downwards from its six lowest vertices; the worst gap must be at most 2 mm.

low = sorted(verts, key=lambda v: v.z)[:6]
worst = 0.0
for v in low:
    best = 9.0
    for other in nearby_objects:
        hit = bvh(other).ray_cast(v + Vector((0, 0, 0.0002)), Vector((0, 0, -1)), 0.06)
        if hit[0] is not None:
            best = min(best, max(0.0, hit[3] - 0.0002))
    worst = max(worst, best)
if worst > 0.002:
    report["not_grounded"].append({"object": o.name, "worst_gap_mm": round(worst * 1000, 1)})

Intersections. BVH overlap between meshes, more than 20 overlapping faces counts. A short benign list covers construction that really overlaps (trims meeting walls, recessed spots in the ceiling, objects resting on the floor); acceptance additionally allows socket wells up to 10 mm into the wall and feet up to 8 mm into rug pile. Textile-on-textile clashes are flagged separately.

Size and alignment. Regex rules per object class (seat height 0.42 to 0.49 m, door handles 0.90 to 1.10 m and so on). Numbered series such as kitchen fronts must share top and bottom edges; a spread over 2 mm is flagged.

Before and after: wall shelf brackets sitting on top of the wooden boards, then two brackets carrying each board from below.
Both versions pass the gate. Only the second can carry a plate. This is why the gate is not the only check.

2. Close-ups with line-of-sight

One camera per family. Floor items are framed on their lower third at 28° elevation; wall-mounted items (thin in x or y, off the floor) are shot head-on at 0°, so perspective never reads as tilt. A pose is accepted only if the first ray from the eye hits the family itself and the eye is not inside geometry:

for k in (0, 1, -1, 2, -2, 3, -3, 4):          # azimuth steps of 40°
    for f in (1.0, 0.8, 1.3):                  # distance factors
        eye = target + rotate_z(d, 40 * k) * dist * f + elevation_offset
        hit, loc, _, _, obj, _ = scene.ray_cast(depsgraph, eye, (target - eye).normalized(), dist_to_target)
        if (not hit or obj.name in family_names) and not inside_geometry(eye):
            accept(eye)

Before this search existed, five of 25 close-ups in one run were black: the camera sat inside the sofa or a wall, and those objects were never inspected while the run reported success. render.use_persistent_data = True is mandatory here; without it every crop re-uploads the scene to the GPU, and 25 crops take minutes instead of seconds each.

Before and after: an all-black close-up because the camera sat inside the sofa, then a clear close-up of the sofa found by the line-of-sight camera search.

3. Vision judge with limits

Each close-up goes to Gemini 3.1 Pro with a fixed checklist (floating, contact shadow, intersection, attachment, scale, orientation, materials, geometry, logic) and returns JSON with a severity per finding.

The judge invents findings. On one room it reported 21, then 29, then 33 findings across three rounds while confirmed defects went down; it called correct handles reversed and level sockets rotated. Driving it to zero majors does not converge. The rules we settled on:

  • Contact, floating, grounding and intersection: the gate is authoritative. A contradicting judge claim gets one direct measurement (distance, angle, overlap) and is dismissed with evidence.
  • Materials, missing parts, mesh artefacts, stretched textures and logic: the judge is binding.
  • “Missing contact shadow” is a lighting finding and never a reason to move a correctly placed object.
  • At most three judge rounds per room. Then a human decides, and the agent goes straight to the final render.
Before and after: a double light-switch frame with an empty hole instead of a rocker, then two rockers sitting flush in one frame.

4. Realism scores are for A/B, not acceptance

A blind realism judge calibrated with anchors gave real photos 6 to 7 out of 10, professionally staged real listings about 4 and our renders 3 to 4. It penalises staging itself. We use it to choose between candidate assets in the same scene, never as a target.

Cost model

Without caching, the agent resends the full history every step, images included. Input per step grows roughly linearly with the step number, so total input tokens grow roughly with the square of the session length.

SettingMeasured
150-step sessionabout 45 M input tokens, about $90
60-step session, state in filesabout 4.7 M input tokens, about $10
One room through the full loopabout $30 to $40 model cost
Gemini 4 Argon list price$2 / $10 per M input / output tokens until 30 Nov 2026, then $4 / $20; cached input 95% off
GPUabout $0.90 per hour
Preview render 1600 × 1067, 96 spp20 to 30 s
8K panorama, 256 spp + OIDNabout 8.5 min

Next steps on cost: context caching, dropping old renders from the context, summarising tool outputs. Most input tokens are stale images.

Expert access through MCP

The interior designer works through her own Claude. An MCP server (FastMCP from the official Python SDK, pinned below 2.x, streamable HTTP, stateless) runs on the pod behind the provider's HTTPS proxy. The URL contains a random secret path; everything else returns 404.

  • Isolation: a second Blender instance on a copy of the scene, so the designer never collides with the pipeline.
  • Tools (25): overview, render_view, render_panorama, list_furniture, check_room (the same gate), move, remove and place furniture from BlenderKit or our own library, material search and assignment (Poly Haven and ambientCG, up to 8K), lighting moods, undo, variants, reset, an expert Python tool, and Argon tasks with a budget cap.
  • Undo: every edit snapshots the scene first (last six versions) and saves after.
  • Proxy timeout: the proxy drops requests after about 30 s. Every tool runs in a thread pool; if it finishes in 20 s the result comes back directly, otherwise a job id that the client collects with get_result.
fut = POOL.submit(tool_fn, *args)
result = await wait(fut, timeout=20)
if result is None and not fut.done():
    JOBS[job_id] = fut
    return f"still running, call get_result(job_id='{job_id}')"

Failure log

SymptomCauseFix
Hours of work lostContainer crash, scene on the wiped container diskEverything on a persistent volume, more RAM
Monitoring hung for 30+ minSingle-threaded server busy renderingRead-only status port
Objects never inspectedCrop camera inside geometry, black imagesLine-of-sight pose search
Two agents on one scenepkill pattern inside sh -c killed its own shellKill by PID from /proc; one writer per scene
Finished job shown as runningZombie child: kill(pid, 0) succeeds on zombieswaitpid(WNOHANG) and /proc state check
Sessions failing for two hours at $0No CA certificates after a restartInstall them in autostart; check for real progress
Connector calls cut offProxy timeout around 30 sJob pattern with get_result
403 responsesDefault Python-urllib user agent blockedExplicit user agent
Cost spike overnight150-step sessions, full history resent60-step sessions with state in files

What a domain expert checks

Plan fidelity, clearances, ergonomic heights, fixtures and local standards (lock side, Schuko, tilt-and-turn handles), mounting logic, material scale, light plausibility, fit-out conformity with the developer's specification, and livability. The last one produced the most useful feedback: “you can tell it's rendered, like a Sims game”. Geometry was correct; the room did not feel like a home.

Before and after wide shots of the living and dining area: the judge-approved first version, and the version after the gate, close-ups and human review.
The wide shot barely changes before and after the loop. The difference is in the details.

What we build next

  • Constraint-based placement: the model chooses and describes the layout, a solver produces coordinates with wall contact, clearances and grounding built in.
  • Vetted product library: real products with verified dimensions and licences, gate-checked once, reused across projects.
  • Regression harness: gate plus close-ups as a test suite per room, run after every change, with the allow-list in the repository.
  • Ops: network volume instead of a pod volume, secrets in a secret manager, alerts on pod status and balance.

Argon figures: Google, “Gemini 4 Argon: Built to solve your hardest problems” (blog.google) and Google Developers Blog, “Gemini 4 Argon for robotics”. All other numbers are our measurements.

Building with agents in 3D?

We build the pipelines, verification layers and expert tooling that make agent output shippable. Tell us what you are rendering.