# Signature 360

> Build a production 360 virtual tour from a Blender scene to a working web viewer, the way the the cinematic reference residence tour was built: a persistent headless Blender server, equirectangular Cycles renders in quality tiers, generation-prefixed publishing with instant rollback, and a dependency-free WebGL panorama viewer with doorway-aimed hotspots, a floor-plan minimap and a view cone. Trigger on "360 tour", "virtual tour", "panorama", "equirect", "walkthrough of the apartment", "make a 3D showroom", "render the rooms", "panorama viewer", "Blender render pipeline", "photosphere", or when a property, hotel, venue or interior needs a navigable 360 experience on the web. Do NOT use for video-based tours or for generic Blender modelling help. For the surrounding website design use signature-design instead.

- Skill: `sva-admin/signature-360` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add sva-admin/signature-360`
- Raw SKILL.md: https://api.skillmd.com/api/skills/sva-admin/signature-360/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: sva-admin (https://skillmd.com/u/sva-admin)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/sva-admin/signature-360

---


# Signature 360

A working eight-room residence tour, rendered in Blender and delivered as one
HTML file with no 3D library. This skill is the pipeline, including the
mistakes, because the mistakes cost more than the code did.

Drawn from a shipped client project. The project itself is private, so the room
names, coordinates and figures below are illustrative; the settings, the
measured costs and the failure modes are real.

## What the pipeline actually is

```
.blend scene
   |  persistent headless Blender server on a dedicated port
   |  detached python drivers, one per render generation
   v
pNN-<room>.jpg          4096x2048 equirectangular, Cycles
pNN-<room>-m.jpg        2048x1024 mobile variant, made at publish time
   |  publisher rewrites ONE string in the viewer
   v
tour.html               dependency-free WebGL, hotspots, minimap, view cone
```

Four ideas carry the whole thing: **a Blender that stays alive between calls**,
**quality tiers so you never wait for a look-dev render**, **generation prefixes
so publishing and rollback are one string change**, and **a viewer that aims its
hotspots at doorways rather than at cameras**.

## The three laws

These are the expensive ones. Everything else you can rediscover cheaply.

### 1. Trust the output file, never the client

**"Render stalled" is almost always false.** Blender finishes the render and
writes the file, while the python client times out and reports
`[Errno 32] Broken pipe` immediately after a `Saved:` line. One real log shows
the same room rendered three times, each render succeeding, each reported as a
failure, each triggering another restart. Roughly 15 GPU-minutes wasted in a
single excerpt, and hours across the project.

The cause is architectural: the client's receive loop breaks on timeout and
closes the socket while the server is still inside `exec`, so the server's
`sendall` hits a dead peer. Do not try to fix it in the socket layer. **Poll the
output file's modification time and size instead.**

Encode it as a resume rule so a killed batch never redoes finished work:

```python
p = os.path.join(OUT, 'p32-%s.jpg' % room)
if os.path.exists(p) and os.path.getsize(p) > 400000:
    continue   # a 4096x2048 q92 JPEG is 380 to 810 KB
```

### 2. When a render looks cheap, it is almost never the materials

The single most reusable diagnosis in the archive:

> Resolution deficit, not material flatness, was the primary visual quality
> issue. A 2048-wide panorama shows only **427 real pixels** at 75 degree FOV on
> a 1440px display, a 3.4x upscale.

Everyone reads that blur as "the materials look flat" and starts rewriting
shaders. The fix is pixels. Render at 4096x2048 minimum, 6144x3072 if you want
pixel-native display on a large screen.

When resolution is not the problem, a scored comparison against a good reference
render put the remaining gap here, and it generalises well:

| Missing element | Points |
|---|---|
| Furniture geometry (cushion sag, pillow detail, real chaise not a flat box) | 2.0 |
| Styling and props (a glass, a throw, a book, rather than an empty surface) | 1.0 |
| Lighting design (a four-layer scheme rather than downlights only) | 1.0 |
| Curtains with real folds and daylight through them | 0.5 |
| Camera and grade (depth of field, balanced exposure) | 0.5 |

Note that **albedo and material tweaking is not on the list**. Spend the effort
on geometry and props.

### 3. Never AI-upscale an equirectangular image

Upscalers hallucinate different detail on the left and right edges of the wrap
seam, so the panorama no longer joins, and they smear the poles. Native 8K at
about 238 seconds per panorama is time-comparable to upscaling and
artifact-free. This also rules out most generative video tools for tour work:
one was measured at $0.85/s with a 30 second cap and no equirect output at all.

## Render tiers

Never wait for a publish render while doing look-dev. Three tiers, all measured
on this project:

| Tier | Resolution | Samples | Time per room | Use |
|---|---|---|---|---|
| Look-dev | 4096x2048 | 64 | ~25 s | Is the change in the right direction |
| Rehearsal | 2048x1024 | 128 | ~35 s | Composition and lighting review |
| Publish | 4096x2048 | 448 to 512 | 5 to 17 min | The real thing |

Full publish settings:

```python
S.render.engine = 'CYCLES'                      # GPU, MetalRT on Apple silicon
S.cycles.samples = 448                          # 512 on earlier generations
S.cycles.use_denoising = True                   # OpenImageDenoise, Albedo + Normal
S.render.resolution_x, S.render.resolution_y = 4096, 2048
S.render.image_settings.file_format = 'JPEG'
S.render.image_settings.quality = 92
# view transform AgX, look "AgX - Punchy", exposure -1.70
```

Light paths: total 12, diffuse 6 to 8, glossy 6, transmission 8 to 12, filter
glossy 0.5 to 1.0, clamp indirect 10, adaptive threshold 0.01 (0.005 for
finals), min samples 64. **Caustics off**, both reflective and refractive.

Two measured findings worth not re-deriving:

- **512 samples is not worth double the time.** Noise falls as `1/sqrt(samples)`:
  0.264 at 64, 0.142 at 256. Beyond 256 the gain is imperceptible in a viewer
  where the user is panning.
- **Light portals did nothing here**, 1.3% noise improvement at 64 spp and 0.5%
  at 256, because floor-to-ceiling glazing already samples the environment
  trivially. They can even be slower.

Rooms cost very different amounts: a balcony is mostly sky and renders in ~4.6
minutes, while a small bathroom full of glossy stone takes ~17.5. Budget by room
type, not by count.

The equirectangular camera type lives in the `.blend` camera datablocks
(`pano_<room>`), not in the scripts. Scripts only select the camera and set
resolution. Set `clip_end` generously (60,000 m here) or the default 1 km will
cut the sea off below the horizon.

## What this skill bundles

| File | What it is |
|---|---|
| `scripts/blserver.py` | The persistent headless Blender server. Copy it next to your `.blend` files and launch it with `--python`. Reusable as-is. |
| `scripts/bl.py` | The client. `bl.run(code)` executes python inside the live Blender and returns whatever it printed. Also `bl.rendered(path)`, the file-based completeness check that replaces trusting the socket. |
| `references/pipeline-ops.md` | Ports, detached launches, generation prefixes, publishing, checkpoints, QA. |
| `references/viewer.md` | The web viewer: shader, hotspots, minimap, input, onboarding, embedding. |

Quick start:

```bash
cp ~/.claude/skills/signature-360/scripts/{blserver.py,bl.py} ./tour-pipeline/
lsof -iTCP:9877 -sTCP:LISTEN            # must be empty before you start
nohup /Applications/Blender.app/Contents/MacOS/Blender \
  --background checkpoints/current.blend --python blserver.py > blserver.log 2>&1 &
# wait for "listening" in blserver.log, then:
BLSERVER_PORT=9877 python3 bl.py info
```

## Operating the pipeline

The mechanics, the port discipline, the detached-launch rules and the publisher
are in `references/pipeline-ops.md`. The short version:

- Headless Blender listens on a **dedicated port** with `scripts/blserver.py`.
  The stock Blender MCP addon refuses to serve in `--background`, which is why
  this 43-line replacement exists.
- **Never share a port with a GUI Blender.** A GUI instance squatting the
  default port once answered production render calls with its own default cube
  and overwrote a checkpoint.
- **Long renders run detached**, never through a foreground shell call, because
  the harness kills at 600 seconds and rooms take 11 to 16 minutes.
- **The socket is serial.** During a render every other call times out with "no
  data". That means busy, retry later. It is not a fault to diagnose.

## The viewer

`references/viewer.md` has the full design. The two ideas that matter most:

**Aim hotspots at the doorway, not at the destination camera.** The naive
implementation put the hall-to-bedroom marker 40.7 degrees off, flat on a blank
wall. Give each link a `via` waypoint holding the coordinates of the actual
opening.

**A room with one exit needs an explicit arrival yaw.** Normally you arrive
facing the way you travelled, but in a bathroom whose arch is the only way out,
that leaves every exit off screen and the room reads as having no way out.

## Checkpoints and scripts

- Checkpoint `.blend` files are numbered and named for what they contain
  (`22_before7fixes.blend`, `23_sevenfixes.blend`). Always keep the
  immediately-prior state as the rollback point, and pack textures so a
  checkpoint is self-contained.
- **Every mutation script is idempotent and prints before and after.** The
  server captures stdout and returns it as the call's result, so a script that
  prints is a script you can audit. A silent mutation is unreviewable and cannot
  be safely re-run.
- Write fix scripts to be coordinate-free where possible: measure the room from
  its own walls rather than hardcoding positions, so the script survives a scene
  edit.

## The loop

1. Fix the scene with an idempotent, printing script at look-dev quality.
2. Rehearse the whole set at 128 spp and actually look at the images.
3. Render the publish tier detached, into a new generation prefix.
4. QA every room: ten views per room, checking seams, poles, exits, and that
   every hotspot lands on an opening.
5. Publish by rewriting the one prefix string, and generate mobile variants.
6. Load the live URL on a real phone viewport before calling it done.

Rollback at any point is a single `sed` back to the previous prefix, which is
the entire reason the prefix scheme exists.

