CoolFace
Apppublic

PatrickSchaeferling/spatial-timber-arcade

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
App README

SpatialTimber Arcade

Browser-Arcade-Spiel auf einem trainierten FEM-Surrogate. Der Spieler platziert Wandzellen auf einem mehrgeschossigen Holz-Tragwerk; nach jeder Aktion liefert das Surrogate-Netz innerhalb von ~50 ms die vorhergesagten Spannungen und Verformungen, die als Heatmaps gerendert werden. Ziel: möglichst hoher Score durch geschicktes Setzen aussteifender Wände bei minimalem Materialeinsatz.


1. Schnellstart

1.1 Voraussetzungen prüfen

KomponentePfad / Wert
Python-UmgebungC:\Users\Patrick\miniforge3\envs\torch_env\python.exe (gleiche Env wie das FEM_surrogate-Repo)
Trainiertes Modell..\FEM_surrogate\experiments\2026-02-24_RSE_CBAM_v1\best_model.pth
Output-ScalerC:\Users\Patrick\Downloads\26-01-30 U-Net\26-01-30 U-Net\augmented_shuffled_U-Net\y_scaler_pytorch_unet.pkl
Trainings-Shardsfür extract_levels.py und calibrate_score.py — werden in backend/config.py referenziert

Pfade werden zentral in `backend/config.py` aufgelöst. Wenn du etwas verschiebst, dort anpassen.

1.2 Erster Lauf (einmalig)

powershell
cd C:\Users\Patrick\source\repos\SpatialTimber_DesignExplorer\spatial_timber_arcade

# Python-Pakete installieren
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe -m pip install -r requirements.txt

# Level-Geometrien aus den Trainings-Shards extrahieren
# (erzeugt levels/<id>/footprint.npy + supports.npy in-distribution)
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe scripts\extract_levels.py

extract_levels.py schreibt auch levels/_preview.png als visuelle Sanity-Check-Vorschau aller Levels nebeneinander.

1.3 Server starten

powershell
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe -m uvicorn backend.main:app --host 127.0.0.1 --port 8765 --reload

Browser öffnen: <http://127.0.0.1:8765>

--reload lässt uvicorn auf Backend-Änderungen reagieren. Frontend-Dateien (frontend/*.html|css|js) werden vom Static-Mount serviert; Änderungen dort brauchen nur Strg+F5 im Browser (Cache umgehen).

1.4 Server stoppen

Im Terminal mit Strg+C. Falls der Prozess hängt:

powershell
Get-NetTCPConnection -LocalPort 8765 | Select OwningProcess
Stop-Process -Id <PID> -Force

1.5 Tests / Werkzeuge

powershell
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe tests\test_inference_roundtrip.py
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe tests\test_scoring.py
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe scripts\calibrate_score.py
C:\Users\Patrick\miniforge3\envs\torch_env\python.exe scripts\inspect_channels.py

2. Spielanleitung

2.1 Hauptbildschirm-Layout

┌────────────────────────────────────────────────────────────────┐
│ HEADER:  Level · Mode · Status · WALLS/UNDO/JOKER-Badges       │
├──────────┬───────────┬───────────────────┬─────────────────────┤
│ AUSN.    │ AUSN.     │   BAUEN           │ VERFORMUNG DECKEN   │
│ DECKEN   │ WÄNDE     │   (große Iso)     │   (Mini-Stack)      │
│ (Mini)   │ (Mini,    │                   │                     │
│          │  klickbar │                   │                     │
│          │  zum      │                   │                     │
│          │  Geschoss-│                   │                     │
│          │  wählen)  │                   │                     │
├──────────┼───────────┴───────────────────┼─────────────────────┤
│ TOOLS    │ UTIL-SCORE · BONUS · DISP     │ GAME OPTIONS        │
│ (WALLS,  │ (3 Karten)  +  TOTAL-SCORE    │ FINISH · RETRY      │
│  REMOVE, │ (gelb hervorgehoben)          │ QUIT · DEBUG        │
│  JOKER)  │                               │                     │
└──────────┴───────────────────────────────┴─────────────────────┘
  • AUSN. WÄNDE (Mini-Stack 2. Spalte): zeigt alle Geschosse als isometrische Stapel. Klick wählt das aktive Geschoss aus.
  • BAUEN (große Iso): zeigt das aktive Geschoss vergrößert. Hier wird per Maus gezeichnet. Wandkanten erscheinen orange beim Hover, blau wenn gesetzt, blau-fett wenn vom Joker vorgeschlagen.
  • AUSN. DECKEN und VERFORMUNG DECKEN (Mini-Stacks links/rechts): Heatmaps pro Decke. Aktualisieren live nach jeder Aktion (außer Hölle-Mode).
  • Score-Karten: UTILIZATION = 1 / max_stress, DISPLACEMENT = δ_lim / max_disp, BONUS = Belohnung für Restbudget (nur aktiv wenn beide Sub-Scores > 1). TOTAL = Util · Disp · Bonus · Difficulty.

2.2 Wände setzen — Edge-Click + Drag

  • Linksklick auf eine Kante zwischen zwei Footprint-Zellen: setzt eine Wand. Rechtsklick entfernt sie und kostet 1 Undo-Token.
  • Klick + Halten + Bewegen zeichnet eine Wandlinie. Beim ersten gesetzten Edge wird die Achse (X oder Y) und Reihe/Spalte gesperrt — du ziehst eine saubere gerade Linie, ohne dass die Maus exakt auf einer Achse bleiben muss.
  • Während des Drags wird kein Predict ausgelöst. Erst beim Loslassen geht ein Request an /api/predict mit dem kompletten neuen Wandsatz.

2.3 Hotkeys

TasteAktion
1–5Geschoss wechseln (oder Klick auf Mini-Stack links)
ZLetzte Wand zurück (kostet 1 Undo-Token)
JJoker: vom Modell-Gradient vorgeschlagene Platzierung
EnterFinish Level (öffnet End-Screen)
EscAbbrechen (zurück zum Start-Screen)
DDebug-Screen (alle 17 Roh-Kanäle visualisiert)

2.4 Joker-Mechanik

Joker-Knopf (Tools-Spalte) oder J schickt den aktuellen Bauzustand an /api/joker. Backend macht einen differenzierbaren Forward Pass mit allen Wand-Kanälen als requires_grad=True, leitet score.backward() durch und liefert das argmax über den Gradienten als nächste empfohlene Wand-Zelle (Geschoss, Orientierung, Cell). Frontend springt aufs richtige Geschoss und hebt die vorgeschlagene Kante visuell hervor. Du musst die vorgeschlagene Wand selbst setzen — der Joker entscheidet nur den Tipp, nicht die Aktion.

Joker sind nur im Normal-Mode verfügbar.

2.5 Schwierigkeitsmodi

ModeLive-Feedback während BauenJokerScore-Multiplier
Normaljaja× 1.0
Alptraumjanein× 1.5
Höllenein (Reveal beim Finish)nein× 2.5

Im Hölle-Mode bleibt die Heatmap statisch auf "???". Der /api/predict-Call wird während der Platzierung nicht ausgelöst — erst Finish triggert einen einzigen Predict mit voller Reveal-Animation und Score-Aufschlüsselung.


3. Score-Formel

Die Bewertung ist multiplikativ:

score_stress  = STRESS_LIMIT / max(max_stress, eps)    # 1.0 bei genau Limit
score_disp    = disp_ref     / max(max_disp,   eps)    # 1.0 bei genau Limit
bonus         = walls_unused·10 + jokers_left·50       # nur wenn beide > 1
total         = round(score_stress · score_disp · bonus)
final         = total · difficulty_multiplier

Charakteristik:

  • Sub-Scores ohne Hard-Cap: ein Tragwerk mit max_stress = 0.5 bekommt score_stress = 2.0 — das wird mit score_disp multipliziert, also lohnt sich Ausnutzung unter dem Limit überproportional.
  • Bonus aktiviert nur bei Erfolg auf beiden Achsen (stress > 1 und disp > 1). Wenn eines der Limits gerissen ist → bonus = 0total = 0. Das ergibt einen klaren Failure-Mode: ein knappes Versagen ist sofort sichtbar (Score springt auf 0).
  • Difficulty-Multiplier: Normal 1.0, Alptraum 1.5, Hölle 2.5.

disp_ref wird pro Level dynamisch gesetzt: bbox_height_in_cells · 0.625 / 300 (in Metern; entspricht L/300 als typisches SLS-Verformungslimit für die y-Spannweite). STRESS_LIMIT = 1.0 und alle Score-Konstanten in `backend/scoring.py`.


4. Architektur

spatial_timber_arcade/
├─ backend/
│  ├─ config.py        Pfade (Modell, Scaler, Shards) + Konstanten
│  ├─ inference.py     Modell-Wrapper: predict() (no_grad) + joker_gradient()
│  ├─ scoring.py       compute() (numpy) + compute_torch() (differenzierbar)
│  ├─ levels.py        LevelDef-Katalog + load_geometry() + Footprint-Zentrierung
│  ├─ schemas.py       Pydantic Request/Response-Modelle
│  ├─ db.py            SQLite-Scoreboard (CRUD)
│  └─ main.py          FastAPI-App + Endpunkte + Static-Mount
├─ frontend/
│  ├─ index.html       SPA-Shell (Header, 4-Spalten-Grid, End/Start/Scoreboard-Panels)
│  ├─ style.css        80er-CRT-Look (Neon, Pixel-Font, Scanlines, Score-Cards)
│  └─ game.js          Canvas-Renderer, State, API-Client, FX-System
├─ levels/
│  └─ <id>/footprint.npy  + supports.npy  (32×96 binär, in-distribution)
├─ scripts/
│  ├─ extract_levels.py   Generiert Level-NPYs aus den Trainings-Shards
│  ├─ inspect_channels.py 17-Kanal-Visualisierung eines Shard-Samples
│  └─ calibrate_score.py  Score-Verteilung über echte Samples (Histogramm)
├─ tests/
│  ├─ test_inference_roundtrip.py   Wrapper bit-identisch zur Original-Pipeline
│  └─ test_scoring.py               Unit-Tests für Score-Formel
├─ scores.db            SQLite-Datei (lokal, .gitignore)
├─ requirements.txt
└─ README.md            (diese Datei)

4.1 Datenfluss pro User-Aktion (Live-Mode)

  1. 1.Mausdrag im BAUEN-CanvasapplyDragAt(edge)placeWall(...) mit skipPredict=true. State (state.walls) wird aktualisiert, updateHud und redrawAll sofort lokal — kein Backend-Call.
  2. 2.MouseupendDrag() setzt state.pendingFx = {x, y} (für Sparks/Glow) und ruft callPredict().
  3. 3.`POST /api/predict` — Body enthält level_id, walls, difficulty.
  4. 4.Backend:
  5. 5.inference.build_input_tensor baut den 17-Kanal-Tensor (Decken- Kanäle aus Level + Wand-Kanäle aus Player-Walls), hängt GPS und Proximity an → 20 Kanäle.
  6. 6.predict() Forward Pass im no_grad, Output-Inverse-Transform mit dem geladenen Y-Scaler.
  7. 7._split_outputs trennt 22 Output-Kanäle in wall_stress (16), deck_stress (n+1), deck_displacement (n+1).
  8. 8.scoring.compute berechnet das ScoreBreakdown.
  9. 9.Response → Frontend:
  10. 10.state.lastResp = resp
  11. 11.triggerScoreFx(resp.score) spawnt Sparks (pro Score-Delta) und Glow (gelb/rot/cyan je nach Total-Delta) an der eingefrorenen Klick-Position.
  12. 12.updateHud() startet Tween-Animationen für die 4 Score-Zahlen.
  13. 13.redrawAll() zeichnet alle 4 Iso-Canvases neu.

4.2 Backend-Endpunkte

EndpointMethodeZweck
/api/levelsGETListe aller Levels mit Metadaten + Footprint/Supports (für Frontend-Rendering)
/api/predictPOST{level_id, walls, difficulty}{wall_stress, deck_stress, deck_disp, score}
/api/jokerPOST{level_id, walls, difficulty}{floor, orientation, cell:[r,c], gain}
/api/scores/{level_id}GETTop-N Highscores für ein Level (Modus-getrennt)
/api/scoresPOST{level_id, difficulty, name, score, walls} → speichert in scores.db
/api/debug/channelsPOSTLiefert PNG mit allen 17 Roh-Kanälen für aktuellen Bauzustand (Debug-Screen)
/GETStatic-Mount → frontend/index.html
/static/*GETStatic-Mount → frontend/{style.css, game.js}

5. Modell-Eingabe (Kanal-Layout)

20 Eingabe-Kanäle = 17 Roh + 2 GPS + 1 Proximity-Map. Die 17 Roh-Kanäle (verifiziert via inspect_channels.py):

KanalInhaltQuelle
0, 3, 6, 9, 12, 15Decken-Footprint (6× identisch — pro Geschoss eine)Level
1, 4, 7, 10, 13Wände X (EG, OG1..OG4)Spieler
2, 5, 8, 11, 14Wände Y (EG, OG1..OG4)Spieler
16AuflagerpunkteLevel

Wichtig: Wände existieren pro Geschoss × Orientierung in eigenen Kanälen — das ist nicht eine einzige binäre Maske, sondern 10 Wand-Kanäle (5 Geschosse × 2 Orientierungen). Die Game-UX (Floor-Switcher + X/Y-Edge-Pick) ist direkt diese Modell-Struktur.

GPS- und Proximity-Map-Berechnung übernimmt 1:1 den Code aus jenga_dataset.py:266-314 aus dem Trainings-Repo, damit Player-Inputs exakt in derselben Distribution liegen wie die Trainingsdaten.

Output: 22 Kanäle — Ch 0–15 sind Spannungen der 16 Bauteile (10 Wände

  • 6 Decken), Ch 16–21 sind Verformungen der 6 Decken. Räumlich 32×96 (Cell-Convention; eine Zelle = 0.625 m).

6. Levels

6.1 Aktueller Katalog

IDNameFootprintFloorsWallsUndoJokerTarget
01_einstiegEinstieg11×11350153500
02_komplexKomplex14×29360122600
03_marathonMarathon20×405120101700
04_pinwheelPinwheel8×83602010500
05_kompaktKompakt6×6310010020500

Definiert in `backend/levels.py` (CATALOG-Liste). Geometrien (Footprint + Auflager) liegen pro ID als NPY-Dateien unter levels/<id>/.

6.2 Eigenes Level anlegen

  1. 1.Geometrie erzeugen (in einer Python-Session mit der torch_env):
python
   import numpy as np, os
   d = r"spatial_timber_arcade\levels\06_meinlevel"
   os.makedirs(d, exist_ok=True)
   fp = np.zeros((32, 96), dtype=np.float32)
   sp = np.zeros((32, 96), dtype=np.float32)
   fp[1:7, 1:7] = 1.0                             # 6x6 Footprint
   for r, c in [(1,1),(1,7),(7,1),(7,7)]:         # Auflager an den Ecken
       sp[r, c] = 1.0
   np.save(os.path.join(d, "footprint.npy"), fp)
   np.save(os.path.join(d, "supports.npy"), sp)

Konventionen:

  • Footprint: 1.0 für Zellen, die zum Geschoss-Footprint gehören (cell-convention, Cell (r,c) belegt das Quadrat [r,r+1]×[c,c+1]).
  • Supports: 1.0 für Auflager-Punkte (vertex-convention, Punkt (r,c) ist die Ecke). Typischerweise an den 4 Außenecken des Footprint-Bbox: (rmin, cmin), (rmin, cmax+1), (rmax+1, cmin), (rmax+1, cmax+1).
  • Backend zentriert Footprint+Supports automatisch im 32×96 Grid (levels._center_in_grid), damit das Modell in-distribution arbeitet (es wurde auf zentriert platzierten Bauten trainiert).
  1. 1.`LevelDef` in `backend/levels.py` ergänzen:
python
   LevelDef(
       id="06_meinlevel",
       name="Meinlevel",
       description="6x6 Sandbox.",
       n_active_floors=3,    # 1..5
       cell_budget=100,
       undo_budget=100,
       joker_count=20,
       target_score=500,
   ),
  1. 1.Backend neu starten (uvicorn --reload reicht für reine Code- Änderungen; neu angelegte NPY-Dateien werden beim ersten /api/levels- Call gelesen und sind dann via lru_cache persistent).

6.3 Tipps für sinnvolle Level

  • In-distribution bleiben: Footprints sollten kompakt + zusammenhängend sein (das Trainings-Set besteht aus rechteckigen / wenig fragmentierten Grundrissen). Frei zerstreute Inseln liefern unzuverlässige Modell-Vorhersagen.
  • Auflager an Ecken oder mit Symmetrie: zentral platzierte Auflager (Pinwheel-Pattern, Eckpattern) sind häufig in den Trainingsdaten.
  • Footprint < 25×60: bleibt komfortabel im 32×96-Grid auch nach Zentrierung, ohne die Ränder zu touchieren.

7. Frontend-FX (Sparks · Glow · Tweens)

Alle Animationen laufen über einen einzigen requestAnimationFrame-Loop (fxStep) auf dem Vollbild-Canvas #fx-overlay. Die Loop startet selbständig wenn etwas zu animieren ist und beendet sich, sobald alle Listen leer sind (fx.sparks, fx.glows, fx.tweens).

7.1 Sparks

Bei jedem Predict-Resolve fliegen Funken von der eingefrorenen Klick-Position zu den vier Score-Karten (final, util, disp, bonus). Pro Score wird ein eigener Spawn ausgelöst:

  • Anzahl skaliert mit dem Betrag des Score-Deltas (siehe sparkCountFor — Total-Score /5, Util/Disp ×30, Bonus /5, Cap 100 pro Ziel).
  • Farbe = gelb (#fff060) bei positivem Delta, rot (#ff3a3a) bei negativem.
  • Trajektorie in zwei Phasen: (1) radialer Burst nach außen zu einem Kontrollpunkt, (2) Anflug Richtung Score-Karte. Smoothstep blendet zwischen beiden Phasen, damit kein sichtbarer Knick entsteht.
  • Burst-Reichweite wird per radiusScale mit |Δfinal|/200 skaliert — bei großem Delta fliegen die Funken weiter raus, bevor sie zum Score abdrehen.

7.2 Glow

Pro Predict-Resolve einmal — radialer Farbverlauf, der von der Klick-Position nach außen expandiert und ausklingt:

  • Farbe & Intensität koppeln an Δfinal: gelb wenn Score steigt, rot wenn er fällt, cyan-Baseline wenn unverändert (damit jede Aktion visuell quittiert wird).
  • Radius linear an Δ: bei Δ=200 ist der Peak-Radius doppelt so groß wie der Standard (260 → 520 px). Kein Hard-Cap.
  • Lebenszeit wächst leicht mit (600–1200 ms), damit größere Explosionen Zeit zum Ausklingen haben.
  • Alpha ist auf 1 geklemmt, damit nichts überstrahlt.

7.3 Score-Tweens

tweenNumber(elemId, from, to, fmt, dur) animiert die Anzeige der vier Score-Werte (#sb-score-num, #ss-util-value, #ss-disp-value, #ss-bonus-value) über 700 ms. Wichtig:

  • Jeder Aufruf entfernt vorab einen evtl. noch laufenden Tween für dieselbe ID — sonst überschreibt der alte rAF-Frame das frische textContent.
  • Wenn |to - from| < 0.001 wird ohne Tween direkt gesetzt.
  • Quelle ist fx.prevScore (alter Stand) → sc (neuer Stand). prevScore wird nach den Tween-Aufrufen aktualisiert, damit die Deltas auch in triggerScoreFx korrekt berechnet werden.

8. Troubleshooting

SymptomUrsache / Fix
Server startet nicht: ModuleNotFoundError: torchDu benutzt nicht die torch_env. Vollen Pfad zur python.exe aus miniforge3 verwenden.
Server startet, aber /api/predict 500: FileNotFoundError best_model.pthPfad in backend/config.py zeigt auf falsche Stelle. Modell vom FEM_surrogate-Repo an den erwarteten Ort kopieren oder Konstante anpassen.
Levels-Picker leer / 500 bei /api/levelsNPY-Dateien fehlen unter levels/<id>/. python scripts\extract_levels.py erneut ausführen.
Heatmap zeigt korrekt etwas Rotes, aber Score-Wert "klebt" an altem WertBrowser-Cache. Strg+F5 (kein normales F5 — der Cache muss umgangen werden).
Tween/Glow/Sparks bleiben nach erster Aktion stehenSollte mit aktuellem Code nicht mehr passieren. Falls doch → Browser-DevTools → Console: nach IndexSizeError oder anderen Throws suchen, die die rAF-Schleife abbrechen.
Rechtsklick + Drag → Browser navigiert wegBrowser-spezifisches Verhalten (Edge → Zurück-Geste mit Maus). Browser-Setting deaktivieren oder Linksklick mit Tools-Modus "Remove" verwenden.
Joker setzt aktive Wand-Orientierung festSollte gefixt sein — beide Orientierungen bleiben nach Joker zeichenbar. Falls nicht: state.activeOrient = "A" Zuweisung in callJoker checken.
Modell-Vorhersagen wirken "off" bei einem neuen LevelFootprint-Form ist out-of-distribution. Geometrie kompakter / rechteckiger gestalten. levels._center_in_grid zentriert automatisch — nutzt es, sieht sinnvoll aus.

9. Deployment auf Hugging Face Spaces

Das Repo enthaelt alles fuer ein Docker-basiertes HF Space:

  • Dockerfile (CPU-only Torch, Port 7860)
  • .gitattributes (Git LFS fuer models/*.pth + models/*.pkl)
  • YAML-Frontmatter in dieser README (sdk: docker, app_port: 7860)
  • backend/vendor/ — vendored model.py + scaler.py aus dem FEM_surrogate-Repo, damit der Container keinen externen Repo-Pfad braucht
  • backend/config.py — Pfade ueber Env-Vars MODEL_PATH / SCALER_PATH ueberschreibbar (Dockerfile setzt sie auf /app/models/*)

9.1 Lokal das Image testen

powershell
cd C:\Users\Patrick\source\repos\SpatialTimber_DesignExplorer\spatial_timber_arcade
docker build -t spatial-timber .
docker run -p 7860:7860 spatial-timber
# Browser: http://localhost:7860

Wenn das laeuft, ist HF nur noch ein git push.

9.2 Auf HF pushen

  1. 1.Space anlegen: huggingface.co → New Space → SDK: Docker → Name z.B. spatial-timber-arcade.
  2. 2.LFS lokal aktivieren (einmalig):
powershell
   git lfs install
  1. 1.Remote hinzufuegen + pushen:
powershell
   git init                      # falls noch nicht
   git add .gitattributes        # WICHTIG: vor den LFS-Dateien adden
   git add .
   git commit -m "Initial HF deploy"
   git remote add hf https://huggingface.co/spaces/<dein-user>/spatial-timber-arcade
   git push hf main
  1. 1.Beim ersten Push fragt's nach HF-Token (huggingface.co/settings/tokens, Scope: "write").

HF baut das Image automatisch (~3-5 min beim Erststart) und startet den Container. Logs sind im Space-UI unter "Logs".

9.3 Hardware-Auswahl

Im Space-UI unter Settings → Hardware:

TierKostenPredict-LatenzEmpfehlung
CPU basicgratis0.4–1 sStart hier — reicht funktional
CPU upgrade~$0.03/h0.3–0.6 sminimal schneller, kaum lohnenswert
T4 small (GPU)~$0.40/h30–80 mswenn fluessiges Spielen gefragt

9.4 Limitationen Free Tier

  • `scores.db` ist fluechtig — verschwindet bei Container-Restart. Optionen: Persistent Storage zubuchen (~$5/Monat) oder auf externe DB (z.B. Supabase) auslagern.
  • Sleep nach ~48 h Idle — Cold-Start beim naechsten Aufruf ~30 s.
  • Space ist public — privater Space kostet.

10. Was als nächstes ansteht

  • Polish + Score-Konstanten nach 2. Spieltest tunen (backend/scoring.py: BONUS_PER_WALL, BONUS_PER_JOKER, evtl. STRESS_LIMIT-Skalierung).
  • Optional: Sound-Effekte, Mobile-Touch-Support, Multi-Language.

Out-of-scope (ausdrücklich nicht geplant): Authentifizierung, Account-System, Live-Multiplayer, In-Game-Level-Editor.