BolyosCsaba
fix: replace Three.js WebGL studio with pure SVG — zero dependencies
471b2cd
"""
Self-contained SVG isometric studio scene for Gradio — zero external dependencies.
No Three.js, no CDN. Pure SVG + HTML + vanilla JS.
"""
import json
from pathlib import Path
def _load_characters_js() -> str:
p = Path(__file__).parent / "sandbox_cache" / "characters.js"
return p.read_text() if p.exists() else ""
_CHARACTERS_JS = _load_characters_js()
# ---------------------------------------------------------------------------
# Isometric helpers
# ---------------------------------------------------------------------------
def _pts(pairs, ox=0, oy=0):
return " ".join(f"{x+ox},{y+oy}" for x, y in pairs)
def _darken(hex_color: str, factor: float = 0.65) -> str:
h = hex_color.lstrip("#")
if len(h) != 6:
return hex_color
r, g, b = int(h[:2], 16), int(h[2:4], 16), int(h[4:], 16)
return f"#{int(r*factor):02x}{int(g*factor):02x}{int(b*factor):02x}"
# Base desk polygon coordinates (desk slot 0, no offset)
_DESK_TABLETOP = [(148,268),(240,220),(306,252),(214,300)]
_DESK_LEFT = [(148,268),(148,288),(214,320),(214,300)]
_DESK_RIGHT = [(214,300),(214,320),(306,270),(306,252)]
_DESK_MAT = [(158,262),(238,218),(298,248),(218,292)]
_MON_BACK = [(196,208),(218,198),(242,210),(220,220)]
_MON_SCREEN = [(200,210),(218,202),(238,212),(220,220)]
_MON_LINES = [(200,211,228,203),(200,215,224,207),(200,219,230,211)]
# Base character polygons — head anchor at (174, 264) relative to slot 0
_CHAR_HEAD_TOP = [(174,264),(192,255),(202,259),(184,268)]
_CHAR_HEAD_SIDE = [(184,268),(202,259),(203,261),(185,270)]
_CHAR_BODY_TOP = [(176,276),(196,267),(204,271),(184,280)]
_CHAR_BODY_SIDE = [(184,280),(202,271),(204,273),(186,282)]
_CHAR_LEG_L = [(177,288),(185,284),(189,286),(181,290)]
_CHAR_LEG_R = [(184,285),(192,281),(196,283),(188,287)]
_CHAR_ARM_L = (186,270, 174,264)
_CHAR_ARM_R = (196,266, 210,260)
_CHAR_EYE_L = (178,258)
_CHAR_EYE_R = (189,255)
# Five desk slot offsets (ox, oy)
_SLOTS = [
(0, 0), # back-left
(188, 34), # back-center
(350, 2), # back-right
(0, 95), # front-left
(188, 129), # front-center
]
def _svg_desk(ox: int, oy: int) -> str:
lines = "".join(
f'<line stroke="#00ff88" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
if i == 0 else
f'<line stroke="#44aaff" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
if i == 1 else
f'<line stroke="#ffcc00" stroke-width=".9" x1="{x1+ox}" y1="{y1+oy}" x2="{x2+ox}" y2="{y2+oy}"/>'
for i, (x1, y1, x2, y2) in enumerate(_MON_LINES)
)
return (
f'<polygon fill="#8a6038" stroke="#aa8050" stroke-width="1" points="{_pts(_DESK_TABLETOP,ox,oy)}"/>'
f'<polygon fill="#6a4020" points="{_pts(_DESK_LEFT,ox,oy)}"/>'
f'<polygon fill="#5a3010" points="{_pts(_DESK_RIGHT,ox,oy)}"/>'
f'<polygon fill="#5a8a3a" opacity=".7" points="{_pts(_DESK_MAT,ox,oy)}"/>'
f'<polygon fill="#222" stroke="#555" stroke-width=".9" points="{_pts(_MON_BACK,ox,oy)}"/>'
f'<polygon fill="#001812" points="{_pts(_MON_SCREEN,ox,oy)}"/>'
+ lines
)
def _svg_char(ox: int, oy: int, color: str) -> str:
dark = _darken(color)
al = _CHAR_ARM_L
ar = _CHAR_ARM_R
el = _CHAR_EYE_L
er = _CHAR_EYE_R
return (
f'<line stroke="{color}" stroke-width="3.5" x1="{al[0]+ox}" y1="{al[1]+oy}" x2="{al[2]+ox}" y2="{al[3]+oy}"/>'
f'<line stroke="{color}" stroke-width="3.5" x1="{ar[0]+ox}" y1="{ar[1]+oy}" x2="{ar[2]+ox}" y2="{ar[3]+oy}"/>'
f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_L,ox,oy)}"/>'
f'<polygon fill="{dark}" points="{_pts(_CHAR_LEG_R,ox,oy)}"/>'
f'<polygon fill="{color}" stroke="{dark}" stroke-width=".5" points="{_pts(_CHAR_BODY_TOP,ox,oy)}"/>'
f'<polygon fill="{dark}" points="{_pts(_CHAR_BODY_SIDE,ox,oy)}"/>'
f'<polygon fill="{color}" stroke="{dark}" stroke-width=".6" points="{_pts(_CHAR_HEAD_TOP,ox,oy)}"/>'
f'<polygon fill="{dark}" points="{_pts(_CHAR_HEAD_SIDE,ox,oy)}"/>'
f'<rect fill="#1a1a1a" x="{el[0]+ox}" y="{el[1]+oy}" width="5" height="5" rx=".8"/>'
f'<rect fill="#1a1a1a" x="{er[0]+ox}" y="{er[1]+oy}" width="5" height="5" rx=".8"/>'
)
def build_studio_html(model_assignments: list[dict]) -> str:
"""
model_assignments: list of dicts:
{model_id, role, character_fn, color, desk (1-5)}
Returns self-contained HTML string.
"""
assignments_json = json.dumps(model_assignments)
# Build SVG for each assigned desk + character
desk_svg_parts = []
char_svg_parts = []
for i, a in enumerate(model_assignments[:5]):
ox, oy = _SLOTS[i]
desk_svg_parts.append(_svg_desk(ox, oy))
char_svg_parts.append(_svg_char(ox, oy, a.get("color", "#aaaaaa")))
desks_svg = "\n".join(desk_svg_parts)
chars_svg = "\n".join(char_svg_parts)
# Speech bubble anchor positions (SVG coords) per slot — above character head
bubble_anchors = json.dumps([
{"svgX": 174 + ox, "svgY": 255 + oy}
for ox, oy in _SLOTS
])
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Studio</title>
<style>
* {{ box-sizing:border-box; margin:0; padding:0; }}
body {{ background:#1a1a1a; font-family:system-ui,sans-serif; overflow:hidden; }}
.scene-wrap {{ position:relative; width:100vw; height:calc(100vh - 28px); }}
svg.iso {{ width:100%; height:100%; display:block; }}
#phase-bar {{
position:fixed; bottom:0; left:0; right:0; height:28px;
background:#111; display:flex; align-items:center; gap:12px;
padding:0 14px; font-size:11px; color:#eee; z-index:20;
}}
#phase-label {{ flex:1; }}
#phase-progress {{ width:180px; height:7px; background:#333; border-radius:4px; overflow:hidden; }}
#phase-fill {{ height:100%; background:#7c3aed; border-radius:4px; transition:width .5s; }}
.speech-bubble {{
position:absolute; background:rgba(255,255,255,.95);
border:2px solid #333; border-radius:6px; padding:5px 9px;
font-size:11px; max-width:180px; line-height:1.35;
box-shadow:2px 2px 6px rgba(0,0,0,.4); pointer-events:none;
transition:opacity .5s; display:none;
}}
.speech-bubble::after {{
content:''; position:absolute; bottom:-9px; left:16px;
border:5px solid transparent; border-top-color:#333;
}}
.floating-popup {{
position:absolute; padding:3px 9px; border-radius:6px;
font-size:11px; font-weight:700; color:#fff;
animation:floatUp 2.5s ease-out forwards; pointer-events:none;
}}
@keyframes floatUp {{
0% {{ transform:translateY(0); opacity:1; }}
100% {{ transform:translateY(-60px); opacity:0; }}
}}
/* floor tiles */
.f0 {{ fill:#d4a060; stroke:#b07838; stroke-width:.7; }}
.f1 {{ fill:#c8924a; stroke:#a06830; stroke-width:.7; }}
/* walls */
.wb {{ fill:#dce8c4; stroke:#bccca4; stroke-width:.8; }}
.wl {{ fill:#ccdcb0; stroke:#accc90; stroke-width:.8; }}
.wr {{ fill:#c4d4a8; stroke:#a4c488; stroke-width:.8; }}
.sk {{ fill:#a09070; stroke:#807050; stroke-width:.5; }}
</style>
</head>
<body>
<div class="scene-wrap" id="scene-wrap">
<svg class="iso" viewBox="0 0 860 540" xmlns="http://www.w3.org/2000/svg">
<!-- WALLS -->
<polygon class="wb" points="60,30 430,215 800,30 430,-155"/>
<polygon class="wl" points="60,30 60,250 430,435 430,215"/>
<polygon class="wr" points="430,215 430,435 800,250 800,30"/>
<polygon class="sk" points="60,242 430,427 430,435 60,250"/>
<polygon class="sk" style="fill:#908060" points="430,427 800,242 800,250 430,435"/>
<!-- FLOOR -->
<polygon class="f0" points="60,215 245,120 430,215 245,310"/>
<polygon class="f1" points="245,120 430,25 615,120 430,215"/>
<polygon class="f0" points="430,25 615,-70 800,25 615,120"/>
<polygon class="f1" points="152,167 337,72 430,120 245,215"/>
<polygon class="f0" points="337,72 522,-23 615,25 430,120"/>
<polygon class="f1" points="522,-23 707,-118 800,-70 615,25"/>
<polygon class="f1" points="60,310 245,215 430,310 245,405"/>
<polygon class="f0" points="245,215 430,120 615,215 430,310"/>
<polygon class="f1" points="430,120 615,25 800,120 615,215"/>
<polygon class="f0" points="152,262 337,167 430,215 245,310"/>
<polygon class="f1" points="337,167 522,72 615,120 430,215"/>
<polygon class="f0" points="522,72 707,-23 800,25 615,120"/>
<polygon class="f0" points="60,405 245,310 430,405 245,500"/>
<polygon class="f1" points="245,310 430,215 615,310 430,405"/>
<polygon class="f0" points="430,215 615,120 800,215 615,310"/>
<polygon class="f1" points="152,357 337,262 430,310 245,405"/>
<polygon class="f0" points="337,262 522,167 615,215 430,310"/>
<polygon class="f1" points="522,167 707,72 800,120 615,215"/>
<!-- WHITEBOARDS -->
<rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="70" y="10" width="115" height="82" rx="3"/>
<rect fill="#e5e5dc" x="70" y="10" width="115" height="10" rx="3"/>
<text fill="#5a7a3a" font-size="7" font-weight="700" x="75" y="18">ASSET MANIFEST</text>
<line stroke="#99aacc" stroke-width="1" x1="78" y1="28" x2="178" y2="28"/>
<line stroke="#99aacc" stroke-width="1" x1="78" y1="39" x2="165" y2="39"/>
<line stroke="#99aacc" stroke-width="1" x1="78" y1="50" x2="172" y2="50"/>
<rect fill="#298" x="78" y="56" width="13" height="17" rx="1.5"/>
<rect fill="#f5c542" x="81" y="52" width="9" height="8" rx="1.5"/>
<rect fill="#f5f5ee" stroke="#ccc" stroke-width="1.5" x="298" y="-5" width="136" height="72" rx="3"/>
<rect fill="#e5e5dc" x="298" y="-5" width="136" height="10" rx="3"/>
<text fill="#5a7a3a" font-size="7" font-weight="700" x="303" y="4">DESIGN SPEC</text>
<line stroke="#cc8888" stroke-width="1.5" x1="303" y1="10" x2="426" y2="10"/>
<text fill="#333" font-size="6" x="303" y="22">gravity: 18 jumpForce: 9.5</text>
<text fill="#333" font-size="6" x="303" y="33">laneWidth: 2 snapSpeed: 8</text>
<text fill="#4488cc" font-size="6.5" font-weight="700" x="303" y="46">AI · COLLAB · BUILD</text>
<!-- VENDING MACHINE -->
<polygon fill="#cc2222" stroke="#ee4444" stroke-width="1.2" points="60,176 60,296 102,273 102,153"/>
<polygon fill="#aa1111" stroke="#cc2222" stroke-width="1" points="60,153 102,130 102,153 60,176"/>
<rect fill="#ffcc00" x="64" y="196" width="30" height="10" rx="2.5"/>
<rect fill="#00ccff" x="64" y="209" width="30" height="10" rx="2.5"/>
<rect fill="#ff8800" x="64" y="222" width="30" height="10" rx="2.5"/>
<rect fill="#111" x="64" y="167" width="30" height="12" rx="2"/>
<rect fill="#00ff88" opacity=".55" x="66" y="169" width="26" height="8" rx="1.5"/>
<!-- PLANTS -->
<polygon fill="#c8681a" stroke="#a04808" stroke-width="1" points="660,100 680,90 680,110 660,120"/>
<ellipse fill="#3a8a1a" cx="676" cy="78" rx="16" ry="13"/>
<ellipse fill="#2a7a0a" cx="664" cy="73" rx="11" ry="10"/>
<ellipse fill="#4a9a2a" cx="685" cy="70" rx="11" ry="10"/>
<ellipse fill="#5aaa3a" cx="676" cy="62" rx="8" ry="7"/>
<!-- RESULT MONITOR — glowing purple -->
<polygon fill="#201030" stroke="#9944dd" stroke-width="2.5" points="646,76 728,33 756,48 674,91">
<animate attributeName="stroke-opacity" values="0.6;1;0.6" dur="2s" repeatCount="indefinite"/>
</polygon>
<polygon fill="#050d05" points="650,78 724,37 752,51 678,92"/>
<line stroke="#3aaa3a" stroke-width="3" x1="658" y1="82" x2="746" y2="40"/>
<text fill="#bb77ff" font-size="7" font-weight="700" x="665" y="108">GENERATING...</text>
<ellipse fill="none" stroke="#9944dd" stroke-width="2" opacity=".45" cx="700" cy="64" rx="40" ry="22">
<animate attributeName="opacity" values="0.3;0.6;0.3" dur="1.8s" repeatCount="indefinite"/>
</ellipse>
<!-- PIZZA BOX (hidden initially, shown at phase 7+) -->
<polygon id="pizza-box" fill="#c8a060" stroke="#a88040" stroke-width=".8" visibility="hidden"
points="464,278 498,260 514,268 480,286"/>
<polygon id="pizza-box2" fill="#e0b878" stroke="#c09848" stroke-width=".6" visibility="hidden"
points="464,274 498,256 514,264 480,282"/>
<text id="pizza-emoji" visibility="hidden" fill="#aa6622" font-size="10" x="477" y="272" transform="rotate(-20,477,272)">🍕</text>
<!-- DESKS (back-to-front for correct z-order) -->
{desks_svg}
<!-- CHARACTERS -->
{chars_svg}
</svg>
<!-- Speech bubble overlay (absolute-positioned HTML) -->
<div id="bubble-layer" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none">
</div>
</div>
<div id="phase-bar">
<span id="phase-label">Waiting…</span>
<div id="phase-progress"><div id="phase-fill" style="width:0%"></div></div>
</div>
<script>
const assignments = {assignments_json};
const bubbleAnchors = {bubble_anchors};
// Pre-create one speech bubble per slot
const bubbleLayer = document.getElementById('bubble-layer');
const bubbles = bubbleAnchors.map((_, i) => {{
const d = document.createElement('div');
d.className = 'speech-bubble';
d.id = 'bubble-' + i;
bubbleLayer.appendChild(d);
return d;
}});
// Map role → slot index
const roleToSlot = {{}};
assignments.forEach((a, i) => {{ roleToSlot[a.role] = i; }});
const phaseLabel = document.getElementById('phase-label');
const phaseFill = document.getElementById('phase-fill');
const svgEl = document.querySelector('svg.iso');
const sceneWrap = document.getElementById('scene-wrap');
function svgToHtml(svgX, svgY) {{
const vbW = 860, vbH = 540;
const bbox = svgEl.getBoundingClientRect();
const swRect = sceneWrap.getBoundingClientRect();
return {{
x: (svgX / vbW) * bbox.width + (bbox.left - swRect.left),
y: (svgY / vbH) * bbox.height + (bbox.top - swRect.top),
}};
}}
function positionBubble(bubble, slotIdx) {{
const anchor = bubbleAnchors[slotIdx];
const pos = svgToHtml(anchor.svgX, anchor.svgY);
bubble.style.left = (pos.x - 90) + 'px';
bubble.style.top = (pos.y - 70) + 'px';
}}
function showBubble(slotIdx, text, borderColor) {{
const b = bubbles[slotIdx];
if (!b) return;
b.textContent = text;
b.style.display = 'block';
b.style.opacity = '1';
b.style.borderColor = borderColor || '#333';
positionBubble(b, slotIdx);
clearTimeout(b._timer);
b._timer = setTimeout(() => {{
b.style.opacity = '0';
setTimeout(() => {{ b.style.display = 'none'; }}, 500);
}}, 2500);
}}
const popupPool = [];
for (let i = 0; i < 8; i++) {{
const d = document.createElement('div');
d.className = 'floating-popup';
d.style.display = 'none';
bubbleLayer.appendChild(d);
popupPool.push(d);
}}
function spawnPopup(text, color, svgX, svgY) {{
const popup = popupPool.find(p => p.style.display === 'none') || popupPool[0];
const pos = svgToHtml(svgX, svgY);
popup.textContent = text;
popup.style.backgroundColor = color;
popup.style.left = pos.x + 'px';
popup.style.top = pos.y + 'px';
popup.style.display = 'block';
popup.style.animation = 'none';
void popup.offsetWidth; // reflow
popup.style.animation = 'floatUp 2.5s ease-out forwards';
setTimeout(() => {{ popup.style.display = 'none'; }}, 2500);
}}
// typeBuffers per slot
const typeBuffers = assignments.map(() => '');
window.studioUpdate = function(msg) {{
if (msg.type === 'text') {{
const slot = roleToSlot[msg.role];
if (slot === undefined) return;
typeBuffers[slot] += msg.text;
if (typeBuffers[slot].length > 160) typeBuffers[slot] = typeBuffers[slot].slice(-160);
const lines = typeBuffers[slot].split('\\n').slice(-2).join('\\n');
showBubble(slot, lines);
}}
else if (msg.type === 'phase_start') {{
phaseLabel.textContent = `Phase ${{msg.phase}}: ${{msg.name}}`;
phaseFill.style.width = Math.round((msg.phase / 9) * 100) + '%';
if (msg.phase >= 7) {{
['pizza-box','pizza-box2','pizza-emoji'].forEach(id => {{
const el = document.getElementById(id);
if (el) el.setAttribute('visibility','visible');
}});
}}
}}
else if (msg.type === 'phase_complete') {{
spawnPopup(`✓ ${{msg.name}}`, '#f39c12', 430, 200);
}}
else if (msg.type === 'commit') {{
spawnPopup(`📁 ${{msg.file}}`, '#27ae60', 430, 240);
}}
else if (msg.type === 'error') {{
const slot = roleToSlot[msg.role];
if (slot !== undefined) showBubble(slot, `❌ ${{msg.text}}`, '#e74c3c');
}}
else if (msg.type === 'done') {{
phaseLabel.textContent = '✅ Generation Complete!';
phaseFill.style.width = '100%';
spawnPopup('🎉 Done!', '#7c3aed', 430, 180);
if (window.onStudioDone) window.onStudioDone();
}}
else if (msg.type === 'cancelled') {{
phaseLabel.textContent = '⛔ Cancelled';
}}
}};
window.addEventListener('message', function(e) {{
if (e.data && e.data.type) window.studioUpdate(e.data);
}});
// Keep bubbles in position on resize
window.addEventListener('resize', () => {{
bubbles.forEach((b, i) => {{
if (b.style.display !== 'none') positionBubble(b, i);
}});
}});
</script>
</body>
</html>"""