Ayush-Singh commited on
Commit
e60ffc0
·
1 Parent(s): a0552d4

minior fixes

Browse files
Files changed (3) hide show
  1. demo.html +33 -749
  2. server/interactive_demo.html +2154 -687
  3. tests/test_server_routes.py +33 -0
demo.html CHANGED
@@ -1,756 +1,40 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Structural Design Env - Interactive Demo</title>
7
- <meta name="description" content="Interactive demo for StructuralDesignEnv">
8
- <style>
9
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
10
-
11
- :root {
12
- --bg-color: #0f1115;
13
- --panel-bg: rgba(25, 28, 36, 0.7);
14
- --border-color: rgba(255, 255, 255, 0.1);
15
- --text-main: #f3f4f6;
16
- --text-dim: #9ca3af;
17
- --accent: #3b82f6;
18
- --accent-hover: #2563eb;
19
- --success: #10b981;
20
- --error: #ef4444;
21
- --warning: #f59e0b;
22
- }
23
-
24
- * {
25
- box-sizing: border-box;
26
- margin: 0;
27
- padding: 0;
28
- }
29
-
30
- body {
31
- font-family: 'Inter', sans-serif;
32
- background-color: var(--bg-color);
33
- background-image:
34
- radial-gradient(at 0% 0%, hsla(253,16%,7%,1) 0, transparent 50%),
35
- radial-gradient(at 100% 0%, hsla(225,39%,15%,1) 0, transparent 50%),
36
- radial-gradient(at 100% 100%, hsla(339,20%,12%,1) 0, transparent 50%);
37
- background-attachment: fixed;
38
- color: var(--text-main);
39
- height: 100vh;
40
- display: flex;
41
- flex-direction: column;
42
- overflow: hidden;
43
- }
44
-
45
- header {
46
- padding: 1.5rem 2rem;
47
- display: flex;
48
- justify-content: space-between;
49
- align-items: center;
50
- border-bottom: 1px solid var(--border-color);
51
- background: rgba(15, 17, 21, 0.6);
52
- backdrop-filter: blur(12px);
53
- z-index: 10;
54
- }
55
-
56
- h1 {
57
- font-size: 1.5rem;
58
- font-weight: 600;
59
- background: linear-gradient(to right, #60a5fa, #a78bfa);
60
- -webkit-background-clip: text;
61
- -webkit-text-fill-color: transparent;
62
- margin: 0;
63
- }
64
-
65
- .header-controls {
66
- display: flex;
67
- gap: 1rem;
68
- align-items: center;
69
- }
70
-
71
- select, button {
72
- font-family: 'Inter', sans-serif;
73
- background: rgba(255, 255, 255, 0.05);
74
- border: 1px solid var(--border-color);
75
- color: white;
76
- padding: 0.5rem 1rem;
77
- border-radius: 0.5rem;
78
- font-size: 0.9rem;
79
- outline: none;
80
- cursor: pointer;
81
- transition: all 0.2s;
82
- }
83
-
84
- select:focus, button:focus {
85
- border-color: var(--accent);
86
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
87
- }
88
-
89
- select option {
90
- background: var(--bg-color);
91
- }
92
-
93
- button {
94
- background: var(--accent);
95
- border: none;
96
- font-weight: 500;
97
- }
98
-
99
- button:hover {
100
- background: var(--accent-hover);
101
- }
102
-
103
- button.secondary {
104
- background: rgba(255, 255, 255, 0.1);
105
- }
106
-
107
- button.secondary:hover {
108
- background: rgba(255, 255, 255, 0.2);
109
- }
110
-
111
- .main-container {
112
- display: flex;
113
- flex: 1;
114
- overflow: hidden;
115
- }
116
-
117
- .left-panel {
118
- flex: 2;
119
- padding: 2rem;
120
- display: flex;
121
- flex-direction: column;
122
- border-right: 1px solid var(--border-color);
123
- position: relative;
124
- }
125
-
126
- .right-panel {
127
- flex: 1;
128
- padding: 2rem;
129
- background: var(--panel-bg);
130
- backdrop-filter: blur(10px);
131
- overflow-y: auto;
132
- border-left: 1px solid rgba(255,255,255,0.05);
133
- }
134
-
135
- .toolbar {
136
- display: flex;
137
- gap: 0.5rem;
138
- margin-bottom: 1.5rem;
139
- background: rgba(0, 0, 0, 0.3);
140
- padding: 0.5rem;
141
- border-radius: 0.75rem;
142
- border: 1px solid var(--border-color);
143
- }
144
-
145
- .tool-btn {
146
- background: transparent;
147
- color: var(--text-dim);
148
- flex: 1;
149
- }
150
-
151
- .tool-btn.active {
152
- background: rgba(255, 255, 255, 0.1);
153
- color: var(--text-main);
154
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
155
- }
156
-
157
- .svg-container {
158
- flex: 1;
159
- background: rgba(0, 0, 0, 0.2);
160
- border-radius: 1rem;
161
- border: 1px solid var(--border-color);
162
- position: relative;
163
- overflow: auto;
164
- display: flex;
165
- align-items: center;
166
- justify-content: center;
167
- }
168
-
169
- #grid-svg {
170
- cursor: crosshair;
171
- }
172
-
173
- /* Tooltip style */
174
- .tooltip {
175
- position: absolute;
176
- background: rgba(0,0,0,0.8);
177
- border: 1px solid rgba(255,255,255,0.1);
178
- padding: 8px 12px;
179
- border-radius: 6px;
180
- pointer-events: none;
181
- font-size: 13px;
182
- z-index: 100;
183
- display: none;
184
- color: #fff;
185
- backdrop-filter: blur(4px);
186
- }
187
-
188
- .panel-section {
189
- margin-bottom: 2rem;
190
- }
191
-
192
- .panel-section h3 {
193
- font-size: 0.85rem;
194
- text-transform: uppercase;
195
- letter-spacing: 0.05em;
196
- color: var(--text-dim);
197
- margin-bottom: 1rem;
198
- border-bottom: 1px solid var(--border-color);
199
- padding-bottom: 0.5rem;
200
- }
201
-
202
- .stat-grid {
203
- display: grid;
204
- grid-template-columns: 1fr 1fr;
205
- gap: 1rem;
206
- }
207
-
208
- .stat-card {
209
- background: rgba(255, 255, 255, 0.03);
210
- border: 1px solid var(--border-color);
211
- border-radius: 0.75rem;
212
- padding: 1rem;
213
- transition: transform 0.2s;
214
- }
215
-
216
- .stat-card:hover {
217
- transform: translateY(-2px);
218
- background: rgba(255, 255, 255, 0.05);
219
- }
220
-
221
- .stat-value {
222
- font-size: 1.5rem;
223
- font-weight: 600;
224
- margin-bottom: 0.25rem;
225
- }
226
-
227
- .stat-label {
228
- font-size: 0.8rem;
229
- color: var(--text-dim);
230
- }
231
-
232
- .status-badge {
233
- display: inline-block;
234
- padding: 0.25rem 0.75rem;
235
- border-radius: 9999px;
236
- font-size: 0.8rem;
237
- font-weight: 500;
238
- margin-bottom: 1rem;
239
- }
240
-
241
- .status-valid {
242
- background: rgba(16, 185, 129, 0.2);
243
- color: #34d399;
244
- border: 1px solid rgba(16, 185, 129, 0.3);
245
- }
246
-
247
- .status-invalid {
248
- background: rgba(239, 68, 68, 0.2);
249
- color: #f87171;
250
- border: 1px solid rgba(239, 68, 68, 0.3);
251
- }
252
-
253
- .status-unconverged {
254
- background: rgba(245, 158, 11, 0.2);
255
- color: #fbbf24;
256
- border: 1px solid rgba(245, 158, 11, 0.3);
257
- }
258
-
259
- .message-box {
260
- background: rgba(0, 0, 0, 0.3);
261
- border-radius: 0.5rem;
262
- padding: 1rem;
263
- font-family: monospace;
264
- font-size: 0.85rem;
265
- color: var(--text-dim);
266
- white-space: pre-wrap;
267
- max-height: 200px;
268
- overflow-y: auto;
269
- }
270
-
271
- .loading-overlay {
272
- position: absolute;
273
- top: 0; left: 0; right: 0; bottom: 0;
274
- background: rgba(15, 17, 21, 0.8);
275
- display: flex;
276
- justify-content: center;
277
- align-items: center;
278
- z-index: 50;
279
- color: white;
280
- font-size: 1.2rem;
281
- backdrop-filter: blur(4px);
282
- display: none;
283
- }
284
-
285
- .spinner {
286
- width: 40px;
287
- height: 40px;
288
- border: 4px solid rgba(255,255,255,0.1);
289
- border-left-color: var(--accent);
290
- border-radius: 50%;
291
- animation: spin 1s linear infinite;
292
- margin-right: 15px;
293
- }
294
-
295
- @keyframes spin {
296
- to { transform: rotate(360deg); }
297
- }
298
-
299
- table {
300
- width: 100%;
301
- border-collapse: collapse;
302
- font-size: 0.85rem;
303
- }
304
- th, td {
305
- text-align: left;
306
- padding: 0.5rem;
307
- border-bottom: 1px solid rgba(255,255,255,0.1);
308
- }
309
- th {
310
- color: var(--text-dim);
311
- font-weight: 500;
312
- }
313
- </style>
314
  </head>
315
  <body>
316
-
317
- <header>
318
- <h1>Structural Design Env</h1>
319
- <div class="header-controls">
320
- <select id="task-select">
321
- <option value="task1_warehouse">Task 1: Warehouse</option>
322
- <option value="task2_office">Task 2: Office</option>
323
- <option value="task3_hospital">Task 3: Hospital</option>
324
- </select>
325
- <button id="btn-reset" class="secondary">New Episode</button>
326
- <button id="btn-done">Done (Evaluate)</button>
327
- </div>
328
- </header>
329
-
330
- <div class="main-container">
331
- <div class="left-panel">
332
- <div id="loading" class="loading-overlay">
333
- <div class="spinner"></div> Loading...
334
- </div>
335
-
336
- <div class="toolbar">
337
- <button class="tool-btn active" data-mode="place_column">Place Column</button>
338
- <button class="tool-btn" data-mode="place_beam_x">Place Beam X (Horiz)</button>
339
- <button class="tool-btn" data-mode="place_beam_y">Place Beam Y (Vert)</button>
340
- <button class="tool-btn" data-mode="remove">Remove Element</button>
341
- </div>
342
-
343
- <div class="toolbar" style="margin-top: -1rem; margin-bottom: 1.5rem;">
344
- <label style="color: var(--text-dim); font-size: 0.9rem; align-self: center; margin-left: 1rem;">Section:</label>
345
- <select id="col-section-select" style="margin-left: 0.5rem;">
346
- <option value="HEB140">HEB140</option>
347
- <option value="HEB160">HEB160</option>
348
- <option value="HEB200" selected>HEB200</option>
349
- <option value="HEB240">HEB240</option>
350
- <option value="HEB300">HEB300</option>
351
- <option value="HEB360">HEB360</option>
352
- <option value="HEB400">HEB400</option>
353
- </select>
354
- <select id="beam-section-select" style="margin-left: 0.5rem; display: none;">
355
- <option value="IPE200">IPE200</option>
356
- <option value="IPE240">IPE240</option>
357
- <option value="IPE300" selected>IPE300</option>
358
- <option value="IPE360">IPE360</option>
359
- <option value="IPE400">IPE400</option>
360
- <option value="IPE450">IPE450</option>
361
- <option value="IPE500">IPE500</option>
362
- </select>
363
- </div>
364
-
365
- <div class="svg-container" id="svg-wrap">
366
- <svg id="grid-svg" width="100%" height="100%">
367
- <!-- Generated via JS -->
368
- </svg>
369
- </div>
370
- </div>
371
-
372
- <div class="right-panel">
373
- <div class="panel-section">
374
- <h3>Design Status</h3>
375
- <div id="status-badge" class="status-badge status-unconverged">Waiting for layout...</div>
376
-
377
- <div class="stat-grid">
378
- <div class="stat-card">
379
- <div class="stat-value" id="stat-mass">0 kg</div>
380
- <div class="stat-label">Total Steel Mass</div>
381
- </div>
382
- <div class="stat-card">
383
- <div class="stat-value" id="stat-carbon">0 kg</div>
384
- <div class="stat-label">Embodied Carbon</div>
385
- </div>
386
- <div class="stat-card">
387
- <div class="stat-value" id="stat-drift">0 %</div>
388
- <div class="stat-label">Max Drift Ratio</div>
389
- </div>
390
- <div class="stat-card">
391
- <div class="stat-value" id="stat-deflection">0 mm</div>
392
- <div class="stat-label">Max Deflection</div>
393
- </div>
394
- </div>
395
- </div>
396
-
397
- <div class="panel-section">
398
- <h3>Utilization Hotspots</h3>
399
- <table id="hotspot-table">
400
- <thead>
401
- <tr>
402
- <th>Element</th>
403
- <th>UR Bending</th>
404
- <th>UR Shear</th>
405
- <th>UR Axial</th>
406
- </tr>
407
- </thead>
408
- <tbody id="hotspot-tbody">
409
- <tr><td colspan="4" style="text-align:center; color: var(--text-dim)">No data</td></tr>
410
- </tbody>
411
- </table>
412
- </div>
413
-
414
- <div class="panel-section">
415
- <h3>Environment Message</h3>
416
- <div class="message-box" id="env-message">Initialize an episode to start.</div>
417
- </div>
418
- </div>
419
- </div>
420
-
421
- <div id="tooltip" class="tooltip"></div>
422
-
423
- <script>
424
- let sessionId = null;
425
- let currentMode = 'place_column';
426
- let currentState = null;
427
- let cellSpacing = 40; // Pixels per grid cell
428
-
429
- const svg = document.getElementById('grid-svg');
430
- const tooltip = document.getElementById('tooltip');
431
- const loading = document.getElementById('loading');
432
-
433
- // Setup toolbar interactions
434
- document.querySelectorAll('.tool-btn').forEach(btn => {
435
- btn.addEventListener('click', (e) => {
436
- document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
437
- e.target.classList.add('active');
438
- currentMode = e.target.dataset.mode;
439
-
440
- if(currentMode === 'place_column') {
441
- document.getElementById('col-section-select').style.display = 'block';
442
- document.getElementById('beam-section-select').style.display = 'none';
443
- } else if(currentMode.includes('beam')) {
444
- document.getElementById('col-section-select').style.display = 'none';
445
- document.getElementById('beam-section-select').style.display = 'block';
446
- } else {
447
- document.getElementById('col-section-select').style.display = 'none';
448
- document.getElementById('beam-section-select').style.display = 'none';
449
- }
450
- });
451
- });
452
-
453
- document.getElementById('btn-reset').addEventListener('click', resetEnvironment);
454
- document.getElementById('btn-done').addEventListener('click', finishEpisode);
455
-
456
- async function setLoading(isLoading) {
457
- loading.style.display = isLoading ? 'flex' : 'none';
458
- }
459
-
460
- async function resetEnvironment() {
461
- setLoading(true);
462
- const taskId = document.getElementById('task-select').value;
463
- try {
464
- const res = await fetch('/reset', {
465
- method: 'POST',
466
- headers: {'Content-Type': 'application/json'},
467
- body: JSON.stringify({ task_id: taskId })
468
- });
469
- const data = await res.json();
470
- sessionId = data.session_id;
471
- currentState = data.observation;
472
- renderUI();
473
- } catch (err) {
474
- console.error(err);
475
- alert('Error resetting environment.');
476
- }
477
- setLoading(false);
478
- }
479
-
480
- async function sendAction(actionData) {
481
- if (!sessionId) return;
482
- setLoading(true);
483
- try {
484
- const res = await fetch('/step', {
485
- method: 'POST',
486
- headers: {'Content-Type': 'application/json'},
487
- body: JSON.stringify({
488
- session_id: sessionId,
489
- message: JSON.stringify(actionData)
490
- })
491
- });
492
- const data = await res.json();
493
- currentState = data.observation;
494
-
495
- if(data.done) {
496
- alert(`Episode complete! Reward: ${data.reward}`);
497
- }
498
-
499
- renderUI();
500
- } catch (err) {
501
- console.error(err);
502
- }
503
- setLoading(false);
504
- }
505
-
506
- async function finishEpisode() {
507
- if (!sessionId) return;
508
- await sendAction({action_type: 'done'});
509
- }
510
-
511
- function getColorForUR(ur) {
512
- if (ur === 0 || !ur) return '#6b7280'; // gray (unassembled/no load)
513
- if (ur > 1.0) return '#ef4444'; // red (fail)
514
- if (ur > 0.8) return '#f59e0b'; // orange (high)
515
- return '#34d399'; // green (safe)
516
- }
517
-
518
- function renderUI() {
519
- if (!currentState) return;
520
-
521
- // 1. Update Physics Stats
522
- const isUnconverged = currentState.message && currentState.message.includes("Not converged");
523
- const isValid = currentState.is_structurally_valid;
524
-
525
- const badge = document.getElementById('status-badge');
526
- if (isUnconverged) {
527
- badge.className = 'status-badge status-unconverged';
528
- badge.innerText = 'Not converged (Structurally incomplete)';
529
- } else if (isValid) {
530
- badge.className = 'status-badge status-valid';
531
- badge.innerText = 'Safe & Valid';
532
- } else {
533
- badge.className = 'status-badge status-invalid';
534
- badge.innerText = 'Code Violations Detected';
535
- }
536
-
537
- document.getElementById('stat-mass').innerText = currentState.total_steel_mass_kg.toFixed(0) + ' kg';
538
- document.getElementById('stat-carbon').innerText = currentState.carbon_kg.toFixed(0) + ' kg';
539
- document.getElementById('stat-drift').innerText = (currentState.max_lateral_drift_ratio * 100).toFixed(3) + '%';
540
- document.getElementById('stat-deflection').innerText = currentState.max_deflection_mm.toFixed(1) + ' mm';
541
- document.getElementById('env-message').innerText = currentState.message || "Ready";
542
-
543
- // Render Hotspots Tab
544
- const tbody = document.getElementById('hotspot-tbody');
545
- tbody.innerHTML = '';
546
- // We'll fake critical elements if state.critical_members is not robustly structured,
547
- // or we'll filter top URs from placed_elements
548
- let elements = currentState.placed_elements || [];
549
- elements = elements.filter(e => e.utilization_ratio > 0)
550
- .sort((a,b) => b.utilization_ratio - a.utilization_ratio)
551
- .slice(0, 5);
552
-
553
- if (elements.length === 0) {
554
- tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; color: var(--text-dim)">No active load data</td></tr>';
555
- } else {
556
- elements.forEach(e => {
557
- const tr = document.createElement('tr');
558
- tr.innerHTML = `
559
- <td>${e.element_id}</td>
560
- <td><span style="color: ${getColorForUR(e.ur_bending)}">${e.ur_bending ? e.ur_bending.toFixed(2) : '-'}</span></td>
561
- <td><span style="color: ${getColorForUR(e.ur_shear)}">${e.ur_shear ? e.ur_shear.toFixed(2) : '-'}</span></td>
562
- <td><span style="color: ${getColorForUR(e.utilization_ratio)}">${e.utilization_ratio.toFixed(2)}</span></td>
563
- `;
564
- tbody.appendChild(tr);
565
- });
566
- }
567
-
568
- // 2. Render SVG Grid
569
- renderGrid();
570
- }
571
-
572
- function renderGrid() {
573
- svg.innerHTML = '';
574
- if (!currentState) return;
575
-
576
- const w = currentState.grid_width;
577
- const d = currentState.grid_depth;
578
-
579
- // Grid logic: x=column (0 to w), y=row (0 to d)
580
- // Flip Y so that y=0 is at the bottom visually
581
-
582
- const svgW = w * cellSpacing + 80;
583
- const svgH = d * cellSpacing + 80;
584
- // Add viewBox to make it responsive
585
- svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`);
586
-
587
- const offsetX = 40;
588
- const offsetY = 40;
589
-
590
- const getX = (gx) => offsetX + gx * cellSpacing;
591
- const getY = (gy) => offsetY + (d - gy) * cellSpacing;
592
-
593
- // Draw background grid
594
- for(let x=0; x<=w; x++) {
595
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
596
- line.setAttribute('x1', getX(x));
597
- line.setAttribute('y1', getY(0));
598
- line.setAttribute('x2', getX(x));
599
- line.setAttribute('y2', getY(d));
600
- line.setAttribute('stroke', 'rgba(255,255,255,0.05)');
601
- line.setAttribute('stroke-width', '1');
602
- svg.appendChild(line);
603
- }
604
- for(let y=0; y<=d; y++) {
605
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
606
- line.setAttribute('x1', getX(0));
607
- line.setAttribute('y1', getY(y));
608
- line.setAttribute('x2', getX(w));
609
- line.setAttribute('y2', getY(y));
610
- line.setAttribute('stroke', 'rgba(255,255,255,0.05)');
611
- line.setAttribute('stroke-width', '1');
612
- svg.appendChild(line);
613
- }
614
-
615
- // Clickable areas (invisible rects centered on nodes and spanning edges)
616
- for(let x=0; x<=w; x++) {
617
- for(let y=0; y<=d; y++) {
618
- const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
619
- rect.setAttribute('x', getX(x) - cellSpacing/2);
620
- rect.setAttribute('y', getY(y) - cellSpacing/2);
621
- rect.setAttribute('width', cellSpacing);
622
- rect.setAttribute('height', cellSpacing);
623
- rect.setAttribute('fill', 'transparent');
624
- rect.style.cursor = 'pointer';
625
-
626
- const handleHover = (e) => {
627
- tooltip.style.display = 'block';
628
- tooltip.style.left = e.pageX + 15 + 'px';
629
- tooltip.style.top = e.pageY + 15 + 'px';
630
- tooltip.innerHTML = `Grid(${x}, ${y})`;
631
- rect.setAttribute('fill', 'rgba(255,255,255,0.05)');
632
- };
633
- const handleOut = () => {
634
- tooltip.style.display = 'none';
635
- rect.setAttribute('fill', 'transparent');
636
- };
637
-
638
- rect.addEventListener('mousemove', handleHover);
639
- rect.addEventListener('mouseleave', handleOut);
640
- rect.addEventListener('click', () => handleGridClick(x, y));
641
-
642
- svg.appendChild(rect);
643
- }
644
- }
645
-
646
- // Draw placed structural elements
647
- const elements = currentState.placed_elements || [];
648
-
649
- // Draw Beams first so they are behind columns
650
- elements.filter(e => e.type === 'Beam').forEach(b => {
651
- const parts = b.element_id.split('_');
652
- // e.g. beam_0_0_5_0_0
653
- if(parts.length >= 6) {
654
- const x1 = parseInt(parts[1]);
655
- const y1 = parseInt(parts[2]);
656
- const x2 = parseInt(parts[3]);
657
- const y2 = parseInt(parts[4]);
658
- // Only draw ground floor beams for demo
659
- const floor = parseInt(parts[5]);
660
- if(floor !== 0) return;
661
-
662
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
663
- line.setAttribute('x1', getX(x1));
664
- line.setAttribute('y1', getY(y1));
665
- line.setAttribute('x2', getX(x2));
666
- line.setAttribute('y2', getY(y2));
667
- line.setAttribute('stroke', getColorForUR(b.utilization_ratio));
668
- line.setAttribute('stroke-width', '6');
669
- line.setAttribute('stroke-linecap', 'round');
670
-
671
- line.addEventListener('mousemove', (e) => {
672
- tooltip.style.display = 'block';
673
- tooltip.style.left = e.pageX + 15 + 'px';
674
- tooltip.style.top = e.pageY + 15 + 'px';
675
- tooltip.innerHTML = `${b.element_id}<br/>UR: ${b.utilization_ratio.toFixed(2)}`;
676
- });
677
- line.addEventListener('mouseleave', () => tooltip.style.display = 'none');
678
- line.addEventListener('click', (e) => {
679
- e.stopPropagation();
680
- if(currentMode === 'remove') {
681
- sendAction({action_type: 'remove_element', element_id: b.element_id});
682
- }
683
- });
684
-
685
- svg.appendChild(line);
686
- }
687
- });
688
-
689
- // Draw Columns
690
- elements.filter(e => e.type === 'Column').forEach(c => {
691
- const parts = c.element_id.split('_');
692
- // col_x_y_f
693
- if(parts.length >= 4) {
694
- const x = parseInt(parts[1]);
695
- const y = parseInt(parts[2]);
696
- const floor = parseInt(parts[3]);
697
- if(floor !== 0) return; // Only 2D ground view
698
-
699
- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
700
- circle.setAttribute('cx', getX(x));
701
- circle.setAttribute('cy', getY(y));
702
- circle.setAttribute('r', '8');
703
- circle.setAttribute('fill', getColorForUR(c.utilization_ratio));
704
- circle.setAttribute('stroke', 'rgba(255,255,255,0.8)');
705
- circle.setAttribute('stroke-width', '2');
706
-
707
- circle.addEventListener('mousemove', (e) => {
708
- tooltip.style.display = 'block';
709
- tooltip.style.left = e.pageX + 15 + 'px';
710
- tooltip.style.top = e.pageY + 15 + 'px';
711
- tooltip.innerHTML = `${c.element_id} [${c.section}]<br/>UR: ${c.utilization_ratio.toFixed(2)}`;
712
- });
713
- circle.addEventListener('mouseleave', () => tooltip.style.display = 'none');
714
- circle.addEventListener('click', (e) => {
715
- e.stopPropagation();
716
- if(currentMode === 'remove') {
717
- sendAction({action_type: 'remove_element', element_id: c.element_id});
718
- }
719
- });
720
-
721
- svg.appendChild(circle);
722
- }
723
- });
724
- }
725
-
726
- function handleGridClick(x, y) {
727
- if(currentMode === 'place_column') {
728
- const sec = document.getElementById('col-section-select').value;
729
- sendAction({
730
- action_type: 'place_column',
731
- grid_x: x, grid_y: y, floor: 0, section: sec
732
- });
733
- } else if(currentMode === 'place_beam_x') {
734
- const sec = document.getElementById('beam-section-select').value;
735
- sendAction({
736
- action_type: 'place_beam',
737
- from_node_x: x, from_node_y: y,
738
- to_node_x: x+1, to_node_y: y,
739
- floor: 0, section: sec, orientation: 'x'
740
- });
741
- } else if(currentMode === 'place_beam_y') {
742
- const sec = document.getElementById('beam-section-select').value;
743
- sendAction({
744
- action_type: 'place_beam',
745
- from_node_x: x, from_node_y: y,
746
- to_node_x: x, to_node_y: y+1,
747
- floor: 0, section: sec, orientation: 'y'
748
- });
749
- }
750
- }
751
-
752
- // Initial load
753
- resetEnvironment();
754
- </script>
755
  </body>
756
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>StructuralDesignEnv Demo</title>
7
+ <meta http-equiv="refresh" content="0; url=/demo" />
8
+ <style>
9
+ body {
10
+ margin: 0;
11
+ min-height: 100vh;
12
+ display: grid;
13
+ place-items: center;
14
+ padding: 24px;
15
+ font-family: "Avenir Next", "Trebuchet MS", sans-serif;
16
+ color: #f4efe6;
17
+ background: linear-gradient(180deg, #08131d 0%, #091925 100%);
18
+ }
19
+
20
+ main {
21
+ max-width: 560px;
22
+ padding: 28px;
23
+ border-radius: 20px;
24
+ border: 1px solid rgba(255, 255, 255, 0.12);
25
+ background: rgba(13, 29, 41, 0.92);
26
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
27
+ }
28
+
29
+ a {
30
+ color: #68c3b3;
31
+ }
32
+ </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  </head>
34
  <body>
35
+ <main>
36
+ <h1>StructuralDesignEnv demo moved to <a href="/demo">/demo</a>.</h1>
37
+ <p>The interactive experience is now served by the FastAPI app so it can call the live environment endpoints directly.</p>
38
+ </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  </body>
40
  </html>
server/interactive_demo.html CHANGED
@@ -1,756 +1,2223 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Structural Design Env - Interactive Demo</title>
7
- <meta name="description" content="Interactive demo for StructuralDesignEnv">
8
- <style>
9
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
10
-
11
- :root {
12
- --bg-color: #0f1115;
13
- --panel-bg: rgba(25, 28, 36, 0.7);
14
- --border-color: rgba(255, 255, 255, 0.1);
15
- --text-main: #f3f4f6;
16
- --text-dim: #9ca3af;
17
- --accent: #3b82f6;
18
- --accent-hover: #2563eb;
19
- --success: #10b981;
20
- --error: #ef4444;
21
- --warning: #f59e0b;
22
- }
 
 
 
 
 
 
 
 
 
23
 
24
- * {
25
- box-sizing: border-box;
26
- margin: 0;
27
- padding: 0;
28
- }
29
 
30
- body {
31
- font-family: 'Inter', sans-serif;
32
- background-color: var(--bg-color);
33
- background-image:
34
- radial-gradient(at 0% 0%, hsla(253,16%,7%,1) 0, transparent 50%),
35
- radial-gradient(at 100% 0%, hsla(225,39%,15%,1) 0, transparent 50%),
36
- radial-gradient(at 100% 100%, hsla(339,20%,12%,1) 0, transparent 50%);
37
- background-attachment: fixed;
38
- color: var(--text-main);
39
- height: 100vh;
40
- display: flex;
41
- flex-direction: column;
42
- overflow: hidden;
43
- }
44
 
45
- header {
46
- padding: 1.5rem 2rem;
47
- display: flex;
48
- justify-content: space-between;
49
- align-items: center;
50
- border-bottom: 1px solid var(--border-color);
51
- background: rgba(15, 17, 21, 0.6);
52
- backdrop-filter: blur(12px);
53
- z-index: 10;
54
- }
55
 
56
- h1 {
57
- font-size: 1.5rem;
58
- font-weight: 600;
59
- background: linear-gradient(to right, #60a5fa, #a78bfa);
60
- -webkit-background-clip: text;
61
- -webkit-text-fill-color: transparent;
62
- margin: 0;
63
- }
 
 
 
64
 
65
- .header-controls {
66
- display: flex;
67
- gap: 1rem;
68
- align-items: center;
69
- }
70
 
71
- select, button {
72
- font-family: 'Inter', sans-serif;
73
- background: rgba(255, 255, 255, 0.05);
74
- border: 1px solid var(--border-color);
75
- color: white;
76
- padding: 0.5rem 1rem;
77
- border-radius: 0.5rem;
78
- font-size: 0.9rem;
79
- outline: none;
80
- cursor: pointer;
81
- transition: all 0.2s;
82
- }
83
 
84
- select:focus, button:focus {
85
- border-color: var(--accent);
86
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
87
- }
 
88
 
89
- select option {
90
- background: var(--bg-color);
91
- }
 
 
 
 
92
 
93
- button {
94
- background: var(--accent);
95
- border: none;
96
- font-weight: 500;
97
- }
 
 
 
 
 
 
98
 
99
- button:hover {
100
- background: var(--accent-hover);
101
- }
102
-
103
- button.secondary {
104
- background: rgba(255, 255, 255, 0.1);
105
- }
106
-
107
- button.secondary:hover {
108
- background: rgba(255, 255, 255, 0.2);
109
- }
110
 
111
- .main-container {
112
- display: flex;
113
- flex: 1;
114
- overflow: hidden;
115
- }
116
 
117
- .left-panel {
118
- flex: 2;
119
- padding: 2rem;
120
- display: flex;
121
- flex-direction: column;
122
- border-right: 1px solid var(--border-color);
123
- position: relative;
124
- }
125
 
126
- .right-panel {
127
- flex: 1;
128
- padding: 2rem;
129
- background: var(--panel-bg);
130
- backdrop-filter: blur(10px);
131
- overflow-y: auto;
132
- border-left: 1px solid rgba(255,255,255,0.05);
133
- }
134
 
135
- .toolbar {
136
- display: flex;
137
- gap: 0.5rem;
138
- margin-bottom: 1.5rem;
139
- background: rgba(0, 0, 0, 0.3);
140
- padding: 0.5rem;
141
- border-radius: 0.75rem;
142
- border: 1px solid var(--border-color);
143
- }
144
 
145
- .tool-btn {
146
- background: transparent;
147
- color: var(--text-dim);
148
- flex: 1;
149
- }
150
 
151
- .tool-btn.active {
152
- background: rgba(255, 255, 255, 0.1);
153
- color: var(--text-main);
154
- box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
155
- }
156
 
157
- .svg-container {
158
- flex: 1;
159
- background: rgba(0, 0, 0, 0.2);
160
- border-radius: 1rem;
161
- border: 1px solid var(--border-color);
162
- position: relative;
163
- overflow: auto;
164
- display: flex;
165
- align-items: center;
166
- justify-content: center;
167
- }
168
-
169
- #grid-svg {
170
- cursor: crosshair;
171
- }
172
-
173
- /* Tooltip style */
174
- .tooltip {
175
- position: absolute;
176
- background: rgba(0,0,0,0.8);
177
- border: 1px solid rgba(255,255,255,0.1);
178
- padding: 8px 12px;
179
- border-radius: 6px;
180
- pointer-events: none;
181
- font-size: 13px;
182
- z-index: 100;
183
- display: none;
184
- color: #fff;
185
- backdrop-filter: blur(4px);
186
- }
187
 
188
- .panel-section {
189
- margin-bottom: 2rem;
190
- }
 
 
 
 
 
 
 
 
191
 
192
- .panel-section h3 {
193
- font-size: 0.85rem;
194
- text-transform: uppercase;
195
- letter-spacing: 0.05em;
196
- color: var(--text-dim);
197
- margin-bottom: 1rem;
198
- border-bottom: 1px solid var(--border-color);
199
- padding-bottom: 0.5rem;
200
- }
201
 
202
- .stat-grid {
203
- display: grid;
204
- grid-template-columns: 1fr 1fr;
205
- gap: 1rem;
206
- }
207
 
208
- .stat-card {
209
- background: rgba(255, 255, 255, 0.03);
210
- border: 1px solid var(--border-color);
211
- border-radius: 0.75rem;
212
- padding: 1rem;
213
- transition: transform 0.2s;
214
- }
215
 
216
- .stat-card:hover {
217
- transform: translateY(-2px);
218
- background: rgba(255, 255, 255, 0.05);
219
- }
 
 
 
 
220
 
221
- .stat-value {
222
- font-size: 1.5rem;
223
- font-weight: 600;
224
- margin-bottom: 0.25rem;
225
- }
226
 
227
- .stat-label {
228
- font-size: 0.8rem;
229
- color: var(--text-dim);
230
- }
 
 
 
 
 
 
 
231
 
232
- .status-badge {
233
- display: inline-block;
234
- padding: 0.25rem 0.75rem;
235
- border-radius: 9999px;
236
- font-size: 0.8rem;
237
- font-weight: 500;
238
- margin-bottom: 1rem;
239
- }
 
 
 
 
 
 
 
 
 
240
 
241
- .status-valid {
242
- background: rgba(16, 185, 129, 0.2);
243
- color: #34d399;
244
- border: 1px solid rgba(16, 185, 129, 0.3);
245
- }
 
 
 
 
 
246
 
247
- .status-invalid {
248
- background: rgba(239, 68, 68, 0.2);
249
- color: #f87171;
250
- border: 1px solid rgba(239, 68, 68, 0.3);
251
- }
 
 
252
 
253
- .status-unconverged {
254
- background: rgba(245, 158, 11, 0.2);
255
- color: #fbbf24;
256
- border: 1px solid rgba(245, 158, 11, 0.3);
257
- }
258
-
259
- .message-box {
260
- background: rgba(0, 0, 0, 0.3);
261
- border-radius: 0.5rem;
262
- padding: 1rem;
263
- font-family: monospace;
264
- font-size: 0.85rem;
265
- color: var(--text-dim);
266
- white-space: pre-wrap;
267
- max-height: 200px;
268
- overflow-y: auto;
269
- }
270
-
271
- .loading-overlay {
272
- position: absolute;
273
- top: 0; left: 0; right: 0; bottom: 0;
274
- background: rgba(15, 17, 21, 0.8);
275
- display: flex;
276
- justify-content: center;
277
- align-items: center;
278
- z-index: 50;
279
- color: white;
280
- font-size: 1.2rem;
281
- backdrop-filter: blur(4px);
282
- display: none;
283
- }
284
-
285
- .spinner {
286
- width: 40px;
287
- height: 40px;
288
- border: 4px solid rgba(255,255,255,0.1);
289
- border-left-color: var(--accent);
290
- border-radius: 50%;
291
- animation: spin 1s linear infinite;
292
- margin-right: 15px;
293
- }
294
-
295
- @keyframes spin {
296
- to { transform: rotate(360deg); }
297
- }
298
 
299
- table {
300
- width: 100%;
301
- border-collapse: collapse;
302
- font-size: 0.85rem;
303
- }
304
- th, td {
305
- text-align: left;
306
- padding: 0.5rem;
307
- border-bottom: 1px solid rgba(255,255,255,0.1);
308
- }
309
- th {
310
- color: var(--text-dim);
311
- font-weight: 500;
312
- }
313
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  </head>
315
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
- <header>
318
- <h1>Structural Design Env</h1>
319
- <div class="header-controls">
320
- <select id="task-select">
321
- <option value="task1_warehouse">Task 1: Warehouse</option>
322
- <option value="task2_office">Task 2: Office</option>
323
- <option value="task3_hospital">Task 3: Hospital</option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  </select>
325
- <button id="btn-reset" class="secondary">New Episode</button>
326
- <button id="btn-done">Done (Evaluate)</button>
 
 
 
 
 
 
327
  </div>
328
- </header>
329
 
330
- <div class="main-container">
331
- <div class="left-panel">
332
- <div id="loading" class="loading-overlay">
333
- <div class="spinner"></div> Loading...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  </div>
335
-
336
- <div class="toolbar">
337
- <button class="tool-btn active" data-mode="place_column">Place Column</button>
338
- <button class="tool-btn" data-mode="place_beam_x">Place Beam X (Horiz)</button>
339
- <button class="tool-btn" data-mode="place_beam_y">Place Beam Y (Vert)</button>
340
- <button class="tool-btn" data-mode="remove">Remove Element</button>
341
  </div>
342
-
343
- <div class="toolbar" style="margin-top: -1rem; margin-bottom: 1.5rem;">
344
- <label style="color: var(--text-dim); font-size: 0.9rem; align-self: center; margin-left: 1rem;">Section:</label>
345
- <select id="col-section-select" style="margin-left: 0.5rem;">
346
- <option value="HEB140">HEB140</option>
347
- <option value="HEB160">HEB160</option>
348
- <option value="HEB200" selected>HEB200</option>
349
- <option value="HEB240">HEB240</option>
350
- <option value="HEB300">HEB300</option>
351
- <option value="HEB360">HEB360</option>
352
- <option value="HEB400">HEB400</option>
353
- </select>
354
- <select id="beam-section-select" style="margin-left: 0.5rem; display: none;">
355
- <option value="IPE200">IPE200</option>
356
- <option value="IPE240">IPE240</option>
357
- <option value="IPE300" selected>IPE300</option>
358
- <option value="IPE360">IPE360</option>
359
- <option value="IPE400">IPE400</option>
360
- <option value="IPE450">IPE450</option>
361
- <option value="IPE500">IPE500</option>
362
- </select>
363
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
- <div class="svg-container" id="svg-wrap">
366
- <svg id="grid-svg" width="100%" height="100%">
367
- <!-- Generated via JS -->
368
- </svg>
 
 
 
 
 
 
369
  </div>
370
- </div>
371
 
372
- <div class="right-panel">
373
- <div class="panel-section">
374
- <h3>Design Status</h3>
375
- <div id="status-badge" class="status-badge status-unconverged">Waiting for layout...</div>
376
-
377
- <div class="stat-grid">
378
- <div class="stat-card">
379
- <div class="stat-value" id="stat-mass">0 kg</div>
380
- <div class="stat-label">Total Steel Mass</div>
381
- </div>
382
- <div class="stat-card">
383
- <div class="stat-value" id="stat-carbon">0 kg</div>
384
- <div class="stat-label">Embodied Carbon</div>
385
- </div>
386
- <div class="stat-card">
387
- <div class="stat-value" id="stat-drift">0 %</div>
388
- <div class="stat-label">Max Drift Ratio</div>
389
- </div>
390
- <div class="stat-card">
391
- <div class="stat-value" id="stat-deflection">0 mm</div>
392
- <div class="stat-label">Max Deflection</div>
393
- </div>
394
- </div>
395
  </div>
 
 
 
 
 
396
 
397
- <div class="panel-section">
398
- <h3>Utilization Hotspots</h3>
399
- <table id="hotspot-table">
400
- <thead>
401
- <tr>
402
- <th>Element</th>
403
- <th>UR Bending</th>
404
- <th>UR Shear</th>
405
- <th>UR Axial</th>
406
- </tr>
407
- </thead>
408
- <tbody id="hotspot-tbody">
409
- <tr><td colspan="4" style="text-align:center; color: var(--text-dim)">No data</td></tr>
410
- </tbody>
411
- </table>
 
 
412
  </div>
 
 
 
 
413
 
414
- <div class="panel-section">
415
- <h3>Environment Message</h3>
416
- <div class="message-box" id="env-message">Initialize an episode to start.</div>
 
 
417
  </div>
418
- </div>
419
- </div>
420
-
421
- <div id="tooltip" class="tooltip"></div>
422
-
423
- <script>
424
- let sessionId = null;
425
- let currentMode = 'place_column';
426
- let currentState = null;
427
- let cellSpacing = 40; // Pixels per grid cell
428
-
429
- const svg = document.getElementById('grid-svg');
430
- const tooltip = document.getElementById('tooltip');
431
- const loading = document.getElementById('loading');
432
-
433
- // Setup toolbar interactions
434
- document.querySelectorAll('.tool-btn').forEach(btn => {
435
- btn.addEventListener('click', (e) => {
436
- document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
437
- e.target.classList.add('active');
438
- currentMode = e.target.dataset.mode;
439
-
440
- if(currentMode === 'place_column') {
441
- document.getElementById('col-section-select').style.display = 'block';
442
- document.getElementById('beam-section-select').style.display = 'none';
443
- } else if(currentMode.includes('beam')) {
444
- document.getElementById('col-section-select').style.display = 'none';
445
- document.getElementById('beam-section-select').style.display = 'block';
446
- } else {
447
- document.getElementById('col-section-select').style.display = 'none';
448
- document.getElementById('beam-section-select').style.display = 'none';
449
- }
450
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  });
 
452
 
453
- document.getElementById('btn-reset').addEventListener('click', resetEnvironment);
454
- document.getElementById('btn-done').addEventListener('click', finishEpisode);
 
 
 
 
455
 
456
- async function setLoading(isLoading) {
457
- loading.style.display = isLoading ? 'flex' : 'none';
 
 
 
 
 
 
458
  }
 
459
 
460
- async function resetEnvironment() {
461
- setLoading(true);
462
- const taskId = document.getElementById('task-select').value;
463
- try {
464
- const res = await fetch('/reset', {
465
- method: 'POST',
466
- headers: {'Content-Type': 'application/json'},
467
- body: JSON.stringify({ task_id: taskId })
468
- });
469
- const data = await res.json();
470
- sessionId = data.session_id;
471
- currentState = data.observation;
472
- renderUI();
473
- } catch (err) {
474
- console.error(err);
475
- alert('Error resetting environment.');
476
- }
477
- setLoading(false);
478
  }
479
-
480
- async function sendAction(actionData) {
481
- if (!sessionId) return;
482
- setLoading(true);
483
- try {
484
- const res = await fetch('/step', {
485
- method: 'POST',
486
- headers: {'Content-Type': 'application/json'},
487
- body: JSON.stringify({
488
- session_id: sessionId,
489
- message: JSON.stringify(actionData)
490
- })
491
- });
492
- const data = await res.json();
493
- currentState = data.observation;
494
-
495
- if(data.done) {
496
- alert(`Episode complete! Reward: ${data.reward}`);
497
- }
498
-
499
- renderUI();
500
- } catch (err) {
501
- console.error(err);
502
- }
503
- setLoading(false);
504
  }
505
-
506
- async function finishEpisode() {
507
- if (!sessionId) return;
508
- await sendAction({action_type: 'done'});
 
509
  }
 
510
 
511
- function getColorForUR(ur) {
512
- if (ur === 0 || !ur) return '#6b7280'; // gray (unassembled/no load)
513
- if (ur > 1.0) return '#ef4444'; // red (fail)
514
- if (ur > 0.8) return '#f59e0b'; // orange (high)
515
- return '#34d399'; // green (safe)
516
  }
517
-
518
- function renderUI() {
519
- if (!currentState) return;
520
-
521
- // 1. Update Physics Stats
522
- const isUnconverged = currentState.message && currentState.message.includes("Not converged");
523
- const isValid = currentState.is_structurally_valid;
524
-
525
- const badge = document.getElementById('status-badge');
526
- if (isUnconverged) {
527
- badge.className = 'status-badge status-unconverged';
528
- badge.innerText = 'Not converged (Structurally incomplete)';
529
- } else if (isValid) {
530
- badge.className = 'status-badge status-valid';
531
- badge.innerText = 'Safe & Valid';
532
- } else {
533
- badge.className = 'status-badge status-invalid';
534
- badge.innerText = 'Code Violations Detected';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  }
536
-
537
- document.getElementById('stat-mass').innerText = currentState.total_steel_mass_kg.toFixed(0) + ' kg';
538
- document.getElementById('stat-carbon').innerText = currentState.carbon_kg.toFixed(0) + ' kg';
539
- document.getElementById('stat-drift').innerText = (currentState.max_lateral_drift_ratio * 100).toFixed(3) + '%';
540
- document.getElementById('stat-deflection').innerText = currentState.max_deflection_mm.toFixed(1) + ' mm';
541
- document.getElementById('env-message').innerText = currentState.message || "Ready";
542
-
543
- // Render Hotspots Tab
544
- const tbody = document.getElementById('hotspot-tbody');
545
- tbody.innerHTML = '';
546
- // We'll fake critical elements if state.critical_members is not robustly structured,
547
- // or we'll filter top URs from placed_elements
548
- let elements = currentState.placed_elements || [];
549
- elements = elements.filter(e => e.utilization_ratio > 0)
550
- .sort((a,b) => b.utilization_ratio - a.utilization_ratio)
551
- .slice(0, 5);
552
-
553
- if (elements.length === 0) {
554
- tbody.innerHTML = '<tr><td colspan="4" style="text-align:center; color: var(--text-dim)">No active load data</td></tr>';
 
 
 
 
 
 
 
 
 
555
  } else {
556
- elements.forEach(e => {
557
- const tr = document.createElement('tr');
558
- tr.innerHTML = `
559
- <td>${e.element_id}</td>
560
- <td><span style="color: ${getColorForUR(e.ur_bending)}">${e.ur_bending ? e.ur_bending.toFixed(2) : '-'}</span></td>
561
- <td><span style="color: ${getColorForUR(e.ur_shear)}">${e.ur_shear ? e.ur_shear.toFixed(2) : '-'}</span></td>
562
- <td><span style="color: ${getColorForUR(e.utilization_ratio)}">${e.utilization_ratio.toFixed(2)}</span></td>
563
- `;
564
- tbody.appendChild(tr);
565
- });
566
  }
567
-
568
- // 2. Render SVG Grid
569
- renderGrid();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  }
 
 
 
 
571
 
572
- function renderGrid() {
573
- svg.innerHTML = '';
574
- if (!currentState) return;
575
-
576
- const w = currentState.grid_width;
577
- const d = currentState.grid_depth;
578
-
579
- // Grid logic: x=column (0 to w), y=row (0 to d)
580
- // Flip Y so that y=0 is at the bottom visually
581
-
582
- const svgW = w * cellSpacing + 80;
583
- const svgH = d * cellSpacing + 80;
584
- // Add viewBox to make it responsive
585
- svg.setAttribute('viewBox', `0 0 ${svgW} ${svgH}`);
586
-
587
- const offsetX = 40;
588
- const offsetY = 40;
589
-
590
- const getX = (gx) => offsetX + gx * cellSpacing;
591
- const getY = (gy) => offsetY + (d - gy) * cellSpacing;
592
-
593
- // Draw background grid
594
- for(let x=0; x<=w; x++) {
595
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
596
- line.setAttribute('x1', getX(x));
597
- line.setAttribute('y1', getY(0));
598
- line.setAttribute('x2', getX(x));
599
- line.setAttribute('y2', getY(d));
600
- line.setAttribute('stroke', 'rgba(255,255,255,0.05)');
601
- line.setAttribute('stroke-width', '1');
602
- svg.appendChild(line);
603
- }
604
- for(let y=0; y<=d; y++) {
605
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
606
- line.setAttribute('x1', getX(0));
607
- line.setAttribute('y1', getY(y));
608
- line.setAttribute('x2', getX(w));
609
- line.setAttribute('y2', getY(y));
610
- line.setAttribute('stroke', 'rgba(255,255,255,0.05)');
611
- line.setAttribute('stroke-width', '1');
612
- svg.appendChild(line);
613
- }
614
-
615
- // Clickable areas (invisible rects centered on nodes and spanning edges)
616
- for(let x=0; x<=w; x++) {
617
- for(let y=0; y<=d; y++) {
618
- const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
619
- rect.setAttribute('x', getX(x) - cellSpacing/2);
620
- rect.setAttribute('y', getY(y) - cellSpacing/2);
621
- rect.setAttribute('width', cellSpacing);
622
- rect.setAttribute('height', cellSpacing);
623
- rect.setAttribute('fill', 'transparent');
624
- rect.style.cursor = 'pointer';
625
-
626
- const handleHover = (e) => {
627
- tooltip.style.display = 'block';
628
- tooltip.style.left = e.pageX + 15 + 'px';
629
- tooltip.style.top = e.pageY + 15 + 'px';
630
- tooltip.innerHTML = `Grid(${x}, ${y})`;
631
- rect.setAttribute('fill', 'rgba(255,255,255,0.05)');
632
- };
633
- const handleOut = () => {
634
- tooltip.style.display = 'none';
635
- rect.setAttribute('fill', 'transparent');
636
- };
637
-
638
- rect.addEventListener('mousemove', handleHover);
639
- rect.addEventListener('mouseleave', handleOut);
640
- rect.addEventListener('click', () => handleGridClick(x, y));
641
-
642
- svg.appendChild(rect);
643
- }
644
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
 
646
- // Draw placed structural elements
647
- const elements = currentState.placed_elements || [];
648
-
649
- // Draw Beams first so they are behind columns
650
- elements.filter(e => e.type === 'Beam').forEach(b => {
651
- const parts = b.element_id.split('_');
652
- // e.g. beam_0_0_5_0_0
653
- if(parts.length >= 6) {
654
- const x1 = parseInt(parts[1]);
655
- const y1 = parseInt(parts[2]);
656
- const x2 = parseInt(parts[3]);
657
- const y2 = parseInt(parts[4]);
658
- // Only draw ground floor beams for demo
659
- const floor = parseInt(parts[5]);
660
- if(floor !== 0) return;
661
-
662
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
663
- line.setAttribute('x1', getX(x1));
664
- line.setAttribute('y1', getY(y1));
665
- line.setAttribute('x2', getX(x2));
666
- line.setAttribute('y2', getY(y2));
667
- line.setAttribute('stroke', getColorForUR(b.utilization_ratio));
668
- line.setAttribute('stroke-width', '6');
669
- line.setAttribute('stroke-linecap', 'round');
670
-
671
- line.addEventListener('mousemove', (e) => {
672
- tooltip.style.display = 'block';
673
- tooltip.style.left = e.pageX + 15 + 'px';
674
- tooltip.style.top = e.pageY + 15 + 'px';
675
- tooltip.innerHTML = `${b.element_id}<br/>UR: ${b.utilization_ratio.toFixed(2)}`;
676
- });
677
- line.addEventListener('mouseleave', () => tooltip.style.display = 'none');
678
- line.addEventListener('click', (e) => {
679
- e.stopPropagation();
680
- if(currentMode === 'remove') {
681
- sendAction({action_type: 'remove_element', element_id: b.element_id});
682
- }
683
- });
684
-
685
- svg.appendChild(line);
686
- }
687
- });
688
-
689
- // Draw Columns
690
- elements.filter(e => e.type === 'Column').forEach(c => {
691
- const parts = c.element_id.split('_');
692
- // col_x_y_f
693
- if(parts.length >= 4) {
694
- const x = parseInt(parts[1]);
695
- const y = parseInt(parts[2]);
696
- const floor = parseInt(parts[3]);
697
- if(floor !== 0) return; // Only 2D ground view
698
-
699
- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
700
- circle.setAttribute('cx', getX(x));
701
- circle.setAttribute('cy', getY(y));
702
- circle.setAttribute('r', '8');
703
- circle.setAttribute('fill', getColorForUR(c.utilization_ratio));
704
- circle.setAttribute('stroke', 'rgba(255,255,255,0.8)');
705
- circle.setAttribute('stroke-width', '2');
706
-
707
- circle.addEventListener('mousemove', (e) => {
708
- tooltip.style.display = 'block';
709
- tooltip.style.left = e.pageX + 15 + 'px';
710
- tooltip.style.top = e.pageY + 15 + 'px';
711
- tooltip.innerHTML = `${c.element_id} [${c.section}]<br/>UR: ${c.utilization_ratio.toFixed(2)}`;
712
- });
713
- circle.addEventListener('mouseleave', () => tooltip.style.display = 'none');
714
- circle.addEventListener('click', (e) => {
715
- e.stopPropagation();
716
- if(currentMode === 'remove') {
717
- sendAction({action_type: 'remove_element', element_id: c.element_id});
718
- }
719
- });
720
-
721
- svg.appendChild(circle);
722
- }
723
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
  }
725
-
726
- function handleGridClick(x, y) {
727
- if(currentMode === 'place_column') {
728
- const sec = document.getElementById('col-section-select').value;
729
- sendAction({
730
- action_type: 'place_column',
731
- grid_x: x, grid_y: y, floor: 0, section: sec
732
- });
733
- } else if(currentMode === 'place_beam_x') {
734
- const sec = document.getElementById('beam-section-select').value;
735
- sendAction({
736
- action_type: 'place_beam',
737
- from_node_x: x, from_node_y: y,
738
- to_node_x: x+1, to_node_y: y,
739
- floor: 0, section: sec, orientation: 'x'
740
- });
741
- } else if(currentMode === 'place_beam_y') {
742
- const sec = document.getElementById('beam-section-select').value;
743
- sendAction({
744
- action_type: 'place_beam',
745
- from_node_x: x, from_node_y: y,
746
- to_node_x: x, to_node_y: y+1,
747
- floor: 0, section: sec, orientation: 'y'
748
- });
749
- }
750
  }
751
-
752
- // Initial load
753
- resetEnvironment();
754
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  </body>
756
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
  <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>StructuralDesignEnv Interactive Demo</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg: #07131d;
11
+ --bg-soft: #0d1d29;
12
+ --panel: rgba(13, 29, 41, 0.92);
13
+ --panel-strong: rgba(18, 38, 52, 0.96);
14
+ --line: rgba(255, 255, 255, 0.1);
15
+ --line-strong: rgba(255, 255, 255, 0.18);
16
+ --text: #f4efe6;
17
+ --muted: #98acb8;
18
+ --accent: #e9a24f;
19
+ --accent-soft: rgba(233, 162, 79, 0.16);
20
+ --secondary: #68c3b3;
21
+ --secondary-soft: rgba(104, 195, 179, 0.16);
22
+ --good: #74c77b;
23
+ --warn: #f2c462;
24
+ --danger: #ef6b5b;
25
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
26
+ --radius: 22px;
27
+ --radius-sm: 14px;
28
+ --heading-font: Georgia, "Times New Roman", serif;
29
+ --body-font: "Avenir Next", "Trebuchet MS", sans-serif;
30
+ --mono-font: "SFMono-Regular", "Consolas", monospace;
31
+ }
32
 
33
+ * {
34
+ box-sizing: border-box;
35
+ }
 
 
36
 
37
+ html {
38
+ scroll-behavior: smooth;
39
+ }
 
 
 
 
 
 
 
 
 
 
 
40
 
41
+ body {
42
+ margin: 0;
43
+ min-height: 100vh;
44
+ font-family: var(--body-font);
45
+ color: var(--text);
46
+ background:
47
+ radial-gradient(circle at top left, rgba(104, 195, 179, 0.16), transparent 28%),
48
+ radial-gradient(circle at top right, rgba(233, 162, 79, 0.18), transparent 24%),
49
+ linear-gradient(180deg, #08131d 0%, #091925 45%, #07131d 100%);
50
+ }
51
 
52
+ body::before {
53
+ content: "";
54
+ position: fixed;
55
+ inset: 0;
56
+ pointer-events: none;
57
+ background-image:
58
+ linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
59
+ linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
60
+ background-size: 28px 28px;
61
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.4), transparent 88%);
62
+ }
63
 
64
+ a {
65
+ color: inherit;
66
+ }
 
 
67
 
68
+ button,
69
+ select {
70
+ font: inherit;
71
+ }
 
 
 
 
 
 
 
 
72
 
73
+ .page-shell {
74
+ width: min(1500px, calc(100vw - 32px));
75
+ margin: 0 auto;
76
+ padding: 28px 0 36px;
77
+ }
78
 
79
+ .hero {
80
+ display: grid;
81
+ grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.9fr);
82
+ gap: 24px;
83
+ align-items: stretch;
84
+ margin-bottom: 24px;
85
+ }
86
 
87
+ .hero-copy,
88
+ .hero-card,
89
+ .panel {
90
+ position: relative;
91
+ overflow: hidden;
92
+ border: 1px solid var(--line);
93
+ border-radius: var(--radius);
94
+ background: var(--panel);
95
+ box-shadow: var(--shadow);
96
+ backdrop-filter: blur(18px);
97
+ }
98
 
99
+ .hero-copy {
100
+ padding: 32px;
101
+ background:
102
+ radial-gradient(circle at 0% 0%, rgba(233, 162, 79, 0.14), transparent 36%),
103
+ radial-gradient(circle at 100% 30%, rgba(104, 195, 179, 0.16), transparent 34%),
104
+ linear-gradient(180deg, rgba(13, 29, 41, 0.98), rgba(9, 21, 31, 0.94));
105
+ }
 
 
 
 
106
 
107
+ .hero-card {
108
+ padding: 26px;
109
+ background:
110
+ linear-gradient(180deg, rgba(14, 31, 44, 0.98), rgba(11, 24, 34, 0.95));
111
+ }
112
 
113
+ .eyebrow,
114
+ .section-label {
115
+ margin: 0 0 10px;
116
+ color: var(--secondary);
117
+ font-size: 0.77rem;
118
+ letter-spacing: 0.16em;
119
+ text-transform: uppercase;
120
+ }
121
 
122
+ h1,
123
+ h2,
124
+ h3 {
125
+ margin: 0;
126
+ font-family: var(--heading-font);
127
+ font-weight: 700;
128
+ letter-spacing: -0.02em;
129
+ }
130
 
131
+ h1 {
132
+ max-width: 12ch;
133
+ font-size: clamp(2.8rem, 5vw, 4.8rem);
134
+ line-height: 0.95;
135
+ }
 
 
 
 
136
 
137
+ h2 {
138
+ font-size: 1.65rem;
139
+ line-height: 1.05;
140
+ }
 
141
 
142
+ p {
143
+ margin: 0;
144
+ line-height: 1.55;
145
+ }
 
146
 
147
+ .lede {
148
+ max-width: 58ch;
149
+ margin-top: 18px;
150
+ font-size: 1.06rem;
151
+ color: var(--muted);
152
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ .hero-actions,
155
+ .top-meta,
156
+ .mode-strip,
157
+ .control-grid,
158
+ .selection-actions,
159
+ .legend,
160
+ .footer-links {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ gap: 12px;
164
+ }
165
 
166
+ .hero-actions {
167
+ margin-top: 24px;
168
+ }
 
 
 
 
 
 
169
 
170
+ .top-meta {
171
+ margin-top: 26px;
172
+ gap: 14px;
173
+ }
 
174
 
175
+ .hero-stat {
176
+ min-width: 140px;
177
+ padding: 14px 16px;
178
+ border: 1px solid var(--line);
179
+ border-radius: 16px;
180
+ background: rgba(255, 255, 255, 0.03);
181
+ }
182
 
183
+ .hero-stat-label {
184
+ display: block;
185
+ margin-bottom: 5px;
186
+ color: var(--muted);
187
+ font-size: 0.78rem;
188
+ text-transform: uppercase;
189
+ letter-spacing: 0.08em;
190
+ }
191
 
192
+ .hero-stat-value {
193
+ font-size: 1rem;
194
+ font-weight: 700;
195
+ }
 
196
 
197
+ .btn,
198
+ .mode-button,
199
+ .task-card,
200
+ .member-row {
201
+ transition:
202
+ transform 160ms ease,
203
+ border-color 160ms ease,
204
+ background 160ms ease,
205
+ color 160ms ease,
206
+ box-shadow 160ms ease;
207
+ }
208
 
209
+ .btn,
210
+ .mode-button,
211
+ .selection-actions button,
212
+ .footer-links a {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ gap: 8px;
217
+ min-height: 44px;
218
+ padding: 0 16px;
219
+ border: 1px solid var(--line);
220
+ border-radius: 999px;
221
+ color: var(--text);
222
+ text-decoration: none;
223
+ background: rgba(255, 255, 255, 0.03);
224
+ cursor: pointer;
225
+ }
226
 
227
+ .btn:hover,
228
+ .mode-button:hover,
229
+ .selection-actions button:hover,
230
+ .footer-links a:hover,
231
+ .task-card:hover,
232
+ .member-row:hover {
233
+ transform: translateY(-1px);
234
+ border-color: var(--line-strong);
235
+ background: rgba(255, 255, 255, 0.06);
236
+ }
237
 
238
+ .btn.primary,
239
+ .selection-actions button.primary {
240
+ background: linear-gradient(135deg, rgba(233, 162, 79, 0.96), rgba(239, 107, 91, 0.92));
241
+ color: #1a0f07;
242
+ border-color: transparent;
243
+ font-weight: 700;
244
+ }
245
 
246
+ .btn.secondary {
247
+ background: rgba(104, 195, 179, 0.1);
248
+ color: #d8fbf4;
249
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
+ .btn:disabled,
252
+ .mode-button:disabled,
253
+ .selection-actions button:disabled,
254
+ select:disabled {
255
+ opacity: 0.46;
256
+ cursor: not-allowed;
257
+ transform: none;
258
+ }
259
+
260
+ .hero-card p {
261
+ margin-top: 10px;
262
+ color: var(--muted);
263
+ }
264
+
265
+ .task-cards {
266
+ display: grid;
267
+ gap: 12px;
268
+ margin-top: 18px;
269
+ }
270
+
271
+ .task-card {
272
+ width: 100%;
273
+ padding: 16px;
274
+ border: 1px solid var(--line);
275
+ border-radius: 18px;
276
+ background: rgba(255, 255, 255, 0.03);
277
+ color: inherit;
278
+ text-align: left;
279
+ cursor: pointer;
280
+ }
281
+
282
+ .task-card.active {
283
+ border-color: rgba(233, 162, 79, 0.6);
284
+ background: var(--accent-soft);
285
+ box-shadow: inset 0 0 0 1px rgba(233, 162, 79, 0.18);
286
+ }
287
+
288
+ .task-card-head {
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: space-between;
292
+ gap: 10px;
293
+ margin-bottom: 8px;
294
+ }
295
+
296
+ .task-pill,
297
+ .status-pill,
298
+ .floor-button {
299
+ display: inline-flex;
300
+ align-items: center;
301
+ justify-content: center;
302
+ min-height: 34px;
303
+ padding: 0 12px;
304
+ border: 1px solid var(--line);
305
+ border-radius: 999px;
306
+ font-size: 0.78rem;
307
+ font-weight: 700;
308
+ letter-spacing: 0.04em;
309
+ text-transform: uppercase;
310
+ background: rgba(255, 255, 255, 0.03);
311
+ color: var(--muted);
312
+ }
313
+
314
+ .task-pill.easy,
315
+ .status-pill.good {
316
+ color: #daf5dd;
317
+ background: rgba(116, 199, 123, 0.16);
318
+ border-color: rgba(116, 199, 123, 0.28);
319
+ }
320
+
321
+ .task-pill.medium,
322
+ .status-pill.warn {
323
+ color: #fff3cc;
324
+ background: rgba(242, 196, 98, 0.16);
325
+ border-color: rgba(242, 196, 98, 0.28);
326
+ }
327
+
328
+ .task-pill.hard,
329
+ .status-pill.danger {
330
+ color: #ffd8d3;
331
+ background: rgba(239, 107, 91, 0.16);
332
+ border-color: rgba(239, 107, 91, 0.28);
333
+ }
334
+
335
+ .status-pill.idle {
336
+ color: var(--muted);
337
+ }
338
+
339
+ .task-card-meta {
340
+ margin-top: 10px;
341
+ font-size: 0.84rem;
342
+ color: var(--muted);
343
+ }
344
+
345
+ .workspace {
346
+ display: grid;
347
+ grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.88fr);
348
+ gap: 24px;
349
+ }
350
+
351
+ .panel {
352
+ padding: 24px;
353
+ background:
354
+ linear-gradient(180deg, rgba(13, 29, 41, 0.98), rgba(10, 22, 31, 0.94));
355
+ }
356
+
357
+ .panel + .panel {
358
+ margin-top: 18px;
359
+ }
360
+
361
+ .panel-head {
362
+ display: flex;
363
+ align-items: flex-start;
364
+ justify-content: space-between;
365
+ gap: 12px;
366
+ margin-bottom: 18px;
367
+ }
368
+
369
+ .session-pill {
370
+ display: inline-flex;
371
+ align-items: center;
372
+ gap: 8px;
373
+ padding: 10px 12px;
374
+ border-radius: 999px;
375
+ background: rgba(255, 255, 255, 0.04);
376
+ color: var(--muted);
377
+ font-size: 0.84rem;
378
+ max-width: 100%;
379
+ }
380
+
381
+ .session-pill code,
382
+ .meta-row code {
383
+ overflow-wrap: anywhere;
384
+ font-family: var(--mono-font);
385
+ color: #fbd8ae;
386
+ }
387
+
388
+ .toolbar {
389
+ display: grid;
390
+ grid-template-columns: repeat(2, minmax(0, 1fr));
391
+ gap: 14px;
392
+ margin-bottom: 14px;
393
+ }
394
+
395
+ label.control {
396
+ display: flex;
397
+ flex-direction: column;
398
+ gap: 8px;
399
+ color: var(--muted);
400
+ font-size: 0.88rem;
401
+ }
402
+
403
+ select {
404
+ width: 100%;
405
+ min-height: 46px;
406
+ padding: 0 14px;
407
+ border: 1px solid var(--line);
408
+ border-radius: 14px;
409
+ color: var(--text);
410
+ background: rgba(255, 255, 255, 0.04);
411
+ outline: none;
412
+ }
413
+
414
+ .mode-strip {
415
+ margin-bottom: 14px;
416
+ }
417
+
418
+ .mode-button.active,
419
+ .floor-button.active {
420
+ border-color: rgba(104, 195, 179, 0.52);
421
+ color: #dcfffa;
422
+ background: var(--secondary-soft);
423
+ box-shadow: inset 0 0 0 1px rgba(104, 195, 179, 0.16);
424
+ }
425
+
426
+ .control-grid {
427
+ display: grid;
428
+ grid-template-columns: repeat(4, minmax(0, 1fr));
429
+ gap: 14px;
430
+ margin-bottom: 14px;
431
+ }
432
+
433
+ .control-actions {
434
+ display: flex;
435
+ flex-wrap: wrap;
436
+ gap: 12px;
437
+ margin-bottom: 14px;
438
+ }
439
+
440
+ .notice {
441
+ margin-bottom: 16px;
442
+ padding: 12px 14px;
443
+ border-radius: 14px;
444
+ border: 1px solid var(--line);
445
+ font-size: 0.92rem;
446
+ color: var(--text);
447
+ background: rgba(255, 255, 255, 0.04);
448
+ }
449
+
450
+ .notice.info {
451
+ background: rgba(104, 195, 179, 0.1);
452
+ border-color: rgba(104, 195, 179, 0.28);
453
+ }
454
+
455
+ .notice.warn {
456
+ background: rgba(242, 196, 98, 0.12);
457
+ border-color: rgba(242, 196, 98, 0.28);
458
+ }
459
+
460
+ .notice.error {
461
+ background: rgba(239, 107, 91, 0.12);
462
+ border-color: rgba(239, 107, 91, 0.3);
463
+ }
464
+
465
+ .floor-strip {
466
+ display: flex;
467
+ flex-wrap: wrap;
468
+ gap: 10px;
469
+ }
470
+
471
+ .floor-button {
472
+ cursor: pointer;
473
+ }
474
+
475
+ .canvas-wrap {
476
+ position: relative;
477
+ min-height: 420px;
478
+ border-radius: 22px;
479
+ border: 1px solid var(--line);
480
+ background:
481
+ linear-gradient(180deg, rgba(14, 29, 40, 0.94), rgba(9, 20, 29, 0.98));
482
+ overflow: hidden;
483
+ }
484
+
485
+ #gridSvg {
486
+ display: block;
487
+ width: 100%;
488
+ height: auto;
489
+ min-height: 420px;
490
+ }
491
+
492
+ .grid-empty-state {
493
+ position: absolute;
494
+ inset: 0;
495
+ display: flex;
496
+ align-items: center;
497
+ justify-content: center;
498
+ padding: 32px;
499
+ text-align: center;
500
+ color: var(--muted);
501
+ font-size: 1rem;
502
+ background: linear-gradient(180deg, rgba(7, 19, 29, 0.24), rgba(7, 19, 29, 0.7));
503
+ }
504
+
505
+ .legend {
506
+ align-items: center;
507
+ gap: 10px 14px;
508
+ margin-top: 14px;
509
+ color: var(--muted);
510
+ font-size: 0.84rem;
511
+ }
512
+
513
+ .legend-chip {
514
+ display: inline-flex;
515
+ align-items: center;
516
+ gap: 8px;
517
+ }
518
+
519
+ .legend-swatch {
520
+ width: 12px;
521
+ height: 12px;
522
+ border-radius: 999px;
523
+ border: 1px solid rgba(255, 255, 255, 0.16);
524
+ }
525
+
526
+ .metric-grid {
527
+ display: grid;
528
+ grid-template-columns: repeat(3, minmax(0, 1fr));
529
+ gap: 12px;
530
+ margin-bottom: 16px;
531
+ }
532
+
533
+ .metric {
534
+ padding: 14px;
535
+ border-radius: 16px;
536
+ border: 1px solid var(--line);
537
+ background: rgba(255, 255, 255, 0.03);
538
+ }
539
+
540
+ .metric-label {
541
+ display: block;
542
+ color: var(--muted);
543
+ font-size: 0.78rem;
544
+ text-transform: uppercase;
545
+ letter-spacing: 0.08em;
546
+ margin-bottom: 6px;
547
+ }
548
+
549
+ .metric-value {
550
+ font-size: 1.12rem;
551
+ font-weight: 700;
552
+ }
553
+
554
+ .summary-copy {
555
+ padding: 14px 16px;
556
+ border-radius: 16px;
557
+ border: 1px solid var(--line);
558
+ background: rgba(255, 255, 255, 0.03);
559
+ color: var(--muted);
560
+ white-space: pre-wrap;
561
+ }
562
+
563
+ .meta-list {
564
+ display: grid;
565
+ gap: 10px;
566
+ }
567
+
568
+ .meta-row {
569
+ display: flex;
570
+ align-items: flex-start;
571
+ justify-content: space-between;
572
+ gap: 16px;
573
+ padding-bottom: 10px;
574
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
575
+ color: var(--muted);
576
+ font-size: 0.92rem;
577
+ }
578
+
579
+ .meta-row:last-child {
580
+ border-bottom: 0;
581
+ padding-bottom: 0;
582
+ }
583
+
584
+ .meta-row strong {
585
+ color: var(--text);
586
+ text-align: right;
587
+ max-width: 68%;
588
+ overflow-wrap: anywhere;
589
+ }
590
+
591
+ .selected-card {
592
+ padding: 16px;
593
+ border-radius: 18px;
594
+ border: 1px solid var(--line);
595
+ background: rgba(255, 255, 255, 0.03);
596
+ min-height: 158px;
597
+ }
598
+
599
+ .selected-grid {
600
+ display: grid;
601
+ grid-template-columns: repeat(2, minmax(0, 1fr));
602
+ gap: 10px;
603
+ margin-top: 14px;
604
+ }
605
+
606
+ .selected-field {
607
+ padding: 10px 12px;
608
+ border-radius: 14px;
609
+ background: rgba(255, 255, 255, 0.03);
610
+ border: 1px solid rgba(255, 255, 255, 0.05);
611
+ }
612
+
613
+ .selected-field span {
614
+ display: block;
615
+ margin-bottom: 4px;
616
+ color: var(--muted);
617
+ font-size: 0.76rem;
618
+ text-transform: uppercase;
619
+ letter-spacing: 0.08em;
620
+ }
621
+
622
+ .selected-field strong {
623
+ font-size: 0.96rem;
624
+ }
625
+
626
+ .selection-actions {
627
+ margin-top: 14px;
628
+ }
629
+
630
+ pre.code-box {
631
+ margin: 14px 0 0;
632
+ padding: 14px;
633
+ border-radius: 16px;
634
+ border: 1px solid var(--line);
635
+ background: rgba(4, 11, 17, 0.72);
636
+ color: #d8e9f0;
637
+ white-space: pre-wrap;
638
+ word-break: break-word;
639
+ font-family: var(--mono-font);
640
+ font-size: 0.86rem;
641
+ line-height: 1.55;
642
+ min-height: 78px;
643
+ }
644
+
645
+ .impact-box {
646
+ margin-top: 14px;
647
+ padding: 12px 14px;
648
+ border-radius: 14px;
649
+ border: 1px solid var(--line);
650
+ background: rgba(255, 255, 255, 0.03);
651
+ color: var(--muted);
652
+ font-size: 0.9rem;
653
+ min-height: 54px;
654
+ }
655
+
656
+ .member-list {
657
+ display: grid;
658
+ gap: 10px;
659
+ }
660
+
661
+ .member-row {
662
+ width: 100%;
663
+ display: flex;
664
+ align-items: center;
665
+ justify-content: space-between;
666
+ gap: 14px;
667
+ padding: 14px 16px;
668
+ border: 1px solid var(--line);
669
+ border-radius: 16px;
670
+ color: inherit;
671
+ background: rgba(255, 255, 255, 0.03);
672
+ text-align: left;
673
+ cursor: pointer;
674
+ }
675
+
676
+ .member-row.is-selected {
677
+ border-color: rgba(233, 162, 79, 0.54);
678
+ background: var(--accent-soft);
679
+ box-shadow: inset 0 0 0 1px rgba(233, 162, 79, 0.18);
680
+ }
681
+
682
+ .member-copy {
683
+ min-width: 0;
684
+ }
685
+
686
+ .member-title {
687
+ font-weight: 700;
688
+ overflow-wrap: anywhere;
689
+ }
690
+
691
+ .member-subtitle {
692
+ margin-top: 4px;
693
+ color: var(--muted);
694
+ font-size: 0.84rem;
695
+ }
696
+
697
+ .member-ur {
698
+ min-width: 78px;
699
+ text-align: right;
700
+ font-weight: 700;
701
+ }
702
+
703
+ .server-message {
704
+ min-height: 180px;
705
+ margin: 0;
706
+ }
707
+
708
+ .empty-copy {
709
+ color: var(--muted);
710
+ min-height: 72px;
711
+ display: flex;
712
+ align-items: center;
713
+ }
714
+
715
+ .ur-good {
716
+ color: var(--good);
717
+ }
718
+
719
+ .ur-warn {
720
+ color: var(--warn);
721
+ }
722
+
723
+ .ur-danger {
724
+ color: var(--danger);
725
+ }
726
+
727
+ footer.panel {
728
+ margin-top: 24px;
729
+ display: flex;
730
+ align-items: center;
731
+ justify-content: space-between;
732
+ gap: 16px;
733
+ color: var(--muted);
734
+ }
735
+
736
+ .footer-links a {
737
+ min-height: 40px;
738
+ border-radius: 12px;
739
+ }
740
+
741
+ @media (max-width: 1180px) {
742
+ .hero,
743
+ .workspace {
744
+ grid-template-columns: 1fr;
745
+ }
746
+ }
747
+
748
+ @media (max-width: 860px) {
749
+ .page-shell {
750
+ width: min(100vw - 20px, 100%);
751
+ padding-top: 18px;
752
+ }
753
+
754
+ .hero-copy,
755
+ .hero-card,
756
+ .panel {
757
+ padding: 18px;
758
+ }
759
+
760
+ .toolbar,
761
+ .control-grid,
762
+ .metric-grid,
763
+ .selected-grid {
764
+ grid-template-columns: 1fr;
765
+ }
766
+
767
+ .panel-head,
768
+ footer.panel,
769
+ .meta-row {
770
+ flex-direction: column;
771
+ align-items: flex-start;
772
+ }
773
+
774
+ .meta-row strong {
775
+ max-width: 100%;
776
+ text-align: left;
777
+ }
778
+ }
779
+ </style>
780
  </head>
781
  <body>
782
+ <div class="page-shell">
783
+ <header class="hero">
784
+ <section class="hero-copy">
785
+ <p class="eyebrow">OpenEnv / Hugging Face Docker Space</p>
786
+ <h1>Design a steel frame live in the browser.</h1>
787
+ <p class="lede">
788
+ This Space now includes a real interactive simulation. Start an episode, click the grid to place columns,
789
+ connect them with beams or shear walls, and watch the structural metrics update after every API-backed step.
790
+ </p>
791
+ <div class="hero-actions">
792
+ <button id="heroStartButton" class="btn primary" type="button" data-start-episode>New Episode</button>
793
+ <a class="btn secondary" href="/docs">API Docs</a>
794
+ <a class="btn secondary" href="/tasks">Tasks JSON</a>
795
+ <a class="btn secondary" href="/action_schema">Action Schema</a>
796
+ </div>
797
+ <div class="top-meta">
798
+ <div class="hero-stat">
799
+ <span class="hero-stat-label">API</span>
800
+ <span class="hero-stat-value" id="apiStatus">Loading</span>
801
+ </div>
802
+ <div class="hero-stat">
803
+ <span class="hero-stat-label">Episode</span>
804
+ <span class="hero-stat-value" id="episodeStatus">Not started</span>
805
+ </div>
806
+ <div class="hero-stat">
807
+ <span class="hero-stat-label">Solver</span>
808
+ <span class="hero-stat-value" id="solverStatus">Idle</span>
809
+ </div>
810
+ </div>
811
+ </section>
812
+
813
+ <aside class="hero-card">
814
+ <p class="section-label">Live Tasks</p>
815
+ <h2>Pick a scenario and build directly on the site grid.</h2>
816
+ <p>
817
+ The task list below is loaded from the API, so the demo stays aligned with the current environment config.
818
+ </p>
819
+ <div class="task-cards" id="taskCards"></div>
820
+ </aside>
821
+ </header>
822
+
823
+ <main class="workspace">
824
+ <section class="panel">
825
+ <div class="panel-head">
826
+ <div>
827
+ <p class="section-label">Interactive Simulation</p>
828
+ <h2>Site Grid</h2>
829
+ </div>
830
+ <div class="session-pill">Session <code id="sessionIdDisplay">not started</code></div>
831
+ </div>
832
 
833
+ <div class="toolbar">
834
+ <label class="control">
835
+ Task
836
+ <select id="taskSelect"></select>
837
+ </label>
838
+ <label class="control">
839
+ Floor
840
+ <div class="floor-strip" id="floorButtons"></div>
841
+ </label>
842
+ </div>
843
+
844
+ <div class="mode-strip">
845
+ <button id="modeInspect" class="mode-button" type="button">Inspect</button>
846
+ <button id="modeColumn" class="mode-button" type="button">Place Column</button>
847
+ <button id="modeBeam" class="mode-button" type="button">Place Beam</button>
848
+ <button id="modeWall" class="mode-button" type="button">Add Wall</button>
849
+ </div>
850
+
851
+ <div class="control-grid">
852
+ <label class="control">
853
+ Column section
854
+ <select id="columnSection"></select>
855
+ </label>
856
+ <label class="control">
857
+ Beam section
858
+ <select id="beamSection"></select>
859
+ </label>
860
+ <label class="control">
861
+ Wall thickness
862
+ <select id="wallThickness">
863
+ <option value="0.2">0.2 m</option>
864
+ <option value="0.3">0.3 m</option>
865
  </select>
866
+ </label>
867
+ <div class="control">
868
+ Actions
869
+ <div class="control-actions">
870
+ <button id="workspaceStartButton" class="btn secondary" type="button" data-start-episode>New Episode</button>
871
+ <button id="doneButton" class="btn primary" type="button">Finish Design</button>
872
+ </div>
873
+ </div>
874
  </div>
 
875
 
876
+ <div class="notice info" id="noticeBar">Loading API metadata...</div>
877
+
878
+ <div class="canvas-wrap">
879
+ <svg id="gridSvg" viewBox="0 0 800 520" aria-label="Structural grid"></svg>
880
+ <div class="grid-empty-state" id="gridEmptyState">
881
+ Start a new episode to activate the interactive simulation.
882
+ </div>
883
+ </div>
884
+
885
+ <div class="legend">
886
+ <span class="legend-chip"><span class="legend-swatch" style="background:#74c77b"></span>UR &lt; 0.60</span>
887
+ <span class="legend-chip"><span class="legend-swatch" style="background:#f2c462"></span>0.60 to 1.00</span>
888
+ <span class="legend-chip"><span class="legend-swatch" style="background:#ef6b5b"></span>UR &gt;= 1.00</span>
889
+ <span class="legend-chip"><span class="legend-swatch" style="background:#68c3b3"></span>Selected member</span>
890
+ <span class="legend-chip"><span class="legend-swatch" style="background:#d6dbe1"></span>Column node</span>
891
+ </div>
892
+ </section>
893
+
894
+ <aside>
895
+ <section class="panel">
896
+ <div class="panel-head">
897
+ <div>
898
+ <p class="section-label">Physics</p>
899
+ <h2>Live Results</h2>
900
+ </div>
901
+ <span class="status-pill idle" id="statusBadge">Idle</span>
902
+ </div>
903
+
904
+ <div class="metric-grid">
905
+ <div class="metric">
906
+ <span class="metric-label">Validity</span>
907
+ <span class="metric-value" id="metricValidity">-</span>
908
+ </div>
909
+ <div class="metric">
910
+ <span class="metric-label">Max UR</span>
911
+ <span class="metric-value" id="metricUR">-</span>
912
  </div>
913
+ <div class="metric">
914
+ <span class="metric-label">Drift Ratio</span>
915
+ <span class="metric-value" id="metricDrift">-</span>
 
 
 
916
  </div>
917
+ <div class="metric">
918
+ <span class="metric-label">Deflection</span>
919
+ <span class="metric-value" id="metricDeflection">-</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
920
  </div>
921
+ <div class="metric">
922
+ <span class="metric-label">Steel Mass</span>
923
+ <span class="metric-value" id="metricMass">-</span>
924
+ </div>
925
+ <div class="metric">
926
+ <span class="metric-label">Carbon</span>
927
+ <span class="metric-value" id="metricCarbon">-</span>
928
+ </div>
929
+ <div class="metric">
930
+ <span class="metric-label">Frame Type</span>
931
+ <span class="metric-value" id="metricFrameType">-</span>
932
+ </div>
933
+ <div class="metric">
934
+ <span class="metric-label">Last Reward</span>
935
+ <span class="metric-value" id="metricReward">-</span>
936
+ </div>
937
+ <div class="metric">
938
+ <span class="metric-label">Final Score</span>
939
+ <span class="metric-value" id="metricScore">-</span>
940
+ </div>
941
+ </div>
942
 
943
+ <div class="summary-copy" id="summaryMessage">
944
+ Episode-level feedback will appear here after the first action.
945
+ </div>
946
+ </section>
947
+
948
+ <section class="panel">
949
+ <div class="panel-head">
950
+ <div>
951
+ <p class="section-label">Episode</p>
952
+ <h2>Session Details</h2>
953
  </div>
954
+ </div>
955
 
956
+ <div class="meta-list" id="episodeMeta"></div>
957
+ </section>
958
+
959
+ <section class="panel">
960
+ <div class="panel-head">
961
+ <div>
962
+ <p class="section-label">Selection</p>
963
+ <h2>Element Inspector</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
964
  </div>
965
+ </div>
966
+
967
+ <div class="selected-card" id="selectedElementCard">
968
+ <div class="empty-copy">Click a rendered member in Inspect mode to inspect forces and run edits.</div>
969
+ </div>
970
 
971
+ <div class="selection-actions">
972
+ <button id="clearSelectionButton" type="button">Clear Selection</button>
973
+ <button id="upgradeButton" type="button">Upgrade</button>
974
+ <button id="downgradeButton" type="button">Downgrade</button>
975
+ <button id="removeButton" type="button">Remove</button>
976
+ <button id="whatIfButton" type="button">What-If Remove</button>
977
+ </div>
978
+
979
+ <pre class="code-box" id="forcesBox">No member selected.</pre>
980
+ <div class="impact-box" id="impactBox">Counterfactual impact will appear here.</div>
981
+ </section>
982
+
983
+ <section class="panel">
984
+ <div class="panel-head">
985
+ <div>
986
+ <p class="section-label">Critical Members</p>
987
+ <h2>Top Checks</h2>
988
  </div>
989
+ </div>
990
+
991
+ <div class="member-list" id="criticalMembersList"></div>
992
+ </section>
993
 
994
+ <section class="panel">
995
+ <div class="panel-head">
996
+ <div>
997
+ <p class="section-label">Server Summary</p>
998
+ <h2>Observation Message</h2>
999
  </div>
1000
+ </div>
1001
+
1002
+ <pre class="code-box server-message" id="serverMessage">Reset the environment to see the backend summary.</pre>
1003
+ </section>
1004
+ </aside>
1005
+ </main>
1006
+
1007
+ <footer class="panel">
1008
+ <p>
1009
+ 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.
1010
+ </p>
1011
+ <div class="footer-links">
1012
+ <a href="/demo">/demo</a>
1013
+ <a href="/docs">/docs</a>
1014
+ <a href="/health">/health</a>
1015
+ </div>
1016
+ </footer>
1017
+ </div>
1018
+
1019
+ <script>
1020
+ const SVG_NS = "http://www.w3.org/2000/svg";
1021
+ const fallbackSections = {
1022
+ columns: ["HEB140", "HEB160", "HEB200", "HEB240", "HEB300", "HEB360", "HEB400"],
1023
+ beams: ["IPE200", "IPE240", "IPE300", "IPE360", "IPE400", "IPE450", "IPE500"]
1024
+ };
1025
+
1026
+ const state = {
1027
+ health: null,
1028
+ schema: null,
1029
+ tasks: [],
1030
+ sessionId: null,
1031
+ observation: null,
1032
+ selectedFloor: 0,
1033
+ selectedElementId: null,
1034
+ selectedElementForces: null,
1035
+ selectedElementImpact: null,
1036
+ mode: "inspect",
1037
+ pendingPoint: null,
1038
+ busy: false,
1039
+ busyLabel: "",
1040
+ lastReward: null,
1041
+ lastInfo: null,
1042
+ episodeDone: false,
1043
+ notice: { text: "Loading API metadata...", tone: "info" }
1044
+ };
1045
+
1046
+ const $ = (id) => document.getElementById(id);
1047
+
1048
+ document.addEventListener("DOMContentLoaded", () => {
1049
+ bindEvents();
1050
+ bootstrap();
1051
+ });
1052
+
1053
+ function bindEvents() {
1054
+ document.querySelectorAll("[data-start-episode]").forEach((button) => {
1055
+ button.addEventListener("click", () => {
1056
+ if (!state.busy) {
1057
+ startEpisode();
1058
+ }
1059
  });
1060
+ });
1061
 
1062
+ $("taskSelect").addEventListener("change", () => {
1063
+ if (!state.observation) {
1064
+ state.selectedFloor = 0;
1065
+ }
1066
+ renderAll();
1067
+ });
1068
 
1069
+ $("modeInspect").addEventListener("click", () => setMode("inspect"));
1070
+ $("modeColumn").addEventListener("click", () => setMode("column"));
1071
+ $("modeBeam").addEventListener("click", () => setMode("beam"));
1072
+ $("modeWall").addEventListener("click", () => setMode("wall"));
1073
+
1074
+ $("doneButton").addEventListener("click", () => {
1075
+ if (!state.busy) {
1076
+ finalizeEpisode();
1077
  }
1078
+ });
1079
 
1080
+ $("clearSelectionButton").addEventListener("click", () => {
1081
+ clearSelection();
1082
+ });
1083
+
1084
+ $("upgradeButton").addEventListener("click", () => {
1085
+ if (!state.busy) {
1086
+ sendElementMutation("upgrade_section");
 
 
 
 
 
 
 
 
 
 
 
1087
  }
1088
+ });
1089
+
1090
+ $("downgradeButton").addEventListener("click", () => {
1091
+ if (!state.busy) {
1092
+ sendElementMutation("downgrade_section");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1093
  }
1094
+ });
1095
+
1096
+ $("removeButton").addEventListener("click", () => {
1097
+ if (!state.busy) {
1098
+ sendElementMutation("remove_element");
1099
  }
1100
+ });
1101
 
1102
+ $("whatIfButton").addEventListener("click", () => {
1103
+ if (!state.busy) {
1104
+ runWhatIfRemove();
 
 
1105
  }
1106
+ });
1107
+ }
1108
+
1109
+ async function bootstrap() {
1110
+ setBusy(true, "Loading");
1111
+ try {
1112
+ const [health, tasksResponse, schema] = await Promise.all([
1113
+ api("/health"),
1114
+ api("/tasks"),
1115
+ api("/action_schema")
1116
+ ]);
1117
+
1118
+ state.health = health;
1119
+ state.tasks = Array.isArray(tasksResponse.tasks) ? tasksResponse.tasks : [];
1120
+ state.schema = schema || {};
1121
+ populateTaskSelect();
1122
+ populateSectionSelects();
1123
+ setNotice("The demo is ready. Start a new episode to begin placing members.", "info");
1124
+ } catch (error) {
1125
+ setNotice(error.message, "error");
1126
+ } finally {
1127
+ setBusy(false);
1128
+ renderAll();
1129
+ }
1130
+ }
1131
+
1132
+ async function startEpisode() {
1133
+ const taskId = selectedTaskId();
1134
+ setBusy(true, "Resetting");
1135
+ try {
1136
+ const payload = await api("/reset", {
1137
+ method: "POST",
1138
+ body: JSON.stringify({ task_id: taskId })
1139
+ });
1140
+
1141
+ state.sessionId = payload.session_id;
1142
+ state.lastReward = null;
1143
+ state.lastInfo = null;
1144
+ state.episodeDone = false;
1145
+ state.selectedFloor = 0;
1146
+ state.selectedElementId = null;
1147
+ state.selectedElementForces = null;
1148
+ state.selectedElementImpact = null;
1149
+ state.pendingPoint = null;
1150
+ applyObservation(payload.observation);
1151
+ setMode("column");
1152
+ setNotice("Episode ready. Click the grid to place columns on floor 0.", "info");
1153
+ } catch (error) {
1154
+ setNotice(error.message, "error");
1155
+ } finally {
1156
+ setBusy(false);
1157
+ renderAll();
1158
+ }
1159
+ }
1160
+
1161
+ async function finalizeEpisode() {
1162
+ if (!state.sessionId || !state.observation || state.episodeDone) {
1163
+ return;
1164
+ }
1165
+ await sendAction({ action_type: "done" }, "Final grading");
1166
+ }
1167
+
1168
+ async function sendElementMutation(actionType) {
1169
+ const element = getSelectedElement();
1170
+ if (!element || !state.sessionId || state.episodeDone) {
1171
+ return;
1172
+ }
1173
+ await sendAction({ action_type: actionType, element_id: element.id }, "Updating section");
1174
+ }
1175
+
1176
+ async function sendAction(action, busyLabel) {
1177
+ if (!state.sessionId) {
1178
+ setNotice("Start an episode before sending actions.", "warn");
1179
+ renderAll();
1180
+ return;
1181
+ }
1182
+
1183
+ setBusy(true, busyLabel || "Sending action");
1184
+ try {
1185
+ const payload = await api("/step", {
1186
+ method: "POST",
1187
+ body: JSON.stringify({
1188
+ session_id: state.sessionId,
1189
+ message: JSON.stringify(action)
1190
+ })
1191
+ });
1192
+
1193
+ state.lastReward = typeof payload.reward === "number" ? payload.reward : null;
1194
+ state.lastInfo = payload.info || {};
1195
+ state.episodeDone = Boolean(payload.done);
1196
+ state.pendingPoint = null;
1197
+ applyObservation(payload.observation);
1198
+
1199
+ if (state.observation && state.observation.last_action_result === "INVALID") {
1200
+ setNotice(state.observation.last_action_error || "The backend rejected that action.", "warn");
1201
+ } else if (state.episodeDone && typeof state.lastInfo.graded_score === "number") {
1202
+ setNotice("Episode finished. Final score: " + formatNumber(state.lastInfo.graded_score, 4), "info");
1203
+ } else {
1204
+ setNotice(actionSummary(action), "info");
1205
+ }
1206
+
1207
+ await refreshSelectedElementDetails();
1208
+ } catch (error) {
1209
+ setNotice(error.message, "error");
1210
+ } finally {
1211
+ setBusy(false);
1212
+ renderAll();
1213
+ }
1214
+ }
1215
+
1216
+ async function runWhatIfRemove() {
1217
+ const element = getSelectedElement();
1218
+ if (!element || !state.sessionId) {
1219
+ return;
1220
+ }
1221
+
1222
+ setBusy(true, "Running what-if");
1223
+ try {
1224
+ state.selectedElementImpact = await api("/what_if_remove", {
1225
+ method: "POST",
1226
+ body: JSON.stringify({
1227
+ session_id: state.sessionId,
1228
+ element_id: element.id
1229
+ })
1230
+ });
1231
+ setNotice("Counterfactual complete for " + element.id + ".", "info");
1232
+ } catch (error) {
1233
+ state.selectedElementImpact = { error: error.message };
1234
+ setNotice(error.message, "warn");
1235
+ } finally {
1236
+ setBusy(false);
1237
+ renderAll();
1238
+ }
1239
+ }
1240
+
1241
+ function applyObservation(observation) {
1242
+ state.observation = observation || null;
1243
+
1244
+ if (state.observation) {
1245
+ const availableFloors = Math.max(1, state.observation.n_floors || 1);
1246
+ if (state.selectedFloor >= availableFloors) {
1247
+ state.selectedFloor = availableFloors - 1;
1248
+ }
1249
+ } else {
1250
+ state.selectedFloor = 0;
1251
+ }
1252
+
1253
+ if (state.selectedElementId && !getSelectedElement()) {
1254
+ state.selectedElementId = null;
1255
+ state.selectedElementForces = null;
1256
+ state.selectedElementImpact = null;
1257
+ }
1258
+ }
1259
+
1260
+ async function selectElement(elementId) {
1261
+ if (!state.observation) {
1262
+ return;
1263
+ }
1264
+
1265
+ state.selectedElementId = elementId;
1266
+ state.selectedElementForces = null;
1267
+ state.selectedElementImpact = null;
1268
+ renderAll();
1269
+ await refreshSelectedElementDetails();
1270
+ }
1271
+
1272
+ async function refreshSelectedElementDetails() {
1273
+ const element = getSelectedElement();
1274
+ if (!element || !state.sessionId) {
1275
+ state.selectedElementForces = null;
1276
+ return;
1277
+ }
1278
+
1279
+ try {
1280
+ state.selectedElementForces = await api(
1281
+ "/query_forces?session_id=" + encodeURIComponent(state.sessionId) +
1282
+ "&element_id=" + encodeURIComponent(element.id)
1283
+ );
1284
+ } catch (error) {
1285
+ state.selectedElementForces = { error: error.message };
1286
+ }
1287
+ renderAll();
1288
+ }
1289
+
1290
+ function clearSelection() {
1291
+ state.selectedElementId = null;
1292
+ state.selectedElementForces = null;
1293
+ state.selectedElementImpact = null;
1294
+ state.pendingPoint = null;
1295
+ setNotice(modeHint(), "info");
1296
+ renderAll();
1297
+ }
1298
+
1299
+ function populateTaskSelect() {
1300
+ const select = $("taskSelect");
1301
+ const active = select.value || "task1_warehouse";
1302
+ select.innerHTML = state.tasks.map((task) => (
1303
+ "<option value=\"" + escapeHtml(task.id) + "\"" +
1304
+ (task.id === active ? " selected" : "") + ">" +
1305
+ escapeHtml(task.name) + " (" + escapeHtml(task.id) + ")" +
1306
+ "</option>"
1307
+ )).join("");
1308
+
1309
+ if (!select.value && state.tasks.length) {
1310
+ select.value = state.tasks[0].id;
1311
+ }
1312
+ }
1313
+
1314
+ function populateSectionSelects() {
1315
+ const schemaSections = state.schema && state.schema.sections ? state.schema.sections : fallbackSections;
1316
+ populateSelectWithOptions($("columnSection"), schemaSections.columns || fallbackSections.columns);
1317
+ populateSelectWithOptions($("beamSection"), schemaSections.beams || fallbackSections.beams);
1318
+ $("columnSection").value = "HEB200";
1319
+ $("beamSection").value = "IPE300";
1320
+ }
1321
+
1322
+ function populateSelectWithOptions(select, options) {
1323
+ select.innerHTML = options.map((value) => (
1324
+ "<option value=\"" + escapeHtml(value) + "\">" + escapeHtml(value) + "</option>"
1325
+ )).join("");
1326
+ }
1327
+
1328
+ function selectedTaskId() {
1329
+ return $("taskSelect").value || "task1_warehouse";
1330
+ }
1331
+
1332
+ function currentTaskId() {
1333
+ return state.observation ? state.observation.task_id : selectedTaskId();
1334
+ }
1335
+
1336
+ function currentTaskMeta() {
1337
+ return state.tasks.find((task) => task.id === currentTaskId()) || null;
1338
+ }
1339
+
1340
+ function setMode(mode) {
1341
+ state.mode = mode;
1342
+ state.pendingPoint = null;
1343
+ setNotice(modeHint(), "info");
1344
+ renderAll();
1345
+ }
1346
+
1347
+ function setBusy(isBusy, label) {
1348
+ state.busy = isBusy;
1349
+ state.busyLabel = label || "";
1350
+ }
1351
+
1352
+ function setNotice(text, tone) {
1353
+ state.notice = { text: text, tone: tone || "info" };
1354
+ }
1355
+
1356
+ function renderAll() {
1357
+ renderHeader();
1358
+ renderTaskCards();
1359
+ renderFloorButtons();
1360
+ renderModeButtons();
1361
+ renderNotice();
1362
+ renderGrid();
1363
+ renderMetrics();
1364
+ renderEpisodeMeta();
1365
+ renderSelectedElement();
1366
+ renderCriticalMembers();
1367
+ renderServerMessage();
1368
+ refreshControlStates();
1369
+ }
1370
+
1371
+ function renderHeader() {
1372
+ const health = state.health;
1373
+ $("apiStatus").textContent = health ? (health.status + " / v" + health.version) : "Unavailable";
1374
+ $("episodeStatus").textContent = state.observation
1375
+ ? ("Step " + state.observation.step_count + " / " + state.observation.max_steps)
1376
+ : "Not started";
1377
+ $("solverStatus").textContent = state.busy
1378
+ ? state.busyLabel || "Working"
1379
+ : solverLabel();
1380
+ $("sessionIdDisplay").textContent = state.sessionId || "not started";
1381
+ }
1382
+
1383
+ function renderTaskCards() {
1384
+ const wrapper = $("taskCards");
1385
+ if (!state.tasks.length) {
1386
+ wrapper.innerHTML = "<div class=\"empty-copy\">No tasks available.</div>";
1387
+ return;
1388
+ }
1389
+
1390
+ const activeId = selectedTaskId();
1391
+ wrapper.innerHTML = state.tasks.map((task) => {
1392
+ const difficultyClass = difficultyTone(task.difficulty);
1393
+ return (
1394
+ "<button type=\"button\" class=\"task-card " + (task.id === activeId ? "active" : "") + "\" data-task-id=\"" + escapeHtml(task.id) + "\">" +
1395
+ "<div class=\"task-card-head\">" +
1396
+ "<strong>" + escapeHtml(task.name) + "</strong>" +
1397
+ "<span class=\"task-pill " + difficultyClass + "\">" + escapeHtml(task.difficulty) + "</span>" +
1398
+ "</div>" +
1399
+ "<p>" + escapeHtml(task.description || "") + "</p>" +
1400
+ "<div class=\"task-card-meta\">" +
1401
+ escapeHtml(task.id) + " | " +
1402
+ escapeHtml(String(task.n_floors)) + " floor(s) | " +
1403
+ escapeHtml(String(task.max_steps)) + " steps" +
1404
+ "</div>" +
1405
+ "</button>"
1406
+ );
1407
+ }).join("");
1408
+
1409
+ wrapper.querySelectorAll("[data-task-id]").forEach((button) => {
1410
+ button.addEventListener("click", () => {
1411
+ if (state.busy) {
1412
+ return;
1413
+ }
1414
+ $("taskSelect").value = button.getAttribute("data-task-id");
1415
+ if (!state.observation) {
1416
+ state.selectedFloor = 0;
1417
+ }
1418
+ renderAll();
1419
+ });
1420
+ });
1421
+ }
1422
+
1423
+ function renderFloorButtons() {
1424
+ const wrapper = $("floorButtons");
1425
+ const floorCount = state.observation
1426
+ ? Math.max(1, state.observation.n_floors || 1)
1427
+ : Math.max(1, currentTaskMeta() ? currentTaskMeta().n_floors : 1);
1428
+
1429
+ if (state.selectedFloor >= floorCount) {
1430
+ state.selectedFloor = floorCount - 1;
1431
+ }
1432
+
1433
+ wrapper.innerHTML = Array.from({ length: floorCount }, (_, floor) => (
1434
+ "<button type=\"button\" class=\"floor-button " + (floor === state.selectedFloor ? "active" : "") + "\" data-floor=\"" + floor + "\">" +
1435
+ "Floor " + floor +
1436
+ "</button>"
1437
+ )).join("");
1438
+
1439
+ wrapper.querySelectorAll("[data-floor]").forEach((button) => {
1440
+ button.addEventListener("click", () => {
1441
+ state.selectedFloor = Number(button.getAttribute("data-floor"));
1442
+ state.pendingPoint = null;
1443
+ renderAll();
1444
+ });
1445
+ });
1446
+ }
1447
+
1448
+ function renderModeButtons() {
1449
+ const mapping = {
1450
+ inspect: $("modeInspect"),
1451
+ column: $("modeColumn"),
1452
+ beam: $("modeBeam"),
1453
+ wall: $("modeWall")
1454
+ };
1455
+
1456
+ Object.entries(mapping).forEach(([mode, button]) => {
1457
+ button.classList.toggle("active", state.mode === mode);
1458
+ });
1459
+ }
1460
+
1461
+ function renderNotice() {
1462
+ const notice = $("noticeBar");
1463
+ const tone = state.notice ? state.notice.tone : "info";
1464
+ notice.className = "notice " + tone;
1465
+ notice.textContent = state.busy ? (state.busyLabel + "...") : (state.notice ? state.notice.text : modeHint());
1466
+ }
1467
+
1468
+ function renderGrid() {
1469
+ const svg = $("gridSvg");
1470
+ const emptyState = $("gridEmptyState");
1471
+ svg.innerHTML = "";
1472
+
1473
+ if (!state.observation) {
1474
+ emptyState.style.display = "flex";
1475
+ return;
1476
+ }
1477
+
1478
+ emptyState.style.display = "none";
1479
+ const width = Math.max(1, Math.round(state.observation.site_width_m));
1480
+ const depth = Math.max(1, Math.round(state.observation.site_depth_m));
1481
+ const cell = Math.max(24, Math.floor(620 / Math.max(width, depth)));
1482
+ const padding = 56;
1483
+ const viewWidth = padding * 2 + width * cell;
1484
+ const viewHeight = padding * 2 + depth * cell;
1485
+
1486
+ svg.setAttribute("viewBox", "0 0 " + viewWidth + " " + viewHeight);
1487
+ svg.style.aspectRatio = viewWidth + " / " + viewHeight;
1488
+
1489
+ const centerX = (x) => padding + (x * cell) + (cell / 2);
1490
+ const centerY = (y) => padding + ((depth - y - 1) * cell) + (cell / 2);
1491
+ const cellLeft = (x) => padding + (x * cell);
1492
+ const cellTop = (y) => padding + ((depth - y - 1) * cell);
1493
+
1494
+ svg.appendChild(createSvg("rect", {
1495
+ x: padding,
1496
+ y: padding,
1497
+ width: width * cell,
1498
+ height: depth * cell,
1499
+ rx: 18,
1500
+ fill: "rgba(9, 20, 29, 0.84)",
1501
+ stroke: "rgba(255, 255, 255, 0.12)",
1502
+ "stroke-width": 1.5
1503
+ }));
1504
+
1505
+ for (let x = 0; x <= width; x += 1) {
1506
+ svg.appendChild(createSvg("line", {
1507
+ x1: padding + x * cell,
1508
+ y1: padding,
1509
+ x2: padding + x * cell,
1510
+ y2: padding + depth * cell,
1511
+ stroke: "rgba(255, 255, 255, 0.08)",
1512
+ "stroke-width": 1
1513
+ }));
1514
+ }
1515
+
1516
+ for (let y = 0; y <= depth; y += 1) {
1517
+ svg.appendChild(createSvg("line", {
1518
+ x1: padding,
1519
+ y1: padding + y * cell,
1520
+ x2: padding + width * cell,
1521
+ y2: padding + y * cell,
1522
+ stroke: "rgba(255, 255, 255, 0.08)",
1523
+ "stroke-width": 1
1524
+ }));
1525
+ }
1526
+
1527
+ for (let x = 0; x < width; x += 1) {
1528
+ svg.appendChild(createSvg("text", {
1529
+ x: centerX(x),
1530
+ y: viewHeight - 20,
1531
+ "text-anchor": "middle",
1532
+ fill: "rgba(255, 255, 255, 0.48)",
1533
+ "font-size": 12,
1534
+ "font-family": "monospace"
1535
+ }, String(x)));
1536
+ }
1537
+
1538
+ for (let y = 0; y < depth; y += 1) {
1539
+ svg.appendChild(createSvg("text", {
1540
+ x: 28,
1541
+ y: centerY(y) + 4,
1542
+ "text-anchor": "middle",
1543
+ fill: "rgba(255, 255, 255, 0.48)",
1544
+ "font-size": 12,
1545
+ "font-family": "monospace"
1546
+ }, String(y)));
1547
+ }
1548
+
1549
+ svg.appendChild(createSvg("text", {
1550
+ x: padding,
1551
+ y: 28,
1552
+ fill: "#dfeaf0",
1553
+ "font-size": 14,
1554
+ "font-family": "\"Avenir Next\", \"Trebuchet MS\", sans-serif"
1555
+ }, currentTaskId() + " / floor " + state.selectedFloor));
1556
+
1557
+ for (let x = 0; x < width; x += 1) {
1558
+ for (let y = 0; y < depth; y += 1) {
1559
+ const hit = createSvg("rect", {
1560
+ x: cellLeft(x),
1561
+ y: cellTop(y),
1562
+ width: cell,
1563
+ height: cell,
1564
+ fill: "transparent",
1565
+ cursor: state.busy ? "wait" : "pointer"
1566
+ });
1567
+ hit.addEventListener("click", () => handleGridPointClick(x, y));
1568
+ svg.appendChild(hit);
1569
+ }
1570
+ }
1571
+
1572
+ const elements = state.observation.placed_elements || [];
1573
+
1574
+ elements
1575
+ .filter((element) => element.type !== "column" && elementVisibleOnFloor(element, state.selectedFloor))
1576
+ .forEach((element) => {
1577
+ const start = parseNodeId(element.node_i);
1578
+ const end = parseNodeId(element.node_j);
1579
+ if (!start || !end) {
1580
+ return;
1581
+ }
1582
+
1583
+ const ur = urForElement(element.id);
1584
+ const selected = state.selectedElementId === element.id;
1585
+ const stroke = selected ? "#68c3b3" : urColor(ur);
1586
+ const widthValue = element.type === "wall" ? Math.max(12, cell * 0.3) : Math.max(6, cell * 0.16);
1587
+
1588
+ if (selected) {
1589
+ svg.appendChild(createSvg("line", {
1590
+ x1: centerX(start.x),
1591
+ y1: centerY(start.y),
1592
+ x2: centerX(end.x),
1593
+ y2: centerY(end.y),
1594
+ stroke: "rgba(104, 195, 179, 0.18)",
1595
+ "stroke-width": widthValue + 8,
1596
+ "stroke-linecap": "round"
1597
+ }));
1598
+ }
1599
+
1600
+ const line = createSvg("line", {
1601
+ x1: centerX(start.x),
1602
+ y1: centerY(start.y),
1603
+ x2: centerX(end.x),
1604
+ y2: centerY(end.y),
1605
+ stroke: stroke,
1606
+ "stroke-width": widthValue,
1607
+ "stroke-linecap": "round",
1608
+ opacity: 0.96,
1609
+ cursor: "pointer"
1610
+ });
1611
+ line.addEventListener("click", (event) => {
1612
+ event.stopPropagation();
1613
+ if (state.mode === "inspect") {
1614
+ selectElement(element.id);
1615
  }
1616
+ });
1617
+ svg.appendChild(line);
1618
+ });
1619
+
1620
+ elements
1621
+ .filter((element) => element.type === "column" && elementVisibleOnFloor(element, state.selectedFloor))
1622
+ .forEach((element) => {
1623
+ const node = parseNodeId(element.node_i);
1624
+ if (!node) {
1625
+ return;
1626
+ }
1627
+
1628
+ const ur = urForElement(element.id);
1629
+ const selected = state.selectedElementId === element.id;
1630
+ const circle = createSvg("circle", {
1631
+ cx: centerX(node.x),
1632
+ cy: centerY(node.y),
1633
+ r: Math.max(8, cell * 0.2),
1634
+ fill: selected ? "#68c3b3" : urColor(ur),
1635
+ stroke: selected ? "#dff9f4" : "#f6ede1",
1636
+ "stroke-width": selected ? 2.5 : 1.2,
1637
+ cursor: "pointer"
1638
+ });
1639
+
1640
+ circle.addEventListener("click", (event) => {
1641
+ event.stopPropagation();
1642
+ if (state.mode === "inspect") {
1643
+ selectElement(element.id);
1644
  } else {
1645
+ handleGridPointClick(node.x, node.y);
 
 
 
 
 
 
 
 
 
1646
  }
1647
+ });
1648
+ svg.appendChild(circle);
1649
+ });
1650
+
1651
+ if (state.pendingPoint) {
1652
+ svg.appendChild(createSvg("circle", {
1653
+ cx: centerX(state.pendingPoint.x),
1654
+ cy: centerY(state.pendingPoint.y),
1655
+ r: Math.max(12, cell * 0.28),
1656
+ fill: "transparent",
1657
+ stroke: "#f6ede1",
1658
+ "stroke-width": 2.5,
1659
+ "stroke-dasharray": "6 4"
1660
+ }));
1661
+ }
1662
+ }
1663
+
1664
+ function handleGridPointClick(x, y) {
1665
+ if (state.busy) {
1666
+ return;
1667
+ }
1668
+
1669
+ if (!state.observation) {
1670
+ setNotice("Start a new episode first.", "warn");
1671
+ renderAll();
1672
+ return;
1673
+ }
1674
+
1675
+ if (state.episodeDone) {
1676
+ setNotice("This episode is finished. Start a new one to continue editing.", "warn");
1677
+ renderAll();
1678
+ return;
1679
+ }
1680
+
1681
+ if (state.mode === "inspect") {
1682
+ state.selectedElementId = null;
1683
+ state.selectedElementForces = null;
1684
+ state.selectedElementImpact = null;
1685
+ setNotice("Inspect mode is active. Click a rendered member to inspect it.", "info");
1686
+ renderAll();
1687
+ return;
1688
+ }
1689
+
1690
+ if (state.mode === "column") {
1691
+ sendAction({
1692
+ action_type: "place_column",
1693
+ grid_x: x,
1694
+ grid_y: y,
1695
+ floor: state.selectedFloor,
1696
+ section: $("columnSection").value
1697
+ }, "Placing column");
1698
+ return;
1699
+ }
1700
+
1701
+ if (!columnExistsAt(x, y, state.selectedFloor)) {
1702
+ setNotice("Beam and wall endpoints must already have columns on this floor.", "warn");
1703
+ renderAll();
1704
+ return;
1705
+ }
1706
+
1707
+ if (!state.pendingPoint) {
1708
+ state.pendingPoint = { x: x, y: y };
1709
+ setNotice(
1710
+ "Start point locked at (" + x + ", " + y + "). Choose an axis-aligned end point.",
1711
+ "info"
1712
+ );
1713
+ renderAll();
1714
+ return;
1715
+ }
1716
+
1717
+ if (state.pendingPoint.x === x && state.pendingPoint.y === y) {
1718
+ state.pendingPoint = null;
1719
+ setNotice(modeHint(), "info");
1720
+ renderAll();
1721
+ return;
1722
+ }
1723
+
1724
+ if (state.pendingPoint.x !== x && state.pendingPoint.y !== y) {
1725
+ setNotice("Only axis-aligned members are supported here. Choose a point sharing x or y.", "warn");
1726
+ renderAll();
1727
+ return;
1728
+ }
1729
+
1730
+ const action = state.mode === "beam"
1731
+ ? {
1732
+ action_type: "place_beam",
1733
+ from_node_x: state.pendingPoint.x,
1734
+ from_node_y: state.pendingPoint.y,
1735
+ to_node_x: x,
1736
+ to_node_y: y,
1737
+ floor: state.selectedFloor,
1738
+ section: $("beamSection").value,
1739
+ orientation: state.pendingPoint.y === y ? "x" : "y"
1740
+ }
1741
+ : {
1742
+ action_type: "add_wall",
1743
+ from_node_x: state.pendingPoint.x,
1744
+ from_node_y: state.pendingPoint.y,
1745
+ to_node_x: x,
1746
+ to_node_y: y,
1747
+ floor: state.selectedFloor,
1748
+ thickness_m: Number($("wallThickness").value),
1749
+ orientation: state.pendingPoint.y === y ? "x" : "y"
1750
+ };
1751
+
1752
+ sendAction(action, state.mode === "beam" ? "Placing beam" : "Adding wall");
1753
+ }
1754
+
1755
+ function renderMetrics() {
1756
+ const observation = state.observation;
1757
+
1758
+ if (!observation) {
1759
+ $("statusBadge").className = "status-pill idle";
1760
+ $("statusBadge").textContent = state.busy ? state.busyLabel : "Idle";
1761
+ $("metricValidity").textContent = "-";
1762
+ $("metricUR").textContent = "-";
1763
+ $("metricDrift").textContent = "-";
1764
+ $("metricDeflection").textContent = "-";
1765
+ $("metricMass").textContent = "-";
1766
+ $("metricCarbon").textContent = "-";
1767
+ $("metricFrameType").textContent = "-";
1768
+ $("metricReward").textContent = "-";
1769
+ $("metricScore").textContent = "-";
1770
+ $("summaryMessage").textContent = "Episode-level feedback will appear here after the first action.";
1771
+ return;
1772
+ }
1773
+
1774
+ const validity = observation.is_structurally_valid ? "Valid" : (observation.n_elements_placed ? "Invalid" : "Awaiting frame");
1775
+ const maxUr = observation.critical_members && observation.critical_members.length
1776
+ ? observation.critical_members[0].max_UR
1777
+ : null;
1778
+ const score = state.lastInfo && typeof state.lastInfo.graded_score === "number"
1779
+ ? state.lastInfo.graded_score
1780
+ : null;
1781
+
1782
+ $("statusBadge").className = "status-pill " + statusTone(observation);
1783
+ $("statusBadge").textContent = state.busy ? state.busyLabel : solverLabel();
1784
+ $("metricValidity").textContent = validity;
1785
+ $("metricUR").textContent = maxUr === null ? "-" : formatNumber(maxUr, 3);
1786
+ $("metricUR").className = "metric-value " + urToneClass(maxUr);
1787
+ $("metricDrift").textContent = observation.n_elements_placed
1788
+ ? formatNumber(observation.max_lateral_drift_ratio, 3)
1789
+ : "-";
1790
+ $("metricDeflection").textContent = observation.n_elements_placed
1791
+ ? formatNumber(observation.max_deflection_mm, 2) + " mm"
1792
+ : "-";
1793
+ $("metricMass").textContent = observation.n_elements_placed
1794
+ ? Math.round(observation.total_steel_mass_kg).toLocaleString() + " kg"
1795
+ : "-";
1796
+ $("metricCarbon").textContent = observation.n_elements_placed
1797
+ ? Math.round(observation.carbon_kg).toLocaleString() + " kg"
1798
+ : "-";
1799
+ $("metricFrameType").textContent = observation.is_braced_frame ? "Braced" : "Unbraced";
1800
+ $("metricReward").textContent = typeof state.lastReward === "number" ? formatNumber(state.lastReward, 4) : "-";
1801
+ $("metricScore").textContent = typeof score === "number" ? formatNumber(score, 4) : "-";
1802
+ $("summaryMessage").textContent = summaryCopy(observation);
1803
+ }
1804
+
1805
+ function renderEpisodeMeta() {
1806
+ const wrapper = $("episodeMeta");
1807
+ const observation = state.observation;
1808
+ const rows = [
1809
+ { label: "Task", value: observation ? observation.task_id : currentTaskId() },
1810
+ { label: "Episode ID", value: observation ? observation.episode_id : "-" },
1811
+ { label: "Session ID", value: state.sessionId ? "<code>" + escapeHtml(state.sessionId) + "</code>" : "-" },
1812
+ { label: "Steps", value: observation ? (observation.step_count + " / " + observation.max_steps) : "-" },
1813
+ { label: "Elements", value: observation ? String(observation.n_elements_placed) : "-" },
1814
+ { label: "Last action", value: observation ? observation.last_action_result : "-" },
1815
+ { label: "Error", value: observation && observation.last_action_error ? observation.last_action_error : "None" },
1816
+ { label: "Done", value: state.episodeDone ? "Yes" : "No" }
1817
+ ];
1818
+
1819
+ if (state.lastInfo && typeof state.lastInfo.graded_score === "number") {
1820
+ rows.push({ label: "Final score", value: formatNumber(state.lastInfo.graded_score, 4) });
1821
+ }
1822
+
1823
+ wrapper.innerHTML = rows.map((row) => (
1824
+ "<div class=\"meta-row\"><span>" + escapeHtml(row.label) + "</span><strong>" + row.value + "</strong></div>"
1825
+ )).join("");
1826
+ }
1827
+
1828
+ function renderSelectedElement() {
1829
+ const wrapper = $("selectedElementCard");
1830
+ const element = getSelectedElement();
1831
+
1832
+ if (!element) {
1833
+ wrapper.innerHTML = "<div class=\"empty-copy\">Click a rendered member in Inspect mode to inspect forces and run edits.</div>";
1834
+ $("forcesBox").textContent = "No member selected.";
1835
+ $("impactBox").textContent = "Counterfactual impact will appear here.";
1836
+ return;
1837
+ }
1838
+
1839
+ const critical = criticalMemberById(element.id);
1840
+ const floor = elementDisplayFloor(element);
1841
+ wrapper.innerHTML =
1842
+ "<strong>" + escapeHtml(element.id) + "</strong>" +
1843
+ "<div class=\"selected-grid\">" +
1844
+ selectedField("Type", element.type) +
1845
+ selectedField("Section", element.section || "wall") +
1846
+ selectedField("Floor", String(floor)) +
1847
+ selectedField("Length", formatNumber(element.length_m, 2) + " m") +
1848
+ selectedField("Max UR", critical ? formatNumber(critical.max_UR, 3) : "n/a") +
1849
+ selectedField("Orientation", element.orientation || "-") +
1850
+ "</div>";
1851
+
1852
+ $("forcesBox").textContent = forceSummary();
1853
+ $("impactBox").textContent = impactSummary();
1854
+ }
1855
+
1856
+ function renderCriticalMembers() {
1857
+ const wrapper = $("criticalMembersList");
1858
+ const observation = state.observation;
1859
+
1860
+ if (!observation || !observation.critical_members || !observation.critical_members.length) {
1861
+ wrapper.innerHTML = "<div class=\"empty-copy\">Critical members appear after the solver has a connected frame to analyze.</div>";
1862
+ return;
1863
+ }
1864
+
1865
+ wrapper.innerHTML = observation.critical_members.slice(0, 8).map((member) => (
1866
+ "<button type=\"button\" class=\"member-row " + (state.selectedElementId === member.id ? "is-selected" : "") + "\" data-member-id=\"" + escapeHtml(member.id) + "\">" +
1867
+ "<span class=\"member-copy\">" +
1868
+ "<span class=\"member-title\">" + escapeHtml(member.id) + "</span>" +
1869
+ "<span class=\"member-subtitle\">" +
1870
+ escapeHtml(member.type) + " | " + escapeHtml(member.section) + " | L=" + escapeHtml(formatNumber(member.length_m, 2)) + " m" +
1871
+ "</span>" +
1872
+ "</span>" +
1873
+ "<span class=\"member-ur " + urToneClass(member.max_UR) + "\">" + escapeHtml(formatNumber(member.max_UR, 3)) + "</span>" +
1874
+ "</button>"
1875
+ )).join("");
1876
+
1877
+ wrapper.querySelectorAll("[data-member-id]").forEach((button) => {
1878
+ button.addEventListener("click", () => {
1879
+ if (!state.busy) {
1880
+ selectElement(button.getAttribute("data-member-id"));
1881
+ }
1882
+ });
1883
+ });
1884
+ }
1885
+
1886
+ function renderServerMessage() {
1887
+ $("serverMessage").textContent = state.observation
1888
+ ? state.observation.message
1889
+ : "Reset the environment to see the backend summary.";
1890
+ }
1891
+
1892
+ function refreshControlStates() {
1893
+ const hasEpisode = Boolean(state.observation && state.sessionId);
1894
+ const selectedElement = getSelectedElement();
1895
+ const selectedIsWall = Boolean(selectedElement && selectedElement.type === "wall");
1896
+
1897
+ $("taskSelect").disabled = state.busy;
1898
+ $("columnSection").disabled = state.busy || !hasEpisode || state.episodeDone;
1899
+ $("beamSection").disabled = state.busy || !hasEpisode || state.episodeDone;
1900
+ $("wallThickness").disabled = state.busy || !hasEpisode || state.episodeDone;
1901
+ $("doneButton").disabled = state.busy || !hasEpisode || state.episodeDone;
1902
+ $("clearSelectionButton").disabled = state.busy || (!selectedElement && !state.pendingPoint);
1903
+ $("upgradeButton").disabled = state.busy || !selectedElement || selectedIsWall || state.episodeDone;
1904
+ $("downgradeButton").disabled = state.busy || !selectedElement || selectedIsWall || state.episodeDone;
1905
+ $("removeButton").disabled = state.busy || !selectedElement || state.episodeDone;
1906
+ $("whatIfButton").disabled = state.busy || !selectedElement;
1907
+ document.querySelectorAll("[data-start-episode]").forEach((button) => {
1908
+ button.disabled = state.busy;
1909
+ });
1910
+ }
1911
+
1912
+ function createSvg(tag, attrs, text) {
1913
+ const element = document.createElementNS(SVG_NS, tag);
1914
+ Object.entries(attrs || {}).forEach(([key, value]) => {
1915
+ element.setAttribute(key, String(value));
1916
+ });
1917
+ if (text !== undefined) {
1918
+ element.textContent = text;
1919
+ }
1920
+ return element;
1921
+ }
1922
+
1923
+ function getSelectedElement() {
1924
+ return state.observation && state.selectedElementId
1925
+ ? (state.observation.placed_elements || []).find((element) => element.id === state.selectedElementId) || null
1926
+ : null;
1927
+ }
1928
+
1929
+ function criticalMemberById(elementId) {
1930
+ return state.observation
1931
+ ? (state.observation.critical_members || []).find((member) => member.id === elementId) || null
1932
+ : null;
1933
+ }
1934
+
1935
+ function urForElement(elementId) {
1936
+ const member = criticalMemberById(elementId);
1937
+ return member ? member.max_UR : null;
1938
+ }
1939
+
1940
+ function columnExistsAt(x, y, floor) {
1941
+ return Boolean(state.observation && (state.observation.placed_elements || []).some((element) => {
1942
+ if (element.type !== "column") {
1943
+ return false;
1944
  }
1945
+ const node = parseNodeId(element.node_i);
1946
+ return Boolean(node && node.x === x && node.y === y && node.floor === floor);
1947
+ }));
1948
+ }
1949
 
1950
+ function parseNodeId(nodeId) {
1951
+ const match = /^n_(\d+)_(\d+)_(\d+)$/.exec(nodeId || "");
1952
+ if (!match) {
1953
+ return null;
1954
+ }
1955
+ return {
1956
+ x: Number(match[1]),
1957
+ y: Number(match[2]),
1958
+ floor: Number(match[3])
1959
+ };
1960
+ }
1961
+
1962
+ function elementVisibleOnFloor(element, floor) {
1963
+ const node = parseNodeId(element.node_i);
1964
+ if (!node) {
1965
+ return false;
1966
+ }
1967
+ if (element.type === "column") {
1968
+ return node.floor === floor;
1969
+ }
1970
+ return node.floor === floor + 1;
1971
+ }
1972
+
1973
+ function elementDisplayFloor(element) {
1974
+ const node = parseNodeId(element.node_i);
1975
+ if (!node) {
1976
+ return 0;
1977
+ }
1978
+ return element.type === "column" ? node.floor : Math.max(0, node.floor - 1);
1979
+ }
1980
+
1981
+ function solverLabel() {
1982
+ if (!state.observation) {
1983
+ return "Idle";
1984
+ }
1985
+ if (state.episodeDone && state.lastInfo && typeof state.lastInfo.graded_score === "number") {
1986
+ return "Graded";
1987
+ }
1988
+ if (!state.observation.n_elements_placed) {
1989
+ return "Awaiting frame";
1990
+ }
1991
+ if (!state.observation.critical_members.length) {
1992
+ return "Disconnected";
1993
+ }
1994
+ return state.observation.is_structurally_valid ? "Valid" : "Invalid";
1995
+ }
1996
+
1997
+ function statusTone(observation) {
1998
+ if (!observation || !observation.n_elements_placed) {
1999
+ return "idle";
2000
+ }
2001
+ if (observation.is_structurally_valid) {
2002
+ return "good";
2003
+ }
2004
+ if (observation.critical_members.length) {
2005
+ return "danger";
2006
+ }
2007
+ return "warn";
2008
+ }
2009
+
2010
+ function difficultyTone(difficulty) {
2011
+ const value = String(difficulty || "").toLowerCase();
2012
+ if (value === "easy") {
2013
+ return "easy";
2014
+ }
2015
+ if (value === "hard") {
2016
+ return "hard";
2017
+ }
2018
+ return "medium";
2019
+ }
2020
+
2021
+ function urColor(ur) {
2022
+ if (typeof ur !== "number") {
2023
+ return "#d6dbe1";
2024
+ }
2025
+ if (ur < 0.6) {
2026
+ return "#74c77b";
2027
+ }
2028
+ if (ur < 1.0) {
2029
+ return "#f2c462";
2030
+ }
2031
+ return "#ef6b5b";
2032
+ }
2033
+
2034
+ function urToneClass(ur) {
2035
+ if (typeof ur !== "number") {
2036
+ return "";
2037
+ }
2038
+ if (ur < 0.6) {
2039
+ return "ur-good";
2040
+ }
2041
+ if (ur < 1.0) {
2042
+ return "ur-warn";
2043
+ }
2044
+ return "ur-danger";
2045
+ }
2046
+
2047
+ function summaryCopy(observation) {
2048
+ if (!observation) {
2049
+ return "Episode-level feedback will appear here after the first action.";
2050
+ }
2051
+
2052
+ if (observation.last_action_result === "INVALID" && observation.last_action_error) {
2053
+ return "Invalid action: " + observation.last_action_error;
2054
+ }
2055
+
2056
+ if (state.episodeDone && state.lastInfo && typeof state.lastInfo.graded_score === "number") {
2057
+ return "Episode complete. Final score " + formatNumber(state.lastInfo.graded_score, 4) +
2058
+ ". Structural validity: " + (observation.is_structurally_valid ? "pass" : "fail") + ".";
2059
+ }
2060
 
2061
+ if (!observation.n_elements_placed) {
2062
+ return "No members placed yet. Use Place Column mode to seed the frame.";
2063
+ }
2064
+
2065
+ if (!observation.critical_members.length) {
2066
+ return "The frame has members, but the solver has not produced full member checks yet. Add connectivity and supports.";
2067
+ }
2068
+
2069
+ return (
2070
+ (observation.is_structurally_valid ? "Current frame passes the available checks." : "Current frame has " + observation.n_code_violations + " code violation(s).") +
2071
+ " Max UR " + formatNumber(observation.critical_members[0].max_UR, 3) +
2072
+ ", drift " + formatNumber(observation.max_lateral_drift_ratio, 3) +
2073
+ ", mass " + Math.round(observation.total_steel_mass_kg).toLocaleString() + " kg."
2074
+ );
2075
+ }
2076
+
2077
+ function forceSummary() {
2078
+ const element = getSelectedElement();
2079
+ if (!element) {
2080
+ return "No member selected.";
2081
+ }
2082
+ if (!state.selectedElementForces) {
2083
+ return "Loading member forces...";
2084
+ }
2085
+ if (state.selectedElementForces.error) {
2086
+ return "Member forces unavailable: " + state.selectedElementForces.error;
2087
+ }
2088
+
2089
+ const forces = state.selectedElementForces.forces || {};
2090
+ return [
2091
+ "Element: " + element.id,
2092
+ "Section: " + (state.selectedElementForces.section || element.section || "wall"),
2093
+ "Length: " + formatNumber(state.selectedElementForces.length_m, 2) + " m",
2094
+ "N: " + formatNumber(forces.N_kN, 3) + " kN",
2095
+ "V: " + formatNumber(forces.V_kN, 3) + " kN",
2096
+ "Mmax: " + formatNumber(forces.M_max_kNm, 3) + " kNm",
2097
+ "Delta: " + formatNumber(forces.delta_max_mm, 3) + " mm"
2098
+ ].join("\n");
2099
+ }
2100
+
2101
+ function impactSummary() {
2102
+ const impact = state.selectedElementImpact;
2103
+ if (!impact) {
2104
+ return "Counterfactual impact will appear here.";
2105
+ }
2106
+ if (impact.error) {
2107
+ return "What-if failed: " + impact.error;
2108
+ }
2109
+ return [
2110
+ "Verdict: " + impact.verdict,
2111
+ "Current max UR: " + safeFormat(impact.current_max_UR, 4),
2112
+ "Without member: " + safeFormat(impact.counterfactual_max_UR, 4),
2113
+ "Delta UR: " + safeFormat(impact.delta_UR, 4)
2114
+ ].join(" | ");
2115
+ }
2116
+
2117
+ function actionSummary(action) {
2118
+ if (!action || !action.action_type) {
2119
+ return modeHint();
2120
+ }
2121
+ if (action.action_type === "place_column") {
2122
+ return "Column placed at (" + action.grid_x + ", " + action.grid_y + ") on floor " + action.floor + ".";
2123
+ }
2124
+ if (action.action_type === "place_beam") {
2125
+ return "Beam placed between (" + action.from_node_x + ", " + action.from_node_y + ") and (" + action.to_node_x + ", " + action.to_node_y + ").";
2126
+ }
2127
+ if (action.action_type === "add_wall") {
2128
+ return "Wall placed between (" + action.from_node_x + ", " + action.from_node_y + ") and (" + action.to_node_x + ", " + action.to_node_y + ").";
2129
+ }
2130
+ if (action.action_type === "remove_element") {
2131
+ return "Removed " + action.element_id + ".";
2132
+ }
2133
+ if (action.action_type === "upgrade_section") {
2134
+ return "Upgraded " + action.element_id + ".";
2135
+ }
2136
+ if (action.action_type === "downgrade_section") {
2137
+ return "Downgraded " + action.element_id + ".";
2138
+ }
2139
+ if (action.action_type === "done") {
2140
+ return "Final grading requested.";
2141
+ }
2142
+ return modeHint();
2143
+ }
2144
+
2145
+ function modeHint() {
2146
+ if (state.mode === "column") {
2147
+ return "Place Column mode: click any valid grid point on the current floor.";
2148
+ }
2149
+ if (state.mode === "beam") {
2150
+ return state.pendingPoint
2151
+ ? "Beam mode: choose the second endpoint on the same x or y line."
2152
+ : "Beam mode: click two existing columns on the current floor.";
2153
+ }
2154
+ if (state.mode === "wall") {
2155
+ return state.pendingPoint
2156
+ ? "Wall mode: choose the second endpoint on the same x or y line."
2157
+ : "Wall mode: click two existing columns to add a shear wall.";
2158
+ }
2159
+ return "Inspect mode: click rendered members to view forces, upgrade sections, remove them, or run what-if analysis.";
2160
+ }
2161
+
2162
+ function selectedField(label, value) {
2163
+ return (
2164
+ "<div class=\"selected-field\">" +
2165
+ "<span>" + escapeHtml(label) + "</span>" +
2166
+ "<strong>" + escapeHtml(value) + "</strong>" +
2167
+ "</div>"
2168
+ );
2169
+ }
2170
+
2171
+ async function api(url, options) {
2172
+ const request = Object.assign(
2173
+ {
2174
+ headers: { "Content-Type": "application/json" }
2175
+ },
2176
+ options || {}
2177
+ );
2178
+
2179
+ if (!request.body) {
2180
+ delete request.headers["Content-Type"];
2181
+ }
2182
+
2183
+ const response = await fetch(url, request);
2184
+ const text = await response.text();
2185
+ let payload = null;
2186
+
2187
+ if (text) {
2188
+ try {
2189
+ payload = JSON.parse(text);
2190
+ } catch (error) {
2191
+ payload = text;
2192
  }
2193
+ }
2194
+
2195
+ if (!response.ok) {
2196
+ if (payload && typeof payload === "object" && payload.detail) {
2197
+ throw new Error(typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2198
  }
2199
+ throw new Error(typeof payload === "string" ? payload : ("Request failed with status " + response.status));
2200
+ }
2201
+
2202
+ return payload;
2203
+ }
2204
+
2205
+ function formatNumber(value, digits) {
2206
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "-";
2207
+ }
2208
+
2209
+ function safeFormat(value, digits) {
2210
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "n/a";
2211
+ }
2212
+
2213
+ function escapeHtml(value) {
2214
+ return String(value)
2215
+ .replace(/&/g, "&amp;")
2216
+ .replace(/</g, "&lt;")
2217
+ .replace(/>/g, "&gt;")
2218
+ .replace(/"/g, "&quot;")
2219
+ .replace(/'/g, "&#39;");
2220
+ }
2221
+ </script>
2222
  </body>
2223
  </html>
tests/test_server_routes.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+
3
+ from server.app import app
4
+
5
+
6
+ client = TestClient(app)
7
+
8
+
9
+ def test_root_serves_interactive_demo():
10
+ response = client.get("/")
11
+
12
+ assert response.status_code == 200
13
+ assert "StructuralDesignEnv Interactive Demo" in response.text
14
+ assert "Site Grid" in response.text
15
+ assert 'id="gridSvg"' in response.text
16
+
17
+
18
+ def test_demo_alias_serves_interactive_demo():
19
+ response = client.get("/demo")
20
+
21
+ assert response.status_code == 200
22
+ assert "Element Inspector" in response.text
23
+ assert "Live Results" in response.text
24
+ assert "data-start-episode" in response.text
25
+
26
+
27
+ def test_tasks_endpoint_still_lists_registry():
28
+ response = client.get("/tasks")
29
+
30
+ assert response.status_code == 200
31
+ payload = response.json()
32
+ task_ids = {task["id"] for task in payload["tasks"]}
33
+ assert {"task1_warehouse", "task2_office", "task3_hospital"} <= task_ids