| """ |
| 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() |
|
|
|
|
| |
| |
| |
|
|
| 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}" |
|
|
|
|
| |
| _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)] |
|
|
| |
| _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) |
|
|
| |
| _SLOTS = [ |
| (0, 0), |
| (188, 34), |
| (350, 2), |
| (0, 95), |
| (188, 129), |
| ] |
|
|
|
|
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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>""" |
|
|