BolyosCsaba Claude Sonnet 4.6 commited on
Commit ·
471b2cd
1
Parent(s): 4f032fd
fix: replace Three.js WebGL studio with pure SVG — zero dependencies
Browse filesThree.js r167/r184 CDN URLs all 404'd; the new build dropped the UMD
global bundle entirely. SVG isometric studio (adapted from studio-v4)
renders instantly in any browser and inside sandboxed srcdoc iframes
with no external requests, no module loading, and 16 KB total HTML.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
studio.py
CHANGED
|
@@ -1,543 +1,413 @@
|
|
| 1 |
"""
|
| 2 |
-
Self-contained
|
| 3 |
-
Three.js
|
| 4 |
-
self-contained and works in srcdoc iframes without any external requests.
|
| 5 |
"""
|
| 6 |
import json
|
| 7 |
-
import urllib.request
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
|
| 11 |
-
def _fetch_threejs() -> str:
|
| 12 |
-
# Try local file first (committed to repo)
|
| 13 |
-
local = Path(__file__).parent / "sandbox_cache" / "three.min.js"
|
| 14 |
-
if local.exists() and local.stat().st_size > 100_000:
|
| 15 |
-
print("[studio] Using local three.min.js")
|
| 16 |
-
return local.read_text()
|
| 17 |
-
# Try CDNs
|
| 18 |
-
cdns = [
|
| 19 |
-
"https://cdnjs.cloudflare.com/ajax/libs/three.js/r167/three.min.js",
|
| 20 |
-
"https://unpkg.com/three@0.167.0/build/three.min.js",
|
| 21 |
-
"https://cdn.jsdelivr.net/npm/three@0.167.0/build/three.min.js",
|
| 22 |
-
]
|
| 23 |
-
for url in cdns:
|
| 24 |
-
try:
|
| 25 |
-
with urllib.request.urlopen(url, timeout=15) as r:
|
| 26 |
-
data = r.read().decode("utf-8")
|
| 27 |
-
if len(data) > 100_000:
|
| 28 |
-
# Cache locally for next startup
|
| 29 |
-
try:
|
| 30 |
-
local.write_text(data)
|
| 31 |
-
except Exception:
|
| 32 |
-
pass
|
| 33 |
-
print(f"[studio] Fetched Three.js from {url} ({len(data):,} bytes)")
|
| 34 |
-
return data
|
| 35 |
-
except Exception as e:
|
| 36 |
-
print(f"[studio] CDN {url} failed: {e}")
|
| 37 |
-
print("[studio] ERROR: All Three.js sources failed")
|
| 38 |
-
return "console.error('[IVDS] Three.js failed to load — studio will not render');"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
def _load_characters_js() -> str:
|
| 42 |
p = Path(__file__).parent / "sandbox_cache" / "characters.js"
|
| 43 |
-
if p.exists()
|
| 44 |
-
return p.read_text()
|
| 45 |
-
return "/* characters.js not found */"
|
| 46 |
|
| 47 |
|
| 48 |
-
print("[studio] Loading Three.js from CDN...")
|
| 49 |
-
_THREEJS_JS = _fetch_threejs()
|
| 50 |
_CHARACTERS_JS = _load_characters_js()
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
def build_studio_html(model_assignments: list[dict]) -> str:
|
| 55 |
"""
|
| 56 |
model_assignments: list of dicts:
|
| 57 |
-
{
|
| 58 |
Returns self-contained HTML string.
|
| 59 |
"""
|
| 60 |
assignments_json = json.dumps(model_assignments)
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return f"""<!DOCTYPE html>
|
| 63 |
<html lang="en">
|
| 64 |
<head>
|
| 65 |
<meta charset="UTF-8">
|
| 66 |
-
<meta name="viewport" content="width=device-width,
|
| 67 |
-
<title>
|
| 68 |
<style>
|
| 69 |
-
* {{
|
| 70 |
-
body {{ background:
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
.speech-bubble {{
|
| 74 |
-
position:
|
| 75 |
-
border:
|
| 76 |
-
font-size:
|
| 77 |
-
box-shadow:
|
| 78 |
-
|
| 79 |
}}
|
| 80 |
.speech-bubble::after {{
|
| 81 |
-
content:
|
| 82 |
-
border:
|
| 83 |
}}
|
| 84 |
.floating-popup {{
|
| 85 |
-
position:
|
| 86 |
-
font-size:
|
| 87 |
-
animation:
|
| 88 |
}}
|
| 89 |
@keyframes floatUp {{
|
| 90 |
-
0% {{ transform:
|
| 91 |
-
100% {{ transform:
|
| 92 |
}}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
}}
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
#phase-fill {{ height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.5s; }}
|
| 102 |
</style>
|
| 103 |
</head>
|
| 104 |
<body>
|
| 105 |
-
<
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
<div id="phase-bar">
|
| 108 |
<span id="phase-label">Waiting…</span>
|
| 109 |
<div id="phase-progress"><div id="phase-fill" style="width:0%"></div></div>
|
| 110 |
</div>
|
| 111 |
|
| 112 |
-
<script>{_THREEJS_JS}</script>
|
| 113 |
-
<script>{_CHARACTERS_JS}</script>
|
| 114 |
<script>
|
| 115 |
-
const assignments
|
| 116 |
-
|
| 117 |
-
const DESK_POSITIONS = [
|
| 118 |
-
[-2.5, 0, -1.5],
|
| 119 |
-
[-0.8, 0, -1.5],
|
| 120 |
-
[ 0.8, 0, -1.5],
|
| 121 |
-
[ 2.0, 0, 0.5],
|
| 122 |
-
[-1.5, 0, 1.0],
|
| 123 |
-
];
|
| 124 |
-
|
| 125 |
-
// Scene setup
|
| 126 |
-
const canvas = document.getElementById('studio-canvas');
|
| 127 |
-
const renderer = new THREE.WebGLRenderer({{ canvas, antialias: false }});
|
| 128 |
-
renderer.toneMapping = THREE.NoToneMapping;
|
| 129 |
-
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
| 130 |
-
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 131 |
-
|
| 132 |
-
const scene = new THREE.Scene();
|
| 133 |
-
scene.background = new THREE.Color(0x1a1a2e);
|
| 134 |
-
|
| 135 |
-
// Isometric camera
|
| 136 |
-
const aspect = window.innerWidth / window.innerHeight;
|
| 137 |
-
const zoom = 8;
|
| 138 |
-
const camera = new THREE.OrthographicCamera(
|
| 139 |
-
-aspect * zoom, aspect * zoom,
|
| 140 |
-
zoom, -zoom,
|
| 141 |
-
0.1, 100
|
| 142 |
-
);
|
| 143 |
-
camera.position.set(10, 8.165, 10);
|
| 144 |
-
camera.lookAt(0, 0, 0);
|
| 145 |
-
|
| 146 |
-
// Lighting
|
| 147 |
-
const ambientLight = new THREE.AmbientLight(0xffeedd, 0.8);
|
| 148 |
-
scene.add(ambientLight);
|
| 149 |
-
|
| 150 |
-
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
|
| 151 |
-
dirLight.position.set(8, 12, 6);
|
| 152 |
-
scene.add(dirLight);
|
| 153 |
-
|
| 154 |
-
// Floor (InstancedMesh)
|
| 155 |
-
const floorGeo = new THREE.PlaneGeometry(2, 2);
|
| 156 |
-
floorGeo.rotateX(-Math.PI / 2);
|
| 157 |
-
const floorMat = new THREE.MeshLambertMaterial({{ color: 0xc8924a }});
|
| 158 |
-
const floorMesh = new THREE.InstancedMesh(floorGeo, floorMat, 16);
|
| 159 |
-
|
| 160 |
-
let idx = 0;
|
| 161 |
-
const matrix = new THREE.Matrix4();
|
| 162 |
-
for (let x = 0; x < 4; x++) {{
|
| 163 |
-
for (let z = 0; z < 4; z++) {{
|
| 164 |
-
matrix.setPosition(x * 2 - 3, 0, z * 2 - 3);
|
| 165 |
-
floorMesh.setMatrixAt(idx++, matrix);
|
| 166 |
-
}}
|
| 167 |
-
}}
|
| 168 |
-
floorMesh.instanceMatrix.needsUpdate = true;
|
| 169 |
-
scene.add(floorMesh);
|
| 170 |
-
|
| 171 |
-
// Walls
|
| 172 |
-
const wallMat = new THREE.MeshToonMaterial({{ color: 0xdce8c4 }});
|
| 173 |
-
|
| 174 |
-
const backWall = new THREE.Mesh(
|
| 175 |
-
new THREE.BoxGeometry(8, 4, 0.2),
|
| 176 |
-
wallMat
|
| 177 |
-
);
|
| 178 |
-
backWall.position.set(0, 2, -4);
|
| 179 |
-
scene.add(backWall);
|
| 180 |
-
|
| 181 |
-
const rightWall = new THREE.Mesh(
|
| 182 |
-
new THREE.BoxGeometry(0.2, 4, 8),
|
| 183 |
-
wallMat
|
| 184 |
-
);
|
| 185 |
-
rightWall.position.set(4, 2, 0);
|
| 186 |
-
scene.add(rightWall);
|
| 187 |
-
|
| 188 |
-
// Helper to add boxes
|
| 189 |
-
function addBox(w, h, d, color, x, y, z) {{
|
| 190 |
-
const mesh = new THREE.Mesh(
|
| 191 |
-
new THREE.BoxGeometry(w, h, d),
|
| 192 |
-
new THREE.MeshToonMaterial({{ color }})
|
| 193 |
-
);
|
| 194 |
-
mesh.position.set(x, y, z);
|
| 195 |
-
scene.add(mesh);
|
| 196 |
-
return mesh;
|
| 197 |
-
}}
|
| 198 |
|
| 199 |
-
//
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
new THREE.MeshToonMaterial({{ color: 0x27ae60 }})
|
| 209 |
-
);
|
| 210 |
-
foliage1.position.set(3.0, 0.35, -3.5);
|
| 211 |
-
scene.add(foliage1);
|
| 212 |
-
|
| 213 |
-
const pot2 = addBox(0.2, 0.15, 0.2, 0x8b4513, 3.5, 0.075, -2.8);
|
| 214 |
-
const foliage2 = new THREE.Mesh(
|
| 215 |
-
new THREE.SphereGeometry(0.2, 8, 8),
|
| 216 |
-
new THREE.MeshToonMaterial({{ color: 0x27ae60 }})
|
| 217 |
-
);
|
| 218 |
-
foliage2.position.set(3.5, 0.35, -2.8);
|
| 219 |
-
scene.add(foliage2);
|
| 220 |
-
|
| 221 |
-
// Whiteboards
|
| 222 |
-
addBox(0.8, 0.5, 0.05, 0xf5f5f5, -1.0, 2.0, -3.95);
|
| 223 |
-
addBox(0.8, 0.5, 0.05, 0xf5f5f5, 1.5, 2.0, -3.95);
|
| 224 |
-
|
| 225 |
-
// Result monitor
|
| 226 |
-
addBox(0.4, 0.6, 0.1, 0x2c3e50, 3.5, 1.8, -3.5);
|
| 227 |
-
const monitorScreen = addBox(0.35, 0.5, 0.02, 0x8e44ad, 3.5, 1.8, -3.42);
|
| 228 |
-
monitorScreen.material.emissive = new THREE.Color(0x8e44ad);
|
| 229 |
-
monitorScreen.material.emissiveIntensity = 0.3;
|
| 230 |
-
|
| 231 |
-
// Pizza box (hidden initially)
|
| 232 |
-
const pizzaBox = addBox(0.3, 0.05, 0.3, 0xf39c12, DESK_POSITIONS[1][0], 1.0, DESK_POSITIONS[1][2]);
|
| 233 |
-
pizzaBox.visible = false;
|
| 234 |
-
|
| 235 |
-
// Build desks and characters
|
| 236 |
-
const characters = [];
|
| 237 |
-
|
| 238 |
-
function buildDesk(x, y, z) {{
|
| 239 |
-
const deskGroup = new THREE.Group();
|
| 240 |
-
|
| 241 |
-
// Tabletop
|
| 242 |
-
const top = new THREE.Mesh(
|
| 243 |
-
new THREE.BoxGeometry(1.0, 0.08, 0.6),
|
| 244 |
-
new THREE.MeshToonMaterial({{ color: 0x8b6f47 }})
|
| 245 |
-
);
|
| 246 |
-
top.position.set(x, 0.9, z);
|
| 247 |
-
deskGroup.add(top);
|
| 248 |
-
|
| 249 |
-
// Legs
|
| 250 |
-
const legGeo = new THREE.BoxGeometry(0.05, 0.9, 0.05);
|
| 251 |
-
const legMat = new THREE.MeshToonMaterial({{ color: 0x5a4a2a }});
|
| 252 |
-
const offsets = [
|
| 253 |
-
[-0.45, 0, -0.25],
|
| 254 |
-
[ 0.45, 0, -0.25],
|
| 255 |
-
[-0.45, 0, 0.25],
|
| 256 |
-
[ 0.45, 0, 0.25],
|
| 257 |
-
];
|
| 258 |
-
offsets.forEach(([ox, oy, oz]) => {{
|
| 259 |
-
const leg = new THREE.Mesh(legGeo, legMat);
|
| 260 |
-
leg.position.set(x + ox, 0.45, z + oz);
|
| 261 |
-
deskGroup.add(leg);
|
| 262 |
-
}});
|
| 263 |
-
|
| 264 |
-
// Small monitor on desk
|
| 265 |
-
const mon = new THREE.Mesh(
|
| 266 |
-
new THREE.BoxGeometry(0.15, 0.12, 0.02),
|
| 267 |
-
new THREE.MeshToonMaterial({{ color: 0x2c3e50 }})
|
| 268 |
-
);
|
| 269 |
-
mon.position.set(x, 1.0, z - 0.15);
|
| 270 |
-
deskGroup.add(mon);
|
| 271 |
-
|
| 272 |
-
scene.add(deskGroup);
|
| 273 |
-
return deskGroup;
|
| 274 |
-
}}
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
const body = new THREE.Mesh(
|
| 280 |
-
new THREE.BoxGeometry(0.35, 0.4, 0.25),
|
| 281 |
-
new THREE.MeshToonMaterial({{ color }})
|
| 282 |
-
);
|
| 283 |
-
body.position.y = 1.1;
|
| 284 |
-
charGroup.add(body);
|
| 285 |
-
|
| 286 |
-
const head = new THREE.Mesh(
|
| 287 |
-
new THREE.BoxGeometry(0.28, 0.28, 0.28),
|
| 288 |
-
new THREE.MeshToonMaterial({{ color }})
|
| 289 |
-
);
|
| 290 |
-
head.position.y = 1.45;
|
| 291 |
-
charGroup.add(head);
|
| 292 |
-
|
| 293 |
-
return charGroup;
|
| 294 |
-
}}
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
}}
|
| 311 |
-
}} catch (e) {{
|
| 312 |
-
console.warn('Character fn failed:', e);
|
| 313 |
-
charMesh = buildFallbackCharacter(assignment.color);
|
| 314 |
-
}}
|
| 315 |
-
|
| 316 |
-
charMesh.position.set(x + 0.3, 0, z + 0.2);
|
| 317 |
-
scene.add(charMesh);
|
| 318 |
-
|
| 319 |
-
characters.push({{
|
| 320 |
-
mesh: charMesh,
|
| 321 |
-
role: assignment.role,
|
| 322 |
-
color: assignment.color,
|
| 323 |
-
bubbleEl: null,
|
| 324 |
-
_bobPhase: Math.random() * Math.PI * 2,
|
| 325 |
-
activePhase: false,
|
| 326 |
-
_typeBuffer: ''
|
| 327 |
-
}});
|
| 328 |
-
}});
|
| 329 |
|
| 330 |
-
|
| 331 |
-
const
|
| 332 |
-
const
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
bubble.className = 'speech-bubble';
|
| 336 |
-
bubbleLayer.appendChild(bubble);
|
| 337 |
-
bubblePool.push(bubble);
|
| 338 |
}}
|
| 339 |
|
| 340 |
-
function
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
}}
|
| 343 |
|
| 344 |
-
// Floating popup pool
|
| 345 |
const popupPool = [];
|
| 346 |
-
for (let i = 0; i <
|
| 347 |
-
const
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
bubbleLayer.appendChild(
|
| 351 |
-
popupPool.push(
|
| 352 |
}}
|
| 353 |
|
| 354 |
-
function spawnPopup(text, color,
|
| 355 |
const popup = popupPool.find(p => p.style.display === 'none') || popupPool[0];
|
|
|
|
| 356 |
popup.textContent = text;
|
| 357 |
popup.style.backgroundColor = color;
|
| 358 |
-
popup.style.left = x + 'px';
|
| 359 |
-
popup.style.top
|
| 360 |
popup.style.display = 'block';
|
| 361 |
popup.style.animation = 'none';
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
}},
|
| 365 |
-
setTimeout(() => {{
|
| 366 |
-
popup.style.display = 'none';
|
| 367 |
-
}}, 2000);
|
| 368 |
}}
|
| 369 |
|
| 370 |
-
//
|
| 371 |
-
const
|
| 372 |
-
const phaseFill = document.getElementById('phase-fill');
|
| 373 |
-
|
| 374 |
-
// Project to screen coords
|
| 375 |
-
function projectToScreen(worldPos) {{
|
| 376 |
-
const vector = worldPos.clone();
|
| 377 |
-
vector.project(camera);
|
| 378 |
-
return {{
|
| 379 |
-
x: (vector.x * 0.5 + 0.5) * window.innerWidth,
|
| 380 |
-
y: (-vector.y * 0.5 + 0.5) * window.innerHeight
|
| 381 |
-
}};
|
| 382 |
-
}}
|
| 383 |
|
| 384 |
-
// studioUpdate event router
|
| 385 |
window.studioUpdate = function(msg) {{
|
| 386 |
if (msg.type === 'text') {{
|
| 387 |
-
const
|
| 388 |
-
if (
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
}}
|
| 394 |
-
|
| 395 |
-
if (!char.bubbleEl) {{
|
| 396 |
-
char.bubbleEl = getBubble();
|
| 397 |
-
}}
|
| 398 |
-
|
| 399 |
-
const lines = char._typeBuffer.split('\\n').slice(-2);
|
| 400 |
-
char.bubbleEl.textContent = lines.join('\\n');
|
| 401 |
-
char.bubbleEl.style.display = 'block';
|
| 402 |
-
char.bubbleEl.style.opacity = '1';
|
| 403 |
-
|
| 404 |
-
clearTimeout(char._fadeTimer);
|
| 405 |
-
char._fadeTimer = setTimeout(() => {{
|
| 406 |
-
char.bubbleEl.style.opacity = '0';
|
| 407 |
-
setTimeout(() => {{
|
| 408 |
-
char.bubbleEl.style.display = 'none';
|
| 409 |
-
}}, 500);
|
| 410 |
-
}}, 1500);
|
| 411 |
}}
|
| 412 |
-
|
| 413 |
else if (msg.type === 'phase_start') {{
|
| 414 |
phaseLabel.textContent = `Phase ${{msg.phase}}: ${{msg.name}}`;
|
| 415 |
-
|
| 416 |
-
phaseFill.style.width = progress + '%';
|
| 417 |
-
|
| 418 |
-
if (msg.role) {{
|
| 419 |
-
characters.forEach(c => c.activePhase = false);
|
| 420 |
-
const char = characters.find(c => c.role === msg.role);
|
| 421 |
-
if (char) char.activePhase = true;
|
| 422 |
-
}}
|
| 423 |
-
|
| 424 |
if (msg.phase >= 7) {{
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
| 426 |
}}
|
| 427 |
}}
|
| 428 |
-
|
| 429 |
else if (msg.type === 'phase_complete') {{
|
| 430 |
-
|
| 431 |
-
spawnPopup(`✓ ${{msg.name}}`, '#f39c12', pos.x, pos.y);
|
| 432 |
}}
|
| 433 |
-
|
| 434 |
else if (msg.type === 'commit') {{
|
| 435 |
-
|
| 436 |
-
spawnPopup(`📁 ${{msg.file}}`, '#27ae60', pos.x, pos.y);
|
| 437 |
}}
|
| 438 |
-
|
| 439 |
else if (msg.type === 'error') {{
|
| 440 |
-
const
|
| 441 |
-
if (
|
| 442 |
-
if (!char.bubbleEl) char.bubbleEl = getBubble();
|
| 443 |
-
char.bubbleEl.textContent = `❌ ${{msg.text}}`;
|
| 444 |
-
char.bubbleEl.style.display = 'block';
|
| 445 |
-
char.bubbleEl.style.opacity = '1';
|
| 446 |
-
char.bubbleEl.style.borderColor = '#e74c3c';
|
| 447 |
-
}}
|
| 448 |
}}
|
| 449 |
-
|
| 450 |
else if (msg.type === 'done') {{
|
| 451 |
phaseLabel.textContent = '✅ Generation Complete!';
|
| 452 |
phaseFill.style.width = '100%';
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
characters.forEach((char, i) => {{
|
| 456 |
-
setTimeout(() => {{
|
| 457 |
-
const startY = char.mesh.position.y;
|
| 458 |
-
const jumpDuration = 500;
|
| 459 |
-
const jumpHeight = 0.5;
|
| 460 |
-
const startTime = Date.now();
|
| 461 |
-
|
| 462 |
-
function jumpAnim() {{
|
| 463 |
-
const elapsed = Date.now() - startTime;
|
| 464 |
-
const progress = Math.min(elapsed / jumpDuration, 1);
|
| 465 |
-
const eased = Math.sin(progress * Math.PI);
|
| 466 |
-
char.mesh.position.y = startY + eased * jumpHeight;
|
| 467 |
-
|
| 468 |
-
if (progress < 1) {{
|
| 469 |
-
requestAnimationFrame(jumpAnim);
|
| 470 |
-
}} else {{
|
| 471 |
-
char.mesh.position.y = startY;
|
| 472 |
-
}}
|
| 473 |
-
}}
|
| 474 |
-
jumpAnim();
|
| 475 |
-
}}, i * 100);
|
| 476 |
-
}});
|
| 477 |
-
|
| 478 |
-
if (window.onStudioDone) {{
|
| 479 |
-
window.onStudioDone();
|
| 480 |
-
}}
|
| 481 |
}}
|
| 482 |
-
|
| 483 |
else if (msg.type === 'cancelled') {{
|
| 484 |
phaseLabel.textContent = '⛔ Cancelled';
|
| 485 |
}}
|
| 486 |
}};
|
| 487 |
|
| 488 |
-
// Listen for messages from parent (Gradio bridge)
|
| 489 |
window.addEventListener('message', function(e) {{
|
| 490 |
-
if (e.data && e.data.type)
|
| 491 |
-
window.studioUpdate(e.data);
|
| 492 |
-
}}
|
| 493 |
}});
|
| 494 |
|
| 495 |
-
//
|
| 496 |
-
const clock = new THREE.Clock();
|
| 497 |
-
const tmpVec = new THREE.Vector3();
|
| 498 |
-
|
| 499 |
-
function animate() {{
|
| 500 |
-
requestAnimationFrame(animate);
|
| 501 |
-
const t = clock.getElapsedTime();
|
| 502 |
-
|
| 503 |
-
// Monitor glow pulse
|
| 504 |
-
monitorScreen.material.emissiveIntensity = 0.2 + 0.2 * Math.sin(t * 2);
|
| 505 |
-
|
| 506 |
-
// Character idle bob and wobble
|
| 507 |
-
characters.forEach((ch, i) => {{
|
| 508 |
-
ch._bobPhase += 0.03;
|
| 509 |
-
ch.mesh.position.y = Math.sin(ch._bobPhase) * 0.04;
|
| 510 |
-
|
| 511 |
-
if (ch.activePhase) {{
|
| 512 |
-
ch.mesh.rotation.z = Math.sin(t * 3 + i) * 0.06;
|
| 513 |
-
}} else {{
|
| 514 |
-
ch.mesh.rotation.z *= 0.9;
|
| 515 |
-
}}
|
| 516 |
-
|
| 517 |
-
// Update bubble position
|
| 518 |
-
if (ch.bubbleEl && ch.bubbleEl.style.display !== 'none') {{
|
| 519 |
-
ch.mesh.getWorldPosition(tmpVec);
|
| 520 |
-
tmpVec.y += 0.8;
|
| 521 |
-
const s = projectToScreen(tmpVec);
|
| 522 |
-
ch.bubbleEl.style.left = (s.x - 100) + 'px';
|
| 523 |
-
ch.bubbleEl.style.top = (s.y - 80) + 'px';
|
| 524 |
-
}}
|
| 525 |
-
}});
|
| 526 |
-
|
| 527 |
-
renderer.render(scene, camera);
|
| 528 |
-
}}
|
| 529 |
-
animate();
|
| 530 |
-
|
| 531 |
-
// Resize handler
|
| 532 |
window.addEventListener('resize', () => {{
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
camera.left = -a * zoom;
|
| 537 |
-
camera.right = a * zoom;
|
| 538 |
-
camera.top = zoom;
|
| 539 |
-
camera.bottom = -zoom;
|
| 540 |
-
camera.updateProjectionMatrix();
|
| 541 |
}});
|
| 542 |
</script>
|
| 543 |
</body>
|
|
|
|
| 1 |
"""
|
| 2 |
+
Self-contained SVG isometric studio scene for Gradio — zero external dependencies.
|
| 3 |
+
No Three.js, no CDN. Pure SVG + HTML + vanilla JS.
|
|
|
|
| 4 |
"""
|
| 5 |
import json
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
def _load_characters_js() -> str:
|
| 10 |
p = Path(__file__).parent / "sandbox_cache" / "characters.js"
|
| 11 |
+
return p.read_text() if p.exists() else ""
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
| 14 |
_CHARACTERS_JS = _load_characters_js()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Isometric helpers
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
def _pts(pairs, ox=0, oy=0):
|
| 22 |
+
return " ".join(f"{x+ox},{y+oy}" for x, y in pairs)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _darken(hex_color: str, factor: float = 0.65) -> str:
|
| 26 |
+
h = hex_color.lstrip("#")
|
| 27 |
+
if len(h) != 6:
|
| 28 |
+
return hex_color
|
| 29 |
+
r, g, b = int(h[:2], 16), int(h[2:4], 16), int(h[4:], 16)
|
| 30 |
+
return f"#{int(r*factor):02x}{int(g*factor):02x}{int(b*factor):02x}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# Base desk polygon coordinates (desk slot 0, no offset)
|
| 34 |
+
_DESK_TABLETOP = [(148,268),(240,220),(306,252),(214,300)]
|
| 35 |
+
_DESK_LEFT = [(148,268),(148,288),(214,320),(214,300)]
|
| 36 |
+
_DESK_RIGHT = [(214,300),(214,320),(306,270),(306,252)]
|
| 37 |
+
_DESK_MAT = [(158,262),(238,218),(298,248),(218,292)]
|
| 38 |
+
_MON_BACK = [(196,208),(218,198),(242,210),(220,220)]
|
| 39 |
+
_MON_SCREEN = [(200,210),(218,202),(238,212),(220,220)]
|
| 40 |
+
_MON_LINES = [(200,211,228,203),(200,215,224,207),(200,219,230,211)]
|
| 41 |
+
|
| 42 |
+
# Base character polygons — head anchor at (174, 264) relative to slot 0
|
| 43 |
+
_CHAR_HEAD_TOP = [(174,264),(192,255),(202,259),(184,268)]
|
| 44 |
+
_CHAR_HEAD_SIDE = [(184,268),(202,259),(203,261),(185,270)]
|
| 45 |
+
_CHAR_BODY_TOP = [(176,276),(196,267),(204,271),(184,280)]
|
| 46 |
+
_CHAR_BODY_SIDE = [(184,280),(202,271),(204,273),(186,282)]
|
| 47 |
+
_CHAR_LEG_L = [(177,288),(185,284),(189,286),(181,290)]
|
| 48 |
+
_CHAR_LEG_R = [(184,285),(192,281),(196,283),(188,287)]
|
| 49 |
+
_CHAR_ARM_L = (186,270, 174,264)
|
| 50 |
+
_CHAR_ARM_R = (196,266, 210,260)
|
| 51 |
+
_CHAR_EYE_L = (178,258)
|
| 52 |
+
_CHAR_EYE_R = (189,255)
|
| 53 |
+
|
| 54 |
+
# Five desk slot offsets (ox, oy)
|
| 55 |
+
_SLOTS = [
|
| 56 |
+
(0, 0), # back-left
|
| 57 |
+
(188, 34), # back-center
|
| 58 |
+
(350, 2), # back-right
|
| 59 |
+
(0, 95), # front-left
|
| 60 |
+
(188, 129), # front-center
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _svg_desk(ox: int, oy: int) -> str:
|
| 65 |
+
lines = "".join(
|
| 66 |
+
f'<line stroke="#00ff88" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
|
| 67 |
+
if i == 0 else
|
| 68 |
+
f'<line stroke="#44aaff" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
|
| 69 |
+
if i == 1 else
|
| 70 |
+
f'<line stroke="#ffcc00" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
|
| 71 |
+
for i, (x1, y1, x2, y2) in enumerate(_MON_LINES)
|
| 72 |
+
)
|
| 73 |
+
return (
|
| 74 |
+
f'<polygon fill="#8a6038" stroke="#aa8050" stroke-width="1" points="{_pts(_DESK_TABLETOP,ox,oy)}"/>'
|
| 75 |
+
f'<polygon fill="#6a4020" points="{_pts(_DESK_LEFT,ox,oy)}"/>'
|
| 76 |
+
f'<polygon fill="#5a3010" points="{_pts(_DESK_RIGHT,ox,oy)}"/>'
|
| 77 |
+
f'<polygon fill="#5a8a3a" opacity=".7" points="{_pts(_DESK_MAT,ox,oy)}"/>'
|
| 78 |
+
f'<polygon fill="#222" stroke="#555" stroke-width=".9" points="{_pts(_MON_BACK,ox,oy)}"/>'
|
| 79 |
+
f'<polygon fill="#001812" points="{_pts(_MON_SCREEN,ox,oy)}"/>'
|
| 80 |
+
+ lines
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _svg_char(ox: int, oy: int, color: str) -> str:
|
| 85 |
+
dark = _darken(color)
|
| 86 |
+
al = _CHAR_ARM_L
|
| 87 |
+
ar = _CHAR_ARM_R
|
| 88 |
+
el = _CHAR_EYE_L
|
| 89 |
+
er = _CHAR_EYE_R
|
| 90 |
+
return (
|
| 91 |
+
f'<line stroke="{color}" stroke-width="3.5" x1="{al[0]+ox}" y1="{al[1]+oy}" x2="{al[2]+ox}" y2="{al[3]+oy}"/>'
|
| 92 |
+
f'<line stroke="{color}" stroke-width="3.5" x1="{ar[0]+ox}" y1="{ar[1]+oy}" x2="{ar[2]+ox}" y2="{ar[3]+oy}"/>'
|
| 93 |
+
f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_L,ox,oy)}"/>'
|
| 94 |
+
f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_R,ox,oy)}"/>'
|
| 95 |
+
f'<polygon fill="{color}" stroke="{dark}" stroke-width=".5" points="{_pts(_CHAR_BODY_TOP,ox,oy)}"/>'
|
| 96 |
+
f'<polygon fill="{dark}" points="{_pts(_CHAR_BODY_SIDE,ox,oy)}"/>'
|
| 97 |
+
f'<polygon fill="{color}" stroke="{dark}" stroke-width=".6" points="{_pts(_CHAR_HEAD_TOP,ox,oy)}"/>'
|
| 98 |
+
f'<polygon fill="{dark}" points="{_pts(_CHAR_HEAD_SIDE,ox,oy)}"/>'
|
| 99 |
+
f'<rect fill="#1a1a1a" x="{el[0]+ox}" y="{el[1]+oy}" width="5" height="5" rx=".8"/>'
|
| 100 |
+
f'<rect fill="#1a1a1a" x="{er[0]+ox}" y="{er[1]+oy}" width="5" height="5" rx=".8"/>'
|
| 101 |
+
)
|
| 102 |
|
| 103 |
|
| 104 |
def build_studio_html(model_assignments: list[dict]) -> str:
|
| 105 |
"""
|
| 106 |
model_assignments: list of dicts:
|
| 107 |
+
{model_id, role, character_fn, color, desk (1-5)}
|
| 108 |
Returns self-contained HTML string.
|
| 109 |
"""
|
| 110 |
assignments_json = json.dumps(model_assignments)
|
| 111 |
+
|
| 112 |
+
# Build SVG for each assigned desk + character
|
| 113 |
+
desk_svg_parts = []
|
| 114 |
+
char_svg_parts = []
|
| 115 |
+
for i, a in enumerate(model_assignments[:5]):
|
| 116 |
+
ox, oy = _SLOTS[i]
|
| 117 |
+
desk_svg_parts.append(_svg_desk(ox, oy))
|
| 118 |
+
char_svg_parts.append(_svg_char(ox, oy, a.get("color", "#aaaaaa")))
|
| 119 |
+
|
| 120 |
+
desks_svg = "\n".join(desk_svg_parts)
|
| 121 |
+
chars_svg = "\n".join(char_svg_parts)
|
| 122 |
+
|
| 123 |
+
# Speech bubble anchor positions (SVG coords) per slot — above character head
|
| 124 |
+
bubble_anchors = json.dumps([
|
| 125 |
+
{"svgX": 174 + ox, "svgY": 255 + oy}
|
| 126 |
+
for ox, oy in _SLOTS
|
| 127 |
+
])
|
| 128 |
+
|
| 129 |
return f"""<!DOCTYPE html>
|
| 130 |
<html lang="en">
|
| 131 |
<head>
|
| 132 |
<meta charset="UTF-8">
|
| 133 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 134 |
+
<title>Studio</title>
|
| 135 |
<style>
|
| 136 |
+
* {{ box-sizing:border-box; margin:0; padding:0; }}
|
| 137 |
+
body {{ background:#1a1a1a; font-family:system-ui,sans-serif; overflow:hidden; }}
|
| 138 |
+
.scene-wrap {{ position:relative; width:100vw; height:calc(100vh - 28px); }}
|
| 139 |
+
svg.iso {{ width:100%; height:100%; display:block; }}
|
| 140 |
+
#phase-bar {{
|
| 141 |
+
position:fixed; bottom:0; left:0; right:0; height:28px;
|
| 142 |
+
background:#111; display:flex; align-items:center; gap:12px;
|
| 143 |
+
padding:0 14px; font-size:11px; color:#eee; z-index:20;
|
| 144 |
+
}}
|
| 145 |
+
#phase-label {{ flex:1; }}
|
| 146 |
+
#phase-progress {{ width:180px; height:7px; background:#333; border-radius:4px; overflow:hidden; }}
|
| 147 |
+
#phase-fill {{ height:100%; background:#7c3aed; border-radius:4px; transition:width .5s; }}
|
| 148 |
.speech-bubble {{
|
| 149 |
+
position:absolute; background:rgba(255,255,255,.95);
|
| 150 |
+
border:2px solid #333; border-radius:6px; padding:5px 9px;
|
| 151 |
+
font-size:11px; max-width:180px; line-height:1.35;
|
| 152 |
+
box-shadow:2px 2px 6px rgba(0,0,0,.4); pointer-events:none;
|
| 153 |
+
transition:opacity .5s; display:none;
|
| 154 |
}}
|
| 155 |
.speech-bubble::after {{
|
| 156 |
+
content:''; position:absolute; bottom:-9px; left:16px;
|
| 157 |
+
border:5px solid transparent; border-top-color:#333;
|
| 158 |
}}
|
| 159 |
.floating-popup {{
|
| 160 |
+
position:absolute; padding:3px 9px; border-radius:6px;
|
| 161 |
+
font-size:11px; font-weight:700; color:#fff;
|
| 162 |
+
animation:floatUp 2.5s ease-out forwards; pointer-events:none;
|
| 163 |
}}
|
| 164 |
@keyframes floatUp {{
|
| 165 |
+
0% {{ transform:translateY(0); opacity:1; }}
|
| 166 |
+
100% {{ transform:translateY(-60px); opacity:0; }}
|
| 167 |
}}
|
| 168 |
+
/* floor tiles */
|
| 169 |
+
.f0 {{ fill:#d4a060; stroke:#b07838; stroke-width:.7; }}
|
| 170 |
+
.f1 {{ fill:#c8924a; stroke:#a06830; stroke-width:.7; }}
|
| 171 |
+
/* walls */
|
| 172 |
+
.wb {{ fill:#dce8c4; stroke:#bccca4; stroke-width:.8; }}
|
| 173 |
+
.wl {{ fill:#ccdcb0; stroke:#accc90; stroke-width:.8; }}
|
| 174 |
+
.wr {{ fill:#c4d4a8; stroke:#a4c488; stroke-width:.8; }}
|
| 175 |
+
.sk {{ fill:#a09070; stroke:#807050; stroke-width:.5; }}
|
|
|
|
| 176 |
</style>
|
| 177 |
</head>
|
| 178 |
<body>
|
| 179 |
+
<div class="scene-wrap" id="scene-wrap">
|
| 180 |
+
|
| 181 |
+
<svg class="iso" viewBox="0 0 860 540" xmlns="http://www.w3.org/2000/svg">
|
| 182 |
+
|
| 183 |
+
<!-- WALLS -->
|
| 184 |
+
<polygon class="wb" points="60,30 430,215 800,30 430,-155"/>
|
| 185 |
+
<polygon class="wl" points="60,30 60,250 430,435 430,215"/>
|
| 186 |
+
<polygon class="wr" points="430,215 430,435 800,250 800,30"/>
|
| 187 |
+
<polygon class="sk" points="60,242 430,427 430,435 60,250"/>
|
| 188 |
+
<polygon class="sk" style="fill:#908060" points="430,427 800,242 800,250 430,435"/>
|
| 189 |
+
|
| 190 |
+
<!-- FLOOR -->
|
| 191 |
+
<polygon class="f0" points="60,215 245,120 430,215 245,310"/>
|
| 192 |
+
<polygon class="f1" points="245,120 430,25 615,120 430,215"/>
|
| 193 |
+
<polygon class="f0" points="430,25 615,-70 800,25 615,120"/>
|
| 194 |
+
<polygon class="f1" points="152,167 337,72 430,120 245,215"/>
|
| 195 |
+
<polygon class="f0" points="337,72 522,-23 615,25 430,120"/>
|
| 196 |
+
<polygon class="f1" points="522,-23 707,-118 800,-70 615,25"/>
|
| 197 |
+
<polygon class="f1" points="60,310 245,215 430,310 245,405"/>
|
| 198 |
+
<polygon class="f0" points="245,215 430,120 615,215 430,310"/>
|
| 199 |
+
<polygon class="f1" points="430,120 615,25 800,120 615,215"/>
|
| 200 |
+
<polygon class="f0" points="152,262 337,167 430,215 245,310"/>
|
| 201 |
+
<polygon class="f1" points="337,167 522,72 615,120 430,215"/>
|
| 202 |
+
<polygon class="f0" points="522,72 707,-23 800,25 615,120"/>
|
| 203 |
+
<polygon class="f0" points="60,405 245,310 430,405 245,500"/>
|
| 204 |
+
<polygon class="f1" points="245,310 430,215 615,310 430,405"/>
|
| 205 |
+
<polygon class="f0" points="430,215 615,120 800,215 615,310"/>
|
| 206 |
+
<polygon class="f1" points="152,357 337,262 430,310 245,405"/>
|
| 207 |
+
<polygon class="f0" points="337,262 522,167 615,215 430,310"/>
|
| 208 |
+
<polygon class="f1" points="522,167 707,72 800,120 615,215"/>
|
| 209 |
+
|
| 210 |
+
<!-- WHITEBOARDS -->
|
| 211 |
+
<rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="70" y="10" width="115" height="82" rx="3"/>
|
| 212 |
+
<rect fill="#e5e5dc" x="70" y="10" width="115" height="10" rx="3"/>
|
| 213 |
+
<text fill="#5a7a3a" font-size="7" font-weight="700" x="75" y="18">ASSET MANIFEST</text>
|
| 214 |
+
<line stroke="#99aacc" stroke-width="1" x1="78" y1="28" x2="178" y2="28"/>
|
| 215 |
+
<line stroke="#99aacc" stroke-width="1" x1="78" y1="39" x2="165" y2="39"/>
|
| 216 |
+
<line stroke="#99aacc" stroke-width="1" x1="78" y1="50" x2="172" y2="50"/>
|
| 217 |
+
<rect fill="#298" x="78" y="56" width="13" height="17" rx="1.5"/>
|
| 218 |
+
<rect fill="#f5c542" x="81" y="52" width="9" height="8" rx="1.5"/>
|
| 219 |
+
|
| 220 |
+
<rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="298" y="-5" width="136" height="72" rx="3"/>
|
| 221 |
+
<rect fill="#e5e5dc" x="298" y="-5" width="136" height="10" rx="3"/>
|
| 222 |
+
<text fill="#5a7a3a" font-size="7" font-weight="700" x="303" y="4">DESIGN SPEC</text>
|
| 223 |
+
<line stroke="#cc8888" stroke-width="1.5" x1="303" y1="10" x2="426" y2="10"/>
|
| 224 |
+
<text fill="#333" font-size="6" x="303" y="22">gravity: 18 jumpForce: 9.5</text>
|
| 225 |
+
<text fill="#333" font-size="6" x="303" y="33">laneWidth: 2 snapSpeed: 8</text>
|
| 226 |
+
<text fill="#4488cc" font-size="6.5" font-weight="700" x="303" y="46">AI · COLLAB · BUILD</text>
|
| 227 |
+
|
| 228 |
+
<!-- VENDING MACHINE -->
|
| 229 |
+
<polygon fill="#cc2222" stroke="#ee4444" stroke-width="1.2" points="60,176 60,296 102,273 102,153"/>
|
| 230 |
+
<polygon fill="#aa1111" stroke="#cc2222" stroke-width="1" points="60,153 102,130 102,153 60,176"/>
|
| 231 |
+
<rect fill="#ffcc00" x="64" y="196" width="30" height="10" rx="2.5"/>
|
| 232 |
+
<rect fill="#00ccff" x="64" y="209" width="30" height="10" rx="2.5"/>
|
| 233 |
+
<rect fill="#ff8800" x="64" y="222" width="30" height="10" rx="2.5"/>
|
| 234 |
+
<rect fill="#111" x="64" y="167" width="30" height="12" rx="2"/>
|
| 235 |
+
<rect fill="#00ff88" opacity=".55" x="66" y="169" width="26" height="8" rx="1.5"/>
|
| 236 |
+
|
| 237 |
+
<!-- PLANTS -->
|
| 238 |
+
<polygon fill="#c8681a" stroke="#a04808" stroke-width="1" points="660,100 680,90 680,110 660,120"/>
|
| 239 |
+
<ellipse fill="#3a8a1a" cx="676" cy="78" rx="16" ry="13"/>
|
| 240 |
+
<ellipse fill="#2a7a0a" cx="664" cy="73" rx="11" ry="10"/>
|
| 241 |
+
<ellipse fill="#4a9a2a" cx="685" cy="70" rx="11" ry="10"/>
|
| 242 |
+
<ellipse fill="#5aaa3a" cx="676" cy="62" rx="8" ry="7"/>
|
| 243 |
+
|
| 244 |
+
<!-- RESULT MONITOR — glowing purple -->
|
| 245 |
+
<polygon fill="#201030" stroke="#9944dd" stroke-width="2.5" points="646,76 728,33 756,48 674,91">
|
| 246 |
+
<animate attributeName="stroke-opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite"/>
|
| 247 |
+
</polygon>
|
| 248 |
+
<polygon fill="#050d05" points="650,78 724,37 752,51 678,92"/>
|
| 249 |
+
<line stroke="#3aaa3a" stroke-width="3" x1="658" y1="82" x2="746" y2="40"/>
|
| 250 |
+
<text fill="#bb77ff" font-size="7" font-weight="700" x="665" y="108">GENERATING...</text>
|
| 251 |
+
<ellipse fill="none" stroke="#9944dd" stroke-width="2" opacity=".45" cx="700" cy="64" rx="40" ry="22">
|
| 252 |
+
<animate attributeName="opacity" values="0.3;0.6;0.3" dur="1.8s" repeatCount="indefinite"/>
|
| 253 |
+
</ellipse>
|
| 254 |
+
|
| 255 |
+
<!-- PIZZA BOX (hidden initially, shown at phase 7+) -->
|
| 256 |
+
<polygon id="pizza-box" fill="#c8a060" stroke="#a88040" stroke-width=".8" visibility="hidden"
|
| 257 |
+
points="464,278 498,260 514,268 480,286"/>
|
| 258 |
+
<polygon id="pizza-box2" fill="#e0b878" stroke="#c09848" stroke-width=".6" visibility="hidden"
|
| 259 |
+
points="464,274 498,256 514,264 480,282"/>
|
| 260 |
+
<text id="pizza-emoji" visibility="hidden" fill="#aa6622" font-size="10" x="477" y="272" transform="rotate(-20,477,272)">🍕</text>
|
| 261 |
+
|
| 262 |
+
<!-- DESKS (back-to-front for correct z-order) -->
|
| 263 |
+
{desks_svg}
|
| 264 |
+
|
| 265 |
+
<!-- CHARACTERS -->
|
| 266 |
+
{chars_svg}
|
| 267 |
+
|
| 268 |
+
</svg>
|
| 269 |
+
|
| 270 |
+
<!-- Speech bubble overlay (absolute-positioned HTML) -->
|
| 271 |
+
<div id="bubble-layer" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none">
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
<div id="phase-bar">
|
| 277 |
<span id="phase-label">Waiting…</span>
|
| 278 |
<div id="phase-progress"><div id="phase-fill" style="width:0%"></div></div>
|
| 279 |
</div>
|
| 280 |
|
|
|
|
|
|
|
| 281 |
<script>
|
| 282 |
+
const assignments = {assignments_json};
|
| 283 |
+
const bubbleAnchors = {bubble_anchors};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
+
// Pre-create one speech bubble per slot
|
| 286 |
+
const bubbleLayer = document.getElementById('bubble-layer');
|
| 287 |
+
const bubbles = bubbleAnchors.map((_, i) => {{
|
| 288 |
+
const d = document.createElement('div');
|
| 289 |
+
d.className = 'speech-bubble';
|
| 290 |
+
d.id = 'bubble-' + i;
|
| 291 |
+
bubbleLayer.appendChild(d);
|
| 292 |
+
return d;
|
| 293 |
+
}});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
+
// Map role → slot index
|
| 296 |
+
const roleToSlot = {{}};
|
| 297 |
+
assignments.forEach((a, i) => {{ roleToSlot[a.role] = i; }});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
+
const phaseLabel = document.getElementById('phase-label');
|
| 300 |
+
const phaseFill = document.getElementById('phase-fill');
|
| 301 |
+
const svgEl = document.querySelector('svg.iso');
|
| 302 |
+
const sceneWrap = document.getElementById('scene-wrap');
|
| 303 |
+
|
| 304 |
+
function svgToHtml(svgX, svgY) {{
|
| 305 |
+
const vbW = 860, vbH = 540;
|
| 306 |
+
const bbox = svgEl.getBoundingClientRect();
|
| 307 |
+
const swRect = sceneWrap.getBoundingClientRect();
|
| 308 |
+
return {{
|
| 309 |
+
x: (svgX / vbW) * bbox.width + (bbox.left - swRect.left),
|
| 310 |
+
y: (svgY / vbH) * bbox.height + (bbox.top - swRect.top),
|
| 311 |
+
}};
|
| 312 |
+
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
+
function positionBubble(bubble, slotIdx) {{
|
| 315 |
+
const anchor = bubbleAnchors[slotIdx];
|
| 316 |
+
const pos = svgToHtml(anchor.svgX, anchor.svgY);
|
| 317 |
+
bubble.style.left = (pos.x - 90) + 'px';
|
| 318 |
+
bubble.style.top = (pos.y - 70) + 'px';
|
|
|
|
|
|
|
|
|
|
| 319 |
}}
|
| 320 |
|
| 321 |
+
function showBubble(slotIdx, text, borderColor) {{
|
| 322 |
+
const b = bubbles[slotIdx];
|
| 323 |
+
if (!b) return;
|
| 324 |
+
b.textContent = text;
|
| 325 |
+
b.style.display = 'block';
|
| 326 |
+
b.style.opacity = '1';
|
| 327 |
+
b.style.borderColor = borderColor || '#333';
|
| 328 |
+
positionBubble(b, slotIdx);
|
| 329 |
+
clearTimeout(b._timer);
|
| 330 |
+
b._timer = setTimeout(() => {{
|
| 331 |
+
b.style.opacity = '0';
|
| 332 |
+
setTimeout(() => {{ b.style.display = 'none'; }}, 500);
|
| 333 |
+
}}, 2500);
|
| 334 |
}}
|
| 335 |
|
|
|
|
| 336 |
const popupPool = [];
|
| 337 |
+
for (let i = 0; i < 8; i++) {{
|
| 338 |
+
const d = document.createElement('div');
|
| 339 |
+
d.className = 'floating-popup';
|
| 340 |
+
d.style.display = 'none';
|
| 341 |
+
bubbleLayer.appendChild(d);
|
| 342 |
+
popupPool.push(d);
|
| 343 |
}}
|
| 344 |
|
| 345 |
+
function spawnPopup(text, color, svgX, svgY) {{
|
| 346 |
const popup = popupPool.find(p => p.style.display === 'none') || popupPool[0];
|
| 347 |
+
const pos = svgToHtml(svgX, svgY);
|
| 348 |
popup.textContent = text;
|
| 349 |
popup.style.backgroundColor = color;
|
| 350 |
+
popup.style.left = pos.x + 'px';
|
| 351 |
+
popup.style.top = pos.y + 'px';
|
| 352 |
popup.style.display = 'block';
|
| 353 |
popup.style.animation = 'none';
|
| 354 |
+
void popup.offsetWidth; // reflow
|
| 355 |
+
popup.style.animation = 'floatUp 2.5s ease-out forwards';
|
| 356 |
+
setTimeout(() => {{ popup.style.display = 'none'; }}, 2500);
|
|
|
|
|
|
|
|
|
|
| 357 |
}}
|
| 358 |
|
| 359 |
+
// typeBuffers per slot
|
| 360 |
+
const typeBuffers = assignments.map(() => '');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
|
|
|
|
| 362 |
window.studioUpdate = function(msg) {{
|
| 363 |
if (msg.type === 'text') {{
|
| 364 |
+
const slot = roleToSlot[msg.role];
|
| 365 |
+
if (slot === undefined) return;
|
| 366 |
+
typeBuffers[slot] += msg.text;
|
| 367 |
+
if (typeBuffers[slot].length > 160) typeBuffers[slot] = typeBuffers[slot].slice(-160);
|
| 368 |
+
const lines = typeBuffers[slot].split('\\n').slice(-2).join('\\n');
|
| 369 |
+
showBubble(slot, lines);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
}}
|
|
|
|
| 371 |
else if (msg.type === 'phase_start') {{
|
| 372 |
phaseLabel.textContent = `Phase ${{msg.phase}}: ${{msg.name}}`;
|
| 373 |
+
phaseFill.style.width = Math.round((msg.phase / 9) * 100) + '%';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
if (msg.phase >= 7) {{
|
| 375 |
+
['pizza-box','pizza-box2','pizza-emoji'].forEach(id => {{
|
| 376 |
+
const el = document.getElementById(id);
|
| 377 |
+
if (el) el.setAttribute('visibility','visible');
|
| 378 |
+
}});
|
| 379 |
}}
|
| 380 |
}}
|
|
|
|
| 381 |
else if (msg.type === 'phase_complete') {{
|
| 382 |
+
spawnPopup(`✓ ${{msg.name}}`, '#f39c12', 430, 200);
|
|
|
|
| 383 |
}}
|
|
|
|
| 384 |
else if (msg.type === 'commit') {{
|
| 385 |
+
spawnPopup(`📁 ${{msg.file}}`, '#27ae60', 430, 240);
|
|
|
|
| 386 |
}}
|
|
|
|
| 387 |
else if (msg.type === 'error') {{
|
| 388 |
+
const slot = roleToSlot[msg.role];
|
| 389 |
+
if (slot !== undefined) showBubble(slot, `❌ ${{msg.text}}`, '#e74c3c');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
}}
|
|
|
|
| 391 |
else if (msg.type === 'done') {{
|
| 392 |
phaseLabel.textContent = '✅ Generation Complete!';
|
| 393 |
phaseFill.style.width = '100%';
|
| 394 |
+
spawnPopup('🎉 Done!', '#7c3aed', 430, 180);
|
| 395 |
+
if (window.onStudioDone) window.onStudioDone();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
}}
|
|
|
|
| 397 |
else if (msg.type === 'cancelled') {{
|
| 398 |
phaseLabel.textContent = '⛔ Cancelled';
|
| 399 |
}}
|
| 400 |
}};
|
| 401 |
|
|
|
|
| 402 |
window.addEventListener('message', function(e) {{
|
| 403 |
+
if (e.data && e.data.type) window.studioUpdate(e.data);
|
|
|
|
|
|
|
| 404 |
}});
|
| 405 |
|
| 406 |
+
// Keep bubbles in position on resize
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
window.addEventListener('resize', () => {{
|
| 408 |
+
bubbles.forEach((b, i) => {{
|
| 409 |
+
if (b.style.display !== 'none') positionBubble(b, i);
|
| 410 |
+
}});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
}});
|
| 412 |
</script>
|
| 413 |
</body>
|