Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>StructuralDesignEnv Interactive Demo</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| --bg: #07131d; | |
| --bg-soft: #0d1d29; | |
| --panel: rgba(13, 29, 41, 0.92); | |
| --panel-strong: rgba(18, 38, 52, 0.96); | |
| --line: rgba(255, 255, 255, 0.1); | |
| --line-strong: rgba(255, 255, 255, 0.18); | |
| --text: #f4efe6; | |
| --muted: #98acb8; | |
| --accent: #e9a24f; | |
| --accent-soft: rgba(233, 162, 79, 0.16); | |
| --secondary: #68c3b3; | |
| --secondary-soft: rgba(104, 195, 179, 0.16); | |
| --good: #74c77b; | |
| --warn: #f2c462; | |
| --danger: #ef6b5b; | |
| --shadow: 0 24px 80px rgba(0, 0, 0, 0.28); | |
| --radius: 22px; | |
| --radius-sm: 14px; | |
| --heading-font: Georgia, "Times New Roman", serif; | |
| --body-font: "Avenir Next", "Trebuchet MS", sans-serif; | |
| --mono-font: "SFMono-Regular", "Consolas", monospace; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| html { | |
| scroll-behavior: smooth; | |
| } | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| font-family: var(--body-font); | |
| color: var(--text); | |
| background: | |
| radial-gradient(circle at top left, rgba(104, 195, 179, 0.16), transparent 28%), | |
| radial-gradient(circle at top right, rgba(233, 162, 79, 0.18), transparent 24%), | |
| linear-gradient(180deg, #08131d 0%, #091925 45%, #07131d 100%); | |
| } | |
| body::before { | |
| content: ""; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| background-image: | |
| linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); | |
| background-size: 28px 28px; | |
| mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.4), transparent 88%); | |
| } | |
| a { | |
| color: inherit; | |
| } | |
| button, | |
| select { | |
| font: inherit; | |
| } | |
| .page-shell { | |
| width: min(1500px, calc(100vw - 32px)); | |
| margin: 0 auto; | |
| padding: 28px 0 36px; | |
| } | |
| .hero { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.9fr); | |
| gap: 24px; | |
| align-items: stretch; | |
| margin-bottom: 24px; | |
| } | |
| .hero-copy, | |
| .hero-card, | |
| .panel { | |
| position: relative; | |
| overflow: hidden; | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| background: var(--panel); | |
| box-shadow: var(--shadow); | |
| backdrop-filter: blur(18px); | |
| } | |
| .hero-copy { | |
| padding: 32px; | |
| background: | |
| radial-gradient(circle at 0% 0%, rgba(233, 162, 79, 0.14), transparent 36%), | |
| radial-gradient(circle at 100% 30%, rgba(104, 195, 179, 0.16), transparent 34%), | |
| linear-gradient(180deg, rgba(13, 29, 41, 0.98), rgba(9, 21, 31, 0.94)); | |
| } | |
| .hero-card { | |
| padding: 26px; | |
| background: | |
| linear-gradient(180deg, rgba(14, 31, 44, 0.98), rgba(11, 24, 34, 0.95)); | |
| } | |
| .eyebrow, | |
| .section-label { | |
| margin: 0 0 10px; | |
| color: var(--secondary); | |
| font-size: 0.77rem; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| } | |
| h1, | |
| h2, | |
| h3 { | |
| margin: 0; | |
| font-family: var(--heading-font); | |
| font-weight: 700; | |
| letter-spacing: -0.02em; | |
| } | |
| h1 { | |
| max-width: 12ch; | |
| font-size: clamp(2.8rem, 5vw, 4.8rem); | |
| line-height: 0.95; | |
| } | |
| h2 { | |
| font-size: 1.65rem; | |
| line-height: 1.05; | |
| } | |
| p { | |
| margin: 0; | |
| line-height: 1.55; | |
| } | |
| .lede { | |
| max-width: 58ch; | |
| margin-top: 18px; | |
| font-size: 1.06rem; | |
| color: var(--muted); | |
| } | |
| .hero-actions, | |
| .top-meta, | |
| .mode-strip, | |
| .control-grid, | |
| .selection-actions, | |
| .legend, | |
| .footer-links { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| } | |
| .hero-actions { | |
| margin-top: 24px; | |
| } | |
| .top-meta { | |
| margin-top: 26px; | |
| gap: 14px; | |
| } | |
| .hero-stat { | |
| min-width: 140px; | |
| padding: 14px 16px; | |
| border: 1px solid var(--line); | |
| border-radius: 16px; | |
| background: rgba(255, 255, 255, 0.03); | |
| } | |
| .hero-stat-label { | |
| display: block; | |
| margin-bottom: 5px; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .hero-stat-value { | |
| font-size: 1rem; | |
| font-weight: 700; | |
| } | |
| .btn, | |
| .mode-button, | |
| .task-card, | |
| .member-row { | |
| transition: | |
| transform 160ms ease, | |
| border-color 160ms ease, | |
| background 160ms ease, | |
| color 160ms ease, | |
| box-shadow 160ms ease; | |
| } | |
| .btn, | |
| .mode-button, | |
| .selection-actions button, | |
| .footer-links a { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| min-height: 44px; | |
| padding: 0 16px; | |
| border: 1px solid var(--line); | |
| border-radius: 999px; | |
| color: var(--text); | |
| text-decoration: none; | |
| background: rgba(255, 255, 255, 0.03); | |
| cursor: pointer; | |
| } | |
| .btn:hover, | |
| .mode-button:hover, | |
| .selection-actions button:hover, | |
| .footer-links a:hover, | |
| .task-card:hover, | |
| .member-row:hover { | |
| transform: translateY(-1px); | |
| border-color: var(--line-strong); | |
| background: rgba(255, 255, 255, 0.06); | |
| } | |
| .btn.primary, | |
| .selection-actions button.primary { | |
| background: linear-gradient(135deg, rgba(233, 162, 79, 0.96), rgba(239, 107, 91, 0.92)); | |
| color: #1a0f07; | |
| border-color: transparent; | |
| font-weight: 700; | |
| } | |
| .btn.secondary { | |
| background: rgba(104, 195, 179, 0.1); | |
| color: #d8fbf4; | |
| } | |
| .btn:disabled, | |
| .mode-button:disabled, | |
| .selection-actions button:disabled, | |
| select:disabled { | |
| opacity: 0.46; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .hero-card p { | |
| margin-top: 10px; | |
| color: var(--muted); | |
| } | |
| .task-cards { | |
| display: grid; | |
| gap: 12px; | |
| margin-top: 18px; | |
| } | |
| .task-card { | |
| width: 100%; | |
| padding: 16px; | |
| border: 1px solid var(--line); | |
| border-radius: 18px; | |
| background: rgba(255, 255, 255, 0.03); | |
| color: inherit; | |
| text-align: left; | |
| cursor: pointer; | |
| } | |
| .task-card.active { | |
| border-color: rgba(233, 162, 79, 0.6); | |
| background: var(--accent-soft); | |
| box-shadow: inset 0 0 0 1px rgba(233, 162, 79, 0.18); | |
| } | |
| .task-card-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 10px; | |
| margin-bottom: 8px; | |
| } | |
| .task-pill, | |
| .status-pill, | |
| .floor-button { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 34px; | |
| padding: 0 12px; | |
| border: 1px solid var(--line); | |
| border-radius: 999px; | |
| font-size: 0.78rem; | |
| font-weight: 700; | |
| letter-spacing: 0.04em; | |
| text-transform: uppercase; | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--muted); | |
| } | |
| .task-pill.easy, | |
| .status-pill.good { | |
| color: #daf5dd; | |
| background: rgba(116, 199, 123, 0.16); | |
| border-color: rgba(116, 199, 123, 0.28); | |
| } | |
| .task-pill.medium, | |
| .status-pill.warn { | |
| color: #fff3cc; | |
| background: rgba(242, 196, 98, 0.16); | |
| border-color: rgba(242, 196, 98, 0.28); | |
| } | |
| .task-pill.hard, | |
| .status-pill.danger { | |
| color: #ffd8d3; | |
| background: rgba(239, 107, 91, 0.16); | |
| border-color: rgba(239, 107, 91, 0.28); | |
| } | |
| .status-pill.idle { | |
| color: var(--muted); | |
| } | |
| .task-card-meta { | |
| margin-top: 10px; | |
| font-size: 0.84rem; | |
| color: var(--muted); | |
| } | |
| .workspace { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.88fr); | |
| gap: 24px; | |
| } | |
| .panel { | |
| padding: 24px; | |
| background: | |
| linear-gradient(180deg, rgba(13, 29, 41, 0.98), rgba(10, 22, 31, 0.94)); | |
| } | |
| .panel + .panel { | |
| margin-top: 18px; | |
| } | |
| .panel-head { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 18px; | |
| } | |
| .session-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 12px; | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, 0.04); | |
| color: var(--muted); | |
| font-size: 0.84rem; | |
| max-width: 100%; | |
| } | |
| .session-pill code, | |
| .meta-row code { | |
| overflow-wrap: anywhere; | |
| font-family: var(--mono-font); | |
| color: #fbd8ae; | |
| } | |
| .toolbar { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 14px; | |
| margin-bottom: 14px; | |
| } | |
| label.control { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| color: var(--muted); | |
| font-size: 0.88rem; | |
| } | |
| select { | |
| width: 100%; | |
| min-height: 46px; | |
| padding: 0 14px; | |
| border: 1px solid var(--line); | |
| border-radius: 14px; | |
| color: var(--text); | |
| background: rgba(255, 255, 255, 0.04); | |
| outline: none; | |
| } | |
| .mode-strip { | |
| margin-bottom: 14px; | |
| } | |
| .mode-button.active, | |
| .floor-button.active { | |
| border-color: rgba(104, 195, 179, 0.52); | |
| color: #dcfffa; | |
| background: var(--secondary-soft); | |
| box-shadow: inset 0 0 0 1px rgba(104, 195, 179, 0.16); | |
| } | |
| .control-grid { | |
| display: grid; | |
| grid-template-columns: repeat(4, minmax(0, 1fr)); | |
| gap: 14px; | |
| margin-bottom: 14px; | |
| } | |
| .control-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| margin-bottom: 14px; | |
| } | |
| .notice { | |
| margin-bottom: 16px; | |
| padding: 12px 14px; | |
| border-radius: 14px; | |
| border: 1px solid var(--line); | |
| font-size: 0.92rem; | |
| color: var(--text); | |
| background: rgba(255, 255, 255, 0.04); | |
| } | |
| .notice.info { | |
| background: rgba(104, 195, 179, 0.1); | |
| border-color: rgba(104, 195, 179, 0.28); | |
| } | |
| .notice.warn { | |
| background: rgba(242, 196, 98, 0.12); | |
| border-color: rgba(242, 196, 98, 0.28); | |
| } | |
| .notice.error { | |
| background: rgba(239, 107, 91, 0.12); | |
| border-color: rgba(239, 107, 91, 0.3); | |
| } | |
| .floor-strip { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| } | |
| .floor-button { | |
| cursor: pointer; | |
| } | |
| .canvas-wrap { | |
| position: relative; | |
| min-height: 420px; | |
| border-radius: 22px; | |
| border: 1px solid var(--line); | |
| background: | |
| linear-gradient(180deg, rgba(14, 29, 40, 0.94), rgba(9, 20, 29, 0.98)); | |
| overflow: hidden; | |
| } | |
| #gridSvg { | |
| display: block; | |
| width: 100%; | |
| height: auto; | |
| min-height: 420px; | |
| } | |
| .grid-empty-state { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 32px; | |
| text-align: center; | |
| color: var(--muted); | |
| font-size: 1rem; | |
| background: linear-gradient(180deg, rgba(7, 19, 29, 0.24), rgba(7, 19, 29, 0.7)); | |
| } | |
| .legend { | |
| align-items: center; | |
| gap: 10px 14px; | |
| margin-top: 14px; | |
| color: var(--muted); | |
| font-size: 0.84rem; | |
| } | |
| .legend-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .legend-swatch { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255, 255, 255, 0.16); | |
| } | |
| .metric-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 16px; | |
| } | |
| .metric { | |
| padding: 14px; | |
| border-radius: 16px; | |
| border: 1px solid var(--line); | |
| background: rgba(255, 255, 255, 0.03); | |
| } | |
| .metric-label { | |
| display: block; | |
| color: var(--muted); | |
| font-size: 0.78rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin-bottom: 6px; | |
| } | |
| .metric-value { | |
| font-size: 1.12rem; | |
| font-weight: 700; | |
| } | |
| .summary-copy { | |
| padding: 14px 16px; | |
| border-radius: 16px; | |
| border: 1px solid var(--line); | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--muted); | |
| white-space: pre-wrap; | |
| } | |
| .meta-list { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .meta-row { | |
| display: flex; | |
| align-items: flex-start; | |
| justify-content: space-between; | |
| gap: 16px; | |
| padding-bottom: 10px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.06); | |
| color: var(--muted); | |
| font-size: 0.92rem; | |
| } | |
| .meta-row:last-child { | |
| border-bottom: 0; | |
| padding-bottom: 0; | |
| } | |
| .meta-row strong { | |
| color: var(--text); | |
| text-align: right; | |
| max-width: 68%; | |
| overflow-wrap: anywhere; | |
| } | |
| .selected-card { | |
| padding: 16px; | |
| border-radius: 18px; | |
| border: 1px solid var(--line); | |
| background: rgba(255, 255, 255, 0.03); | |
| min-height: 158px; | |
| } | |
| .selected-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 10px; | |
| margin-top: 14px; | |
| } | |
| .selected-field { | |
| padding: 10px 12px; | |
| border-radius: 14px; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| } | |
| .selected-field span { | |
| display: block; | |
| margin-bottom: 4px; | |
| color: var(--muted); | |
| font-size: 0.76rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .selected-field strong { | |
| font-size: 0.96rem; | |
| } | |
| .selection-actions { | |
| margin-top: 14px; | |
| } | |
| pre.code-box { | |
| margin: 14px 0 0; | |
| padding: 14px; | |
| border-radius: 16px; | |
| border: 1px solid var(--line); | |
| background: rgba(4, 11, 17, 0.72); | |
| color: #d8e9f0; | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| font-family: var(--mono-font); | |
| font-size: 0.86rem; | |
| line-height: 1.55; | |
| min-height: 78px; | |
| } | |
| .impact-box { | |
| margin-top: 14px; | |
| padding: 12px 14px; | |
| border-radius: 14px; | |
| border: 1px solid var(--line); | |
| background: rgba(255, 255, 255, 0.03); | |
| color: var(--muted); | |
| font-size: 0.9rem; | |
| min-height: 54px; | |
| } | |
| .member-list { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .member-row { | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 14px; | |
| padding: 14px 16px; | |
| border: 1px solid var(--line); | |
| border-radius: 16px; | |
| color: inherit; | |
| background: rgba(255, 255, 255, 0.03); | |
| text-align: left; | |
| cursor: pointer; | |
| } | |
| .member-row.is-selected { | |
| border-color: rgba(233, 162, 79, 0.54); | |
| background: var(--accent-soft); | |
| box-shadow: inset 0 0 0 1px rgba(233, 162, 79, 0.18); | |
| } | |
| .member-copy { | |
| min-width: 0; | |
| } | |
| .member-title { | |
| font-weight: 700; | |
| overflow-wrap: anywhere; | |
| } | |
| .member-subtitle { | |
| margin-top: 4px; | |
| color: var(--muted); | |
| font-size: 0.84rem; | |
| } | |
| .member-ur { | |
| min-width: 78px; | |
| text-align: right; | |
| font-weight: 700; | |
| } | |
| .server-message { | |
| min-height: 180px; | |
| margin: 0; | |
| } | |
| .empty-copy { | |
| color: var(--muted); | |
| min-height: 72px; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .ur-good { | |
| color: var(--good); | |
| } | |
| .ur-warn { | |
| color: var(--warn); | |
| } | |
| .ur-danger { | |
| color: var(--danger); | |
| } | |
| footer.panel { | |
| margin-top: 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| color: var(--muted); | |
| } | |
| .footer-links a { | |
| min-height: 40px; | |
| border-radius: 12px; | |
| } | |
| @media (max-width: 1180px) { | |
| .hero, | |
| .workspace { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| @media (max-width: 860px) { | |
| .page-shell { | |
| width: min(100vw - 20px, 100%); | |
| padding-top: 18px; | |
| } | |
| .hero-copy, | |
| .hero-card, | |
| .panel { | |
| padding: 18px; | |
| } | |
| .toolbar, | |
| .control-grid, | |
| .metric-grid, | |
| .selected-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .panel-head, | |
| footer.panel, | |
| .meta-row { | |
| flex-direction: column; | |
| align-items: flex-start; | |
| } | |
| .meta-row strong { | |
| max-width: 100%; | |
| text-align: left; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="page-shell"> | |
| <header class="hero"> | |
| <section class="hero-copy"> | |
| <p class="eyebrow">OpenEnv / Hugging Face Docker Space</p> | |
| <h1>Design a steel frame live in the browser.</h1> | |
| <p class="lede"> | |
| This Space now includes a real interactive simulation. Start an episode, click the grid to place columns, | |
| connect them with beams or shear walls, and watch the structural metrics update after every API-backed step. | |
| </p> | |
| <div class="hero-actions"> | |
| <button id="heroStartButton" class="btn primary" type="button" data-start-episode>New Episode</button> | |
| <a class="btn secondary" href="/docs">API Docs</a> | |
| <a class="btn secondary" href="/tasks">Tasks JSON</a> | |
| <a class="btn secondary" href="/action_schema">Action Schema</a> | |
| </div> | |
| <div class="top-meta"> | |
| <div class="hero-stat"> | |
| <span class="hero-stat-label">API</span> | |
| <span class="hero-stat-value" id="apiStatus">Loading</span> | |
| </div> | |
| <div class="hero-stat"> | |
| <span class="hero-stat-label">Episode</span> | |
| <span class="hero-stat-value" id="episodeStatus">Not started</span> | |
| </div> | |
| <div class="hero-stat"> | |
| <span class="hero-stat-label">Solver</span> | |
| <span class="hero-stat-value" id="solverStatus">Idle</span> | |
| </div> | |
| </div> | |
| </section> | |
| <aside class="hero-card"> | |
| <p class="section-label">Live Tasks</p> | |
| <h2>Pick a scenario and build directly on the site grid.</h2> | |
| <p> | |
| The task list below is loaded from the API, so the demo stays aligned with the current environment config. | |
| </p> | |
| <div class="task-cards" id="taskCards"></div> | |
| </aside> | |
| </header> | |
| <main class="workspace"> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <p class="section-label">Interactive Simulation</p> | |
| <h2>Site Grid</h2> | |
| </div> | |
| <div class="session-pill">Session <code id="sessionIdDisplay">not started</code></div> | |
| </div> | |
| <div class="toolbar"> | |
| <label class="control"> | |
| Task | |
| <select id="taskSelect"></select> | |
| </label> | |
| <label class="control"> | |
| Floor | |
| <div class="floor-strip" id="floorButtons"></div> | |
| </label> | |
| </div> | |
| <div class="mode-strip"> | |
| <button id="modeInspect" class="mode-button" type="button">Inspect</button> | |
| <button id="modeColumn" class="mode-button" type="button">Place Column</button> | |
| <button id="modeBeam" class="mode-button" type="button">Place Beam</button> | |
| <button id="modeWall" class="mode-button" type="button">Add Wall</button> | |
| </div> | |
| <div class="control-grid"> | |
| <label class="control"> | |
| Column section | |
| <select id="columnSection"></select> | |
| </label> | |
| <label class="control"> | |
| Beam section | |
| <select id="beamSection"></select> | |
| </label> | |
| <label class="control"> | |
| Wall thickness | |
| <select id="wallThickness"> | |
| <option value="0.2">0.2 m</option> | |
| <option value="0.3">0.3 m</option> | |
| </select> | |
| </label> | |
| <div class="control"> | |
| Actions | |
| <div class="control-actions"> | |
| <button id="workspaceStartButton" class="btn secondary" type="button" data-start-episode>New Episode</button> | |
| <button id="doneButton" class="btn primary" type="button">Finish Design</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="notice info" id="noticeBar">Loading API metadata...</div> | |
| <div class="canvas-wrap"> | |
| <svg id="gridSvg" viewBox="0 0 800 520" aria-label="Structural grid"></svg> | |
| <div class="grid-empty-state" id="gridEmptyState"> | |
| Start a new episode to activate the interactive simulation. | |
| </div> | |
| </div> | |
| <div class="legend"> | |
| <span class="legend-chip"><span class="legend-swatch" style="background:#74c77b"></span>UR < 0.60</span> | |
| <span class="legend-chip"><span class="legend-swatch" style="background:#f2c462"></span>0.60 to 1.00</span> | |
| <span class="legend-chip"><span class="legend-swatch" style="background:#ef6b5b"></span>UR >= 1.00</span> | |
| <span class="legend-chip"><span class="legend-swatch" style="background:#68c3b3"></span>Selected member</span> | |
| <span class="legend-chip"><span class="legend-swatch" style="background:#d6dbe1"></span>Column node</span> | |
| </div> | |
| </section> | |
| <aside> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <p class="section-label">Physics</p> | |
| <h2>Live Results</h2> | |
| </div> | |
| <span class="status-pill idle" id="statusBadge">Idle</span> | |
| </div> | |
| <div class="metric-grid"> | |
| <div class="metric"> | |
| <span class="metric-label">Validity</span> | |
| <span class="metric-value" id="metricValidity">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Max UR</span> | |
| <span class="metric-value" id="metricUR">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Drift Ratio</span> | |
| <span class="metric-value" id="metricDrift">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Deflection</span> | |
| <span class="metric-value" id="metricDeflection">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Steel Mass</span> | |
| <span class="metric-value" id="metricMass">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Carbon</span> | |
| <span class="metric-value" id="metricCarbon">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Frame Type</span> | |
| <span class="metric-value" id="metricFrameType">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Last Reward</span> | |
| <span class="metric-value" id="metricReward">-</span> | |
| </div> | |
| <div class="metric"> | |
| <span class="metric-label">Final Score</span> | |
| <span class="metric-value" id="metricScore">-</span> | |
| </div> | |
| </div> | |
| <div class="summary-copy" id="summaryMessage"> | |
| Episode-level feedback will appear here after the first action. | |
| </div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <p class="section-label">Episode</p> | |
| <h2>Session Details</h2> | |
| </div> | |
| </div> | |
| <div class="meta-list" id="episodeMeta"></div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <p class="section-label">Selection</p> | |
| <h2>Element Inspector</h2> | |
| </div> | |
| </div> | |
| <div class="selected-card" id="selectedElementCard"> | |
| <div class="empty-copy">Click a rendered member in Inspect mode to inspect forces and run edits.</div> | |
| </div> | |
| <div class="selection-actions"> | |
| <button id="clearSelectionButton" type="button">Clear Selection</button> | |
| <button id="upgradeButton" type="button">Upgrade</button> | |
| <button id="downgradeButton" type="button">Downgrade</button> | |
| <button id="removeButton" type="button">Remove</button> | |
| <button id="whatIfButton" type="button">What-If Remove</button> | |
| </div> | |
| <pre class="code-box" id="forcesBox">No member selected.</pre> | |
| <div class="impact-box" id="impactBox">Counterfactual impact will appear here.</div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <p class="section-label">Critical Members</p> | |
| <h2>Top Checks</h2> | |
| </div> | |
| </div> | |
| <div class="member-list" id="criticalMembersList"></div> | |
| </section> | |
| <section class="panel"> | |
| <div class="panel-head"> | |
| <div> | |
| <p class="section-label">Server Summary</p> | |
| <h2>Observation Message</h2> | |
| </div> | |
| </div> | |
| <pre class="code-box server-message" id="serverMessage">Reset the environment to see the backend summary.</pre> | |
| </section> | |
| </aside> | |
| </main> | |
| <footer class="panel"> | |
| <p> | |
| StructuralDesignEnv couples a live editing UI with the existing reset, step, query, and what-if endpoints so users can explore the environment directly inside the Space. | |
| </p> | |
| <div class="footer-links"> | |
| <a href="/demo">/demo</a> | |
| <a href="/docs">/docs</a> | |
| <a href="/health">/health</a> | |
| </div> | |
| </footer> | |
| </div> | |
| <script> | |
| const SVG_NS = "http://www.w3.org/2000/svg"; | |
| const fallbackSections = { | |
| columns: ["HEB140", "HEB160", "HEB200", "HEB240", "HEB300", "HEB360", "HEB400"], | |
| beams: ["IPE200", "IPE240", "IPE300", "IPE360", "IPE400", "IPE450", "IPE500"] | |
| }; | |
| const state = { | |
| health: null, | |
| schema: null, | |
| tasks: [], | |
| sessionId: null, | |
| observation: null, | |
| selectedFloor: 0, | |
| selectedElementId: null, | |
| selectedElementForces: null, | |
| selectedElementImpact: null, | |
| mode: "inspect", | |
| pendingPoint: null, | |
| busy: false, | |
| busyLabel: "", | |
| lastReward: null, | |
| lastInfo: null, | |
| episodeDone: false, | |
| notice: { text: "Loading API metadata...", tone: "info" } | |
| }; | |
| const $ = (id) => document.getElementById(id); | |
| document.addEventListener("DOMContentLoaded", () => { | |
| bindEvents(); | |
| bootstrap(); | |
| }); | |
| function bindEvents() { | |
| document.querySelectorAll("[data-start-episode]").forEach((button) => { | |
| button.addEventListener("click", () => { | |
| if (!state.busy) { | |
| startEpisode(); | |
| } | |
| }); | |
| }); | |
| $("taskSelect").addEventListener("change", () => { | |
| if (!state.observation) { | |
| state.selectedFloor = 0; | |
| } | |
| renderAll(); | |
| }); | |
| $("modeInspect").addEventListener("click", () => setMode("inspect")); | |
| $("modeColumn").addEventListener("click", () => setMode("column")); | |
| $("modeBeam").addEventListener("click", () => setMode("beam")); | |
| $("modeWall").addEventListener("click", () => setMode("wall")); | |
| $("doneButton").addEventListener("click", () => { | |
| if (!state.busy) { | |
| finalizeEpisode(); | |
| } | |
| }); | |
| $("clearSelectionButton").addEventListener("click", () => { | |
| clearSelection(); | |
| }); | |
| $("upgradeButton").addEventListener("click", () => { | |
| if (!state.busy) { | |
| sendElementMutation("upgrade_section"); | |
| } | |
| }); | |
| $("downgradeButton").addEventListener("click", () => { | |
| if (!state.busy) { | |
| sendElementMutation("downgrade_section"); | |
| } | |
| }); | |
| $("removeButton").addEventListener("click", () => { | |
| if (!state.busy) { | |
| sendElementMutation("remove_element"); | |
| } | |
| }); | |
| $("whatIfButton").addEventListener("click", () => { | |
| if (!state.busy) { | |
| runWhatIfRemove(); | |
| } | |
| }); | |
| } | |
| async function bootstrap() { | |
| setBusy(true, "Loading"); | |
| try { | |
| const [health, tasksResponse, schema] = await Promise.all([ | |
| api("/health"), | |
| api("/tasks"), | |
| api("/action_schema") | |
| ]); | |
| state.health = health; | |
| state.tasks = Array.isArray(tasksResponse.tasks) ? tasksResponse.tasks : []; | |
| state.schema = schema || {}; | |
| populateTaskSelect(); | |
| populateSectionSelects(); | |
| setNotice("The demo is ready. Start a new episode to begin placing members.", "info"); | |
| } catch (error) { | |
| setNotice(error.message, "error"); | |
| } finally { | |
| setBusy(false); | |
| renderAll(); | |
| } | |
| } | |
| async function startEpisode() { | |
| const taskId = selectedTaskId(); | |
| setBusy(true, "Resetting"); | |
| try { | |
| const payload = await api("/reset", { | |
| method: "POST", | |
| body: JSON.stringify({ task_id: taskId }) | |
| }); | |
| state.sessionId = payload.session_id; | |
| state.lastReward = null; | |
| state.lastInfo = null; | |
| state.episodeDone = false; | |
| state.selectedFloor = 0; | |
| state.selectedElementId = null; | |
| state.selectedElementForces = null; | |
| state.selectedElementImpact = null; | |
| state.pendingPoint = null; | |
| applyObservation(payload.observation); | |
| setMode("column"); | |
| setNotice("Episode ready. Click the grid to place columns on floor 0.", "info"); | |
| } catch (error) { | |
| setNotice(error.message, "error"); | |
| } finally { | |
| setBusy(false); | |
| renderAll(); | |
| } | |
| } | |
| async function finalizeEpisode() { | |
| if (!state.sessionId || !state.observation || state.episodeDone) { | |
| return; | |
| } | |
| await sendAction({ action_type: "done" }, "Final grading"); | |
| } | |
| async function sendElementMutation(actionType) { | |
| const element = getSelectedElement(); | |
| if (!element || !state.sessionId || state.episodeDone) { | |
| return; | |
| } | |
| await sendAction({ action_type: actionType, element_id: element.id }, "Updating section"); | |
| } | |
| async function sendAction(action, busyLabel) { | |
| if (!state.sessionId) { | |
| setNotice("Start an episode before sending actions.", "warn"); | |
| renderAll(); | |
| return; | |
| } | |
| setBusy(true, busyLabel || "Sending action"); | |
| try { | |
| const payload = await api("/step", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| session_id: state.sessionId, | |
| message: JSON.stringify(action) | |
| }) | |
| }); | |
| state.lastReward = typeof payload.reward === "number" ? payload.reward : null; | |
| state.lastInfo = payload.info || {}; | |
| state.episodeDone = Boolean(payload.done); | |
| state.pendingPoint = null; | |
| applyObservation(payload.observation); | |
| if (state.observation && state.observation.last_action_result === "INVALID") { | |
| setNotice(state.observation.last_action_error || "The backend rejected that action.", "warn"); | |
| } else if (state.episodeDone && typeof state.lastInfo.graded_score === "number") { | |
| setNotice("Episode finished. Final score: " + formatNumber(state.lastInfo.graded_score, 4), "info"); | |
| } else { | |
| setNotice(actionSummary(action), "info"); | |
| } | |
| await refreshSelectedElementDetails(); | |
| } catch (error) { | |
| setNotice(error.message, "error"); | |
| } finally { | |
| setBusy(false); | |
| renderAll(); | |
| } | |
| } | |
| async function runWhatIfRemove() { | |
| const element = getSelectedElement(); | |
| if (!element || !state.sessionId) { | |
| return; | |
| } | |
| setBusy(true, "Running what-if"); | |
| try { | |
| state.selectedElementImpact = await api("/what_if_remove", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| session_id: state.sessionId, | |
| element_id: element.id | |
| }) | |
| }); | |
| setNotice("Counterfactual complete for " + element.id + ".", "info"); | |
| } catch (error) { | |
| state.selectedElementImpact = { error: error.message }; | |
| setNotice(error.message, "warn"); | |
| } finally { | |
| setBusy(false); | |
| renderAll(); | |
| } | |
| } | |
| function applyObservation(observation) { | |
| state.observation = observation || null; | |
| if (state.observation) { | |
| const availableFloors = Math.max(1, state.observation.n_floors || 1); | |
| if (state.selectedFloor >= availableFloors) { | |
| state.selectedFloor = availableFloors - 1; | |
| } | |
| } else { | |
| state.selectedFloor = 0; | |
| } | |
| if (state.selectedElementId && !getSelectedElement()) { | |
| state.selectedElementId = null; | |
| state.selectedElementForces = null; | |
| state.selectedElementImpact = null; | |
| } | |
| } | |
| async function selectElement(elementId) { | |
| if (!state.observation) { | |
| return; | |
| } | |
| state.selectedElementId = elementId; | |
| state.selectedElementForces = null; | |
| state.selectedElementImpact = null; | |
| renderAll(); | |
| await refreshSelectedElementDetails(); | |
| } | |
| async function refreshSelectedElementDetails() { | |
| const element = getSelectedElement(); | |
| if (!element || !state.sessionId) { | |
| state.selectedElementForces = null; | |
| return; | |
| } | |
| try { | |
| state.selectedElementForces = await api( | |
| "/query_forces?session_id=" + encodeURIComponent(state.sessionId) + | |
| "&element_id=" + encodeURIComponent(element.id) | |
| ); | |
| } catch (error) { | |
| state.selectedElementForces = { error: error.message }; | |
| } | |
| renderAll(); | |
| } | |
| function clearSelection() { | |
| state.selectedElementId = null; | |
| state.selectedElementForces = null; | |
| state.selectedElementImpact = null; | |
| state.pendingPoint = null; | |
| setNotice(modeHint(), "info"); | |
| renderAll(); | |
| } | |
| function populateTaskSelect() { | |
| const select = $("taskSelect"); | |
| const active = select.value || "task1_warehouse"; | |
| select.innerHTML = state.tasks.map((task) => ( | |
| "<option value=\"" + escapeHtml(task.id) + "\"" + | |
| (task.id === active ? " selected" : "") + ">" + | |
| escapeHtml(task.name) + " (" + escapeHtml(task.id) + ")" + | |
| "</option>" | |
| )).join(""); | |
| if (!select.value && state.tasks.length) { | |
| select.value = state.tasks[0].id; | |
| } | |
| } | |
| function populateSectionSelects() { | |
| const schemaSections = state.schema && state.schema.sections ? state.schema.sections : fallbackSections; | |
| populateSelectWithOptions($("columnSection"), schemaSections.columns || fallbackSections.columns); | |
| populateSelectWithOptions($("beamSection"), schemaSections.beams || fallbackSections.beams); | |
| $("columnSection").value = "HEB200"; | |
| $("beamSection").value = "IPE300"; | |
| } | |
| function populateSelectWithOptions(select, options) { | |
| select.innerHTML = options.map((value) => ( | |
| "<option value=\"" + escapeHtml(value) + "\">" + escapeHtml(value) + "</option>" | |
| )).join(""); | |
| } | |
| function selectedTaskId() { | |
| return $("taskSelect").value || "task1_warehouse"; | |
| } | |
| function currentTaskId() { | |
| return state.observation ? state.observation.task_id : selectedTaskId(); | |
| } | |
| function currentTaskMeta() { | |
| return state.tasks.find((task) => task.id === currentTaskId()) || null; | |
| } | |
| function setMode(mode) { | |
| state.mode = mode; | |
| state.pendingPoint = null; | |
| setNotice(modeHint(), "info"); | |
| renderAll(); | |
| } | |
| function setBusy(isBusy, label) { | |
| state.busy = isBusy; | |
| state.busyLabel = label || ""; | |
| } | |
| function setNotice(text, tone) { | |
| state.notice = { text: text, tone: tone || "info" }; | |
| } | |
| function renderAll() { | |
| renderHeader(); | |
| renderTaskCards(); | |
| renderFloorButtons(); | |
| renderModeButtons(); | |
| renderNotice(); | |
| renderGrid(); | |
| renderMetrics(); | |
| renderEpisodeMeta(); | |
| renderSelectedElement(); | |
| renderCriticalMembers(); | |
| renderServerMessage(); | |
| refreshControlStates(); | |
| } | |
| function renderHeader() { | |
| const health = state.health; | |
| $("apiStatus").textContent = health ? (health.status + " / v" + health.version) : "Unavailable"; | |
| $("episodeStatus").textContent = state.observation | |
| ? ("Step " + state.observation.step_count + " / " + state.observation.max_steps) | |
| : "Not started"; | |
| $("solverStatus").textContent = state.busy | |
| ? state.busyLabel || "Working" | |
| : solverLabel(); | |
| $("sessionIdDisplay").textContent = state.sessionId || "not started"; | |
| } | |
| function renderTaskCards() { | |
| const wrapper = $("taskCards"); | |
| if (!state.tasks.length) { | |
| wrapper.innerHTML = "<div class=\"empty-copy\">No tasks available.</div>"; | |
| return; | |
| } | |
| const activeId = selectedTaskId(); | |
| wrapper.innerHTML = state.tasks.map((task) => { | |
| const difficultyClass = difficultyTone(task.difficulty); | |
| return ( | |
| "<button type=\"button\" class=\"task-card " + (task.id === activeId ? "active" : "") + "\" data-task-id=\"" + escapeHtml(task.id) + "\">" + | |
| "<div class=\"task-card-head\">" + | |
| "<strong>" + escapeHtml(task.name) + "</strong>" + | |
| "<span class=\"task-pill " + difficultyClass + "\">" + escapeHtml(task.difficulty) + "</span>" + | |
| "</div>" + | |
| "<p>" + escapeHtml(task.description || "") + "</p>" + | |
| "<div class=\"task-card-meta\">" + | |
| escapeHtml(task.id) + " | " + | |
| escapeHtml(String(task.n_floors)) + " floor(s) | " + | |
| escapeHtml(String(task.max_steps)) + " steps" + | |
| "</div>" + | |
| "</button>" | |
| ); | |
| }).join(""); | |
| wrapper.querySelectorAll("[data-task-id]").forEach((button) => { | |
| button.addEventListener("click", () => { | |
| if (state.busy) { | |
| return; | |
| } | |
| $("taskSelect").value = button.getAttribute("data-task-id"); | |
| if (!state.observation) { | |
| state.selectedFloor = 0; | |
| } | |
| renderAll(); | |
| }); | |
| }); | |
| } | |
| function renderFloorButtons() { | |
| const wrapper = $("floorButtons"); | |
| const floorCount = state.observation | |
| ? Math.max(1, state.observation.n_floors || 1) | |
| : Math.max(1, currentTaskMeta() ? currentTaskMeta().n_floors : 1); | |
| if (state.selectedFloor >= floorCount) { | |
| state.selectedFloor = floorCount - 1; | |
| } | |
| wrapper.innerHTML = Array.from({ length: floorCount }, (_, floor) => ( | |
| "<button type=\"button\" class=\"floor-button " + (floor === state.selectedFloor ? "active" : "") + "\" data-floor=\"" + floor + "\">" + | |
| "Floor " + floor + | |
| "</button>" | |
| )).join(""); | |
| wrapper.querySelectorAll("[data-floor]").forEach((button) => { | |
| button.addEventListener("click", () => { | |
| state.selectedFloor = Number(button.getAttribute("data-floor")); | |
| state.pendingPoint = null; | |
| renderAll(); | |
| }); | |
| }); | |
| } | |
| function renderModeButtons() { | |
| const mapping = { | |
| inspect: $("modeInspect"), | |
| column: $("modeColumn"), | |
| beam: $("modeBeam"), | |
| wall: $("modeWall") | |
| }; | |
| Object.entries(mapping).forEach(([mode, button]) => { | |
| button.classList.toggle("active", state.mode === mode); | |
| }); | |
| } | |
| function renderNotice() { | |
| const notice = $("noticeBar"); | |
| const tone = state.notice ? state.notice.tone : "info"; | |
| notice.className = "notice " + tone; | |
| notice.textContent = state.busy ? (state.busyLabel + "...") : (state.notice ? state.notice.text : modeHint()); | |
| } | |
| function renderGrid() { | |
| const svg = $("gridSvg"); | |
| const emptyState = $("gridEmptyState"); | |
| svg.innerHTML = ""; | |
| if (!state.observation) { | |
| emptyState.style.display = "flex"; | |
| return; | |
| } | |
| emptyState.style.display = "none"; | |
| const width = Math.max(1, Math.round(state.observation.site_width_m)); | |
| const depth = Math.max(1, Math.round(state.observation.site_depth_m)); | |
| const cell = Math.max(24, Math.floor(620 / Math.max(width, depth))); | |
| const padding = 56; | |
| const viewWidth = padding * 2 + width * cell; | |
| const viewHeight = padding * 2 + depth * cell; | |
| svg.setAttribute("viewBox", "0 0 " + viewWidth + " " + viewHeight); | |
| svg.style.aspectRatio = viewWidth + " / " + viewHeight; | |
| const centerX = (x) => padding + (x * cell) + (cell / 2); | |
| const centerY = (y) => padding + ((depth - y - 1) * cell) + (cell / 2); | |
| const cellLeft = (x) => padding + (x * cell); | |
| const cellTop = (y) => padding + ((depth - y - 1) * cell); | |
| svg.appendChild(createSvg("rect", { | |
| x: padding, | |
| y: padding, | |
| width: width * cell, | |
| height: depth * cell, | |
| rx: 18, | |
| fill: "rgba(9, 20, 29, 0.84)", | |
| stroke: "rgba(255, 255, 255, 0.12)", | |
| "stroke-width": 1.5 | |
| })); | |
| for (let x = 0; x <= width; x += 1) { | |
| svg.appendChild(createSvg("line", { | |
| x1: padding + x * cell, | |
| y1: padding, | |
| x2: padding + x * cell, | |
| y2: padding + depth * cell, | |
| stroke: "rgba(255, 255, 255, 0.08)", | |
| "stroke-width": 1 | |
| })); | |
| } | |
| for (let y = 0; y <= depth; y += 1) { | |
| svg.appendChild(createSvg("line", { | |
| x1: padding, | |
| y1: padding + y * cell, | |
| x2: padding + width * cell, | |
| y2: padding + y * cell, | |
| stroke: "rgba(255, 255, 255, 0.08)", | |
| "stroke-width": 1 | |
| })); | |
| } | |
| for (let x = 0; x < width; x += 1) { | |
| svg.appendChild(createSvg("text", { | |
| x: centerX(x), | |
| y: viewHeight - 20, | |
| "text-anchor": "middle", | |
| fill: "rgba(255, 255, 255, 0.48)", | |
| "font-size": 12, | |
| "font-family": "monospace" | |
| }, String(x))); | |
| } | |
| for (let y = 0; y < depth; y += 1) { | |
| svg.appendChild(createSvg("text", { | |
| x: 28, | |
| y: centerY(y) + 4, | |
| "text-anchor": "middle", | |
| fill: "rgba(255, 255, 255, 0.48)", | |
| "font-size": 12, | |
| "font-family": "monospace" | |
| }, String(y))); | |
| } | |
| svg.appendChild(createSvg("text", { | |
| x: padding, | |
| y: 28, | |
| fill: "#dfeaf0", | |
| "font-size": 14, | |
| "font-family": "\"Avenir Next\", \"Trebuchet MS\", sans-serif" | |
| }, currentTaskId() + " / floor " + state.selectedFloor)); | |
| for (let x = 0; x < width; x += 1) { | |
| for (let y = 0; y < depth; y += 1) { | |
| const hit = createSvg("rect", { | |
| x: cellLeft(x), | |
| y: cellTop(y), | |
| width: cell, | |
| height: cell, | |
| fill: "transparent", | |
| cursor: state.busy ? "wait" : "pointer" | |
| }); | |
| hit.addEventListener("click", () => handleGridPointClick(x, y)); | |
| svg.appendChild(hit); | |
| } | |
| } | |
| const elements = state.observation.placed_elements || []; | |
| elements | |
| .filter((element) => element.type !== "column" && elementVisibleOnFloor(element, state.selectedFloor)) | |
| .forEach((element) => { | |
| const start = parseNodeId(element.node_i); | |
| const end = parseNodeId(element.node_j); | |
| if (!start || !end) { | |
| return; | |
| } | |
| const ur = urForElement(element.id); | |
| const selected = state.selectedElementId === element.id; | |
| const stroke = selected ? "#68c3b3" : urColor(ur); | |
| const widthValue = element.type === "wall" ? Math.max(12, cell * 0.3) : Math.max(6, cell * 0.16); | |
| if (selected) { | |
| svg.appendChild(createSvg("line", { | |
| x1: centerX(start.x), | |
| y1: centerY(start.y), | |
| x2: centerX(end.x), | |
| y2: centerY(end.y), | |
| stroke: "rgba(104, 195, 179, 0.18)", | |
| "stroke-width": widthValue + 8, | |
| "stroke-linecap": "round" | |
| })); | |
| } | |
| const line = createSvg("line", { | |
| x1: centerX(start.x), | |
| y1: centerY(start.y), | |
| x2: centerX(end.x), | |
| y2: centerY(end.y), | |
| stroke: stroke, | |
| "stroke-width": widthValue, | |
| "stroke-linecap": "round", | |
| opacity: 0.96, | |
| cursor: "pointer" | |
| }); | |
| line.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| if (state.mode === "inspect") { | |
| selectElement(element.id); | |
| } | |
| }); | |
| svg.appendChild(line); | |
| }); | |
| elements | |
| .filter((element) => element.type === "column" && elementVisibleOnFloor(element, state.selectedFloor)) | |
| .forEach((element) => { | |
| const node = parseNodeId(element.node_i); | |
| if (!node) { | |
| return; | |
| } | |
| const ur = urForElement(element.id); | |
| const selected = state.selectedElementId === element.id; | |
| const circle = createSvg("circle", { | |
| cx: centerX(node.x), | |
| cy: centerY(node.y), | |
| r: Math.max(8, cell * 0.2), | |
| fill: selected ? "#68c3b3" : urColor(ur), | |
| stroke: selected ? "#dff9f4" : "#f6ede1", | |
| "stroke-width": selected ? 2.5 : 1.2, | |
| cursor: "pointer" | |
| }); | |
| circle.addEventListener("click", (event) => { | |
| event.stopPropagation(); | |
| if (state.mode === "inspect") { | |
| selectElement(element.id); | |
| } else { | |
| handleGridPointClick(node.x, node.y); | |
| } | |
| }); | |
| svg.appendChild(circle); | |
| }); | |
| if (state.pendingPoint) { | |
| svg.appendChild(createSvg("circle", { | |
| cx: centerX(state.pendingPoint.x), | |
| cy: centerY(state.pendingPoint.y), | |
| r: Math.max(12, cell * 0.28), | |
| fill: "transparent", | |
| stroke: "#f6ede1", | |
| "stroke-width": 2.5, | |
| "stroke-dasharray": "6 4" | |
| })); | |
| } | |
| } | |
| function handleGridPointClick(x, y) { | |
| if (state.busy) { | |
| return; | |
| } | |
| if (!state.observation) { | |
| setNotice("Start a new episode first.", "warn"); | |
| renderAll(); | |
| return; | |
| } | |
| if (state.episodeDone) { | |
| setNotice("This episode is finished. Start a new one to continue editing.", "warn"); | |
| renderAll(); | |
| return; | |
| } | |
| if (state.mode === "inspect") { | |
| state.selectedElementId = null; | |
| state.selectedElementForces = null; | |
| state.selectedElementImpact = null; | |
| setNotice("Inspect mode is active. Click a rendered member to inspect it.", "info"); | |
| renderAll(); | |
| return; | |
| } | |
| if (state.mode === "column") { | |
| sendAction({ | |
| action_type: "place_column", | |
| grid_x: x, | |
| grid_y: y, | |
| floor: state.selectedFloor, | |
| section: $("columnSection").value | |
| }, "Placing column"); | |
| return; | |
| } | |
| if (!columnExistsAt(x, y, state.selectedFloor)) { | |
| setNotice("Beam and wall endpoints must already have columns on this floor.", "warn"); | |
| renderAll(); | |
| return; | |
| } | |
| if (!state.pendingPoint) { | |
| state.pendingPoint = { x: x, y: y }; | |
| setNotice( | |
| "Start point locked at (" + x + ", " + y + "). Choose an axis-aligned end point.", | |
| "info" | |
| ); | |
| renderAll(); | |
| return; | |
| } | |
| if (state.pendingPoint.x === x && state.pendingPoint.y === y) { | |
| state.pendingPoint = null; | |
| setNotice(modeHint(), "info"); | |
| renderAll(); | |
| return; | |
| } | |
| if (state.pendingPoint.x !== x && state.pendingPoint.y !== y) { | |
| setNotice("Only axis-aligned members are supported here. Choose a point sharing x or y.", "warn"); | |
| renderAll(); | |
| return; | |
| } | |
| const action = state.mode === "beam" | |
| ? { | |
| action_type: "place_beam", | |
| from_node_x: state.pendingPoint.x, | |
| from_node_y: state.pendingPoint.y, | |
| to_node_x: x, | |
| to_node_y: y, | |
| floor: state.selectedFloor, | |
| section: $("beamSection").value, | |
| orientation: state.pendingPoint.y === y ? "x" : "y" | |
| } | |
| : { | |
| action_type: "add_wall", | |
| from_node_x: state.pendingPoint.x, | |
| from_node_y: state.pendingPoint.y, | |
| to_node_x: x, | |
| to_node_y: y, | |
| floor: state.selectedFloor, | |
| thickness_m: Number($("wallThickness").value), | |
| orientation: state.pendingPoint.y === y ? "x" : "y" | |
| }; | |
| sendAction(action, state.mode === "beam" ? "Placing beam" : "Adding wall"); | |
| } | |
| function renderMetrics() { | |
| const observation = state.observation; | |
| if (!observation) { | |
| $("statusBadge").className = "status-pill idle"; | |
| $("statusBadge").textContent = state.busy ? state.busyLabel : "Idle"; | |
| $("metricValidity").textContent = "-"; | |
| $("metricUR").textContent = "-"; | |
| $("metricDrift").textContent = "-"; | |
| $("metricDeflection").textContent = "-"; | |
| $("metricMass").textContent = "-"; | |
| $("metricCarbon").textContent = "-"; | |
| $("metricFrameType").textContent = "-"; | |
| $("metricReward").textContent = "-"; | |
| $("metricScore").textContent = "-"; | |
| $("summaryMessage").textContent = "Episode-level feedback will appear here after the first action."; | |
| return; | |
| } | |
| const validity = observation.is_structurally_valid ? "Valid" : (observation.n_elements_placed ? "Invalid" : "Awaiting frame"); | |
| const maxUr = observation.critical_members && observation.critical_members.length | |
| ? observation.critical_members[0].max_UR | |
| : null; | |
| const score = state.lastInfo && typeof state.lastInfo.graded_score === "number" | |
| ? state.lastInfo.graded_score | |
| : null; | |
| $("statusBadge").className = "status-pill " + statusTone(observation); | |
| $("statusBadge").textContent = state.busy ? state.busyLabel : solverLabel(); | |
| $("metricValidity").textContent = validity; | |
| $("metricUR").textContent = maxUr === null ? "-" : formatNumber(maxUr, 3); | |
| $("metricUR").className = "metric-value " + urToneClass(maxUr); | |
| $("metricDrift").textContent = observation.n_elements_placed | |
| ? formatNumber(observation.max_lateral_drift_ratio, 3) | |
| : "-"; | |
| $("metricDeflection").textContent = observation.n_elements_placed | |
| ? formatNumber(observation.max_deflection_mm, 2) + " mm" | |
| : "-"; | |
| $("metricMass").textContent = observation.n_elements_placed | |
| ? Math.round(observation.total_steel_mass_kg).toLocaleString() + " kg" | |
| : "-"; | |
| $("metricCarbon").textContent = observation.n_elements_placed | |
| ? Math.round(observation.carbon_kg).toLocaleString() + " kg" | |
| : "-"; | |
| $("metricFrameType").textContent = observation.is_braced_frame ? "Braced" : "Unbraced"; | |
| $("metricReward").textContent = typeof state.lastReward === "number" ? formatNumber(state.lastReward, 4) : "-"; | |
| $("metricScore").textContent = typeof score === "number" ? formatNumber(score, 4) : "-"; | |
| $("summaryMessage").textContent = summaryCopy(observation); | |
| } | |
| function renderEpisodeMeta() { | |
| const wrapper = $("episodeMeta"); | |
| const observation = state.observation; | |
| const rows = [ | |
| { label: "Task", value: observation ? observation.task_id : currentTaskId() }, | |
| { label: "Episode ID", value: observation ? observation.episode_id : "-" }, | |
| { label: "Session ID", value: state.sessionId ? "<code>" + escapeHtml(state.sessionId) + "</code>" : "-" }, | |
| { label: "Steps", value: observation ? (observation.step_count + " / " + observation.max_steps) : "-" }, | |
| { label: "Elements", value: observation ? String(observation.n_elements_placed) : "-" }, | |
| { label: "Last action", value: observation ? observation.last_action_result : "-" }, | |
| { label: "Error", value: observation && observation.last_action_error ? observation.last_action_error : "None" }, | |
| { label: "Done", value: state.episodeDone ? "Yes" : "No" } | |
| ]; | |
| if (state.lastInfo && typeof state.lastInfo.graded_score === "number") { | |
| rows.push({ label: "Final score", value: formatNumber(state.lastInfo.graded_score, 4) }); | |
| } | |
| wrapper.innerHTML = rows.map((row) => ( | |
| "<div class=\"meta-row\"><span>" + escapeHtml(row.label) + "</span><strong>" + row.value + "</strong></div>" | |
| )).join(""); | |
| } | |
| function renderSelectedElement() { | |
| const wrapper = $("selectedElementCard"); | |
| const element = getSelectedElement(); | |
| if (!element) { | |
| wrapper.innerHTML = "<div class=\"empty-copy\">Click a rendered member in Inspect mode to inspect forces and run edits.</div>"; | |
| $("forcesBox").textContent = "No member selected."; | |
| $("impactBox").textContent = "Counterfactual impact will appear here."; | |
| return; | |
| } | |
| const critical = criticalMemberById(element.id); | |
| const floor = elementDisplayFloor(element); | |
| wrapper.innerHTML = | |
| "<strong>" + escapeHtml(element.id) + "</strong>" + | |
| "<div class=\"selected-grid\">" + | |
| selectedField("Type", element.type) + | |
| selectedField("Section", element.section || "wall") + | |
| selectedField("Floor", String(floor)) + | |
| selectedField("Length", formatNumber(element.length_m, 2) + " m") + | |
| selectedField("Max UR", critical ? formatNumber(critical.max_UR, 3) : "n/a") + | |
| selectedField("Orientation", element.orientation || "-") + | |
| "</div>"; | |
| $("forcesBox").textContent = forceSummary(); | |
| $("impactBox").textContent = impactSummary(); | |
| } | |
| function renderCriticalMembers() { | |
| const wrapper = $("criticalMembersList"); | |
| const observation = state.observation; | |
| if (!observation || !observation.critical_members || !observation.critical_members.length) { | |
| wrapper.innerHTML = "<div class=\"empty-copy\">Critical members appear after the solver has a connected frame to analyze.</div>"; | |
| return; | |
| } | |
| wrapper.innerHTML = observation.critical_members.slice(0, 8).map((member) => ( | |
| "<button type=\"button\" class=\"member-row " + (state.selectedElementId === member.id ? "is-selected" : "") + "\" data-member-id=\"" + escapeHtml(member.id) + "\">" + | |
| "<span class=\"member-copy\">" + | |
| "<span class=\"member-title\">" + escapeHtml(member.id) + "</span>" + | |
| "<span class=\"member-subtitle\">" + | |
| escapeHtml(member.type) + " | " + escapeHtml(member.section) + " | L=" + escapeHtml(formatNumber(member.length_m, 2)) + " m" + | |
| "</span>" + | |
| "</span>" + | |
| "<span class=\"member-ur " + urToneClass(member.max_UR) + "\">" + escapeHtml(formatNumber(member.max_UR, 3)) + "</span>" + | |
| "</button>" | |
| )).join(""); | |
| wrapper.querySelectorAll("[data-member-id]").forEach((button) => { | |
| button.addEventListener("click", () => { | |
| if (!state.busy) { | |
| selectElement(button.getAttribute("data-member-id")); | |
| } | |
| }); | |
| }); | |
| } | |
| function renderServerMessage() { | |
| $("serverMessage").textContent = state.observation | |
| ? state.observation.message | |
| : "Reset the environment to see the backend summary."; | |
| } | |
| function refreshControlStates() { | |
| const hasEpisode = Boolean(state.observation && state.sessionId); | |
| const selectedElement = getSelectedElement(); | |
| const selectedIsWall = Boolean(selectedElement && selectedElement.type === "wall"); | |
| $("taskSelect").disabled = state.busy; | |
| $("columnSection").disabled = state.busy || !hasEpisode || state.episodeDone; | |
| $("beamSection").disabled = state.busy || !hasEpisode || state.episodeDone; | |
| $("wallThickness").disabled = state.busy || !hasEpisode || state.episodeDone; | |
| $("doneButton").disabled = state.busy || !hasEpisode || state.episodeDone; | |
| $("clearSelectionButton").disabled = state.busy || (!selectedElement && !state.pendingPoint); | |
| $("upgradeButton").disabled = state.busy || !selectedElement || selectedIsWall || state.episodeDone; | |
| $("downgradeButton").disabled = state.busy || !selectedElement || selectedIsWall || state.episodeDone; | |
| $("removeButton").disabled = state.busy || !selectedElement || state.episodeDone; | |
| $("whatIfButton").disabled = state.busy || !selectedElement; | |
| document.querySelectorAll("[data-start-episode]").forEach((button) => { | |
| button.disabled = state.busy; | |
| }); | |
| } | |
| function createSvg(tag, attrs, text) { | |
| const element = document.createElementNS(SVG_NS, tag); | |
| Object.entries(attrs || {}).forEach(([key, value]) => { | |
| element.setAttribute(key, String(value)); | |
| }); | |
| if (text !== undefined) { | |
| element.textContent = text; | |
| } | |
| return element; | |
| } | |
| function getSelectedElement() { | |
| return state.observation && state.selectedElementId | |
| ? (state.observation.placed_elements || []).find((element) => element.id === state.selectedElementId) || null | |
| : null; | |
| } | |
| function criticalMemberById(elementId) { | |
| return state.observation | |
| ? (state.observation.critical_members || []).find((member) => member.id === elementId) || null | |
| : null; | |
| } | |
| function urForElement(elementId) { | |
| const member = criticalMemberById(elementId); | |
| return member ? member.max_UR : null; | |
| } | |
| function columnExistsAt(x, y, floor) { | |
| return Boolean(state.observation && (state.observation.placed_elements || []).some((element) => { | |
| if (element.type !== "column") { | |
| return false; | |
| } | |
| const node = parseNodeId(element.node_i); | |
| return Boolean(node && node.x === x && node.y === y && node.floor === floor); | |
| })); | |
| } | |
| function parseNodeId(nodeId) { | |
| const match = /^n_(\d+)_(\d+)_(\d+)$/.exec(nodeId || ""); | |
| if (!match) { | |
| return null; | |
| } | |
| return { | |
| x: Number(match[1]), | |
| y: Number(match[2]), | |
| floor: Number(match[3]) | |
| }; | |
| } | |
| function elementVisibleOnFloor(element, floor) { | |
| const node = parseNodeId(element.node_i); | |
| if (!node) { | |
| return false; | |
| } | |
| if (element.type === "column") { | |
| return node.floor === floor; | |
| } | |
| return node.floor === floor + 1; | |
| } | |
| function elementDisplayFloor(element) { | |
| const node = parseNodeId(element.node_i); | |
| if (!node) { | |
| return 0; | |
| } | |
| return element.type === "column" ? node.floor : Math.max(0, node.floor - 1); | |
| } | |
| function solverLabel() { | |
| if (!state.observation) { | |
| return "Idle"; | |
| } | |
| if (state.episodeDone && state.lastInfo && typeof state.lastInfo.graded_score === "number") { | |
| return "Graded"; | |
| } | |
| if (!state.observation.n_elements_placed) { | |
| return "Awaiting frame"; | |
| } | |
| if (!state.observation.critical_members.length) { | |
| return "Disconnected"; | |
| } | |
| return state.observation.is_structurally_valid ? "Valid" : "Invalid"; | |
| } | |
| function statusTone(observation) { | |
| if (!observation || !observation.n_elements_placed) { | |
| return "idle"; | |
| } | |
| if (observation.is_structurally_valid) { | |
| return "good"; | |
| } | |
| if (observation.critical_members.length) { | |
| return "danger"; | |
| } | |
| return "warn"; | |
| } | |
| function difficultyTone(difficulty) { | |
| const value = String(difficulty || "").toLowerCase(); | |
| if (value === "easy") { | |
| return "easy"; | |
| } | |
| if (value === "hard") { | |
| return "hard"; | |
| } | |
| return "medium"; | |
| } | |
| function urColor(ur) { | |
| if (typeof ur !== "number") { | |
| return "#d6dbe1"; | |
| } | |
| if (ur < 0.6) { | |
| return "#74c77b"; | |
| } | |
| if (ur < 1.0) { | |
| return "#f2c462"; | |
| } | |
| return "#ef6b5b"; | |
| } | |
| function urToneClass(ur) { | |
| if (typeof ur !== "number") { | |
| return ""; | |
| } | |
| if (ur < 0.6) { | |
| return "ur-good"; | |
| } | |
| if (ur < 1.0) { | |
| return "ur-warn"; | |
| } | |
| return "ur-danger"; | |
| } | |
| function summaryCopy(observation) { | |
| if (!observation) { | |
| return "Episode-level feedback will appear here after the first action."; | |
| } | |
| if (observation.last_action_result === "INVALID" && observation.last_action_error) { | |
| return "Invalid action: " + observation.last_action_error; | |
| } | |
| if (state.episodeDone && state.lastInfo && typeof state.lastInfo.graded_score === "number") { | |
| return "Episode complete. Final score " + formatNumber(state.lastInfo.graded_score, 4) + | |
| ". Structural validity: " + (observation.is_structurally_valid ? "pass" : "fail") + "."; | |
| } | |
| if (!observation.n_elements_placed) { | |
| return "No members placed yet. Use Place Column mode to seed the frame."; | |
| } | |
| if (!observation.critical_members.length) { | |
| return "The frame has members, but the solver has not produced full member checks yet. Add connectivity and supports."; | |
| } | |
| return ( | |
| (observation.is_structurally_valid ? "Current frame passes the available checks." : "Current frame has " + observation.n_code_violations + " code violation(s).") + | |
| " Max UR " + formatNumber(observation.critical_members[0].max_UR, 3) + | |
| ", drift " + formatNumber(observation.max_lateral_drift_ratio, 3) + | |
| ", mass " + Math.round(observation.total_steel_mass_kg).toLocaleString() + " kg." | |
| ); | |
| } | |
| function forceSummary() { | |
| const element = getSelectedElement(); | |
| if (!element) { | |
| return "No member selected."; | |
| } | |
| if (!state.selectedElementForces) { | |
| return "Loading member forces..."; | |
| } | |
| if (state.selectedElementForces.error) { | |
| return "Member forces unavailable: " + state.selectedElementForces.error; | |
| } | |
| const forces = state.selectedElementForces.forces || {}; | |
| return [ | |
| "Element: " + element.id, | |
| "Section: " + (state.selectedElementForces.section || element.section || "wall"), | |
| "Length: " + formatNumber(state.selectedElementForces.length_m, 2) + " m", | |
| "N: " + formatNumber(forces.N_kN, 3) + " kN", | |
| "V: " + formatNumber(forces.V_kN, 3) + " kN", | |
| "Mmax: " + formatNumber(forces.M_max_kNm, 3) + " kNm", | |
| "Delta: " + formatNumber(forces.delta_max_mm, 3) + " mm" | |
| ].join("\n"); | |
| } | |
| function impactSummary() { | |
| const impact = state.selectedElementImpact; | |
| if (!impact) { | |
| return "Counterfactual impact will appear here."; | |
| } | |
| if (impact.error) { | |
| return "What-if failed: " + impact.error; | |
| } | |
| return [ | |
| "Verdict: " + impact.verdict, | |
| "Current max UR: " + safeFormat(impact.current_max_UR, 4), | |
| "Without member: " + safeFormat(impact.counterfactual_max_UR, 4), | |
| "Delta UR: " + safeFormat(impact.delta_UR, 4) | |
| ].join(" | "); | |
| } | |
| function actionSummary(action) { | |
| if (!action || !action.action_type) { | |
| return modeHint(); | |
| } | |
| if (action.action_type === "place_column") { | |
| return "Column placed at (" + action.grid_x + ", " + action.grid_y + ") on floor " + action.floor + "."; | |
| } | |
| if (action.action_type === "place_beam") { | |
| return "Beam placed between (" + action.from_node_x + ", " + action.from_node_y + ") and (" + action.to_node_x + ", " + action.to_node_y + ")."; | |
| } | |
| if (action.action_type === "add_wall") { | |
| return "Wall placed between (" + action.from_node_x + ", " + action.from_node_y + ") and (" + action.to_node_x + ", " + action.to_node_y + ")."; | |
| } | |
| if (action.action_type === "remove_element") { | |
| return "Removed " + action.element_id + "."; | |
| } | |
| if (action.action_type === "upgrade_section") { | |
| return "Upgraded " + action.element_id + "."; | |
| } | |
| if (action.action_type === "downgrade_section") { | |
| return "Downgraded " + action.element_id + "."; | |
| } | |
| if (action.action_type === "done") { | |
| return "Final grading requested."; | |
| } | |
| return modeHint(); | |
| } | |
| function modeHint() { | |
| if (state.mode === "column") { | |
| return "Place Column mode: click any valid grid point on the current floor."; | |
| } | |
| if (state.mode === "beam") { | |
| return state.pendingPoint | |
| ? "Beam mode: choose the second endpoint on the same x or y line." | |
| : "Beam mode: click two existing columns on the current floor."; | |
| } | |
| if (state.mode === "wall") { | |
| return state.pendingPoint | |
| ? "Wall mode: choose the second endpoint on the same x or y line." | |
| : "Wall mode: click two existing columns to add a shear wall."; | |
| } | |
| return "Inspect mode: click rendered members to view forces, upgrade sections, remove them, or run what-if analysis."; | |
| } | |
| function selectedField(label, value) { | |
| return ( | |
| "<div class=\"selected-field\">" + | |
| "<span>" + escapeHtml(label) + "</span>" + | |
| "<strong>" + escapeHtml(value) + "</strong>" + | |
| "</div>" | |
| ); | |
| } | |
| async function api(url, options) { | |
| const request = Object.assign( | |
| { | |
| headers: { "Content-Type": "application/json" } | |
| }, | |
| options || {} | |
| ); | |
| if (!request.body) { | |
| delete request.headers["Content-Type"]; | |
| } | |
| const response = await fetch(url, request); | |
| const text = await response.text(); | |
| let payload = null; | |
| if (text) { | |
| try { | |
| payload = JSON.parse(text); | |
| } catch (error) { | |
| payload = text; | |
| } | |
| } | |
| if (!response.ok) { | |
| if (payload && typeof payload === "object" && payload.detail) { | |
| throw new Error(typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail)); | |
| } | |
| throw new Error(typeof payload === "string" ? payload : ("Request failed with status " + response.status)); | |
| } | |
| return payload; | |
| } | |
| function formatNumber(value, digits) { | |
| return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "-"; | |
| } | |
| function safeFormat(value, digits) { | |
| return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "n/a"; | |
| } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| </script> | |
| </body> | |
| </html> | |