commit b2e6443c68c19f0a7bb6bbaf934de074b8c33644 Author: Dominic Gundel Date: Thu Oct 15 08:45:56 2026 +0200 Grotti 2.0.0: eigenes Repo, Umbenennung von Farm. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a2c3282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Zugangsdaten und Dateiablage bleiben lokal +config.json +data/ + +.venv/ +__pycache__/ +*.pyc +_tmp_* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..35a84b1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Grotti — Hinweise für Agenten + +Grotti ist ein eigenständiges Repo. Alles, was das Tool betrifft, bleibt hier. + +- Sprache: Deutsch, in Oberfläche, Meldungen, Doku und Commits. +- Jede Änderung bekommt einen Eintrag in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben). +- Version in `grotti/version.py` nach SemVer: Patch für Fehlerbehebung, Minor für Funktion, Major für Bruch an API oder Datenbankschema. Beim Release `[Unreleased]` in `[x.y.z] – Datum` umbenennen. Git-Tag `vX.Y.Z`. +- Technische Doku liegt in `docs/` und wird unter `/doku/` gerendert. Neue Erkenntnisse zum Tool dort ergänzen. +- CSS-Version in `web/templates/layout.html` (`grotti.css?v=`) erhöhen, wenn sich `grotti.css` ändert. +- Keine freie G-code-Konsole, kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Druckstarts brauchen `confirm: true`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c3019b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,145 @@ +# Changelog + +Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionen nach [SemVer](https://semver.org/lang/de/). + +## [2.0.0] – 2026-10-15 + +### Neu + +- Einstellungen: **FQDN** (öffentliche Basis-URL) für QR-Codes auf Spulen-Etiketten; leer = Request-Host. + +### Geändert + +- Umbenennung Farm → **Grotti**, eigenes Repo. +- Python-Paket `farm/` → `grotti/`, Ausnahme `GrottiError`, Standard-Datenbank `grotti`. +- Oberfläche, CSS (`grotti.css`), G-code-Viewer und `localStorage`-Schlüssel (mit Fallback von `farm-display`). +- Doku-URLs ohne `Farm-`-Präfix (`/doku/Abraeumen`, …). +- Git-Tags `vX.Y.Z` statt `farm-vX.Y.Z`. + +### Behoben + +- Schieben: nach hinten nicht mehr mit `G0 Z2 F3000` (Vollgas) senken — langsam `F400`; vor dem Senken Z ≥ ~15 mm prüfen. +- Abkühlen: Slice-Ende hebt Z nicht — Park relativ **Z+40**, gekappt gegen `toolhead.axis_maximum` Z. Hub wird geprüft; sonst Abbruch vor Schieben. + +## [1.4.0] – 2026-10-15 + +### Neu + +- Anzeige **Desktop / Regal (Touch)** in Einstellungen (nur dieser Browser, `localStorage`); Shortcut `?shelf=1`. +- Seitenmenü einklappbar (Icon-Rail). +- Bibliothek: G-code-Footer-Metadaten (`slice_meta`: Schicht, Düse, Temps, …); Chips + Info-Popup. +- Warteschlange zeigt Direktstarts unter „Läuft“ (wie Sidebar „Läuft jetzt“). +- Cockpit: Bauraumtemperatur und zusätzliche Lüfter wenn Moonraker sie liefert; Icons für Düse/Bett/Lüfter. +- Ablauf: Three.js-Draufsicht der Schiebe-Bahn. +- Seitenfuß: © by dominic@gundel.dev. +- Interne Notizen zu Markt, Recht und Homepage unter `intern/` (nicht unter `/doku`). +- Doku: Voraussetzungen (Moonraker, Kobra 3 und Kobra S1) und Haftung (Entwurf). +- Statische Homepage-Vorschau `site/index.html`. Die Wand unter `/` bleibt. +- Doku Abräumen: Slice-Ende Kobra 3 Present `X250 Y220` + `M84`; Farm entfernt `M84` bei Ablauf mit Cool/Sweep. + +### Geändert + +- Cockpit ohne leere Kamera-Fläche wenn Kamera zu oder fehlt (`cam-collapsed`, eine Spalte). +- Schieben: Default/Limit **Y hinten 250**; Tasten-Grenze vorübergehend **3,0 mm** (Düsen-Klumpen). + +### Behoben + +- Druckwarteschlange startete nur **einen** Drucker pro Tick (~8 s). Mehrere freie Drucker starten jetzt im selben Durchlauf parallel (Upload je Host im Worker). +- Nach Druckstart kurz „Platte belegt“: `needs_clear` wurde schon beim Start gesetzt. Jetzt erst bei Druckende (Watcher); Streifen bleibt während Heizen/Queue ausgeblendet. +- Abräumen robust: `M84` aus Upload streichen; Idle während Druck/Cool; nach M84 `SET_KINEMATIC` nur mit echter XYZ; Schieben erst Y hinten dann Z; kein Extra-Mitten-Pass; Tasten mit Mesh/`Result is z=` (keine Fake-Z). +- Abkühlen-Park: Retry + Meldung; Anzeige zeigt wieder Warte-Temps (nicht nur „Mitte“). +- Druckstart: Worker weg → erneut starten; kein Jump auf Cool bei stale `complete`; kurzes Moonraker-`cancelled` beim LeviQ zählt nicht; `keep_motors`/`row`-Bug behoben. + +## [1.3.0] – 2026-10-14 + +### Neu + +- Cockpit: **Objekte ausschließen** (Klipper Exclude-Object) — Polygone im 3D-Pfad, Liste, Zwei-Klick → `EXCLUDE_OBJECT` (Whitelist, nur printing/paused). Ohne `[exclude_object]` oder DEFINEs bleibt das Panel aus. +- Seite **Warteschlange**: wartend, laufend, Fehler und Historie; Priorität und Abbrechen. +- Automatik-Schalter und Drucker „gesperrt“ (Einstellungen): kein neuer „wenn frei“-Start. +- Spulen an den Ort: Unique-Platz je Drucker, Auflösung beim Start, Material-Prüfung Datei vs. Rolle, „Einlegen“/„Ins Regal“, Material-Liste nach Ort. +- Senden zeigt eingelegte Rollen je Ziel-Drucker statt freiem Spulen-Dropdown; Projektwahl im Flow. +- `ace_map` und Bettnetz in `sends` für „Nochmal“; Protokoll-Select sortiert nach Drucker. +- three.js lokal; Toolpath-Cache unter `data/toolpath/`; Tests unter `tests/`. +- Watcher: Protokoll-Abgleich und Spulenabzug nach „fertig“; ACE-Abzug anteilig je Platz. +- Seitenleiste „Aufgaben“: Drucker mit belegter Platte, die kein Ablauf räumt, mit Knopf „geräumt“ (Zwei-Klick). `GET /api/v1/aufgaben`. +- Gestoppter Ablauf lässt sich in der Seitenleiste ab dem Schritt fortsetzen, an dem er stand (▶). +- Druckerkarte zeigt einen gestoppten Ablauf als roten Streifen mit „Weiter ab …“ und „Verwerfen“. + +### Geändert + +- 3D-Pfad: freieres Orbit, Zoom delta-basiert (Chrome), Schwenken/Pinch, Doppelklick-Reset; gedruckt leicht verdickt, Rest transparent; Fortschritt nach Datei-`progress`; Toolpath `version` 5 (segmenttreu, `E.39`/`Z.6`); Bettgitter 10 mm mit mm-Sprites und Legende. +- UI-Polish: konsistentes Icon-Set (Lucide-Stil), kürzere Microcopy, einheitliche Seitenköpfe/Buttons, kompakte Tabellen, Formular-Feldraster, Empty-States, ruhigere Sidebar und Mobile-Nav. +- **Material**: Reiter Spulen / Sorten. Spulen-Liste ohne dauerhaft offenes Formular. Sorten CRUD per API. Ort zeigt Drucker + ACE/extern. Scan im Seitenkopf. +- Bestätigungen überall Zwei-Klick (`armButton`), Rückmeldungen über Toast. Wortwahl „offline“ / „Auslastung“ vereinheitlicht. +- Ablauf-Standard: Drucken → Abkühlen → Schieben → **Tasten** (Mesh aus, relativ +5 mm, `PROBE` Mitte, kein `G28 Z`, danach Lift `z_clear`). +- Drucker-Details als Cockpit: Kamera, Status, Steuerung, 3D-Pfad, Bettnetz; zuklappbar (`farm-desk-closed`). + +### Behoben + +- Cockpit: Kamera-Polling ließ `no-cam` und Layout ruckeln; 3D-Orbit sprang zurück. Kamera lädt vor und behält das letzte Bild; Pfad setzt die Ansicht nur beim ersten Laden zurück. +- Toolpath-Parser ignorierte `E.39`/`Z.6` ohne führende Null — Extrusion/Infill fehlten fast komplett. +- Ablauf hing nach Farm-Neustart bei „tastet Mitte…“ / „räumt ab…“: Worker fehlte, Phase blieb stehen — Schieben/Tasten starten erneut. +- Kameras-Seite holte `live=0` und zeigte alle Drucker als „weg“. +- Steuerung setzte Flow immer auf 100 %; jetzt `extrude_factor` vom Drucker. +- E-Stop nur noch bei aktivem Druck, Zwei-Klick, Hinweis auf Strom aus/an. +- Ablauf: Phase `starting` vor Upload, Timeout wenn der Druck nicht anläuft; lange Schritte in Worker-Threads. +- „Nächster freier“ übernahm die ACE-Map eines anderen Druckers. +- Drucker Host ändern/löschen zieht Queue, Ereignisse und `bed_status` mit. +- Live-Zustand kommt vom Watcher (eine Quelle). +- Summary „frei“ ignorierte belegte Platte; Queue-Status von außen nicht mehr frei auf `done` setzbar. +- „Läuft jetzt“ zeigte nur Queue-Aufträge; Direktstarts stehen mit Fortschritt dabei. +- Abkühlen: Idle-Timeout / `SET_KINEMATIC_POSITION`, Anzeige mit Zielwerten, Düse zur Mitte, `M107` nach Schieben. +- MQTT-Druckstart-Timeout vs. `info/query` taskid; Prüfen/Timeouts/Bettnetz; Karten- und Sidebar-Randfälle. + +## [1.1.0] – 2026-10-13 + +### Neu + +- Live-Leiste in der Sidebar: „Läuft jetzt“, „Wartet“, „Alerts“ und „Senden“ mit Zählern, scrollbar, immer sichtbar. +- Druckerkarte zeigt laufenden Ablauf mit Schrittleiste (Drucken, Abkühlen, Schieben, Prüfen), Wiederholung und Abbrechen; wartende Jobs je Drucker. +- Seite **Aktivität**: alle Ereignisse als Zeitstrahl, Filter nach Drucker, Art und Zeitraum. +- Ereignisse für jeden Ablauf-Schritt, jede Wiederholung, geräumte Fehler und Abbrüche (Art `ablauf`). Telegram dafür abschaltbar (Standard aus). +- Senden als Flow: Datei, Ziel (Drucker-Chips mit Live-Zustand), Start (Jetzt, Wenn frei, Nächster freier) mit Zwei-Klick-Bestätigung. Ablauf als eigener Reiter. +- Version im Sidebar-Fuß, `GET /api/v1/version`, dieser Changelog und die Technik-Doku unter `/doku`. +- Designsystem: Zustandsfarben, Chips, Schrittleiste, Kacheln, Temperaturbalken, Hinweise, Toasts. +- Ereignisse auch für Druckstart, Pause, Fortsetzen (Art `start`), Abbruch (Art `abbruch`), „wieder erreichbar“, Einreihen und Abbrechen von Hand. +- `GET /api/v1/ereignisse` filtert nach `host`, `kind` (kommagetrennt) und `since`. +- Dateien hochladen per Ziehen auf die Bibliothek. +- Handbuch unter `/doku` mit Inhaltsverzeichnis; `README.md` für Einrichtung und API. + +### Geändert + +- Druckerkarte neu geordnet: Fertig-Uhrzeit groß, Fortschritt mit Prozent, drei Kacheln statt Zahlenzeile, IP und Mainsail im Detailteil. +- Technik-Doku liegt jetzt im Tool unter `docs/`; das Tool ist vom übrigen Repo unabhängig. +- Alerts in der Sidebar verlinken auf die Aktivität. Ablauf-Schritte und Starts zählen nicht als Alert (gleich als gelesen gespeichert). +- Telegram-Schalter „Ablauf-Schritte“ und „Druckstart und Pause“ in den Einstellungen; Abbrüche laufen unter „Fehler“. +- Die Queue räumt einen hängenden Fehler nur noch, wenn für den Drucker wirklich ein Auftrag wartet. +- Auf dem Handy wird „Wartet“ ausgeblendet, solange nichts wartet. + +### Behoben + +- Queue und Ablauf waren in der Sidebar unter dem Bildschirmrand abgeschnitten. +- Ereignisse gingen durch die 30-s-Entprellung verloren; entprellt wird nur noch Telegram. +- Leeres `homed_axes` im Leerlauf galt als Fehler und blockierte Abläufe; hängender `print_stats=error` wird vor dem Start geräumt. + +## [1.0.0] – 2026-10-13 + +Erster festgehaltener Stand. + +### Neu + +- Druckerwand mit Live-Zustand über Moonraker, Kamera, Bettnetz, 3D-Pfad und leichter Steuerung (Temperatur, Speed, Flow, Lüfter, E-Stop). +- Dateien: Bibliothek mit Suche und Filter, Upload, Ordnerüberwachung, Metadaten aus G-code und 3MF. +- Senden im Hintergrund mit Fortschritt an mehrere Drucker, ACE-Zuordnung mit `.acm`, Bettnetz vor dem Druck per Anycubic-MQTT. +- Warteschlange „wenn frei“ mit Priorität; Ablauf für Kobra 3 (Drucken, Abkühlen, Schieben, Prüfen, mehrfach). +- „Platte geräumt“ als Quittierung, die „wenn frei“ freigibt. +- Material, Spulen, ACE-Plätze, Etiketten; Projekte mit Kalkulation; Protokoll mit Spulenabzug und „Nochmal“. +- Einstellungen: Druckernamen, Telegram-Alerts, Energie. + +### Behoben + +- Hängender Fehlerzustand: „Fehler löschen“ statt Firmware-Restart (GoKlipper deadlockt bei `FIRMWARE_RESTART`). +- Heizung aus nach Druckfehler. +- Rinkhals-Kopfzeile vor dem Upload, damit die Kobra die Datei nicht doppelt liest. +- Nach Abbruch zusätzlich MQTT-Stop, sonst blieb der Anycubic-Task aktiv (10101). diff --git a/README.md b/README.md new file mode 100644 index 0000000..c18917a --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Grotti + +Steuert die 3D-Drucker im Haus von einer Weboberfläche aus: Live-Zustand, Dateien senden, Warteschlange, Abläufe für den Kobra 3 (drucken, abkühlen, abschieben, prüfen), Material, Projekte und ein Protokoll, was wann passiert ist. + +Der Name kommt von der Mühle Grotti: sie mahlt ohne Pause, was man ihr aufträgt. + +Die Drucker laufen mit Rinkhals (Kobra 3, Kobra S1). Grotti spricht sie über Moonraker an. + +Aktuelle Version: siehe `grotti/version.py`, Änderungen in [CHANGELOG.md](CHANGELOG.md). + +## Voraussetzungen + +- Python 3.11 +- PostgreSQL (eine leere Datenbank `grotti` reicht, das Schema legt Grotti selbst an) +- Drucker im selben Netz mit Moonraker auf Port 80 + +## Einrichten + +```powershell +cd D:\Projekte\Grotti +python -m venv .venv +.\.venv\Scripts\pip install -r requirements.txt +copy config.example.json config.json +``` + +`config.json` anpassen: + +| Schlüssel | Bedeutung | +| --- | --- | +| `host`, `port` | Wo die Oberfläche lauscht. Standard `127.0.0.1:8770`. | +| `database_url` | PostgreSQL-Verbindung. | +| `data_dir` | Uploads, Vorschaubilder, Cache. Standard `data`. | +| `watch_dir` | Slice-Ordner, der beobachtet wird. Leer = nur Upload. | +| `token` | Wenn gesetzt, braucht die API `Authorization: Bearer `. | +| `fqdn` | Öffentliche Basis-URL für Spulen-Etiketten-QR. Leer = Request-Host. | +| `energy` | Watt und Strompreis für die Kosten im Protokoll. | +| `telegram`, `alerts` | Bot und welche Ereignisse als Nachricht kommen. | +| `auto_queue` | Wenn `false`, startet die Warteschlange nichts Neues. | + +Die Drucker stehen beim ersten Start in `drucker.json` und landen dann in der Datenbank. Danach werden sie unter Einstellungen gepflegt. + +```json +[{ "name": "Kobra 3 da26", "host": "10.10.1.60" }] +``` + +Die Familie (Kobra 3 oder S1) liest Grotti aus dem Namen. + +## Starten + +```powershell +.\.venv\Scripts\python.exe server.py +``` + +Dann öffnen. Handbuch in der Oberfläche unter **Doku**. + +`config.json` und `data/` sind lokal und nicht im Git. + +## Ordner + +| Pfad | Inhalt | +| --- | --- | +| `server.py` | Start, Datenbank vorbereiten, Hintergrund-Threads | +| `grotti/` | Kern: Moonraker, Warteschlange (`print_queue.py`), Senden (`send_queue.py`), Abräumen (`eject.py`), Zustandswächter (`watcher.py`), Ereignisse und Telegram (`notify.py`), Datenbank (`store.py`, `schema.sql`) | +| `web/` | Flask-App (`app.py`), Seiten (`templates/`), CSS und JS (`static/`) | +| `docs/` | Technische Notizen, gerendert unter `/doku/` | +| `CHANGELOG.md` | Änderungen je Version | + +## API + +Alles unter `/api/v1`, JSON. Die wichtigsten Endpunkte: + +| Methode und Pfad | Zweck | +| --- | --- | +| `GET /version` | Version | +| `GET /drucker?live=1` | Drucker mit Live-Zustand, laufendem und wartendem Auftrag | +| `POST /drucker//` | Pause, Fortsetzen, Abbrechen, Fehler löschen | +| `POST /drucker//steuern` | Temperatur, Speed, Flow, Lüfter | +| `GET /dateien`, `POST /dateien` | Bibliothek, Upload | +| `POST /senden` | Datei an Drucker. `confirm: true` ist Pflicht. `when_free: true` reiht ein. | +| `GET /druck-queue`, `PATCH /druck-queue/` | Warteschlange, Abbrechen (`status: cancelled`), Ablauf fortsetzen (`resume`) | +| `POST /spulen//einlegen` | Spule an Drucker/Platz; alte Rolle dort ins Regal | +| `POST /ablauf` | Ablauf für Kobra 3 einreihen | +| `GET /ereignisse?host=&kind=&since=&limit=` | Aktivität | +| `POST /ereignisse/gelesen` | Alerts als gelesen | +| `GET /spulen`, `GET /laeufe`, `GET /projekte` | Material, Protokoll, Projekte | + +## Sicherheit + +- Druckstarts brauchen `confirm: true`. Die Oberfläche fragt per Zwei-Klick. +- Keine freie G-code-Konsole. +- Kein `FIRMWARE_RESTART`, GoKlipper hängt sich dabei auf. Fehler werden über „Fehler löschen“ geräumt. + +## Versionen und Changelog + +- Versionierung nach [SemVer](https://semver.org/lang/de/): Patch für Fehlerbehebung, Minor für neue Funktion, Major für Bruch an API oder Datenbankschema. +- Jede Änderung kommt in `CHANGELOG.md` unter `[Unreleased]` (Neu, Geändert, Behoben). +- Beim Release: Version in `grotti/version.py` setzen, `[Unreleased]` in `[x.y.z] – Datum` umbenennen, Git-Tag `vX.Y.Z`. +- Ändert sich `web/static/grotti.css` oder `api.js`, in `layout.html` den `?v=` hochzählen. + +## Repo + +Eigenes Repository: . Doku zum Tool gehört nach `docs/`. diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..bb22a94 --- /dev/null +++ b/config.example.json @@ -0,0 +1,31 @@ +{ + "host": "127.0.0.1", + "port": 8770, + "data_dir": "data", + "watch_dir": "", + "database_url": "postgresql://postgres@127.0.0.1:5432/grotti", + "token": "", + "fqdn": "", + "auto_queue": true, + "energy": { + "watts": 120, + "euro_per_kwh": 0.35, + "dryer_watts": 0, + "dry_hours": 0, + "waste_percent": 0, + "utilization": 50 + }, + "telegram": { + "enabled": false, + "bot_token": "", + "chat_id": "" + }, + "alerts": { + "fertig": true, + "fehler": true, + "offline": true, + "queue": true, + "ablauf": false, + "start": false + } +} diff --git a/docs/ACE-Live.md b/docs/ACE-Live.md new file mode 100644 index 0000000..882523c --- /dev/null +++ b/docs/ACE-Live.md @@ -0,0 +1,42 @@ +# ACE-Farben in Grotti (Moonraker) + +Stand **12.10.2026**: An Kobra 3 **da26** (`10.10.1.60`) liefert Moonraker die ACE-Spulenfarben unter **`mmu`**, nicht unter `filament_hub` (steht oft nicht in `objects/list`). + +- Relevante Felder: `gate_color` (Hex `AARRGGBB` oder `RRGGBB`), `gate_material`, optional `gate_status` bzw. Listen pro Platz. +- Grotti liest das in `grotti/moonraker.py` (`extract_slots`) und zeigt die Farben als Swatches auf der Druckerwand. +- Fallback: `filament_hub` / `filament_hubs[].slots` mit `color` und `type`, falls vorhanden. + +## Senden: welches Material welcher Slot + +12.10.2026: In `Dateien` bei Druckern mit Slots die Slice-Farbe (`paint_index`) einem ACE-Platz (`ams_index`, 0-basiert) zuordnen. Grotti schreibt `Dateiname.gcode.acm` und lädt sie vor `print/start` hoch. Ohne Sidecar: auf `da26` oft HTTP 400 „cannot parse the file“. Siehe [Grotti-Senden-Queue](Grotti-Senden-Queue). + +## 12.10. Abbruch `tulip pla.gcode` auf da26 — kein ACE-Blink + +Beobachtung: Material ging initial zum Extruder, ACE-Slot blinkte nicht, kein Nachschub vom ACE. Andere Kobra 3 (92ee/6e41) drucken dieselbe Datei von der externen Spule weiter; nicht angefasst. + +Ursache (Moonraker-Log `da26`, lesend): + +- Job `000009` (tot-printing, 0 mm Filament) und `00000A` (~695 s Druck, ~658 mm) beide `cancelled` auf `10.10.1.60`. +- Bei jedem MQTT-Start: `mmu_ace.patch_print_data` → `detected_tools unknown … skipping auto-feed (no trailer found)`. Ohne Trailer/Tools kein Auto-Feed → kein Slot-Blinken, kein ACE-Nachschub. +- Metadaten-Extrakt für die Datei scheitert (`Unable to extract metadata` / `mmu_ace_metadata` abgebrochen). +- Grotti-Sidecar `tulip pla.gcode.acm`: `paint_index 0 → ams_index 1` (Farbe Datei `#000000`, Slot-Farbe `002B49`). Parallel liegt `tulip pla.acm` mit Identity-Map T0–T3 → Plätze 0–3. MQTT-Payload nutzte die 4er-Map, nicht Grotti-1er-Map. +- Start ohne `bed_leveling`: Grotti übergibt `ams_settings` nur als `.acm`; Moonraker baut die MQTT-`ams_settings` selbst. `auto_leveling: 0`. +- Initialer Extruder-Feed ohne Blink: Restfilament im Pfad / Extrusion aus dem Schlauch, nicht ACE-Auswahl. Nach Abbruch: `mmu.gate`/`tool` = −1, `active_filament` leer; vier Plätze weiter belegt. + +Grotti-DB `sends`: da26 zweimal `tulip pla.gcode` (ohne persistiertes `ace_map`; Mapping nur in der Queue im Speicher und in der `.acm` auf dem Drucker). Queue-API leer nach Abschluss. + +Nächster Start auf da26: gewünschten ACE-Platz in Grotti setzen (bei schwarzer Slice-Farbe zuletzt Platz 1 = dunkelblau). Alte `tulip pla.acm` nicht mit Grotti-`.gcode.acm` vermischen. Ohne erfolgreichen Trailer/Metadaten bleibt Auto-Feed aus — dann blinkt der ACE weiterhin nicht. + +## Fix Grotti (12.10.2026, nach Diagnose) + +Anycubic erwartet die Sidecar als **gleichen Basename + `.acm`**: Start `tulip pla.gcode` → `tulip pla.gcode.acm`. Grotti lädt Grotti-Map zusätzlich nach `tulip pla.acm` hoch (Überschreiben), damit eine alte Identity-Map nicht gewinnt. + +Mit gesetzter ACE-Zuordnung startet Grotti den Druck per **MQTT** und setzt `ams_box_mapping` aus Grotti-Map — nicht mehr nur über Moonraker-`print/start` (dort Identity-Fallback / falsche `.acm`). Bettnetz-Checkbox steuert weiter nur `auto_leveling`. + +13.10.2026: Fehlt `; referenced_tools` beim Senden mit ACE-Zuordnung, hängt Grotti ihn an — aus T-Befehlen, sonst aus `paint_info` / der gewählten `paint_index`-Map (Einzelfarbe oft `0`). Zusätzlich die Rinkhals-Kopfzeile `; processed by MmuAcePatcher`. Warnung nur noch, wenn danach immer noch kein Trailer da ist. + +## 13.10.2026 — MQTT 10101 trotz Moonraker `cancelled` (da26 / Gridfinity) + +Nach Grotti-Send `Gridfinity_bin_1x1x6u_PLA_0.2_49m32s.gcode` an `da26` (`10.10.1.60`): Moonraker `print_stats.state` = `cancelled`, Anycubic-MQTT-Task blieb aktiv → nachfolgender Start mit **code 10101** („已存在打印任务“ / Print task already exists). Grotti `abbrechen` traf nur Moonraker `/printer/print/cancel`, nicht den Anycubic-Task. + +Klärung: MQTT `action: stop`, `taskid: "-1"` auf Topic `…/slicer/printer/{modelId}/{deviceId}/print` (Rinkhals-Docs). Report `state: stopping`, code 200; danach MQTT-Info `data.state: free`. Grotti `abbrechen` macht seither Moonraker-cancel **und** best-effort MQTT-stop (`anycubic_lan.cancel_print`). Wenn MQTT-stop nicht greift: Display abbrechen oder Reboot als letzte Option. diff --git a/docs/Abraeumen.md b/docs/Abraeumen.md new file mode 100644 index 0000000..01caf41 --- /dev/null +++ b/docs/Abraeumen.md @@ -0,0 +1,81 @@ +# Grotti: Abräumen und weiterdrucken (Kobra 3) + +Stand **13.10.2026**. + +Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die Teile mit dem Extruderkopf nach vorn vom Bett schieben. Danach startet der nächste Schritt oder die nächste Wiederholung. Die **Kobra S1** ist geschlossen — Abräumen dort ist noch offen. + +## Quittierung vs. Schieben + +- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker//abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher bei Druckende/Abbruch/Fehler (nicht schon beim Start). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht. +- **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert. +- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren. + +## Oberfläche + +Unter **Dateien** erscheint die Karte **Ablauf**, sobald mindestens eine Kobra 3 gewählt ist. Der Nutzer baut die Schritte selbst: + +- **Drucken** — Datei, Bettnetz (LeviQ) standardmäßig an +- **Abkühlen** — Heizung aus, Lüfter an, Düse in die Bettmitte, warten bis Bett/Düse unter den Grenzen +- **Schieben** — Bahn mit einstellbaren Zahlen, danach Lüfter aus +- **Tasten** — kein `G28 Z`. **Mit aktivem LeviQ-Netz** (kein `BED_MESH_CLEAR`): `Result` = Höhe über der lokalen Platte. Bezugspunkt + Tastpunkt (x/y) einstellbar. Maß: letztes `Result is z=` aus dem Store. Über 1 mm = Restteil. + +Darunter **wie oft hintereinander**. Einreihen legt einen Eintrag in die Warteschlange „Wenn frei“. **Schieben testen** fährt die Bahn auf einer freien Kobra 3 (Bestätigung „Bett ist leer“). Werte merkt sich der Browser lokal. + +Vor dem Bett braucht es eine Kiste. Der Weg nach vorn muss frei sein. + +Standardreihenfolge: `Drucken → Abkühlen → Schieben → Tasten` (dann Wiederholung, LeviQ am Druckstart). Altes 3×3-Prüfen (`probe_count` / `mesh_*`) wird übersprungen und in der Schrittleiste nicht mehr gezeigt. + +## Standardbahn (editierbar) + +- Abkühlen: Heizung aus, Idle verlängern (Stepper an), Park Bettmitte. Z zuerst relativ **+40 mm** (gegen Z-max gekappt), dann X125/Y125. Hub wird geprüft. +- Schieben: hoch → Y hinten (`y_from` Default **250**) → Z noch klar? → **langsam** auf `z_push` (`F400`, nie F3000). Z prüfen; bei Abweichung Abbruch. Nur die X-Spuren aus der Config. `M107`. +- Tasten: Mesh an. Absolut auf Freiraum, ein `PROBE` am Tastpunkt, `Result is z=`. Kein `SET Z=100` / Bezug-Null (hat die Koordinate nur verfälscht). Z zu tief nach Leerlauf → Abbruch, Idle soll Stepper an lassen. + +Y klein ist vorn. Das Wischpad liegt hinten (~Y 260) und bleibt außerhalb der Bahn. Grotti klemmt Eingaben (X 0–250, Y 0–235, Z-Schieben 0,6–30, Z-Umsetzen bis 80, Tempo 5–80 mm/s). + +## Technik + +- Kein Makro auf dem Drucker (`printer.cfg` schreibt Rinkhals neu). Grotti schickt G-code per Moonraker `/printer/gcode/script`. +- Modul `grotti/eject.py`, Steuerung in `print_queue.py`. +- Ablauf = eine Queue-Zeile mit `steps` (JSON), `step_index`, `repeat_total`, `repeat_done`, `phase`. +- Nach dem Druck kein `G28` (Z-Home würde in die Teile fahren). Slice-Ende Present nur `X250 Y220` — **kein Z-Hub**. Abkühlen: Heizung aus, Lüfter an, relativ Z+40 (≤ Z-max), dann X 125 Y 125. Schieben: anheben, Spur hinten ansetzen, langsam senken, nach vorn, hinten parken, `M107`. +- Abräumen braucht gehomed Achsen. Slice-Ende der Kobra 3 enthält `M84; disable motors` (nach Present) — danach sofort unhomed. Bei Ablauf mit Cool/Sweep entfernt Grotti `M84`/`M18` beim Upload. Zusätzlich: `SET_IDLE_TIMEOUT` schon während des Drucks und alle 120 s beim Abkühlen; wenn trotzdem unhomed, `SET_KINEMATIC_POSITION` mit der **echten** letzten Toolhead-XYZ (Present), nie erfundene Z. Kein automatisches `G28` bei belegtem Bett. „Schieben testen“ (Bett leer bestätigt) homt mit `G28`. + +Gestoppter Ablauf: Seitenleiste ▶ setzt beim gespeicherten `step_index` fort (`PATCH /druck-queue/` mit `status: running, resume: true`). +- Tasten: Mesh aus, Rand-PROBE + Z-Null (Retract 2 mm), Mitte-PROBE, letztes `Result is z=` gegen `max_delta_mm`. Nie `G28 Z`. Nie Toolhead-Z nach dem Retract. Fortsetzen bei gemeldetem Z unter Bett erlaubt (nur relativ hoch). +- Druckfehler oder Abbruch stoppt den Ablauf — kein Weiterschieben. + +## Trockenlauf (Crash-Fix prüfen) + +Bett **leer**, ein Drucker reicht. Ziel: Cool-Hub und Anfahren hinten testen, **ohne** `G0 Z{z_push}`. +`PROBE` fährt nur bis Trigger — nicht wie `G0 Z2` zwangsweise auf Koordinate. Trotzdem **nicht** mit Z8 starten (nach dem Crash zu unheimlich). + +**Lauf A — sicher (zuerst):** +1. `G28`, Düse wischen. +2. `G90` → `G0 Z40 F1800` → Present `X250 Y220`. +3. Cool-Park: relativ Z+40 (≤ Z-max), Mitte. Erwartung: Z ≈ 80, Mitte. +4. `sweep_go_back_high` (hoch + Y hinten). Erwartung: hinten, Z klar oben. +5. Statt Senken: **`PROBE`**. Erwartung: `Result is z=` typisch < 1 mm; sofort `G0 Z40`. +6. Stop. Keine Y-Bahn, kein `z_push`. + +**Lauf B — niedrige „letzte Schicht“ (nur wenn A sauber):** +Wie A, aber Schritt 2 mit `G0 Z15` (nicht Z8). Cool +40 → Z ≈ 55. Wieder nur PROBE, nie `G0 Z2`. + +Optional ganz am Ende: einmal `sweep_lower` mit `F400` + `assert_clear`, Bett leer — nur nach A+B ok. + +Grotti-Schalter „Trockenlauf / PROBE statt z_push“ noch nicht gebaut — bis dahin manuell oder `_tmp_`-Skript mit `eject.*_script`. + +## Grenzen + +- Nur Familie `kobra_3`. +- Teile höher als die Umsetzhöhe trifft die Düse auf dem Weg nach hinten. +- Festsitzendes Material (z. B. PETG) schiebt die Düse nicht zuverlässig ab. + +## Backlog (später) + +Ideen liegen in Grotti unter **Ideen** (Status „später“), nicht nur hier: + +- **Abdeckungen gegen Verkanten** — Führung/Rutsche vorn, damit geschobene Teile nicht an Bettkante, Rahmen oder in der Kiste verkanten und den nächsten Lauf blockieren. +- **Kobra S1** — eigener Abräum-Ablauf (geschlossen, Tür). + +Erledigt: Antasten / Bettnetz vergleichen (Schritt **Prüfen**, 13.10.2026). diff --git a/docs/Chargen-Etiketten-Viewer.md b/docs/Chargen-Etiketten-Viewer.md new file mode 100644 index 0000000..b3056cf --- /dev/null +++ b/docs/Chargen-Etiketten-Viewer.md @@ -0,0 +1,25 @@ +# Chargen, Etiketten und 3D-Viewer + +Stand 14.10.2026. + +## Spulen-Chargen + +Jede Spule in Grotti bekommt automatisch eine `chargen_nr` der Form `S-YYYYMMDD-0001`. Derselbe Wert steht in `code` und auf dem Etikett. Optional: `hersteller_charge` vom Rollenetikett. + +Scannen: auf Material den Code tippen oder per USB-Handscanner (Tastatur + Enter). Lookup `GET /api/v1/spulen/by-code/`. QR auf dem Etikett öffnet `/material?spool=`. + +Etikett: `/etikett/`, Druck über den Browser. QR per qrcode.js (CDN). Basis-URL für den QR kommt aus Einstellungen → **FQDN** (`config.json` Key `fqdn`); leer = Host der aktuellen Anfrage. Beispiel: `https://grotti.example.de/material?spool=12`. + +## 3D-Pfad + +`GET /api/v1/dateien//pfad` liefert einen gedünnten Toolpath (bis ca. 120 000 Punkte, plus `layer_breaks`, Cache-`version` 5) aus dem lokalen G-code. Die Dateien-Seite und das Cockpit zeichnen ihn mit Three.js. + +Darstellung: nur Extrusionswege. Preview = orange. Live: gedruckt leicht verdickt grün, offen grau; Schnitt nach `progress`. Parser akzeptiert `E.39`/`Z.6`. Bett: Gitter 10 mm, mm-Sprites, Legende. + +**Exclude-Object** (nur Klipper/Moonraker): Status aus `exclude_object`. Polygone im Viewer; Liste unter dem Pfad; Zwei-Klick → `POST .../steuern` Action `exclude_object` mit `confirm`. Fehlt Modul/DEFINEs → Panel aus. BambuLab später eigener Adapter. + +Cockpit-Live: Kamera-Frames werden unsichtbar vorgeladen (letztes Bild bleibt), `no-cam`-Layout nur ohne Kamera-URL. 3D-Orbit bleibt beim 5‑s-Poll erhalten (`setPath` nur beim ersten Laden / Dateiwechsel). + +Auf der Wand: Caption `Dateiname · % · Schicht a/b`, sonst „Datei nicht in Grotti“. + +Bedienung: Orbit / Zoom / Schwenken / Doppelklick-Reset; Tippen auf Polygon wählt das Objekt. diff --git a/docs/Dateien-Metadaten.md b/docs/Dateien-Metadaten.md new file mode 100644 index 0000000..c10ad3d --- /dev/null +++ b/docs/Dateien-Metadaten.md @@ -0,0 +1,20 @@ +# Grotti-Dateien Metadaten + +`stored_path` liegt nur in der DB und in Rohzeilen (`get_file_row`, `list_file_rows`). Die API-Antwort (`file_out` / `GET /api/v1/dateien`) enthält den Pfad bewusst nicht. + +`POST /api/v1/dateien/neu-lesen` liest Watch-Ordner neu und ruft `refresh_file_meta` auf Rohzeilen auf — nicht auf `list_files`, sonst `KeyError: 'stored_path'`. + +## Was der Parser liest + +Kopfkommentare **und** Footer (letzte ~128 KB). Anycubic/Orca legen Gewicht, Zeit und Prozesswerte oft ans Dateiende. + +In der Bibliothek gespeichert: + +| Feld | Bedeutung | +|------|-----------| +| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) | +| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit | +| `printer_family` | aus `printer_model` | +| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` | + +Anzeige: Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt immer in der Zeile; Rest im Info-Popup bzw. unter der gewählten Datei. Nach Parser-Update: **Ordner neu lesen**. diff --git a/docs/Features-UI.md b/docs/Features-UI.md new file mode 100644 index 0000000..4e43867 --- /dev/null +++ b/docs/Features-UI.md @@ -0,0 +1,55 @@ +# Grotti: Alltag und Oberfläche + +Stand **14.10.2026**, v1.4.0. + +## Design + +- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`). +- Kurze Seitenuntertitel; Technik-/Config-Hinweise liegen in der Doku, nicht als Fließtext in den Formularen. +- Einheitliche Seitenköpfe, Toolbar-Filter, kompakte Tabellen, Formularfelder mit kleinen Labels, Empty-States mit Icon. +- **Anzeige pro Browser** (Einstellungen → Dieser Browser): `Desktop` oder `Regal (Touch)` in `localStorage grotti-display`. Shortcut `?shelf=1` / `?shelf=0`. Regal: größere Controls, kompakte Nav (Drucker/Kameras/Warteschlange), Sidebar default eingeklappt. +- Seitenmenü einklappbar (Icon-Rail, `grotti-nav-collapsed`). Unter 760 px weiter Bottom-Nav. + +## Seitenleiste + +- Hauptmenü: Drucker, Dateien, Kameras, Aktivität, Warteschlange, Protokoll, Material, Projekte. Darunter Einstellungen, Ideen, Doku (mit Icons); Fuß **© by dominic@gundel.dev** und Version (Changelog). +- Live-Leiste, eigene Scrollfläche, Boxen auf- und zuklappbar (Zustand in `localStorage` `grotti-live-closed`): + - **Läuft jetzt**: laufende Aufträge und Abläufe mit Schrittleiste, Durchlauf `n/m`, aktueller Phase und ×-Abbrechen; auch Direktstarts ohne Queue. + - **Wartet**: wartende Aufträge mit Ziel, Priorität hoch/runter, Abbrechen; die letzten Fehler mit Fortsetzen/ausblenden. + - **Aufgaben**: Platte belegt ohne Ablauf — „geräumt“ per Zwei-Klick. + - **Senden**: laufende Uploads mit Fortschritt. + - **Alerts**: ungelesene wichtige Ereignisse, „gelesen“, Link auf Aktivität. +- Aktualisierung: Senden 2 s, Queue 4 s, Aufgaben/Alerts 8 s. + +## Wand + +- Karte je Drucker: Zustandschip, Fertig-Uhrzeit groß, Fortschrittsbalken, drei Kacheln (Rest, Schicht, Läuft), Temperaturbalken Düse/Bett mit Ziel, Datei. Klick auf die Karte öffnet das Cockpit. Pause, Abbrechen, Fehler löschen und Platte geräumt bleiben auf der Karte (Zwei-Klick). +- Laufender Ablauf als Streifen mit Schrittleiste und Abbrechen. Wartende Aufträge für diesen Drucker als Hinweis. +- Streifen „Fehler“ mit **Fehler löschen** und „Platte belegt“ mit **Platte geräumt** (während eines Ablaufs ausgeblendet, der räumt selbst). +- **Cockpit** (Hash `/#`): Kamera (Frame-Vorladen; zugeklappt oder ohne Kamera → eine Spalte, kein leeres 16:10), Status, Temps inkl. Bauraum wenn vorhanden, Lüfter-Icons, Steuerung, 3D-Pfad (Exclude-Object), Bettnetz. E-Stop nur bei printing/paused/heating, Zwei-Klick. Live vom Watcher. + +## Dateien und Senden + +- Links Bibliothek: Suche, Filter Maschine/Projekt, Vorschaubild; Zeile zeigt Material, Zeit, Gewicht, Schichthöhe, Maschine, Projekt; Details per Info-Popup. Metadaten aus G-code-Kopf **und** Footer. Hochladen / Ordner neu lesen. +- Rechts der Flow: Datei → Drucker (Live-Zustand, eingelegte Rolle) → Wann (Jetzt / Wenn frei / Nächster freier) oder Reiter Ablauf. +- Kein freies Spulen-Dropdown: Abzug nimmt die Rolle(n) am Ziel-Drucker zur Startzeit. ACE: Zuordnung Datei-Farbe → Platz. Warnung bei Material-Mismatch oder zu wenig Rest. +- Startknopf mit Zwei-Klick bei „Jetzt“. Auswahl in `localStorage` `grotti-send-v1`. + +## Material + +- Reiter **Spulen** / **Sorten**. Scan-Feld im Seitenkopf. +- Spulen: Filter Alle / Am Drucker / Regal / Wenig Rest + Suche. Formular nur bei Auswahl oder „Neue Spule“ (× schließt). Ort = Drucker · ACE/extern bzw. Regal. +- Sorten: Profile (Dichte, mm³/s, Preis, Maschinen). Anlegen/ändern/löschen; „Spule damit anlegen“ wechselt zum Spulen-Reiter. + +## Warteschlange + +- Seite `/warteschlange`: Filter Läuft / Wartet / Fehler / Historie. Priorität, Abbrechen, Fortsetzen. +- Unter **Läuft** / **Alle aktiv** auch Direktstarts aus `/laeuft` (ohne Queue-Zeile), mit Fortschritt und Link zur Wand. + +## Aktivität + +- Zeitstrahl nach Tagen. Filter in der URL. Ungelesene Alerts markiert. + +## Kameras und Handy + +- Kamerakacheln mit Live-Zustand (`live=1`). Snapshot-Cache 2 s. Unter 760 px: Schnellmenü Drucker / Dateien / Aktivität. diff --git a/docs/Haftung.md b/docs/Haftung.md new file mode 100644 index 0000000..3674dec --- /dev/null +++ b/docs/Haftung.md @@ -0,0 +1,31 @@ +# Haftung + +**Entwurf.** Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB dazu. Diese Seiten nennen keinen Preis. + +Stand **14.10.2026**. Was die Drucker können müssen, steht unter [Voraussetzungen](Voraussetzungen.md). + +## Grundsatz + +Grotti flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. + +Der Nutzer richtet seinen eigenen Drucker ein, bevor er Grotti nutzt. Verlinkt wird nur die Installationsseite von Rinkhals: . Die Warnung, die der Hersteller selbst einblendet, bleibt seine. + +Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic. + +Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (). + +Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet. + +Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer Grotti wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg. + +Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. + +Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. + +## Vor dem ersten Start + +Düse und Bett werden heiß. Den ersten Lauf behält der Nutzer im Blick. Der Satz dazu steht oben, nicht erst hier: Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. + +## Was Grotti beim Start schon verlangt + +Druckstarts brauchen eine Bestätigung (`confirm: true`). Es gibt keine freie G-code-Konsole und kein `FIRMWARE_RESTART`. diff --git a/docs/Ops-Queue-Alerts.md b/docs/Ops-Queue-Alerts.md new file mode 100644 index 0000000..4d923d6 --- /dev/null +++ b/docs/Ops-Queue-Alerts.md @@ -0,0 +1,50 @@ +# Grotti Ops: Queue, Alerts, Steuerung + +Stand 14.10.2026. + +## Einstellungen + +Seite `/einstellungen`: Druckernamen/Host/Familie/gesperrt (PostgreSQL `printers`, `drucker.json` nur Seed), Automatik (`auto_queue` in `config.json`), Telegram, Energie. Live-API und Wand nutzen die DB-Namen. Gesperrte Drucker und `auto_queue: false` verhindern neue „wenn frei“-Starts; laufende Abläufe laufen weiter. + +## Live-Quelle + +`StateWatcher` pollt die Drucker (ca. 10 s). `GET /drucker?live=1` und der Print-Queue-Dispatcher lesen diesen Stand. Nur wenn der Cache älter als ~60 s / 30 s ist, wird einmal direkt abgefragt. Lange Ablauf-Schritte (Upload, Schieben, Tasten) laufen in Worker-Threads. + +## Druckwarteschlange „wenn frei“ + +Dateien → „Wenn frei“ oder „Nächster freier“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`. + +## Spulen + +Ort = Regal oder Drucker (+ `ace_slot`). Unique-Index `(printer_host, ace_slot)`. Beim Start löst Grotti die Spule(n) aus Host + `ace_map` auf; Materialtyp der Datei muss passen. `sends` speichert `ace_map` und `bed_leveling`. Nach „fertig“ synct der Watcher das Protokoll und zieht Spulen ab (ACE anteilig). + +## Telegram / Ereignisse + +Jedes Ereignis landet in `events` und auf der Seite `/aktivitaet`. Telegram ist pro Art abschaltbar und wird je Host und Art entprellt (Watcher 60 s, Queue 30 s). Das Speichern wird nie entprellt. Gemeinsamer Weg: `notify.record()`. + +| Art | Woher | Alert | Telegram-Standard | +| --- | --- | --- | --- | +| `fertig` | Watcher, `printing`→`complete` | nein, Platte unter „Aufgaben“ | an | +| `fehler` | Watcher, Zustand `error` | ja | an | +| `abbruch` | Watcher, `printing`/`paused`→`cancelled`/`standby` | ja | wie `fehler` | +| `offline` | Watcher, ≥2 Polls weg; auch „wieder erreichbar“ | ja | an | +| `spule` | Watcher, Rest unter Job-Gewicht | ja | an | +| `queue` | Dispatcher: Queue-Fehler, Ablauf gestoppt | ja | an | +| `start` | Watcher: Druck startet, Pause, Fortsetzen | nein | aus | +| `ablauf` | Dispatcher: Start, Schritte, Wiederholung, fertig; App: eingereiht, von Hand abgebrochen | nein | aus | + +„Alert“ heißt: ungelesen, steht in der Sidebar. `start`, `ablauf` und `fertig` werden gleich als gelesen gespeichert (`store.QUIET_EVENT_KINDS`), damit unter Alerts nur Probleme stehen. + +„Aufgaben“ (Sidebar, `GET /api/v1/aufgaben`): Drucker mit `bed_status.needs_clear`, ohne laufenden Queue-Eintrag und nicht am Drucken. Knopf „geräumt“ ruft `POST /api/v1/drucker//abraeumen` mit `confirm: true` (Zwei-Klick), fährt nichts. + +API: `GET /api/v1/ereignisse?limit=&host=&kind=a,b&since=&unread=1`, `POST /api/v1/ereignisse/gelesen`. + +Bei `print_stats.state=error` ist `message` auf Anycubic oft leer. Grotti ergänzt dann nur aus dem G-code-Log (echte `Must home`-/Fehlerzeilen) oder „Druck sofort abgebrochen“. Leeres `homed_axes` allein gilt nicht als Fehler — Idle schaltet die Stepper ab, das ist normal. + +Nach Fehler: Watcher schaltet Bett/Düse/Lüfter aus (nicht weiter heizen). Vor Start und zwischen Ablauf-Schritten: hängender `print_stats=error` wird per `SDCARD_RESET_FILE` geräumt und der Druck gestartet. Kein Homing-Zwang vorher — der Slice-Start macht `G28`. Nur mitten im Druck (`print_active`) bricht ein error den Ablauf ab. + +## Steuerung + +Wand-Details: Düse/Bett, Speed/Flow/Fan, E-Stop und Exclude-Object über `POST /api/v1/drucker//steuern` (Whitelist, keine freie Konsole; `exclude_object` braucht `confirm` + `name`). Abbruch weiterhin Moonraker-cancel + MQTT-stop. + +**Kein Firmware-Restart:** Auf GoKlipper/Rinkhals deadlocked `FIRMWARE_RESTART` gklib bis Power-Cycle ([Rinkhals #32](https://github.com/jbatonnet/Rinkhals/issues/32)). Grotti lehnt den Befehl ab. `printer.cfg` nur über Display-Reboot oder Strom. Hängenden `print_stats=error`: Wand **Fehler löschen** (`SDCARD_RESET_FILE` + Heizung aus). diff --git a/docs/Senden-Queue.md b/docs/Senden-Queue.md new file mode 100644 index 0000000..be4af0a --- /dev/null +++ b/docs/Senden-Queue.md @@ -0,0 +1,76 @@ +# Grotti: Senden und Start dauert + +Stand **14.10.2026**. Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“. + +## Warum Sekunden / Minuten + +Über Grotti-App (`Dateien` → Starten) läuft der Aufruf nicht nur lokal: die Datei wird als **ein synchroner Multipart-POST** an Moonraker hochgeladen (`/server/files/upload`), danach startet `/printer/print/start`. Der HTTP-Request endet erst, wenn Moonraker die Datei geschrieben und oft Metadaten/MMU-Preprocess angeschoben hat — bis dahin wirkt die Queue „still“. + +Messung 12.10.2026, `tulip pla.gcode` (~**50,4 MB** in `data/eingang`, typisch daneben ~2 MB): Upload auf `da26` (`10.10.1.60`) **~67 s** laut Moonraker-Log (`201 POST … 67106 ms`), plus kurze `.acm`-Sidecar (~87 ms) und Start. Gesamter Queue-Job ~**96 s**. Kleine Dateien bleiben im Sekundenbereich. + +13.10.2026: Die lange Pause bei „50/50 MB“ ist Rinkhals `mmu_ace_metadata.py`. Der liest die ganze Datei auf der Kobra, bevor der Upload-HTTP antwortet, und schreibt sie bei gefundenen Werkzeugen noch einmal um. Grotti setzt vorher die Kopfzeile `; processed by MmuAcePatcher`. Bei Prusa, Super, Orca, Bambu oder AnycubicSlicerNext und Werkzeugen (`T0`, `MMU_CHANGE_TOOL`) hängt sie `; referenced_tools = …` an — dieselbe Zeile, die der Drucker sonst selbst schreibt. Danach überspringt er diesen Durchlauf. Stehen die Bytes, wechselt die Anzeige auf „liest die Datei…“. Grotti neu starten (`use_reloader=False`). + +Vorher prüft Grotti Online-Status und ob schon gedruckt wird — laufende Drucke werden nicht abgebrochen. Die Rinkhals-Touch-UI können wir nicht umbauen; Fortschritt betrifft Grotti unter `:8770`. + +## Queue (Grotti) + +Ab 12.10.2026: Start legt Hintergrund-Aufträge an (`pending` → `running` → `done`/`error`). Pro gewähltem Drucker ein Queue-Job; mehrere Worker. UI pollt ~500 ms. Beim Hochladen zeigt die Queue jetzt **MB-Fortschritt** (urllib liest den Body gestreamt); Timeout skaliert mit Dateigröße (ca. 180–600 s). Bestätigung „Druck wirklich starten“ bleibt Pflicht. Nach Python-Änderung Grotti neu starten (`use_reloader=False`). + +## Slice-Ordner / Upload + +`watch_dir` in `config.json` zeigt auf den Slice-Ordner der Bibliothek. Ohne Ordner geht Hochladen über die Dateien-Seite trotzdem. + +## ACE / `.acm` + +Drucker mit ACE/MMU-Plätzen (`mmu.gate_color` / `gate_material`, Grotti-`slots`) brauchen vor `print/start` eine Sidecar-Datei `*.gcode.acm`. In der UI unter Optionen → ACE-Zuordnung (Datei-Farbe → Platz). Ohne passende `.acm` lehnt der Drucker oft mit „cannot parse the file“ ab — Details hier, nicht als Banner in der App: + +```json +{"use_ams":true,"ams_box_mapping":[{"paint_index":0,"paint_color":[r,g,b],"material_type":"PLA","ams_index":1,"ams_color":[r,g,b]}]} +``` + +`paint_index` = Farbe in der Slice-Datei (0-basiert), `ams_index` = ACE-Platz 0-basiert. Externe Spule / keine Slots: kein `.acm`. Keine Community-Makros (`ACE_SET_SLOT` / `G9111`). + +AnycubicSlicerNext schreibt die Farben oft als `; paint_info: [{...}]` statt `filament_type` — Grotti liest das seit 12.10.2026 mit. + +## Bettnetz / auto_leveling beim Start (12.10.2026) + +Rinkhals `kobra.py` `mqtt_print_file` baut den Anycubic-MQTT-Start so (Topic `…/slicer/printer/{modelId}/{deviceId}/print`, lokal `127.0.0.1:2883`): + +```json +{ + "type": "print", + "action": "start", + "msgid": "", + "timestamp": 1660201929871, + "data": { + "filename": "job.gcode", + "filepath": "/", + "taskid": "123456", + "task_mode": 1, + "filetype": 1, + "task_settings": { + "auto_leveling": 1, + "vibration_compensation": 0, + "flow_calibration": 0 + } + } +} +``` + +`auto_leveling` ist **0/1** (Integer). `1` löst die Anycubic-Nivellierfolge beim Jobstart aus (LeviQ / Netz messen) — nicht über G-code `LEVIQ_*` / `BED_MESH_CALIBRATE` / `SAVE_CONFIG` von Grotti. Ohne Grotti-Flag kommt der Wert aus der **globalen** App-Property `40-moonraker` → `mqtt_print_auto_leveling` (Default `False`). Extra-Felder an Moonraker `POST /printer/print/start` werden ignoriert. App-Property setzen geht nur per SSH (`set_app_property 40-moonraker mqtt_print_auto_leveling True`), keine HTTP-API. + +### Grotti-Verhalten + +| Checkbox / ACE | Startweg | +| --- | --- | +| kein Bettnetz, keine ACE-Map | Upload + Moonraker `print/start` → Rinkhals MQTT (globale `auto_leveling`, oft 0) | +| ACE-Map gesetzt | Upload + `.acm` (Basename + Stamm-Variante) + **Grotti-MQTT** mit `ams_settings` aus der Map; `auto_leveling` nur 1 wenn Bettnetz an | +| Bettnetz an | wie ACE-Map-Pfad, `auto_leveling:1` | + +Credentials holt Grotti ohne SSH: `GET :18910/info` → signiertes `POST :18910/ctrl` → AES-Decrypt (Username/Password/`deviceId`). Port **2883** ist vom PC zu; **9883** (TLS, Cert-Check aus) ist offen. Mit Grotti-MQTT liegt `ams_box_mapping` explizit in der Payload (kein Moonraker-Identity-Fallback). Ohne `; referenced_tools`-Trailer: Warnung in UI/Queue, kein Fake-Trailer. + +**Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`). + +**Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`). + +Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca). diff --git a/docs/Voraussetzungen.md b/docs/Voraussetzungen.md new file mode 100644 index 0000000..69a07a2 --- /dev/null +++ b/docs/Voraussetzungen.md @@ -0,0 +1,39 @@ +# Voraussetzungen + +Stand **14.10.2026**. + +Grotti steuert Drucker, die im eigenen Netz schon Moonraker sprechen. Bewährt ist das auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft. Moonraker liegt auf Port 80. Den Aufsatz installiert der Nutzer selbst. Grotti liefert keine Firmware und keine Schraubanleitung. + +Verlinkt wird nur die Installationsseite von Rinkhals: . Die Warnung, die der Hersteller selbst einblendet, bleibt seine. + +Andere Drucker nur, wenn Moonraker dort schon erreichbar ist. Dafür gibt es keine eigene Anleitung. Bambu Lab, Prusa und Creality Cloud bleiben bei deren eigenen Programmen. + +Der Ablauf Abkühlen, Schieben, Tasten ist für die Kobra 3. Die Kobra S1 ist geschlossen, Abräumen dort ist offen. + +Einrichtung der Software (Python, PostgreSQL, Start) steht im [README](../README.md). + +## Vor dem ersten Start + +Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. Düse und Bett werden heiß. Den ersten Lauf behält der Nutzer im Blick. + +## Grundsatz + +**Entwurf.** Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB dazu. Diese Seiten nennen keinen Preis. + +Grotti flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. + +Der Nutzer richtet seinen eigenen Drucker ein, bevor er Grotti nutzt. Verlinkt wird nur die Installationsseite von Rinkhals: . Die Warnung, die der Hersteller selbst einblendet, bleibt seine. + +Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic. + +Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (). + +Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet. + +Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer Grotti wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg. + +Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. + +Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. + +Ausführlich derselbe Hinweis: [Haftung](Haftung.md). diff --git a/docs/Wand-Bettnetz.md b/docs/Wand-Bettnetz.md new file mode 100644 index 0000000..2cde144 --- /dev/null +++ b/docs/Wand-Bettnetz.md @@ -0,0 +1,9 @@ +# Grotti-Wand: Bettnetz + +Stand **13.10.2026**. + +Die Heatmap im Drucker-Cockpit (unter dem Kamerabild, neben dem 3D-Pfad) zeigt das **aktuelle** Moonraker-Objekt `bed_mesh` je Drucker (`probed_matrix`, sonst `mesh_matrix`), nicht ein Netz zum laufenden G-code. + +Drei Drucker mit derselben Datei (z. B. Gridfinity) haben deshalb **drei verschiedene** Netze — eines pro Bett. Die Zeile am 3D-Viewer ist der Auftragspfad (`3D-Pfad · …`), nicht die Netz-Identität. + +Grotti merkt sich das zuletzt gelesene Netz im Speicher. Offline oder leeres `bed_mesh` → Anzeige mit Hinweis „offline gespeichert“ und Uhrzeit der letzten erfolgreichen Abfrage. Neu nivellieren am Drucker aktualisiert das Cockpit beim nächsten Poll (~5 s). diff --git a/drucker.json b/drucker.json new file mode 100644 index 0000000..af528f1 --- /dev/null +++ b/drucker.json @@ -0,0 +1,18 @@ +[ + { + "name": "Kobra 3 da26", + "host": "10.10.1.60" + }, + { + "name": "Kobra 3 92ee", + "host": "10.10.1.59" + }, + { + "name": "Kobra 3 6e41", + "host": "10.10.1.34" + }, + { + "name": "Kobra S1", + "host": "10.10.1.88" + } +] diff --git a/grotti/__init__.py b/grotti/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/grotti/acm.py b/grotti/acm.py new file mode 100644 index 0000000..f826ea0 --- /dev/null +++ b/grotti/acm.py @@ -0,0 +1,111 @@ +"""Anycubic ACE Sidecar (.acm) für Moonraker-Start.""" + +from __future__ import annotations + +import json +import re + +# Rinkhals mmu_ace: ohne `; referenced_tools = …` kein Auto-Feed / Nachschub. +TRAILER_WARN = "ACE-Nachschub braucht Slicer-Metadaten/Trailer" + + +def color_channels(value): + if isinstance(value, (list, tuple)) and len(value) >= 3: + out = [] + for channel in value[:3]: + if isinstance(channel, bool) or not isinstance(channel, (int, float)): + return [] + out.append(max(0, min(255, int(round(float(channel)))))) + return out + if value in (None, ""): + return [] + text = str(value).strip() + if text.startswith("#"): + text = text[1:] + text = re.sub(r"[^0-9A-Fa-f]", "", text) + if len(text) == 8: + # Moonraker mmu.gate_color: RRGGBBAA + text = text[:6] + if len(text) != 6: + return [] + try: + return [int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)] + except ValueError: + return [] + + +def acm_filename(gcode_name): + """Sidecar neben dem Start-Dateinamen: `job.gcode` → `job.gcode.acm`.""" + name = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1] + if name.lower().endswith(".acm"): + return name + return name + ".acm" + + +def acm_upload_names(gcode_name): + """Primärer Basename wie Start-filename, plus Stamm-Variante zum Überschreiben. + + Anycubic/Rinkhals erwarten typisch `name.gcode.acm`. Parallel kann eine + ältere Identity-Map als `name.acm` liegen und Grotti-Map verdrängen. + """ + primary = acm_filename(gcode_name) + names = [primary] + base = (gcode_name or "job.gcode").replace("\\", "/").split("/")[-1] + if base.lower().endswith(".acm"): + base = base[: -len(".acm")] + if base.lower().endswith(".gcode"): + alt = base[: -len(".gcode")] + ".acm" + if alt.lower() != primary.lower(): + names.append(alt) + return names + + +def normalize_mapping(raw): + if not isinstance(raw, list): + return [] + out = [] + seen = set() + for item in raw: + if not isinstance(item, dict): + continue + try: + paint = int(item.get("paint_index")) + ams = int(item.get("ams_index")) + except (TypeError, ValueError): + continue + if paint < 0 or ams < 0 or ams > 7 or paint in seen: + continue + seen.add(paint) + out.append({"paint_index": paint, "ams_index": ams}) + return out + + +def build_acm(filaments, mapping, slots): + filaments = filaments or [] + slots = slots or [] + slot_by_index = {int(slot.get("index")): slot for slot in slots if isinstance(slot.get("index"), int)} + entries = [] + for item in normalize_mapping(mapping): + paint = item["paint_index"] + ams = item["ams_index"] + filament = filaments[paint] if 0 <= paint < len(filaments) else {} + slot = slot_by_index.get(ams) or {} + paint_color = color_channels(filament.get("color")) or [0, 0, 0] + ams_color = color_channels(slot.get("color")) or paint_color + material = (filament.get("type") or slot.get("type") or "PLA").strip() or "PLA" + entries.append( + { + "paint_index": paint, + "paint_color": paint_color, + "material_type": material, + "ams_index": ams, + "ams_color": ams_color, + } + ) + if not entries: + return None + return {"use_ams": True, "ams_box_mapping": entries} + + +def acm_bytes(payload): + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") diff --git a/grotti/anycubic_lan.py b/grotti/anycubic_lan.py new file mode 100644 index 0000000..9aac824 --- /dev/null +++ b/grotti/anycubic_lan.py @@ -0,0 +1,347 @@ +"""Anycubic LAN: Credentials über :18910, Start/Stop über MQTTS :9883. + +Entspricht Rinkhals kobra.py mqtt_print_file, aber mit pro-Job task_settings +(statt globaler App-Property mqtt_print_auto_leveling). Stop: action stop, +taskid -1 (Rinkhals MQTT-Docs) — nötig, weil Moonraker-cancel den Anycubic-Task +nicht räumt (sonst 10101). +""" + +from __future__ import annotations + +import hashlib +import json +import random +import ssl +import string +import time +import uuid +import urllib.error +import urllib.request +from base64 import b64decode + +import paho.mqtt.client as mqtt +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +CRED_TTL = 300 +_CRED_CACHE = {} + + +class LanError(Exception): + def __init__(self, message): + super().__init__(message) + self.message = message + + +def _info(host): + try: + with urllib.request.urlopen("http://%s:18910/info" % host, timeout=5) as response: + return json.load(response) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise LanError("LAN-Info vom Drucker nicht erreichbar (:18910).") from exc + + +def _ctrl(host, token, did="grotti"): + ts = str(int(time.time() * 1000)) + nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6)) + sign = hashlib.md5( + (hashlib.md5(token[:16].encode()).hexdigest() + ts + nonce).encode() + ).hexdigest() + url = "http://%s:18910/ctrl?ts=%s&nonce=%s&sign=%s&did=%s" % ( + host, ts, nonce, sign, did, + ) + try: + request = urllib.request.Request(url, data=b"", method="POST") + with urllib.request.urlopen(request, timeout=8) as response: + payload = json.load(response) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise LanError("LAN-Credentials vom Drucker nicht erreichbar (:18910/ctrl).") from exc + if payload.get("code") != 200 or not isinstance(payload.get("data"), dict): + raise LanError("LAN-Credentials abgelehnt.") + return payload["data"] + + +def _decrypt(info_token, ctrl_data): + key = info_token[16:32].encode("ascii") + iv_raw = ctrl_data.get("token") or "" + iv = iv_raw.encode("ascii")[:16] if isinstance(iv_raw, str) else b"" + if len(iv) != 16: + raise LanError("LAN-Credentials ungültig (IV).") + try: + blob = b64decode(ctrl_data["info"]) + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + raw = decryptor.update(blob) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plain = unpadder.update(raw) + unpadder.finalize() + data = json.loads(plain.decode("utf-8")) + except Exception as exc: + raise LanError("LAN-Credentials lassen sich nicht lesen.") from exc + if not data.get("deviceId") or not data.get("username") or not data.get("password"): + raise LanError("LAN-Credentials unvollständig.") + return data + + +def credentials(host): + now = time.time() + cached = _CRED_CACHE.get(host) + if cached and now - cached[0] < CRED_TTL: + return cached[1] + info = _info(host) + token = info.get("token") or "" + if len(token) < 32: + raise LanError("LAN-Info ohne Token.") + data = _decrypt(token, _ctrl(host, token)) + data["modelId"] = str(info.get("modelId") or data.get("modeId") or data.get("modelId") or "") + if not data["modelId"]: + raise LanError("LAN-Info ohne modelId.") + _CRED_CACHE[host] = (now, data) + return data + + +def _rgba(value): + if isinstance(value, (list, tuple)) and len(value) >= 3: + channels = [] + for channel in value[:3]: + if isinstance(channel, bool) or not isinstance(channel, (int, float)): + return [0, 0, 0, 255] + channels.append(max(0, min(255, int(round(float(channel)))))) + alpha = 255 + if len(value) >= 4 and isinstance(value[3], (int, float)) and not isinstance(value[3], bool): + alpha = max(0, min(255, int(round(float(value[3]))))) + return channels + [alpha] + return [0, 0, 0, 255] + + +def normalize_ams(ams_settings): + if not isinstance(ams_settings, dict): + return None + mapping = ams_settings.get("ams_box_mapping") + if not isinstance(mapping, list) or not mapping: + return None + entries = [] + for item in mapping: + if not isinstance(item, dict): + continue + try: + paint = int(item.get("paint_index")) + ams = int(item.get("ams_index")) + except (TypeError, ValueError): + continue + material = (item.get("material_type") or "PLA").strip() or "PLA" + entries.append({ + "paint_index": paint, + "ams_index": ams, + "paint_color": _rgba(item.get("paint_color")), + "ams_color": _rgba(item.get("ams_color") or item.get("paint_color")), + "material_type": material, + }) + if not entries: + return None + return {"use_ams": True, "ams_box_mapping": entries} + + +def _report_error(body, default_message): + code = body.get("code") + if code == 10107: + return "Filament gerissen. Bitte neues laden. (code 10107)" + if code == 10101: + return ( + "Alter Druckauftrag noch aktiv (Anycubic-MQTT). " + "Bitte zuerst abbrechen. (code 10101)" + ) + message = str(body.get("msg") or default_message) + return "%s%s" % (message, (" (code %s)" % code) if code else "") + + +def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."): + """Publish auf slicer/…/print, warte auf print/report.""" + cred = credentials(host) + model_id = cred["modelId"] + device_id = cred["deviceId"] + payload = json.dumps(request) + topic_print = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/print" % (model_id, device_id) + topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/print/report" % ( + model_id, device_id, + ) + + state = {"done": False, "error": None, "report": None} + + def on_connect(client, userdata, flags, reason_code, properties=None): + if getattr(reason_code, "is_failure", False): + state["error"] = "MQTT-Verbindung abgelehnt (%s)." % reason_code + state["done"] = True + return + client.subscribe(topic_report) + client.publish(topic_print, payload=payload, qos=1) + + def on_message(client, userdata, msg): + try: + body = json.loads(msg.payload.decode("utf-8", "replace")) + except (json.JSONDecodeError, UnicodeError): + return + state["report"] = body + report = str(body.get("state") or "") + action = str(request.get("action") or "") + if action == "start" and report in ("failed", "stoped", "stopped"): + state["error"] = _report_error(body, "Druckstart abgelehnt") + elif action == "stop" and report in ("failed",): + state["error"] = _report_error(body, "Druckabbruch abgelehnt") + # start: heating / leveling / printing / preheating = angenommen + # stop: stoped / stopped / done = angenommen + state["done"] = True + + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + protocol=mqtt.MQTTv311, + client_id="grotti-%s" % uuid.uuid4().hex[:10], + ) + client.username_pw_set(cred["username"], cred["password"]) + client.tls_set(cert_reqs=ssl.CERT_NONE) + client.tls_insecure_set(True) + client.on_connect = on_connect + client.on_message = on_message + + try: + client.connect(host, 9883, keepalive=30) + except OSError as exc: + raise LanError("MQTT am Drucker nicht erreichbar (:9883).") from exc + + deadline = time.time() + max(5, int(timeout)) + try: + while not state["done"]: + if time.time() > deadline: + state["error"] = timeout_message + break + client.loop(timeout=0.25) + finally: + try: + client.disconnect() + except Exception: + pass + + if state["error"]: + raise LanError(state["error"]) + return state["report"] + + +def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30): + """MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0.""" + name = (filename or "").replace("\\", "/").split("/")[-1] + if not name: + raise LanError("Dateiname fehlt.") + + data = { + "filename": name, + "filepath": "/", + "taskid": str(random.randint(0, 1000000)), + "task_mode": 1, + "filetype": 1, + "task_settings": { + "auto_leveling": 1 if auto_leveling else 0, + "vibration_compensation": 0, + "flow_calibration": 0, + }, + } + ams = normalize_ams(ams_settings) + if ams: + data["ams_settings"] = ams + + request = { + "type": "print", + "action": "start", + "msgid": str(uuid.uuid4()), + "timestamp": int(time.time() * 1000), + "data": data, + } + timeout_message = "Timeout beim MQTT-Druckstart." + try: + _mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message) + except LanError as exc: + # Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem. + if exc.message != timeout_message: + raise + for attempt in range(3): + project = (print_info(host) or {}).get("project") or {} + if str(project.get("task_id") or "") == data["taskid"]: + break + time.sleep(5) + else: + raise + return {"filename": name, "auto_leveling": bool(auto_leveling)} + + +def print_info(host, timeout=10): + """MQTT info/query, liefert data aus info/report (project.task_id, state …).""" + cred = credentials(host) + model_id = cred["modelId"] + device_id = cred["deviceId"] + topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id) + topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/info/report" % ( + model_id, device_id, + ) + payload = json.dumps({ + "type": "info", + "action": "query", + "msgid": str(uuid.uuid4()), + "timestamp": int(time.time() * 1000), + "data": None, + }) + state = {"data": None} + + def on_connect(client, userdata, flags, reason_code, properties=None): + if getattr(reason_code, "is_failure", False): + return + client.subscribe(topic_report) + client.publish(topic_query, payload=payload, qos=1) + + def on_message(client, userdata, msg): + try: + body = json.loads(msg.payload.decode("utf-8", "replace")) + except (json.JSONDecodeError, UnicodeError): + return + if isinstance(body.get("data"), dict): + state["data"] = body["data"] + + client = mqtt.Client( + mqtt.CallbackAPIVersion.VERSION2, + protocol=mqtt.MQTTv311, + client_id="grotti-%s" % uuid.uuid4().hex[:10], + ) + client.username_pw_set(cred["username"], cred["password"]) + client.tls_set(cert_reqs=ssl.CERT_NONE) + client.tls_insecure_set(True) + client.on_connect = on_connect + client.on_message = on_message + try: + client.connect(host, 9883, keepalive=30) + except OSError: + return None + deadline = time.time() + max(3, int(timeout)) + try: + while state["data"] is None and time.time() < deadline: + client.loop(timeout=0.25) + finally: + try: + client.disconnect() + except Exception: + pass + return state["data"] + + +def cancel_print(host, timeout=15): + """MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task).""" + request = { + "type": "print", + "action": "stop", + "msgid": str(uuid.uuid4()), + "timestamp": int(time.time() * 1000), + "data": {"taskid": "-1"}, + } + report = _mqtt_print( + host, + request, + timeout=timeout, + timeout_message="Timeout beim MQTT-Druckabbruch.", + ) + return {"mqtt": "stop", "report": report} diff --git a/grotti/catalog.py b/grotti/catalog.py new file mode 100644 index 0000000..4a8c31b --- /dev/null +++ b/grotti/catalog.py @@ -0,0 +1,257 @@ +"""Sorten aus den Filamentprofilen und die Farbvorauswahl.""" + +MATERIALS = [ + { + "name": "Sunlu PLA", + "brand": "Sunlu", + "material_type": "PLA", + "density": 1.24, + "volumetric": 12, + "volumetric_s1": 14, + "machines": "beide", + "price_per_kg": 0, + "note": "Holz und Marmor laufen über dieses Profil. Glow nur mit gehärteter Düse.", + }, + { + "name": "Sunlu PLA Meta", + "brand": "Sunlu", + "material_type": "PLA", + "density": 1.21, + "volumetric": 14, + "volumetric_s1": 16, + "machines": "beide", + "price_per_kg": 0, + "note": "", + }, + { + "name": "Sunlu PLA Matte", + "brand": "Sunlu", + "material_type": "PLA", + "density": 1.31, + "volumetric": 10, + "volumetric_s1": 10, + "machines": "beide", + "price_per_kg": 0, + "note": "", + }, + { + "name": "Sunlu PLA Silk", + "brand": "Sunlu", + "material_type": "PLA", + "density": 1.24, + "volumetric": 8, + "volumetric_s1": 8, + "machines": "beide", + "price_per_kg": 0, + "note": "Langsamer, damit die Oberfläche glänzt.", + }, + { + "name": "Sunlu PLA+", + "brand": "Sunlu", + "material_type": "PLA", + "density": 1.21, + "volumetric": 12, + "volumetric_s1": 13, + "machines": "beide", + "price_per_kg": 0, + "note": "", + }, + { + "name": "Sunlu PETG", + "brand": "Sunlu", + "material_type": "PETG", + "density": 1.27, + "volumetric": 10, + "volumetric_s1": 12, + "machines": "beide", + "price_per_kg": 0, + "note": "", + }, + { + "name": "Sunlu PETG Matte", + "brand": "Sunlu", + "material_type": "PETG", + "density": 1.27, + "volumetric": 9, + "volumetric_s1": 10, + "machines": "beide", + "price_per_kg": 0, + "note": "", + }, + { + "name": "Sunlu TPU 95A", + "brand": "Sunlu", + "material_type": "TPU", + "density": 1.21, + "volumetric": 5, + "volumetric_s1": 5, + "machines": "beide", + "price_per_kg": 0, + "note": "", + }, + { + "name": "NinjaFlex", + "brand": "NinjaTek", + "material_type": "TPU", + "density": 1.19, + "volumetric": 2, + "volumetric_s1": None, + "machines": "kobra_3", + "price_per_kg": 0, + "note": "Nur Kobra 3.", + }, + { + "name": "Sunlu ASA", + "brand": "Sunlu", + "material_type": "ASA", + "density": 1.04, + "volumetric": 12, + "volumetric_s1": 12, + "machines": "kobra_s1", + "price_per_kg": 0, + "note": "Nur Kobra S1, Tür zu.", + }, + { + "name": "Formfutura ApolloX", + "brand": "Formfutura", + "material_type": "ASA", + "density": 1.07, + "volumetric": 12, + "volumetric_s1": 12, + "machines": "kobra_s1", + "price_per_kg": 0, + "note": "Nur Kobra S1, Tür zu.", + }, + { + "name": "Sunlu ABS", + "brand": "Sunlu", + "material_type": "ABS", + "density": 1.02, + "volumetric": 11, + "volumetric_s1": 11, + "machines": "kobra_s1", + "price_per_kg": 0, + "note": "Nur Kobra S1, Tür zu.", + }, + { + "name": "Sunlu PA12-CF", + "brand": "Sunlu", + "material_type": "PA", + "density": 1.08, + "volumetric": 8, + "volumetric_s1": 8, + "machines": "kobra_s1", + "price_per_kg": 50, + "note": "Nur Kobra S1, gehärtete Düse. Der ACE trocknet das nicht, dafür den externen Trockner.", + }, + { + "name": "eSUN PETG-CF", + "brand": "eSUN", + "material_type": "PETG", + "density": 1.26, + "volumetric": 8, + "volumetric_s1": 8, + "machines": "kobra_s1", + "price_per_kg": 0, + "note": "Nur Kobra S1, gehärtete Düse.", + }, +] + +SOLIDS = [ + {"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"}, + {"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"}, + {"id": "grau", "name": "Grau", "hex": "#8a8f8c"}, + {"id": "rot", "name": "Rot", "hex": "#c23b3b"}, + {"id": "orange", "name": "Orange", "hex": "#e07a2f"}, + {"id": "gelb", "name": "Gelb", "hex": "#e2c04a"}, + {"id": "gruen", "name": "Grün", "hex": "#3d8f5a"}, + {"id": "blau", "name": "Blau", "hex": "#3a6ea5"}, + {"id": "hellblau", "name": "Hellblau", "hex": "#7eb6d9"}, + {"id": "lila", "name": "Lila", "hex": "#7a5ea7"}, + {"id": "pink", "name": "Pink", "hex": "#e7a0b4"}, + {"id": "braun", "name": "Braun", "hex": "#8a5a3c"}, + {"id": "beige", "name": "Beige", "hex": "#d7c4a3"}, + {"id": "natur", "name": "Natur", "hex": "#e4d7c3"}, + {"id": "gold", "name": "Gold", "hex": "#d4af37"}, + {"id": "silber", "name": "Silber", "hex": "#c0c6cc"}, + {"id": "transparent", "name": "Transparent", "hex": "#d5e4ea"}, + {"id": "kohle", "name": "Kohlefaser", "hex": "#2a2a2a"}, + {"id": "glow", "name": "Glow", "hex": "#b6f25c"}, +] + +MULTI = [ + { + "id": "regenbogen", + "name": "Regenbogen", + "colors": [ + {"name": "Rot", "hex": "#e23b3b"}, + {"name": "Orange", "hex": "#e07a2f"}, + {"name": "Gelb", "hex": "#e2c04a"}, + {"name": "Grün", "hex": "#3d8f5a"}, + {"name": "Blau", "hex": "#3a6ea5"}, + {"name": "Lila", "hex": "#7a5ea7"}, + ], + }, + { + "id": "holz", + "name": "Holz", + "colors": [ + {"name": "Braun", "hex": "#8a5a3c"}, + {"name": "Beige", "hex": "#d7c4a3"}, + {"name": "Dunkelbraun", "hex": "#5c3a24"}, + ], + }, + { + "id": "marmor", + "name": "Marmor", + "colors": [ + {"name": "Weiß", "hex": "#f4f4f4"}, + {"name": "Grau", "hex": "#8a8f8c"}, + {"name": "Schwarz", "hex": "#1c1c1c"}, + ], + }, + { + "id": "schwarz-weiss", + "name": "Schwarz / Weiß", + "colors": [ + {"name": "Schwarz", "hex": "#1c1c1c"}, + {"name": "Weiß", "hex": "#f4f4f4"}, + ], + }, + { + "id": "rot-schwarz", + "name": "Rot / Schwarz", + "colors": [ + {"name": "Rot", "hex": "#c23b3b"}, + {"name": "Schwarz", "hex": "#1c1c1c"}, + ], + }, + { + "id": "blau-weiss", + "name": "Blau / Weiß", + "colors": [ + {"name": "Blau", "hex": "#3a6ea5"}, + {"name": "Weiß", "hex": "#f4f4f4"}, + ], + }, + { + "id": "gold-silber", + "name": "Gold / Silber", + "colors": [ + {"name": "Gold", "hex": "#d4af37"}, + {"name": "Silber", "hex": "#c0c6cc"}, + ], + }, +] + +MACHINE_LABELS = { + "beide": "Kobra 3 und S1", + "kobra_3": "nur Kobra 3", + "kobra_s1": "nur Kobra S1", +} + + +def color_catalog(): + solids = [{"id": row["id"], "name": row["name"], "colors": [{"name": row["name"], "hex": row["hex"]}]} for row in SOLIDS] + multi = [{"id": row["id"], "name": row["name"], "colors": row["colors"]} for row in MULTI] + return {"solids": solids, "multi": multi} diff --git a/grotti/config.py b/grotti/config.py new file mode 100644 index 0000000..bcf5bbe --- /dev/null +++ b/grotti/config.py @@ -0,0 +1,170 @@ +"""Host, Port, Datenordner und Datenbank kommen aus config.json.""" + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LOOPBACK = {"127.0.0.1", "::1", "localhost"} +CONFIG_PATH = ROOT / "config.json" + + +def _energy(raw): + energy = raw.get("energy") or {} + return { + "watts": float(energy.get("watts", 120)), + "euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)), + "dryer_watts": float(energy.get("dryer_watts", 0)), + "dry_hours": float(energy.get("dry_hours", 0)), + "waste_percent": float(energy.get("waste_percent", 0)), + "utilization": float(energy.get("utilization", 50)), + } + + +def _telegram(raw): + tg = raw.get("telegram") or {} + return { + "enabled": bool(tg.get("enabled")), + "bot_token": str(tg.get("bot_token") or ""), + "chat_id": str(tg.get("chat_id") or ""), + } + + +def _alerts(raw): + alerts = raw.get("alerts") or {} + return { + "fertig": bool(alerts.get("fertig", True)), + "fehler": bool(alerts.get("fehler", True)), + "offline": bool(alerts.get("offline", True)), + "queue": bool(alerts.get("queue", True)), + "ablauf": bool(alerts.get("ablauf", False)), + "start": bool(alerts.get("start", False)), + } + + +def normalize_fqdn(value): + """Leer string oder Basis-URL ohne trailing slash; ohne Schema → https://.""" + text = str(value or "").strip().rstrip("/") + if not text: + return "" + if "://" not in text: + text = "https://" + text + return text.rstrip("/") + + +def public_base(cfg, request_root=None): + """Öffentliche Basis für Etiketten-QR: fqdn, sonst Request-Root.""" + base = normalize_fqdn(cfg.get("fqdn") or "") + if base: + return base + return str(request_root or "").rstrip("/") + + +def load(): + path = CONFIG_PATH + if not path.exists(): + path.write_text((ROOT / "config.example.json").read_text(encoding="utf-8"), encoding="utf-8") + raw = json.loads(path.read_text(encoding="utf-8")) + host = (raw.get("host") or "127.0.0.1").strip() + token = raw.get("token") or "" + if host not in LOOPBACK and not token: + raise SystemExit("Ohne Token startet der Dienst nicht, wenn er nicht nur lokal lauscht.") + data_dir = Path(raw.get("data_dir") or "data") + if not data_dir.is_absolute(): + data_dir = ROOT / data_dir + watch = (raw.get("watch_dir") or "").strip() + watch_dir = Path(watch) if watch else None + if watch_dir and not watch_dir.is_absolute(): + watch_dir = (ROOT / watch_dir).resolve() + return { + "host": host, + "port": int(raw.get("port") or 8770), + "data_dir": data_dir, + "watch_dir": watch_dir, + "database_url": raw.get("database_url") or "postgresql://postgres@127.0.0.1:5432/grotti", + "token": token, + "fqdn": normalize_fqdn(raw.get("fqdn") or ""), + "printers_file": ROOT / "drucker.json", + "energy": _energy(raw), + "telegram": _telegram(raw), + "alerts": _alerts(raw), + "auto_queue": bool(raw.get("auto_queue", True)), + "_raw": raw, + "_path": path, + } + + +def mask_token(token): + token = token or "" + if len(token) <= 8: + return "••••" if token else "" + return token[:4] + "…" + token[-2:] + + +def settings_public(cfg): + tg = cfg.get("telegram") or {} + return { + "watch_dir": str(cfg.get("watch_dir") or ""), + "auto_queue": bool(cfg.get("auto_queue", True)), + "fqdn": str(cfg.get("fqdn") or ""), + "energy": dict(cfg.get("energy") or {}), + "telegram": { + "enabled": bool(tg.get("enabled")), + "bot_token": mask_token(tg.get("bot_token") or ""), + "bot_token_set": bool(tg.get("bot_token")), + "chat_id": str(tg.get("chat_id") or ""), + }, + "alerts": dict(cfg.get("alerts") or {}), + } + + +def save_settings(cfg, energy=None, telegram=None, alerts=None, auto_queue=None, fqdn=None): + """Schreibt energy/telegram/alerts/auto_queue/fqdn zurück nach config.json und aktualisiert cfg.""" + path = cfg.get("_path") or CONFIG_PATH + raw = dict(cfg.get("_raw") or {}) + if path.exists(): + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + if auto_queue is not None: + raw["auto_queue"] = bool(auto_queue) + cfg["auto_queue"] = bool(auto_queue) + if fqdn is not None: + normalized = normalize_fqdn(fqdn) + raw["fqdn"] = normalized + cfg["fqdn"] = normalized + if energy is not None: + raw["energy"] = { + "watts": float(energy.get("watts", 120)), + "euro_per_kwh": float(energy.get("euro_per_kwh", 0.35)), + "dryer_watts": float(energy.get("dryer_watts", 0)), + "dry_hours": float(energy.get("dry_hours", 0)), + "waste_percent": float(energy.get("waste_percent", 0)), + "utilization": float(energy.get("utilization", 50)), + } + cfg["energy"] = _energy(raw) + if telegram is not None: + current = _telegram(raw) + token = telegram.get("bot_token") + if token is None or token == "" or "…" in str(token) or str(token).startswith("••••"): + token = current["bot_token"] + raw["telegram"] = { + "enabled": bool(telegram.get("enabled", current["enabled"])), + "bot_token": str(token or ""), + "chat_id": str(telegram.get("chat_id", current["chat_id"]) or ""), + } + cfg["telegram"] = _telegram(raw) + if alerts is not None: + raw["alerts"] = { + "fertig": bool(alerts.get("fertig", True)), + "fehler": bool(alerts.get("fehler", True)), + "offline": bool(alerts.get("offline", True)), + "queue": bool(alerts.get("queue", True)), + "ablauf": bool(alerts.get("ablauf", False)), + "start": bool(alerts.get("start", False)), + } + cfg["alerts"] = _alerts(raw) + path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + cfg["_raw"] = raw + cfg["_path"] = path + return settings_public(cfg) diff --git a/grotti/cost.py b/grotti/cost.py new file mode 100644 index 0000000..7ad2d32 --- /dev/null +++ b/grotti/cost.py @@ -0,0 +1,42 @@ +"""Dieselbe Rechnung wie in der Druckkalkulation.""" + + +def calculate(job, material): + part_g = float(job.get("weight_g") or 0) * max(1, float(job.get("qty") or 1)) + waste = float(job.get("waste_percent") or 0) + filament_g = part_g * (1 + waste / 100) + price = float(material.get("price_per_kg") or 0) + filament_cost = (filament_g / 1000) * price + util = min(100, max(1, float(job.get("utilization") or 1))) / 100 + density = float(material.get("density") or 0) or 1 + volumetric = float(material.get("volumetric") or 0) + cm3_per_hour = volumetric * 3.6 * util + hours = (filament_g / density) / cm3_per_hour if cm3_per_hour else 0 + watts = float(job.get("watts") or 0) + euro_per_kwh = float(job.get("euro_per_kwh") or 0) + print_kwh = (watts / 1000) * hours + dry_kwh = (float(job.get("dryer_watts") or 0) / 1000) * float(job.get("dry_hours") or 0) + energy_cost = (print_kwh + dry_kwh) * euro_per_kwh + if job.get("price_mode") == "perKg": + revenue = (part_g / 1000) * float(job.get("price") or 0) + else: + revenue = float(job.get("price") or 0) * max(1, float(job.get("qty") or 1)) + margin = revenue - filament_cost - energy_cost + per_hour = margin / hours if hours else 0 + return { + "part_g": part_g, + "filament_g": filament_g, + "filament_cost": filament_cost, + "hours": hours, + "energy_cost": energy_cost, + "revenue": revenue, + "margin": margin, + "per_hour": per_hour, + "print_kwh": print_kwh, + "dry_kwh": dry_kwh, + } + + +def energy_cost(duration_s, energy): + hours = float(duration_s or 0) / 3600 + return (float(energy["watts"]) / 1000) * hours * float(energy["euro_per_kwh"]) diff --git a/grotti/db.py b/grotti/db.py new file mode 100644 index 0000000..da06a54 --- /dev/null +++ b/grotti/db.py @@ -0,0 +1,62 @@ +"""PostgreSQL-Verbindung und Schema.""" + +import re +from contextlib import contextmanager +from pathlib import Path +from urllib.parse import urlparse, urlunparse + +import psycopg +from psycopg import sql +from psycopg.rows import dict_row + +SCHEMA = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8") + + +def database_name(url): + name = urlparse(url).path.lstrip("/") + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""): + raise SystemExit("Der Datenbankname in database_url darf nur Buchstaben, Zahlen und Unterstrich enthalten.") + return name + + +def apply_schema(conn): + for statement in SCHEMA.split(";"): + cleaned = statement.strip() + if cleaned: + conn.execute(cleaned) + + +def prepare(url): + name = database_name(url) + admin = urlunparse(urlparse(url)._replace(path="/postgres")) + try: + with psycopg.connect(admin, autocommit=True, connect_timeout=5) as conn: + exists = conn.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)).fetchone() + if not exists: + conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(name))) + except psycopg.Error: + pass + try: + with psycopg.connect(url, connect_timeout=5) as conn: + apply_schema(conn) + conn.commit() + except psycopg.Error as exc: + raise SystemExit( + "PostgreSQL ist nicht erreichbar.\n" + "In config.json bei database_url das Passwort eintragen, zum Beispiel\n" + "postgresql://postgres:DEINPASSWORT@127.0.0.1:5432/grotti\n" + "Die Datenbank grotti legt die App an, sobald die Anmeldung klappt.\n" + + str(exc) + ) from exc + + +@contextmanager +def connect(url): + conn = psycopg.connect(url, row_factory=dict_row) + try: + yield conn + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/grotti/eject.py b/grotti/eject.py new file mode 100644 index 0000000..1f3ff37 --- /dev/null +++ b/grotti/eject.py @@ -0,0 +1,671 @@ +"""Abräumen auf der Kobra 3: Abkühlen und mit dem Extruder nach vorn schieben.""" + +from __future__ import annotations + +from grotti.errors import GrottiError +from grotti.printers import family_from_name + +# Grenzen gegen Tippfehler (Wischpad ~Y 260, Achse max 260 — Ansetzen knapper davor) +LIMITS = { + "x_min": 0.0, + "x_max": 250.0, + "y_min": 0.0, + "y_max": 250.0, + "z_push_min": 0.6, + "z_push_max": 30.0, + "z_travel_min": 1.0, + "z_travel_max": 80.0, + "speed_min": 5.0, + "speed_max": 80.0, + "bed_max_lo": 15.0, + "bed_max_hi": 80.0, + "nozzle_max_lo": 20.0, + "nozzle_max_hi": 120.0, + "fan_lo": 0, + "fan_hi": 100, + "timeout_lo": 1, + "timeout_hi": 120, + "max_delta_lo": 0.05, + "max_delta_hi": 5.0, + "check_timeout_lo": 1, + "check_timeout_hi": 15, +} + +DEFAULT_COOL = { + "type": "cool", + "bed_max": 30.0, + "nozzle_max": 45.0, + "fan_percent": 100, + "timeout_min": 25, +} + +DEFAULT_SWEEP = { + "type": "sweep", + "z_push": 2.0, + "z_travel": 20.0, + "x_from": 25.0, + "x_to": 225.0, + "x_step": 20.0, + # möglichst weit hinten, bevor Z sinkt — Present war 220, Teile oft davor + "y_from": 250.0, + "y_to": 5.0, + "speed": 25.0, +} + +# Ein Punkt in der Bettmitte — kein LeviQ, kein 3×3, kein Heizen. +DEFAULT_CHECK = { + "type": "check", + # Result is z= gegen Grenze. Temporär 3 mm — kalter Kunststoff an der Düse + # tastet sonst oft 1–2 mm und meldet Restteil. Später wieder straffen. + "max_delta_mm": 3.0, + "timeout_min": 2, + "x": 125.0, + "y": 125.0, + "ref_x": 25.0, + "ref_y": 125.0, + "z_clear": 20.0, +} + + +def _num(value, default, lo, hi): + try: + n = float(value if value is not None else default) + except (TypeError, ValueError): + n = float(default) + return max(lo, min(hi, n)) + + +def _int(value, default, lo, hi): + return int(round(_num(value, default, lo, hi))) + + +def require_kobra_3(printer): + family = (printer or {}).get("family") or family_from_name((printer or {}).get("name") or "") + if family != "kobra_3": + raise GrottiError("Abräumen gibt es nur auf der Kobra 3.", "familie") + return family + + +def normalize_cool(step=None): + raw = step if isinstance(step, dict) else {} + return { + "type": "cool", + "bed_max": _num(raw.get("bed_max"), DEFAULT_COOL["bed_max"], LIMITS["bed_max_lo"], LIMITS["bed_max_hi"]), + "nozzle_max": _num( + raw.get("nozzle_max"), DEFAULT_COOL["nozzle_max"], LIMITS["nozzle_max_lo"], LIMITS["nozzle_max_hi"] + ), + "fan_percent": _int(raw.get("fan_percent"), DEFAULT_COOL["fan_percent"], LIMITS["fan_lo"], LIMITS["fan_hi"]), + "timeout_min": _int( + raw.get("timeout_min"), DEFAULT_COOL["timeout_min"], LIMITS["timeout_lo"], LIMITS["timeout_hi"] + ), + } + + +def normalize_sweep(step=None): + raw = step if isinstance(step, dict) else {} + z_push = _num(raw.get("z_push"), DEFAULT_SWEEP["z_push"], LIMITS["z_push_min"], LIMITS["z_push_max"]) + z_travel = _num(raw.get("z_travel"), DEFAULT_SWEEP["z_travel"], LIMITS["z_travel_min"], LIMITS["z_travel_max"]) + if z_travel < z_push + 1.0: + z_travel = min(LIMITS["z_travel_max"], z_push + 1.0) + x_from = _num(raw.get("x_from"), DEFAULT_SWEEP["x_from"], LIMITS["x_min"], LIMITS["x_max"]) + x_to = _num(raw.get("x_to"), DEFAULT_SWEEP["x_to"], LIMITS["x_min"], LIMITS["x_max"]) + x_step = abs(_num(raw.get("x_step"), DEFAULT_SWEEP["x_step"], 1.0, 100.0)) + y_from = _num(raw.get("y_from"), DEFAULT_SWEEP["y_from"], LIMITS["y_min"], LIMITS["y_max"]) + y_to = _num(raw.get("y_to"), DEFAULT_SWEEP["y_to"], LIMITS["y_min"], LIMITS["y_max"]) + if y_from <= y_to: + raise GrottiError("Schieben: Y hinten (y_from) muss größer sein als Y vorn (y_to).", "bahn") + return { + "type": "sweep", + "z_push": z_push, + "z_travel": z_travel, + "x_from": x_from, + "x_to": x_to, + "x_step": x_step, + "y_from": y_from, + "y_to": y_to, + "speed": _num(raw.get("speed"), DEFAULT_SWEEP["speed"], LIMITS["speed_min"], LIMITS["speed_max"]), + } + + +def normalize_print(step=None, fallback=None): + raw = step if isinstance(step, dict) else {} + fb = fallback if isinstance(fallback, dict) else {} + try: + file_id = int(raw.get("file_id") if raw.get("file_id") is not None else fb.get("file_id")) + except (TypeError, ValueError) as exc: + raise GrottiError("Druck-Schritt braucht eine Datei.", "datei") from exc + spool_raw = raw.get("spool_id") if "spool_id" in raw else fb.get("spool_id") + try: + spool_id = int(spool_raw) if spool_raw not in (None, "") else None + except (TypeError, ValueError): + spool_id = None + ace = raw.get("ace_map") if isinstance(raw.get("ace_map"), list) else fb.get("ace_map") + if not isinstance(ace, list): + ace = [] + return { + "type": "print", + "file_id": file_id, + "spool_id": spool_id, + "ace_map": ace, + "bed_leveling": bool(raw.get("bed_leveling", fb.get("bed_leveling", False))), + "override_machine": bool(raw.get("override_machine", fb.get("override_machine", False))), + } + + +def normalize_check(step=None): + raw = step if isinstance(step, dict) else {} + # Alte enge Grenzen (1.0 / 2.5) → aktueller Default (Düsen-Klumpen). + raw_delta = raw.get("max_delta_mm") + try: + if raw_delta is not None and raw_delta != "": + d = float(raw_delta) + if abs(d - 2.5) < 1e-9 or abs(d - 1.0) < 1e-9: + raw_delta = DEFAULT_CHECK["max_delta_mm"] + except (TypeError, ValueError): + raw_delta = raw.get("max_delta_mm") + return { + "type": "check", + "max_delta_mm": _num( + raw_delta, DEFAULT_CHECK["max_delta_mm"], LIMITS["max_delta_lo"], LIMITS["max_delta_hi"] + ), + "timeout_min": _int( + raw.get("timeout_min"), + DEFAULT_CHECK["timeout_min"], + LIMITS["check_timeout_lo"], + LIMITS["check_timeout_hi"], + ), + "x": _num(raw.get("x"), DEFAULT_CHECK["x"], LIMITS["x_min"], LIMITS["x_max"]), + "y": _num(raw.get("y"), DEFAULT_CHECK["y"], LIMITS["y_min"], LIMITS["y_max"]), + "ref_x": _num(raw.get("ref_x"), DEFAULT_CHECK["ref_x"], LIMITS["x_min"], LIMITS["x_max"]), + "ref_y": _num(raw.get("ref_y"), DEFAULT_CHECK["ref_y"], LIMITS["y_min"], LIMITS["y_max"]), + "z_clear": _num( + raw.get("z_clear"), DEFAULT_CHECK["z_clear"], LIMITS["z_travel_min"], LIMITS["z_travel_max"] + ), + } + + +def normalize_steps(steps, fallback=None): + if not isinstance(steps, list) or not steps: + raise GrottiError("Ablauf braucht mindestens einen Schritt.", "ablauf") + out = [] + for raw in steps: + if not isinstance(raw, dict): + raise GrottiError("Ungültiger Ablauf-Schritt.", "ablauf") + kind = (raw.get("type") or "").strip().lower() + if kind == "print": + out.append(normalize_print(raw, fallback)) + elif kind == "cool": + out.append(normalize_cool(raw)) + elif kind == "sweep": + out.append(normalize_sweep(raw)) + elif kind == "check": + out.append(normalize_check(raw)) + else: + raise GrottiError("Unbekannter Schritt: %s" % kind, "ablauf") + if not any(step["type"] == "print" for step in out): + raise GrottiError("Ablauf braucht mindestens einen Druck-Schritt.", "ablauf") + return out + + +def first_print_file_id(steps): + for step in steps: + if step.get("type") == "print": + return step["file_id"] + return None + + +def x_positions(sweep): + start = sweep["x_from"] + end = sweep["x_to"] + step = sweep["x_step"] + if abs(end - start) < 0.01: + return [round(start, 2)] + direction = 1.0 if end >= start else -1.0 + positions = [] + x = start + # inklusive Endpunkt, ohne Endlosschleife + for _ in range(200): + positions.append(round(x, 2)) + if direction > 0 and x >= end - 0.01: + break + if direction < 0 and x <= end + 0.01: + break + x += direction * step + if direction > 0 and x > end: + positions.append(round(end, 2)) + break + if direction < 0 and x < end: + positions.append(round(end, 2)) + break + # Duplikate am Ende entfernen + cleaned = [] + for pos in positions: + if not cleaned or abs(cleaned[-1] - pos) > 0.01: + cleaned.append(pos) + return cleaned + + +# Abkühlen: Düse zur Bettmitte (Park während Abkühlung). +COOL_PARK_X = 125.0 +COOL_PARK_Y = 125.0 +# Slice-Ende hebt Z nicht — relativ weit hoch, gegen axis_maximum Z gekappt. +COOL_PARK_LIFT = 40.0 +COOL_PARK_Z_MAX_DEFAULT = 250.0 +COOL_PARK_TOLERANCE_MM = 20.0 +COOL_PARK_Z_TOLERANCE_MM = 2.0 + + +def cool_script(cool): + fan = max(0, min(100, int(cool["fan_percent"]))) + pwm = int(round(fan * 255 / 100)) if fan > 0 else 0 + lines = [ + "SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0", + "SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0", + ] + if pwm > 0: + lines.append("M106 S%d" % pwm) + else: + lines.append("M107") + return "\n".join(lines) + + +def cool_park_z_now(live): + pos = (live or {}).get("position") or [] + if len(pos) >= 3 and isinstance(pos[2], (int, float)): + return float(pos[2]) + return None + + +def cool_park_z_max(live): + z_max = (live or {}).get("z_max") + if isinstance(z_max, (int, float)) and float(z_max) > 10: + return float(z_max) + return float(COOL_PARK_Z_MAX_DEFAULT) + + +def cool_park_lift_mm(live): + """Relativer Z-Hub, begrenzt auf verbleibenden Weg bis Z-max.""" + z_now = cool_park_z_now(live) + z_max = cool_park_z_max(live) + want = float(COOL_PARK_LIFT) + if z_now is None: + return want + headroom = z_max - z_now + if headroom < 0.5: + return 0.0 + return min(want, headroom) + + +def cool_park_script(live=None): + """Relativ Z hoch (gegen Z-max gekappt), dann Bettmitte.""" + lift = cool_park_lift_mm(live) + lines = ["M83", "G90"] + if lift >= 0.5: + lines.extend([ + "G91", + "G0 Z%.2f F1800" % lift, + "G90", + ]) + lines.extend([ + "G0 X%.2f Y%.2f F6000" % (COOL_PARK_X, COOL_PARK_Y), + "M400", + "G90", + ]) + return "\n".join(lines) + + +def cool_parked(live, before_z=None, lift_mm=None, tolerance_mm=COOL_PARK_TOLERANCE_MM): + """True wenn Mitte und geplanter Z-Hub angekommen ist (before_z/lift_mm nötig).""" + pos = (live or {}).get("position") or [] + if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]): + return False + dx = float(pos[0]) - COOL_PARK_X + dy = float(pos[1]) - COOL_PARK_Y + if (dx * dx + dy * dy) ** 0.5 > float(tolerance_mm): + return False + if before_z is None or lift_mm is None: + return False + if float(lift_mm) < 0.5: + return True + need = float(before_z) + float(lift_mm) - float(COOL_PARK_Z_TOLERANCE_MM) + return float(pos[2]) >= need + + +def idle_timeout_script(cool=None): + """Leerlauf-Timeout verlängern — Stepper bleiben gehomed. + + Ohne cool-Schritt: 1 h (Ablauf-Druck). Mit cool: Cool-Zeit + 10 min. + """ + if cool and isinstance(cool, dict) and cool.get("timeout_min") is not None: + seconds = max(900, int(cool["timeout_min"]) * 60 + 600) + else: + seconds = 3600 + return "SET_IDLE_TIMEOUT TIMEOUT=%d" % seconds + + +def strip_end_motor_disable(content): + """M84/M18 aus G-code entfernen — Slice-Ende schaltet sonst die Stepper aus. + + Anycubic Present-Ende: `M84; disable motors` nach X250 Y220. Danach unhomed, + Abkühlen/Schieben scheitern. Kommentarzeilen bleiben. + """ + if content is None: + return content + if isinstance(content, bytes): + text = content.decode("utf-8", errors="replace") + as_bytes = True + else: + text = str(content) + as_bytes = False + out = [] + for line in text.splitlines(keepends=True): + body = line.split(";", 1)[0].strip().upper() + if body == "M84" or body.startswith("M84 ") or body == "M18" or body.startswith("M18 "): + continue + out.append(line) + text = "".join(out) + return text.encode("utf-8") if as_bytes else text + + +def restore_position_script(live, sweep, allow_low_z=False): + """Nach M84/Idle: letzte Toolhead-XYZ übernehmen (keine Bewegung). + + Nur echte Moonraker-Position — keine erfundene Z. Nach Slice-M84 steht der Kopf + noch am Present; SET_KINEMATIC mit genau dieser XYZ ist korrekt. Zu niedrige Z → Abbruch. + """ + _ = allow_low_z + pos = (live or {}).get("position") or [] + if len(pos) < 3 or not all(isinstance(v, (int, float)) for v in pos[:3]): + raise GrottiError( + "Achsen nicht gehomed und letzte Position unbekannt. " + "Bei leerem Bett in Mainsail G28, dann Ablauf neu.", + "homed", + ) + x, y, z = (float(v) for v in pos[:3]) + min_z = float((sweep or {}).get("z_push") or 2) + 1 + if z < min_z: + raise GrottiError( + "Achsen nicht gehomed und Düse zu tief gemeldet (Z %.1f mm) — unsicher. " + "Bei leerem Bett in Mainsail G28, dann Ablauf neu." % z, + "homed", + ) + return "SET_KINEMATIC_POSITION X=%.3f Y=%.3f Z=%.3f" % (x, y, z), z + + +def cool_ready(live, cool): + bed = live.get("bed") + nozzle = live.get("nozzle") + if not isinstance(bed, (int, float)) or not isinstance(nozzle, (int, float)): + return False + return float(bed) <= float(cool["bed_max"]) and float(nozzle) <= float(cool["nozzle_max"]) + + +def explain_printer_error(message): + """Moonraker/Klipper-Text → kurze Grotti-Meldung.""" + text = (message or "").strip() + low = text.lower() + if "must home axis first" in low or "must home" in low: + return ( + "Achsen sind nicht gehomed. Gleich nach dem Druck abräumen " + "(bevor die Stepper ausgehen), oder in Mainsail homen — " + "Z nur bei leerem Bett." + ) + if "is not homed" in low: + return ( + "Achsen sind nicht gehomed. In Mainsail homen " + "(Z nur bei leerem Bett), dann nochmal abräumen." + ) + if "no trigger on probe" in low: + return ( + "Taste hat nicht ausgelöst (Wägezelle, voller Hub ohne Kontakt). " + "Bei leerem Bett meist falsche Z nach Leerlauf (zu wenig Hub) — " + "nicht Restteil. Düse auf Klumpen prüfen, dann Tasten erneut; " + "Grotti setzt vor dem Tasten ein Z-Budget." + ) + return text or "Der Drucker hat den Befehl abgelehnt." + + +# Senken auf z_push: nie F3000 — bei falscher Z-Null war das Vollgas ins Bett. +SWEEP_FEED_DOWN = 400 # mm/min ≈ 6,7 mm/s + + +def sweep_go_back_high_script(sweep): + """Nur hoch + nach hinten (y_from) — noch nicht senken.""" + travel = sweep["z_travel"] + mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0 + feed_travel = max(3000, int(round(float(sweep["speed"]) * 60))) + return "\n".join([ + "G90", + "G91", + "G0 Z%.2f F1800" % max(20.0, float(travel)), + "G90", + "G0 X%.2f Y%.2f F%d" % (mid_x, sweep["y_from"], feed_travel), + "M400", + ]) + + +def sweep_lower_to_push_script(sweep): + """Langsam auf z_push — kein F3000-Sturz.""" + push = sweep["z_push"] + return "\n".join([ + "G90", + "G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN), + "M400", + ]) + + +def sweep_probe_height_script(sweep): + """Kompatibel: hoch, hinten, langsam auf z_push.""" + return sweep_go_back_high_script(sweep) + "\n" + sweep_lower_to_push_script(sweep) + + +def assert_clear_before_lower(live, sweep, min_z_mm=15.0): + """Vor dem Senken: Toolhead muss noch klar über dem Bett stehen.""" + pos = (live or {}).get("position") or [] + if len(pos) < 3 or not isinstance(pos[2], (int, float)): + raise GrottiError("Schieben: Z-Position unbekannt — kein Senken.", "bahn") + z = float(pos[2]) + need = max(float(min_z_mm), float(sweep["z_push"]) + 8.0) + if z < need: + raise GrottiError( + "Schieben gestoppt: nach hinten nur Z %.1f mm (braucht ≥ %.0f mm). " + "Senken würde ins Bett/Teil rauschen — Z-Null prüfen, leeres Bett: G28." + % (z, need), + "bahn", + ) + + +def assert_at_push_height(live, sweep, tol_mm=1.5): + """Wenn Z nicht bei z_push liegt, würde die Bahn das Teil nur überfliegen.""" + pos = (live or {}).get("position") or [] + if len(pos) < 3 or not isinstance(pos[2], (int, float)): + raise GrottiError("Schieben: Z-Position unbekannt — Abbruch.", "bahn") + z = float(pos[2]) + want = float(sweep["z_push"]) + if abs(z - want) > float(tol_mm): + raise GrottiError( + "Schieben gestoppt: Düse bei Z %.1f mm, erwartet %.1f mm. " + "Mit falscher Z-Höhe fährt die Bahn über das Teil (ohne Schrittverlust). " + "Z-Koordinate und reale Höhe passen oft nicht — " + "bei leerem Bett in Mainsail G28, dann neu." + % (z, want), + "bahn", + ) + + +def sweep_script(sweep): + """Absolute Bahn: anheben, nach hinten, langsam senken, nach vorn — nur Config-Spuren.""" + travel = sweep["z_travel"] + push = sweep["z_push"] + y_from = sweep["y_from"] + y_to = sweep["y_to"] + # mm/s → mm/min für G0 F + feed = int(round(float(sweep["speed"]) * 60)) + feed_travel = max(feed, 3000) + mid_x = (sweep["x_from"] + sweep["x_to"]) / 2.0 + lines = [ + "G90", + "G91", + "G0 Z%.2f F1800" % max(20.0, float(travel)), + "G90", + "G0 X%.2f Y%.2f F%d" % (mid_x, y_from, feed_travel), + ] + for x in x_positions(sweep): + lines.append("G0 X%.2f Y%.2f F%d" % (x, y_from, feed_travel)) + lines.append("G0 Z%.2f F%d" % (push, SWEEP_FEED_DOWN)) + lines.append("G0 Y%.2f F%d" % (y_to, feed)) + lines.append("G0 Z%.2f F%d" % (travel, feed_travel)) + # hinten oben parken, weg von der Kiste + lines.append("G0 X%.2f Y%.2f Z%.2f F%d" % (mid_x, y_from, travel, feed_travel)) + lines.append("M107") + lines.append("M400") + return "\n".join(lines) + + +def check_script(check, current_z=None): + """Restteil-Tasten ohne G28 Z — mit aktivem Bettnetz (LeviQ). + + Absolut auf Freiraum (z_clear bzw. aktuelle Z), Tastpunkt, ein PROBE. + Result is z= = Höhe in der echten Koordinate. Kein SET Z=100 / Bezug-Null — + das hat die Koordinate nur kaputt gemacht. + """ + check = normalize_check(check) + clear = max(25.0, float(check["z_clear"])) + z_travel = clear + if isinstance(current_z, (int, float)) and float(current_z) > clear: + z_travel = float(current_z) + return "\n".join([ + "G90", + "G0 Z%.2f F1800" % z_travel, + "G0 X%.2f Y%.2f F6000" % (check["x"], check["y"]), + "PROBE", + "M400", + "G90", + ]) + + +def check_z_from_live(live): + """Nur Notfall: probe.last_z_result. Toolhead-Z nicht nutzen (Retract +2 mm).""" + probe_z = (live or {}).get("probe_z") + if isinstance(probe_z, (int, float)): + return float(probe_z), "probe" + return None, "" + + +def check_lift_script(check): + """Nach dem Tasten absolut hoch — Düse nicht auf dem Bett lassen.""" + check = normalize_check(check) + return "\n".join([ + "G90", + "G0 Z%.2f F1800" % max(25.0, float(check["z_clear"])), + "M400", + ]) + + +def _axis_coords(lo, hi, count): + if count <= 1: + return [(lo + hi) / 2.0] + return [lo + (hi - lo) * i / (count - 1) for i in range(count)] + + +def _matrix_ok(matrix): + if not isinstance(matrix, list) or not matrix: + return False + width = None + for row in matrix: + if not isinstance(row, list) or not row: + return False + if width is None: + width = len(row) + if len(row) != width: + return False + for value in row: + if not isinstance(value, (int, float)): + return False + return True + + +def interpolate_mesh_z(mesh, x, y): + """Bilinear aus Roh-probed_matrix. Klipper: Zeile 0 = mesh_min Y.""" + matrix = mesh.get("matrix") if isinstance(mesh, dict) else None + if not _matrix_ok(matrix): + return None + mesh_min = mesh.get("mesh_min") or [0, 0] + mesh_max = mesh.get("mesh_max") or [0, 0] + try: + x0, y0 = float(mesh_min[0]), float(mesh_min[1]) + x1, y1 = float(mesh_max[0]), float(mesh_max[1]) + except (TypeError, ValueError, IndexError): + return None + rows = len(matrix) + cols = len(matrix[0]) + if cols < 1 or rows < 1: + return None + if abs(x1 - x0) < 1e-9 or abs(y1 - y0) < 1e-9: + return float(matrix[0][0]) + tx = 0.0 if cols == 1 else (float(x) - x0) / (x1 - x0) * (cols - 1) + ty = 0.0 if rows == 1 else (float(y) - y0) / (y1 - y0) * (rows - 1) + tx = max(0.0, min(float(cols - 1), tx)) + ty = max(0.0, min(float(rows - 1), ty)) + c0 = int(tx) + r0 = int(ty) + c1 = min(cols - 1, c0 + 1) + r1 = min(rows - 1, r0 + 1) + fx = tx - c0 + fy = ty - r0 + z00 = float(matrix[r0][c0]) + z10 = float(matrix[r0][c1]) + z01 = float(matrix[r1][c0]) + z11 = float(matrix[r1][c1]) + z0 = z00 * (1 - fx) + z10 * fx + z1 = z01 * (1 - fx) + z11 * fx + return z0 * (1 - fy) + z1 * fy + + +def mesh_delta_mm(baseline, measured): + """Max |Δ| und Mittel |Δ|: Messpunkte gegen interpoliertes Baseline-Netz.""" + if not isinstance(baseline, dict) or not _matrix_ok(baseline.get("matrix")): + raise GrottiError("Kein Bettnetz zum Vergleich — vorher nivellieren.", "mesh") + if not isinstance(measured, dict) or not _matrix_ok(measured.get("matrix")): + raise GrottiError("Neues Bettnetz fehlt nach dem Antasten.", "mesh") + matrix = measured["matrix"] + mesh_min = measured.get("mesh_min") or [0, 0] + mesh_max = measured.get("mesh_max") or [0, 0] + try: + x0, y0 = float(mesh_min[0]), float(mesh_min[1]) + x1, y1 = float(mesh_max[0]), float(mesh_max[1]) + except (TypeError, ValueError, IndexError) as exc: + raise GrottiError("Messnetz ohne gültigen Bereich.", "mesh") from exc + rows = len(matrix) + cols = len(matrix[0]) + xs = _axis_coords(x0, x1, cols) + ys = _axis_coords(y0, y1, rows) + deltas = [] + for ri, y in enumerate(ys): + for ci, x in enumerate(xs): + expected = interpolate_mesh_z(baseline, x, y) + if expected is None: + raise GrottiError("Baseline-Netz lässt sich nicht interpolieren.", "mesh") + deltas.append(abs(float(matrix[ri][ci]) - expected)) + if not deltas: + raise GrottiError("Keine Messpunkte zum Vergleich.", "mesh") + return { + "max_delta_mm": max(deltas), + "mean_delta_mm": sum(deltas) / len(deltas), + "points": len(deltas), + } + + +def step_label(step): + if not isinstance(step, dict): + return "" + kind = step.get("type") + if kind == "print": + return "Drucken" + if kind == "cool": + return "Abkühlen" + if kind == "sweep": + return "Schieben" + if kind == "check": + return "Tasten" + return kind or "" diff --git a/grotti/errors.py b/grotti/errors.py new file mode 100644 index 0000000..3978e68 --- /dev/null +++ b/grotti/errors.py @@ -0,0 +1,6 @@ +class GrottiError(Exception): + def __init__(self, message, code="fehler", status=400): + super().__init__(message) + self.message = message + self.code = code + self.status = status diff --git a/grotti/jobs.py b/grotti/jobs.py new file mode 100644 index 0000000..6784b4e --- /dev/null +++ b/grotti/jobs.py @@ -0,0 +1,503 @@ +"""Senden, Steuern und das Druckprotokoll.""" + +import json +import urllib.error +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone + +from grotti import acm, eject, library, moonraker, store +from grotti.cost import energy_cost +from grotti.errors import GrottiError +from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode +from grotti.printers import FAMILY_LABELS + +TERMINAL = {"completed", "cancelled", "error", "interrupted"} + + +def weight_of(metadata): + raw = (metadata or {}).get("filament_weight_total") + if isinstance(raw, list): + return sum(float(item or 0) for item in raw) + if isinstance(raw, (int, float)): + return float(raw) + if isinstance(raw, str): + try: + return float(raw.replace(",", ".")) + except ValueError: + return 0.0 + return 0.0 + + +def material_of(metadata): + raw = (metadata or {}).get("filament_type") or "" + if isinstance(raw, list): + raw = ";".join(str(item) for item in raw if item) + return str(raw).split(";")[0].strip() + + +def unix_to_dt(value): + if not value: + return None + return datetime.fromtimestamp(float(value), timezone.utc) + + +def choose_spool(conn, host, material, hinted_id): + if hinted_id: + row = store.get_spool_row(conn, hinted_id) + if row: + return row + rows = store.spools_for_printer(conn, host) + if len(rows) == 1: + return rows[0] + if material: + same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()] + if len(same) == 1: + return same[0] + return None + + +def file_material_types(file_row): + types = [] + for item in (file_row or {}).get("filaments") or []: + kind = (item.get("type") or "").strip() + if kind and kind not in types: + types.append(kind) + return types + + +def resolve_spools_at_host(conn, host, ace_map=None): + """Spulen am Drucker zur Startzeit: ACE-Plätze aus Map, sonst externe Rolle.""" + mapping = acm.normalize_mapping(ace_map) + found = [] + if mapping: + for item in mapping: + slot = int(item["ams_index"]) + 1 + row = conn.execute( + """ + SELECT * FROM spools + WHERE printer_host = %s AND ace_slot = %s + ORDER BY id LIMIT 1 + """, + (host, slot), + ).fetchone() + if row: + found.append(row) + return found + # Externe Spule: höchstens eine ohne ACE-Platz am Host. + rows = conn.execute( + """ + SELECT * FROM spools + WHERE printer_host = %s AND ace_slot IS NULL + ORDER BY id + """, + (host,), + ).fetchall() + return list(rows) + + +def assert_material_match(file_row, spools): + """Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste.""" + want = {t.lower() for t in file_material_types(file_row)} + if not want or not spools: + return + have = {(row.get("material_type") or "").strip().lower() for row in spools} + have.discard("") + if not have: + return + if want.isdisjoint(have): + raise GrottiError( + "Material passt nicht: Datei braucht %s, im Drucker steckt %s." + % (", ".join(sorted(want)), ", ".join(sorted(have)) or "unbekannt"), + "material", + 409, + ) + + +def assert_spool_at_host(conn, spool_id, host): + if not spool_id or not host: + return + spool = store.get_spool_row(conn, spool_id) + if not spool: + return + if (spool.get("printer_host") or "") != host: + raise GrottiError( + "Spule „%s“ steckt nicht in diesem Drucker (Ort: %s)." + % (spool["name"], spool.get("printer_host") or "Regal"), + "spule", + 409, + ) + + +def apply_deduction(conn, run, energy): + if run["deducted"] or run["weight_g"] <= 0 or run["state"] not in TERMINAL: + return False + weight = float(run["weight_g"] or 0) + # ACE: Abzug anteilig auf die Plätze der letzten Sendung + sent = store.latest_send(conn, run["printer_host"], run.get("filename") or "") + ace = [] + if sent: + raw = sent.get("ace_map") + if isinstance(raw, list): + ace = raw + elif isinstance(raw, str): + try: + ace = json.loads(raw) + except json.JSONDecodeError: + ace = [] + targets = [] + if ace: + for item in acm.normalize_mapping(ace): + slot = int(item["ams_index"]) + 1 + row = conn.execute( + """ + SELECT * FROM spools + WHERE printer_host = %s AND ace_slot = %s + ORDER BY id LIMIT 1 + """, + (run["printer_host"], slot), + ).fetchone() + if row: + targets.append(row) + if not targets and run.get("spool_id"): + spool = store.get_spool_row(conn, run["spool_id"]) + if spool: + targets = [spool] + if not targets: + return False + share = weight / len(targets) + filament = sum((share / 1000) * float(spool["price_per_kg"] or 0) for spool in targets) + power = energy_cost(run["duration_s"], energy) + marked = store.mark_deducted(conn, run["id"], targets[0]["id"], filament, power) + if not marked: + return False + for spool in targets: + store.deduct_spool(conn, spool["id"], share) + return True + + +def sync_history(conn, cfg): + printers = store.list_printers(conn) + + def fetch(printer): + return printer, moonraker.history(printer["host"]) + + with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool: + fetched = list(pool.map(fetch, printers)) + collected = [] + for printer, items in fetched: + for job in items: + collected.append((printer, job)) + deducted = 0 + for printer, job in collected: + job_id = str(job.get("job_id") or "") + if not job_id: + continue + filename = job.get("filename") or "" + metadata = job.get("metadata") or {} + weight = weight_of(metadata) + material = material_of(metadata) + state = job.get("status") or "" + sent = store.latest_send(conn, printer["host"], filename) + known = store.file_by_name(conn, filename) + file_id = (sent or {}).get("file_id") or (known or {}).get("id") + project_id = (sent or {}).get("project_id") or (known or {}).get("project_id") + hinted = (sent or {}).get("spool_id") or (known or {}).get("spool_id") + spool = choose_spool(conn, printer["host"], material, hinted) + terminal = state in TERMINAL + row = store.upsert_run(conn, { + "printer_host": printer["host"], + "printer_name": printer["name"], + "filename": filename, + "file_id": file_id, + "project_id": project_id, + "spool_id": spool["id"] if spool else None, + "state": state, + "started_at": unix_to_dt(job.get("start_time")), + "ended_at": unix_to_dt(job.get("end_time")), + "duration_s": float(job.get("print_duration") or job.get("total_duration") or 0), + "weight_g": weight, + "energy_cost": energy_cost(job.get("print_duration") or job.get("total_duration") or 0, cfg["energy"]), + "moonraker_job_id": job_id, + "needs_spool": bool(terminal and weight > 0 and not spool), + "material_type": material, + }) + if apply_deduction(conn, row, cfg["energy"]): + deducted += 1 + return {"gelesen": len(collected), "abgezogen": deducted} + + +def assert_filament_enough(conn, file_row, spool_id=None, ace_map=None, host=""): + """Dateien-Gewicht gegen Spule oder ACE-Plätze prüfen.""" + need = float((file_row or {}).get("weight_g") or 0) + if need <= 0: + return + mapping = acm.normalize_mapping(ace_map) + if mapping and host: + leftovers = [] + for item in mapping: + slot = int(item["ams_index"]) + 1 + row = conn.execute( + """ + SELECT remaining_g, name FROM spools + WHERE printer_host = %s AND ace_slot = %s + ORDER BY id LIMIT 1 + """, + (host, slot), + ).fetchone() + if row: + leftovers.append(float(row["remaining_g"] or 0)) + if leftovers and sum(leftovers) + 0.5 < need: + raise GrottiError( + "ACE-Plätze haben nur %.0f g, Datei braucht %.0f g." + % (sum(leftovers), need), + "spule", + ) + return + if not spool_id: + return + spool = store.get_spool_row(conn, spool_id) + if not spool: + return + left = float(spool["remaining_g"] or 0) + if left + 0.5 < need: + raise GrottiError( + "Spule „%s“ hat nur %.0f g, Datei braucht %.0f g." + % (spool["name"], left, need), + "spule", + ) + + +def prepare_send(conn, file_id, host, confirm, override, spool_id, ace_map=None): + if not confirm: + raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung") + printer = store.printer_by_host(conn, host) + if not printer: + raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) + row = store.get_file_row(conn, file_id) + if not row: + raise GrottiError("Die Datei fehlt.", "datei", 404) + file_family = row["printer_family"] + if file_family and printer["family"] and file_family != printer["family"] and not override: + raise GrottiError( + "Die Datei ist für %s geschnitten, der Drucker ist eine %s." + % (FAMILY_LABELS.get(file_family, file_family), printer["family_label"] or "andere Maschine"), + "maschine", + 409, + ) + # Spule ist Ort des Druckers — Auflösung zur Startzeit, optionaler Hinweis nur wenn + # die gewählte Spule wirklich an diesem Host steckt. + if spool_id: + assert_spool_at_host(conn, spool_id, host) + at_host = resolve_spools_at_host(conn, host, ace_map) + assert_material_match(row, at_host) + if spool_id and store.get_spool_row(conn, spool_id): + chosen = spool_id + elif len(at_host) == 1: + chosen = at_host[0]["id"] + elif at_host: + chosen = at_host[0]["id"] + else: + chosen = None + assert_filament_enough(conn, row, chosen, ace_map, host) + return printer, row, chosen + + +def send_file( + conn, + cfg, + file_id, + host, + confirm, + override, + spool_id, + on_progress=None, + prepared=False, + ace_map=None, + bed_leveling=False, + keep_motors=False, +): + def progress(step, percent, message): + if on_progress: + on_progress(step, percent, message) + + if prepared: + printer = store.printer_by_host(conn, host) + row = store.get_file_row(conn, file_id) + if not printer or not row: + raise GrottiError("Die Datei oder der Drucker fehlt.", "datei", 404) + chosen = spool_id + if chosen and not store.get_spool_row(conn, chosen): + chosen = None + else: + printer, row, chosen = prepare_send( + conn, file_id, host, confirm, override, spool_id, ace_map=ace_map + ) + + if prepared: + assert_filament_enough(conn, row, chosen, ace_map, host) + + progress("pruefen", 15, "Drucker prüfen…") + try: + live = moonraker.query(host) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as exc: + raise GrottiError("Der Drucker ist nicht erreichbar.", "offline") from exc + if live.get("state") in ("printing", "paused"): + raise GrottiError("Der Drucker druckt schon.", "belegt", 409) + state = (live.get("state") or "").lower() + if state == "error": + # Hängender Status nach fehlgeschlagenem Befehl — Start-G-code homed selbst, + # aber print_stats=error blockiert. Kurz zurücksetzen, dann weiter. + try: + moonraker.clear_print_error(host) + live = moonraker.query(host) + state = (live.get("state") or "").lower() + except Exception: + pass + if state == "error": + raise GrottiError( + "Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann nochmal starten.", + "fehler", + 409, + ) + # Kein Homing-Zwang vor Start: Idle löscht homed_axes; der Slice-Start macht G28. + + slots = live.get("slots") or moonraker.slots(host) + mapping = acm.normalize_mapping(ace_map) + filaments = list(row.get("filaments") or []) + if slots and not mapping: + raise GrottiError( + "Dieser Drucker hat ACE/MMU-Plätze. Bitte Farbe der Datei einem ACE-Platz zuordnen.", + "ace", + ) + if slots and mapping and not filaments: + filaments = [{"type": "PLA", "color": ""}] + + progress("vorbereiten", 30, "Datei vorbereiten…") + filename, content, plate = library.printer_file(row) + ace_tools = [item.get("paint_index") for item in mapping] if mapping else None + if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"): + content = prepare_rinkhals_gcode(content, tools=ace_tools) + if keep_motors: + # Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper. + content = eject.strip_end_motor_disable(content) + size_mb = len(content) / (1024 * 1024) + + def upload_progress(sent, total): + if total <= 0: + return + frac = min(1.0, sent / total) + pct = 45 + int(frac * 19) + if sent >= total: + progress( + "hochladen", + pct, + "%s liest die Datei…" % printer["name"], + ) + return + progress( + "hochladen", + pct, + "Hochladen zu %s… %.0f/%.0f MB" % ( + printer["name"], + sent / (1024 * 1024), + total / (1024 * 1024), + ), + ) + + progress( + "hochladen", + 45, + "Hochladen zu %s… 0/%.0f MB" % (printer["name"], size_mb), + ) + ace_payload = None + warnings = [] + has_trailer = content_has_ace_trailer(content, kind=row.get("kind") or "", plate=plate) + if slots and mapping and not has_trailer: + # Nach prepare_rinkhals_gcode sollte der Trailer da sein; sonst Warnung. + warnings.append(acm.TRAILER_WARN) + progress("ace", 42, acm.TRAILER_WARN) + try: + stored = moonraker.upload(host, filename, content, on_progress=upload_progress) + if slots and mapping: + ace_payload = acm.build_acm(filaments, mapping, slots) + if ace_payload: + progress("ace", 65, "ACE-Zuordnung hochladen…") + body = acm.acm_bytes(ace_payload) + for acm_name in acm.acm_upload_names(stored): + moonraker.upload(host, acm_name, body) + progress("starten", 80, "Druck starten…") + moonraker.start_print( + host, + stored, + bed_leveling=bool(bed_leveling), + ams_settings=ace_payload, + ) + except moonraker.PrinterError as exc: + try: + moonraker.heaters_off(host) + except Exception: + pass + raise GrottiError(exc.message, "drucker") from exc + + progress("buchen", 95, "Auftrag speichern…") + store.add_send( + conn, + host, + stored, + row["id"], + row["project_id"], + chosen, + ace_map=mapping, + bed_leveling=bool(bed_leveling), + ) + # needs_clear erst bei Druckende (Watcher) — sonst kurz „Platte belegt“ vorm Start + note = "Gestartet auf %s: %s" % (printer["name"], stored) + if bed_leveling: + note += " (Bettnetz vor dem Druck)" + if warnings: + note += " · " + " · ".join(warnings) + progress("fertig", 100, note) + return { + "printer": printer["name"], + "filename": stored, + "plate": plate, + "ace": bool(slots and mapping), + "ace_trailer": has_trailer, + "warnings": warnings, + "bed_leveling": bool(bed_leveling), + "bed_leveling_wired": True, + } + + +def control(conn, host, action): + printer = store.printer_by_host(conn, host) + if not printer: + raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) + try: + moonraker.command(host, action) + except moonraker.PrinterError as exc: + raise GrottiError(exc.message, "drucker") from exc + return {"printer": printer["name"], "action": action} + + +def steuern(conn, host, action, params=None): + printer = store.printer_by_host(conn, host) + if not printer: + raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) + try: + moonraker.steuern(host, action, params) + except moonraker.PrinterError as exc: + raise GrottiError(exc.message, "drucker") from exc + return {"printer": printer["name"], "action": action} + + +def assign_and_deduct(conn, cfg, run_id, spool_id): + spool = store.get_spool_row(conn, spool_id) + if not spool: + raise GrottiError("Die Spule fehlt.", "spule", 404) + run = store.assign_run_spool(conn, run_id, spool_id) + if not run: + raise GrottiError("Der Lauf fehlt.", "lauf", 404) + apply_deduction(conn, run, cfg["energy"]) + fresh = store.get_run_row(conn, run_id) + return store.run_out(fresh) diff --git a/grotti/library.py b/grotti/library.py new file mode 100644 index 0000000..3e2b694 --- /dev/null +++ b/grotti/library.py @@ -0,0 +1,181 @@ +"""Watch-Ordner, Upload und Vorschaubild.""" + +import time +import zipfile +from pathlib import Path + +from grotti import store +from grotti.errors import GrottiError +from grotti.parse import embedded_png, inspect_file, safe_zip_name + +_LAST_WATCH_SYNC = 0.0 +_WATCH_MIN_INTERVAL_S = 20 + + +def skip_path(path, data_dir): + if any(part.startswith(".") or part == "__pycache__" for part in path.parts): + return True + resolved = path.resolve() + data = data_dir.resolve() + return resolved == data or data in resolved.parents + + +def meta_for(path): + parsed = inspect_file(path) + stat = path.stat() + parsed.update({ + "name": path.name, + "stored_path": str(path.resolve()), + "size_bytes": stat.st_size, + "mtime": stat.st_mtime, + }) + return parsed + + +def sync_watch(conn, cfg, force=False): + global _LAST_WATCH_SYNC + folder = cfg["watch_dir"] + if folder is None: + return {"gelesen": 0, "hinweis": "In config.json watch_dir auf den Slice-Ordner setzen."} + if not folder.exists(): + return {"gelesen": 0, "hinweis": "Der Watch-Ordner fehlt: %s" % folder} + now = time.monotonic() + if not force and _LAST_WATCH_SYNC and now - _LAST_WATCH_SYNC < _WATCH_MIN_INTERVAL_S: + return {"gelesen": 0, "hinweis": "", "skipped": True} + seen = [] + for path in folder.rglob("*"): + if not path.is_file() or skip_path(path, cfg["data_dir"]): + continue + lower = path.name.lower() + if not (lower.endswith(".gcode") or lower.endswith(".3mf")): + continue + try: + store.upsert_scanned(conn, meta_for(path), force=force) + seen.append(str(path.resolve())) + except (OSError, zipfile.BadZipFile): + continue + store.forget_missing_watch(conn, seen) + _LAST_WATCH_SYNC = now + return {"gelesen": len(seen), "hinweis": ""} + + +def refresh_file_meta(conn, force=False): + """Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments).""" + if not force: + return 0 + # list_files/file_out ohne stored_path — Rohzeilen nutzen. + updated = 0 + for row in store.list_file_rows(conn): + stored = row.get("stored_path") or "" + if not stored: + continue + path = Path(stored) + if not path.is_file(): + continue + try: + meta = meta_for(path) + except (OSError, zipfile.BadZipFile): + continue + store.update_file( + conn, + row["id"], + { + "printer_family": meta.get("printer_family") or row.get("printer_family"), + "filaments": meta.get("filaments") or [], + "weight_g": meta.get("weight_g") or 0, + "estimated_s": meta.get("estimated_s") or 0, + "thumb_entry": meta.get("thumb_entry") or "", + "kind": meta.get("kind") or row.get("kind"), + "plate": meta.get("plate") or "", + "slice_meta": meta.get("slice_meta") or {}, + }, + ) + updated += 1 + return updated + + +def safe_filename(name): + base = Path(name).name.replace("\\", "_").replace("/", "_").replace('"', "") + return base or "datei.gcode" + + +def unique_path(directory, filename): + filename = safe_filename(filename) + if filename.lower().endswith(".gcode.3mf"): + stem, suffix = filename[: -len(".gcode.3mf")], ".gcode.3mf" + else: + stem, suffix = Path(filename).stem, Path(filename).suffix + candidate = directory / filename + number = 2 + while candidate.exists(): + candidate = directory / ("%s-%s%s" % (stem, number, suffix)) + number += 1 + return candidate + + +def save_upload(conn, filename, content, cfg, project_id=None): + directory = cfg["data_dir"] / "eingang" + directory.mkdir(parents=True, exist_ok=True) + path = unique_path(directory, filename) + path.write_bytes(content) + meta = meta_for(path) + meta["project_id"] = project_id + return store.insert_upload(conn, meta) + + +def ensure_library_gcode(conn, cfg, host, filename): + """Wenn die laufende Datei fehlt: einmal vom Drucker nach Grotti ziehen.""" + base = (filename or "").replace("\\", "/").split("/")[-1] + if not base or not base.lower().endswith(".gcode"): + return None + known = store.file_by_name(conn, base) + if known: + path = Path(known.get("stored_path") or "") + if path.is_file(): + return store.file_out(known) + from grotti import moonraker + + pulled = moonraker.download_gcode(host, base) + if not pulled: + return None + name, content = pulled + return save_upload(conn, name, content, cfg) + + +def printer_file(row): + path = Path(row["stored_path"]) + if not path.is_file(): + raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) + if row["kind"] == "gcode": + return path.name, path.read_bytes(), "" + if row["kind"] != "gcode.3mf": + raise GrottiError("Die Datei enthält keinen G-code. Zuerst slicen.", "ungesliced") + return path.name, path.read_bytes(), row["plate"] or "" + + +def preview_png(row): + path = Path(row["stored_path"]) + if not path.is_file(): + return None + entry = row["thumb_entry"] + if row["kind"] == "gcode": + return embedded_png(path.read_bytes()[:400_000].decode("utf-8", "replace")) + if not zipfile.is_zipfile(path): + return None + with zipfile.ZipFile(path) as archive: + names = [] + if entry and entry != "embedded" and safe_zip_name(entry) and entry in archive.namelist(): + names.append(entry) + names.extend( + name for name in archive.namelist() + if name.lower().endswith(".png") and safe_zip_name(name) and name not in names + ) + for name in names: + data = archive.read(name) + if data.startswith(b"\x89PNG"): + return data + plate = row["plate"] + if plate and safe_zip_name(plate) and plate in archive.namelist(): + with archive.open(plate) as handle: + return embedded_png(handle.read(400_000).decode("utf-8", "replace")) + return None diff --git a/grotti/moonraker.py b/grotti/moonraker.py new file mode 100644 index 0000000..2b14d21 --- /dev/null +++ b/grotti/moonraker.py @@ -0,0 +1,970 @@ +"""Moonraker, nur für Drucker, die in Grotti stehen.""" + +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +from grotti.parse import embedded_png + +META_CACHE = {} +CAMERA_CACHE = {} +EMBEDDED = {} +SLOT_CACHE = {} +MESH_CACHE = {} +OBJECT_CACHE = {} +THUMB_RE_LIMIT = 180_000 + + +def matrix_rows(points): + if isinstance(points, list) and points and isinstance(points[0], list): + return list(reversed(points)) + return [] + + +def extract_mesh(mesh): + mesh = mesh or {} + shown = matrix_rows(mesh.get("probed_matrix")) + if not shown: + shown = matrix_rows(mesh.get("mesh_matrix")) + profile = mesh.get("profile_name") or "" + return shown, profile + + +def _raw_matrix(points): + if isinstance(points, list) and points and isinstance(points[0], list): + rows = [] + for row in points: + if not isinstance(row, list): + return [] + cleaned = [] + for value in row: + if not isinstance(value, (int, float)): + return [] + cleaned.append(float(value)) + rows.append(cleaned) + return rows + return [] + + +def fetch_bed_mesh(host): + """Roh-Bettnetz von Moonraker (ohne UI-Spiegelung).""" + url = "http://%s/printer/objects/query?bed_mesh" % host + payload = fetch_json(url, timeout=10) + status = (payload.get("result") or {}).get("status") or {} + mesh = status.get("bed_mesh") or {} + matrix = _raw_matrix(mesh.get("probed_matrix")) + if not matrix: + matrix = _raw_matrix(mesh.get("mesh_matrix")) + mesh_min = mesh.get("mesh_min") + mesh_max = mesh.get("mesh_max") + if not (isinstance(mesh_min, (list, tuple)) and len(mesh_min) >= 2): + mesh_min = None + if not (isinstance(mesh_max, (list, tuple)) and len(mesh_max) >= 2): + mesh_max = None + if not matrix or mesh_min is None or mesh_max is None: + return None + return { + "matrix": matrix, + "mesh_min": [float(mesh_min[0]), float(mesh_min[1])], + "mesh_max": [float(mesh_max[0]), float(mesh_max[1])], + "profile": mesh.get("profile_name") or "", + } + + +def store_mesh(host, shown, profile=""): + if not shown: + return + MESH_CACHE[host] = { + "mesh": shown, + "at": time.time(), + "profile": profile or "", + } + + +def mesh_fields(host, shown=None, profile=""): + """Live-Netz vom Drucker, sonst zuletzt bekannte Matrix (auch offline).""" + if shown: + store_mesh(host, shown, profile) + cached = MESH_CACHE[host] + return { + "mesh": cached["mesh"], + "mesh_at": cached["at"], + "mesh_cached": False, + "mesh_profile": cached.get("profile") or "", + } + cached = MESH_CACHE.get(host) + if cached and cached.get("mesh"): + return { + "mesh": cached["mesh"], + "mesh_at": cached["at"], + "mesh_cached": True, + "mesh_profile": cached.get("profile") or "", + } + return { + "mesh": [], + "mesh_at": None, + "mesh_cached": False, + "mesh_profile": "", + } + + +class PrinterError(Exception): + def __init__(self, message): + super().__init__(message) + self.message = message + + +def is_timeout(exc): + text = (getattr(exc, "message", None) or str(exc) or "").lower() + return "zu lange nicht geantwortet" in text or "timed out" in text + + +def fetch_json(url, timeout=4): + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response) + + +def read_error(exc): + try: + payload = json.loads(exc.read().decode("utf-8", "replace")) + message = (payload.get("error") or {}).get("message") + if message: + if "cannot parse the file" in message.lower(): + return "Der Drucker lehnt die Datei ab." + return message + except (json.JSONDecodeError, AttributeError, UnicodeError): + pass + return "Der Drucker hat den Aufruf abgelehnt." + + +def post_json(host, path, payload, timeout=20): + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + "http://%s%s" % (host, path), + data=data, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read() + return json.loads(raw.decode("utf-8")) if raw else {} + except urllib.error.HTTPError as exc: + raise PrinterError(read_error(exc)) from exc + except TimeoutError as exc: + raise PrinterError("Der Drucker hat zu lange nicht geantwortet.") from exc + except urllib.error.URLError as exc: + reason = str(getattr(exc, "reason", "") or exc) + if "timed out" in reason.lower() or isinstance(getattr(exc, "reason", None), TimeoutError): + raise PrinterError("Der Drucker hat zu lange nicht geantwortet.") from exc + raise PrinterError("Der Drucker ist nicht erreichbar.") from exc + + +def multipart(filename, content): + boundary = "----GrottiBoundary7f3a" + ascii_name = "".join(ch if ch.isascii() and ch not in '"\\' else "_" for ch in filename) or "job.gcode" + quoted = urllib.parse.quote(filename) + fields = ( + "--%s\r\nContent-Disposition: form-data; name=\"root\"\r\n\r\ngcodes\r\n" % boundary + ).encode("ascii") + head = ( + "--%s\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"%s\"; filename*=UTF-8''%s\r\n" + "Content-Type: application/octet-stream\r\n\r\n" + ) % (boundary, ascii_name, quoted) + tail = ("\r\n--%s--\r\n" % boundary).encode("ascii") + return boundary, fields + head.encode("ascii") + content + tail + + +class _UploadBody: + """Bytes als lesbarer Stream, damit urllib Fortschritt melden kann.""" + + def __init__(self, data, on_progress=None): + self._data = memoryview(data) + self._pos = 0 + self._on_progress = on_progress + self._last_pct = -1 + + def __len__(self): + return len(self._data) + + def read(self, size=-1): + total = len(self._data) + if self._pos >= total: + return b"" + if size is None or size < 0: + size = 64 * 1024 + end = min(self._pos + size, total) + chunk = self._data[self._pos:end].tobytes() + self._pos = end + if self._on_progress and total: + pct = int(100 * self._pos / total) + if pct != self._last_pct and (pct == 100 or pct - self._last_pct >= 2): + self._last_pct = pct + self._on_progress(self._pos, total) + return chunk + + +def upload_timeout(size): + # Große G-codes brauchen länger als 120 s (LAN + Moonraker-Metadaten). + size = max(0, int(size or 0)) + return max(180, min(600, 90 + size // (80 * 1024))) + + +def upload(host, filename, content, on_progress=None): + boundary, body = multipart(filename, content) + stream = _UploadBody(body, on_progress=on_progress) + request = urllib.request.Request( + "http://%s/server/files/upload" % host, + data=stream, + headers={ + "Content-Type": "multipart/form-data; boundary=%s" % boundary, + "Content-Length": str(len(body)), + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=upload_timeout(len(body))) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise PrinterError(read_error(exc)) from exc + except (urllib.error.URLError, TimeoutError) as exc: + raise PrinterError("Hochladen ist fehlgeschlagen.") from exc + item = (payload.get("result") or {}).get("item") or {} + return item.get("path") or filename + + +def start_print(host, filename, bed_leveling=False, ams_settings=None): + """Start: Moonraker nur ohne Bettnetz und ohne Grotti-ACE-Map. + + Moonraker `/printer/print/start` kennt nur filename. Rinkhals baut dann + MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm / + Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings + startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload. + """ + use_mqtt = bool(bed_leveling) or bool(ams_settings) + if not use_mqtt: + return post_json(host, "/printer/print/start", {"filename": filename}) + from grotti import anycubic_lan + + try: + return anycubic_lan.start_print( + host, + filename, + auto_leveling=bool(bed_leveling), + ams_settings=ams_settings, + ) + except anycubic_lan.LanError as exc: + raise PrinterError(exc.message) from exc + + +def command(host, action): + path = { + "pause": "/printer/print/pause", + "fortsetzen": "/printer/print/resume", + "abbrechen": "/printer/print/cancel", + }.get(action) + if not path: + raise PrinterError("Unbekannter Befehl.") + result = post_json(host, path, {}) + # Nach ACE/MQTT-Start reicht Moonraker-cancel nicht: Anycubic-Task bleibt + # aktiv → nächster MQTT-Start mit 10101. Best-effort MQTT stop. + if action == "abbrechen": + try: + from grotti import anycubic_lan + + anycubic_lan.cancel_print(host) + except Exception: + pass + return result + + +def gcode_script(host, script, timeout=30): + script = (script or "").strip() + if not script: + raise PrinterError("Kein G-code.") + return post_json(host, "/printer/gcode/script", {"script": script}, timeout=max(10, int(timeout))) + + +def heaters_off(host): + """Heizung und Lüfter aus — nach Fehler / Abbruch, nicht weiter heizen.""" + return gcode_script( + host, + "\n".join([ + "SET_HEATER_TEMPERATURE HEATER=extruder TARGET=0", + "SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=0", + "M107", + ]), + timeout=20, + ) + + +def clear_print_error(host): + """Hängenden print_stats/idle error lösen (Anycubic lässt sonst 'error' stehen).""" + try: + heaters_off(host) + except PrinterError: + pass + try: + post_json(host, "/printer/print/cancel", {}, timeout=15) + except PrinterError: + pass + gcode_script(host, "SDCARD_RESET_FILE", timeout=20) + try: + from grotti import anycubic_lan + + anycubic_lan.cancel_print(host) + except Exception: + pass + return {"ok": True, "message": "Fehlerstatus zurückgesetzt."} + + +def steuern(host, action, params=None): + """Whitelist-Steuerung ohne freie Konsole.""" + params = params if isinstance(params, dict) else {} + action = (action or "").strip() + + def pct(key, default=100, lo=0, hi=200): + try: + value = int(round(float(params.get(key, default)))) + except (TypeError, ValueError) as exc: + raise PrinterError("%s muss eine Zahl sein." % key) from exc + return max(lo, min(hi, value)) + + def temp(key): + try: + value = float(params.get(key)) + except (TypeError, ValueError) as exc: + raise PrinterError("%s muss eine Temperatur sein." % key) from exc + if value < 0 or value > 350: + raise PrinterError("Temperatur außerhalb 0–350 °C.") + return value + + if action == "nozzle": + return gcode_script( + host, + "SET_HEATER_TEMPERATURE HEATER=extruder TARGET=%.1f" % temp("temp"), + ) + if action == "bed": + return gcode_script( + host, + "SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=%.1f" % temp("temp"), + ) + if action == "speed": + return gcode_script(host, "M220 S%d" % pct("percent", 100, 10, 200)) + if action == "flow": + return gcode_script(host, "M221 S%d" % pct("percent", 100, 50, 150)) + if action == "fan": + value = pct("percent", 0, 0, 100) + if value <= 0: + return gcode_script(host, "M107") + return gcode_script(host, "M106 S%d" % int(round(value * 255 / 100))) + if action == "emergency_stop": + return post_json(host, "/printer/emergency_stop", {}, timeout=10) + if action == "clear_error": + return clear_print_error(host) + if action == "exclude_object": + name = str(params.get("name") or "").strip() + if not re.fullmatch(r"[A-Za-z0-9._+\-]+", name): + raise PrinterError("Ungültiger Objektname.") + return gcode_script(host, "EXCLUDE_OBJECT NAME=%s" % name, timeout=20) + if action == "firmware_restart": + # GoKlipper/Rinkhals: FIRMWARE_RESTART deadlocked gklib (Rinkhals #32). + raise PrinterError( + "Firmware-Restart geht auf GoKlipper nicht — Drucker am Display " + "neu starten oder Strom aus. Hängenden Fehler: „Fehler löschen“." + ) + raise PrinterError("Unbekannte Steuerung.") + + +def download_gcode(host, filename, max_bytes=40 * 1024 * 1024): + """Lädt eine G-code-Datei vom Drucker (für Grotti-Viewer-Cache).""" + name = (filename or "").replace("\\", "/").split("/")[-1] + if not name or ".." in name: + return None + url = "http://%s/server/files/gcodes/%s" % (host, urllib.parse.quote(name)) + try: + with urllib.request.urlopen(url, timeout=60) as response: + data = response.read(max_bytes + 1) + except (urllib.error.URLError, TimeoutError, OSError): + return None + if not data or len(data) > max_bytes: + return None + return name, data + + +def history(host, limit=50): + url = "http://%s/server/history/list?limit=%s&start=0&order=desc" % (host, int(limit)) + try: + payload = fetch_json(url, timeout=8) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): + return [] + return (payload.get("result") or {}).get("jobs") or [] + + +def metadata(host, path): + key = (host, path) + cached = META_CACHE.get(key) + now = time.time() + if cached and now - cached[0] < 60: + return cached[1] + url = "http://%s/server/files/metadata?filename=%s" % (host, urllib.parse.quote(path)) + try: + data = fetch_json(url).get("result") or {} + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError): + data = {} + META_CACHE[key] = (now, data) + return data + + +def preview_path(gcode_path, meta): + thumbs = meta.get("thumbnails") or [] + if not thumbs or not gcode_path: + return "" + best = max(thumbs, key=lambda item: item.get("width") or 0) + relative = (best.get("relative_path") or "").replace("\\", "/") + if not relative.endswith(".png") or ".thumbs/" not in relative or ".." in relative: + return "" + folder = gcode_path.replace("\\", "/").rsplit("/", 1)[0] if "/" in gcode_path else "" + return "%s/%s" % (folder, relative) if folder else relative + + +def embedded_preview(host, path): + key = (host, path) + if key in EMBEDDED: + return EMBEDDED[key] + url = "http://%s/server/files/gcodes/%s" % (host, urllib.parse.quote(path)) + request = urllib.request.Request(url, headers={"Range": "bytes=0-%s" % THUMB_RE_LIMIT}) + try: + with urllib.request.urlopen(request, timeout=6) as response: + text = response.read().decode("utf-8", "replace") + except (urllib.error.URLError, TimeoutError): + EMBEDDED[key] = None + return None + png = embedded_png(text) + EMBEDDED[key] = png + return png + + +def css_hex(value): + if isinstance(value, (list, tuple)) and len(value) >= 3: + channels = [] + for channel in value[:3]: + if isinstance(channel, bool) or not isinstance(channel, (int, float)): + return "" + channels.append(max(0, min(255, int(round(float(channel)))))) + return "#%02x%02x%02x" % tuple(channels) + if value in (None, ""): + return "" + text = str(value).strip() + if text.startswith("#"): + text = text[1:] + digits = "0123456789abcdefABCDEF" + if not text or any(ch not in digits for ch in text): + return "" + if len(text) == 8: + if text[6:8].lower() != "ff": + return "#" + text.lower() + text = text[:6] + if len(text) == 6: + return "#" + text.lower() + return "" + + +def slot_kind(value): + text = str(value or "").strip() + if not text or text.lower() == "unknown": + return "" + return text + + +def slots_from_mmu(mmu): + if not isinstance(mmu, dict): + return [] + colors = mmu.get("gate_color") + materials = mmu.get("gate_material") + names = mmu.get("gate_filament_name") + states = mmu.get("gate_status") + colors = colors if isinstance(colors, list) else [] + materials = materials if isinstance(materials, list) else [] + names = names if isinstance(names, list) else [] + states = states if isinstance(states, list) else [] + count = mmu.get("num_gates") + if not isinstance(count, int) or isinstance(count, bool) or count < 1: + count = max(len(colors), len(materials), len(names), len(states)) + found = [] + for index in range(min(count, 8)): + state = states[index] if index < len(states) else None + if state == 0: + continue + kind = slot_kind(materials[index] if index < len(materials) else "") + if not kind and index < len(names): + kind = slot_kind(names[index]) + color = css_hex(colors[index] if index < len(colors) else "") + if state not in (1, 2) and not kind and color in ("", "#000000"): + continue + if not color and not kind: + continue + found.append({"index": index, "color": color, "type": kind}) + return found + + +def slots_from_hubs(status): + hubs = status.get("filament_hubs") + if isinstance(hubs, list): + groups = hubs + elif isinstance(status.get("filament_hub"), dict): + groups = [status["filament_hub"]] + else: + return [] + found = [] + for hub_index, hub in enumerate(groups): + if not isinstance(hub, dict): + continue + items = hub.get("slots") + if not isinstance(items, list): + continue + for offset, item in enumerate(items): + if not isinstance(item, dict): + continue + index = item.get("index") + if isinstance(index, bool) or not isinstance(index, int): + index = hub_index * 4 + offset + raw_color = item.get("color") + if raw_color in (None, ""): + raw_color = item.get("colour") + color = css_hex(raw_color) + kind = slot_kind(item.get("type") or item.get("material") or item.get("filament_type")) + if not color and not kind: + continue + found.append({"index": index, "color": color, "type": kind}) + return found + + +def extract_slots(status): + status = status or {} + found = slots_from_mmu(status.get("mmu")) + if not found: + found = slots_from_hubs(status) + found.sort(key=lambda item: item.get("index") or 0) + return found[:8] + + +def slots(host, status=None): + if status is not None: + found = extract_slots(status) + SLOT_CACHE[host] = (time.time(), found) + return found + cached = SLOT_CACHE.get(host) + now = time.time() + if cached and now - cached[0] < 60: + return cached[1] + found = [] + try: + payload = fetch_json( + "http://%s/printer/objects/query?mmu" % host, + timeout=2, + ) + found = extract_slots((payload.get("result") or {}).get("status") or {}) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError): + found = [] + SLOT_CACHE[host] = (now, found) + return found + + +def fan_speed_of(entry): + if not isinstance(entry, dict): + return None + speed = entry.get("speed") + if isinstance(speed, (int, float)): + return max(0.0, min(1.0, float(speed))) + return None + + +def fan_values(status): + fan = status.get("fan") + if not isinstance(fan, dict): + return None, None + speed = fan_speed_of(fan) + rpm = fan.get("rpm") + if isinstance(rpm, (int, float)): + rpm = int(rpm) + else: + rpm = None + return speed, rpm + + +def _object_names(host): + now = time.time() + cached = OBJECT_CACHE.get(host) + if cached and now - cached[0] < 120: + return cached[1] + names = [] + try: + payload = fetch_json("http://%s/printer/objects/list" % host, timeout=3) + names = list((payload.get("result") or {}).get("objects") or []) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError, TypeError): + names = [] + OBJECT_CACHE[host] = (now, names) + return names + + +def _pick_object(names, prefixes, needles): + lowered = [(name, name.lower()) for name in names] + for needle in needles: + for name, low in lowered: + if any(low.startswith(prefix) for prefix in prefixes) and needle in low: + return name + for name, low in lowered: + if any(low.startswith(prefix) for prefix in prefixes): + return name + return "" + + +def chamber_and_fans(host, status): + names = _object_names(host) + chamber_name = _pick_object( + names, + ("temperature_sensor ", "heater_generic "), + ("chamber", "bauraum", "盒", "box"), + ) + part_name = "fan" if "fan" in names else "" + chamber_fan = _pick_object(names, ("temperature_fan ", "heater_fan ", "controller_fan "), ("chamber", "bauraum")) + aux_fan = _pick_object(names, ("temperature_fan ", "heater_fan ", "controller_fan ", "fan_generic "), ("aux", "auxiliary", "side")) + wanted = [name for name in (chamber_name, part_name, chamber_fan, aux_fan) if name and name not in status] + extra = {} + if wanted: + try: + url = "http://%s/printer/objects/query?%s" % ( + host, + "&".join(urllib.parse.quote(name, safe="") for name in wanted), + ) + payload = fetch_json(url, timeout=3) + extra = (payload.get("result") or {}).get("status") or {} + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError): + extra = {} + merged = dict(status) + merged.update(extra) + chamber = None + chamber_target = None + if chamber_name and isinstance(merged.get(chamber_name), dict): + entry = merged[chamber_name] + temp = entry.get("temperature") + if isinstance(temp, (int, float)): + chamber = float(temp) + target = entry.get("target") + if isinstance(target, (int, float)): + chamber_target = float(target) + fans = { + "part": fan_speed_of(merged.get("fan")), + "chamber": fan_speed_of(merged.get(chamber_fan)) if chamber_fan else None, + "aux": fan_speed_of(merged.get(aux_fan)) if aux_fan else None, + } + return chamber, chamber_target, fans + + +def last_gcode_error(host, limit=40): + """Letzte Fehlerzeile aus dem G-code-Log (Anycubic lässt print_stats.message oft leer).""" + try: + payload = fetch_json("http://%s/server/gcode_store?count=%d" % (host, int(limit)), timeout=3) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, OSError): + return "" + items = ((payload.get("result") or {}).get("gcode_store") or []) + for item in reversed(items): + if (item.get("type") or "") != "response": + continue + text = (item.get("message") or "").strip() + low = text.lower() + if "error" not in low and "!!" not in text and "must home" not in low: + continue + # "error: typ = WebRequestError, code = 10011600, message = Must home axis first" + marker = "message =" + if marker in text: + text = text.split(marker, 1)[-1].strip() + if text.lower().startswith("error:"): + text = text[6:].strip() + return text + return "" + + +def enrich_error_message(host, stats, status): + raw = (stats.get("message") or "").strip() + if raw and raw.lower() not in ("error", "fehler", "failed", "fail"): + return raw + bits = [] + detail = last_gcode_error(host) + if detail: + # Nur echte G-code-Antworten — leeres homed_axes im Idle ist normal + # (Stepper aus) und darf keinen Druckstart blockieren oder erklären. + if "must home" in detail.lower(): + bits.append("Bewegung ohne Homing (Must home axis first)") + else: + bits.append(detail) + duration = stats.get("print_duration") or 0 + if stats.get("filename") and isinstance(duration, (int, float)) and float(duration) <= 0: + bits.append("Druck sofort abgebrochen") + idle = ((status.get("idle_timeout") or {}).get("state") or "").lower() + if idle == "error" and not bits: + bits.append("Drucker im Fehlerzustand") + return " · ".join(bits) if bits else (raw or "Unbekannter Fehler — in Mainsail prüfen") + + +def exclude_status(host): + """Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert.""" + empty = {"objects": [], "excluded": [], "current": None} + url = "http://%s/printer/objects/query?exclude_object" % host + try: + payload = fetch_json(url, timeout=4) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, TypeError): + return empty + eo = ((payload.get("result") or {}).get("status") or {}).get("exclude_object") + if not isinstance(eo, dict): + return empty + objects = [] + for item in eo.get("objects") or []: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + center = item.get("center") + polygon = item.get("polygon") + objects.append({ + "name": name, + "center": center if isinstance(center, list) else [], + "polygon": polygon if isinstance(polygon, list) else [], + }) + excluded = [str(x) for x in (eo.get("excluded_objects") or []) if x] + current = eo.get("current_object") + return { + "objects": objects, + "excluded": excluded, + "current": str(current) if current else None, + } + + +def _probe_z_of(status): + probe = status.get("probe") + if not isinstance(probe, dict): + return None + raw = probe.get("last_z_result") + if isinstance(raw, (int, float)): + return float(raw) + return None + + +_PROBE_RESULT_RE = re.compile(r"Result is z=\s*([+-]?\d+(?:\.\d+)?)", re.I) + + +def _message_has_probe_cmd(message): + return bool(re.search(r"(?im)^\s*PROBE\s*$", message or "")) + + +def latest_probe_result_z(host, after_time=None): + """Tast-Z aus Moonraker-G-code-Store (`Result is z=`). + + Auf Kobra 3/GoKlipper ist `printer.probe` oft leer — `last_z_result` fehlt. + Tasten-Script: ein Moonraker-Befehl mit zwei PROBE (Rand-Null + Mitte) — + es zählt das **letzte** `Result is z=` nach diesem Befehl. + """ + url = "http://%s/server/gcode_store?count=60" % host + payload = fetch_json(url, timeout=10) + entries = (payload.get("result") or {}).get("gcode_store") or [] + min_t = None if after_time is None else float(after_time) - 2.0 + last_probe_cmd = -1 + for idx, entry in enumerate(entries): + if not isinstance(entry, dict) or (entry.get("type") or "") != "command": + continue + if not _message_has_probe_cmd(entry.get("message") or ""): + continue + if min_t is not None: + try: + if float(entry.get("time") or 0) < min_t: + continue + except (TypeError, ValueError): + continue + last_probe_cmd = idx + if last_probe_cmd >= 0: + found = None + for entry in entries[last_probe_cmd + 1 :]: + if not isinstance(entry, dict): + continue + if (entry.get("type") or "") == "command": + break + match = _PROBE_RESULT_RE.search(entry.get("message") or "") + if match: + found = float(match.group(1)) + return found + for entry in reversed(entries): + if not isinstance(entry, dict): + continue + if min_t is not None: + try: + if float(entry.get("time") or 0) < min_t: + continue + except (TypeError, ValueError): + continue + match = _PROBE_RESULT_RE.search(entry.get("message") or "") + if match: + return float(match.group(1)) + return None + + +def query(host): + base = ( + "print_stats&heater_bed&extruder&virtual_sdcard&bed_mesh&fan&gcode_move&mmu&toolhead&idle_timeout" + ) + names = _object_names(host) + extras = [] + if "probe" in names: + extras.append("probe") + url = "http://%s/printer/objects/query?%s" % (host, "&".join([base] + extras)) + payload = fetch_json(url) + status = payload["result"]["status"] + stats = status.get("print_stats") or {} + card = status.get("virtual_sdcard") or {} + extruder = status.get("extruder") or {} + bed = status.get("heater_bed") or {} + raw_name = stats.get("filename") or "" + filename = raw_name.replace("\\", "/").rsplit("/", 1)[-1] if raw_name else "" + meta = metadata(host, raw_name) if raw_name else {} + picture = preview_path(raw_name, meta) + if not picture and raw_name: + embedded_preview(host, raw_name) + shown, mesh_profile = extract_mesh(status.get("bed_mesh")) + info = stats.get("info") or {} + progress = card.get("progress") + if not isinstance(progress, (int, float)): + size = card.get("file_size") or 0 + pos = card.get("file_position") or 0 + progress = (pos / size) if size else 0 + quoted_host = urllib.parse.quote(host) + fan_speed, fan_rpm = fan_values(status) + chamber, chamber_target, fans = chamber_and_fans(host, status) + if fans.get("part") is None: + fans["part"] = fan_speed + move = status.get("gcode_move") or {} + speed_factor = move.get("speed_factor") + if isinstance(speed_factor, (int, float)): + speed_factor = float(speed_factor) + else: + speed_factor = None + extrude_factor = move.get("extrude_factor") + if isinstance(extrude_factor, (int, float)): + extrude_factor = float(extrude_factor) + else: + extrude_factor = None + state = stats.get("state") or "standby" + message = stats.get("message") or "" + if (state or "").lower() == "error": + message = enrich_error_message(host, stats, status) + toolhead = status.get("toolhead") or {} + axis_max = toolhead.get("axis_maximum") or [] + z_max = None + if isinstance(axis_max, (list, tuple)) and len(axis_max) >= 3 and isinstance(axis_max[2], (int, float)): + z_max = float(axis_max[2]) + return { + "ok": True, + "state": state, + "message": message, + "homed_axes": toolhead.get("homed_axes") or "", + "position": toolhead.get("position") or [], + "z_max": z_max, + "probe_z": _probe_z_of(status), + "filename": filename, + "progress": max(0, min(1, float(progress))), + "layer": info.get("current_layer") or card.get("current_layer") or 0, + "layers": info.get("total_layer") or card.get("total_layer") or 0, + "remain": card.get("remain_time") or 0, + "elapsed": stats.get("print_duration") or 0, + "nozzle": extruder.get("temperature"), + "nozzle_target": extruder.get("target"), + "bed": bed.get("temperature"), + "bed_target": bed.get("target"), + "chamber": chamber, + "chamber_target": chamber_target, + "material": card.get("filament_type") or meta.get("filament_type") or "", + "weight": meta.get("filament_weight_total") or 0, + "height": meta.get("object_height") or 0, + "layer_height": meta.get("layer_height") or card.get("layer_height") or 0, + "estimated": meta.get("estimated_time") or 0, + "preview": ( + "/api/v1/vorschau?host=%s&file=%s" % (quoted_host, urllib.parse.quote(picture)) + if picture else + "/api/v1/vorschau?host=%s&embedded=%s" % (quoted_host, urllib.parse.quote(raw_name)) + if raw_name and EMBEDDED.get((host, raw_name)) else "" + ), + "camera": "/api/v1/kamera?host=%s" % quoted_host, + **mesh_fields(host, shown, mesh_profile), + "fan": fan_speed, + "fan_rpm": fan_rpm, + "fans": fans, + "speed_factor": speed_factor, + "extrude_factor": extrude_factor, + "slots": slots(host, status), + "exclude": exclude_status(host), + } + + +def snapshot(printers): + def one(printer): + card = { + "name": printer["name"], + "host": printer["host"], + "family": printer["family"], + "family_label": printer["family_label"], + "mainsail": "http://%s/" % printer["host"], + } + try: + card.update(query(printer["host"])) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError) as error: + card.update({ + "ok": False, + "state": "offline", + "message": str(error), + "fan": None, + "fan_rpm": None, + "fans": {"part": None, "chamber": None, "aux": None}, + "chamber": None, + "chamber_target": None, + "speed_factor": None, + "extrude_factor": None, + "slots": [], + }) + card.update(mesh_fields(printer["host"])) + return card + + with ThreadPoolExecutor(max_workers=max(1, len(printers))) as pool: + return list(pool.map(one, printers)) + + +def proxy_image(host, url): + request = urllib.request.Request(url) + with urllib.request.urlopen(request, timeout=5) as response: + body = response.read() + kind = response.headers.get("Content-Type") or "image/jpeg" + return kind, body + + +def camera_image(host): + cached = CAMERA_CACHE.get(host) + now = time.time() + if cached and now - cached[0] < cached[1]: + return cached[2] + try: + kind, body = proxy_image(host, "http://%s/webcam/?action=snapshot" % host) + if not body.startswith(b"\xff\xd8") and not body.startswith(b"\x89PNG"): + raise urllib.error.URLError("kein Bild") + CAMERA_CACHE[host] = (now, 2, (kind, body)) + except (urllib.error.URLError, TimeoutError): + CAMERA_CACHE[host] = (now, 30, None) + return CAMERA_CACHE[host][2] + + +def embedded_bytes(host, name): + return EMBEDDED.get((host, name)) diff --git a/grotti/notify.py b/grotti/notify.py new file mode 100644 index 0000000..f91e55f --- /dev/null +++ b/grotti/notify.py @@ -0,0 +1,53 @@ +"""Telegram-Benachrichtigungen (Bot API).""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request + + +def telegram_configured(cfg): + tg = (cfg or {}).get("telegram") or {} + return bool(tg.get("enabled") and tg.get("bot_token") and tg.get("chat_id")) + + +def send_telegram(cfg, text): + """Sendet Text an Telegram. Fehler werden geschluckt (False).""" + if not telegram_configured(cfg): + return False + tg = cfg["telegram"] + url = "https://api.telegram.org/bot%s/sendMessage" % tg["bot_token"] + payload = urllib.parse.urlencode({ + "chat_id": str(tg["chat_id"]), + "text": text[:4000], + "disable_web_page_preview": "1", + }).encode("utf-8") + request = urllib.request.Request(url, data=payload, method="POST") + try: + with urllib.request.urlopen(request, timeout=12) as response: + body = json.loads(response.read().decode("utf-8", "replace")) + return bool(body.get("ok")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError): + return False + + +ALERT_GROUP = {"abbruch": "fehler"} +DEFAULT_OFF = {"ablauf", "start"} + + +def alert_enabled(cfg, kind): + alerts = (cfg or {}).get("alerts") or {} + key = ALERT_GROUP.get(kind, kind) + return bool(alerts.get(key, key not in DEFAULT_OFF)) + + +def record(conn, cfg, host, printer, kind, message, filename="", dedupe_s=60): + """Ereignis immer speichern; Telegram nur einmal pro Art und Drucker im Zeitfenster.""" + from grotti import store + + recent = store.recent_event_kind(conn, host, kind, within_s=dedupe_s) + store.add_event(conn, host, printer, kind, message, filename or "") + if not recent and alert_enabled(cfg, kind): + send_telegram(cfg, message) diff --git a/grotti/parse.py b/grotti/parse.py new file mode 100644 index 0000000..57ae511 --- /dev/null +++ b/grotti/parse.py @@ -0,0 +1,455 @@ +"""Metadaten aus G-code und 3MF, ohne den Drucker anzufassen.""" + +import base64 +import io +import json +import re +import zipfile +from pathlib import Path + +from grotti.printers import family_from_text + +THUMB_RE = re.compile(r"; thumbnail begin (\d+)x(\d+) \d+\s*(.*?); thumbnail end", re.S) +COMMENT_RE = re.compile(r"^;\s*([^:=]+?)\s*[:=]\s*(.*)$") +DURATION_RE = re.compile(r"(\d+)\s*([dhms])", re.I) +TRAILER_RE = re.compile(rb";\s*referenced_tools\s*=", re.I) +# Rinkhals mmu_ace_metadata.py: erste Zeile überspringt den vollen Scan auf dem Drucker. +RINKHALS_FINGERPRINT = b"; processed by MmuAcePatcher" +_SLICER_RE = re.compile(rb"^;.*generated by ([A-Za-z]*) .*$|^; (BambuStudio) .*$", re.I) +_TOOL_RE = re.compile(rb"^(?:MMU_CHANGE_TOOL(?:_STANDALONE)? .*?TOOL=|T)(\d{1,2})", re.I) +_AUTHORIZED_SLICERS = { + "prusaslicer", + "superslicer", + "orcaslicer", + "bambustudio", + "anycubicslicernext", +} + + +def parse_duration(value): + total = 0 + for number, unit in DURATION_RE.findall(value or ""): + total += int(number) * {"d": 86400, "h": 3600, "m": 60, "s": 1}[unit.lower()] + return total + + +def sum_numbers(value): + total = 0.0 + for number in re.findall(r"\d+(?:[.,]\d+)?", value or ""): + total += float(number.replace(",", ".")) + return total + + +def comments_of(text): + found = {} + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith(";"): + break + match = COMMENT_RE.match(stripped) + if match: + found[match.group(1).strip().lower()] = match.group(2).strip() + return found + + +def comments_scan(text): + """Alle `; key = value`-Zeilen, auch im Footer nach G-code.""" + found = {} + for line in (text or "").splitlines(): + match = COMMENT_RE.match(line.strip()) + if match: + found[match.group(1).strip().lower()] = match.group(2).strip() + return found + + +def _num_comment(comments, *keys): + for key in keys: + raw = comments.get(key) or "" + if not raw: + continue + match = re.search(r"[-+]?\d+(?:[.,]\d+)?", raw.replace(",", ".")) + if match: + try: + return float(match.group(0)) + except ValueError: + continue + return 0.0 + + +def _int_comment(comments, *keys): + value = _num_comment(comments, *keys) + return int(value) if value else 0 + + +def color_hex(channels): + if not isinstance(channels, (list, tuple)) or len(channels) < 3: + return "" + try: + rgb = [max(0, min(255, int(round(float(channel))))) for channel in channels[:3]] + except (TypeError, ValueError): + return "" + return "#%02x%02x%02x" % tuple(rgb) + + +def filaments_from_paint_info(raw): + try: + items = json.loads(raw or "") + except (TypeError, json.JSONDecodeError): + return [] + if not isinstance(items, list): + return [] + found = [] + for item in items: + if not isinstance(item, dict): + continue + try: + index = int(item.get("paint_index")) + except (TypeError, ValueError): + index = len(found) + while len(found) <= index: + found.append({"type": "", "color": ""}) + found[index] = { + "type": str(item.get("material_type") or "").strip(), + "color": color_hex(item.get("paint_color")), + } + return [row for row in found if row["type"] or row["color"]] + + +def filaments_of(comments): + painted = filaments_from_paint_info(comments.get("paint_info")) + if painted: + return painted + types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()] + colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")] + return [ + {"type": kind, "color": colors[index] if index < len(colors) else ""} + for index, kind in enumerate(types) + ] + + +def _gcode_lines(content): + start = 0 + size = len(content) + while start < size: + end = content.find(b"\n", start) + if end < 0: + line = content[start:] + if line.endswith(b"\r"): + line = line[:-1] + if line: + yield line + break + line = content[start:end] + if line.endswith(b"\r"): + line = line[:-1] + yield line + start = end + 1 + + +def _paint_tool_indexes(content): + """paint_index aus ; paint_info = […] im G-code.""" + found = set() + for line in _gcode_lines(content): + if b"paint_info" not in line.lower(): + continue + text = line.decode("utf-8", "replace") + match = COMMENT_RE.match(text.strip()) + raw = match.group(2).strip() if match else "" + if not raw and "=" in text: + raw = text.split("=", 1)[-1].strip() + try: + items = json.loads(raw or "") + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + try: + found.add(int(item.get("paint_index"))) + except (TypeError, ValueError): + continue + return found + + +def prepare_rinkhals_gcode(content, tools=None): + """Kopfzeile für Rinkhals plus `; referenced_tools`, wenn sie fehlt. + + `tools`: explizite Indizes (z. B. aus der ACE-Zuordnung). Sonst aus T-Befehlen + oder paint_info. Ohne Fingerprint scannt mmu_ace_metadata die ganze Datei auf + dem Drucker, bevor der Upload zurückkommt. + """ + if not content or content[:2] == b"PK": + return content + first_end = content.find(b"\n") + first = content if first_end < 0 else content[:first_end] + if first.endswith(b"\r"): + first = first[:-1] + has_fingerprint = first.lower().startswith(RINKHALS_FINGERPRINT.lower()) + forced = set() + for value in tools or []: + try: + forced.add(int(value)) + except (TypeError, ValueError): + continue + slicer = "" + found_tools = set() + for line in _gcode_lines(content): + if not slicer and line.startswith(b";"): + match = _SLICER_RE.match(line) + if match: + raw = match.group(1) or match.group(2) or b"" + slicer = raw.decode("ascii", "replace").lower() + match = _TOOL_RE.match(line) + if match: + found_tools.add(int(match.group(1))) + paint_tools = _paint_tool_indexes(content) + chosen = found_tools or forced or paint_tools + # ACE-Send mit Map, aber nichts erkannt: Einzelfarbe Platz 0. + if forced and not chosen: + chosen = {0} + stamped = content if has_fingerprint else (RINKHALS_FINGERPRINT + b"\n" + content) + need_trailer = chosen and not has_ace_trailer(content) + allow_trailer = bool(forced) or slicer in _AUTHORIZED_SLICERS + if need_trailer and allow_trailer: + if not stamped.endswith(b"\n"): + stamped += b"\n" + listed = ",".join(str(number) for number in sorted(chosen)) + stamped += ("; referenced_tools = %s\n" % listed).encode("ascii") + return stamped + + +def has_ace_trailer(data): + """True wenn Rinkhals-/Slicer-Trailer `; referenced_tools = …` vorhanden ist.""" + if not data: + return False + if isinstance(data, str): + data = data.encode("utf-8", "replace") + if TRAILER_RE.search(data[-131072:]): + return True + return bool(TRAILER_RE.search(data[:65536])) + + +def content_has_ace_trailer(content, kind="", plate=""): + if has_ace_trailer(content): + return True + kind = (kind or "").lower() + if kind not in ("gcode.3mf", "3mf") and not (content and content[:2] == b"PK"): + return False + try: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + names = [name for name in archive.namelist() if safe_zip_name(name)] + candidates = [] + if plate and plate in names: + candidates.append(plate) + candidates.extend( + name for name in names + if name.lower().endswith(".gcode") and name not in candidates + ) + for name in candidates[:3]: + with archive.open(name) as handle: + raw = handle.read() + if has_ace_trailer(raw): + return True + except (zipfile.BadZipFile, OSError, RuntimeError): + return False + return False + + +def _stream_has_ace_trailer(handle, head_limit=400_000): + """Liest Stream: Kopf für Parse, Ende für Trailer — ohne ganze Datei im RAM.""" + head = handle.read(head_limit) + found = bool(TRAILER_RE.search(head[:65536])) or bool(TRAILER_RE.search(head[-131072:])) + tail = head[-131072:] if len(head) > 131072 else head + while True: + chunk = handle.read(256 * 1024) + if not chunk: + break + if len(chunk) >= 131072: + tail = chunk[-131072:] + else: + tail = (tail + chunk)[-131072:] + if not found and TRAILER_RE.search(tail): + found = True + return found, head + + +def path_has_ace_trailer(path, kind="", plate=""): + path = Path(path) + if not path.is_file(): + return False + try: + kind = (kind or "").lower() + if kind in ("gcode.3mf", "3mf") or path.name.lower().endswith(".3mf"): + if not zipfile.is_zipfile(path): + return False + with zipfile.ZipFile(path) as archive: + names = [name for name in archive.namelist() if safe_zip_name(name)] + candidates = [] + if plate and plate in names: + candidates.append(plate) + candidates.extend( + name for name in names + if name.lower().endswith(".gcode") and name not in candidates + ) + for name in candidates[:3]: + with archive.open(name) as handle: + found, _ = _stream_has_ace_trailer(handle) + if found: + return True + return False + size = path.stat().st_size + with path.open("rb") as handle: + if size <= 200_000: + return has_ace_trailer(handle.read()) + head = handle.read(65536) + handle.seek(max(0, size - 131072)) + tail = handle.read(131072) + return has_ace_trailer(head + tail) + except (OSError, zipfile.BadZipFile, RuntimeError): + return False + + +def parse_gcode(text, trailer=""): + head = comments_of(text) + tail = comments_scan(trailer) if trailer else comments_scan(text[-131072:] if len(text) > 200_000 else "") + comments = dict(head) + comments.update(tail) + weight_key = comments.get("total filament used [g]") or comments.get("filament used [g]") or "" + model = comments.get("printer_model") or comments.get("printer model") or "" + family = family_from_text(model) if model else family_from_text(text[:8000]) + time_key = ( + comments.get("estimated printing time (normal mode)") + or comments.get("estimated printing time") + or comments.get("model printing time") + or "" + ) + slicer = "" + for line in (text or "").splitlines()[:40]: + match = re.search(r"generated by\s+(\S+)|;\s*(BambuStudio)\b", line, re.I) + if match: + slicer = (match.group(1) or match.group(2) or "").strip() + break + slice_meta = { + "layer_height": _num_comment(comments, "layer_height", "layer height"), + "layers": _int_comment(comments, "total layer number", "total layers count"), + "max_z_mm": _num_comment(comments, "max_z_height", "max z height", "object_height"), + "nozzle_mm": _num_comment(comments, "nozzle_diameter", "nozzle diameter"), + "temp_nozzle": _num_comment(comments, "first_layer_temperature", "nozzle_temperature"), + "temp_bed": _num_comment(comments, "bed_temperature", "first_layer_bed_temperature"), + "slicer": slicer, + } + return { + "printer_family": family, + "filaments": filaments_of(comments) or filaments_of(head), + "weight_g": sum_numbers(weight_key), + "estimated_s": parse_duration(time_key), + "thumb_entry": "embedded" if "; thumbnail begin" in text else "", + "ace_trailer": has_ace_trailer(text.encode("utf-8", "replace") if isinstance(text, str) else text), + "slice_meta": {key: value for key, value in slice_meta.items() if value}, + } + + +def embedded_png(text): + best = None + for match in THUMB_RE.finditer(text): + width = int(match.group(1)) + raw = "".join( + line[1:].strip() if line.startswith(";") else line.strip() + for line in match.group(3).splitlines() + ) + try: + png = base64.b64decode(raw) + except ValueError: + continue + if png.startswith(b"\x89PNG") and (best is None or width > best[0]): + best = (width, png) + return best[1] if best else None + + +def safe_zip_name(name): + parts = Path(name).parts + return not name.startswith(("/", "\\")) and ".." not in parts + + +def pick_name(names, suffix, preferred_end): + chosen = [name for name in names if name.lower().endswith(suffix) and safe_zip_name(name)] + for name in chosen: + if name.lower().endswith(preferred_end): + return name + return sorted(chosen)[0] if chosen else "" + + +def _read_gcode_head_tail(path, head_limit=400_000, tail_limit=131072): + path = Path(path) + size = path.stat().st_size + with path.open("rb") as handle: + head = handle.read(head_limit).decode("utf-8", "replace") + if size <= head_limit: + return head, "" + handle.seek(max(0, size - tail_limit)) + tail = handle.read(tail_limit).decode("utf-8", "replace") + return head, tail + + +def inspect_file(path): + path = Path(path) + empty = { + "kind": "gcode", + "printer_family": "", + "filaments": [], + "weight_g": 0, + "estimated_s": 0, + "thumb_entry": "", + "plate": "", + "ace_trailer": False, + "slice_meta": {}, + } + lower = path.name.lower() + if lower.endswith(".gcode") and not lower.endswith(".3mf"): + text, trailer = _read_gcode_head_tail(path) + meta = parse_gcode(text, trailer) + meta["kind"] = "gcode" + meta["plate"] = "" + meta["ace_trailer"] = path_has_ace_trailer(path, kind="gcode") + return meta + if not zipfile.is_zipfile(path): + empty["kind"] = "3mf" if lower.endswith(".3mf") else "gcode" + return empty + with zipfile.ZipFile(path) as archive: + names = [name for name in archive.namelist() if safe_zip_name(name) and not name.startswith("__MACOSX")] + plate = pick_name(names, ".gcode", "plate_1.gcode") + thumb = pick_name(names, ".png", "plate_1.png") + text = "" + trailer = "" + ace_trailer = False + if plate: + with archive.open(plate) as handle: + ace_trailer, head = _stream_has_ace_trailer(handle) + text = head.decode("utf-8", "replace") + # Trailer: Ende der Plate-Datei nachlesen + try: + info = archive.getinfo(plate) + if info.file_size > len(head): + with archive.open(plate) as again: + again.read(max(0, info.file_size - 131072)) + trailer = again.read(131072).decode("utf-8", "replace") + except (OSError, RuntimeError, KeyError): + trailer = "" + meta = parse_gcode(text, trailer) if text else dict(empty) + meta["kind"] = "gcode.3mf" if plate else "3mf" + meta["plate"] = plate + meta["thumb_entry"] = thumb or meta.get("thumb_entry") or "" + meta["ace_trailer"] = ace_trailer + if not meta["printer_family"]: + for entry in names: + if entry.lower().endswith("project_settings.config"): + raw = archive.read(entry)[:200_000] + try: + model = str(json.loads(raw).get("printer_model") or "") + except (json.JSONDecodeError, UnicodeDecodeError, AttributeError): + model = raw.decode("utf-8", "replace") + meta["printer_family"] = family_from_text(model) + break + return meta diff --git a/grotti/print_queue.py b/grotti/print_queue.py new file mode 100644 index 0000000..75908b2 --- /dev/null +++ b/grotti/print_queue.py @@ -0,0 +1,1169 @@ +"""Druckwarteschlange: startet den nächsten Job, sobald ein Drucker frei ist. + +Abläufe (Drucken → Abkühlen → Schieben, optional mehrfach) bleiben running, +bis die letzte Wiederholung durch ist. +""" + +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone + +from grotti import eject, jobs, moonraker, notify, store +from grotti.db import connect +from grotti.errors import GrottiError + +FREE = {"standby", "complete", "cancelled"} +BUSY = {"printing", "paused", "heating", "error"} +# deutlich unter Klippers idle_timeout (600 s) +KEEPALIVE_S = 120 +# Nach Start ohne print_active: Upload/MQTT/LeviQ brauchen Zeit, aber nicht ewig +PRINT_START_TIMEOUT_S = 600 + + +def _ablauf_needs_motors(item): + """True wenn nach dem aktuellen Schritt noch Cool/Sweep kommt (kein M84 im Upload).""" + steps = item.get("steps") or [] + try: + idx = int(item.get("step_index") or 0) + except (TypeError, ValueError): + idx = 0 + for step in steps[idx + 1 :]: + if isinstance(step, dict) and step.get("type") in ("cool", "sweep"): + return True + return False + + +def _cool_wait_text(live, cool): + """Ohne „ · “: die Oberfläche zeigt nur das letzte „ · “-Stück der Meldung.""" + waiting = [] + done = [] + for label, key, limit_key in (("Düse", "nozzle", "nozzle_max"), ("Bett", "bed", "bed_max")): + value = live.get(key) + limit = float(cool[limit_key]) + if not isinstance(value, (int, float)): + waiting.append("%s ? → ≤ %.0f °C" % (label, limit)) + elif float(value) > limit: + waiting.append("%s %.0f → ≤ %.0f °C" % (label, value, limit)) + else: + done.append("%s %.0f °C ok" % (label, value)) + text = "wartet auf " + " und ".join(waiting) if waiting else "gleich kühl genug" + if done: + text += ", " + ", ".join(done) + return text + + +def _parse_steps(row): + steps = row.get("steps") if isinstance(row, dict) else None + if isinstance(steps, str): + import json + + try: + steps = json.loads(steps) + except json.JSONDecodeError: + steps = [] + return steps if isinstance(steps, list) else [] + + +def _has_ablauf(row): + return bool(_parse_steps(row)) + + +class PrintQueueDispatcher: + def __init__(self, database_url, cfg, interval=8, watcher=None): + self.database_url = database_url + self.cfg = cfg + self.interval = max(4, int(interval)) + self.watcher = watcher + self._stop = threading.Event() + self._thread = None + self._keepalive = {} + self._cool_park = {} + self._workers = {} + self._worker_lock = threading.Lock() + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread( + target=self._loop, + name="grotti-print-queue", + daemon=True, + ) + self._thread.start() + + def stop(self): + self._stop.set() + + def _loop(self): + while not self._stop.is_set(): + try: + self.tick() + except Exception as exc: + print("Druck-Queue: %s" % exc, flush=True) + self._stop.wait(self.interval) + + def _live_map(self, printers): + if self.watcher: + cached = self.watcher.by_host() + age = self.watcher.age_s() + if cached and age is not None and age <= 30: + return cached + return {row["host"]: row for row in moonraker.snapshot(printers)} + + def _auto_enabled(self): + return bool((self.cfg or {}).get("auto_queue", True)) + + def _worker_busy(self, item_id): + with self._worker_lock: + thread = self._workers.get(item_id) + return bool(thread and thread.is_alive()) + + def _spawn_worker(self, item_id, target, *args): + with self._worker_lock: + existing = self._workers.get(item_id) + if existing and existing.is_alive(): + return False + + def run(): + try: + target(*args) + finally: + with self._worker_lock: + self._workers.pop(item_id, None) + + thread = threading.Thread( + target=run, + name="grotti-queue-job-%s" % item_id, + daemon=True, + ) + self._workers[item_id] = thread + thread.start() + return True + + def tick(self): + with connect(self.database_url) as conn: + printers = store.list_printers(conn) + if not printers: + conn.commit() + return + lives = self._live_map(printers) + # Zuerst laufende Abläufe weiterführen; diese Hosts sind für „wenn frei“ belegt. + ablauf_hosts = set() + for printer in printers: + host = printer["host"] + live = lives.get(host) or {} + running = store.running_print_on_host(conn, host) + if running and _has_ablauf(running): + ablauf_hosts.add(host) + # Langer Schritt läuft schon im Worker — nicht doppelt anstoßen. + if self._worker_busy(running["id"]): + continue + self._advance_ablauf(conn, running, printer, live) + conn.commit() + + if not self._auto_enabled(): + conn.commit() + return + + # Alle freien Drucker in diesem Tick anstoßen — nicht einer alle 8 s. + for printer in printers: + host = printer["host"] + if host in ablauf_hosts: + continue + if printer.get("locked"): + continue + live = lives.get(host) or {} + state = (live.get("state") or "").lower() + if live.get("ok") is False or state in ("offline", ""): + continue + # Hängender error (z. B. alter Fehlstart) blockiert sonst „wenn frei“ — + # räumen wie beim Sofort-Start, Slice macht G28 selbst. + if state == "error": + if not store.next_waiting_print(conn, host=host, family=printer.get("family") or ""): + continue + live = self._try_clear_stuck_error(conn, printer, live) + lives[host] = live + state = (live.get("state") or "").lower() + if state in BUSY: + continue + if state not in FREE: + continue + if store.running_print_on_host(conn, host): + continue + if store.bed_needs_clear(conn, host): + continue + waiting = store.next_waiting_print(conn, host=host, family=printer.get("family") or "") + if not waiting: + continue + claimed = store.claim_print_queue(conn, waiting["id"], host) + if not claimed: + continue + conn.commit() + ablauf_hosts.add(host) + if _has_ablauf(claimed): + self._begin_ablauf(conn, claimed, printer, live) + else: + self._start_simple(conn, claimed, printer) + conn.commit() + + def _start_simple(self, conn, item, printer): + """Upload/Start im Worker — mehrere freie Drucker parallel.""" + host = printer["host"] + item_id = item["id"] + ace = item.get("ace_map") or [] + if isinstance(ace, str): + import json + + try: + ace = json.loads(ace) + except json.JSONDecodeError: + ace = [] + ace_list = ace if isinstance(ace, list) else [] + store.update_print_queue( + conn, + item_id, + { + "status": "running", + "host": host, + "phase": "starting", + "phase_started_at": "now", + "message": "Wird gestartet…", + }, + ) + conn.commit() + printer_copy = dict(printer) + file_id = item["file_id"] + override = bool(item.get("override_machine")) + spool_id = item.get("spool_id") + bed_leveling = bool(item.get("bed_leveling")) + + def work(): + with connect(self.database_url) as wconn: + try: + result = jobs.send_file( + wconn, + self.cfg, + file_id, + host, + True, + override, + spool_id, + prepared=False, + ace_map=ace_list, + bed_leveling=bed_leveling, + ) + name = (result or {}).get("filename") or "" + store.update_print_queue( + wconn, + item_id, + { + "status": "done", + "message": "Gestartet auf %s: %s" % (printer_copy["name"], name), + "host": host, + "phase": "", + "phase_started_at": None, + }, + ) + self._event( + wconn, + host, + printer_copy["name"], + "ablauf", + "%s startet %s" % (printer_copy["name"], name), + name, + ) + wconn.commit() + except GrottiError as exc: + store.update_print_queue( + wconn, + item_id, + {"status": "error", "message": exc.message, "host": host, "phase": ""}, + ) + self._event( + wconn, + host, + printer_copy["name"], + "queue", + "%s Queue-Fehler: %s" % (printer_copy["name"], exc.message), + "", + ) + wconn.commit() + except Exception as exc: + store.update_print_queue( + wconn, + item_id, + { + "status": "error", + "message": str(exc) or "Start fehlgeschlagen.", + "host": host, + "phase": "", + }, + ) + self._event( + wconn, + host, + printer_copy["name"], + "queue", + "%s Queue-Fehler: %s" % (printer_copy["name"], exc), + "", + ) + wconn.commit() + + self._spawn_worker(item_id, work) + + def _begin_ablauf(self, conn, item, printer, live): + try: + eject.require_kobra_3(printer) + except GrottiError as exc: + self._fail_ablauf(conn, item, printer, exc.message) + return + store.update_print_queue( + conn, + item["id"], + { + "step_index": 0, + "repeat_done": 0, + "phase": "", + "phase_started_at": None, + "message": self._ablauf_message(item, "startet…"), + }, + ) + item = store.get_print_queue_row(conn, item["id"]) or item + steps = _parse_steps(item) + self._event( + conn, + printer["host"], + printer["name"], + "ablauf", + "%s Ablauf startet: %s (%s, %d×)" % ( + printer["name"], + item.get("filename") or "", + " → ".join(eject.step_label(step) for step in steps), + max(1, int(item.get("repeat_total") or 1)), + ), + item.get("filename") or "", + ) + self._run_current_step(conn, item, printer, live) + + def _try_clear_stuck_error(self, conn, printer, live): + """Hängenden print_stats=error räumen (Idle, kein aktiver Druck).""" + state = (live.get("state") or "").lower() + if state != "error": + return live + host = printer["host"] + before = live.get("message") or "Fehlerzustand" + try: + moonraker.clear_print_error(host) + fresh = moonraker.query(host) + except Exception as exc: + self._event(conn, host, printer["name"], "ablauf", + "%s: hängenden Fehler nicht geräumt (%s)" % (printer["name"], exc), "") + return live + if (fresh.get("state") or "").lower() != "error": + self._event(conn, host, printer["name"], "ablauf", + "%s: hängenden Fehler geräumt („%s“)" % (printer["name"], before), "") + return fresh + + def _advance_ablauf(self, conn, item, printer, live): + host = printer["host"] + state = (live.get("state") or "").lower() + if live.get("ok") is False or state in ("", "offline"): + return + + steps = _parse_steps(item) + if not steps: + store.update_print_queue(conn, item["id"], {"status": "done", "message": "Fertig.", "phase": ""}) + return + + step_index = int(item.get("step_index") or 0) + if step_index < 0 or step_index >= len(steps): + self._finish_or_repeat(conn, item, printer, live) + return + + step = steps[step_index] + kind = (step.get("type") or "").lower() + phase = (item.get("phase") or "").lower() + + # Hängender Fehler zwischen Schritten / vor Start: räumen und weiter. + # Nur mitten im Druck (print_active / printing nach Start) ist error ein Abbruch. + if state == "error" and phase not in ("starting", "printing", "print_active"): + live = self._try_clear_stuck_error(conn, printer, live) + state = (live.get("state") or "").lower() + if state == "error": + self._fail_ablauf( + conn, + item, + printer, + "Drucker im Fehlerzustand — auf der Wand „Fehler löschen“, dann Ablauf neu einreihen.", + ) + return + + if kind == "print": + if phase in ("starting", "printing"): + # Start angenommen / Upload lief — warten auf print_active oder Timeout. + if state == "error": + self._fail_ablauf( + conn, + item, + printer, + live.get("message") or "Druckerfehler beim Start.", + ) + return + if state == "cancelled": + # Anycubic/GoKlipper meldet cancelled kurz beim Start/LeviQ — + # frisch abfragen; wenn schon wieder Druck → nicht abbrechen. + try: + live = moonraker.query(printer["host"]) + state = (live.get("state") or "").lower() + except Exception: + pass + if state == "cancelled": + self._fail_ablauf(conn, item, printer, "Druck abgebrochen.") + return + # Nach Grotti-Neustart mitten im Upload: Datei läuft schon → übernehmen. + want = self._step_filename(conn, step) + live_name = (live.get("filename") or "").replace("\\", "/").rsplit("/", 1)[-1] + if want and live_name and ( + live_name == want or live_name.endswith(want) or want.endswith(live_name) + ): + if state in ("printing", "paused", "heating"): + store.update_print_queue( + conn, + item["id"], + { + "phase": "print_active", + "message": self._ablauf_message(item, "druckt…"), + }, + ) + return + # Nie bei complete/standby weiter: nach Repeat zeigt Moonraker oft + # noch complete vom vorherigen Durchlauf (gleiche Datei) → sonst + # springt der Ablauf sofort auf Abkühlen, während LeviQ/Druck startet. + if state in ("printing", "paused", "heating"): + store.update_print_queue( + conn, + item["id"], + { + "phase": "print_active", + "message": self._ablauf_message(item, "druckt…"), + }, + ) + return + # Worker tot (typisch Grotti-Neustart): nicht als Fehler, sondern erneut starten. + # Gesamt-Timeout bleibt über phase_started_at des ersten Versuchs. + if phase == "starting" and not self._worker_busy(item["id"]): + started = item.get("phase_started_at") + age = 0 + if started: + if getattr(started, "tzinfo", None) is None: + started = started.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - started).total_seconds() + if age > 45: + if self._print_start_timed_out(item): + self._fail_ablauf( + conn, + item, + printer, + "Druckstart-Timeout: Drucker hat nach %d min nicht angefangen." + % (PRINT_START_TIMEOUT_S // 60), + ) + return + if state in BUSY: + return + self._step_event( + conn, item, printer, + "Start-Worker weg — Upload/Start erneut", + ) + self._start_print_step( + conn, item, printer, step, keep_phase_started=True + ) + return + if self._print_start_timed_out(item): + self._fail_ablauf( + conn, + item, + printer, + "Druckstart-Timeout: Drucker hat nach %d min nicht angefangen." + % (PRINT_START_TIMEOUT_S // 60), + ) + return + # Fortschritt in der Meldung, damit „lädt ewig“ nicht stumm wirkt. + if phase == "starting": + started = item.get("phase_started_at") + age_m = 0 + if started: + if getattr(started, "tzinfo", None) is None: + started = started.replace(tzinfo=timezone.utc) + age_m = int((datetime.now(timezone.utc) - started).total_seconds() // 60) + hint = "lädt und startet…" + if age_m >= 1: + hint = "lädt und startet… (%d min, LeviQ/Upload)" % age_m + store.update_print_queue( + conn, + item["id"], + {"message": self._ablauf_message(item, hint)}, + ) + return + if phase != "print_active": + # Druck noch nicht gestartet (z. B. nach Repeat) + if state in BUSY: + return + self._start_print_step(conn, item, printer, step) + return + if state == "printing" or state == "paused" or state == "heating": + store.update_print_queue( + conn, + item["id"], + { + "phase": "print_active", + "message": self._ablauf_message(item, "druckt…"), + }, + ) + return + if state == "cancelled": + try: + live = moonraker.query(printer["host"]) + state = (live.get("state") or "").lower() + except Exception: + pass + if state in ("printing", "paused", "heating") or live.get("print_active"): + store.update_print_queue( + conn, + item["id"], + { + "phase": "print_active", + "message": self._ablauf_message(item, "druckt…"), + }, + ) + return + if state == "cancelled": + self._fail_ablauf(conn, item, printer, "Druck abgebrochen.") + return + if state == "error": + self._fail_ablauf( + conn, + item, + printer, + live.get("message") or "Druckerfehler während des Drucks.", + ) + return + # Erst weiter, wenn der Job wirklich gelaufen ist — sonst Race + # (noch standby/complete vom vorherigen Auftrag). + if phase == "print_active" and state in ("complete", "standby"): + self._next_step(conn, item, printer, live) + return + # Idle schon während des Drucks verlängern — sonst greift M84/Default zu früh. + if phase == "print_active" and _ablauf_needs_motors(item): + last = self._keepalive.get(item["id"], 0) + if time.monotonic() - last >= KEEPALIVE_S: + try: + moonraker.gcode_script( + printer["host"], eject.idle_timeout_script(), timeout=15 + ) + self._keepalive[item["id"]] = time.monotonic() + except Exception: + pass + return + + if kind == "cool": + if state in ("printing", "paused", "heating"): + return + if phase != "cooling": + self._start_cool_step(conn, item, printer, step) + return + self._poll_cool(conn, item, printer, live, step) + return + + if kind == "sweep": + if state in ("printing", "paused", "heating"): + return + if phase == "sweeping": + # Worker macht _next_step selbst. FREE hier weiterzuschalten war ein + # Race. Nach Grotti-Neustart ist der Thread weg → erneut schieben. + if self._worker_busy(item["id"]): + return + self._step_event(conn, item, printer, "Schieben erneut (Worker war weg)") + self._start_sweep_step(conn, item, printer, step) + return + self._start_sweep_step(conn, item, printer, step) + return + + if kind == "check": + # Altes 3×3 (probe_count/mesh_*): aus der Kette nehmen — Timeout ohne Netz. + if store._is_legacy_mesh_check(step): + self._step_event(conn, item, printer, "altes 3×3-Prüfen übersprungen") + self._next_step(conn, item, printer, live) + return + if state == "printing": + return + if phase == "checking": + # Worker läuft noch — warten. Nach Grotti-Neustart ist der Thread weg: + # sonst hängt „tastet Mitte…“ für immer. + if self._worker_busy(item["id"]): + return + self._step_event(conn, item, printer, "Tasten erneut (Worker war weg)") + self._start_check_step(conn, item, printer, step) + return + self._start_check_step(conn, item, printer, step) + return + + self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind) + + def _print_start_timed_out(self, item): + started = item.get("phase_started_at") + if not started: + return False + if getattr(started, "tzinfo", None) is None: + started = started.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - started).total_seconds() + return age > PRINT_START_TIMEOUT_S + + def _step_filename(self, conn, step): + try: + file_id = int((step or {}).get("file_id") or 0) + except (TypeError, ValueError): + return "" + if not file_id: + return "" + row = store.get_file_row(conn, file_id) + return (row or {}).get("name") or "" + + def _start_print_step(self, conn, item, printer, step, keep_phase_started=False): + try: + normalized = eject.normalize_print(step) + except GrottiError as exc: + self._fail_ablauf(conn, item, printer, exc.message) + return + if self._worker_busy(item["id"]): + return + # Vor dem Upload markieren — sonst Doppelstart nach Grotti-Neustart. + fields = { + "phase": "starting", + "file_id": normalized["file_id"], + "message": self._ablauf_message(item, "lädt und startet…"), + } + if not keep_phase_started: + fields["phase_started_at"] = "now" + store.update_print_queue(conn, item["id"], fields) + conn.commit() + item_id = item["id"] + printer_copy = dict(printer) + step_copy = dict(normalized) + + def work(): + with connect(self.database_url) as wconn: + try: + result = jobs.send_file( + wconn, + self.cfg, + step_copy["file_id"], + printer_copy["host"], + True, + bool(step_copy.get("override_machine")), + step_copy.get("spool_id"), + prepared=False, + ace_map=step_copy.get("ace_map") or [], + bed_leveling=bool(step_copy.get("bed_leveling")), + # Slice-Ende M84 → unhomed; Abräumen braucht die Motoren. + keep_motors=_ablauf_needs_motors(item), + ) + name = (result or {}).get("filename") or "" + row = store.get_print_queue_row(wconn, item_id) or item + store.update_print_queue( + wconn, + item_id, + { + "phase": "printing", + "phase_started_at": "now", + "file_id": step_copy["file_id"], + "message": self._ablauf_message(row, "druckt %s" % name), + }, + ) + self._step_event(wconn, row, printer_copy, "druckt %s" % name) + wconn.commit() + except GrottiError as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, exc.message) + wconn.commit() + except Exception as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, str(exc) or "Druckstart fehlgeschlagen.") + wconn.commit() + + self._spawn_worker(item_id, work) + + def _try_cool_park(self, host, item_id, force=False): + """Kopf zur Bettmitte. Merkt Erfolg/Fehler in self._cool_park. + + Nach Slice-M84 oft unhomed: letzte XYZ per SET_KINEMATIC (Present), dann Park. + """ + state = self._cool_park.setdefault(item_id, {"ok": False, "tries": 0, "error": ""}) + if state["ok"] and not force: + return state + if state["tries"] >= 3 and not force: + return state + state["tries"] += 1 + try: + live = moonraker.query(host) + homed = (live.get("homed_axes") or "").lower() + if not all(axis in homed for axis in "xyz"): + script, _z = eject.restore_position_script(live, {"z_push": 2}) + moonraker.gcode_script(host, script, timeout=15) + live = moonraker.query(host) + before_z = eject.cool_park_z_now(live) + lift = eject.cool_park_lift_mm(live) + moonraker.gcode_script(host, eject.cool_park_script(live), timeout=60) + after = moonraker.query(host) + state["before_z"] = before_z + state["lift_mm"] = lift + if not eject.cool_parked(after, before_z=before_z, lift_mm=lift): + pos = after.get("position") or [] + z = float(pos[2]) if len(pos) >= 3 and isinstance(pos[2], (int, float)) else None + state["ok"] = False + state["error"] = ( + "Kühl-Z-Hub fehlgeschlagen (Z %s mm nach +%.0f mm, max %.0f) — kein Schieben." + % ( + ("%.1f" % z) if z is not None else "?", + lift, + eject.cool_park_z_max(live), + ) + ) + return state + state["ok"] = True + state["error"] = "" + except GrottiError as exc: + state["ok"] = False + state["error"] = exc.message + except Exception as exc: + state["ok"] = False + state["error"] = eject.explain_printer_error(str(exc)) or str(exc) + return state + + def _start_cool_step(self, conn, item, printer, step): + host = printer["host"] + cool = eject.normalize_cool(step) + try: + moonraker.gcode_script(host, eject.cool_script(cool), timeout=30) + # Idle zuerst verlängern, dann Park (sonst Stepper aus → must home). + try: + moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15) + except Exception: + pass + park = self._try_cool_park(host, item["id"], force=True) + self._keepalive[item["id"]] = time.monotonic() + park_note = "" + if not park["ok"] and park["error"]: + park_note = " — Mitte fehlgeschlagen: %s" % park["error"] + elif park["ok"]: + park_note = " · Düse Mitte" + store.update_print_queue( + conn, + item["id"], + { + "phase": "cooling", + "phase_started_at": "now", + "message": self._ablauf_message( + item, + "kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s" + % (cool["bed_max"], cool["nozzle_max"], park_note), + ), + }, + ) + self._step_event( + conn, item, printer, + "kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C%s" + % (cool["bed_max"], cool["nozzle_max"], park_note), + ) + except Exception as exc: + self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc) + + def _poll_cool(self, conn, item, printer, live, step): + cool = eject.normalize_cool(step) + started = item.get("phase_started_at") + if started: + if getattr(started, "tzinfo", None) is None: + started = started.replace(tzinfo=timezone.utc) + age = (datetime.now(timezone.utc) - started).total_seconds() + if age > cool["timeout_min"] * 60: + self._cool_park.pop(item["id"], None) + self._fail_ablauf( + conn, + item, + printer, + "Abkühlen abgebrochen nach %d min." % cool["timeout_min"], + ) + return + # Nachziehen zur Mitte, falls Start-Park scheiterte oder Position noch am Rand. + park = self._cool_park.get(item["id"]) or {} + if not eject.cool_parked(live, before_z=park.get("before_z"), lift_mm=park.get("lift_mm")): + park = self._try_cool_park(printer["host"], item["id"]) + if park["ok"]: + # frische Live-Position kommt im nächsten Tick + pass + last = self._keepalive.get(item["id"], 0) + if time.monotonic() - last >= KEEPALIVE_S: + try: + moonraker.gcode_script( + printer["host"], + eject.idle_timeout_script(cool) + "\nG4 P10", + timeout=15, + ) + self._keepalive[item["id"]] = time.monotonic() + except Exception: + pass + if eject.cool_ready(live, cool): + park = self._cool_park.get(item["id"]) or {} + parked = eject.cool_parked( + live, before_z=park.get("before_z"), lift_mm=park.get("lift_mm") + ) + if not parked: + park = self._try_cool_park(printer["host"], item["id"], force=True) + after = moonraker.query(printer["host"]) + parked = eject.cool_parked( + after, before_z=park.get("before_z"), lift_mm=park.get("lift_mm") + ) + if not parked: + self._cool_park.pop(item["id"], None) + self._fail_ablauf( + conn, + item, + printer, + park.get("error") + or "Abgekühlt, aber Kühl-Z-Hub fehlgeschlagen — kein Schieben.", + ) + return + self._keepalive.pop(item["id"], None) + self._cool_park.pop(item["id"], None) + self._step_event( + conn, item, printer, + "abgekühlt (Bett %s °C, Düse %s °C)" % ( + "%.0f" % live["bed"] if isinstance(live.get("bed"), (int, float)) else "?", + "%.0f" % live["nozzle"] if isinstance(live.get("nozzle"), (int, float)) else "?", + ), + ) + self._next_step(conn, item, printer, live) + return + park = self._cool_park.get(item["id"]) or {} + wait = _cool_wait_text(live, cool) + parked_now = eject.cool_parked( + live, before_z=park.get("before_z"), lift_mm=park.get("lift_mm") + ) + # Oberfläche (phaseText) zeigt nur das letzte „ · “-Stück — Warte-Text zuletzt. + if not parked_now and park.get("error"): + wait = "Mitte fehlgeschlagen: %s · %s" % (park["error"], wait) + elif parked_now: + wait = "Mitte · %s" % wait + store.update_print_queue( + conn, + item["id"], + {"message": self._ablauf_message(item, wait)}, + ) + + def _start_sweep_step(self, conn, item, printer, step): + host = printer["host"] + try: + eject.require_kobra_3(printer) + sweep = eject.normalize_sweep(step) + self._ensure_position(conn, item, printer, sweep) + store.update_print_queue( + conn, + item["id"], + { + "phase": "sweeping", + "phase_started_at": "now", + "message": self._ablauf_message(item, "räumt ab…"), + }, + ) + self._step_event(conn, item, printer, "schiebt die Platte frei") + conn.commit() + except GrottiError as exc: + self._fail_ablauf(conn, item, printer, exc.message) + return + except Exception as exc: + self._fail_ablauf(conn, item, printer, "Schieben: %s" % exc) + return + + item_id = item["id"] + printer_copy = dict(printer) + sweep_copy = dict(sweep) + + def work(): + with connect(self.database_url) as wconn: + try: + # 1) hoch + hinten 2) Z noch klar? 3) langsam senken 4) Bahn + moonraker.gcode_script( + host, eject.sweep_go_back_high_script(sweep_copy), timeout=60 + ) + eject.assert_clear_before_lower(moonraker.query(host), sweep_copy) + moonraker.gcode_script( + host, eject.sweep_lower_to_push_script(sweep_copy), timeout=60 + ) + eject.assert_at_push_height(moonraker.query(host), sweep_copy) + moonraker.gcode_script(host, eject.sweep_script(sweep_copy), timeout=180) + store.set_bed_needs_clear(wconn, host, False, "Ablauf Schieben") + row = store.get_print_queue_row(wconn, item_id) or item + self._step_event(wconn, row, printer_copy, "Platte freigeschoben") + self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True}) + wconn.commit() + except GrottiError as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, exc.message) + wconn.commit() + except moonraker.PrinterError as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message)) + wconn.commit() + except Exception as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, "Schieben: %s" % exc) + wconn.commit() + + self._spawn_worker(item_id, work) + + def _ensure_position(self, conn, item, printer, sweep, allow_low_z=False): + """Wenn unhomed (typisch nach M84): letzte XYZ setzen, sonst weiter.""" + host = printer["host"] + live = moonraker.query(host) + homed = (live.get("homed_axes") or "").lower() + if all(axis in homed for axis in "xyz"): + return + script, z = eject.restore_position_script( + live, sweep or {"z_push": 2}, allow_low_z=allow_low_z + ) + moonraker.gcode_script(host, script, timeout=15) + homed = (moonraker.query(host).get("homed_axes") or "").lower() + if not all(axis in homed for axis in "xyz"): + raise GrottiError("Position nach Leerlauf/M84 ließ sich nicht wiederherstellen.", "homed") + self._step_event( + conn, item, printer, + "Motoren waren aus (M84/Idle), letzte Position übernommen (Z %.1f mm)" % z, + ) + + def _start_check_step(self, conn, item, printer, step): + host = printer["host"] + try: + eject.require_kobra_3(printer) + check = eject.normalize_check(step) + self._ensure_position(conn, item, printer, {"z_push": 2}) + live_z = None + pos = (moonraker.query(host).get("position") or []) + if len(pos) >= 3 and isinstance(pos[2], (int, float)): + live_z = float(pos[2]) + store.update_print_queue( + conn, + item["id"], + { + "phase": "checking", + "phase_started_at": "now", + "message": self._ablauf_message(item, "tastet Mitte…"), + }, + ) + self._step_event(conn, item, printer, "tastet Bettmitte") + conn.commit() + except GrottiError as exc: + self._fail_ablauf(conn, item, printer, exc.message) + return + except Exception as exc: + self._fail_ablauf(conn, item, printer, "Tasten: %s" % exc) + return + + item_id = item["id"] + printer_copy = dict(printer) + check_copy = dict(check) + live_z_copy = live_z + + def work(): + with connect(self.database_url) as wconn: + try: + timeout_s = max(30, int(check_copy["timeout_min"]) * 60) + t0 = time.time() + moonraker.gcode_script( + host, + eject.check_script(check_copy, current_z=live_z_copy), + timeout=timeout_s, + ) + z = moonraker.latest_probe_result_z(host, after_time=t0) + z_src = "result" + if z is None: + live = moonraker.query(host) + z, z_src = eject.check_z_from_live(live) + if z is None: + raise GrottiError( + "Nach dem Tasten fehlt Result is z= (probe-Objekt auf Kobra 3 leer).", + "probe", + ) + if hasattr(eject, "check_lift_script"): + moonraker.gcode_script(host, eject.check_lift_script(check_copy), timeout=30) + limit = float(check_copy["max_delta_mm"]) + if z > limit: + raise GrottiError( + "Restteil? Mitte tastet Z %.2f mm (Grenze %.1f mm)%s." + % (z, limit, " · %s" % z_src if z_src else ""), + "probe", + ) + row = store.get_print_queue_row(wconn, item_id) or item + store.update_print_queue( + wconn, + item_id, + {"message": self._ablauf_message(row, "Mitte frei (Z %.2f mm)" % z)}, + ) + self._step_event(wconn, row, printer_copy, "Mitte frei (Z %.2f mm)" % z) + self._next_step(wconn, row, printer_copy, {"state": "standby", "ok": True}) + wconn.commit() + except GrottiError as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, exc.message) + wconn.commit() + except moonraker.PrinterError as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, eject.explain_printer_error(exc.message)) + wconn.commit() + except Exception as exc: + row = store.get_print_queue_row(wconn, item_id) or item + self._fail_ablauf(wconn, row, printer_copy, "Tasten: %s" % exc) + wconn.commit() + + self._spawn_worker(item_id, work) + + def _next_step(self, conn, item, printer, live): + steps = _parse_steps(item) + nxt = int(item.get("step_index") or 0) + 1 + if nxt >= len(steps): + self._finish_or_repeat(conn, item, printer, live) + return + store.update_print_queue( + conn, + item["id"], + { + "step_index": nxt, + "phase": "", + "phase_started_at": None, + "message": self._ablauf_message( + {**item, "step_index": nxt}, + eject.step_label(steps[nxt]) or "nächster Schritt", + ), + }, + ) + refreshed = store.get_print_queue_row(conn, item["id"]) or item + self._run_current_step(conn, refreshed, printer, live) + + def _finish_or_repeat(self, conn, item, printer, live): + done = int(item.get("repeat_done") or 0) + 1 + total = max(1, int(item.get("repeat_total") or 1)) + if done < total: + self._event( + conn, + printer["host"], + printer["name"], + "ablauf", + "%s Ablauf: Durchlauf %d/%d fertig, startet %d/%d" % (printer["name"], done, total, done + 1, total), + item.get("filename") or "", + ) + store.update_print_queue( + conn, + item["id"], + { + "repeat_done": done, + "step_index": 0, + "phase": "", + "phase_started_at": None, + "message": self._ablauf_message( + {**item, "repeat_done": done, "step_index": 0}, + "Wiederholung %d/%d" % (done + 1, total), + ), + }, + ) + refreshed = store.get_print_queue_row(conn, item["id"]) or item + self._run_current_step(conn, refreshed, printer, live) + return + store.update_print_queue( + conn, + item["id"], + { + "status": "done", + "repeat_done": done, + "phase": "", + "phase_started_at": None, + "message": "Ablauf fertig (%d×)." % total, + }, + ) + self._event( + conn, + printer["host"], + printer["name"], + "ablauf", + "%s Ablauf fertig (%d×)." % (printer["name"], total), + item.get("filename") or "", + ) + + def _run_current_step(self, conn, item, printer, live): + steps = _parse_steps(item) + idx = int(item.get("step_index") or 0) + if idx < 0 or idx >= len(steps): + self._finish_or_repeat(conn, item, printer, live) + return + step = steps[idx] + kind = (step.get("type") or "").lower() + state = (live.get("state") or "").lower() + if kind == "print": + if state in BUSY: + return + self._start_print_step(conn, item, printer, step) + elif kind == "cool": + self._start_cool_step(conn, item, printer, step) + elif kind == "sweep": + self._start_sweep_step(conn, item, printer, step) + elif kind == "check": + self._start_check_step(conn, item, printer, step) + else: + self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind) + + def _fail_ablauf(self, conn, item, printer, message): + store.update_print_queue( + conn, + item["id"], + { + "status": "error", + "message": message, + "phase": "", + "phase_started_at": None, + }, + ) + self._event( + conn, + printer["host"], + printer["name"], + "queue", + "%s Ablauf gestoppt: %s" % (printer["name"], message), + item.get("filename") or "", + ) + + def _ablauf_message(self, item, suffix): + steps = _parse_steps(item) + total = max(1, int(item.get("repeat_total") or 1)) + done = int(item.get("repeat_done") or 0) + idx = int(item.get("step_index") or 0) + label = eject.step_label(steps[idx]) if 0 <= idx < len(steps) else "" + if total > 1: + head = "%d/%d" % (done + 1, total) + if label: + return "%s · %s · %s" % (head, label, suffix) + return "%s · %s" % (head, suffix) + if label: + return "%s · %s" % (label, suffix) + return suffix + + def _event(self, conn, host, printer, kind, message, filename): + notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=30) + + def _step_event(self, conn, item, printer, text): + self._event( + conn, + printer["host"], + printer["name"], + "ablauf", + "%s Ablauf %s" % (printer["name"], self._ablauf_message(item, text)), + item.get("filename") or "", + ) diff --git a/grotti/printers.py b/grotti/printers.py new file mode 100644 index 0000000..1507dbc --- /dev/null +++ b/grotti/printers.py @@ -0,0 +1,44 @@ +"""Die Drucker: Familie aus dem Namen, die Liste selbst liegt in PostgreSQL.""" + +import json + +FAMILY_LABELS = {"kobra_s1": "Kobra S1", "kobra_3": "Kobra 3"} + + +def family_from_name(name): + low = name.lower() + if "s1" in low: + return "kobra_s1" + if "kobra 3" in low or "kobra3" in low: + return "kobra_3" + return "" + + +def family_from_text(text): + low = (text or "").lower() + if "kobra s1" in low: + return "kobra_s1" + if "kobra 3" in low or "kobra3" in low: + return "kobra_3" + return "" + + +def load_printers(path): + rows = json.loads(path.read_text(encoding="utf-8")) + printers = [] + for row in rows: + family = row.get("family") or family_from_name(row["name"]) + printers.append({ + "name": row["name"], + "host": row["host"], + "family": family, + "family_label": FAMILY_LABELS.get(family, family), + }) + return printers + + +def printer_by_host(path, host): + for printer in load_printers(path): + if printer["host"] == host: + return printer + return None diff --git a/grotti/schema.sql b/grotti/schema.sql new file mode 100644 index 0000000..7952eb2 --- /dev/null +++ b/grotti/schema.sql @@ -0,0 +1,182 @@ +CREATE TABLE IF NOT EXISTS projects ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + notes text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'idee', + quote jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS spools ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + brand text NOT NULL DEFAULT '', + material_type text NOT NULL DEFAULT '', + color text NOT NULL DEFAULT '', + price_per_kg double precision NOT NULL DEFAULT 0, + density double precision NOT NULL DEFAULT 1.24, + volumetric double precision NOT NULL DEFAULT 12, + remaining_g double precision NOT NULL DEFAULT 1000, + printer_host text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS files ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id bigint REFERENCES projects (id) ON DELETE SET NULL, + spool_id bigint REFERENCES spools (id) ON DELETE SET NULL, + name text NOT NULL, + stored_path text NOT NULL, + source text NOT NULL, + kind text NOT NULL, + printer_family text NOT NULL DEFAULT '', + filaments jsonb NOT NULL DEFAULT '[]'::jsonb, + weight_g double precision NOT NULL DEFAULT 0, + estimated_s double precision NOT NULL DEFAULT 0, + thumb_entry text NOT NULL DEFAULT '', + plate text NOT NULL DEFAULT '', + size_bytes bigint NOT NULL DEFAULT 0, + mtime double precision NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (source, stored_path) +); + +ALTER TABLE files ADD COLUMN IF NOT EXISTS slice_meta jsonb NOT NULL DEFAULT '{}'::jsonb; + +CREATE TABLE IF NOT EXISTS sends ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + printer_host text NOT NULL, + filename text NOT NULL, + file_id bigint REFERENCES files (id) ON DELETE SET NULL, + project_id bigint REFERENCES projects (id) ON DELETE SET NULL, + spool_id bigint REFERENCES spools (id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE sends ADD COLUMN IF NOT EXISTS ace_map jsonb NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE sends ADD COLUMN IF NOT EXISTS bed_leveling boolean NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS runs ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + printer_host text NOT NULL, + printer_name text NOT NULL DEFAULT '', + filename text NOT NULL DEFAULT '', + file_id bigint REFERENCES files (id) ON DELETE SET NULL, + project_id bigint REFERENCES projects (id) ON DELETE SET NULL, + spool_id bigint REFERENCES spools (id) ON DELETE SET NULL, + state text NOT NULL DEFAULT '', + started_at timestamptz, + ended_at timestamptz, + duration_s double precision NOT NULL DEFAULT 0, + weight_g double precision NOT NULL DEFAULT 0, + filament_cost double precision NOT NULL DEFAULT 0, + energy_cost double precision NOT NULL DEFAULT 0, + moonraker_job_id text NOT NULL, + deducted boolean NOT NULL DEFAULT false, + needs_spool boolean NOT NULL DEFAULT false, + verdict text NOT NULL DEFAULT '', + material_type text NOT NULL DEFAULT '', + UNIQUE (printer_host, moonraker_job_id) +); + +CREATE INDEX IF NOT EXISTS files_project ON files (project_id); + +CREATE INDEX IF NOT EXISTS runs_started ON runs (started_at DESC); + +CREATE TABLE IF NOT EXISTS printers ( + id uuid PRIMARY KEY, + name text NOT NULL, + host text NOT NULL UNIQUE, + family text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE printers ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS ideas ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + title text NOT NULL, + body text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'offen', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS materials ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + brand text NOT NULL DEFAULT '', + material_type text NOT NULL DEFAULT '', + density double precision NOT NULL, + volumetric double precision NOT NULL, + volumetric_s1 double precision, + machines text NOT NULL DEFAULT 'beide', + price_per_kg double precision NOT NULL DEFAULT 0, + note text NOT NULL DEFAULT '' +); + +ALTER TABLE spools ADD COLUMN IF NOT EXISTS colors jsonb NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE spools ADD COLUMN IF NOT EXISTS ace_slot smallint; +ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materials (id) ON DELETE SET NULL; +ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text; +ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT ''; +ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text; + +CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> ''; +CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> ''; +-- Ein ACE-Platz gehört höchstens einer Spule. +CREATE UNIQUE INDEX IF NOT EXISTS spools_host_slot + ON spools (printer_host, ace_slot) + WHERE printer_host <> '' AND ace_slot IS NOT NULL; + +CREATE TABLE IF NOT EXISTS events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + host text NOT NULL DEFAULT '', + printer text NOT NULL DEFAULT '', + kind text NOT NULL, + message text NOT NULL DEFAULT '', + filename text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now(), + read_at timestamptz +); + +CREATE INDEX IF NOT EXISTS events_unread ON events (read_at, created_at DESC); + +CREATE TABLE IF NOT EXISTS print_queue ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + file_id bigint NOT NULL REFERENCES files (id) ON DELETE CASCADE, + host text NOT NULL DEFAULT '', + family text NOT NULL DEFAULT '', + priority integer NOT NULL DEFAULT 100, + status text NOT NULL DEFAULT 'waiting', + spool_id bigint REFERENCES spools (id) ON DELETE SET NULL, + ace_map jsonb NOT NULL DEFAULT '[]'::jsonb, + bed_leveling boolean NOT NULL DEFAULT false, + override_machine boolean NOT NULL DEFAULT false, + message text NOT NULL DEFAULT '', + steps jsonb NOT NULL DEFAULT '[]'::jsonb, + step_index integer NOT NULL DEFAULT 0, + repeat_total integer NOT NULL DEFAULT 1, + repeat_done integer NOT NULL DEFAULT 0, + phase text NOT NULL DEFAULT '', + phase_started_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS steps jsonb NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS step_index integer NOT NULL DEFAULT 0; +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL DEFAULT 1; +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0; +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase text NOT NULL DEFAULT ''; +ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS phase_started_at timestamptz; + +CREATE INDEX IF NOT EXISTS print_queue_waiting ON print_queue (status, priority, id); +CREATE INDEX IF NOT EXISTS print_queue_running_host ON print_queue (host, status) WHERE status = 'running'; + +-- Nach dem Druck: Platte manuell quittieren, bevor „wenn frei“ den nächsten Job startet. +CREATE TABLE IF NOT EXISTS bed_status ( + host text PRIMARY KEY, + needs_clear boolean NOT NULL DEFAULT false, + cleared_at timestamptz, + message text NOT NULL DEFAULT '' +); diff --git a/grotti/send_queue.py b/grotti/send_queue.py new file mode 100644 index 0000000..418ba1f --- /dev/null +++ b/grotti/send_queue.py @@ -0,0 +1,218 @@ +"""Hintergrund-Queue für Senden und Starten an Moonraker.""" + +from __future__ import annotations + +import threading +import time +import uuid +from collections import deque +from queue import Empty, Queue + +from grotti import jobs +from grotti.db import connect +from grotti.errors import GrottiError + + +class SendQueue: + def __init__(self, database_url, cfg, workers=4): + self.database_url = database_url + self.cfg = cfg + self._lock = threading.Lock() + self._pending = Queue() + self._jobs = {} + self._order = deque(maxlen=60) + self._threads = [] + for index in range(max(1, int(workers))): + thread = threading.Thread( + target=self._worker, + name="grotti-send-queue-%s" % (index + 1), + daemon=True, + ) + thread.start() + self._threads.append(thread) + + def enqueue( + self, + conn, + file_id, + host, + confirm, + override, + spool_id, + ace_map=None, + bed_leveling=False, + ): + jobs_out = self.enqueue_many( + conn, + file_id, + [host], + confirm, + override, + spool_id, + {host: ace_map or []} if ace_map else {}, + bed_leveling=bed_leveling, + ) + return jobs_out[0] + + def enqueue_many( + self, + conn, + file_id, + hosts, + confirm, + override, + spool_id, + ace_maps=None, + bed_leveling=False, + ): + if not confirm: + raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung") + hosts = [str(host or "").strip() for host in (hosts or []) if str(host or "").strip()] + if not hosts: + raise GrottiError("Mindestens einen Drucker wählen.", "drucker") + seen = set() + unique = [] + for host in hosts: + if host in seen: + continue + seen.add(host) + unique.append(host) + ace_maps = ace_maps if isinstance(ace_maps, dict) else {} + # Eine Batch-Option für alle Drucker im Multi-Send (nicht pro Host). + want_level = bool(bed_leveling) + created = [] + for host in unique: + printer, row, chosen = jobs.prepare_send(conn, file_id, host, True, override, spool_id) + job_id = uuid.uuid4().hex[:12] + mapping = ace_maps.get(host) or ace_maps.get(printer["name"]) or [] + job = { + "id": job_id, + "status": "pending", + "progress": 0, + "step": "warteschlange", + "message": "In der Warteschlange…", + "file_id": row["id"], + "filename": row["name"], + "host": host, + "printer": printer["name"], + "spool_id": chosen, + "override_machine": bool(override), + "ace_map": mapping, + "bed_leveling": want_level, + "result": None, + "error": None, + "created_at": time.time(), + "updated_at": time.time(), + } + with self._lock: + self._jobs[job_id] = job + self._order.appendleft(job_id) + self._pending.put( + { + "id": job_id, + "file_id": row["id"], + "host": host, + "override": bool(override), + "spool_id": chosen, + "ace_map": mapping, + "bed_leveling": want_level, + } + ) + created.append(self.snapshot(job_id)) + return created + + def get(self, job_id): + return self.snapshot(job_id) + + def list_jobs(self, limit=20): + with self._lock: + ids = list(self._order)[: max(1, int(limit))] + return [dict(self._jobs[job_id]) for job_id in ids if job_id in self._jobs] + + def snapshot(self, job_id): + with self._lock: + job = self._jobs.get(job_id) + return dict(job) if job else None + + def _update(self, job_id, **fields): + with self._lock: + job = self._jobs.get(job_id) + if not job: + return + job.update(fields) + job["updated_at"] = time.time() + + def _worker(self): + while True: + try: + item = self._pending.get(timeout=1) + except Empty: + continue + self._run(item) + + def _run(self, item): + job_id = item["id"] + + def on_progress(step, progress, message): + self._update( + job_id, + status="running", + step=step, + progress=int(progress), + message=message, + ) + + self._update( + job_id, + status="running", + step="vorbereiten", + progress=5, + message="Vorbereitung…", + ) + try: + with connect(self.database_url) as conn: + result = jobs.send_file( + conn, + self.cfg, + item["file_id"], + item["host"], + True, + item["override"], + item["spool_id"], + on_progress=on_progress, + prepared=True, + ace_map=item.get("ace_map"), + bed_leveling=bool(item.get("bed_leveling")), + ) + conn.commit() + done_msg = "Gestartet auf %s: %s" % (result["printer"], result["filename"]) + warnings = result.get("warnings") or [] + if warnings: + done_msg += " · " + " · ".join(warnings) + self._update( + job_id, + status="done", + step="fertig", + progress=100, + message=done_msg, + result=result, + error=None, + ) + except GrottiError as exc: + self._update( + job_id, + status="error", + step="fehler", + progress=100, + message=exc.message, + error={"message": exc.message, "code": exc.code}, + ) + except Exception as exc: + self._update( + job_id, + status="error", + step="fehler", + progress=100, + message="Senden ist fehlgeschlagen.", + error={"message": str(exc) or "Senden ist fehlgeschlagen.", "code": "intern"}, + ) diff --git a/grotti/store.py b/grotti/store.py new file mode 100644 index 0000000..01682c1 --- /dev/null +++ b/grotti/store.py @@ -0,0 +1,1282 @@ +"""Projekte, Dateien, Spulen und Läufe in PostgreSQL.""" + +import json +import uuid +from datetime import date + +from psycopg.types.json import Json + +from grotti.catalog import MACHINE_LABELS, MATERIALS +from grotti.printers import FAMILY_LABELS, family_from_name + + +def iso(value): + return value.isoformat() if value else None + + +def project_out(row, files=None, file_count=None): + data = { + "id": row["id"], + "name": row["name"], + "notes": row["notes"], + "status": row["status"], + "quote": row["quote"] or {}, + "created_at": iso(row["created_at"]), + } + if files is not None: + data["files"] = files + if file_count is not None: + data["file_count"] = file_count + return data + + +def file_out(row): + # stored_path bleibt intern (get_file_row / list_file_rows), nicht in der API. + from grotti.parse import path_has_ace_trailer + + stored = row.get("stored_path") or "" + ace_trailer = False + if stored: + ace_trailer = path_has_ace_trailer(stored, kind=row.get("kind") or "", plate=row.get("plate") or "") + return { + "id": row["id"], + "project_id": row["project_id"], + "spool_id": row["spool_id"], + "name": row["name"], + "source": row["source"], + "kind": row["kind"], + "printer_family": row["printer_family"], + "filaments": row["filaments"] or [], + "weight_g": row["weight_g"], + "estimated_s": row["estimated_s"], + "plate": row["plate"], + "size_bytes": row["size_bytes"], + "mtime": row["mtime"], + "slice_meta": row.get("slice_meta") or {}, + "ace_trailer": ace_trailer, + "preview": "/api/v1/dateien/%s/vorschau" % row["id"] if row["thumb_entry"] else "", + } + + +def spool_out(row): + colors = row.get("colors") or [] + if isinstance(colors, str): + colors = json.loads(colors) + return { + "id": row["id"], + "name": row["name"], + "brand": row["brand"], + "material_type": row["material_type"], + "color": row["color"], + "colors": colors, + "price_per_kg": row["price_per_kg"], + "density": row["density"], + "volumetric": row["volumetric"], + "remaining_g": row["remaining_g"], + "printer_host": row["printer_host"], + "ace_slot": row.get("ace_slot"), + "material_id": row.get("material_id"), + "chargen_nr": row.get("chargen_nr") or "", + "hersteller_charge": row.get("hersteller_charge") or "", + "code": row.get("code") or "", + } + + +def next_chargen(conn): + prefix = "S-%s-" % date.today().strftime("%Y%m%d") + row = conn.execute( + """ + SELECT chargen_nr FROM spools + WHERE chargen_nr LIKE %s + ORDER BY chargen_nr DESC + LIMIT 1 + """, + (prefix + "%",), + ).fetchone() + number = 1 + if row and row["chargen_nr"]: + try: + number = int(str(row["chargen_nr"]).rsplit("-", 1)[-1]) + 1 + except ValueError: + number = 1 + return "%s%04d" % (prefix, number) + + +def ensure_spool_codes(conn): + rows = conn.execute( + """ + SELECT id, chargen_nr, code FROM spools + WHERE chargen_nr IS NULL OR chargen_nr = '' OR code IS NULL OR code = '' + """ + ).fetchall() + for row in rows: + code = (row["chargen_nr"] or row["code"] or "").strip() or next_chargen(conn) + conn.execute( + """ + UPDATE spools + SET chargen_nr = COALESCE(NULLIF(chargen_nr, ''), %s), + code = COALESCE(NULLIF(code, ''), %s) + WHERE id = %s + """, + (code, code, row["id"]), + ) + + +def run_out(row): + return { + "id": row["id"], + "printer_host": row["printer_host"], + "printer_name": row["printer_name"], + "filename": row["filename"], + "file_id": row["file_id"], + "project_id": row["project_id"], + "spool_id": row["spool_id"], + "state": row["state"], + "started_at": iso(row["started_at"]), + "ended_at": iso(row["ended_at"]), + "duration_s": row["duration_s"], + "weight_g": row["weight_g"], + "filament_cost": row["filament_cost"], + "energy_cost": row["energy_cost"], + "deducted": row["deducted"], + "needs_spool": row["needs_spool"], + "verdict": row["verdict"], + "material_type": row["material_type"], + } + + +def list_projects(conn): + rows = conn.execute( + """ + SELECT p.*, COUNT(f.id) AS file_count + FROM projects p + LEFT JOIN files f ON f.project_id = p.id + GROUP BY p.id + ORDER BY p.created_at DESC + """ + ).fetchall() + return [project_out(row, file_count=row["file_count"]) for row in rows] + + +def get_project(conn, project_id): + row = conn.execute("SELECT * FROM projects WHERE id = %s", (project_id,)).fetchone() + if not row: + return None + files = conn.execute( + "SELECT * FROM files WHERE project_id = %s ORDER BY mtime DESC, name", + (project_id,), + ).fetchall() + return project_out(row, files=[file_out(item) for item in files]) + + +def create_project(conn, name, notes, status): + row = conn.execute( + """ + INSERT INTO projects (name, notes, status) + VALUES (%s, %s, %s) + RETURNING * + """, + (name, notes, status), + ).fetchone() + return project_out(row, files=[], file_count=0) + + +def update_project(conn, project_id, fields): + sets = [] + values = [] + for key in ("name", "notes", "status"): + if key in fields: + sets.append("%s = %%s" % key) + values.append(fields[key]) + if "quote" in fields: + sets.append("quote = %s") + values.append(Json(fields["quote"])) + if not sets: + return get_project(conn, project_id) + values.append(project_id) + row = conn.execute( + "UPDATE projects SET %s WHERE id = %%s RETURNING id" % ", ".join(sets), + values, + ).fetchone() + if not row: + return None + return get_project(conn, project_id) + + +def delete_project(conn, project_id): + row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone() + return bool(row) + + +def list_file_rows(conn): + """Rohzeilen inkl. stored_path — für interne Pfad-Operationen.""" + return conn.execute("SELECT * FROM files ORDER BY mtime DESC, name").fetchall() + + +def list_files(conn): + return [file_out(row) for row in list_file_rows(conn)] + + +def get_file_row(conn, file_id): + return conn.execute("SELECT * FROM files WHERE id = %s", (file_id,)).fetchone() + + +def upsert_scanned(conn, meta, force=False): + statement = """ + INSERT INTO files ( + name, stored_path, source, kind, printer_family, filaments, weight_g, + estimated_s, thumb_entry, plate, size_bytes, mtime, slice_meta + ) VALUES ( + %(name)s, %(stored_path)s, 'watch', %(kind)s, %(printer_family)s, %(filaments)s, + %(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s, + %(slice_meta)s + ) + ON CONFLICT (source, stored_path) DO UPDATE SET + name = EXCLUDED.name, + kind = EXCLUDED.kind, + printer_family = EXCLUDED.printer_family, + filaments = EXCLUDED.filaments, + weight_g = EXCLUDED.weight_g, + estimated_s = EXCLUDED.estimated_s, + thumb_entry = EXCLUDED.thumb_entry, + plate = EXCLUDED.plate, + size_bytes = EXCLUDED.size_bytes, + mtime = EXCLUDED.mtime, + slice_meta = EXCLUDED.slice_meta + """ + if not force: + statement += """ + WHERE files.size_bytes IS DISTINCT FROM EXCLUDED.size_bytes + OR files.mtime IS DISTINCT FROM EXCLUDED.mtime + """ + payload = dict(meta) + payload["filaments"] = Json(meta.get("filaments") or []) + payload["slice_meta"] = Json(meta.get("slice_meta") or {}) + conn.execute(statement, payload) + + +def insert_upload(conn, meta): + row = conn.execute( + """ + INSERT INTO files ( + name, stored_path, source, kind, printer_family, filaments, weight_g, + estimated_s, thumb_entry, plate, size_bytes, mtime, project_id, slice_meta + ) VALUES ( + %(name)s, %(stored_path)s, 'upload', %(kind)s, %(printer_family)s, %(filaments)s, + %(weight_g)s, %(estimated_s)s, %(thumb_entry)s, %(plate)s, %(size_bytes)s, %(mtime)s, + %(project_id)s, %(slice_meta)s + ) + RETURNING * + """, + { + **meta, + "filaments": Json(meta.get("filaments") or []), + "slice_meta": Json(meta.get("slice_meta") or {}), + }, + ).fetchone() + return file_out(row) + + +def update_file(conn, file_id, fields): + sets = [] + values = [] + for key in ("project_id", "spool_id", "printer_family", "weight_g", "estimated_s", "thumb_entry", "kind", "plate"): + if key in fields: + sets.append("%s = %%s" % key) + values.append(fields[key]) + if "filaments" in fields: + sets.append("filaments = %s") + values.append(Json(fields.get("filaments") or [])) + if "slice_meta" in fields: + sets.append("slice_meta = %s") + values.append(Json(fields.get("slice_meta") or {})) + if not sets: + row = get_file_row(conn, file_id) + return file_out(row) if row else None + values.append(file_id) + row = conn.execute( + "UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), + values, + ).fetchone() + return file_out(row) if row else None + + +def delete_file(conn, file_id): + return conn.execute("DELETE FROM files WHERE id = %s RETURNING *", (file_id,)).fetchone() + + +def forget_missing_watch(conn, seen): + if seen: + conn.execute( + "DELETE FROM files WHERE source = 'watch' AND NOT (stored_path = ANY(%s))", + (seen,), + ) + else: + conn.execute("DELETE FROM files WHERE source = 'watch'") + + +def list_spools(conn): + ensure_spool_codes(conn) + rows = conn.execute("SELECT * FROM spools ORDER BY name, id").fetchall() + return [spool_out(row) for row in rows] + + +def get_spool_row(conn, spool_id): + return conn.execute("SELECT * FROM spools WHERE id = %s", (spool_id,)).fetchone() + + +def get_spool_by_code(conn, code): + code = (code or "").strip() + if not code: + return None + ensure_spool_codes(conn) + row = conn.execute( + """ + SELECT * FROM spools + WHERE code = %s OR chargen_nr = %s + ORDER BY id + LIMIT 1 + """, + (code, code), + ).fetchone() + return spool_out(row) if row else None + + +def create_spool(conn, fields): + payload = dict(fields) + payload["colors"] = Json(payload.get("colors") or []) + payload.setdefault("ace_slot", None) + payload.setdefault("material_id", None) + payload.setdefault("hersteller_charge", "") + code = (payload.get("chargen_nr") or payload.get("code") or "").strip() or next_chargen(conn) + payload["chargen_nr"] = code + payload["code"] = code + row = conn.execute( + """ + INSERT INTO spools ( + name, brand, material_type, color, colors, price_per_kg, density, volumetric, + remaining_g, printer_host, ace_slot, material_id, chargen_nr, hersteller_charge, code + ) VALUES ( + %(name)s, %(brand)s, %(material_type)s, %(color)s, %(colors)s, %(price_per_kg)s, + %(density)s, %(volumetric)s, %(remaining_g)s, %(printer_host)s, %(ace_slot)s, %(material_id)s, + %(chargen_nr)s, %(hersteller_charge)s, %(code)s + ) + RETURNING * + """, + payload, + ).fetchone() + return spool_out(row) + + +def update_spool(conn, spool_id, fields): + allowed = ( + "name", "brand", "material_type", "color", "colors", "price_per_kg", "density", + "volumetric", "remaining_g", "printer_host", "ace_slot", "material_id", + "hersteller_charge", + ) + sets = [] + values = [] + for key in allowed: + if key in fields: + sets.append("%s = %%s" % key) + values.append(Json(fields[key]) if key == "colors" else fields[key]) + if not sets: + row = get_spool_row(conn, spool_id) + return spool_out(row) if row else None + values.append(spool_id) + row = conn.execute( + "UPDATE spools SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), + values, + ).fetchone() + return spool_out(row) if row else None + + +def material_out(row): + s1 = row["volumetric_s1"] + return { + "id": row["id"], + "name": row["name"], + "brand": row["brand"], + "material_type": row["material_type"], + "density": row["density"], + "volumetric": row["volumetric"], + "volumetric_s1": s1, + "machines": row["machines"], + "machines_label": MACHINE_LABELS.get(row["machines"], row["machines"]), + "price_per_kg": row["price_per_kg"], + "note": row["note"], + } + + +def list_materials(conn): + rows = conn.execute("SELECT * FROM materials ORDER BY name").fetchall() + return [material_out(row) for row in rows] + + +def get_material(conn, material_id): + row = conn.execute("SELECT * FROM materials WHERE id = %s", (material_id,)).fetchone() + return material_out(row) if row else None + + +def create_material(conn, data): + row = conn.execute( + """ + INSERT INTO materials ( + name, brand, material_type, density, volumetric, volumetric_s1, + machines, price_per_kg, note + ) VALUES ( + %(name)s, %(brand)s, %(material_type)s, %(density)s, %(volumetric)s, + %(volumetric_s1)s, %(machines)s, %(price_per_kg)s, %(note)s + ) + RETURNING * + """, + data, + ).fetchone() + return material_out(row) + + +def update_material(conn, material_id, fields): + allowed = ( + "name", "brand", "material_type", "density", "volumetric", "volumetric_s1", + "machines", "price_per_kg", "note", + ) + sets = [] + values = {"id": material_id} + for key in allowed: + if key in fields: + sets.append("%s = %%(%s)s" % (key, key)) + values[key] = fields[key] + if not sets: + return get_material(conn, material_id) + row = conn.execute( + "UPDATE materials SET %s WHERE id = %%(id)s RETURNING *" % ", ".join(sets), + values, + ).fetchone() + return material_out(row) if row else None + + +def delete_material(conn, material_id): + used = conn.execute( + "SELECT id FROM spools WHERE material_id = %s LIMIT 1", + (material_id,), + ).fetchone() + if used: + return "in_use" + row = conn.execute( + "DELETE FROM materials WHERE id = %s RETURNING id", + (material_id,), + ).fetchone() + return "ok" if row else None + + +def seed_materials(conn): + for item in MATERIALS: + conn.execute( + """ + INSERT INTO materials ( + name, brand, material_type, density, volumetric, volumetric_s1, + machines, price_per_kg, note + ) VALUES ( + %(name)s, %(brand)s, %(material_type)s, %(density)s, %(volumetric)s, + %(volumetric_s1)s, %(machines)s, %(price_per_kg)s, %(note)s + ) + ON CONFLICT (name) DO UPDATE SET + brand = EXCLUDED.brand, + material_type = EXCLUDED.material_type, + density = EXCLUDED.density, + volumetric = EXCLUDED.volumetric, + volumetric_s1 = EXCLUDED.volumetric_s1, + machines = EXCLUDED.machines, + note = EXCLUDED.note + """, + item, + ) + + +def delete_spool(conn, spool_id): + row = conn.execute("DELETE FROM spools WHERE id = %s RETURNING id", (spool_id,)).fetchone() + return bool(row) + + +def spools_for_printer(conn, host): + return conn.execute( + "SELECT * FROM spools WHERE printer_host = %s ORDER BY id", + (host,), + ).fetchall() + + +def file_by_name(conn, filename): + base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1] + if not base: + return None + return conn.execute( + "SELECT * FROM files WHERE name = %s ORDER BY created_at DESC LIMIT 1", + (base,), + ).fetchone() + + +def add_send(conn, printer_host, filename, file_id, project_id, spool_id, ace_map=None, bed_leveling=False): + conn.execute( + """ + INSERT INTO sends (printer_host, filename, file_id, project_id, spool_id, ace_map, bed_leveling) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + printer_host, + filename, + file_id, + project_id, + spool_id, + Json(ace_map if isinstance(ace_map, list) else []), + bool(bed_leveling), + ), + ) + + +def latest_send(conn, host, filename): + base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1] + return conn.execute( + """ + SELECT * FROM sends + WHERE printer_host = %s AND (filename = %s OR filename = %s) + ORDER BY created_at DESC + LIMIT 1 + """, + (host, filename or "", base), + ).fetchone() + + +def latest_send_for_file(conn, host, file_id): + """Letzter Sende-Eintrag für Datei auf diesem Host (für „Nochmal“).""" + if not host or not file_id: + return None + row = conn.execute( + """ + SELECT * FROM sends + WHERE printer_host = %s AND file_id = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (host, file_id), + ).fetchone() + if not row: + return None + return { + "id": row["id"], + "printer_host": row["printer_host"], + "filename": row["filename"], + "file_id": row["file_id"], + "project_id": row["project_id"], + "spool_id": row["spool_id"], + "ace_map": _json_list(row.get("ace_map")), + "bed_leveling": bool(row.get("bed_leveling")), + "created_at": iso(row["created_at"]), + } + + +def upsert_run(conn, item): + return conn.execute( + """ + INSERT INTO runs ( + printer_host, printer_name, filename, file_id, project_id, spool_id, + state, started_at, ended_at, duration_s, weight_g, energy_cost, + moonraker_job_id, needs_spool, material_type + ) VALUES ( + %(printer_host)s, %(printer_name)s, %(filename)s, %(file_id)s, %(project_id)s, %(spool_id)s, + %(state)s, %(started_at)s, %(ended_at)s, %(duration_s)s, %(weight_g)s, %(energy_cost)s, + %(moonraker_job_id)s, %(needs_spool)s, %(material_type)s + ) + ON CONFLICT (printer_host, moonraker_job_id) DO UPDATE SET + printer_name = EXCLUDED.printer_name, + filename = EXCLUDED.filename, + file_id = COALESCE(runs.file_id, EXCLUDED.file_id), + project_id = COALESCE(runs.project_id, EXCLUDED.project_id), + spool_id = COALESCE(runs.spool_id, EXCLUDED.spool_id), + state = EXCLUDED.state, + started_at = COALESCE(runs.started_at, EXCLUDED.started_at), + ended_at = EXCLUDED.ended_at, + duration_s = EXCLUDED.duration_s, + weight_g = CASE WHEN EXCLUDED.weight_g > 0 THEN EXCLUDED.weight_g ELSE runs.weight_g END, + energy_cost = EXCLUDED.energy_cost, + needs_spool = CASE WHEN runs.deducted THEN false ELSE EXCLUDED.needs_spool END, + material_type = CASE + WHEN EXCLUDED.material_type <> '' THEN EXCLUDED.material_type + ELSE runs.material_type + END + RETURNING * + """, + item, + ).fetchone() + + +def mark_deducted(conn, run_id, spool_id, filament_cost, energy_cost): + return conn.execute( + """ + UPDATE runs + SET deducted = true, needs_spool = false, spool_id = %s, + filament_cost = %s, energy_cost = %s + WHERE id = %s AND deducted = false + RETURNING id + """, + (spool_id, filament_cost, energy_cost, run_id), + ).fetchone() + + +def deduct_spool(conn, spool_id, grams): + return conn.execute( + "UPDATE spools SET remaining_g = remaining_g - %s WHERE id = %s RETURNING *", + (grams, spool_id), + ).fetchone() + + +def list_runs(conn): + rows = conn.execute( + "SELECT * FROM runs ORDER BY started_at DESC NULLS LAST, id DESC LIMIT 200" + ).fetchall() + return [run_out(row) for row in rows] + + +def get_run_row(conn, run_id): + return conn.execute("SELECT * FROM runs WHERE id = %s", (run_id,)).fetchone() + + +def set_verdict(conn, run_id, verdict): + row = conn.execute( + "UPDATE runs SET verdict = %s WHERE id = %s RETURNING *", + (verdict, run_id), + ).fetchone() + return run_out(row) if row else None + + +def assign_run_spool(conn, run_id, spool_id): + return conn.execute( + """ + UPDATE runs + SET spool_id = %s + WHERE id = %s + RETURNING * + """, + (spool_id, run_id), + ).fetchone() + + +def idea_out(row): + return { + "id": row["id"], + "title": row["title"], + "body": row["body"], + "status": row["status"], + "created_at": iso(row["created_at"]), + } + + +def list_ideas(conn): + rows = conn.execute( + """ + SELECT * FROM ideas + ORDER BY CASE status WHEN 'offen' THEN 0 WHEN 'spaeter' THEN 1 ELSE 2 END, created_at DESC + """ + ).fetchall() + return [idea_out(row) for row in rows] + + +def create_idea(conn, title, body, status): + row = conn.execute( + """ + INSERT INTO ideas (title, body, status) + VALUES (%s, %s, %s) + RETURNING * + """, + (title, body, status), + ).fetchone() + return idea_out(row) + + +def update_idea(conn, idea_id, fields): + sets = [] + values = [] + for key in ("title", "body", "status"): + if key in fields: + sets.append("%s = %%s" % key) + values.append(fields[key]) + if not sets: + row = conn.execute("SELECT * FROM ideas WHERE id = %s", (idea_id,)).fetchone() + return idea_out(row) if row else None + values.append(idea_id) + row = conn.execute( + "UPDATE ideas SET %s WHERE id = %%s RETURNING *" % ", ".join(sets), + values, + ).fetchone() + return idea_out(row) if row else None + + +def delete_idea(conn, idea_id): + row = conn.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,)).fetchone() + return bool(row) + + +def printer_out(row): + family = row["family"] or "" + return { + "id": str(row["id"]), + "name": row["name"], + "host": row["host"], + "family": family, + "family_label": FAMILY_LABELS.get(family, ""), + "locked": bool(row.get("locked")), + } + + +def list_printers(conn): + rows = conn.execute("SELECT * FROM printers ORDER BY name").fetchall() + return [printer_out(row) for row in rows] + + +def printer_by_host(conn, host): + row = conn.execute("SELECT * FROM printers WHERE host = %s", (host,)).fetchone() + return printer_out(row) if row else None + + +def printer_by_id(conn, printer_id): + row = conn.execute("SELECT * FROM printers WHERE id = %s", (printer_id,)).fetchone() + return row + + +def create_printer(conn, printer_id, name, host, family): + row = conn.execute( + """ + INSERT INTO printers (id, name, host, family) + VALUES (%s, %s, %s, %s) + RETURNING * + """, + (printer_id, name, host, family), + ).fetchone() + return printer_out(row) + + +def update_printer(conn, printer_id, name, host, family, locked=None): + current = printer_by_id(conn, printer_id) + if not current: + return None + if locked is None: + locked = bool(current.get("locked")) + row = conn.execute( + """ + UPDATE printers + SET name = %s, host = %s, family = %s, locked = %s + WHERE id = %s + RETURNING * + """, + (name, host, family, bool(locked), printer_id), + ).fetchone() + if current["host"] != host: + old = current["host"] + conn.execute("UPDATE spools SET printer_host = %s WHERE printer_host = %s", (host, old)) + conn.execute("UPDATE sends SET printer_host = %s WHERE printer_host = %s", (host, old)) + conn.execute( + "UPDATE runs SET printer_host = %s, printer_name = %s WHERE printer_host = %s", + (host, name, old), + ) + conn.execute("UPDATE print_queue SET host = %s WHERE host = %s", (host, old)) + conn.execute("UPDATE events SET host = %s, printer = %s WHERE host = %s", (host, name, old)) + conn.execute( + """ + INSERT INTO bed_status (host, needs_clear, cleared_at, message) + SELECT %s, needs_clear, cleared_at, message FROM bed_status WHERE host = %s + ON CONFLICT (host) DO UPDATE SET + needs_clear = EXCLUDED.needs_clear, + cleared_at = EXCLUDED.cleared_at, + message = EXCLUDED.message + """, + (host, old), + ) + conn.execute("DELETE FROM bed_status WHERE host = %s", (old,)) + elif current["name"] != name: + conn.execute("UPDATE runs SET printer_name = %s WHERE printer_host = %s", (name, host)) + conn.execute("UPDATE events SET printer = %s WHERE host = %s", (name, host)) + return printer_out(row) + + +def delete_printer(conn, printer_id): + current = printer_by_id(conn, printer_id) + if not current: + return False + host = current["host"] + # Wartende Jobs dieses Hosts streichen; laufende bleiben nicht hängen. + conn.execute( + "UPDATE print_queue SET status = 'cancelled', message = 'Drucker entfernt.' WHERE host = %s AND status IN ('waiting', 'error')", + (host,), + ) + conn.execute( + "UPDATE print_queue SET status = 'cancelled', message = 'Drucker entfernt.', phase = '' WHERE host = %s AND status = 'running'", + (host,), + ) + conn.execute("UPDATE spools SET printer_host = '', ace_slot = NULL WHERE printer_host = %s", (host,)) + conn.execute("DELETE FROM bed_status WHERE host = %s", (host,)) + row = conn.execute("DELETE FROM printers WHERE id = %s RETURNING id", (printer_id,)).fetchone() + return bool(row) + + +def seed_printers(conn, path): + count = conn.execute("SELECT COUNT(*) AS n FROM printers").fetchone()["n"] + if count: + return 0 + if not path.exists(): + return 0 + inserted = 0 + for row in json.loads(path.read_text(encoding="utf-8")): + name = (row.get("name") or "").strip() + host = (row.get("host") or "").strip() + if not name or not host: + continue + family = row.get("family") or family_from_name(name) + create_printer(conn, uuid.uuid4(), name, host, family) + inserted += 1 + return inserted + + +def event_out(row): + return { + "id": row["id"], + "host": row["host"] or "", + "printer": row["printer"] or "", + "kind": row["kind"], + "message": row["message"] or "", + "filename": row["filename"] or "", + "created_at": iso(row["created_at"]), + "read_at": iso(row["read_at"]), + "unread": row["read_at"] is None, + } + + +# Protokoll-Ereignisse, die kein Alert sind: gleich als gelesen speichern. +QUIET_EVENT_KINDS = {"ablauf", "start", "fertig"} + + +def add_event(conn, host, printer, kind, message, filename=""): + row = conn.execute( + """ + INSERT INTO events (host, printer, kind, message, filename, read_at) + VALUES (%s, %s, %s, %s, %s, CASE WHEN %s THEN now() ELSE NULL END) + RETURNING * + """, + (host or "", printer or "", kind, message or "", filename or "", kind in QUIET_EVENT_KINDS), + ).fetchone() + return event_out(row) + + +def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since=None): + where = [] + values = [] + if unread_only: + where.append("read_at IS NULL") + if host: + where.append("host = %s") + values.append(host) + if kinds: + where.append("kind = ANY(%s)") + values.append(list(kinds)) + if since: + where.append("created_at >= %s") + values.append(since) + values.append(int(limit)) + rows = conn.execute( + "SELECT * FROM events %s ORDER BY created_at DESC, id DESC LIMIT %%s" + % ("WHERE " + " AND ".join(where) if where else ""), + values, + ).fetchall() + return [event_out(row) for row in rows] + + +def unread_event_count(conn): + return conn.execute("SELECT COUNT(*) AS n FROM events WHERE read_at IS NULL").fetchone()["n"] + + +def mark_events_read(conn, event_ids=None): + if event_ids: + conn.execute( + "UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)", + (list(event_ids),), + ) + else: + conn.execute("UPDATE events SET read_at = now() WHERE read_at IS NULL") + return unread_event_count(conn) + + +def recent_event_kind(conn, host, kind, within_s=60): + row = conn.execute( + """ + SELECT id FROM events + WHERE host = %s AND kind = %s AND created_at > now() - (%s || ' seconds')::interval + LIMIT 1 + """, + (host or "", kind, str(int(within_s))), + ).fetchone() + return bool(row) + + +def _json_list(value): + if isinstance(value, list): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return [] + return parsed if isinstance(parsed, list) else [] + return [] + + +STEP_LABELS = {"print": "Drucken", "cool": "Abkühlen", "sweep": "Schieben", "check": "Tasten"} + + +def _step_label(step): + kind = (step or {}).get("type") if isinstance(step, dict) else "" + return STEP_LABELS.get(kind, kind or "") + + +def _is_legacy_mesh_check(step): + """Altes 3×3-Prüfen (probe_count / mesh_*) — aus UI und Ablauf raus.""" + if not isinstance(step, dict) or (step.get("type") or "").lower() != "check": + return False + return "probe_count" in step or "mesh_min_x" in step + + +def print_queue_out(row, file_name=None): + ace = _json_list(row.get("ace_map")) + steps = _json_list(row.get("steps")) + visible_idx = [i for i, step in enumerate(steps) if not _is_legacy_mesh_check(step)] + labels = [_step_label(steps[i]) for i in visible_idx] + raw_index = int(row.get("step_index") or 0) + display_index = 0 + for vis_i, orig_i in enumerate(visible_idx): + if orig_i <= raw_index: + display_index = vis_i + family = row["family"] or "" + return { + "id": row["id"], + "file_id": row["file_id"], + "filename": file_name or row.get("filename") or "", + "host": row["host"] or "", + "printer_name": row.get("printer_name") or "", + "family": family, + "family_label": FAMILY_LABELS.get(family, ""), + "step_labels": labels, + "step_label_current": labels[display_index] if 0 <= display_index < len(labels) else "", + "priority": int(row["priority"] or 100), + "status": row["status"], + "spool_id": row["spool_id"], + "ace_map": ace, + "bed_leveling": bool(row["bed_leveling"]), + "override_machine": bool(row["override_machine"]), + "message": row["message"] or "", + "steps": steps, + "step_index": int(row.get("step_index") or 0), + "display_step_index": display_index, + "repeat_total": int(row.get("repeat_total") or 1), + "repeat_done": int(row.get("repeat_done") or 0), + "phase": row.get("phase") or "", + "phase_started_at": iso(row.get("phase_started_at")), + "is_ablauf": bool(steps), + "created_at": iso(row["created_at"]), + "updated_at": iso(row["updated_at"]), + } + + +def enqueue_print( + conn, + file_id, + host, + family, + priority, + spool_id, + ace_map, + bed_leveling, + override, + steps=None, + repeat_total=1, +): + steps = steps if isinstance(steps, list) else [] + try: + repeats = max(1, int(repeat_total if repeat_total is not None else 1)) + except (TypeError, ValueError): + repeats = 1 + row = conn.execute( + """ + INSERT INTO print_queue ( + file_id, host, family, priority, spool_id, ace_map, + bed_leveling, override_machine, status, message, + steps, step_index, repeat_total, repeat_done, phase + ) + VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, 'waiting', %s, + %s, 0, %s, 0, '' + ) + RETURNING * + """, + ( + file_id, + host or "", + family or "", + int(priority if priority is not None else 100), + spool_id, + Json(ace_map if isinstance(ace_map, list) else []), + bool(bed_leveling), + bool(override), + "Ablauf wartet…" if steps else "Wartet auf freien Drucker…", + Json(steps), + repeats, + ), + ).fetchone() + file_row = get_file_row(conn, file_id) + file_name = (file_row or {}).get("name") or "" + printer = printer_by_host(conn, host) if host else None + target = (printer or {}).get("name") or ( + "nächster freier " + FAMILY_LABELS[family] if family in FAMILY_LABELS else "nächster freier Drucker" + ) + what = "Ablauf (%d×)" % repeats if steps else "Druck" + add_event( + conn, + host or "", + (printer or {}).get("name") or "", + "ablauf", + "%s eingereiht: %s → %s" % (what, file_name or "Datei", target), + file_name, + ) + return print_queue_out(row, file_name) + + +def list_print_queue(conn, limit=40): + rows = conn.execute( + """ + SELECT q.*, f.name AS filename, p.name AS printer_name + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + LEFT JOIN printers p ON p.host = q.host AND q.host <> '' + ORDER BY + CASE q.status + WHEN 'running' THEN 0 + WHEN 'waiting' THEN 1 + WHEN 'error' THEN 2 + ELSE 3 + END, + CASE WHEN q.status IN ('waiting', 'running', 'error') THEN q.priority ELSE 0 END ASC, + CASE WHEN q.status IN ('waiting', 'running', 'error') THEN q.id ELSE 0 END ASC, + q.updated_at DESC NULLS LAST, + q.id DESC + LIMIT %s + """, + (int(limit),), + ).fetchall() + return [print_queue_out(row, row.get("filename")) for row in rows] + + +def queue_by_host(conn, printers): + """Laufender Queue-Eintrag und Zahl wartender Jobs je Drucker (für die Wand).""" + running = {} + for row in conn.execute( + """ + SELECT q.*, f.name AS filename, p.name AS printer_name + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + LEFT JOIN printers p ON p.host = q.host + WHERE q.status = 'running' AND q.host <> '' + ORDER BY q.updated_at DESC + """ + ).fetchall(): + running.setdefault(row["host"], print_queue_out(row, row.get("filename"))) + failed = {} + for row in conn.execute( + """ + SELECT q.*, f.name AS filename, p.name AS printer_name + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + LEFT JOIN printers p ON p.host = q.host + WHERE q.status = 'error' AND q.host <> '' AND jsonb_array_length(q.steps) > 0 + AND q.updated_at > now() - interval '24 hours' + ORDER BY q.updated_at DESC + """ + ).fetchall(): + failed.setdefault(row["host"], print_queue_out(row, row.get("filename"))) + waiting_rows = conn.execute( + """ + SELECT q.host, q.family, f.name AS filename + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + WHERE q.status = 'waiting' + ORDER BY q.priority ASC, q.id ASC + """ + ).fetchall() + out = {} + for printer in printers: + host = printer.get("host") or "" + family = printer.get("family") or "" + mine = [ + row for row in waiting_rows + if row["host"] == host or (not row["host"] and (not row["family"] or row["family"] == family)) + ] + out[host] = { + "running": running.get(host), + "failed": None if running.get(host) else failed.get(host), + "waiting": len(mine), + "waiting_next": (mine[0]["filename"] or "") if mine else "", + } + return out + + +def get_print_queue_row(conn, item_id): + row = conn.execute( + """ + SELECT q.*, f.name AS filename, p.name AS printer_name + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + LEFT JOIN printers p ON p.host = q.host AND q.host <> '' + WHERE q.id = %s + """, + (item_id,), + ).fetchone() + return row + + +def update_print_queue(conn, item_id, fields): + allowed = { + "priority": "priority", + "status": "status", + "message": "message", + "host": "host", + "step_index": "step_index", + "repeat_done": "repeat_done", + "phase": "phase", + "file_id": "file_id", + "spool_id": "spool_id", + "bed_leveling": "bed_leveling", + "override_machine": "override_machine", + } + sets = [] + values = [] + for key, column in allowed.items(): + if key in fields: + sets.append("%s = %%s" % column) + values.append(fields[key]) + if "ace_map" in fields: + sets.append("ace_map = %s") + values.append(Json(fields["ace_map"] if isinstance(fields["ace_map"], list) else [])) + if "steps" in fields: + sets.append("steps = %s") + raw_steps = fields["steps"] + if isinstance(raw_steps, Json): + values.append(raw_steps) + else: + values.append(Json(raw_steps if isinstance(raw_steps, list) else [])) + if "phase_started_at" in fields: + if fields["phase_started_at"] is None: + sets.append("phase_started_at = NULL") + elif fields["phase_started_at"] == "now": + sets.append("phase_started_at = now()") + else: + sets.append("phase_started_at = %s") + values.append(fields["phase_started_at"]) + if not sets: + row = get_print_queue_row(conn, item_id) + return print_queue_out(row, (row or {}).get("filename")) if row else None + sets.append("updated_at = now()") + values.append(item_id) + row = conn.execute( + "UPDATE print_queue SET %s WHERE id = %%s RETURNING id" % ", ".join(sets), + values, + ).fetchone() + if not row: + return None + fresh = get_print_queue_row(conn, row["id"]) + return print_queue_out(fresh, fresh.get("filename")) if fresh else None + + +def next_waiting_print(conn, host=None, family=None): + if host: + row = conn.execute( + """ + SELECT q.*, f.name AS filename + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + WHERE q.status = 'waiting' + AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s))) + ORDER BY q.priority ASC, q.id ASC + LIMIT 1 + """, + (host, family or ""), + ).fetchone() + else: + row = conn.execute( + """ + SELECT q.*, f.name AS filename + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + WHERE q.status = 'waiting' + ORDER BY q.priority ASC, q.id ASC + LIMIT 1 + """, + ).fetchone() + return row + + +def running_print_on_host(conn, host): + if not host: + return None + return conn.execute( + """ + SELECT q.*, f.name AS filename + FROM print_queue q + LEFT JOIN files f ON f.id = q.file_id + WHERE q.status = 'running' AND q.host = %s + ORDER BY q.updated_at DESC, q.id DESC + LIMIT 1 + """, + (host,), + ).fetchone() + + +def claim_print_queue(conn, item_id, host): + row = conn.execute( + """ + UPDATE print_queue + SET status = 'running', host = %s, message = 'Wird gestartet…', + step_index = 0, repeat_done = 0, phase = '', phase_started_at = NULL, + updated_at = now() + WHERE id = %s AND status = 'waiting' + RETURNING * + """, + (host, item_id), + ).fetchone() + return row + + +def bed_needs_clear(conn, host): + row = conn.execute( + "SELECT needs_clear FROM bed_status WHERE host = %s", + (host or "",), + ).fetchone() + return bool(row and row["needs_clear"]) + + +def bed_clear_map(conn): + rows = conn.execute("SELECT host, needs_clear FROM bed_status").fetchall() + return {row["host"]: bool(row["needs_clear"]) for row in rows} + + +def set_bed_needs_clear(conn, host, needs, message=""): + host = (host or "").strip() + if not host: + return + needs = bool(needs) + conn.execute( + """ + INSERT INTO bed_status (host, needs_clear, cleared_at, message) + VALUES (%s, %s, CASE WHEN %s THEN NULL ELSE now() END, %s) + ON CONFLICT (host) DO UPDATE SET + needs_clear = EXCLUDED.needs_clear, + cleared_at = CASE WHEN EXCLUDED.needs_clear THEN NULL ELSE now() END, + message = EXCLUDED.message + """, + (host, needs, needs, message or ""), + ) + diff --git a/grotti/toolpath.py b/grotti/toolpath.py new file mode 100644 index 0000000..c8e452b --- /dev/null +++ b/grotti/toolpath.py @@ -0,0 +1,142 @@ +"""G-code zu einem dünnen 3D-Pfad für den Viewer.""" + +import re +import zipfile +from pathlib import Path + +MOVE = re.compile(r"^(?:G0|G00|G1|G01)\b(.*)$", re.IGNORECASE) +# Auch E.39 / Z.6 ohne führende Null (Anycubic/Prusa-Stil) +AXIS = re.compile(r"([XYZE])\s*(-?\d*\.?\d+)", re.IGNORECASE) + +# Cache-/Viewer-Version: Segment-IDs, kein Sehnen-Thinning +TOOLPATH_VERSION = 5 + + +def _gcode_text(path: Path) -> str: + name = path.name.lower() + if name.endswith(".3mf") or name.endswith(".gcode.3mf"): + with zipfile.ZipFile(path) as archive: + names = [ + item + for item in archive.namelist() + if item.lower().endswith(".gcode") and not item.endswith("/") + ] + if not names: + return "" + plate = next((item for item in names if "plate" in item.lower()), names[0]) + return archive.read(plate).decode("utf-8", "replace") + return path.read_text(encoding="utf-8", errors="replace") + + +def _thin_points(points, layer_breaks, max_points): + """Extrusions-Segmente stichprobenartig behalten — nie Endpunkte einer Bahn + weglassen und die Nachbarn verbinden (sonst Sehnen statt Infill).""" + if len(points) <= max_points: + return points, layer_breaks + + extrude_ends = [i for i in range(1, len(points)) if points[i][3]] + keep = {0, len(points) - 1} + for break_i in layer_breaks: + keep.add(max(0, min(len(points) - 1, break_i))) + if break_i > 0: + keep.add(break_i - 1) + + # Budget für Segment-Endpunkte (jedes Segment braucht 2 Indizes, viele teilen sich) + target_segs = max(2000, max_points // 2) + step = max(1, (len(extrude_ends) + target_segs - 1) // target_segs) + for j, end_i in enumerate(extrude_ends): + if j % step == 0: + keep.add(end_i - 1) + keep.add(end_i) + + ordered = sorted(keep) + # Falls immer noch zu groß: Segmente grober + if len(ordered) > max_points: + step2 = max(1, (len(ordered) + max_points - 1) // max_points) + ordered = ordered[::step2] + if ordered[-1] != len(points) - 1: + ordered.append(len(points) - 1) + + index_map = {old: new for new, old in enumerate(ordered)} + kept = [points[i] for i in ordered] + new_breaks = sorted({ + index_map[b] + for b in layer_breaks + if b in index_map + }) + if not new_breaks: + new_breaks = [0] + return kept, new_breaks + + +def build_toolpath(path: Path, max_points=120000): + text = _gcode_text(path) + if not text: + return { + "points": [], + "layer_breaks": [], + "count": 0, + "version": TOOLPATH_VERSION, + } + + x = y = z = 0.0 + e = 0.0 + absolute_e = True + points = [] + layer_breaks = [0] + last_z = None + seq = 0 + + for raw in text.splitlines(): + line = raw.split(";", 1)[0].strip() + if not line: + continue + upper = line.upper() + if upper.startswith("M82"): + absolute_e = True + continue + if upper.startswith("M83"): + absolute_e = False + continue + match = MOVE.match(line) + if not match: + continue + nx, ny, nz, ne = x, y, z, e + saw_e = False + for axis, value in AXIS.findall(match.group(1)): + number = float(value) + axis = axis.upper() + if axis == "X": + nx = number + elif axis == "Y": + ny = number + elif axis == "Z": + nz = number + elif axis == "E": + saw_e = True + ne = number if absolute_e else e + number + if nx == x and ny == y and nz == z: + if saw_e: + e = ne + continue + extrude = 1 if saw_e and ne > e + 1e-6 else 0 + x, y, z = nx, ny, nz + if saw_e: + e = ne + if last_z is None: + last_z = z + elif abs(z - last_z) > 0.01: + layer_breaks.append(len(points)) + last_z = z + # seq = Originalindex — Viewer verbindet nur seq und seq+1 + points.append([round(x, 3), round(y, 3), round(z, 3), extrude, seq]) + seq += 1 + + points, layer_breaks = _thin_points(points, layer_breaks, max_points) + + return { + "points": points, + "layer_breaks": layer_breaks, + "count": len(points), + "version": TOOLPATH_VERSION, + } diff --git a/grotti/version.py b/grotti/version.py new file mode 100644 index 0000000..cbe92f9 --- /dev/null +++ b/grotti/version.py @@ -0,0 +1,3 @@ +"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" + +__version__ = "2.0.0" diff --git a/grotti/watcher.py b/grotti/watcher.py new file mode 100644 index 0000000..4e5255c --- /dev/null +++ b/grotti/watcher.py @@ -0,0 +1,197 @@ +"""Drucker-Zustände beobachten → events + optional Telegram.""" + +from __future__ import annotations + +import threading +import time + +from grotti import moonraker, notify, store +from grotti.db import connect + + +class StateWatcher: + def __init__(self, database_url, cfg, interval=10): + self.database_url = database_url + self.cfg = cfg + self.interval = max(5, int(interval)) + self._prev = {} + self._offline_hits = {} + self.latest = [] + self._lock = threading.Lock() + self._updated_at = 0.0 + self._stop = threading.Event() + self._thread = None + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread( + target=self._loop, + name="grotti-watcher", + daemon=True, + ) + self._thread.start() + + def stop(self): + self._stop.set() + + def snapshot(self): + """Kopie des letzten Live-Stands (für API und Dispatcher).""" + with self._lock: + return [dict(row) for row in self.latest] + + def by_host(self): + return {row.get("host"): row for row in self.snapshot() if row.get("host")} + + def age_s(self): + if not self._updated_at: + return None + return time.monotonic() - self._updated_at + + def _loop(self): + while not self._stop.is_set(): + try: + self.tick() + except Exception as exc: + print("Watcher: %s" % exc, flush=True) + self._stop.wait(self.interval) + + def tick(self): + with connect(self.database_url) as conn: + printers = store.list_printers(conn) + if not printers: + conn.commit() + return + lives = moonraker.snapshot(printers) + with self._lock: + self.latest = lives + self._updated_at = time.monotonic() + for live in lives: + self._one(conn, live) + conn.commit() + + def _one(self, conn, live): + host = live.get("host") or "" + name = live.get("name") or host + state = (live.get("state") or "").lower() + filename = live.get("filename") or "" + offline = live.get("ok") is False or state in ("", "offline") + prev = self._prev.get(host) or {} + prev_state = (prev.get("state") or "").lower() + prev_offline = bool(prev.get("offline")) + + if offline: + hits = self._offline_hits.get(host, 0) + 1 + self._offline_hits[host] = hits + if hits >= 2 and not prev_offline: + self._emit(conn, host, name, "offline", "%s ist offline." % name, filename) + self._prev[host] = {"state": "offline", "offline": True, "filename": filename} + return + + self._offline_hits[host] = 0 + if prev_offline: + self._emit(conn, host, name, "offline", "%s ist wieder erreichbar." % name, filename) + elif prev_state and prev_state not in ("printing", "paused") and state == "printing": + self._emit(conn, host, name, "start", "%s druckt %s" % (name, filename or "einen Auftrag"), filename) + elif prev_state == "printing" and state == "paused": + self._emit(conn, host, name, "start", "%s pausiert." % name, filename) + elif prev_state == "paused" and state == "printing": + self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename) + elif prev_state == "printing" and state == "complete": + store.set_bed_needs_clear(conn, host, True, filename or "fertig") + self._emit( + conn, + host, + name, + "fertig", + "%s fertig: %s" % (name, filename or "Druck"), + filename, + ) + # Protokoll und Spulenabzug nicht erst nach manuellem Knopf + try: + from grotti import jobs + + jobs.sync_history(conn, self.cfg) + except Exception as exc: + print("Watcher Abgleich %s: %s" % (host, exc), flush=True) + elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"): + store.set_bed_needs_clear(conn, host, True, filename or state) + self._emit( + conn, + host, + name, + "abbruch", + "%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"), + filename, + ) + elif state == "error" and prev_state != "error": + if prev_state == "printing" or prev_state == "paused": + store.set_bed_needs_clear(conn, host, True, filename or "Fehler") + msg = (live.get("message") or "").strip() + if not msg or msg.lower() in ("error", "fehler"): + msg = "Ursache unbekannt — in Mainsail prüfen" + self._cool_heaters(host) + self._emit( + conn, + host, + name, + "fehler", + "%s: %s · Heizung aus" % (name, msg), + filename, + ) + elif state == "error": + # Schon im Fehler, Heizer laufen noch → aus + if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0: + self._cool_heaters(host) + elif state == "complete": + known = conn.execute( + "SELECT 1 FROM bed_status WHERE host = %s", (host,) + ).fetchone() + if not known: + # Erster Stand nach Deploy: fertiger Druck ohne Quittierung + store.set_bed_needs_clear(conn, host, True, filename or "fertig") + + spool_warn = bool(prev.get("spool_warn")) + if state == "printing" and not spool_warn: + spool_warn = self._check_spool(conn, host, name, filename, live) + + self._prev[host] = { + "state": state, + "offline": False, + "filename": filename, + "spool_warn": spool_warn if state == "printing" else False, + } + + def _cool_heaters(self, host): + try: + moonraker.heaters_off(host) + except Exception as exc: + print("Watcher Heizung aus %s: %s" % (host, exc), flush=True) + + def _check_spool(self, conn, host, name, filename, live): + need = float(live.get("weight") or 0) + if need <= 0 or not filename: + return False + sent = store.latest_send(conn, host, filename) + spool_id = (sent or {}).get("spool_id") + if not spool_id: + return False + spool = store.get_spool_row(conn, spool_id) + if not spool: + return False + left = float(spool["remaining_g"] or 0) + if left + 0.5 >= need: + return False + self._emit( + conn, + host, + name, + "spule", + "%s: Spule „%s“ hat nur %.0f g, Druck braucht %.0f g." + % (name, spool["name"], left, need), + filename, + ) + return True + + def _emit(self, conn, host, printer, kind, message, filename): + notify.record(conn, self.cfg, host, printer, kind, message, filename, dedupe_s=60) diff --git a/intern/Homepage.md b/intern/Homepage.md new file mode 100644 index 0000000..98eac9d --- /dev/null +++ b/intern/Homepage.md @@ -0,0 +1,56 @@ +# Homepage + +Intern. Liegt nicht unter `docs/` und erscheint nicht unter `/doku/`. + +Die öffentliche Vorschau ist [`site/index.html`](../site/index.html). Sie ersetzt die Druckerwand nicht. Die Route `/` bleibt die Wand der laufenden Farm. + +Rechtssätze nicht abwandeln. Wortlaut in [Recht.md](Recht.md), öffentlich in `docs/Haftung.md` und `docs/Voraussetzungen.md`. + +**Entwurf** steht an jedem rechtlichen Block (Fuß, Garantie-Frage). Die übrige Seite ist die Produktvorschau, ohne Preis, ohne Shop, ohne AGB und ohne Impressum. + +## Aufbau + +1. Überschrift: „Eine lokale Seite für die Drucker in der Werkstatt.“ Darunter: „Warteschlange, Abräumen, Material und Kosten. Im eigenen Netz.“ +2. Drei Karten: Wand, Warteschlange „wenn frei“, Ablauf auf der Kobra 3. Daneben Material, Projekte, Kosten, Telegram. +3. Kasten Voraussetzung: Moonraker im eigenen Netz. +4. Kasten vor dem ersten Start: Hitze und Brandgefahr, sichtbar, nicht nur im Fuß. +5. Was sie nicht ist. +6. Vergleich in Sätzen, Stand 14.10.2026, keine Preistabelle. +7. FAQ: welche Drucker, wer einrichtet, Garantie. +8. Fuß: Entwurf Haftung, Markenhinweis. + +Hinweis im Kopf: „Vorschau. Die Druckerwand der laufenden Farm bleibt unter /.“ + +## Festgelegte Sätze + +Wand: „Eine Karte je Drucker mit Live-Zustand: Fortschritt, Temperatur, Kamera. Pause, Abbrechen und Fehler löschen bleiben auf der Karte.“ + +Warteschlange: „‚Wenn frei‘ hält den Auftrag, bis der Drucker frei ist. Die Priorität lässt sich ändern.“ + +Kobra 3: „Auf der Kobra 3 kann ein Ablauf abkühlen, schieben und tasten.“ + +Daneben: „Material: Spulen, Ort und Rest. Projekte: Zuordnung und Kalkulation. Kosten: Strom aus Watt und Preis, im Protokoll. Telegram: Nachricht, wenn ein eingestelltes Ereignis kommt.“ + +Voraussetzung: „Moonraker im eigenen Netz. Bewährt auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft. Rinkhals installiert der Nutzer selbst. Andere Drucker nur, wenn Moonraker dort schon erreichbar ist. Bambu Lab, Prusa und Creality Cloud bleiben bei deren eigenen Programmen.“ + +Grenze: „Kein Slicer. Keine Cloud. Keine Fehlererkennung aus dem Kamerabild. Kein Ersatz für Bambu Farm Manager oder Prusa Connect.“ + +Vergleich: „Bambu Farm Manager ist das lokale Programm von Bambu Lab, kostenlos und nur für Bambu-Drucker. Prusa Connect ist kostenlos und nur für Prusa. Creality Cloud deckt die Creality-Flotte ab. SimplyPrint und Printago sind Cloud-Dienste für viele Marken, mit Abo. Die Farm ist die lokale deutsche Seite für Drucker, die schon Moonraker sprechen. Bewährt ist das auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft.“ + +FAQ Drucker: „Gemeint sind die Kobra 3 und die Kobra S1, sobald Moonraker über Rinkhals erreichbar ist. Der Ablauf Abkühlen, Schieben, Tasten ist für die Kobra 3. An anderen Modellen ist die Farm nicht bewiesen. Ein Drucker, auf dem Moonraker schon erreichbar ist, kann dazukommen, ohne eigene Anleitung.“ + +FAQ Einrichten: „Der Nutzer. Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. Verlinkt wird nur die Installationsseite von Rinkhals.“ + +FAQ Garantie, Entwurf, wortgleich zum Grundsatz: Anycubic-Satz, Creality-Satz, Bambu-Satz, Mängelhaftung beim Verkäufer, Disclaimer wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg. + +Marken: „Anycubic, Bambu Lab, Creality, Prusa und Elegoo sind Marken ihrer Inhaber. Die Farm gehört nicht zu diesen Herstellern.“ + +## Was nicht auf die Seite darf + +- Schritte zum Flashen, Rooten, zur Signaturprüfung, zur Downgrade-Sperre oder zur Bambu-Anmeldung. +- Weitere Druckermodelle als bewiesen. Kobra X nicht nennen. +- Markenliste Flashforge, Qidi, Sovol. +- Preis, Shop, AGB, Impressum, Datenschutz als wäre das schon veröffentlicht. +- Preistabelle der anderen Programme. „Kostenlos“ und „Abo“ nur in den festgelegten Sätzen, Stand 14.10.2026. +- Behauptung, die Kamera erkenne Fehler. Die Kamera zeigt ein Bild. +- Abräumen auf der Kobra S1. Das ist offen. diff --git a/intern/Markt.md b/intern/Markt.md new file mode 100644 index 0000000..f020fea --- /dev/null +++ b/intern/Markt.md @@ -0,0 +1,75 @@ +# Markt + +Intern. Liegt nicht unter `docs/` und erscheint nicht unter `/doku/`. + +Stand der Recherche: **14.10.2026**. + +Garantie, Disclaimer und das Verbot von Umgehungsanleitungen stehen wortgleich in [Recht.md](Recht.md). Diesen Wortlaut hier nicht abwandeln. + +## Entscheidung + +An den Markt gehen lohnt sich nur schmal: eine lokale, deutschsprachige Farm für Drucker, die schon Moonraker sprechen. Heute ist das die Kobra 3 und die Kobra S1 mit Rinkhals, den der Nutzer selbst installiert. Die Farm verkauft kein Flashen, schickt keine Firmware und bietet keinen Flash-Dienst. + +Die Lücke ist die Werkstattseite: Wand, Warteschlange „wenn frei“, auf der Kobra 3 der Ablauf Abkühlen / Schieben / Tasten, dazu Material, Projekte, Kosten und Telegram. Kein Slicer, keine Cloud, keine Fehlererkennung aus dem Kamerabild. + +## Was wir bewusst nicht bauen + +- Kein Kampf gegen die fertigen Welten: Bambu Farm Manager (lokal, kostenlos, nur Bambu), Prusa Connect (kostenlos, nur Prusa), Creality Cloud (Creality-Flotte), SimplyPrint und Printago (viele Marken, Cloud, Abo). +- Keine eigene Creality-Farm und keine Root-Anleitung. +- Kein Bambu-Adapter in diesem Schritt. Später höchstens neben Kobra-Druckern, und nur über den offiziellen Entwicklermodus. +- Keine Beschreibung der Signaturprüfung an der Kobra X, keiner Downgrade-Sperre bei Elegoo, keiner Umgehung der Bambu-Anmeldung. Keine Schritte, keine Links auf solche Anleitungen. +- Kein Auftrags-Flashen und keine vorgeflashten Drucker. +- Preis, Shop, AGB und Impressum gibt es hier nicht. Vor einem Verkauf kommen sie von außen. + +## Hersteller + +### Anycubic Kobra 3 und Kobra S1 + +Das ist der bewiesene Stand. Offiziell gibt es App und Cloud, keinen Farm-Manager. Die Farm spricht Moonraker. Rinkhals legt Moonraker als Aufsatz darüber; die ursprüngliche Software bleibt darunter ([rinkhals-community/Rinkhals](https://github.com/rinkhals-community/Rinkhals)). Printago und SimplyPrint hängen sich per LAN-Client oder Rinkhals daran. Den Aufsatz installiert der Nutzer selbst. Wir verlinken nur die Projektseite. + +Kobra X: Rinkhals scheitert an der Signaturprüfung. Diesen Weg beschreiben wir nicht. + +Der Ablauf Abkühlen, Schieben, Tasten gilt für die Kobra 3. Die Kobra S1 ist geschlossen, Abräumen dort ist offen. + +### Bambu Lab + +[Bambu Farm Manager](https://wiki.bambulab.com/en/software/bambu-farm-manager) ist lokal, für Windows, kostenlos, mit Queue, Sammelbefehlen und mehreren Nutzern. Drittsoftware nur im offiziellen Entwicklermodus plus LAN, ohne Cloud. Eigene Firmware (X1 Plus) streicht den Support. Für eine Farm nur aus Bambu-Druckern gibt es keinen Grund, die Farm zu nehmen. + +### Creality + +Größte Marke, aber zersplittert. K1 und K2 laufen intern mit Klipper, nach außen mit Creality-API und Creality Cloud. Moonraker setzt voraus, dass der Nutzer den Drucker schon geöffnet hat. Diese Schritte schreiben wir nicht. Creality selbst: beim offiziellen Root bleibt der Hardware-Service, Software-Unterstützung dafür gibt es nicht; fremde Firmware ist von der Garantie ausgenommen ([Creality-Garantie](https://wiki.creality.com/en/warranty-policy)). Eine eigene Creality-Farm lohnt nicht. Ein K1, auf dem Moonraker schon erreichbar ist, kann später derselbe Adapter sein wie die Kobra. + +### Prusa + +Prusa Connect und PrusaLink, offene API, kein Flashen. Gutes Gratis-Werkzeug, nur Prusa. Nicht unser Markt. + +### Elegoo Centauri + +Stock-LAN ohne Root. OpenCentauri ist eine fremde Firmware; neuere Carbon-2-Stände sperren SSH und Downgrade. Diesen Umbau beschreiben wir nicht. Technisch später interessant, weil kein Flashen nötig wäre, aber ein anderes Protokoll als Moonraker. + +### Voron und andere offene Klipper-Drucker + +Moonraker ist schon da. Technisch der leichteste Anschluss, wirtschaftlich klein; die Nutzer haben Mainsail oder Fluidd. In der öffentlichen Doku als „Drucker, auf dem Moonraker schon erreichbar ist“, ohne Extra-Anleitung. Bewiesen haben wir die Farm an Kobra 3 und Kobra S1. + +### Flashforge, Qidi, Sovol + +Eigene Apps, mal Klipper, mal nicht. Keine Markenliste auf der Homepage. + +```mermaid +flowchart LR + nutzer[Nutzer] + farm[Farm lokal] + moon[Moonraker] + kobra[Kobra mit Rinkhals] + andere[Klipper schon offen] + bambu[Bambu Farm Manager] + prusa[Prusa Connect] + cloud[SimplyPrint Printago Creality Cloud] + nutzer --> farm + farm --> moon + moon --> kobra + moon --> andere + nutzer --> bambu + nutzer --> prusa + nutzer --> cloud +``` diff --git a/intern/Recht.md b/intern/Recht.md new file mode 100644 index 0000000..05c96a4 --- /dev/null +++ b/intern/Recht.md @@ -0,0 +1,51 @@ +# Recht + +Intern. Liegt nicht unter `docs/` und erscheint nicht unter `/doku/`. + +**Entwurf.** Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB von außen dazu. Diese Seiten nennen keinen Preis. + +Der folgende Grundsatz steht wortgleich in `docs/Haftung.md`, in `docs/Voraussetzungen.md` und im Fuß der Homepage. Nicht umformulieren. + +## Grundsatz + +Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. + +Der Nutzer richtet seinen eigenen Drucker ein, bevor er die Farm nutzt. Verlinkt wird nur die Installationsseite von Rinkhals: . Die Warnung, die der Hersteller selbst einblendet, bleibt seine. + +Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic. + +Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (). + +Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet. + +Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer der Farm wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg. + +Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. + +Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. + +## Was der Disclaimer nicht darf + +Der Hinweis begrenzt nicht die Haftung für Verletzung von Leben, Körper oder Gesundheit, nicht Vorsatz und nicht grobe Fahrlässigkeit. Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Die Farm ist nicht dieser Verkäufer, solange sie keine Drucker verkauft. + +Den Hinweis nicht ins Kleingedruckte schieben. Hitze, Brandgefahr und unbeaufsichtigtes Drucken stehen vor dem ersten Start, auf der Homepage im sichtbaren Kasten und in der Doku unter Voraussetzungen und Haftung. + +AGB, Impressum und Datenschutz nicht so tun, als seien sie schon veröffentlicht. + +## Umgehungsanleitungen + +Keine Anleitung, die eine technische Schutzmaßnahme umgeht. Das gilt intern und außen. Nicht beschreiben, auch nicht als Schritte in dieser Notiz: + +- die Signaturprüfung der Kobra X +- eine Downgrade-Sperre bei Elegoo +- eine Umgehung der Anmeldung bei Bambu Lab + +Erlaubt ist der Verweis auf die Installationsseite von Rinkhals und auf die Warnung, die der Hersteller selbst zeigt. Kein Link auf Root-Skripte, fremde Firmware oder Sperr-Umgehungen. + +## Nähe zum Gerät + +Kein Auftrags-Flashen und keine vorgeflashten Drucker. Sonst rückt die Farm näher an das Gerät als eine Steuerungssoftware. + +## Was die Software schon einhält + +Druckstarts brauchen `confirm: true`. Es gibt keine freie G-code-Konsole und kein `FIRMWARE_RESTART` (GoKlipper deadlockt). Siehe [README](../README.md). diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7bf5d5f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.1 +psycopg[binary]>=3.2 +paho-mqtt>=2.1 +cryptography>=42 +markdown>=3.6 diff --git a/server.py b/server.py new file mode 100644 index 0000000..90cd51a --- /dev/null +++ b/server.py @@ -0,0 +1,40 @@ +"""Grotti starten. Kern und Seiten liegen daneben.""" + +import threading + +from grotti import jobs, library, store +from grotti.config import load +from grotti.db import connect, prepare +from web.app import create_app + + +def background(cfg): + def work(): + try: + with connect(cfg["database_url"]) as conn: + library.sync_watch(conn, cfg, force=False) + jobs.sync_history(conn, cfg) + conn.commit() + except Exception as exc: + print("Abgleich: %s" % exc, flush=True) + + threading.Thread(target=work, name="grotti-abgleich", daemon=True).start() + + +def main(): + cfg = load() + cfg["data_dir"].mkdir(parents=True, exist_ok=True) + (cfg["data_dir"] / "eingang").mkdir(exist_ok=True) + prepare(cfg["database_url"]) + with connect(cfg["database_url"]) as conn: + store.seed_printers(conn, cfg["printers_file"]) + store.seed_materials(conn) + conn.commit() + app = create_app(cfg) + background(cfg) + print("Grotti: http://%s:%s/" % (cfg["host"], cfg["port"]), flush=True) + app.run(host=cfg["host"], port=cfg["port"], threaded=True, use_reloader=False) + + +if __name__ == "__main__": + main() diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..128405e --- /dev/null +++ b/site/index.html @@ -0,0 +1,294 @@ + + + + + +Farm — Drucker in der Werkstatt + + + +
+
+
Farm
+

Vorschau. Die Druckerwand der laufenden Farm bleibt unter /.

+
+ +
+

Lokal, deutsch, Moonraker

+

Eine lokale Seite für die Drucker in der Werkstatt.

+

Warteschlange, Abräumen, Material und Kosten. Im eigenen Netz.

+
+ +
+
+ Wand +

Live-Zustand

+

Eine Karte je Drucker mit Live-Zustand: Fortschritt, Temperatur, Kamera. Pause, Abbrechen und Fehler löschen bleiben auf der Karte.

+
+
+ Warteschlange +

Wenn frei

+

„Wenn frei“ hält den Auftrag, bis der Drucker frei ist. Die Priorität lässt sich ändern.

+
+
+ Kobra 3 +

Abkühlen, schieben, tasten

+

Auf der Kobra 3 kann ein Ablauf abkühlen, schieben und tasten.

+
+
+ +
+
+

Material

+

Spulen, Ort und Rest.

+
+
+

Projekte

+

Zuordnung und Kalkulation.

+
+
+

Kosten

+

Strom aus Watt und Preis, im Protokoll.

+
+
+

Telegram

+

Nachricht, wenn ein eingestelltes Ereignis kommt.

+
+
+ +
+

Voraussetzung: Moonraker im eigenen Netz

+

Bewährt auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft. Rinkhals installiert der Nutzer selbst. Andere Drucker nur, wenn Moonraker dort schon erreichbar ist. Bambu Lab, Prusa und Creality Cloud bleiben bei deren eigenen Programmen.

+

Verlinkt wird nur die Installationsseite von Rinkhals. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.

+
+ +
+

Vor dem ersten Start

+

Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten. Düse und Bett werden heiß. Den ersten Lauf behält der Nutzer im Blick.

+
+ +

Was sie nicht ist

+
    +
  • Kein Slicer.
  • +
  • Keine Cloud.
  • +
  • Keine Fehlererkennung aus dem Kamerabild.
  • +
  • Kein Ersatz für Bambu Farm Manager oder Prusa Connect.
  • +
+ +
+

Vergleich

+

Bambu Farm Manager ist das lokale Programm von Bambu Lab, kostenlos und nur für Bambu-Drucker. Prusa Connect ist kostenlos und nur für Prusa. Creality Cloud deckt die Creality-Flotte ab. SimplyPrint und Printago sind Cloud-Dienste für viele Marken, mit Abo. Die Farm ist die lokale deutsche Seite für Drucker, die schon Moonraker sprechen. Bewährt ist das auf der Kobra 3 und der Kobra S1, nachdem Rinkhals läuft.

+

Stand 14.10.2026. Keine Preisliste.

+
+ +
+
+ Welche Drucker sind heute gemeint? +

Gemeint sind die Kobra 3 und die Kobra S1, sobald Moonraker über Rinkhals erreichbar ist. Der Ablauf Abkühlen, Schieben, Tasten ist für die Kobra 3. An anderen Modellen ist die Farm nicht bewiesen. Ein Drucker, auf dem Moonraker schon erreichbar ist, kann dazukommen, ohne eigene Anleitung.

+
+
+ Wer richtet den Drucker ein? +

Der Nutzer. Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker. Verlinkt wird nur die Installationsseite von Rinkhals.

+
+
+ Was wird aus der Garantie? +

Entwurf Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic. Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (Creality-Garantie). Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet. Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer der Farm wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.

+
+
+ +
+

Entwurf Kein anwaltlich geprüfter Text. Vor einem Verkauf mit Preis, Abo oder Shop kommen Impressum, Datenschutz und AGB dazu. Diese Seiten nennen keinen Preis.

+

Die Farm flasht nicht, schickt keine Firmware und beschreibt keine Signatur-Umgehung, keinen Root-Exploit und keine Umgehung einer Downgrade-Sperre. Es gibt kein Auftrags-Flashen und keine vorgeflashten Drucker.

+

Der Nutzer richtet seinen eigenen Drucker ein, bevor er die Farm nutzt. Verlinkt wird nur die Installationsseite von Rinkhals. Die Warnung, die der Hersteller selbst einblendet, bleibt seine.

+

Anycubic schließt ungenehmigte Änderungen von der Garantie aus. Rinkhals ist ein Community-Aufsatz; die ursprüngliche Software bleibt darunter, der Aufsatz lässt sich wieder entfernen. Ob Anycubic einen Fall annimmt, entscheidet Anycubic.

+

Creality lässt beim offiziellen Root den Hardware-Service stehen und gibt dafür keine Software-Unterstützung. Fremde Firmware ist von der Garantie ausgenommen (Creality-Garantie).

+

Bambu Lab deckt Schäden durch inoffizielle Firmware nicht ab. Der Entwicklermodus ist eine eigene Funktion von Bambu Lab, mit der Warnung, die Bambu Lab selbst einblendet.

+

Die gesetzliche Mängelhaftung für den Drucker bleibt beim Verkäufer des Druckers. Ein Disclaimer der Farm wischt Verletzung, Vorsatz und grobe Fahrlässigkeit nicht weg.

+

Unbeaufsichtigtes Drucken, Hitze und Brandgefahr stehen vor dem ersten Start, nicht im Kleingedruckten.

+

Anycubic, Bambu Lab, Creality, Prusa und Elegoo sind Marken ihrer Inhaber. Die Farm gehört nicht zu diesen Herstellern.

+

© by dominic@gundel.dev

+
+
+ + diff --git a/tests/test_eject.py b/tests/test_eject.py new file mode 100644 index 0000000..bea5a48 --- /dev/null +++ b/tests/test_eject.py @@ -0,0 +1,198 @@ +"""Tests für Abräum-Bahn und Normalisierung.""" + +import unittest +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grotti import eject +from grotti.errors import GrottiError + + +class EjectTests(unittest.TestCase): + def test_default_steps(self): + steps = eject.normalize_steps([ + {"type": "print", "file_id": 1, "bed_leveling": True}, + {"type": "cool"}, + {"type": "sweep"}, + {"type": "check"}, + ]) + self.assertEqual([s["type"] for s in steps], ["print", "cool", "sweep", "check"]) + self.assertEqual(steps[0]["file_id"], 1) + self.assertTrue(steps[0]["bed_leveling"]) + + def test_needs_print(self): + with self.assertRaises(GrottiError): + eject.normalize_steps([{"type": "cool"}]) + + def test_x_positions_inclusive(self): + sweep = eject.normalize_sweep({"x_from": 25, "x_to": 45, "x_step": 10}) + self.assertEqual(eject.x_positions(sweep), [25.0, 35.0, 45.0]) + + def test_sweep_y_order(self): + with self.assertRaises(GrottiError): + eject.normalize_sweep({"y_from": 5, "y_to": 220}) + + def test_cool_ready(self): + cool = eject.normalize_cool({"bed_max": 30, "nozzle_max": 45}) + self.assertFalse(eject.cool_ready({"bed": 40, "nozzle": 40}, cool)) + self.assertTrue(eject.cool_ready({"bed": 28, "nozzle": 40}, cool)) + + def test_cool_park_center(self): + # Relativ +40, gegen z_max gekappt + low = eject.cool_park_script({"position": [250, 220, 8.0], "z_max": 262}).replace(" ", "") + self.assertIn("G91", low) + self.assertIn("Z40.00", low) + self.assertIn("X125.00", low) + # Nah an Z-max → nur Restweg + near = eject.cool_park_lift_mm({"position": [125, 125, 245.0], "z_max": 250}) + self.assertAlmostEqual(near, 5.0) + capped = eject.cool_park_script({"position": [125, 125, 245.0], "z_max": 250}).replace(" ", "") + self.assertIn("Z5.00", capped) + self.assertTrue( + eject.cool_parked({"position": [125, 125, 48]}, before_z=8.0, lift_mm=40.0) + ) + self.assertFalse( + eject.cool_parked({"position": [125, 125, 20]}, before_z=8.0, lift_mm=40.0) + ) + self.assertFalse(eject.cool_parked({"position": [125, 125, 50]})) # ohne Hub-Nachweis + self.assertFalse(eject.cool_parked({"position": [250, 220, 50]}, before_z=10, lift_mm=40)) + + def test_sweep_does_not_lower_before_back(self): + script = eject.sweep_probe_height_script(eject.normalize_sweep({})) + # Y hinten (Default 250) vor dem Senken auf z_push + y_idx = script.find("Y250") + z_push_idx = script.find("Z2.00") + self.assertGreater(y_idx, 0) + self.assertGreater(z_push_idx, y_idx) + # Senken langsam — nicht F3000 ins Bett + self.assertIn("F%d" % eject.SWEEP_FEED_DOWN, script) + down = script[z_push_idx : z_push_idx + 40] + self.assertNotIn("F3000", down) + + def test_assert_clear_before_lower(self): + sweep = eject.normalize_sweep({}) + eject.assert_clear_before_lower({"position": [125, 250, 40.0]}, sweep) + with self.assertRaises(GrottiError): + eject.assert_clear_before_lower({"position": [125, 250, 8.0]}, sweep) + + def test_check_delta_migrates_retract_workaround(self): + check = eject.normalize_check({"max_delta_mm": 2.5}) + self.assertAlmostEqual(check["max_delta_mm"], 3.0) + self.assertAlmostEqual(eject.normalize_check({"max_delta_mm": 1.0}).get("max_delta_mm"), 3.0) + self.assertAlmostEqual(eject.normalize_check({}).get("max_delta_mm"), 3.0) + err = eject.explain_printer_error("No trigger on probe after full movement") + self.assertIn("Wägezelle", err) + self.assertNotIn("Induktiv", err) + + def test_check_z_ignores_toolhead(self): + z, src = eject.check_z_from_live({"probe_z": None, "position": [125, 125, 1.7]}) + self.assertIsNone(z) + z, src = eject.check_z_from_live({"probe_z": 0.37}) + self.assertAlmostEqual(z, 0.37) + self.assertEqual(src, "probe") + + def test_sweep_respects_single_x(self): + sweep = eject.normalize_sweep({"x_from": 125, "x_to": 125, "x_step": 0}) + script = eject.sweep_script(sweep) + # eine Schiebe-Spur: genau ein Absenken auf z_push, ein Mal nach vorn + self.assertEqual(script.count("Z2.00"), 1) + self.assertEqual(script.count("Y5.00"), 1) + self.assertLess(script.find("Y250"), script.find("Z2.00")) + + def test_assert_at_push_height(self): + sweep = eject.normalize_sweep({}) + eject.assert_at_push_height({"position": [125, 220, 2.0]}, sweep) + with self.assertRaises(GrottiError): + eject.assert_at_push_height({"position": [125, 220, 18.0]}, sweep) + + def test_step_labels(self): + self.assertEqual(eject.step_label({"type": "check"}), "Tasten") + self.assertEqual(eject.step_label({"type": "sweep"}), "Schieben") + + def test_check_script_probe(self): + script = eject.check_script({"x": 125, "y": 125}, current_z=50.0) + self.assertEqual(script.count("PROBE"), 1) + self.assertNotIn("SET_KINEMATIC_POSITION", script) + self.assertNotIn("BED_MESH_CLEAR", script) + self.assertIn("G0 Z50.00", script) + self.assertIn("X125", script.replace(" ", "")) + # Auch bei schlechter Z: kein Budget-Fake, nur z_clear + low = eject.check_script({"x": 125, "y": 125}, current_z=-4.0) + self.assertEqual(low.count("PROBE"), 1) + self.assertNotIn("SET_KINEMATIC_POSITION", low) + self.assertIn("G0 Z25.00", low) + + def test_restore_uses_live_xyz(self): + script, z = eject.restore_position_script( + {"position": [250.0, 220.0, 48.0]}, {"z_push": 2} + ) + self.assertAlmostEqual(z, 48.0) + self.assertIn("SET_KINEMATIC_POSITION", script) + self.assertIn("X=250.000", script) + self.assertIn("Y=220.000", script) + self.assertIn("Z=48.000", script) + with self.assertRaises(GrottiError): + eject.restore_position_script({"position": [125, 125, 1.0]}, {"z_push": 2}) + + def test_strip_end_motor_disable(self): + raw = "G1 X250 Y220 F12000\nM84; disable motors\nM140 S0\n" + out = eject.strip_end_motor_disable(raw) + self.assertNotIn("M84", out) + self.assertIn("G1 X250 Y220", out) + self.assertIn("M140 S0", out) + # Kommentarzeile mit M84 bleibt + commented = "; M84; disable motors\nG1 X1\n" + self.assertIn("; M84", eject.strip_end_motor_disable(commented)) + + def test_probe_result_regex(self): + from grotti import moonraker + + match = moonraker._PROBE_RESULT_RE.search("Result is z=0.374500\n") + self.assertIsNotNone(match) + self.assertAlmostEqual(float(match.group(1)), 0.3745) + + +class StoreQueueOutTests(unittest.TestCase): + def test_legacy_mesh_hidden(self): + from grotti import store + + row = { + "id": 1, + "file_id": 1, + "host": "10.0.0.1", + "family": "kobra_3", + "priority": 100, + "status": "running", + "spool_id": None, + "ace_map": [], + "bed_leveling": True, + "override_machine": False, + "message": "ok", + "steps": [ + {"type": "print", "file_id": 1}, + {"type": "cool"}, + {"type": "sweep"}, + {"type": "check", "probe_count": 3, "mesh_min_x": 0}, + {"type": "check", "max_delta_mm": 1}, + ], + "step_index": 4, + "repeat_total": 1, + "repeat_done": 0, + "phase": "", + "phase_started_at": None, + "created_at": None, + "updated_at": None, + "printer_name": "A", + "filename": "x.gcode", + } + out = store.print_queue_out(row, "x.gcode") + self.assertEqual(out["step_labels"], ["Drucken", "Abkühlen", "Schieben", "Tasten"]) + self.assertEqual(out["step_label_current"], "Tasten") + self.assertTrue(out["is_ablauf"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_print_queue.py b/tests/test_print_queue.py new file mode 100644 index 0000000..b7f5d32 --- /dev/null +++ b/tests/test_print_queue.py @@ -0,0 +1,50 @@ +"""Zustandsmaschine der Druckwarteschlange mit Fake-Moonraker.""" + +import unittest +from pathlib import Path +import sys +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grotti.print_queue import PRINT_START_TIMEOUT_S, PrintQueueDispatcher, _cool_wait_text + + +class CoolTextTests(unittest.TestCase): + def test_wait_text(self): + cool = {"bed_max": 30, "nozzle_max": 45} + text = _cool_wait_text({"bed": 40, "nozzle": 50}, cool) + self.assertIn("Düse", text) + self.assertIn("Bett", text) + self.assertNotIn(" · ", text) + + +class DispatcherHelpers(unittest.TestCase): + def test_timeout_constant(self): + self.assertGreaterEqual(PRINT_START_TIMEOUT_S, 300) + + def test_worker_busy_empty(self): + d = PrintQueueDispatcher("postgresql://x", {}, watcher=None) + self.assertFalse(d._worker_busy(1)) + + def test_auto_enabled_default(self): + d = PrintQueueDispatcher("postgresql://x", {}, watcher=None) + self.assertTrue(d._auto_enabled()) + d.cfg = {"auto_queue": False} + self.assertFalse(d._auto_enabled()) + + +class ResolveSpoolTests(unittest.TestCase): + def test_material_match(self): + from grotti.jobs import assert_material_match + from grotti.errors import GrottiError + + file_row = {"filaments": [{"type": "PLA"}]} + assert_material_match(file_row, [{"material_type": "PLA"}]) + with self.assertRaises(GrottiError): + assert_material_match(file_row, [{"material_type": "PETG"}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..d6c1842 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,91 @@ +"""store.print_queue_out und Hilfen ohne Datenbank.""" + +import unittest +from datetime import datetime, timezone +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grotti.store import print_queue_out + + +class PrintQueueOutTests(unittest.TestCase): + def test_basic_fields(self): + now = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc) + out = print_queue_out( + { + "id": 7, + "file_id": 3, + "filename": "teil.gcode", + "host": "10.10.1.10", + "printer_name": "A2", + "family": "kobra_3", + "priority": 50, + "status": "waiting", + "spool_id": None, + "ace_map": [{"paint_index": 0, "ams_index": 1}], + "bed_leveling": True, + "override_machine": False, + "message": "wartet", + "steps": [], + "step_index": 0, + "repeat_total": 1, + "repeat_done": 0, + "phase": "", + "phase_started_at": None, + "created_at": now, + "updated_at": now, + } + ) + self.assertEqual(out["id"], 7) + self.assertEqual(out["host"], "10.10.1.10") + self.assertEqual(out["family_label"], "Kobra 3") + self.assertTrue(out["bed_leveling"]) + self.assertEqual(out["ace_map"][0]["ams_index"], 1) + self.assertFalse(out["is_ablauf"]) + self.assertEqual(out["filename"], "teil.gcode") + + def test_ablauf_labels_skip_legacy_mesh(self): + steps = [ + {"type": "print"}, + {"type": "cool"}, + {"type": "sweep"}, + {"type": "check", "probe_count": 3, "mesh_min_x": 0}, + {"type": "check", "max_delta_mm": 1}, + ] + out = print_queue_out( + { + "id": 1, + "file_id": 1, + "filename": "x.gcode", + "host": "h", + "printer_name": "", + "family": "kobra_3", + "priority": 100, + "status": "running", + "spool_id": None, + "ace_map": [], + "bed_leveling": False, + "override_machine": False, + "message": "", + "steps": steps, + "step_index": 2, + "repeat_total": 2, + "repeat_done": 0, + "phase": "cooling", + "phase_started_at": None, + "created_at": None, + "updated_at": None, + } + ) + self.assertTrue(out["is_ablauf"]) + self.assertNotIn("mesh", " ".join(out["step_labels"]).lower()) + self.assertEqual(out["step_labels"][0], "Drucken") + self.assertIn(out["step_label_current"], out["step_labels"]) + self.assertEqual(out["repeat_total"], 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..b1516ff --- /dev/null +++ b/web/app.py @@ -0,0 +1,1328 @@ +"""Flask hängt die API und die Seiten an den Kern.""" + +import json +import re +import secrets +import uuid +from datetime import datetime +from pathlib import Path +from urllib.parse import quote + +import markdown +import psycopg +from flask import Flask, abort, g, jsonify, render_template, request +from markupsafe import Markup +from psycopg.rows import dict_row + +from grotti import config as grotti_config +from grotti import eject, jobs, library, moonraker, notify, store +from grotti.catalog import color_catalog +from grotti.cost import calculate +from grotti.errors import GrottiError +from grotti.printers import FAMILY_LABELS, family_from_name +from grotti.print_queue import PrintQueueDispatcher +from grotti.send_queue import SendQueue +from grotti.toolpath import TOOLPATH_VERSION, build_toolpath +from grotti.version import __version__ +from grotti.watcher import StateWatcher + +ROOT = Path(__file__).resolve().parent +TOOL_ROOT = ROOT.parent +DOCS_DIR = TOOL_ROOT / "docs" +CHANGELOG = TOOL_ROOT / "CHANGELOG.md" +PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"} +VERDICTS = {"", "gut", "ausschuss"} +IDEA_STATUS = {"offen", "spaeter", "erledigt"} +PRINTER_FAMILIES = set(FAMILY_LABELS) | {""} +STEER_ACTIONS = { + "nozzle", "bed", "speed", "flow", "fan", "emergency_stop", "clear_error", + "exclude_object", +} + + +def create_app(cfg): + app = Flask( + __name__, + template_folder=str(ROOT / "templates"), + static_folder=str(ROOT / "static"), + ) + app.config["GROTTI"] = cfg + app.config["TEMPLATES_AUTO_RELOAD"] = True + app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 + app.json.ensure_ascii = False + app.extensions["send_queue"] = SendQueue(cfg["database_url"], cfg) + watcher = StateWatcher(cfg["database_url"], cfg) + dispatcher = PrintQueueDispatcher(cfg["database_url"], cfg, watcher=watcher) + app.extensions["print_queue"] = dispatcher + app.extensions["watcher"] = watcher + watcher.start() + dispatcher.start() + + @app.before_request + def check_token(): + token = cfg["token"] + if not token or not request.path.startswith("/api/"): + return None + presented = "" + header = request.headers.get("Authorization", "") + if header.startswith("Bearer "): + presented = header[7:] + elif request.method == "GET": + presented = request.args.get("token", "") + if not presented or not secrets.compare_digest(presented, token): + return jsonify({"error": "Token fehlt oder stimmt nicht.", "code": "token"}), 401 + return None + + @app.teardown_appcontext + def close_db(exc): + conn = g.pop("db", None) + if not conn: + return + try: + if exc: + conn.rollback() + else: + conn.commit() + finally: + conn.close() + + @app.errorhandler(GrottiError) + def grotti_error(err): + conn = g.get("db") + if conn: + conn.rollback() + return jsonify({"error": err.message, "code": err.code}), err.status + + def conn(): + if "db" not in g: + g.db = psycopg.connect(cfg["database_url"], row_factory=dict_row) + return g.db + + def body(): + data = request.get_json(silent=True) + if not isinstance(data, dict): + raise GrottiError("JSON-Objekt erwartet.", "json") + return data + + def optional_id(value): + if value in (None, "", 0, "0"): + return None + try: + return int(value) + except (TypeError, ValueError) as exc: + raise GrottiError("Die Nummer stimmt nicht.", "id") from exc + + def require_host(host): + if not store.printer_by_host(conn(), host or ""): + raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) + return host + + def optional_host(host): + host = (host or "").strip() + if host: + require_host(host) + return host + + @app.context_processor + def inject_version(): + return {"version": __version__} + + def page(name, title): + return render_template(name, page=name.split(".")[0], title=title, token=cfg["token"]) + + def doc_names(): + if not DOCS_DIR.is_dir(): + return [] + return sorted(p.stem for p in DOCS_DIR.glob("*.md")) + + def render_markdown(path): + text = path.read_text(encoding="utf-8") + html = markdown.markdown(text, extensions=["tables", "fenced_code", "sane_lists"]) + return Markup(html) + + @app.get("/") + def wand(): + return page("wand.html", "Drucker") + + @app.get("/kameras") + def kameras(): + return page("kameras.html", "Kameras") + + @app.get("/dateien") + def dateien(): + return page("dateien.html", "Dateien") + + @app.get("/projekte") + def projekte(): + return page("projekte.html", "Projekte") + + @app.get("/einstellungen") + def einstellungen_page(): + return page("einstellungen.html", "Einstellungen") + + @app.get("/material") + def material(): + return page("material.html", "Material") + + @app.get("/etikett/") + def etikett(spool_id): + row = store.get_spool_row(conn(), spool_id) + if not row: + abort(404) + spool = store.spool_out(row) + if not spool.get("chargen_nr"): + store.ensure_spool_codes(conn()) + row = store.get_spool_row(conn(), spool_id) + spool = store.spool_out(row) + base = grotti_config.public_base(cfg, request.url_root) + qr_url = "%s/material?spool=%s" % (base, spool_id) + return render_template( + "etikett.html", + page="etikett", + title="Etikett %s" % spool["chargen_nr"], + token=cfg["token"], + spool=spool, + qr_url=qr_url, + ) + + @app.get("/protokoll") + def protokoll(): + return page("protokoll.html", "Protokoll") + + @app.get("/ideen") + def ideen(): + return page("ideen.html", "Ideen") + + @app.get("/aktivitaet") + def aktivitaet(): + return page("aktivitaet.html", "Aktivität") + + @app.get("/warteschlange") + def warteschlange_page(): + return page("warteschlange.html", "Warteschlange") + + @app.get("/doku") + def doku(): + return render_template( + "doku.html", + page="doku", + title="Doku", + token=cfg["token"], + docs=doc_names(), + ) + + @app.get("/doku/") + def doku_page(name): + if name == "changelog": + path, title = CHANGELOG, "Changelog" + elif name in doc_names(): + path, title = DOCS_DIR / ("%s.md" % name), name.replace("-", " ") + else: + abort(404) + if not path.is_file(): + abort(404) + return render_template( + "doku_page.html", + page="doku", + title=title, + token=cfg["token"], + content=render_markdown(path), + docs=doc_names(), + current=name, + ) + + @app.get("/api/v1/version") + def version_info(): + return jsonify({"version": __version__, "changelog": "/doku/changelog"}) + + @app.get("/api/v1/einstellungen") + def settings(): + return jsonify(grotti_config.settings_public(cfg)) + + @app.patch("/api/v1/einstellungen") + def settings_patch(): + data = body() + energy = data.get("energy") if isinstance(data.get("energy"), dict) else None + telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None + alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None + auto_queue = data.get("auto_queue") if "auto_queue" in data else None + fqdn = data.get("fqdn") if "fqdn" in data else None + return jsonify(grotti_config.save_settings( + cfg, energy=energy, telegram=telegram, alerts=alerts, auto_queue=auto_queue, fqdn=fqdn, + )) + + @app.post("/api/v1/telegram/test") + def telegram_test(): + ok = notify.send_telegram(cfg, "Grotti-Test: Telegram ist verbunden.") + if not ok: + raise GrottiError( + "Telegram nicht erreichbar. Token, Chat-ID und „aktiv“ prüfen.", + "telegram", + ) + return jsonify({"ok": True}) + + @app.get("/api/v1/drucker") + @app.get("/api/drucker") + def printers(): + rows = store.list_printers(conn()) + clears = store.bed_clear_map(conn()) + queues = store.queue_by_host(conn(), rows) + + def attach(item): + host = item.get("host") or "" + item["needs_clear"] = bool(clears.get(host)) + queue = queues.get(host) or {} + item["queue_running"] = queue.get("running") + item["queue_failed"] = queue.get("failed") + item["queue_waiting"] = queue.get("waiting", 0) + item["queue_waiting_next"] = queue.get("waiting_next", "") + + by_id = {row["host"]: row for row in rows} + if request.args.get("live", "1") == "0": + for row in rows: + row["mainsail"] = "http://%s/" % row["host"] + attach(row) + return jsonify(rows) + # Eine Live-Quelle: Watcher. Frischer als 60 s, sonst einmal selbst abfragen. + watcher = app.extensions["watcher"] + cached = watcher.by_host() + age = watcher.age_s() + if not cached or age is None or age > 60: + lives = moonraker.snapshot(rows) + else: + lives = [] + for row in rows: + live = cached.get(row["host"]) + if live: + item = dict(live) + item["name"] = row["name"] + item["family"] = row["family"] + item["family_label"] = row["family_label"] + lives.append(item) + else: + item = dict(row) + item["ok"] = False + item["state"] = "offline" + item["mainsail"] = "http://%s/" % row["host"] + lives.append(item) + for live in lives: + meta = by_id.get(live.get("host") or "") or {} + live["locked"] = bool(meta.get("locked")) + attach(live) + return jsonify(lives) + + @app.post("/api/v1/drucker") + def printer_create(): + data = body() + name = (data.get("name") or "").strip() + host = (data.get("host") or "").strip() + if not name: + raise GrottiError("Der Drucker braucht einen Namen.", "name") + if not host: + raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host") + family = (data.get("family") or "").strip() or family_from_name(name) + if family not in PRINTER_FAMILIES: + raise GrottiError("Unbekannte Druckerfamilie.", "family") + if store.printer_by_host(conn(), host): + raise GrottiError("Diesen Host gibt es schon.", "host") + return jsonify(store.create_printer(conn(), uuid.uuid4(), name, host, family)), 201 + + @app.patch("/api/v1/drucker/id/") + def printer_patch(printer_id): + data = body() + current = store.printer_by_id(conn(), printer_id) + if not current: + raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) + name = (data.get("name") if "name" in data else current["name"]) or "" + name = str(name).strip() + host = (data.get("host") if "host" in data else current["host"]) or "" + host = str(host).strip() + family = data.get("family") if "family" in data else current["family"] + family = (family or "").strip() or family_from_name(name) + if not name: + raise GrottiError("Der Drucker braucht einen Namen.", "name") + if not host: + raise GrottiError("Der Drucker braucht eine IP oder einen Host.", "host") + if family not in PRINTER_FAMILIES: + raise GrottiError("Unbekannte Druckerfamilie.", "family") + other = store.printer_by_host(conn(), host) + if other and str(other["id"]) != str(printer_id): + raise GrottiError("Diesen Host gibt es schon.", "host") + locked = data.get("locked") if "locked" in data else current.get("locked") + row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked)) + return jsonify(row) + + @app.delete("/api/v1/drucker/id/") + def printer_delete(printer_id): + if not store.delete_printer(conn(), printer_id): + raise GrottiError("Diesen Drucker kennt Grotti nicht.", "drucker", 404) + return "", 204 + + @app.post("/api/v1/drucker//") + def printer_action(host, action): + require_host(host) + if action not in ("pause", "fortsetzen", "abbrechen"): + raise GrottiError("Unbekannter Befehl.", "befehl") + return jsonify(jobs.control(conn(), host, action)) + + @app.post("/api/v1/drucker//steuern") + def printer_steer(host): + require_host(host) + data = body() + action = (data.get("action") or "").strip() + if action not in STEER_ACTIONS: + raise GrottiError("Unbekannte Steuerung.", "befehl") + if action in ("emergency_stop", "clear_error", "exclude_object") and not data.get("confirm"): + raise GrottiError("Bestätigung fehlt.", "bestaetigung") + return jsonify(jobs.steuern(conn(), host, action, data)) + + @app.get("/api/v1/kamera") + def camera(): + host = require_host(request.args.get("host", "")) + image = moonraker.camera_image(host) + if not image: + abort(404) + return image[1], 200, {"Content-Type": image[0], "Cache-Control": "no-store"} + + @app.get("/api/v1/vorschau") + def live_preview(): + host = require_host(request.args.get("host", "")) + embedded_name = request.args.get("embedded", "") + if embedded_name: + png = moonraker.embedded_bytes(host, embedded_name) + if not png: + abort(404) + return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"} + file_path = request.args.get("file", "").replace("\\", "/") + if ".." in file_path or ".thumbs/" not in file_path or not file_path.endswith(".png"): + abort(404) + try: + kind, data = moonraker.proxy_image( + host, "http://%s/server/files/gcodes/%s" % (host, quote(file_path, safe="/")) + ) + except Exception: + abort(404) + return data, 200, {"Content-Type": kind, "Cache-Control": "private, max-age=300"} + + @app.get("/api/v1/projekte") + def project_list(): + return jsonify(store.list_projects(conn())) + + @app.post("/api/v1/projekte") + def project_create(): + data = body() + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Das Projekt braucht einen Namen.", "name") + status = data.get("status") or "idee" + if status not in PROJECT_STATUS: + raise GrottiError("Unbekannter Status.", "status") + return jsonify(store.create_project(conn(), name, (data.get("notes") or "").strip(), status)), 201 + + @app.get("/api/v1/projekte/") + def project_get(project_id): + row = store.get_project(conn(), project_id) + if not row: + raise GrottiError("Das Projekt fehlt.", "projekt", 404) + return jsonify(row) + + @app.patch("/api/v1/projekte/") + def project_patch(project_id): + data = body() + fields = {} + if "name" in data: + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Das Projekt braucht einen Namen.", "name") + fields["name"] = name + if "notes" in data: + fields["notes"] = data.get("notes") or "" + if "status" in data: + if data["status"] not in PROJECT_STATUS: + raise GrottiError("Unbekannter Status.", "status") + fields["status"] = data["status"] + row = store.update_project(conn(), project_id, fields) + if not row: + raise GrottiError("Das Projekt fehlt.", "projekt", 404) + return jsonify(row) + + @app.delete("/api/v1/projekte/") + def project_delete(project_id): + if not store.delete_project(conn(), project_id): + raise GrottiError("Das Projekt fehlt.", "projekt", 404) + return "", 204 + + @app.get("/api/v1/dateien") + def file_list(): + library.sync_watch(conn(), cfg, force=False) + return jsonify(store.list_files(conn())) + + @app.post("/api/v1/dateien/neu-lesen") + def file_rescan(): + result = library.sync_watch(conn(), cfg, force=True) + result["metadaten"] = library.refresh_file_meta(conn(), force=True) + return jsonify(result) + + @app.post("/api/v1/dateien") + def file_upload(): + upload = request.files.get("file") + if not upload or not upload.filename: + raise GrottiError("Keine Datei angehängt.", "datei") + project_id = optional_id(request.form.get("project_id")) + saved = library.save_upload(conn(), upload.filename, upload.read(), cfg, project_id) + return jsonify(saved), 201 + + @app.get("/api/v1/dateien//vorschau") + def file_preview(file_id): + row = store.get_file_row(conn(), file_id) + if not row: + abort(404) + png = library.preview_png(row) + if not png: + abort(404) + return png, 200, {"Content-Type": "image/png", "Cache-Control": "private, max-age=300"} + + @app.get("/api/v1/dateien//pfad") + def file_toolpath(file_id): + row = store.get_file_row(conn(), file_id) + if not row: + raise GrottiError("Die Datei fehlt.", "datei", 404) + path = Path(row["stored_path"]) + if not path.is_file(): + raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404) + cache_dir = Path(cfg["data_dir"]) / "toolpath" + cache_dir.mkdir(parents=True, exist_ok=True) + stamp = "%s-%s-%s-v%s" % ( + file_id, + int(row.get("mtime") or 0), + int(row.get("size_bytes") or 0), + TOOLPATH_VERSION, + ) + cache_path = cache_dir / (stamp + ".json") + if cache_path.is_file(): + try: + data = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = None + else: + data = None + if not isinstance(data, dict) or data.get("version") != TOOLPATH_VERSION: + data = build_toolpath(path) + try: + cache_path.write_text(json.dumps(data), encoding="utf-8") + except OSError: + pass + data["file_id"] = file_id + data["name"] = row["name"] + return jsonify(data) + + @app.patch("/api/v1/dateien/") + def file_patch(file_id): + data = body() + fields = {} + if "project_id" in data: + fields["project_id"] = optional_id(data.get("project_id")) + if "spool_id" in data: + spool_id = optional_id(data.get("spool_id")) + if spool_id and not store.get_spool_row(conn(), spool_id): + raise GrottiError("Die Spule fehlt.", "spule", 404) + fields["spool_id"] = spool_id + row = store.update_file(conn(), file_id, fields) + if not row: + raise GrottiError("Die Datei fehlt.", "datei", 404) + return jsonify(row) + + @app.delete("/api/v1/dateien/") + def file_delete(file_id): + row = store.get_file_row(conn(), file_id) + if not row: + raise GrottiError("Die Datei fehlt.", "datei", 404) + if row["source"] != "upload": + raise GrottiError("Die Datei kommt aus dem Watch-Ordner und bleibt, solange sie dort liegt.", "watch") + store.delete_file(conn(), file_id) + Path(row["stored_path"]).unlink(missing_ok=True) + return "", 204 + + @app.post("/api/v1/senden") + def send(): + data = body() + try: + file_id = int(data.get("file_id")) + except (TypeError, ValueError) as exc: + raise GrottiError("Die Datei fehlt.", "datei") from exc + hosts = data.get("hosts") + if isinstance(hosts, list): + hosts = [str(item or "").strip() for item in hosts if str(item or "").strip()] + else: + host = (data.get("host") or "").strip() + hosts = [host] if host else [] + ace_maps = data.get("ace_maps") if isinstance(data.get("ace_maps"), dict) else {} + if not ace_maps and isinstance(data.get("ace_map"), list) and len(hosts) == 1: + ace_maps = {hosts[0]: data.get("ace_map")} + # when_free: in Druckwarteschlange statt sofort starten + if data.get("when_free"): + if not bool(data.get("confirm")): + raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung") + file_row = store.get_file_row(conn(), file_id) + if not file_row: + raise GrottiError("Die Datei fehlt.", "datei", 404) + family = (data.get("family") or file_row.get("printer_family") or "").strip() + spool_id = optional_id(data.get("spool_id")) + bed = bool(data.get("bed_leveling")) + override = bool(data.get("override_machine")) + try: + priority = int(data.get("priority", 100)) + except (TypeError, ValueError): + priority = 100 + created = [] + targets = hosts or [""] + for host in targets: + if host: + require_host(host) + # Nur die Map dieses Hosts — nie die eines anderen Druckers + # (bei „nächster freier“ host="" bleibt die Map leer bis zum Claim). + mapping = ace_maps.get(host) if host else [] + if not isinstance(mapping, list): + mapping = [] + if host: + jobs.assert_filament_enough(conn(), file_row, spool_id, mapping, host) + created.append( + store.enqueue_print( + conn(), + file_id, + host, + family if not host else "", + priority, + spool_id, + mapping, + bed, + override, + ) + ) + return jsonify({"jobs": created, "count": len(created), "mode": "when_free"}), 202 + created = app.extensions["send_queue"].enqueue_many( + conn(), + file_id, + hosts, + bool(data.get("confirm")), + bool(data.get("override_machine")), + optional_id(data.get("spool_id")), + ace_maps, + bed_leveling=bool(data.get("bed_leveling")), + ) + return jsonify({"jobs": created, "count": len(created), "mode": "now"}), 202 + + @app.get("/api/v1/sende-auftraege") + def send_list(): + return jsonify(app.extensions["send_queue"].list_jobs()) + + @app.get("/api/v1/senden-zuletzt") + def send_last(): + host = optional_host(request.args.get("host") or "") + try: + file_id = int(request.args.get("file_id") or 0) + except (TypeError, ValueError): + file_id = 0 + if not host or not file_id: + raise GrottiError("Host und Datei nötig.", "senden") + row = store.latest_send_for_file(conn(), host, file_id) + if not row: + return jsonify(None) + return jsonify(row) + + @app.get("/api/v1/senden/") + def send_status(job_id): + job = app.extensions["send_queue"].get(job_id) + if not job: + raise GrottiError("Der Auftrag fehlt.", "auftrag", 404) + return jsonify(job) + + def enqueue_ablauf_payload(data): + """Ablauf (Drucken/Abkühlen/Schieben) in die Druckwarteschlange.""" + if not bool(data.get("confirm")): + raise GrottiError("Start braucht eine Bestätigung.", "bestaetigung") + hosts = data.get("hosts") + if isinstance(hosts, list): + hosts = [str(item or "").strip() for item in hosts if str(item or "").strip()] + else: + host = (data.get("host") or "").strip() + hosts = [host] if host else [] + if not hosts: + raise GrottiError("Ablauf braucht einen Kobra-3-Drucker.", "drucker") + for host in hosts: + require_host(host) + printer = store.printer_by_host(conn(), host) + eject.require_kobra_3(printer) + fallback = { + "file_id": data.get("file_id"), + "spool_id": data.get("spool_id"), + "ace_map": data.get("ace_map") if isinstance(data.get("ace_map"), list) else [], + "bed_leveling": bool(data.get("bed_leveling")), + "override_machine": bool(data.get("override_machine")), + } + ace_maps = data.get("ace_maps") if isinstance(data.get("ace_maps"), dict) else {} + steps = eject.normalize_steps(data.get("steps"), fallback) + for step in steps: + if step["type"] != "print": + continue + if not store.get_file_row(conn(), step["file_id"]): + raise GrottiError("Die Datei fehlt.", "datei", 404) + file_id = eject.first_print_file_id(steps) + try: + priority = int(data.get("priority", 100)) + except (TypeError, ValueError): + priority = 100 + try: + repeat_total = int(data.get("repeat_total", 1)) + except (TypeError, ValueError): + repeat_total = 1 + repeat_total = max(1, min(99, repeat_total)) + created = [] + for host in hosts: + mapping = ace_maps.get(host) if isinstance(ace_maps.get(host), list) else None + host_steps = [] + for step in steps: + if step["type"] == "print" and mapping is not None: + copy = dict(step) + copy["ace_map"] = mapping + host_steps.append(copy) + else: + host_steps.append(step) + first = next(s for s in host_steps if s["type"] == "print") + file_row = store.get_file_row(conn(), first["file_id"]) + jobs.assert_filament_enough( + conn(), + file_row, + first.get("spool_id"), + first.get("ace_map") or [], + host, + ) + created.append( + store.enqueue_print( + conn(), + file_id, + host, + "kobra_3", + priority, + first.get("spool_id"), + first.get("ace_map") or [], + bool(first.get("bed_leveling")), + bool(first.get("override_machine")), + steps=host_steps, + repeat_total=repeat_total, + ) + ) + return {"jobs": created, "count": len(created), "mode": "ablauf"} + + @app.get("/api/v1/druck-queue") + def print_queue_list(): + try: + limit = max(1, min(200, int(request.args.get("limit") or 40))) + except (TypeError, ValueError): + limit = 40 + return jsonify(store.list_print_queue(conn(), limit=limit)) + + @app.get("/api/v1/laeuft") + def running_now(): + """Was gerade druckt, auch ohne Auftrag in der Queue (Stand des Watchers).""" + items = [] + for live in app.extensions["watcher"].latest or []: + state = (live.get("state") or "").lower() + if state not in ("printing", "paused", "heating"): + continue + items.append({ + "host": live.get("host") or "", + "name": live.get("name") or "", + "state": state, + "progress": live.get("progress") or 0, + "remain": live.get("remain") or 0, + "filename": live.get("filename") or "", + }) + return jsonify(items) + + @app.get("/api/v1/aufgaben") + def open_tasks(): + """Platte belegt und kein Ablauf räumt sie: Hand muss ran.""" + clears = store.bed_clear_map(conn()) + busy = { + row["host"] + for row in conn().execute("SELECT DISTINCT host FROM print_queue WHERE status = 'running'").fetchall() + } + items = [] + for live in app.extensions["watcher"].latest or []: + host = live.get("host") or "" + state = (live.get("state") or "").lower() + if not clears.get(host) or host in busy or state in ("printing", "paused", "heating"): + continue + items.append({ + "host": host, + "name": live.get("name") or "", + "state": state, + "filename": live.get("filename") or "", + }) + return jsonify(items) + + @app.post("/api/v1/druck-queue") + def print_queue_create(): + data = body() + if isinstance(data.get("steps"), list) and data.get("steps"): + return jsonify(enqueue_ablauf_payload(data)), 202 + try: + file_id = int(data.get("file_id")) + except (TypeError, ValueError) as exc: + raise GrottiError("Die Datei fehlt.", "datei") from exc + if not store.get_file_row(conn(), file_id): + raise GrottiError("Die Datei fehlt.", "datei", 404) + host = optional_host(data.get("host") or "") + family = (data.get("family") or "").strip() + ace_map = data.get("ace_map") if isinstance(data.get("ace_map"), list) else [] + try: + priority = int(data.get("priority", 100)) + except (TypeError, ValueError): + priority = 100 + row = store.enqueue_print( + conn(), + file_id, + host, + family, + priority, + optional_id(data.get("spool_id")), + ace_map, + bool(data.get("bed_leveling")), + bool(data.get("override_machine")), + ) + return jsonify(row), 201 + + @app.post("/api/v1/ablauf") + def ablauf_create(): + return jsonify(enqueue_ablauf_payload(body())), 202 + + @app.post("/api/v1/schieben-testen") + def schieben_testen(): + """Nur Testbahn aus dem Ablauf — nicht die Wand-Quittierung.""" + data = body() + if not bool(data.get("confirm_empty")): + raise GrottiError("Bestätigung „Bett ist leer“ fehlt.", "bestaetigung") + host = require_host((data.get("host") or "").strip()) + printer = store.printer_by_host(conn(), host) + eject.require_kobra_3(printer) + live = moonraker.query(host) + state = (live.get("state") or "").lower() + if live.get("ok") is False: + raise GrottiError("Drucker offline.", "offline") + if state in ("printing", "paused", "heating"): + raise GrottiError("Drucker ist beschäftigt.", "busy") + sweep = eject.normalize_sweep(data.get("sweep") if isinstance(data.get("sweep"), dict) else data) + homed = (live.get("homed_axes") or "").lower() + try: + if not all(axis in homed for axis in "xyz"): + # Bett ist laut Bestätigung leer → richtiges Homing ist sicher + moonraker.gcode_script(host, "G28", timeout=180) + moonraker.gcode_script(host, eject.sweep_script(sweep), timeout=180) + except moonraker.PrinterError as exc: + raise GrottiError(eject.explain_printer_error(exc.message), "drucker") from exc + store.set_bed_needs_clear(conn(), host, False, "Schieben-Test") + return jsonify({"ok": True, "host": host, "sweep": sweep, "message": "Bahn gefahren."}) + + @app.post("/api/v1/drucker//abraeumen") + def printer_abraeumen(host): + """Quittierung: Platte ist frei. Fährt keine Bahn — nur für „wenn frei“.""" + data = body() + if not bool(data.get("confirm")): + raise GrottiError("Bestätigung fehlt.", "bestaetigung") + require_host(host) + store.set_bed_needs_clear(conn(), host, False, "quittiert") + return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."}) + + @app.get("/api/v1/abraeumen-defaults") + def abraeumen_defaults(): + return jsonify({ + "cool": eject.DEFAULT_COOL, + "sweep": eject.DEFAULT_SWEEP, + "check": eject.DEFAULT_CHECK, + "limits": eject.LIMITS, + }) + + @app.patch("/api/v1/druck-queue/") + def print_queue_patch(item_id): + data = body() + fields = {} + if "priority" in data: + try: + fields["priority"] = int(data.get("priority")) + except (TypeError, ValueError) as exc: + raise GrottiError("Priorität muss eine Zahl sein.", "zahl") from exc + if "status" in data: + status = data.get("status") + if status not in ("waiting", "cancelled", "error", "done", "running", "dismissed"): + raise GrottiError("Unbekannter Status.", "status") + current_row = store.get_print_queue_row(conn(), item_id) + if not current_row: + raise GrottiError("Der Queue-Eintrag fehlt.", "queue", 404) + # Von außen nur Abbrechen, Verwerfen oder Fortsetzen — kein freies done/running. + if status == "done": + raise GrottiError("Status „fertig“ setzt nur Grotti.", "status") + if status == "running" and not data.get("resume"): + raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status") + if status == "waiting" and current_row.get("status") not in ("waiting",): + raise GrottiError("Status lässt sich so nicht setzen.", "status") + fields["status"] = status + if status == "running" and data.get("resume"): + row = current_row + if row.get("status") != "error" or not row.get("steps"): + raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status") + fields["phase"] = "" + fields["phase_started_at"] = None + fields["message"] = "Wird fortgesetzt…" + store.add_event( + conn(), row.get("host") or "", row.get("printer_name") or "", "ablauf", + "%s Ablauf von Hand fortgesetzt bei Schritt %d (%s)" % ( + row.get("printer_name") or "", + int(row.get("step_index") or 0) + 1, + store.print_queue_out(row).get("step_label_current") or "", + ), + row.get("filename") or "", + ) + if status == "cancelled": + fields["message"] = "Abgebrochen." + fields["phase"] = "" + fields["phase_started_at"] = None + row = store.get_print_queue_row(conn(), item_id) + if row: + was_running = row.get("status") == "running" + what = "Ablauf" if row.get("steps") else "Auftrag" + stopped = "" + # laufenden Druck auf dem Host best-effort stoppen + if was_running and row.get("host"): + try: + moonraker.command(row["host"], "abbrechen") + stopped = ", Druck gestoppt" + except Exception: + stopped = ", Druck nicht gestoppt" + store.add_event( + conn(), + row.get("host") or "", + row.get("printer_name") or "", + "ablauf", + "%s %s von Hand %s%s: %s" % ( + row.get("printer_name") or "Warteschlange", + what, + "abgebrochen" if was_running else "entfernt", + stopped, + row.get("filename") or "", + ), + row.get("filename") or "", + ) + if "message" in data: + fields["message"] = str(data.get("message") or "") + row = store.update_print_queue(conn(), item_id, fields) + if not row: + raise GrottiError("Der Queue-Eintrag fehlt.", "queue", 404) + return jsonify(row) + + @app.get("/api/v1/ereignisse") + def events_list(): + unread = request.args.get("unread") == "1" + try: + limit = max(1, min(1000, int(request.args.get("limit") or 40))) + except (TypeError, ValueError): + limit = 40 + kinds = [k for k in (request.args.get("kind") or "").split(",") if k.strip()] + since = None + if request.args.get("since"): + try: + since = datetime.fromisoformat(request.args["since"].replace("Z", "+00:00")) + except ValueError as exc: + raise GrottiError("since muss ein ISO-Zeitpunkt sein.", "since") from exc + return jsonify({ + "items": store.list_events( + conn(), + limit=limit, + unread_only=unread, + host=(request.args.get("host") or "").strip() or None, + kinds=kinds or None, + since=since, + ), + "unread": store.unread_event_count(conn()), + }) + + @app.post("/api/v1/ereignisse/gelesen") + def events_read(): + data = body() if request.data else {} + ids = data.get("ids") if isinstance(data.get("ids"), list) else None + if ids is not None: + try: + ids = [int(item) for item in ids] + except (TypeError, ValueError) as exc: + raise GrottiError("IDs müssen Zahlen sein.", "id") from exc + unread = store.mark_events_read(conn(), ids) + return jsonify({"unread": unread}) + + @app.post("/api/v1/dateien/von-drucker") + def file_from_printer(): + data = body() + host = require_host(data.get("host") or "") + filename = (data.get("filename") or "").strip() + saved = library.ensure_library_gcode(conn(), cfg, host, filename) + if not saved: + raise GrottiError("Datei konnte nicht vom Drucker geladen werden.", "datei") + return jsonify(saved), 201 + + @app.get("/api/v1/materialien") + def material_list(): + return jsonify(store.list_materials(conn())) + + @app.post("/api/v1/materialien") + def material_create(): + data = body() + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Die Sorte braucht einen Namen.", "name") + machines = data.get("machines") or "beide" + if machines not in ("beide", "kobra_3", "kobra_s1"): + raise GrottiError("Unbekannte Maschinenwahl.", "machines") + try: + row = store.create_material( + conn(), + { + "name": name, + "brand": (data.get("brand") or "").strip(), + "material_type": (data.get("material_type") or "PLA").strip() or "PLA", + "density": float(data.get("density") or 1.24), + "volumetric": float(data.get("volumetric") or 12), + "volumetric_s1": ( + float(data["volumetric_s1"]) + if data.get("volumetric_s1") not in (None, "") + else None + ), + "machines": machines, + "price_per_kg": float(data.get("price_per_kg") or 0), + "note": (data.get("note") or "").strip(), + }, + ) + except Exception as exc: + if "unique" in str(exc).lower() or "duplicate" in str(exc).lower(): + raise GrottiError("Eine Sorte mit diesem Namen gibt es schon.", "name") from exc + raise + return jsonify(row), 201 + + @app.patch("/api/v1/materialien/") + def material_patch(material_id): + data = body() + fields = {} + if "name" in data: + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Die Sorte braucht einen Namen.", "name") + fields["name"] = name + if "brand" in data: + fields["brand"] = (data.get("brand") or "").strip() + if "material_type" in data: + fields["material_type"] = (data.get("material_type") or "PLA").strip() or "PLA" + if "density" in data: + fields["density"] = float(data.get("density") or 1.24) + if "volumetric" in data: + fields["volumetric"] = float(data.get("volumetric") or 12) + if "volumetric_s1" in data: + raw = data.get("volumetric_s1") + fields["volumetric_s1"] = float(raw) if raw not in (None, "") else None + if "machines" in data: + machines = data.get("machines") or "beide" + if machines not in ("beide", "kobra_3", "kobra_s1"): + raise GrottiError("Unbekannte Maschinenwahl.", "machines") + fields["machines"] = machines + if "price_per_kg" in data: + fields["price_per_kg"] = float(data.get("price_per_kg") or 0) + if "note" in data: + fields["note"] = (data.get("note") or "").strip() + try: + row = store.update_material(conn(), material_id, fields) + except Exception as exc: + if "unique" in str(exc).lower() or "duplicate" in str(exc).lower(): + raise GrottiError("Eine Sorte mit diesem Namen gibt es schon.", "name") from exc + raise + if not row: + raise GrottiError("Die Sorte fehlt.", "material", 404) + return jsonify(row) + + @app.delete("/api/v1/materialien/") + def material_delete(material_id): + result = store.delete_material(conn(), material_id) + if result == "in_use": + raise GrottiError("Sorte ist noch an Spulen gebunden.", "material") + if not result: + raise GrottiError("Die Sorte fehlt.", "material", 404) + return "", 204 + + @app.get("/api/v1/farben") + def color_list_api(): + return jsonify(color_catalog()) + + @app.get("/api/v1/ideen") + def idea_list(): + return jsonify(store.list_ideas(conn())) + + @app.post("/api/v1/ideen") + def idea_create(): + data = body() + title = (data.get("title") or "").strip() + if not title: + raise GrottiError("Die Idee braucht einen Titel.", "titel") + status = data.get("status") or "offen" + if status not in IDEA_STATUS: + raise GrottiError("Unbekannter Status.", "status") + return jsonify(store.create_idea(conn(), title, (data.get("body") or "").strip(), status)), 201 + + @app.patch("/api/v1/ideen/") + def idea_patch(idea_id): + data = body() + fields = {} + if "title" in data: + title = (data.get("title") or "").strip() + if not title: + raise GrottiError("Die Idee braucht einen Titel.", "titel") + fields["title"] = title + if "body" in data: + fields["body"] = (data.get("body") or "").strip() + if "status" in data: + if data.get("status") not in IDEA_STATUS: + raise GrottiError("Unbekannter Status.", "status") + fields["status"] = data.get("status") + row = store.update_idea(conn(), idea_id, fields) + if not row: + raise GrottiError("Die Idee fehlt.", "idee", 404) + return jsonify(row) + + @app.delete("/api/v1/ideen/") + def idea_delete(idea_id): + if not store.delete_idea(conn(), idea_id): + raise GrottiError("Die Idee fehlt.", "idee", 404) + return "", 204 + + @app.get("/api/v1/spulen") + def spool_list(): + return jsonify(store.list_spools(conn())) + + @app.get("/api/v1/spulen/by-code/") + def spool_by_code(code): + row = store.get_spool_by_code(conn(), code) + if not row: + raise GrottiError("Keine Spule mit diesem Code.", "spule", 404) + return jsonify(row) + + @app.post("/api/v1/spulen") + def spool_create(): + return jsonify(store.create_spool(conn(), spool_fields(body()))), 201 + + @app.patch("/api/v1/spulen/") + def spool_patch(spool_id): + row = store.update_spool(conn(), spool_id, spool_fields(body(), partial=True, spool_id=spool_id)) + if not row: + raise GrottiError("Die Spule fehlt.", "spule", 404) + return jsonify(row) + + @app.post("/api/v1/spulen//einlegen") + def spool_load(spool_id): + """Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal.""" + data = body() + host = require_host((data.get("host") or "").strip()) + slot = ace_slot(data.get("ace_slot")) + spool = store.get_spool_row(conn(), spool_id) + if not spool: + raise GrottiError("Die Spule fehlt.", "spule", 404) + # Alte Rolle am Platz / externe Rolle am Host freimachen + if slot: + old = conn().execute( + "SELECT * FROM spools WHERE printer_host = %s AND ace_slot = %s AND id <> %s", + (host, slot, spool_id), + ).fetchall() + else: + old = conn().execute( + "SELECT * FROM spools WHERE printer_host = %s AND ace_slot IS NULL AND id <> %s", + (host, spool_id), + ).fetchall() + for row in old: + store.update_spool(conn(), row["id"], {"printer_host": "", "ace_slot": None}) + store.add_event( + conn(), + host, + (store.printer_by_host(conn(), host) or {}).get("name") or host, + "spule", + "%s: „%s“ ins Regal%s" % ( + (store.printer_by_host(conn(), host) or {}).get("name") or host, + row["name"], + (" (Platz %s)" % slot) if slot else "", + ), + "", + ) + updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot}) + printer = store.printer_by_host(conn(), host) or {} + store.add_event( + conn(), + host, + printer.get("name") or host, + "spule", + "%s: „%s“ eingelegt%s" % ( + printer.get("name") or host, + updated["name"], + (" auf Platz %s" % slot) if slot else "", + ), + "", + ) + return jsonify(updated) + + @app.delete("/api/v1/spulen/") + def spool_delete(spool_id): + if not store.delete_spool(conn(), spool_id): + raise GrottiError("Die Spule fehlt.", "spule", 404) + return "", 204 + + def spool_fields(data, partial=False, spool_id=None): + fields = {} + if not partial or "name" in data: + name = (data.get("name") or "").strip() + if not name: + raise GrottiError("Die Spule braucht einen Namen.", "name") + fields["name"] = name + text_keys = ("brand", "material_type", "hersteller_charge") + for key in text_keys: + if not partial or key in data: + fields[key] = (data.get(key) or "").strip() + if not partial or "colors" in data or "color" in data: + fields["colors"] = color_list(data.get("colors")) + label = (data.get("color") or "").strip() + if not label: + label = " / ".join(item["name"] for item in fields["colors"]) + fields["color"] = label + for key, default in (("price_per_kg", 0), ("density", 1.24), ("volumetric", 12), ("remaining_g", 1000)): + if not partial or key in data: + try: + fields[key] = float(data.get(key, default) or 0) + except (TypeError, ValueError) as exc: + raise GrottiError("%s muss eine Zahl sein." % key, "zahl") from exc + if not partial or "printer_host" in data: + fields["printer_host"] = optional_host(data.get("printer_host") or "") + if not partial or "ace_slot" in data: + fields["ace_slot"] = ace_slot(data.get("ace_slot")) + if not partial or "material_id" in data: + fields["material_id"] = optional_id(data.get("material_id")) + # Doppelte Plätze verhindern + host = fields.get("printer_host") + slot = fields.get("ace_slot") + if host is None or slot is None: + current = store.get_spool_row(conn(), spool_id) if spool_id else None + if host is None and current: + host = current.get("printer_host") or "" + if slot is None and current: + slot = current.get("ace_slot") + if host and slot: + taken = conn().execute( + """ + SELECT id, name FROM spools + WHERE printer_host = %s AND ace_slot = %s + AND (%s::bigint IS NULL OR id <> %s) + LIMIT 1 + """, + (host, slot, spool_id, spool_id), + ).fetchone() + if taken: + raise GrottiError( + "Platz %s auf diesem Drucker ist schon von „%s“ belegt." + % (slot, taken["name"]), + "ace", + 409, + ) + return fields + + def color_list(raw): + if raw in (None, ""): + return [] + if not isinstance(raw, list): + raise GrottiError("Farben sind eine Liste.", "farbe") + if len(raw) > 8: + raise GrottiError("Eine Spule hat höchstens acht Farben.", "farbe") + colors = [] + for item in raw: + if isinstance(item, dict): + name = (item.get("name") or "").strip() + hex_value = (item.get("hex") or "").strip() + else: + raise GrottiError("Eine Farbe stimmt nicht.", "farbe") + if not re.fullmatch(r"#[0-9A-Fa-f]{6}", hex_value): + raise GrottiError("Eine Farbe braucht einen Hex-Wert.", "farbe") + colors.append({"name": name or hex_value, "hex": hex_value.lower()}) + return colors + + def ace_slot(raw): + if raw in (None, "", 0, "0"): + return None + try: + slot = int(raw) + except (TypeError, ValueError) as exc: + raise GrottiError("Der ACE-Platz ist eine Zahl.", "ace") from exc + if slot < 1 or slot > 8: + raise GrottiError("Ein ACE hat vier Plätze, ein zweites ACE die Plätze 5 bis 8.", "ace") + return slot + + @app.post("/api/v1/kalkulation") + def quote(): + data = body() + spool = None + spool_id = optional_id(data.get("spool_id")) + if spool_id: + spool = store.get_spool_row(conn(), spool_id) + if not spool: + raise GrottiError("Die Spule fehlt.", "spule", 404) + material = { + "price_per_kg": spool["price_per_kg"] if spool else float(data.get("price_per_kg") or 0), + "density": spool["density"] if spool else float(data.get("density") or 1.24), + "volumetric": spool["volumetric"] if spool else float(data.get("volumetric") or 12), + } + job = { + "weight_g": float(data.get("weight_g") or 0), + "qty": float(data.get("qty") or 1), + "waste_percent": float(data.get("waste_percent", cfg["energy"]["waste_percent"]) or 0), + "utilization": float(data.get("utilization", cfg["energy"]["utilization"]) or 1), + "watts": float(data.get("watts", cfg["energy"]["watts"]) or 0), + "euro_per_kwh": float(data.get("euro_per_kwh", cfg["energy"]["euro_per_kwh"]) or 0), + "dryer_watts": float(data.get("dryer_watts", cfg["energy"]["dryer_watts"]) or 0), + "dry_hours": float(data.get("dry_hours", cfg["energy"]["dry_hours"]) or 0), + "price_mode": data.get("price_mode") or "perPart", + "price": float(data.get("price") or 0), + } + result = calculate(job, material) + payload = {"inputs": job, "material": material, "result": result} + project_id = optional_id(data.get("project_id")) + if project_id: + saved = store.update_project(conn(), project_id, {"quote": payload}) + if not saved: + raise GrottiError("Das Projekt fehlt.", "projekt", 404) + return jsonify(payload) + + @app.get("/api/v1/laeufe") + def run_list(): + return jsonify(store.list_runs(conn())) + + @app.post("/api/v1/laeufe/abgleich") + def run_sync(): + library.sync_watch(conn(), cfg, force=False) + return jsonify(jobs.sync_history(conn(), cfg)) + + @app.patch("/api/v1/laeufe/") + def run_patch(run_id): + data = body() + row = None + if "spool_id" in data and data.get("spool_id"): + row = jobs.assign_and_deduct(conn(), cfg, run_id, int(data["spool_id"])) + if "verdict" in data: + if data["verdict"] not in VERDICTS: + raise GrottiError("Urteil ist gut oder Ausschuss.", "urteil") + row = store.set_verdict(conn(), run_id, data["verdict"]) + if row is None: + current = store.get_run_row(conn(), run_id) + if not current: + raise GrottiError("Der Lauf fehlt.", "lauf", 404) + row = store.run_out(current) + return jsonify(row) + + return app diff --git a/web/static/api.js b/web/static/api.js new file mode 100644 index 0000000..949624e --- /dev/null +++ b/web/static/api.js @@ -0,0 +1,172 @@ +const TOKEN = document.querySelector('meta[name="token"]')?.content || ""; + +const STATE_LABELS = { + printing: "druckt", paused: "pausiert", complete: "fertig", standby: "bereit", + heating: "heizt", error: "Fehler", cancelled: "abgebrochen", offline: "offline", + waiting: "wartet", running: "läuft", +}; + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (TOKEN) headers.set("Authorization", "Bearer " + TOKEN); + let body = options.body; + if (options.json !== undefined) { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(options.json); + } + const response = await fetch("/api/v1" + path, { + method: options.method || "GET", + headers, + body, + cache: "no-store", + }); + if (!response.ok) { + let message = response.statusText; + try { + const payload = await response.json(); + message = payload.error || message; + } catch (error) { + message = response.statusText; + } + throw new Error(message); + } + if (response.status === 204) return null; + const type = response.headers.get("content-type") || ""; + if (type.includes("json")) return response.json(); + return response; +} + +function withToken(url) { + if (!url || !TOKEN) return url; + return url + (url.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(TOKEN); +} + +function euro(value) { + return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" }); +} + +function grams(value) { + return Number(value || 0).toLocaleString("de-DE", { maximumFractionDigits: 0 }) + " g"; +} + +function hours(seconds) { + seconds = Math.round(Number(seconds) || 0); + if (seconds <= 0) return ""; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return h > 0 ? h + " h " + String(m).padStart(2, "0") + " min" : m + " min"; +} + +function finishClock(remainSeconds) { + const sec = Number(remainSeconds) || 0; + if (sec <= 0) return ""; + const at = new Date(Date.now() + sec * 1000); + return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); +} + +function clockOf(iso) { + if (!iso) return ""; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return ""; + return at.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); +} + +function showNote(id, error, ok) { + const node = document.getElementById(id); + if (node) { + if (error) { + node.textContent = error.message || String(error); + node.className = "msg"; + } else { + node.textContent = ok || ""; + node.className = ok ? "ok" : "ok"; + } + } + if (error) toast(error.message || String(error), "bad"); + else if (ok) toast(ok, "good"); +} + +function icon(name, cls) { + return ``; +} + +function chipHtml(state, label, small) { + const text = label || STATE_LABELS[state] || state || ""; + return `${escapeHtml(text)}`; +} + +function stepsHtml(labels, index, compact) { + if (!labels || !labels.length) return ""; + const parts = labels.map((label, i) => { + const cls = i < index ? "done" : i === index ? "now" : ""; + return `${escapeHtml(label)}`; + }); + return `${parts.join('')}`; +} + +function repeatText(job) { + const total = Math.max(1, Number(job.repeat_total) || 1); + if (total <= 1) return ""; + return Math.min(total, (Number(job.repeat_done) || 0) + 1) + "/" + total; +} + +function phaseText(job) { + const message = (job && job.message) || ""; + if (!job || !job.is_ablauf || job.status !== "running") return message; + const parts = message.split(" · "); + return parts.length > 1 ? parts.slice(-1)[0] : message; +} + +function jobLabel(filename) { + return (filename || "").replace(/\.gcode(\.3mf)?$/i, "").replace(/\.3mf$/i, "").replace(/^(\d+-)+/, ""); +} + +function toast(message, kind) { + let host = document.querySelector(".toast-host"); + if (!host) { + host = document.createElement("div"); + host.className = "toast-host"; + host.setAttribute("role", "status"); + host.setAttribute("aria-live", "polite"); + document.body.appendChild(host); + } + const node = document.createElement("div"); + node.className = "toast " + (kind || "good"); + node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "
"; + node.querySelector("div").textContent = message; + host.appendChild(node); + const ttl = kind === "bad" ? 7000 : 4000; + setTimeout(() => { + node.classList.add("out"); + setTimeout(() => node.remove(), 260); + }, ttl); +} + +function armButton(button, armedText, onConfirm, timeoutMs) { + if (button.dataset.armed === "1") { + clearTimeout(Number(button.dataset.armTimer || 0)); + button.dataset.armed = ""; + button.classList.remove("confirm-armed"); + if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml; + onConfirm(); + return; + } + button.dataset.idleHtml = button.innerHTML; + button.dataset.armed = "1"; + button.classList.add("confirm-armed"); + button.textContent = armedText; + const timer = setTimeout(() => { + button.dataset.armed = ""; + button.classList.remove("confirm-armed"); + if (button.dataset.idleHtml) button.innerHTML = button.dataset.idleHtml; + }, timeoutMs || 5000); + button.dataset.armTimer = String(timer); +} diff --git a/web/static/gcode-viewer.js b/web/static/gcode-viewer.js new file mode 100644 index 0000000..8bc9119 --- /dev/null +++ b/web/static/gcode-viewer.js @@ -0,0 +1,723 @@ +/* global THREE */ + +window.GrottiGcodeViewer = (function () { + const PHI_MIN = 0.08; + const PHI_MAX = Math.PI - 0.08; + const COLOR_DONE = 0x5dffb0; + const COLOR_TODO = 0x6b7280; + const COLOR_PREVIEW = 0xf97316; + const COLOR_OBJ = 0x60a5fa; + const COLOR_OBJ_EX = 0xef4444; + const COLOR_OBJ_CUR = 0xfbbf24; + const GRID_STEP = 10; + // Nur leichte Doppelung — starke Offsets + Punkte wirkten wie Vollkörper mit Löchern + const FAT_OFFSETS = [0, -0.22, 0.22]; + + function create(canvas) { + const wrap = document.createElement("div"); + wrap.className = "gcode-viewer-wrap"; + if (canvas.parentNode) { + canvas.parentNode.insertBefore(wrap, canvas); + wrap.appendChild(canvas); + } + const legend = document.createElement("div"); + legend.className = "gcode-legend"; + legend.innerHTML = + 'gedruckt' + + 'offen' + + 'Pfad' + + 'Gitter ' + GRID_STEP + " mm"; + wrap.appendChild(legend); + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x121212); + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 5000); + camera.up.set(0, 0, 1); + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + + const light = new THREE.DirectionalLight(0xffffff, 0.9); + light.position.set(1, -2, 3); + scene.add(light); + scene.add(new THREE.AmbientLight(0xffffff, 0.45)); + + const bedGroup = new THREE.Group(); + scene.add(bedGroup); + const pathGroup = new THREE.Group(); + scene.add(pathGroup); + const excludeGroup = new THREE.Group(); + scene.add(excludeGroup); + + let points = []; + let layerBreaks = [0]; + let progress = null; + let layer = null; + let layers = 0; + let center = { x: 0, y: 0, z: 0 }; + let span = 80; + let excludeData = { objects: [], excluded: [], current: null }; + let selectedName = null; + let onExcludeSelect = null; + let excludeMeshes = []; + + let theta = 0.55; + let phi = 1.05; + let distance = 320; + let target = new THREE.Vector3(0, 0, 20); + let defaultView = { theta: 0.55, phi: 1.05, distance: 320, tx: 0, ty: 0, tz: 20 }; + + let mode = null; + let lastX = 0; + let lastY = 0; + let downX = 0; + let downY = 0; + let pointers = new Map(); + let pinchDist = 0; + let alive = true; + let raf = 0; + let dirty = true; + const raycaster = new THREE.Raycaster(); + const pointerNdc = new THREE.Vector2(); + + canvas.style.cursor = "grab"; + canvas.style.touchAction = "none"; + + function resize() { + const width = canvas.clientWidth || canvas.parentElement.clientWidth || 480; + const height = canvas.clientHeight || 320; + if (canvas.width !== width || canvas.height !== height) { + renderer.setSize(width, height, false); + camera.aspect = width / Math.max(1, height); + camera.updateProjectionMatrix(); + dirty = true; + } + } + + function orbit() { + const sinPhi = Math.sin(phi); + camera.position.set( + target.x + distance * sinPhi * Math.sin(theta), + target.y - distance * sinPhi * Math.cos(theta), + target.z + distance * Math.cos(phi) + ); + camera.lookAt(target); + } + + function clearGroup(group) { + while (group.children.length) { + const child = group.children[0]; + group.remove(child); + if (child.geometry) child.geometry.dispose(); + if (child.material) { + const mats = Array.isArray(child.material) ? child.material : [child.material]; + mats.forEach((m) => { + if (m.map) m.map.dispose(); + m.dispose(); + }); + } + } + } + + function clearLines() { + clearGroup(pathGroup); + } + + function clearBed() { + clearGroup(bedGroup); + } + + function clearExclude() { + clearGroup(excludeGroup); + excludeMeshes = []; + } + + function boundsOf(pts) { + let minX = Infinity; + let minY = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let maxZ = -Infinity; + for (const point of pts) { + minX = Math.min(minX, point[0]); + minY = Math.min(minY, point[1]); + minZ = Math.min(minZ, point[2]); + maxX = Math.max(maxX, point[0]); + maxY = Math.max(maxY, point[1]); + maxZ = Math.max(maxZ, point[2]); + } + return { minX, minY, minZ, maxX, maxY, maxZ }; + } + + function niceFloor(value, step) { + return Math.floor(value / step) * step; + } + + function niceCeil(value, step) { + return Math.ceil(value / step) * step; + } + + function makeLabelSprite(text) { + const c = document.createElement("canvas"); + c.width = 256; + c.height = 64; + const ctx = c.getContext("2d"); + ctx.clearRect(0, 0, c.width, c.height); + ctx.fillStyle = "rgba(18,18,18,0.72)"; + roundRect(ctx, 8, 8, c.width - 16, c.height - 16, 10); + ctx.fill(); + ctx.fillStyle = "#e8e8e8"; + ctx.font = "700 36px ui-sans-serif, system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, c.width / 2, c.height / 2 + 1); + const tex = new THREE.CanvasTexture(c); + tex.needsUpdate = true; + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }); + const sprite = new THREE.Sprite(mat); + sprite.scale.set(18, 4.5, 1); + sprite.renderOrder = 10; + return sprite; + } + + function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + + function buildBed(b) { + clearBed(); + const pad = GRID_STEP * 3; + const minX = niceFloor(b.minX - pad, GRID_STEP) - center.x; + const maxX = niceCeil(b.maxX + pad, GRID_STEP) - center.x; + const minY = niceFloor(b.minY - pad, GRID_STEP) - center.y; + const maxY = niceCeil(b.maxY + pad, GRID_STEP) - center.y; + const w = Math.max(GRID_STEP, maxX - minX); + const h = Math.max(GRID_STEP, maxY - minY); + + const plate = new THREE.Mesh( + new THREE.PlaneGeometry(w, h), + new THREE.MeshBasicMaterial({ + color: 0x1a1a1a, + transparent: true, + opacity: 0.88, + side: THREE.DoubleSide, + }) + ); + plate.position.set((minX + maxX) / 2, (minY + maxY) / 2, -0.08); + bedGroup.add(plate); + + const gridLines = []; + for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) { + const major = Math.round(x) % 50 === 0; + gridLines.push(x, minY, 0, x, maxY, 0); + if (major) gridLines.push(x + 0.2, minY, 0, x + 0.2, maxY, 0); + } + for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) { + const major = Math.round(y) % 50 === 0; + gridLines.push(minX, y, 0, maxX, y, 0); + if (major) gridLines.push(minX, y + 0.2, 0, maxX, y + 0.2, 0); + } + const gridGeo = new THREE.BufferGeometry(); + gridGeo.setAttribute("position", new THREE.Float32BufferAttribute(gridLines, 3)); + bedGroup.add( + new THREE.LineSegments( + gridGeo, + new THREE.LineBasicMaterial({ color: 0x3f3f3f }) + ) + ); + + const tick = []; + for (let x = minX; x <= maxX + 0.01; x += GRID_STEP) { + const len = Math.round(x) % 50 === 0 ? 7 : 3.5; + tick.push(x, minY, 0.02, x, minY - len, 0.02); + } + for (let y = minY; y <= maxY + 0.01; y += GRID_STEP) { + const len = Math.round(y) % 50 === 0 ? 7 : 3.5; + tick.push(minX, y, 0.02, minX - len, y, 0.02); + } + tick.push(minX, minY, 0.02, maxX, minY, 0.02); + tick.push(minX, minY, 0.02, minX, maxY, 0.02); + tick.push(maxX, minY, 0.02, maxX, maxY, 0.02); + tick.push(minX, maxY, 0.02, maxX, maxY, 0.02); + // Achsen-Hinweis + tick.push(minX, minY, 0.05, minX + 25, minY, 0.05); + tick.push(minX, minY, 0.05, minX, minY + 25, 0.05); + + const tickGeo = new THREE.BufferGeometry(); + tickGeo.setAttribute("position", new THREE.Float32BufferAttribute(tick, 3)); + bedGroup.add( + new THREE.LineSegments( + tickGeo, + new THREE.LineBasicMaterial({ color: 0xb0b0b0 }) + ) + ); + + for (let x = minX; x <= maxX + 0.01; x += 50) { + const mm = Math.round(x + center.x); + const sprite = makeLabelSprite(String(mm) + " mm"); + sprite.position.set(x, minY - 14, 1.5); + bedGroup.add(sprite); + } + for (let y = minY + 50; y <= maxY + 0.01; y += 50) { + const mm = Math.round(y + center.y); + const sprite = makeLabelSprite(String(mm)); + sprite.position.set(minX - 16, y, 1.5); + bedGroup.add(sprite); + } + const zero = makeLabelSprite("0"); + zero.position.set(minX - 14, minY - 14, 1.5); + bedGroup.add(zero); + const xAxis = makeLabelSprite("X →"); + xAxis.position.set(minX + 32, minY - 14, 1.5); + bedGroup.add(xAxis); + const yAxis = makeLabelSprite("Y →"); + yAxis.position.set(minX - 16, minY + 32, 1.5); + bedGroup.add(yAxis); + } + + function resetView() { + theta = defaultView.theta; + phi = defaultView.phi; + distance = defaultView.distance; + target.set(defaultView.tx, defaultView.ty, defaultView.tz); + dirty = true; + } + + function isExtrude(point) { + return !point || point.length < 4 || point[3]; + } + + function consecutiveMove(prev, next) { + // Original-seq (Index 4): nach Thinning keine Sehnen über Lücken + if (!prev || !next || prev.length < 5 || next.length < 5) return true; + return Number(next[4]) === Number(prev[4]) + 1; + } + + function fillSegments(from, to, out, fat) { + const end = Math.min(to, points.length - 1); + const start = Math.max(0, from); + const offsets = fat ? FAT_OFFSETS : [0]; + for (let i = start + 1; i <= end; i++) { + if (!isExtrude(points[i])) continue; + if (!consecutiveMove(points[i - 1], points[i])) continue; + const ax = points[i - 1][0] - center.x; + const ay = points[i - 1][1] - center.y; + const az = points[i - 1][2] - center.z; + const bx = points[i][0] - center.x; + const by = points[i][1] - center.y; + const bz = points[i][2] - center.z; + const dx = bx - ax; + const dy = by - ay; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + for (const off of offsets) { + out.push( + ax + nx * off, ay + ny * off, az, + bx + nx * off, by + ny * off, bz + ); + } + } + } + + function makeSegLine(from, to, color, opacity, fat) { + const segs = []; + fillSegments(from, to, segs, fat); + if (segs.length < 6) return null; + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(segs, 3)); + const mat = new THREE.LineBasicMaterial({ + color, + transparent: opacity < 0.99, + opacity, + depthWrite: opacity > 0.9, + }); + return new THREE.LineSegments(geo, mat); + } + + function cutIndex() { + if (!points.length) return 0; + if (progress == null) return points.length - 1; + if (progress >= 0.995 || (layers > 0 && layer != null && Number(layer) >= layers)) { + return points.length - 1; + } + // Primär Datei-Fortschritt. layer_breaks zählt oft Z-Hops mit — + // Index === current_layer schnitt viel zu früh ab. + let cut = Math.floor(points.length * progress); + if (layerBreaks.length > 1 && layers > 0 && layer != null && Number(layer) > 0) { + const ratio = Math.min(1, Number(layer) / Number(layers)); + const idx = Math.floor(ratio * (layerBreaks.length - 1)); + const byLayer = layerBreaks[Math.max(0, Math.min(layerBreaks.length - 1, idx))]; + cut = Math.max(cut, byLayer); + } + return Math.max(0, Math.min(points.length - 1, cut)); + } + + function rebuildProgress() { + clearLines(); + if (!points.length) { + legend.classList.add("preview"); + dirty = true; + return; + } + if (progress == null) { + legend.classList.add("preview"); + const line = makeSegLine(0, points.length - 1, COLOR_PREVIEW, 0.95, true); + if (line) pathGroup.add(line); + dirty = true; + return; + } + legend.classList.remove("preview"); + const cut = cutIndex(); + const todo = makeSegLine(Math.max(0, cut - 1), points.length - 1, COLOR_TODO, 0.42, false); + const done = makeSegLine(0, cut, COLOR_DONE, 1, true); + if (todo) pathGroup.add(todo); + if (done) pathGroup.add(done); + dirty = true; + } + + function polygonHitMesh(polyLocal, name) { + // unsichtbare Fläche für Raycast (Dreiecke fan) + if (!polyLocal || polyLocal.length < 3) return null; + const positions = []; + const c = { x: 0, y: 0 }; + for (const p of polyLocal) { + c.x += p[0]; + c.y += p[1]; + } + c.x /= polyLocal.length; + c.y /= polyLocal.length; + for (let i = 0; i < polyLocal.length; i++) { + const a = polyLocal[i]; + const b = polyLocal[(i + 1) % polyLocal.length]; + positions.push(c.x, c.y, 0.15, a[0], a[1], 0.15, b[0], b[1], 0.15); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + const mesh = new THREE.Mesh( + geo, + new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.01, + side: THREE.DoubleSide, + depthWrite: false, + }) + ); + mesh.userData.excludeName = name; + return mesh; + } + + function rebuildExclude() { + clearExclude(); + const objects = excludeData.objects || []; + const excluded = new Set(excludeData.excluded || []); + const current = excludeData.current || null; + for (const obj of objects) { + const name = obj.name || ""; + const poly = obj.polygon || []; + if (!name || poly.length < 2) continue; + const local = poly.map((p) => [p[0] - center.x, p[1] - center.y]); + const isEx = excluded.has(name); + const isCur = current === name; + const isSel = selectedName === name; + let color = COLOR_OBJ; + if (isEx) color = COLOR_OBJ_EX; + else if (isCur) color = COLOR_OBJ_CUR; + if (isSel && !isEx) color = 0xffffff; + + const loop = []; + for (let i = 0; i < local.length; i++) { + const a = local[i]; + const b = local[(i + 1) % local.length]; + loop.push(a[0], a[1], 0.4, b[0], b[1], 0.4); + if (isEx || isSel) { + loop.push(a[0], a[1], 0.55, b[0], b[1], 0.55); + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(loop, 3)); + const line = new THREE.LineSegments( + geo, + new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: isEx ? 0.45 : 0.95, + }) + ); + excludeGroup.add(line); + + const hit = polygonHitMesh(local, name); + if (hit) { + excludeGroup.add(hit); + excludeMeshes.push(hit); + } + + if (obj.center && obj.center.length >= 2) { + const mark = new THREE.Mesh( + new THREE.CircleGeometry(1.8, 12), + new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity: isEx ? 0.35 : 0.9, + side: THREE.DoubleSide, + depthWrite: false, + }) + ); + mark.position.set(obj.center[0] - center.x, obj.center[1] - center.y, 0.5); + mark.userData.excludeName = name; + excludeGroup.add(mark); + excludeMeshes.push(mark); + } + } + dirty = true; + } + + function setPath(data, opts) { + const keepView = !!(opts && opts.keepView); + clearLines(); + points = (data && data.points) || []; + layerBreaks = (data && data.layer_breaks && data.layer_breaks.length) + ? data.layer_breaks + : [0]; + if (!points.length) { + clearBed(); + clearExclude(); + rebuildProgress(); + return; + } + const b = boundsOf(points); + center = { + x: (b.minX + b.maxX) / 2, + y: (b.minY + b.maxY) / 2, + z: b.minZ, + }; + span = Math.max(b.maxX - b.minX, b.maxY - b.minY, b.maxZ - b.minZ, 40); + const midZ = (b.maxZ - b.minZ) * 0.35; + const fitDist = Math.max(60, span * 1.85); + defaultView = { + theta: 0.55, + phi: 1.05, + distance: fitDist, + tx: 0, + ty: 0, + tz: midZ, + }; + if (!keepView) { + distance = fitDist; + target.set(0, 0, midZ); + theta = defaultView.theta; + phi = defaultView.phi; + } + buildBed(b); + rebuildProgress(); + rebuildExclude(); + dirty = true; + } + + function setProgress(value, meta) { + if (value == null || value === "") { + progress = null; + } else { + progress = Math.max(0, Math.min(1, Number(value) || 0)); + } + if (meta && typeof meta === "object") { + layer = meta.layer != null ? Number(meta.layer) : layer; + layers = meta.layers != null ? Number(meta.layers) : layers; + } + rebuildProgress(); + } + + function setExcludeObjects(data, opts) { + excludeData = data && typeof data === "object" + ? { + objects: data.objects || [], + excluded: data.excluded || data.excluded_objects || [], + current: data.current || data.current_object || null, + } + : { objects: [], excluded: [], current: null }; + if (opts && opts.selected != null) selectedName = opts.selected; + if (opts && typeof opts.onSelect === "function") onExcludeSelect = opts.onSelect; + rebuildExclude(); + } + + function setSelectedExclude(name) { + selectedName = name || null; + rebuildExclude(); + } + + function pickExclude(clientX, clientY) { + if (!excludeMeshes.length) return null; + const rect = canvas.getBoundingClientRect(); + pointerNdc.x = ((clientX - rect.left) / rect.width) * 2 - 1; + pointerNdc.y = -((clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(pointerNdc, camera); + const hits = raycaster.intersectObjects(excludeMeshes, false); + if (!hits.length) return null; + return hits[0].object.userData.excludeName || null; + } + + function panBy(dx, dy) { + const right = new THREE.Vector3(); + const up = new THREE.Vector3(); + right.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); + up.setFromMatrixColumn(camera.matrixWorld, 1).normalize(); + const scale = distance * 0.0018; + target.addScaledVector(right, -dx * scale); + target.addScaledVector(up, dy * scale); + dirty = true; + } + + function frame() { + if (!alive) return; + resize(); + if (dirty || mode) { + orbit(); + renderer.render(scene, camera); + dirty = false; + } + raf = requestAnimationFrame(frame); + } + + function destroy() { + alive = false; + if (raf) cancelAnimationFrame(raf); + clearLines(); + clearBed(); + clearExclude(); + renderer.dispose(); + pointers.clear(); + if (legend.parentNode) legend.parentNode.removeChild(legend); + if (wrap.parentNode && canvas.parentNode === wrap) { + wrap.parentNode.insertBefore(canvas, wrap); + wrap.parentNode.removeChild(wrap); + } + } + + function pointerCount() { + return pointers.size; + } + + function pinchDistance() { + const pts = [...pointers.values()]; + if (pts.length < 2) return 0; + const dx = pts[0].x - pts[1].x; + const dy = pts[0].y - pts[1].y; + return Math.hypot(dx, dy); + } + + canvas.addEventListener("pointerdown", (event) => { + canvas.setPointerCapture(event.pointerId); + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + downX = event.clientX; + downY = event.clientY; + if (pointerCount() === 2) { + mode = "pinch"; + pinchDist = pinchDistance(); + canvas.style.cursor = "grabbing"; + return; + } + if (event.button === 1 || event.button === 2 || event.shiftKey || event.altKey) { + mode = "pan"; + } else if (event.button === 0) { + mode = "orbit"; + } else { + mode = null; + return; + } + lastX = event.clientX; + lastY = event.clientY; + canvas.style.cursor = "grabbing"; + }); + + function endPointer(event) { + const moved = Math.hypot(event.clientX - downX, event.clientY - downY); + if (mode === "orbit" && moved < 6 && event.button === 0) { + const name = pickExclude(event.clientX, event.clientY); + if (name) { + selectedName = name; + rebuildExclude(); + if (onExcludeSelect) onExcludeSelect(name); + } + } + pointers.delete(event.pointerId); + if (pointerCount() === 0) { + mode = null; + canvas.style.cursor = "grab"; + } else if (pointerCount() === 1) { + const remaining = [...pointers.values()][0]; + mode = "orbit"; + lastX = remaining.x; + lastY = remaining.y; + } + } + + canvas.addEventListener("pointerup", endPointer); + canvas.addEventListener("pointercancel", endPointer); + canvas.addEventListener("pointermove", (event) => { + if (!pointers.has(event.pointerId)) return; + pointers.set(event.pointerId, { x: event.clientX, y: event.clientY }); + + if (mode === "pinch" && pointerCount() >= 2) { + const next = pinchDistance(); + if (pinchDist > 0 && next > 0) { + distance = Math.max(25, Math.min(2500, distance * (pinchDist / next))); + dirty = true; + } + pinchDist = next; + return; + } + + if (!mode) return; + const dx = event.clientX - lastX; + const dy = event.clientY - lastY; + lastX = event.clientX; + lastY = event.clientY; + + if (mode === "pan") { + panBy(dx, dy); + return; + } + theta -= dx * 0.008; + phi = Math.max(PHI_MIN, Math.min(PHI_MAX, phi + dy * 0.008)); + dirty = true; + }); + + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + // Chrome (Pixel-Deltas / Trackpad) feuert aggressiver als z. B. Embedded-Browser. + let dy = event.deltaY; + if (event.deltaMode === 1) dy *= 16; // Zeilen + else if (event.deltaMode === 2) dy *= canvas.clientHeight || 320; // Seiten + // sanfte Exponential-Kurve, gekappt gegen Ausreißer + const factor = Math.exp(Math.max(-80, Math.min(80, dy)) * 0.0012); + distance = Math.max(25, Math.min(2500, distance * factor)); + dirty = true; + }, { passive: false }); + + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener("dblclick", (event) => { + event.preventDefault(); + resetView(); + }); + + frame(); + return { + setPath, + setProgress, + setExcludeObjects, + setSelectedExclude, + resize, + destroy, + resetView, + }; + } + + return { create }; +})(); diff --git a/web/static/grotti.css b/web/static/grotti.css new file mode 100644 index 0000000..5305713 --- /dev/null +++ b/web/static/grotti.css @@ -0,0 +1,2195 @@ +[hidden] { display: none !important; } + +:root { + color-scheme: dark; + --bg: #0a0a0c; + --ink: #f0f0f2; + --muted: #9a9aa3; + --faint: #6b6b75; + --line: rgba(255, 255, 255, 0.08); + --line-strong: rgba(255, 255, 255, 0.14); + --card: #141418; + --card-2: #0f0f12; + --accent: #f97316; + --accent-ink: #1a0a00; + --accent-soft: rgba(249, 115, 22, 0.18); + --warn: #f87171; + --pause: #fbbf24; + --track: #25252c; + --shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + --radius: 14px; + --sidebar: 13.5rem; + --live: #3dce7c; + --live-soft: rgba(61, 206, 124, 0.16); + + --s1: 0.25rem; + --s2: 0.5rem; + --s3: 0.75rem; + --s4: 1rem; + --s5: 1.5rem; + --s6: 2rem; + --t-xs: 0.7rem; + --t-sm: 0.8rem; + --t-md: 0.9rem; + --t-lg: 1.1rem; + --t-xl: 1.45rem; + --control-h: 2.15rem; + --radius-sm: 8px; + + --st-printing: #3dce7c; + --st-printing-soft: rgba(61, 206, 124, 0.14); + --st-heating: #fbbf24; + --st-heating-soft: rgba(251, 191, 36, 0.14); + --st-done: #a5b4fc; + --st-done-soft: rgba(165, 180, 252, 0.12); + --st-error: #f87171; + --st-error-soft: rgba(248, 113, 113, 0.14); + --st-offline: #6b6b75; + --st-offline-soft: rgba(255, 255, 255, 0.04); + --st-waiting: #f97316; + --st-waiting-soft: rgba(249, 115, 22, 0.14); + --st-idle: #9a9aa3; + --st-idle-soft: rgba(255, 255, 255, 0.05); + --ease: cubic-bezier(0.2, 0.7, 0.2, 1); +} +* { box-sizing: border-box; } +html { scrollbar-color: rgba(255, 255, 255, 0.14) transparent; scrollbar-width: thin; } +html::-webkit-scrollbar { width: 10px; height: 10px; } +html::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; } +html::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); background-clip: padding-box; } +body { + margin: 0; + font: 14.5px/1.45 "Segoe UI", system-ui, -apple-system, sans-serif; + color: var(--ink); + background: + radial-gradient(800px 400px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%), + var(--bg); + -webkit-font-smoothing: antialiased; +} +a { color: var(--accent); transition: color 0.15s var(--ease); } +a:hover { color: #fdba74; } +:focus-visible { + outline: 2px solid rgba(249, 115, 22, 0.75); + outline-offset: 2px; +} +button:focus:not(:focus-visible), +a:focus:not(:focus-visible), +summary:focus:not(:focus-visible) { outline: none; } + +.app-shell { + display: grid; + grid-template-columns: var(--sidebar) minmax(0, 1fr); + min-height: 100vh; + transition: grid-template-columns 0.2s var(--ease); +} +.sidebar { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + min-height: 0; + padding: 0.95rem 0.6rem 0.7rem; + background: #08080a; + border-right: 1px solid var(--line); +} +.brand-row { + display: flex; + align-items: center; + gap: 0.25rem; + padding-bottom: 0.55rem; +} +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--ink); + font-weight: 720; + letter-spacing: -0.03em; + text-decoration: none; + padding: 0.2rem 0.55rem; + flex: 1; + min-width: 0; +} +.nav-toggle { + flex: none; + width: 2.5rem; + height: 2.5rem; + min-width: 2.5rem; +} +.brand::before { + content: ""; + width: 0.65rem; + height: 0.65rem; + border-radius: 99px; + background: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); +} +.nav-main, .nav-secondary { display: flex; flex-direction: column; gap: 0.1rem; } +.nav-main a, .nav-secondary a { + color: var(--muted); + text-decoration: none; + padding: 0.38rem 0.55rem; + border-radius: 9px; + font-size: 0.88rem; + transition: color 0.15s var(--ease), background 0.15s var(--ease); +} +.nav-main a:hover, .nav-secondary a:hover { color: var(--ink); background: rgba(255, 255, 255, 0.04); } +.nav-main a.on { + color: var(--ink); + background: rgba(255, 255, 255, 0.05); + box-shadow: inset 2px 0 0 var(--accent); + border-radius: 0 9px 9px 0; + padding-left: calc(0.55rem - 2px); +} +.nav-main a { display: flex; align-items: center; gap: 0.55rem; } +.nav-main a .ico { opacity: 0.65; width: 1.05em; height: 1.05em; } +.nav-main a.on .ico { opacity: 1; color: var(--accent); } +.nav-secondary { + padding-top: 0.55rem; + border-top: 1px solid var(--line); + flex-direction: column; + gap: 0.05rem; +} +.nav-secondary a { + display: flex; + align-items: center; + gap: 0.45rem; + font-size: 0.78rem; + color: var(--faint); + padding: 0.32rem 0.5rem; +} +.nav-secondary a .ico { opacity: 0.7; width: 0.95em; height: 0.95em; } +.nav-secondary a.on { color: var(--accent); background: rgba(249, 115, 22, 0.08); } +.nav-secondary a.on .ico { opacity: 1; } +.side-foot { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.5rem 0; + font-size: 0.7rem; + color: var(--faint); +} +.side-foot a { color: var(--faint); text-decoration: none; font-variant-numeric: tabular-nums; } +.side-foot a:hover { color: var(--accent); } +.side-copy { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + +html.nav-collapsed, +body.nav-collapsed { + --sidebar: 3.35rem; +} +html.nav-collapsed .brand, +body.nav-collapsed .brand { + padding: 0.2rem; + justify-content: center; + font-size: 0; +} +html.nav-collapsed .brand::before, +body.nav-collapsed .brand::before { margin: 0; } +html.nav-collapsed .brand-row, +body.nav-collapsed .brand-row { flex-direction: column; padding-bottom: 0.35rem; } +html.nav-collapsed .nav-toggle, +body.nav-collapsed .nav-toggle { width: 2.5rem; height: 2.5rem; } +html.nav-collapsed .nav-main a, +html.nav-collapsed .nav-secondary a, +body.nav-collapsed .nav-main a, +body.nav-collapsed .nav-secondary a { + justify-content: center; + padding: 0.55rem 0.35rem; + min-height: 2.5rem; +} +html.nav-collapsed .nav-main a span, +html.nav-collapsed .nav-secondary a span, +html.nav-collapsed .live-panel, +html.nav-collapsed .side-copy, +body.nav-collapsed .nav-main a span, +body.nav-collapsed .nav-secondary a span, +body.nav-collapsed .live-panel, +body.nav-collapsed .side-copy { + display: none; +} +html.nav-collapsed .nav-main a.on, +body.nav-collapsed .nav-main a.on { + box-shadow: none; + border-radius: 9px; + padding-left: 0.35rem; + background: var(--accent-soft); +} +html.nav-collapsed .side-foot, +body.nav-collapsed .side-foot { justify-content: center; } + +html.shelf, +body.shelf { + --control-h: 2.75rem; +} +html.shelf .nav-desk, +body.shelf .nav-desk { display: none; } +html.shelf .mobile-nav .nav-desk, +body.shelf .mobile-nav .nav-desk { display: none; } +html.shelf .mobile-nav .nav-shelf, +body.shelf .mobile-nav .nav-shelf { display: flex; } +html.shelf .fleet, +body.shelf .fleet { + grid-template-columns: repeat(auto-fill, minmax(15.5rem, 1fr)); + gap: 0.65rem; +} +html.shelf main, +body.shelf main { padding: 0.85rem 0.9rem 2.5rem; max-width: none; } +html.shelf .icon-btn, +body.shelf .icon-btn { + width: 2.75rem; + height: 2.75rem; + min-width: 2.75rem; +} +html.shelf button:not(.icon-btn):not(.fchip):not(.tiny-btn), +html.shelf .button, +body.shelf button:not(.icon-btn):not(.fchip):not(.tiny-btn), +body.shelf .button { + min-height: 2.75rem; + padding: 0.55rem 1rem; +} +html.shelf .card .name, +html.shelf .printer-card .name, +body.shelf .card .name, +body.shelf .printer-card .name { font-size: 1.05rem; } +html.shelf .hero-main, +html.shelf [data-role="hero"], +body.shelf .hero-main, +body.shelf [data-role="hero"] { font-size: 1.35rem; } +.mobile-nav .nav-shelf { display: none; } + +@media (max-height: 900px) { + html.shelf main, + body.shelf main { padding-top: 0.55rem; padding-bottom: 1.5rem; } + html.shelf .page-head, + body.shelf .page-head { margin-bottom: 0.45rem; } +} + +.live-panel { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0.85rem -0.2rem 0.6rem; + padding: 0 0.2rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.12) transparent; +} +.fleet-section .machine-title .badge { display: none; } +.queue-box { + flex: none; + border: 1px solid transparent; + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); + overflow: hidden; +} +.queue-box[open] { border-color: var(--line); } +.queue-box > summary { + list-style: none; + cursor: pointer; + user-select: none; +} +.queue-box > summary::-webkit-details-marker { display: none; } +.queue-title { + margin: 0; + padding: 0.5rem 0.6rem; + color: var(--muted); + font-size: var(--t-xs); + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.06em; + display: flex; + align-items: center; + gap: 0.4rem; +} +.queue-title .grow { flex: 1; } +.queue-title .caret { transition: transform 0.2s var(--ease); opacity: 0.6; } +.queue-box:not([open]) .queue-title .caret { transform: rotate(-90deg); } +.count { + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.35rem; + border-radius: 99px; + display: inline-grid; + place-items: center; + background: rgba(255, 255, 255, 0.07); + color: var(--ink); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0; + font-variant-numeric: tabular-nums; +} +.count.hot { background: var(--accent); color: var(--accent-ink); } +.count.bad { background: var(--st-error); color: #2a0606; } +.count.todo { background: var(--st-done); color: #14163a; } +.tiny-btn { + font-size: 0.68rem !important; + padding: 0.12rem 0.4rem !important; + min-height: 0 !important; + border-radius: 6px !important; +} +.icon-btn { + display: inline-grid; + place-items: center; + width: 1.45rem; + height: 1.45rem; + padding: 0 !important; + border-radius: 7px !important; + background: transparent; + color: var(--muted); + border: 1px solid transparent; +} +.icon-btn:hover { color: var(--ink); background: rgba(255, 255, 255, 0.06); filter: none; } +.icon-btn.danger:hover { color: var(--st-error); background: var(--st-error-soft); } +.queue-list { list-style: none; margin: 0; padding: 0 0.4rem 0.45rem; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.35rem; } +.queue-list > li { min-width: 0; } +.queue-empty { + margin: 0; + padding: 0.35rem 0.6rem 0.55rem; + color: var(--faint); + font-size: var(--t-xs); + display: flex; + align-items: center; + gap: 0.35rem; +} +.queue-empty .ico { opacity: 0.55; } +.queue-item { + display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; + gap: 0.3rem; + padding: 0.5rem 0.55rem; + border-radius: 9px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid transparent; + position: relative; +} +.queue-item.running { border-color: rgba(61, 206, 124, 0.28); background: linear-gradient(180deg, rgba(61, 206, 124, 0.07), rgba(255, 255, 255, 0.02)); } +.queue-item.error { border-color: rgba(248, 113, 113, 0.3); background: var(--st-error-soft); } +.queue-row { display: flex; align-items: center; gap: 0.4rem; min-width: 0; } +.queue-row .grow { flex: 1; min-width: 0; } +.queue-link { color: inherit; text-decoration: none; } +.queue-link:hover .queue-name { color: var(--accent); } +.queue-foot { display: flex; justify-content: flex-end; padding: 0 0.6rem 0.5rem; } +.queue-foot a { font-size: var(--t-xs); text-decoration: none; } +.steer { + margin: 0.65rem 0; + padding: 0.55rem 0.6rem; + border: 1px solid var(--line); + border-radius: 10px; + display: grid; + gap: 0.45rem; +} +.steer-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.65rem; + align-items: end; +} +.steer-row label { + display: grid; + gap: 0.15rem; + font-size: 0.72rem; + color: var(--muted); +} +.steer-row input[type="number"] { + width: 4.5rem; + padding: 0.25rem 0.35rem; + font-size: 0.82rem; +} +.queue-name { color: var(--ink); font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } +.queue-msg { color: var(--muted); font-size: var(--t-xs); line-height: 1.3; overflow-wrap: anywhere; } +.queue-sub { color: var(--faint); font-size: 0.66rem; font-variant-numeric: tabular-nums; flex: none; } +.queue-item > .queue-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queue-row > button { flex: none; } +.icon-btn.done-btn { color: var(--st-printing); border-color: rgba(61, 206, 124, 0.35); border-radius: 99px !important; } +.icon-btn.done-btn:hover { color: var(--st-printing); background: rgba(61, 206, 124, 0.12); } +.icon-btn.done-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.66rem; font-weight: 700; background: rgba(61, 206, 124, 0.18); } +.queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; } +.send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; } +.send-job { display: grid; gap: 0.3rem; } +.send-job-head { + display: flex; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.82rem; + color: var(--muted); +} +.send-job-head strong { color: var(--ink); font-weight: 650; } +.send-progress-track { + height: 0.45rem; + border-radius: 99px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} +.send-progress-track.slim { height: 0.28rem; } +.send-progress-bar { + display: block; + height: 100%; + width: 0; + border-radius: inherit; + background: var(--accent); + transition: width 0.25s ease; +} +.send-progress-bar.good { background: #4ade80; } +.send-progress-bar.bad { background: #f87171; } +#send-printer[multiple] { + min-height: 6.5rem; + padding: 0.35rem; +} +.loaded-spools { display: grid; gap: 0.45rem; margin-bottom: 0.55rem; } +.loaded-host { display: grid; gap: 0.2rem; font-size: var(--t-sm); } +.loaded-spool { + display: block; + padding: 0.25rem 0.45rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: var(--ink); +} +.ace-box { + margin: 0.85rem 0 0.35rem; + padding: 0.7rem 0.75rem; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); +} +.ace-box h3 { margin: 0 0 0.35rem; font-size: 0.92rem; } +.ace-box code { font-size: 0.78rem; color: var(--accent); } +.ace-printer { margin-top: 0.65rem; display: grid; gap: 0.4rem; } +.ace-printer-name { margin: 0; font-size: 0.82rem; font-weight: 650; color: var(--ink); } +.ace-row { + display: grid; + grid-template-columns: 0.85rem minmax(0, 1fr) minmax(7rem, 0.9fr); + gap: 0.45rem; + align-items: center; + margin: 0; + font-size: 0.82rem; +} +.ace-swatch { + width: 0.85rem; + height: 0.85rem; + border-radius: 99px; + background: #444; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.app-main { min-width: 0; } +main { max-width: 1280px; margin: 0 auto; padding: 1.25rem 1.35rem 3rem; } + +h1 { font-size: 1.35rem; margin: 0; letter-spacing: -0.035em; font-weight: 680; line-height: 1.2; } +h2 { font-size: 0.95rem; margin: 0 0 0.65rem; letter-spacing: -0.02em; font-weight: 650; } +.page-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1.1rem; + flex-wrap: wrap; +} +.page-head > div:first-child { min-width: 0; flex: 1 1 12rem; } +.page-tools { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + align-items: center; + margin-bottom: 0.85rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} +.page-tools select, .page-tools input[type="search"] { + width: auto; + min-width: 9rem; + max-width: 16rem; + padding: 0.4rem 0.6rem; + border-radius: var(--radius-sm); + min-height: var(--control-h); +} +.lead, .tiny, .summary { color: var(--muted); } +.summary { margin: 0.3rem 0 0; } +.tiny { font-size: 0.8rem; margin: 0.2rem 0 0; line-height: 1.4; max-width: 52ch; } +.tip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + margin-left: 0.25rem; + border-radius: 99px; + color: var(--faint); + vertical-align: -0.15em; + cursor: help; +} +.tip:hover { color: var(--accent); background: var(--accent-soft); } +.tip .ico { width: 0.85em; height: 0.85em; } +#when { + color: var(--faint); + font-variant-numeric: tabular-nums; + font-size: 0.92rem; +} + +.fleet { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.75rem; +} +.fleet-section { margin-bottom: 1.25rem; } +.fleet-section h3 { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--faint); + margin: 0 0 0.55rem; + font-weight: 650; +} + +article.machine { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%), var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + cursor: pointer; + transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), transform 0.2s var(--ease); +} +article.machine::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 3px; + background: transparent; +} +article.machine:hover { + border-color: var(--line-strong); + box-shadow: var(--shadow), 0 0 0 1px rgba(255, 255, 255, 0.04); + transform: translateY(-1px); +} +article.machine.printing { border-color: rgba(61, 206, 124, 0.35); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.06), 0 18px 40px -24px rgba(61, 206, 124, 0.45); } +article.machine.printing::before { background: linear-gradient(90deg, transparent, var(--st-printing), transparent); opacity: 0.7; } +article.machine.printing:hover { border-color: rgba(61, 206, 124, 0.55); box-shadow: var(--shadow), 0 0 0 1px rgba(61, 206, 124, 0.12), 0 18px 40px -20px rgba(61, 206, 124, 0.55); } +article.machine.paused { border-color: rgba(251, 191, 36, 0.35); } +article.machine.paused:hover { border-color: rgba(251, 191, 36, 0.55); } +article.machine.heating { border-color: rgba(251, 191, 36, 0.28); } +article.machine.heating:hover { border-color: rgba(251, 191, 36, 0.5); } +article.machine.error { border-color: rgba(248, 113, 113, 0.4); } +article.machine.error::before { background: var(--st-error); opacity: 0.7; } +article.machine.error:hover { border-color: rgba(248, 113, 113, 0.6); } +article.machine.offline { opacity: 0.62; } +article.machine.offline:hover { opacity: 1; } + +.machine-head { padding: 0.85rem 0.95rem 0.3rem; } +.machine-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; +} +.machine-title h2 { margin: 0; font-size: 1.02rem; font-weight: 700; flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hero { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex-wrap: wrap; + min-height: 2.1rem; +} +.hero-main { + font-size: var(--t-xl); + font-weight: 720; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + line-height: 1.1; +} +.hero-main small { font-size: var(--t-sm); font-weight: 600; color: var(--muted); letter-spacing: 0; margin-right: 0.3rem; } +.hero-side { color: var(--muted); font-size: var(--t-sm); font-variant-numeric: tabular-nums; } +.hero.error .hero-main { font-size: var(--t-md); color: #fecaca; letter-spacing: 0; font-weight: 600; line-height: 1.35; } +.hero.idle .hero-main { font-size: var(--t-lg); color: var(--muted); letter-spacing: -0.02em; } +.machine-body { padding: 0.35rem 0.95rem 0.85rem; display: grid; gap: 0.65rem; } +.machine-body > :empty { display: none; } +.job-line { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; + color: var(--muted); + font-size: var(--t-sm); +} +.job-line .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; color: var(--ink); } +.job-line .swatches { padding: 0; min-height: 0; flex: none; } +.badge { + font-size: 0.65rem; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 0.15rem 0.45rem; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: var(--muted); + border: 1px solid var(--line); +} +.state { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.18rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.03); +} +.state.printing { color: #d9ffe8; background: var(--live-soft); border-color: transparent; } +.state.paused { color: #fef3c7; background: rgba(251, 191, 36, 0.14); border-color: transparent; } +.state.error, .state.offline { color: #fecaca; background: rgba(248, 113, 113, 0.14); border-color: transparent; } +.state.standby, .state.heating, .state.complete, .state.cancelled { + color: var(--faint); + background: transparent; + border-color: var(--line); +} +.host { font-size: 0.72rem; color: var(--faint); font-variant-numeric: tabular-nums; } + +.temps { + padding: 0 0.85rem 0.45rem; + font-size: 0.82rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.temps b { color: var(--ink); font-weight: 650; } + +.meter { + height: 4px; + background: var(--track); + margin: 0 0.85rem; +} +.meter span { + display: block; + height: 100%; + background: linear-gradient(90deg, #ea580c, #fdba74); + transition: width 0.4s ease; +} + +.machine-stats { + padding: 0.5rem 0.85rem 0.35rem; + font-size: 0.78rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.65rem; + font-variant-numeric: tabular-nums; +} +.machine-stats b { color: var(--ink); font-weight: 600; } +.job-name { + padding: 0 0.85rem 0.45rem; + font-size: 0.8rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.swatches { + display: flex; + flex-wrap: wrap; + gap: 0.28rem; + padding: 0 0.85rem 0.55rem; + min-height: 1.1rem; +} +.swatches .dot { + width: 0.85rem; + height: 0.85rem; + margin-left: 0; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.4); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); +} + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.card-actions:empty { display: none; } +.card-actions button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.38rem 0.75rem; + font-size: var(--t-sm); + border-radius: 9px; +} + +.stack { display: flex; flex-direction: column; gap: 0.7rem; } +article.printer { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 42%), var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} +.row { + width: 100%; + display: grid; + grid-template-columns: 12rem minmax(0, 1fr) 4.4rem 7.2rem auto; + gap: 0.8rem; + align-items: center; + padding: 0.95rem 1rem; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.pill { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 0.22rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.03); +} +.pics { display: flex; gap: 0.6rem; flex-wrap: wrap; } +.shot { + width: 100%; + max-height: 240px; + object-fit: contain; + background: var(--card-2); + border-radius: 12px; + border: 1px solid var(--line); + flex: 1; + min-width: 120px; +} +.facts { display: flex; flex-wrap: wrap; gap: 0.4rem 0.95rem; margin-top: 0.75rem; color: var(--muted); font-size: 0.85rem; } +.facts b { color: var(--ink); font-weight: 650; } +.msg, .ok { margin: 0.55rem 0 0; min-height: 1.2em; } +.msg { color: var(--warn); } +.ok { color: var(--accent); } +.mesh { margin-top: 0.9rem; } +.mesh p { margin: 0 0 0.4rem; color: var(--muted); font-size: 0.85rem; } +.gridmesh { display: grid; gap: 3px; width: min(100%, 280px); } +.desk-mesh .gridmesh { width: 100%; max-width: none; } +.cell { + aspect-ratio: 1; + display: grid; + place-items: center; + font-size: 0.68rem; + color: var(--accent-ink); + border-radius: 4px; + font-variant-numeric: tabular-nums; +} + +.camera-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.85rem; +} +.camera-tile { + margin: 0; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} +.camera-tile figcaption { + padding: 0.55rem 0.75rem; + font-size: 0.88rem; + font-weight: 650; + border-bottom: 1px solid var(--line); +} +.camera-tile img { + display: block; + width: 100%; + max-height: 220px; + object-fit: contain; + background: var(--card-2); +} +.camera-tile img.broken { min-height: 120px; opacity: 0.35; } +.cam-url { + display: block; + padding: 0.4rem 0.75rem 0.6rem; + font-size: 0.72rem; + color: var(--faint); + word-break: break-all; + text-decoration: none; +} +.cam-url:hover { color: var(--accent); } + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1rem 1.05rem 1.1rem; + box-shadow: none; +} +main > .card { margin-bottom: 0.85rem; } +.split { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); gap: 1rem; align-items: start; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.55rem 0.75rem; } +.field { display: grid; gap: 0.28rem; min-width: 0; } +.field > label, .field-label { + display: block; + color: var(--faint); + font-size: 0.72rem; + margin: 0; + font-weight: 650; + letter-spacing: 0.02em; + text-transform: uppercase; +} +.field-help { margin: 0; color: var(--faint); font-size: 0.72rem; line-height: 1.35; } +label { display: block; color: var(--muted); font-size: 0.78rem; margin: 0.7rem 0 0.28rem; letter-spacing: 0.01em; } +form .grid-2 > div > label, +form > label:not(.check):not(.mix) { + margin-top: 0.15rem; + font-size: 0.72rem; + color: var(--faint); + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.02em; +} +.check { + display: flex; + gap: 0.55rem; + align-items: center; + color: var(--ink); + font-size: 0.88rem; + margin-top: 0.7rem; +} +.page-tools .check { margin: 0; } +.check input { width: auto; accent-color: var(--accent); } +input, select, button, textarea { font: inherit; } +input, select, textarea { + width: 100%; + min-height: var(--control-h); + padding: 0.45rem 0.65rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--card-2); + color: var(--ink); + transition: border-color 0.15s var(--ease), background 0.15s var(--ease); +} +input:hover, select:hover, textarea:hover { border-color: rgba(255, 255, 255, 0.22); } +input:focus, select:focus, textarea:focus, button:focus-visible, .nav-main a:focus-visible, .nav-secondary a:focus-visible, a:focus-visible { + outline: 2px solid rgba(249, 115, 22, 0.55); + outline-offset: 2px; +} +textarea { min-height: 6rem; resize: vertical; } +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + min-height: var(--control-h); + border: 1px solid transparent; + background: var(--accent); + color: var(--accent-ink); + padding: 0.4rem 0.85rem; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: 650; + font-size: 0.88rem; + transition: background 0.15s var(--ease), border-color 0.15s var(--ease), color 0.15s var(--ease), filter 0.15s var(--ease); +} +button:hover { filter: brightness(1.06); } +button.ghost { + background: transparent; + color: var(--muted); + border-color: var(--line-strong); +} +button.ghost:hover { color: var(--ink); background: rgba(255, 255, 255, 0.04); filter: none; } +button.warn { background: transparent; color: var(--warn); border-color: rgba(248, 113, 113, 0.45); } +button.warn:hover { background: var(--st-error-soft); filter: none; } +button:disabled { opacity: 0.45; cursor: not-allowed; filter: none; } +.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.85rem; align-items: center; } +.table-wrap { overflow-x: auto; border-radius: 10px; } +.table-wrap > table { margin: 0; } +table { width: 100%; border-collapse: separate; border-spacing: 0; } +th, td { + text-align: left; + padding: 0.5rem 0.55rem; + border-bottom: 1px solid var(--line); + vertical-align: middle; + font-size: 0.86rem; +} +th { + position: sticky; + top: 0; + z-index: 1; + color: var(--faint); + font-weight: 650; + font-size: 0.68rem; + letter-spacing: 0.06em; + text-transform: uppercase; + background: var(--card); + white-space: nowrap; +} +td.num, th.num, td.right { text-align: right; font-variant-numeric: tabular-nums; } +td.nowrap, th.nowrap { white-space: nowrap; } +tbody tr { transition: background 0.12s var(--ease); } +tbody tr:hover td { background: rgba(255, 255, 255, 0.025); } +tr.pick { cursor: pointer; } +tr.pick:hover td { background: rgba(255, 255, 255, 0.04); } +tr.on td { background: var(--accent-soft); } +td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height: 1.9rem; border-radius: 7px; } +.thumb { width: 64px; height: 64px; object-fit: contain; background: var(--card-2); border-radius: 12px; border: 1px solid var(--line); } +.empty { color: var(--muted); padding: 1rem 0.3rem; } +.empty-state { + text-align: center; + padding: 2rem 1.25rem; + color: var(--muted); + display: grid; + justify-items: center; + gap: 0.45rem; +} +.empty-state .ico { + width: 1.75rem; + height: 1.75rem; + opacity: 0.45; + color: var(--faint); + margin-bottom: 0.15rem; +} +.empty-state p { margin: 0; max-width: 28ch; font-size: 0.88rem; line-height: 1.4; } +.empty-state .tiny { max-width: 36ch; } +.empty-state a { font-size: 0.84rem; } +.desk-cam-empty, .desk-empty, .picked-empty { + display: grid; + justify-items: center; + gap: 0.35rem; + text-align: center; + color: var(--faint); + font-size: 0.84rem; + padding: 1.25rem 0.75rem; +} +.desk-cam-empty .ico, .desk-empty .ico, .picked-empty .ico { + width: 1.5rem; + height: 1.5rem; + opacity: 0.45; +} +.nums { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: 0.55rem; margin-top: 0.9rem; } +.num { border: 1px solid var(--line); border-radius: 12px; padding: 0.65rem 0.7rem; background: var(--card-2); } +.num b { display: block; font-size: 1.15rem; letter-spacing: -0.03em; margin-top: 0.15rem; } +.num span { color: var(--muted); font-size: 0.72rem; } +.hint { color: var(--faint); font-size: 0.76rem; margin: 0.15rem 0 0.55rem; line-height: 1.35; max-width: 48ch; } +.color-grid { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.35rem 0 0.8rem; } +button.swatch { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--card-2); + color: var(--ink); + border: 1px solid var(--line-strong); + border-radius: 999px; + padding: 0.28rem 0.7rem 0.28rem 0.35rem; + font-weight: 550; + font-size: 0.85rem; +} +button.swatch.on { border-color: var(--accent); background: var(--accent-soft); } +.dots { display: inline-flex; align-items: center; } +.dot { + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.35); + margin-left: -0.28rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); +} +.dot:first-child { margin-left: 0; } +.picked { display: flex; align-items: center; gap: 0.55rem; min-height: 1.6rem; margin-bottom: 0.35rem; } +.mix { display: flex; align-items: center; gap: 0.45rem; color: var(--muted); font-size: 0.85rem; margin: 0.15rem 0 0.7rem; } +.mix input { width: auto; } +.doc h2 { margin-top: 1.4rem; } +.doc p, .doc li { color: var(--muted); } +.doc ul { padding-left: 1.15rem; } +.section-gap { margin-top: 1.3rem; padding-top: 0.2rem; } + +.scan-card { margin-bottom: 1rem; } +.scan-card input { max-width: 28rem; } + +/* ---------- Material ---------- */ +.scan-inline { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: min(22rem, 100%); + padding: 0.2rem 0.65rem; + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + background: var(--card-2); + color: var(--faint); +} +.scan-inline:focus-within { border-color: rgba(249, 115, 22, 0.55); box-shadow: 0 0 0 2px rgba(249, 115, 22, 0.2); } +.scan-inline .ico { opacity: 0.7; } +.scan-inline input { + border: 0; + background: transparent; + min-height: 2rem; + padding: 0.25rem 0; + border-radius: 0; +} +.scan-inline input:focus { outline: none; } +.scan-note { margin: 0; font-size: var(--t-xs); max-width: 16rem; } +.mat-tools { margin-bottom: 0.55rem; } +.mat-tools .filter-bar { margin: 0; flex: 1 1 auto; } +.mat-tools input[type="search"] { min-width: 10rem; max-width: 14rem; } +.mat-tabs { margin-bottom: 0.85rem; } +.mat-split { grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.95fr); } +.mat-split.is-idle { grid-template-columns: 1fr; } +.mat-list .lib-head { margin-bottom: 0.55rem; } +.mat-list h2 { margin: 0; } +.mat-form-head { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.65rem; +} +.mat-form-head h2 { margin: 0; flex: 1; min-width: 0; } +.mat-group td { + padding-top: 0.85rem; + padding-bottom: 0.25rem; + border-bottom: 0; + color: var(--faint); + font-size: var(--t-xs); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: transparent !important; +} +.mat-color-cap { color: var(--faint); font-size: var(--t-xs); } +.mat-form { position: sticky; top: 0.75rem; } +.mat-color { + margin: 0.75rem 0 0; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} +.mat-color > summary { + list-style: none; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0.7rem; + cursor: pointer; + user-select: none; + color: var(--muted); + font-size: var(--t-sm); + font-weight: 650; +} +.mat-color > summary::-webkit-details-marker { display: none; } +.mat-color > summary .caret { transition: transform 0.2s var(--ease); opacity: 0.6; } +.mat-color:not([open]) > summary .caret { transform: rotate(-90deg); } +.mat-color > summary .grow { flex: 1; color: var(--ink); } +.mat-color-body { padding: 0 0.7rem 0.75rem; } +.mat-color-body .color-grid { margin-bottom: 0.35rem; } +.mat-color-body .picked { min-height: 1.4rem; margin-bottom: 0.45rem; } +.viewer-card { margin-top: 1rem; } +.gcode-viewer-wrap { + position: relative; + border-radius: 12px; + border: 1px solid var(--line); + overflow: hidden; + background: #121212; +} +.gcode-canvas { + width: 100%; + height: 360px; + display: block; + border: 0; + border-radius: 0; + background: #121212; + touch-action: none; + cursor: grab; + user-select: none; +} +.gcode-canvas:active { cursor: grabbing; } +.gcode-canvas.compact { height: 220px; } +.gcode-legend { + position: absolute; + left: 0.55rem; + bottom: 0.5rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.55rem; + padding: 0.28rem 0.5rem; + border-radius: 8px; + background: rgba(12, 12, 12, 0.72); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #c8c8c8; + font-size: 0.72rem; + line-height: 1.2; + pointer-events: none; +} +.gcode-leg-swatch { + width: 0.7rem; + height: 0.22rem; + border-radius: 1px; + flex: none; +} +.gcode-leg-item { display: inline-flex; align-items: center; gap: 0.35rem; } +.gcode-leg-swatch.done { background: #5dffb0; } +.gcode-leg-swatch.done.thick { height: 0.38rem; box-shadow: 0 0 0 1px rgba(93, 255, 176, 0.35); } +.gcode-leg-swatch.todo { background: #6b7280; opacity: 0.45; } +.exclude-panel { + margin-top: 0.55rem; + padding-top: 0.45rem; + border-top: 1px solid var(--line); +} +.exclude-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem 0.75rem; + margin-bottom: 0.35rem; +} +.exclude-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 9rem; + overflow: auto; +} +.exclude-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.45rem; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; +} +.exclude-item:hover { background: rgba(255, 255, 255, 0.03); } +.exclude-item.is-selected { + border-color: rgba(255, 255, 255, 0.22); + background: rgba(255, 255, 255, 0.05); +} +.exclude-item.is-excluded { opacity: 0.55; } +.exclude-item.is-current b { color: #fbbf24; } +.exclude-item.is-excluded b { color: #ef4444; text-decoration: line-through; } +.gcode-leg-item.preview-only { display: none; } +.gcode-legend.preview .gcode-leg-item.live { display: none; } +.gcode-legend.preview .gcode-leg-item.preview-only { display: inline-flex; } +.gcode-legend.preview .gcode-leg-swatch.done { background: #f97316; } +.gcode-leg-scale { + margin-left: 0.15rem; + padding-left: 0.55rem; + border-left: 1px solid rgba(255, 255, 255, 0.12); + color: #9a9a9a; +} +.viewer-slot { margin-top: 0.75rem; } +.viewer-caption { margin: 0 0 0.35rem; color: var(--muted); } + +/* Drucker-Cockpit — nur die Seite scrollt, keine inneren Scrollbereiche */ +body.desk-on main { + max-width: none; + margin: 0; + padding: 1rem 1.25rem 2.5rem; +} +.desk { + display: grid; + gap: 1rem; + animation: desk-in 0.18s var(--ease); +} +@keyframes desk-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} +.desk-bar { + position: sticky; + top: 0; + z-index: 5; + display: grid; + gap: 0.55rem; + padding: 0.55rem 0.7rem; + margin: -0.35rem -0.35rem 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(10, 10, 12, 0.92); + backdrop-filter: blur(10px); +} +.desk-bar-top { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 0; +} +.desk-back { flex: none; } +.desk-title { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; + flex: 1 1 auto; +} +.desk-title h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 720; + letter-spacing: -0.03em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.desk-pills { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.desk-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.65rem; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + font-size: var(--t-xs); + font-weight: 600; + cursor: pointer; +} +.desk-pill:hover { color: var(--ink); border-color: var(--line); filter: none; } +.desk-pill.on { + color: var(--accent); + background: var(--accent-soft); + border-color: transparent; +} +.state-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 99px; + background: var(--st-idle); + flex: none; +} +.state-dot.printing { background: var(--st-printing); } +.state-dot.paused, .state-dot.heating { background: var(--st-heating); } +.state-dot.error { background: var(--st-error); } +.state-dot.offline { background: var(--st-offline); } +.state-dot.complete { background: var(--st-done); } +.desk-mainsail { + margin-left: auto; + font-size: var(--t-sm); + text-decoration: none; + color: var(--muted); + flex: none; +} +.desk-mainsail:hover { color: var(--accent); } +.desk-stage { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(18rem, 22rem); + gap: 1rem; + align-items: start; +} +.desk.no-cam .desk-stage, +.desk.cam-collapsed .desk-stage { + grid-template-columns: 1fr; +} +.desk.no-cam .desk-cam-fold, +.desk.cam-collapsed .desk-cam-fold { + order: -1; +} +.desk-fold { + margin: 0; + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 2px); + background: var(--card); + box-shadow: var(--shadow); + overflow: hidden; +} +.desk-fold-sum { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 0.75rem; + font-size: var(--t-sm); + font-weight: 650; + color: var(--muted); + user-select: none; +} +.desk-fold-sum::-webkit-details-marker { display: none; } +.desk-fold-sum .grow { flex: 1; min-width: 0; color: var(--ink); } +.desk-fold-sum .tiny { + margin: 0; + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 550; + text-transform: none; + letter-spacing: 0; +} +.desk-fold-sum .caret { transition: transform 0.2s var(--ease); opacity: 0.6; flex: none; } +.desk-fold:not([open]) > .desk-fold-sum .caret { transform: rotate(-90deg); } +.desk-fold-sum:hover { color: var(--ink); background: rgba(255, 255, 255, 0.03); } +.desk-fold-body { padding: 0 0.75rem 0.75rem; } +.desk-cam-fold { min-width: 0; } +.desk-cam-fold > .desk-fold-sum { border-bottom: 1px solid transparent; } +.desk-cam-fold[open] > .desk-fold-sum { border-bottom-color: var(--line); } +.desk-cam { + position: relative; + aspect-ratio: 16 / 10; + max-height: 28rem; + background: + radial-gradient(600px 280px at 50% 0%, rgba(249, 115, 22, 0.06), transparent 60%), + #0c0c10; + overflow: hidden; + display: grid; + place-items: center; +} +.desk.no-cam .desk-cam, +.desk.cam-collapsed:not(.no-cam) .desk-cam-fold:not([open]) .desk-cam { + aspect-ratio: auto; + max-height: none; + min-height: 0; +} +.desk.no-cam .desk-cam { + padding: 0.65rem 0.85rem; + background: rgba(255, 255, 255, 0.02); +} +.desk.cam-collapsed .desk-cam-fold:not([open]) .desk-cam { + display: none; +} +.desk.no-cam .desk-cam-fold:not([open]), +.desk.cam-collapsed .desk-cam-fold:not([open]) { box-shadow: none; } +.desk-cam-img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.desk-cam-empty { + margin: 0; + color: var(--faint); + font-size: var(--t-sm); + letter-spacing: 0.02em; +} +.desk-rail { + display: flex; + flex-direction: column; + border: 1px solid var(--line); + border-radius: calc(var(--radius) + 2px); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 28%), var(--card); + box-shadow: var(--shadow); +} +.desk-steer-fold { + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + box-shadow: none; +} +.desk-steer-fold > .desk-fold-sum { padding: 0.45rem 0.55rem; } +.desk-steer-fold[open] > .desk-fold-sum { border-bottom: 1px solid var(--line); } +.desk-steer-fold .steer { + margin: 0; + border: 0; + padding: 0.55rem 0.55rem 0.65rem; + background: transparent; + border-radius: 0; +} +.desk-rail-body { + padding: 0.95rem 1rem; + display: grid; + gap: 0.7rem; + align-content: start; +} +.desk-rail-body > :empty { display: none; } +.desk-job { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + padding: 0.45rem 0.5rem; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--line); +} +.desk-thumb { + width: 3.1rem; + height: 3.1rem; + object-fit: contain; + border-radius: 9px; + border: 1px solid var(--line); + background: var(--card-2); + flex: none; +} +.desk-job-text { + min-width: 0; + display: grid; + gap: 0.25rem; +} +.desk-job-text .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--t-sm); + color: var(--ink); + font-weight: 650; +} +.desk-facts { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.85rem; + color: var(--muted); + font-size: var(--t-sm); +} +.desk-facts b { color: var(--ink); font-weight: 650; } +.desk-foot { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: center; + padding: 0.7rem 1rem; + border-top: 1px solid var(--line); + background: rgba(0, 0, 0, 0.22); +} +.desk-foot .card-actions { margin: 0; flex: 1; } +.desk-foot .card-actions:empty { display: none; } +.desk-tools { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} +.desk-tool { + min-width: 0; +} +.desk-tool > .desk-fold-sum { + font-size: var(--t-xs); + letter-spacing: 0.06em; + text-transform: uppercase; +} +.desk-tool > .desk-fold-sum .grow { font-size: var(--t-xs); letter-spacing: 0.06em; } +.desk-tool[open] > .desk-fold-sum { border-bottom: 1px solid var(--line); } +.desk-canvas { + display: block; + width: 100%; + height: 280px !important; + border-radius: 10px; +} +.desk-tool-mesh .mesh { margin-top: 0; } +.desk-tool-mesh .gridmesh { width: 100%; max-width: 22rem; } +.desk-empty { margin: 0.35rem 0 0; color: var(--faint); } + +.ablauf-card { margin-top: 1rem; } +.ablauf-steps { display: grid; gap: 0.65rem; margin: 0.75rem 0; } +.ablauf-step { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + padding: 0.65rem 0.75rem; +} +.ablauf-step-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.55rem; +} +.ablauf-step-tools { display: flex; gap: 0.25rem; } +.ablauf-step-body { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); + gap: 0.45rem 0.65rem; + align-items: end; +} +.ablauf-field { + display: grid; + gap: 0.2rem; + margin: 0; + font-size: 0.78rem; + color: var(--muted); +} +.ablauf-field input, +.ablauf-field select { + width: 100%; + font-size: 0.88rem; +} + +.file-lib-tools { + margin-bottom: 0.85rem; +} +.file-lib-tools input[type="search"], +.file-lib-tools select { + max-width: 16rem; + margin: 0; +} +#spool-warn { margin: 0.45rem 0 0; } + +.camera-tile .cam-meta { + margin: 0.15rem 0 0.45rem; + padding: 0 0.15rem; + min-height: 1.1rem; +} +.camera-tile.printing { border-color: rgba(61, 206, 124, 0.45); } +.camera-tile.error { border-color: rgba(248, 113, 113, 0.45); } + +/* ---------- Designsystem ---------- */ + +.ico { width: 1em; height: 1em; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -0.125em; } +.ico.lg { width: 1.15rem; height: 1.15rem; } + +.chip { + --c: var(--st-idle); + --cs: var(--st-idle-soft); + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem 0.6rem 0.2rem 0.5rem; + border-radius: 99px; + background: var(--cs); + color: var(--c); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + flex: none; +} +.chip::before { + content: ""; + width: 0.45rem; + height: 0.45rem; + border-radius: 99px; + background: var(--c); + flex: none; +} +.chip.printing { --c: var(--st-printing); --cs: var(--st-printing-soft); } +.chip.printing::before { animation: pulse 1.8s var(--ease) infinite; } +.chip.paused, .chip.heating { --c: var(--st-heating); --cs: var(--st-heating-soft); } +.chip.heating::before { animation: pulse 1.2s var(--ease) infinite; } +.chip.complete { --c: var(--st-done); --cs: var(--st-done-soft); } +.chip.error { --c: var(--st-error); --cs: var(--st-error-soft); } +.chip.offline { --c: var(--st-offline); --cs: var(--st-offline-soft); } +.chip.waiting { --c: var(--st-waiting); --cs: transparent; box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.45); } +.chip.cancelled { --c: var(--muted); --cs: var(--st-idle-soft); } +.chip.sm { font-size: 0.6rem; padding: 0.12rem 0.45rem 0.12rem 0.38rem; gap: 0.3rem; } +.chip.sm::before { width: 0.36rem; height: 0.36rem; } +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--c) 60%, transparent); } + 70% { box-shadow: 0 0 0 6px transparent; } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +.steps { + display: flex; + align-items: center; + gap: 0.2rem; + min-width: 0; + flex-wrap: wrap; +} +.step { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.14rem 0.45rem; + border-radius: 99px; + font-size: 0.66rem; + font-weight: 650; + color: var(--faint); + border: 1px solid var(--line-strong); + white-space: nowrap; +} +.step::before { content: ""; width: 0.36rem; height: 0.36rem; border-radius: 99px; border: 1.5px solid currentColor; box-sizing: border-box; } +.step.done { color: var(--muted); border-color: transparent; background: rgba(255, 255, 255, 0.04); } +.step.done::before { background: currentColor; } +.step.now { --c: var(--accent); color: #ffd7b5; border-color: rgba(249, 115, 22, 0.55); background: var(--accent-soft); } +.step.now::before { background: var(--accent); border-color: var(--accent); animation: pulse 1.6s var(--ease) infinite; } +.step-sep { width: 0.4rem; height: 1px; background: var(--line-strong); flex: none; } +.steps.compact .step { padding: 0.1rem 0.38rem; font-size: 0.6rem; } +.steps.compact .step-sep { width: 0.2rem; } + +.bar { + position: relative; + height: 1.15rem; + border-radius: 7px; + background: var(--track); + overflow: hidden; +} +.bar > span { + position: absolute; + inset: 0 auto 0 0; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, #c2410c, #f97316 55%, #fdba74); + transition: width 0.6s var(--ease); +} +.bar.live > span { background: linear-gradient(90deg, #15803d, #3dce7c 60%, #86efac); } +.bar.slim { display: block; height: 0.3rem; border-radius: 99px; margin: 0.35rem 0 0.15rem; } +.bar.live > span::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.22), transparent); + background-size: 40% 100%; + background-repeat: no-repeat; + animation: shimmer 2.4s linear infinite; +} +.bar > b { + position: relative; + display: block; + padding: 0 0.5rem; + font-size: 0.7rem; + line-height: 1.15rem; + font-weight: 750; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55); + font-variant-numeric: tabular-nums; +} +@keyframes shimmer { from { background-position: -40% 0; } to { background-position: 140% 0; } } + +.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.4rem; } +.stats:empty { display: none; } +.stat { + padding: 0.45rem 0.55rem; + border-radius: 10px; + background: var(--card-2); + border: 1px solid var(--line); + min-width: 0; +} +.stat b { display: block; font-size: var(--t-md); font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.stat span { display: block; font-size: 0.64rem; color: var(--faint); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 650; margin-top: 0.05rem; } + +.tempbars { display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); gap: 0.55rem; } +.tempbar { display: grid; gap: 0.25rem; min-width: 0; } +.tempbar-head { + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + font-size: var(--t-xs); + color: var(--faint); +} +.tempbar-head .ico { width: 0.95rem; height: 0.95rem; opacity: 0.75; flex: none; } +.tempbar-head b { color: var(--ink); font-size: var(--t-sm); font-weight: 650; font-variant-numeric: tabular-nums; margin-left: auto; } +.tempbar-head b small { color: var(--faint); font-weight: 550; } +.fact-ico { display: inline-flex; align-items: center; gap: 0.28rem; } +.fact-ico .ico { width: 0.9rem; height: 0.9rem; opacity: 0.75; } +.steer-row label .ico { width: 0.9rem; height: 0.9rem; opacity: 0.7; vertical-align: -0.15em; } +.tempbar-track { position: relative; height: 4px; border-radius: 99px; background: var(--track); overflow: visible; } +.tempbar-track > span { position: absolute; inset: 0 auto 0 0; border-radius: inherit; background: #64748b; transition: width 0.6s var(--ease), background 0.3s; } +.tempbar-track > i { position: absolute; top: -3px; width: 2px; height: 10px; border-radius: 2px; background: rgba(255, 255, 255, 0.55); transform: translateX(-1px); } +.tempbar.hot .tempbar-track > span { background: linear-gradient(90deg, #f59e0b, #f97316); } +.tempbar.heating .tempbar-track > span { background: linear-gradient(90deg, #f59e0b, #fbbf24); animation: breathe 1.4s ease-in-out infinite; } +@keyframes breathe { 50% { opacity: 0.55; } } + +.strip { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.65rem; + border-radius: 10px; + font-size: var(--t-sm); + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.03); +} +.strip .grow { flex: 1; min-width: 0; } +.strip.warn { border-color: rgba(251, 191, 36, 0.35); background: var(--st-heating-soft); color: #fde68a; } +.strip.bad { border-color: rgba(248, 113, 113, 0.4); background: var(--st-error-soft); color: #fecaca; } +.strip.flow { display: grid; gap: 0.4rem; border-color: rgba(249, 115, 22, 0.3); background: linear-gradient(180deg, rgba(249, 115, 22, 0.08), rgba(249, 115, 22, 0.02)); color: var(--ink); } +.strip.flow .flow-head { display: flex; align-items: center; gap: 0.45rem; } +.strip.flow .flow-head .grow { font-weight: 650; } +.strip.flow .flow-msg { color: var(--muted); font-size: var(--t-xs); } +.strip button { padding: 0.28rem 0.6rem; font-size: var(--t-xs); border-radius: 7px; flex: none; } +.strip.warn button { background: var(--st-heating); color: #2a1a00; } +.strip.stopped { display: grid; gap: 0.45rem; align-items: stretch; } +.strip.stopped .strip-head { display: flex; align-items: center; gap: 0.45rem; } +.strip.stopped .strip-msg { font-size: var(--t-xs); color: #fca5a5; opacity: 0.85; } +.strip.stopped .strip-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.strip.stopped .strip-actions button { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.85rem; font-size: var(--t-sm); } +.strip.stopped .strip-actions button:not(.ghost) { background: var(--accent); color: var(--accent-ink); } +.strip.stopped .strip-actions button.ghost { color: #fecaca; border-color: rgba(248, 113, 113, 0.4); } +.waiting-note { font-size: var(--t-xs); color: var(--muted); display: flex; align-items: center; gap: 0.35rem; } +.waiting-note b { color: var(--accent); } + +.segmented { + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + padding: 0.22rem; + border-radius: 12px; + background: var(--card-2); + border: 1px solid var(--line-strong); + gap: 0.2rem; + width: 100%; +} +.segmented button { + background: transparent; + color: var(--muted); + border-radius: 9px; + padding: 0.5rem 0.6rem; + font-weight: 650; + font-size: var(--t-sm); + display: grid; + gap: 0.05rem; + text-align: center; +} +.segmented button small { font-size: 0.66rem; font-weight: 500; color: var(--faint); } +.segmented button:hover { color: var(--ink); background: rgba(255, 255, 255, 0.04); filter: none; } +.segmented button.on { background: var(--accent-soft); color: #ffd7b5; box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.5); } +.segmented button.on small { color: #fdba74; } + +.tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--line); margin: -0.2rem 0 1rem; } +.tabs button { + background: transparent; + color: var(--muted); + border-radius: 0; + padding: 0.55rem 0.8rem; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-weight: 650; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.tabs button:hover { color: var(--ink); filter: none; } +.tabs button.on { color: var(--ink); border-bottom-color: var(--accent); } + +.toast-host { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 80; + display: grid; + gap: 0.5rem; + max-width: min(24rem, calc(100vw - 2rem)); + pointer-events: none; +} +.toast { + display: flex; + gap: 0.6rem; + align-items: flex-start; + padding: 0.7rem 0.85rem; + border-radius: 12px; + background: #1c1c22; + border: 1px solid var(--line-strong); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55); + font-size: var(--t-sm); + pointer-events: auto; + animation: toast-in 0.28s var(--ease); +} +.toast.good { border-color: rgba(61, 206, 124, 0.45); } +.toast.good .ico { color: var(--st-printing); } +.toast.bad { border-color: rgba(248, 113, 113, 0.5); } +.toast.bad .ico { color: var(--st-error); } +.toast.out { animation: toast-out 0.25s var(--ease) forwards; } +@keyframes toast-in { from { opacity: 0; transform: translateY(8px) scale(0.98); } } +@keyframes toast-out { to { opacity: 0; transform: translateY(6px); } } + +.skeleton { + border-radius: var(--radius); + background: linear-gradient(90deg, var(--card) 0%, #1b1b21 50%, var(--card) 100%); + background-size: 200% 100%; + animation: skel 1.4s linear infinite; + border: 1px solid var(--line); + min-height: 12rem; +} +@keyframes skel { from { background-position: 200% 0; } to { background-position: -200% 0; } } + +.confirm-armed { + background: #dc2626 !important; + color: #fff !important; + animation: armed 0.9s ease-in-out infinite; +} +@keyframes armed { 50% { box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.25); } } + +.summary-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.4rem; margin-top: 0.5rem; } +.summary-text { color: var(--muted); font-size: var(--t-sm); margin-left: 0.35rem; display: inline-flex; align-items: center; gap: 0.3rem; flex-wrap: wrap; } +.summary-text b { color: var(--ink); } +.page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; } +.page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); } +.job-line > .ico { color: var(--faint); } +.job-line[hidden] { display: none; } +article.machine.flash { animation: flash 2s var(--ease); } +@keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } } +.machine .facts { margin-top: 0.85rem; } + +/* ---------- Senden ---------- */ + +.send-layout { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); gap: 1rem; align-items: start; } +.send-panel { position: sticky; top: 1rem; } +.lib-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; } +.lib-head h2 { margin: 0; flex: 1; } +.lib-tools { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-bottom: 0.65rem; + position: sticky; + top: 0; + z-index: 5; + background: linear-gradient(180deg, var(--card) 72%, transparent); + padding-bottom: 0.35rem; +} +.lib-tools input[type="search"] { flex: 1 1 12rem; margin: 0; } +.lib-tools select { flex: 0 1 11rem; width: auto; margin: 0; } +.lib-list { display: grid; gap: 0.35rem; max-height: 70vh; overflow-y: auto; margin: 0 -0.3rem; padding: 0 0.3rem; } +.lib-item { + display: grid; + grid-template-columns: 3.25rem minmax(0, 1fr) auto; + gap: 0.7rem; + align-items: center; + padding: 0.45rem 0.55rem; + border-radius: 12px; + border: 1px solid transparent; + background: rgba(255, 255, 255, 0.02); + cursor: pointer; + text-align: left; + color: inherit; + font-weight: 500; + width: 100%; +} +.lib-item:hover { background: rgba(255, 255, 255, 0.05); filter: none; } +.lib-item.on { border-color: rgba(249, 115, 22, 0.55); background: var(--accent-soft); } +.lib-item img, .lib-item .ph { width: 3.25rem; height: 3.25rem; border-radius: 9px; object-fit: contain; background: var(--card-2); border: 1px solid var(--line); } +.lib-item .ph { display: grid; place-items: center; color: var(--faint); } +.lib-item .t { display: block; font-size: var(--t-sm); font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); } +.lib-item .m { display: flex; flex-wrap: wrap; gap: 0.15rem 0.6rem; font-size: var(--t-xs); color: var(--muted); margin-top: 0.12rem; font-variant-numeric: tabular-nums; } +.lib-item .side { display: flex; gap: 0.25rem; align-items: center; } +.lib-info { position: relative; } +.lib-info > summary { list-style: none; } +.lib-info > summary::-webkit-details-marker { display: none; } +.lib-info-pop { + position: absolute; + right: 0; + top: calc(100% + 0.25rem); + z-index: 20; + min-width: 11rem; + padding: 0.55rem 0.7rem; + border-radius: 10px; + border: 1px solid var(--line-strong); + background: #121217; + box-shadow: var(--shadow); + font-size: var(--t-xs); + color: var(--muted); + display: grid; + gap: 0.2rem; +} +.picked-details { opacity: 0.9; } + +.flow-step { padding: 0.9rem 0 0.95rem; border-top: 1px solid var(--line); } +.flow-step:first-of-type { border-top: 0; padding-top: 0.2rem; } +.flow-step > h3 { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0 0 0.65rem; + font-size: var(--t-md); + font-weight: 700; +} +.flow-step > h3 .num-dot { + width: 1.45rem; + height: 1.45rem; + border-radius: 99px; + display: grid; + place-items: center; + background: rgba(255, 255, 255, 0.07); + font-size: 0.72rem; + color: var(--muted); +} +.flow-step.ok > h3 .num-dot { background: var(--accent); color: var(--accent-ink); } +.picked-file { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr); gap: 0.8rem; align-items: center; } +.picked-file img, .picked-file .ph { width: 4.5rem; height: 4.5rem; border-radius: 12px; object-fit: contain; background: var(--card-2); border: 1px solid var(--line); } +.picked-file .ph { display: grid; place-items: center; color: var(--faint); font-size: 1.5rem; } +.picked-file .t { font-weight: 700; overflow-wrap: anywhere; } +.picked-file .m { display: flex; flex-wrap: wrap; gap: 0.2rem 0.75rem; color: var(--muted); font-size: var(--t-sm); margin-top: 0.2rem; font-variant-numeric: tabular-nums; } +.picked-empty { color: var(--faint); font-size: var(--t-sm); padding: 0.9rem; border: 1px dashed var(--line-strong); border-radius: 12px; text-align: center; } + +.targets { display: grid; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); gap: 0.4rem; } +.target { + display: grid; + gap: 0.25rem; + text-align: left; + padding: 0.55rem 0.65rem; + border-radius: 12px; + background: var(--card-2); + border: 1px solid var(--line-strong); + color: var(--ink); + font-weight: 600; +} +.target:hover { border-color: rgba(255, 255, 255, 0.28); filter: none; } +.target.on { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.4); } +.target.off { opacity: 0.45; } +.target .row1 { display: flex; align-items: center; gap: 0.35rem; min-width: 0; } +.target .row1 .n { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--t-sm); } +.target .row2 { font-size: var(--t-xs); color: var(--muted); font-weight: 500; font-variant-numeric: tabular-nums; } +.target .row2.bad { color: #fca5a5; } + +details.options { margin-top: 0.75rem; border: 1px solid var(--line); border-radius: 12px; background: rgba(255, 255, 255, 0.015); } +details.options > summary { cursor: pointer; list-style: none; padding: 0.6rem 0.75rem; font-size: var(--t-sm); color: var(--muted); font-weight: 650; display: flex; align-items: center; gap: 0.45rem; } +details.options > summary::-webkit-details-marker { display: none; } +details.options > summary .grow { flex: 1; } +details.options > summary .caret { transition: transform 0.2s var(--ease); } +details.options:not([open]) > summary .caret { transform: rotate(-90deg); } +details.options > .opt-body { padding: 0 0.75rem 0.75rem; } +details.options .check { font-size: var(--t-sm); margin-top: 0.6rem; } +.go-row { display: grid; gap: 0.5rem; margin-top: 0.9rem; } +.go-btn { + width: 100%; + padding: 0.8rem 1rem; + border-radius: 12px; + font-size: var(--t-md); + font-weight: 750; + display: inline-flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + box-shadow: 0 10px 28px -12px rgba(249, 115, 22, 0.7); +} +.go-btn:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } +.go-hint { font-size: var(--t-xs); color: var(--faint); text-align: center; min-height: 1em; margin: 0; } +.go-hint.bad { color: #fca5a5; } +.go-btn.confirm-armed { background: var(--st-error); color: #fff; box-shadow: 0 10px 28px -12px rgba(239, 68, 68, 0.8); } +.chip.pip { padding: 0; background: none; gap: 0; box-shadow: none; } +.chip.pip::before { width: 0.55rem; height: 0.55rem; } +.targets.dimmed .target { opacity: 0.55; } +.target .count { font-size: 0.62rem; min-width: 1.1rem; } +.tabs button { display: inline-flex; align-items: center; gap: 0.4rem; } +.tabs button small { font-size: 0.62rem; color: var(--faint); font-weight: 600; } +label.button { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.48rem 0.9rem; + border-radius: 8px; + cursor: pointer; + font-weight: 650; + margin: 0; + border: 1px solid var(--line-strong); + color: var(--accent); + font-size: inherit; +} +label.button:hover { background: rgba(255, 255, 255, 0.04); } +.page-head-side button { display: inline-flex; align-items: center; gap: 0.4rem; } +.lib-card { position: relative; transition: box-shadow 0.2s var(--ease); } +.lib-card.drop { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } +.drop-hint { margin: 0.6rem 0 0; font-size: var(--t-xs); color: var(--faint); text-align: center; } +.lib-card.drop .drop-hint { color: var(--accent); } +.mini-swatches { display: inline-flex; gap: 2px; align-items: center; } +.mini-swatches i { width: 0.6rem; height: 0.6rem; border-radius: 99px; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25); } +.ablauf-preview { margin: 0.4rem 0 0.2rem; } +.sweep-preview-wrap { + margin: 0.35rem 0 0.7rem; + padding: 0.55rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--card-2); +} +.sweep-canvas { + display: block; + width: 100%; + max-width: 22rem; + height: 12rem; + margin: 0 auto; + border-radius: 8px; + background: #0c0c10; +} +.sweep-preview-wrap .tiny { text-align: center; margin: 0.35rem 0 0; } +.send-panel .ablauf-steps { gap: 0.4rem; margin: 0.6rem 0; } +.send-panel .ablauf-step { padding: 0; } +.send-panel .ablauf-step > summary { list-style: none; cursor: pointer; padding: 0.5rem 0.6rem; margin: 0; justify-content: flex-start; } +.send-panel .ablauf-step > summary::-webkit-details-marker { display: none; } +.send-panel .ablauf-step .num-dot { width: 1.3rem; height: 1.3rem; border-radius: 99px; display: grid; place-items: center; background: rgba(255, 255, 255, 0.07); font-size: 0.68rem; color: var(--muted); flex: none; } +.send-panel .ablauf-step strong { font-size: var(--t-sm); } +.ablauf-step-sum { flex: 1; min-width: 0; font-size: var(--t-xs); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.send-panel .ablauf-step-body { padding: 0 0.6rem 0.65rem; } +.ablauf-field.wide { grid-column: 1 / -1; } +.ablauf-add { margin-top: 0.2rem; } +.repeat-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-top: 0.8rem; } +.repeat-row label { margin: 0; } +.stepper { display: inline-flex; align-items: stretch; border: 1px solid var(--line-strong); border-radius: 10px; overflow: hidden; } +.stepper button { border: 0; border-radius: 0; padding: 0.35rem 0.75rem; font-size: 1rem; } +.stepper input { width: 3.5rem; margin: 0; border: 0; border-radius: 0; text-align: center; background: transparent; -moz-appearance: textfield; } +.stepper input::-webkit-inner-spin-button { -webkit-appearance: none; } +.test-row { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem; align-items: center; justify-content: center; margin-top: 0.3rem; } +.test-row .check { margin: 0; font-size: var(--t-sm); } +.test-row button { display: inline-flex; align-items: center; gap: 0.35rem; } + +/* ---------- Aktivität ---------- */ + +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: center; + margin-bottom: 1rem; + position: sticky; + top: 0; + z-index: 5; + background: linear-gradient(180deg, var(--bg) 70%, transparent); + padding-top: 0.15rem; + padding-bottom: 0.45rem; +} +.filter-group { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; } +.filter-group + .filter-group { padding-left: 0.6rem; border-left: 1px solid var(--line); } +.fchip { + background: transparent; + color: var(--muted); + border: 1px solid var(--line-strong); + border-radius: 99px; + padding: 0.28rem 0.7rem; + font-size: var(--t-xs); + font-weight: 650; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.fchip:hover { color: var(--ink); filter: none; } +.fchip.on { background: var(--accent-soft); color: #ffd7b5; border-color: rgba(249, 115, 22, 0.55); } +.fchip i { width: 0.45rem; height: 0.45rem; border-radius: 99px; background: currentColor; } +.timeline { display: grid; gap: 1.25rem; } +.tl-day h3 { position: sticky; top: 0; z-index: 2; margin: 0 0 0.5rem; padding: 0.4rem 0; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); background: linear-gradient(var(--bg) 70%, transparent); } +.tl-list { list-style: none; margin: 0; padding: 0; position: relative; } +.tl-list::before { content: ""; position: absolute; left: 4.35rem; top: 0.4rem; bottom: 0.4rem; width: 1px; background: var(--line-strong); } +.tl-item { display: grid; grid-template-columns: 3.6rem 1.5rem minmax(0, 1fr); gap: 0 0.25rem; align-items: start; padding: 0.4rem 0; } +.tl-time { font-size: var(--t-xs); color: var(--faint); font-variant-numeric: tabular-nums; text-align: right; padding-top: 0.15rem; } +.tl-dot { --c: var(--st-idle); width: 0.65rem; height: 0.65rem; border-radius: 99px; background: var(--c); margin: 0.3rem auto 0; box-shadow: 0 0 0 3px var(--bg); position: relative; z-index: 1; } +.tl-dot.fertig { --c: var(--st-done); } +.tl-dot.fehler { --c: var(--st-error); } +.tl-dot.offline { --c: var(--st-offline); } +.tl-dot.queue { --c: var(--st-waiting); } +.tl-dot.ablauf { --c: var(--st-printing); } +.tl-dot.spule { --c: var(--st-heating); } +.tl-dot.start { --c: #38bdf8; } +.tl-dot.abbruch { --c: #f43f5e; } +.tl-head a { color: inherit; text-decoration: none; } +.tl-head a:hover b { text-decoration: underline; } +.tl-file .ico-sm { width: 0.85rem; height: 0.85rem; vertical-align: -0.12rem; } +.fchip .tl-dot { width: 0.5rem; height: 0.5rem; margin: 0; box-shadow: none; } +.tl-body { padding: 0.45rem 0.65rem; border-radius: 10px; background: var(--card); border: 1px solid var(--line); min-width: 0; } +.tl-item.unread .tl-body { border-color: rgba(249, 115, 22, 0.35); } +.tl-head { display: flex; flex-wrap: wrap; gap: 0.2rem 0.5rem; align-items: center; font-size: var(--t-sm); } +.tl-head b { font-weight: 700; } +.tl-kind { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--faint); font-weight: 700; } +.tl-msg { color: var(--muted); font-size: var(--t-sm); margin-top: 0.15rem; overflow-wrap: anywhere; } +.tl-file { color: var(--faint); font-size: var(--t-xs); margin-top: 0.15rem; overflow-wrap: anywhere; } + +/* ---------- Doku ---------- */ + +.doc-layout { display: grid; grid-template-columns: 13rem minmax(0, 1fr); gap: 1.5rem; align-items: start; } +.doc-toc { position: sticky; top: 1rem; display: grid; gap: 0.1rem; font-size: var(--t-sm); } +.doc-toc p { margin: 0.9rem 0 0.3rem; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); font-weight: 650; } +.doc-toc p:first-child { margin-top: 0; } +.doc-toc a { color: var(--muted); text-decoration: none; padding: 0.28rem 0.55rem; border-radius: 8px; border-left: 2px solid transparent; } +.doc-toc a:hover { color: var(--ink); background: rgba(255, 255, 255, 0.04); } +.doc-toc a.on { color: var(--accent); border-left-color: var(--accent); background: var(--accent-soft); border-radius: 0 8px 8px 0; } +.prose { max-width: 70ch; line-height: 1.65; } +.prose h1 { margin-bottom: 0.4rem; } +.prose h2 { margin: 2rem 0 0.6rem; font-size: var(--t-lg); scroll-margin-top: 1rem; } +.prose h3 { margin: 1.4rem 0 0.4rem; font-size: var(--t-md); } +.prose p, .prose li { color: #c9c9d1; } +.prose ul, .prose ol { padding-left: 1.2rem; } +.prose li + li { margin-top: 0.25rem; } +.prose code { font-size: 0.85em; padding: 0.08rem 0.35rem; border-radius: 5px; background: rgba(255, 255, 255, 0.07); color: #fdba74; } +.prose pre { background: var(--card-2); border: 1px solid var(--line); border-radius: 10px; padding: 0.8rem 0.95rem; overflow-x: auto; } +.prose pre code { background: none; padding: 0; color: var(--ink); } +.prose table { margin: 0.8rem 0; font-size: var(--t-sm); } +.prose blockquote { margin: 0.8rem 0; padding: 0.4rem 0.9rem; border-left: 3px solid var(--accent); background: var(--accent-soft); border-radius: 0 10px 10px 0; } +.prose .lede { font-size: 1.05rem; color: var(--ink); } +.flow-diagram { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; margin: 0.8rem 0 1rem; } +.flow-diagram .step { font-size: var(--t-xs); padding: 0.3rem 0.65rem; } +.terms { display: grid; grid-template-columns: max-content 1fr; gap: 0.45rem 1rem; margin: 0.6rem 0; } +.terms dt { font-weight: 700; color: var(--ink); } +.terms dd { margin: 0; color: #c9c9d1; } + +.mobile-nav { display: none; } + +@media (max-width: 1080px) { + .send-layout { grid-template-columns: 1fr; } + .send-panel { position: static; } + .doc-layout { grid-template-columns: 1fr; } + .doc-toc { position: static; display: flex; flex-wrap: wrap; gap: 0.3rem; padding-bottom: 0.9rem; border-bottom: 1px solid var(--line); } + .doc-toc p { display: none; } + .doc-toc a { border: 1px solid var(--line-strong); border-radius: 99px; padding: 0.22rem 0.65rem; font-size: var(--t-xs); } + .doc-toc a.on { border-radius: 99px; border-color: rgba(249, 115, 22, 0.55); } +} + +@media (max-width: 960px) { + .desk-stage { grid-template-columns: 1fr; } + .desk:not(.no-cam) .desk-cam { aspect-ratio: 16 / 10; max-height: 22rem; } + .desk-tools { grid-template-columns: 1fr; } + .desk-canvas { height: 240px !important; } +} + +@media (max-width: 760px) { + .app-shell { grid-template-columns: 1fr; padding-bottom: 4.25rem; } + html.nav-collapsed, + body.nav-collapsed { --sidebar: 13.5rem; } + .nav-toggle { display: none; } + .sidebar { + position: relative; + height: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + } + .brand-row { padding-bottom: 0; } + .brand { padding: 0 0.35rem; } + .nav-main { display: none; } + html.shelf .mobile-nav, + body.shelf .mobile-nav { + grid-template-columns: repeat(3, 1fr); + } + .nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; } + .side-foot { display: none; } + .live-panel { order: 5; width: 100%; flex: none; overflow: visible; margin: 0; padding: 0; } + .live-panel:empty { display: none; } + .queue-box { width: 100%; } + .queue-box.is-empty { display: none; } + .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } + .split, .grid-2 { grid-template-columns: 1fr; } + .fleet { grid-template-columns: 1fr; } + main { padding: 1rem 0.85rem 2rem; } + .hero-main { font-size: 1.4rem; } + .tl-list::before { left: 3.85rem; } + .tl-item { grid-template-columns: 3.1rem 1.5rem minmax(0, 1fr); } + .targets { grid-template-columns: 1fr 1fr; } + body.desk-on main { padding: 0.85rem 0.85rem 5rem; } + .desk-bar { margin: 0; } + .desk-title h2 { font-size: 1.1rem; } + .desk-mainsail { margin-left: 0; } + .desk:not(.no-cam) .desk-cam { max-height: 18rem; } + .mobile-nav { + display: grid; + grid-template-columns: repeat(3, 1fr); + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 40; + background: #08080aee; + backdrop-filter: blur(10px); + border-top: 1px solid var(--line); + padding: 0.3rem 0.5rem calc(0.3rem + env(safe-area-inset-bottom, 0px)); + } + .mobile-nav a { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + text-decoration: none; + color: var(--muted); + font-size: 0.68rem; + font-weight: 650; + padding: 0.4rem 0.25rem; + border-radius: 10px; + } + .mobile-nav a .ico { width: 1.2rem; height: 1.2rem; opacity: 0.75; } + .mobile-nav a.on { + color: var(--accent); + background: var(--accent-soft); + } + .mobile-nav a.on .ico { opacity: 1; } + .nav-secondary { flex-direction: row; flex-wrap: wrap; } + .nav-secondary a span { display: none; } + .nav-secondary a { padding: 0.4rem; } + .nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; } +} diff --git a/web/static/sweep-viewer.js b/web/static/sweep-viewer.js new file mode 100644 index 0000000..f708f45 --- /dev/null +++ b/web/static/sweep-viewer.js @@ -0,0 +1,121 @@ +/* Draufsicht: Schiebe-Bahn auf dem Kobra-3-Bett. */ +(function (global) { + const BED_X = 250; + const BED_Y = 235; + + function SweepViewer(canvas) { + this.canvas = canvas; + this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + this.scene = new THREE.Scene(); + this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100); + this.camera.position.set(0, 0, 10); + this.camera.lookAt(0, 0, 0); + this.root = new THREE.Group(); + this.scene.add(this.root); + this._bed = null; + this._paths = null; + this._front = null; + this._resize(); + } + + SweepViewer.prototype._resize = function () { + const width = this.canvas.clientWidth || 280; + const height = this.canvas.clientHeight || 220; + this.renderer.setSize(width, height, false); + const aspect = width / height; + const worldW = BED_X * 1.12; + const worldH = BED_Y * 1.12; + let halfW = worldW / 2; + let halfH = worldH / 2; + if (halfW / halfH < aspect) halfW = halfH * aspect; + else halfH = halfW / aspect; + this.camera.left = -halfW; + this.camera.right = halfW; + this.camera.top = halfH; + this.camera.bottom = -halfH; + this.camera.updateProjectionMatrix(); + this.renderer.render(this.scene, this.camera); + }; + + SweepViewer.prototype._clearGroup = function (name) { + const old = this[name]; + if (!old) return; + this.root.remove(old); + old.traverse(function (obj) { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose()); + else obj.material.dispose(); + } + }); + this[name] = null; + }; + + SweepViewer.prototype.setSweep = function (step) { + const params = step || {}; + const xFrom = Number(params.x_from); + const xTo = Number(params.x_to); + const xStep = Math.abs(Number(params.x_step) || 20); + const yFrom = Number(params.y_from); + const yTo = Number(params.y_to); + this._clearGroup("_bed"); + this._clearGroup("_paths"); + this._clearGroup("_front"); + + const bed = new THREE.Group(); + const plate = new THREE.Mesh( + new THREE.PlaneGeometry(BED_X, BED_Y), + new THREE.MeshBasicMaterial({ color: 0x1a1a22, transparent: true, opacity: 0.95 }) + ); + bed.add(plate); + const edge = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.PlaneGeometry(BED_X, BED_Y)), + new THREE.LineBasicMaterial({ color: 0x555566 }) + ); + bed.add(edge); + bed.position.set(BED_X / 2, BED_Y / 2, 0); + this.root.add(bed); + this._bed = bed; + + const front = new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(0, 0, 0.02), + new THREE.Vector3(BED_X, 0, 0.02), + ]), + new THREE.LineBasicMaterial({ color: 0xf97316 }) + ); + this.root.add(front); + this._front = front; + + const paths = new THREE.Group(); + if (Number.isFinite(xFrom) && Number.isFinite(xTo) && Number.isFinite(yFrom) && Number.isFinite(yTo) && xStep > 0) { + const dir = xTo >= xFrom ? 1 : -1; + const points = []; + for (let x = xFrom, i = 0; dir > 0 ? x <= xTo + 0.01 : x >= xTo - 0.01; x += dir * xStep, i += 1) { + const yA = i % 2 === 0 ? yFrom : yTo; + const yB = i % 2 === 0 ? yTo : yFrom; + points.push(new THREE.Vector3(x, yA, 0.05), new THREE.Vector3(x, yB, 0.05)); + if (dir > 0 ? x + dir * xStep <= xTo + 0.01 : x + dir * xStep >= xTo - 0.01) { + points.push(new THREE.Vector3(x, yB, 0.05), new THREE.Vector3(x + dir * xStep, yB, 0.05)); + } + } + if (points.length >= 2) { + const geo = new THREE.BufferGeometry().setFromPoints(points); + paths.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: 0x3dce7c }))); + } + } + this.root.add(paths); + this._paths = paths; + this._resize(); + }; + + SweepViewer.prototype.dispose = function () { + this._clearGroup("_bed"); + this._clearGroup("_paths"); + this._clearGroup("_front"); + this.renderer.dispose(); + }; + + global.SweepViewer = SweepViewer; +})(window); diff --git a/web/static/three.min.js b/web/static/three.min.js new file mode 100644 index 0000000..0c2f4ba --- /dev/null +++ b/web/static/three.min.js @@ -0,0 +1,7 @@ +console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'), +/** + * @license + * Copyright 2010-2023 Three.js Authors + * SPDX-License-Identifier: MIT + */ +function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){void 0!==t&&(ne=t);let e=ne+=1831565813;return e=Math.imul(e^e>>>15,1|e),e^=e+Math.imul(e^e>>>7,61|e),((e^e>>>14)>>>0)/4294967296},degToRad:function(t){return t*ie},radToDeg:function(t){return t*re},isPowerOfTwo:ce,ceilPowerOfTwo:function(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))},floorPowerOfTwo:he,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}},normalize:de,denormalize:ue};class me{constructor(t=0,e=0){me.prototype.isVector2=!0,this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t){return this.x+=t.x,this.y+=t.y,this}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t){return this.x-=t.x,this.y-=t.y,this}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=this.x,n=this.y,i=t.elements;return this.x=i[0]*e+i[3]*n+i[6],this.y=i[1]*e+i[4]*n+i[7],this}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(t){return this.x*t.x+this.y*t.y}cross(t){return this.x*t.y-this.y*t.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}angleTo(t){const e=Math.sqrt(this.lengthSq()*t.lengthSq());if(0===e)return Math.PI/2;const n=this.dot(t)/e;return Math.acos(ae(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y;return e*e+n*n}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this}equals(t){return t.x===this.x&&t.y===this.y}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t}fromBufferAttribute(t,e){return this.x=t.getX(e),this.y=t.getY(e),this}rotateAround(t,e){const n=Math.cos(e),i=Math.sin(e),r=this.x-t.x,s=this.y-t.y;return this.x=r*n-s*i+t.x,this.y=r*i+s*n+t.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}*[Symbol.iterator](){yield this.x,yield this.y}}class fe{constructor(t,e,n,i,r,s,a,o,l){fe.prototype.isMatrix3=!0,this.elements=[1,0,0,0,1,0,0,0,1],void 0!==t&&this.set(t,e,n,i,r,s,a,o,l)}set(t,e,n,i,r,s,a,o,l){const c=this.elements;return c[0]=t,c[1]=i,c[2]=a,c[3]=e,c[4]=r,c[5]=o,c[6]=n,c[7]=s,c[8]=l,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],this}extractBasis(t,e,n){return t.setFromMatrix3Column(this,0),e.setFromMatrix3Column(this,1),n.setFromMatrix3Column(this,2),this}setFromMatrix4(t){const e=t.elements;return this.set(e[0],e[4],e[8],e[1],e[5],e[9],e[2],e[6],e[10]),this}multiply(t){return this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[3],o=n[6],l=n[1],c=n[4],h=n[7],u=n[2],d=n[5],p=n[8],m=i[0],f=i[3],g=i[6],_=i[1],v=i[4],x=i[7],y=i[2],M=i[5],S=i[8];return r[0]=s*m+a*_+o*y,r[3]=s*f+a*v+o*M,r[6]=s*g+a*x+o*S,r[1]=l*m+c*_+h*y,r[4]=l*f+c*v+h*M,r[7]=l*g+c*x+h*S,r[2]=u*m+d*_+p*y,r[5]=u*f+d*v+p*M,r[8]=u*g+d*x+p*S,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[3]*=t,e[6]*=t,e[1]*=t,e[4]*=t,e[7]*=t,e[2]*=t,e[5]*=t,e[8]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8];return e*s*c-e*a*l-n*r*c+n*a*o+i*r*l-i*s*o}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=c*s-a*l,u=a*o-c*r,d=l*r-s*o,p=e*h+n*u+i*d;if(0===p)return this.set(0,0,0,0,0,0,0,0,0);const m=1/p;return t[0]=h*m,t[1]=(i*l-c*n)*m,t[2]=(a*n-i*s)*m,t[3]=u*m,t[4]=(c*e-i*o)*m,t[5]=(i*r-a*e)*m,t[6]=d*m,t[7]=(n*o-l*e)*m,t[8]=(s*e-n*r)*m,this}transpose(){let t;const e=this.elements;return t=e[1],e[1]=e[3],e[3]=t,t=e[2],e[2]=e[6],e[6]=t,t=e[5],e[5]=e[7],e[7]=t,this}getNormalMatrix(t){return this.setFromMatrix4(t).invert().transpose()}transposeIntoArray(t){const e=this.elements;return t[0]=e[0],t[1]=e[3],t[2]=e[6],t[3]=e[1],t[4]=e[4],t[5]=e[7],t[6]=e[2],t[7]=e[5],t[8]=e[8],this}setUvTransform(t,e,n,i,r,s,a){const o=Math.cos(r),l=Math.sin(r);return this.set(n*o,n*l,-n*(o*s+l*a)+s+t,-i*l,i*o,-i*(-l*s+o*a)+a+e,0,0,1),this}scale(t,e){return this.premultiply(ge.makeScale(t,e)),this}rotate(t){return this.premultiply(ge.makeRotation(-t)),this}translate(t,e){return this.premultiply(ge.makeTranslation(t,e)),this}makeTranslation(t,e){return t.isVector2?this.set(1,0,t.x,0,1,t.y,0,0,1):this.set(1,0,t,0,1,e,0,0,1),this}makeRotation(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,-n,0,n,e,0,0,0,1),this}makeScale(t,e){return this.set(t,0,0,0,e,0,0,0,1),this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<9;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<9;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t}clone(){return(new this.constructor).fromArray(this.elements)}}const ge=new fe;function _e(t){for(let e=t.length-1;e>=0;--e)if(t[e]>=65535)return!0;return!1}const ve={Int8Array:Int8Array,Uint8Array:Uint8Array,Uint8ClampedArray:Uint8ClampedArray,Int16Array:Int16Array,Uint16Array:Uint16Array,Int32Array:Int32Array,Uint32Array:Uint32Array,Float32Array:Float32Array,Float64Array:Float64Array};function xe(t,e){return new ve[t](e)}function ye(t){return document.createElementNS("http://www.w3.org/1999/xhtml",t)}function Me(){const t=ye("canvas");return t.style.display="block",t}const Se={};function be(t){t in Se||(Se[t]=!0,console.warn(t))}const Te=(new fe).set(.8224621,.177538,0,.0331941,.9668058,0,.0170827,.0723974,.9105199),Ee=(new fe).set(1.2249401,-.2249404,0,-.0420569,1.0420571,0,-.0196376,-.0786361,1.0982735),we={[Ht]:{transfer:Wt,primaries:jt,toReference:t=>t,fromReference:t=>t},[Gt]:{transfer:Xt,primaries:jt,toReference:t=>t.convertSRGBToLinear(),fromReference:t=>t.convertLinearToSRGB()},[kt]:{transfer:Wt,primaries:qt,toReference:t=>t.applyMatrix3(Ee),fromReference:t=>t.applyMatrix3(Te)},[Vt]:{transfer:Xt,primaries:qt,toReference:t=>t.convertSRGBToLinear().applyMatrix3(Ee),fromReference:t=>t.applyMatrix3(Te).convertLinearToSRGB()}},Ae=new Set([Ht,kt]),Re={enabled:!0,_workingColorSpace:Ht,get workingColorSpace(){return this._workingColorSpace},set workingColorSpace(t){if(!Ae.has(t))throw new Error(`Unsupported working color space, "${t}".`);this._workingColorSpace=t},convert:function(t,e,n){if(!1===this.enabled||e===n||!e||!n)return t;const i=we[e].toReference;return(0,we[n].fromReference)(i(t))},fromWorkingColorSpace:function(t,e){return this.convert(t,this._workingColorSpace,e)},toWorkingColorSpace:function(t,e){return this.convert(t,e,this._workingColorSpace)},getPrimaries:function(t){return we[t].primaries},getTransfer:function(t){return t===zt?Wt:we[t].transfer}};function Ce(t){return t<.04045?.0773993808*t:Math.pow(.9478672986*t+.0521327014,2.4)}function Le(t){return t<.0031308?12.92*t:1.055*Math.pow(t,.41666)-.055}let Pe;class Ie{static getDataURL(t){if(/^data:/i.test(t.src))return t.src;if("undefined"==typeof HTMLCanvasElement)return t.src;let e;if(t instanceof HTMLCanvasElement)e=t;else{void 0===Pe&&(Pe=ye("canvas")),Pe.width=t.width,Pe.height=t.height;const n=Pe.getContext("2d");t instanceof ImageData?n.putImageData(t,0,0):n.drawImage(t,0,0,t.width,t.height),e=Pe}return e.width>2048||e.height>2048?(console.warn("THREE.ImageUtils.getDataURL: Image converted to jpg for performance reasons",t),e.toDataURL("image/jpeg",.6)):e.toDataURL("image/png")}static sRGBToLinear(t){if("undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap){const e=ye("canvas");e.width=t.width,e.height=t.height;const n=e.getContext("2d");n.drawImage(t,0,0,t.width,t.height);const i=n.getImageData(0,0,t.width,t.height),r=i.data;for(let t=0;t0&&(n.userData=this.userData),e||(t.textures[this.uuid]=n),n}dispose(){this.dispatchEvent({type:"dispose"})}transformUv(t){if(this.mapping!==S)return t;if(t.applyMatrix3(this.matrix),t.x<0||t.x>1)switch(this.wrapS){case R:t.x=t.x-Math.floor(t.x);break;case C:t.x=t.x<0?0:1;break;case L:1===Math.abs(Math.floor(t.x)%2)?t.x=Math.ceil(t.x)-t.x:t.x=t.x-Math.floor(t.x)}if(t.y<0||t.y>1)switch(this.wrapT){case R:t.y=t.y-Math.floor(t.y);break;case C:t.y=t.y<0?0:1;break;case L:1===Math.abs(Math.floor(t.y)%2)?t.y=Math.ceil(t.y)-t.y:t.y=t.y-Math.floor(t.y)}return this.flipY&&(t.y=1-t.y),t}set needsUpdate(t){!0===t&&(this.version++,this.source.needsUpdate=!0)}get encoding(){return be("THREE.Texture: Property .encoding has been replaced by .colorSpace."),this.colorSpace===Gt?Bt:Ft}set encoding(t){be("THREE.Texture: Property .encoding has been replaced by .colorSpace."),this.colorSpace=t===Bt?Gt:zt}}Fe.DEFAULT_IMAGE=null,Fe.DEFAULT_MAPPING=S,Fe.DEFAULT_ANISOTROPY=1;class Be{constructor(t=0,e=0,n=0,i=1){Be.prototype.isVector4=!0,this.x=t,this.y=e,this.z=n,this.w=i}get width(){return this.z}set width(t){this.z=t}get height(){return this.w}set height(t){this.w=t}set(t,e,n,i){return this.x=t,this.y=e,this.z=n,this.w=i,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this.w=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setW(t){return this.w=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;case 3:this.w=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this.w=void 0!==t.w?t.w:1,this}add(t){return this.x+=t.x,this.y+=t.y,this.z+=t.z,this.w+=t.w,this}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this.w+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this.w=t.w+e.w,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this.w+=t.w*e,this}sub(t){return this.x-=t.x,this.y-=t.y,this.z-=t.z,this.w-=t.w,this}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this.w-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this.w=t.w-e.w,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this.w*=t.w,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this.w*=t,this}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=this.w,s=t.elements;return this.x=s[0]*e+s[4]*n+s[8]*i+s[12]*r,this.y=s[1]*e+s[5]*n+s[9]*i+s[13]*r,this.z=s[2]*e+s[6]*n+s[10]*i+s[14]*r,this.w=s[3]*e+s[7]*n+s[11]*i+s[15]*r,this}divideScalar(t){return this.multiplyScalar(1/t)}setAxisAngleFromQuaternion(t){this.w=2*Math.acos(t.w);const e=Math.sqrt(1-t.w*t.w);return e<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=t.x/e,this.y=t.y/e,this.z=t.z/e),this}setAxisAngleFromRotationMatrix(t){let e,n,i,r;const s=.01,a=.1,o=t.elements,l=o[0],c=o[4],h=o[8],u=o[1],d=o[5],p=o[9],m=o[2],f=o[6],g=o[10];if(Math.abs(c-u)o&&t>_?t_?o=0?1:-1,i=1-e*e;if(i>Number.EPSILON){const r=Math.sqrt(i),s=Math.atan2(r,e*n);t=Math.sin(t*s)/r,a=Math.sin(a*s)/r}const r=a*n;if(o=o*t+u*r,l=l*t+d*r,c=c*t+p*r,h=h*t+m*r,t===1-a){const t=1/Math.sqrt(o*o+l*l+c*c+h*h);o*=t,l*=t,c*=t,h*=t}}t[e]=o,t[e+1]=l,t[e+2]=c,t[e+3]=h}static multiplyQuaternionsFlat(t,e,n,i,r,s){const a=n[i],o=n[i+1],l=n[i+2],c=n[i+3],h=r[s],u=r[s+1],d=r[s+2],p=r[s+3];return t[e]=a*p+c*h+o*d-l*u,t[e+1]=o*p+c*u+l*h-a*d,t[e+2]=l*p+c*d+a*u-o*h,t[e+3]=c*p-a*h-o*u-l*d,t}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get w(){return this._w}set w(t){this._w=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._w=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(t){return this._x=t.x,this._y=t.y,this._z=t.z,this._w=t.w,this._onChangeCallback(),this}setFromEuler(t,e=!0){const n=t._x,i=t._y,r=t._z,s=t._order,a=Math.cos,o=Math.sin,l=a(n/2),c=a(i/2),h=a(r/2),u=o(n/2),d=o(i/2),p=o(r/2);switch(s){case"XYZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"YXZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"ZXY":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"ZYX":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"YZX":this._x=u*c*h+l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h-u*d*p;break;case"XZY":this._x=u*c*h-l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h+u*d*p;break;default:console.warn("THREE.Quaternion: .setFromEuler() encountered an unknown order: "+s)}return!0===e&&this._onChangeCallback(),this}setFromAxisAngle(t,e){const n=e/2,i=Math.sin(n);return this._x=t.x*i,this._y=t.y*i,this._z=t.z*i,this._w=Math.cos(n),this._onChangeCallback(),this}setFromRotationMatrix(t){const e=t.elements,n=e[0],i=e[4],r=e[8],s=e[1],a=e[5],o=e[9],l=e[2],c=e[6],h=e[10],u=n+a+h;if(u>0){const t=.5/Math.sqrt(u+1);this._w=.25/t,this._x=(c-o)*t,this._y=(r-l)*t,this._z=(s-i)*t}else if(n>a&&n>h){const t=2*Math.sqrt(1+n-a-h);this._w=(c-o)/t,this._x=.25*t,this._y=(i+s)/t,this._z=(r+l)/t}else if(a>h){const t=2*Math.sqrt(1+a-n-h);this._w=(r-l)/t,this._x=(i+s)/t,this._y=.25*t,this._z=(o+c)/t}else{const t=2*Math.sqrt(1+h-n-a);this._w=(s-i)/t,this._x=(r+l)/t,this._y=(o+c)/t,this._z=.25*t}return this._onChangeCallback(),this}setFromUnitVectors(t,e){let n=t.dot(e)+1;return nMath.abs(t.z)?(this._x=-t.y,this._y=t.x,this._z=0,this._w=n):(this._x=0,this._y=-t.z,this._z=t.y,this._w=n)):(this._x=t.y*e.z-t.z*e.y,this._y=t.z*e.x-t.x*e.z,this._z=t.x*e.y-t.y*e.x,this._w=n),this.normalize()}angleTo(t){return 2*Math.acos(Math.abs(ae(this.dot(t),-1,1)))}rotateTowards(t,e){const n=this.angleTo(t);if(0===n)return this;const i=Math.min(1,e/n);return this.slerp(t,i),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(t){return this._x*t._x+this._y*t._y+this._z*t._z+this._w*t._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let t=this.length();return 0===t?(this._x=0,this._y=0,this._z=0,this._w=1):(t=1/t,this._x=this._x*t,this._y=this._y*t,this._z=this._z*t,this._w=this._w*t),this._onChangeCallback(),this}multiply(t){return this.multiplyQuaternions(this,t)}premultiply(t){return this.multiplyQuaternions(t,this)}multiplyQuaternions(t,e){const n=t._x,i=t._y,r=t._z,s=t._w,a=e._x,o=e._y,l=e._z,c=e._w;return this._x=n*c+s*a+i*l-r*o,this._y=i*c+s*o+r*a-n*l,this._z=r*c+s*l+n*o-i*a,this._w=s*c-n*a-i*o-r*l,this._onChangeCallback(),this}slerp(t,e){if(0===e)return this;if(1===e)return this.copy(t);const n=this._x,i=this._y,r=this._z,s=this._w;let a=s*t._w+n*t._x+i*t._y+r*t._z;if(a<0?(this._w=-t._w,this._x=-t._x,this._y=-t._y,this._z=-t._z,a=-a):this.copy(t),a>=1)return this._w=s,this._x=n,this._y=i,this._z=r,this;const o=1-a*a;if(o<=Number.EPSILON){const t=1-e;return this._w=t*s+e*this._w,this._x=t*n+e*this._x,this._y=t*i+e*this._y,this._z=t*r+e*this._z,this.normalize(),this}const l=Math.sqrt(o),c=Math.atan2(l,a),h=Math.sin((1-e)*c)/l,u=Math.sin(e*c)/l;return this._w=s*h+this._w*u,this._x=n*h+this._x*u,this._y=i*h+this._y*u,this._z=r*h+this._z*u,this._onChangeCallback(),this}slerpQuaternions(t,e,n){return this.copy(t).slerp(e,n)}random(){const t=Math.random(),e=Math.sqrt(1-t),n=Math.sqrt(t),i=2*Math.PI*Math.random(),r=2*Math.PI*Math.random();return this.set(e*Math.cos(i),n*Math.sin(r),n*Math.cos(r),e*Math.sin(i))}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._w===this._w}fromArray(t,e=0){return this._x=t[e],this._y=t[e+1],this._z=t[e+2],this._w=t[e+3],this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._w,t}fromBufferAttribute(t,e){return this._x=t.getX(e),this._y=t.getY(e),this._z=t.getZ(e),this._w=t.getW(e),this._onChangeCallback(),this}toJSON(){return this.toArray()}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._w}}class We{constructor(t=0,e=0,n=0){We.prototype.isVector3=!0,this.x=t,this.y=e,this.z=n}set(t,e,n){return void 0===n&&(n=this.z),this.x=t,this.y=e,this.z=n,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this}add(t){return this.x+=t.x,this.y+=t.y,this.z+=t.z,this}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this}sub(t){return this.x-=t.x,this.y-=t.y,this.z-=t.z,this}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this}multiplyVectors(t,e){return this.x=t.x*e.x,this.y=t.y*e.y,this.z=t.z*e.z,this}applyEuler(t){return this.applyQuaternion(je.setFromEuler(t))}applyAxisAngle(t,e){return this.applyQuaternion(je.setFromAxisAngle(t,e))}applyMatrix3(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[3]*n+r[6]*i,this.y=r[1]*e+r[4]*n+r[7]*i,this.z=r[2]*e+r[5]*n+r[8]*i,this}applyNormalMatrix(t){return this.applyMatrix3(t).normalize()}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=t.elements,s=1/(r[3]*e+r[7]*n+r[11]*i+r[15]);return this.x=(r[0]*e+r[4]*n+r[8]*i+r[12])*s,this.y=(r[1]*e+r[5]*n+r[9]*i+r[13])*s,this.z=(r[2]*e+r[6]*n+r[10]*i+r[14])*s,this}applyQuaternion(t){const e=this.x,n=this.y,i=this.z,r=t.x,s=t.y,a=t.z,o=t.w,l=2*(s*i-a*n),c=2*(a*e-r*i),h=2*(r*n-s*e);return this.x=e+o*l+s*h-a*c,this.y=n+o*c+a*l-r*h,this.z=i+o*h+r*c-s*l,this}project(t){return this.applyMatrix4(t.matrixWorldInverse).applyMatrix4(t.projectionMatrix)}unproject(t){return this.applyMatrix4(t.projectionMatrixInverse).applyMatrix4(t.matrixWorld)}transformDirection(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[4]*n+r[8]*i,this.y=r[1]*e+r[5]*n+r[9]*i,this.z=r[2]*e+r[6]*n+r[10]*i,this.normalize()}divide(t){return this.x/=t.x,this.y/=t.y,this.z/=t.z,this}divideScalar(t){return this.multiplyScalar(1/t)}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this.z=Math.min(this.z,t.z),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this.z=Math.max(this.z,t.z),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this.z=Math.max(t.z,Math.min(e.z,this.z)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this.z=Math.max(t,Math.min(e,this.z)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=Math.trunc(this.x),this.y=Math.trunc(this.y),this.z=Math.trunc(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(t){return this.x*t.x+this.y*t.y+this.z*t.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this.z+=(t.z-this.z)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this.z=t.z+(e.z-t.z)*n,this}cross(t){return this.crossVectors(this,t)}crossVectors(t,e){const n=t.x,i=t.y,r=t.z,s=e.x,a=e.y,o=e.z;return this.x=i*o-r*a,this.y=r*s-n*o,this.z=n*a-i*s,this}projectOnVector(t){const e=t.lengthSq();if(0===e)return this.set(0,0,0);const n=t.dot(this)/e;return this.copy(t).multiplyScalar(n)}projectOnPlane(t){return Xe.copy(this).projectOnVector(t),this.sub(Xe)}reflect(t){return this.sub(Xe.copy(t).multiplyScalar(2*this.dot(t)))}angleTo(t){const e=Math.sqrt(this.lengthSq()*t.lengthSq());if(0===e)return Math.PI/2;const n=this.dot(t)/e;return Math.acos(ae(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y,i=this.z-t.z;return e*e+n*n+i*i}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)+Math.abs(this.z-t.z)}setFromSpherical(t){return this.setFromSphericalCoords(t.radius,t.phi,t.theta)}setFromSphericalCoords(t,e,n){const i=Math.sin(e)*t;return this.x=i*Math.sin(n),this.y=Math.cos(e)*t,this.z=i*Math.cos(n),this}setFromCylindrical(t){return this.setFromCylindricalCoords(t.radius,t.theta,t.y)}setFromCylindricalCoords(t,e,n){return this.x=t*Math.sin(e),this.y=n,this.z=t*Math.cos(e),this}setFromMatrixPosition(t){const e=t.elements;return this.x=e[12],this.y=e[13],this.z=e[14],this}setFromMatrixScale(t){const e=this.setFromMatrixColumn(t,0).length(),n=this.setFromMatrixColumn(t,1).length(),i=this.setFromMatrixColumn(t,2).length();return this.x=e,this.y=n,this.z=i,this}setFromMatrixColumn(t,e){return this.fromArray(t.elements,4*e)}setFromMatrix3Column(t,e){return this.fromArray(t.elements,3*e)}setFromEuler(t){return this.x=t._x,this.y=t._y,this.z=t._z,this}setFromColor(t){return this.x=t.r,this.y=t.g,this.z=t.b,this}equals(t){return t.x===this.x&&t.y===this.y&&t.z===this.z}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this.z=t[e+2],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t[e+2]=this.z,t}fromBufferAttribute(t,e){return this.x=t.getX(e),this.y=t.getY(e),this.z=t.getZ(e),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}randomDirection(){const t=2*(Math.random()-.5),e=Math.random()*Math.PI*2,n=Math.sqrt(1-t**2);return this.x=n*Math.cos(e),this.y=n*Math.sin(e),this.z=t,this}*[Symbol.iterator](){yield this.x,yield this.y,yield this.z}}const Xe=new We,je=new ke;class qe{constructor(t=new We(1/0,1/0,1/0),e=new We(-1/0,-1/0,-1/0)){this.isBox3=!0,this.min=t,this.max=e}set(t,e){return this.min.copy(t),this.max.copy(e),this}setFromArray(t){this.makeEmpty();for(let e=0,n=t.length;ethis.max.x||t.ythis.max.y||t.zthis.max.z)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y&&this.min.z<=t.min.z&&t.max.z<=this.max.z}getParameter(t,e){return e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y),(t.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y||t.max.zthis.max.z)}intersectsSphere(t){return this.clampPoint(t.center,Ze),Ze.distanceToSquared(t.center)<=t.radius*t.radius}intersectsPlane(t){let e,n;return t.normal.x>0?(e=t.normal.x*this.min.x,n=t.normal.x*this.max.x):(e=t.normal.x*this.max.x,n=t.normal.x*this.min.x),t.normal.y>0?(e+=t.normal.y*this.min.y,n+=t.normal.y*this.max.y):(e+=t.normal.y*this.max.y,n+=t.normal.y*this.min.y),t.normal.z>0?(e+=t.normal.z*this.min.z,n+=t.normal.z*this.max.z):(e+=t.normal.z*this.max.z,n+=t.normal.z*this.min.z),e<=-t.constant&&n>=-t.constant}intersectsTriangle(t){if(this.isEmpty())return!1;this.getCenter(rn),sn.subVectors(this.max,rn),Ke.subVectors(t.a,rn),$e.subVectors(t.b,rn),Qe.subVectors(t.c,rn),tn.subVectors($e,Ke),en.subVectors(Qe,$e),nn.subVectors(Ke,Qe);let e=[0,-tn.z,tn.y,0,-en.z,en.y,0,-nn.z,nn.y,tn.z,0,-tn.x,en.z,0,-en.x,nn.z,0,-nn.x,-tn.y,tn.x,0,-en.y,en.x,0,-nn.y,nn.x,0];return!!ln(e,Ke,$e,Qe,sn)&&(e=[1,0,0,0,1,0,0,0,1],!!ln(e,Ke,$e,Qe,sn)&&(an.crossVectors(tn,en),e=[an.x,an.y,an.z],ln(e,Ke,$e,Qe,sn)))}clampPoint(t,e){return e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return this.clampPoint(t,Ze).distanceTo(t)}getBoundingSphere(t){return this.isEmpty()?t.makeEmpty():(this.getCenter(t.center),t.radius=.5*this.getSize(Ze).length()),t}intersect(t){return this.min.max(t.min),this.max.min(t.max),this.isEmpty()&&this.makeEmpty(),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}applyMatrix4(t){return this.isEmpty()||(Ye[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(t),Ye[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(t),Ye[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(t),Ye[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(t),Ye[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(t),Ye[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(t),Ye[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(t),Ye[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(t),this.setFromPoints(Ye)),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}}const Ye=[new We,new We,new We,new We,new We,new We,new We,new We],Ze=new We,Je=new qe,Ke=new We,$e=new We,Qe=new We,tn=new We,en=new We,nn=new We,rn=new We,sn=new We,an=new We,on=new We;function ln(t,e,n,i,r){for(let s=0,a=t.length-3;s<=a;s+=3){on.fromArray(t,s);const a=r.x*Math.abs(on.x)+r.y*Math.abs(on.y)+r.z*Math.abs(on.z),o=e.dot(on),l=n.dot(on),c=i.dot(on);if(Math.max(-Math.max(o,l,c),Math.min(o,l,c))>a)return!1}return!0}const cn=new qe,hn=new We,un=new We;class dn{constructor(t=new We,e=-1){this.isSphere=!0,this.center=t,this.radius=e}set(t,e){return this.center.copy(t),this.radius=e,this}setFromPoints(t,e){const n=this.center;void 0!==e?n.copy(e):cn.setFromPoints(t).getCenter(n);let i=0;for(let e=0,r=t.length;ethis.radius*this.radius&&(e.sub(this.center).normalize(),e.multiplyScalar(this.radius).add(this.center)),e}getBoundingBox(t){return this.isEmpty()?(t.makeEmpty(),t):(t.set(this.center,this.center),t.expandByScalar(this.radius),t)}applyMatrix4(t){return this.center.applyMatrix4(t),this.radius=this.radius*t.getMaxScaleOnAxis(),this}translate(t){return this.center.add(t),this}expandByPoint(t){if(this.isEmpty())return this.center.copy(t),this.radius=0,this;hn.subVectors(t,this.center);const e=hn.lengthSq();if(e>this.radius*this.radius){const t=Math.sqrt(e),n=.5*(t-this.radius);this.center.addScaledVector(hn,n/t),this.radius+=n}return this}union(t){return t.isEmpty()?this:this.isEmpty()?(this.copy(t),this):(!0===this.center.equals(t.center)?this.radius=Math.max(this.radius,t.radius):(un.subVectors(t.center,this.center).setLength(t.radius),this.expandByPoint(hn.copy(t.center).add(un)),this.expandByPoint(hn.copy(t.center).sub(un))),this)}equals(t){return t.center.equals(this.center)&&t.radius===this.radius}clone(){return(new this.constructor).copy(this)}}const pn=new We,mn=new We,fn=new We,gn=new We,_n=new We,vn=new We,xn=new We;class yn{constructor(t=new We,e=new We(0,0,-1)){this.origin=t,this.direction=e}set(t,e){return this.origin.copy(t),this.direction.copy(e),this}copy(t){return this.origin.copy(t.origin),this.direction.copy(t.direction),this}at(t,e){return e.copy(this.origin).addScaledVector(this.direction,t)}lookAt(t){return this.direction.copy(t).sub(this.origin).normalize(),this}recast(t){return this.origin.copy(this.at(t,pn)),this}closestPointToPoint(t,e){e.subVectors(t,this.origin);const n=e.dot(this.direction);return n<0?e.copy(this.origin):e.copy(this.origin).addScaledVector(this.direction,n)}distanceToPoint(t){return Math.sqrt(this.distanceSqToPoint(t))}distanceSqToPoint(t){const e=pn.subVectors(t,this.origin).dot(this.direction);return e<0?this.origin.distanceToSquared(t):(pn.copy(this.origin).addScaledVector(this.direction,e),pn.distanceToSquared(t))}distanceSqToSegment(t,e,n,i){mn.copy(t).add(e).multiplyScalar(.5),fn.copy(e).sub(t).normalize(),gn.copy(this.origin).sub(mn);const r=.5*t.distanceTo(e),s=-this.direction.dot(fn),a=gn.dot(this.direction),o=-gn.dot(fn),l=gn.lengthSq(),c=Math.abs(1-s*s);let h,u,d,p;if(c>0)if(h=s*o-a,u=s*a-o,p=r*c,h>=0)if(u>=-p)if(u<=p){const t=1/c;h*=t,u*=t,d=h*(h+s*u+2*a)+u*(s*h+u+2*o)+l}else u=r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u=-r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u<=-p?(h=Math.max(0,-(-s*r+a)),u=h>0?-r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l):u<=p?(h=0,u=Math.min(Math.max(-r,-o),r),d=u*(u+2*o)+l):(h=Math.max(0,-(s*r+a)),u=h>0?r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l);else u=s>0?-r:r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;return n&&n.copy(this.origin).addScaledVector(this.direction,h),i&&i.copy(mn).addScaledVector(fn,u),d}intersectSphere(t,e){pn.subVectors(t.center,this.origin);const n=pn.dot(this.direction),i=pn.dot(pn)-n*n,r=t.radius*t.radius;if(i>r)return null;const s=Math.sqrt(r-i),a=n-s,o=n+s;return o<0?null:a<0?this.at(o,e):this.at(a,e)}intersectsSphere(t){return this.distanceSqToPoint(t.center)<=t.radius*t.radius}distanceToPlane(t){const e=t.normal.dot(this.direction);if(0===e)return 0===t.distanceToPoint(this.origin)?0:null;const n=-(this.origin.dot(t.normal)+t.constant)/e;return n>=0?n:null}intersectPlane(t,e){const n=this.distanceToPlane(t);return null===n?null:this.at(n,e)}intersectsPlane(t){const e=t.distanceToPoint(this.origin);if(0===e)return!0;return t.normal.dot(this.direction)*e<0}intersectBox(t,e){let n,i,r,s,a,o;const l=1/this.direction.x,c=1/this.direction.y,h=1/this.direction.z,u=this.origin;return l>=0?(n=(t.min.x-u.x)*l,i=(t.max.x-u.x)*l):(n=(t.max.x-u.x)*l,i=(t.min.x-u.x)*l),c>=0?(r=(t.min.y-u.y)*c,s=(t.max.y-u.y)*c):(r=(t.max.y-u.y)*c,s=(t.min.y-u.y)*c),n>s||r>i?null:((r>n||isNaN(n))&&(n=r),(s=0?(a=(t.min.z-u.z)*h,o=(t.max.z-u.z)*h):(a=(t.max.z-u.z)*h,o=(t.min.z-u.z)*h),n>o||a>i?null:((a>n||n!=n)&&(n=a),(o=0?n:i,e)))}intersectsBox(t){return null!==this.intersectBox(t,pn)}intersectTriangle(t,e,n,i,r){_n.subVectors(e,t),vn.subVectors(n,t),xn.crossVectors(_n,vn);let s,a=this.direction.dot(xn);if(a>0){if(i)return null;s=1}else{if(!(a<0))return null;s=-1,a=-a}gn.subVectors(this.origin,t);const o=s*this.direction.dot(vn.crossVectors(gn,vn));if(o<0)return null;const l=s*this.direction.dot(_n.cross(gn));if(l<0)return null;if(o+l>a)return null;const c=-s*gn.dot(xn);return c<0?null:this.at(c/a,r)}applyMatrix4(t){return this.origin.applyMatrix4(t),this.direction.transformDirection(t),this}equals(t){return t.origin.equals(this.origin)&&t.direction.equals(this.direction)}clone(){return(new this.constructor).copy(this)}}class Mn{constructor(t,e,n,i,r,s,a,o,l,c,h,u,d,p,m,f){Mn.prototype.isMatrix4=!0,this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],void 0!==t&&this.set(t,e,n,i,r,s,a,o,l,c,h,u,d,p,m,f)}set(t,e,n,i,r,s,a,o,l,c,h,u,d,p,m,f){const g=this.elements;return g[0]=t,g[4]=e,g[8]=n,g[12]=i,g[1]=r,g[5]=s,g[9]=a,g[13]=o,g[2]=l,g[6]=c,g[10]=h,g[14]=u,g[3]=d,g[7]=p,g[11]=m,g[15]=f,this}identity(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this}clone(){return(new Mn).fromArray(this.elements)}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],e[9]=n[9],e[10]=n[10],e[11]=n[11],e[12]=n[12],e[13]=n[13],e[14]=n[14],e[15]=n[15],this}copyPosition(t){const e=this.elements,n=t.elements;return e[12]=n[12],e[13]=n[13],e[14]=n[14],this}setFromMatrix3(t){const e=t.elements;return this.set(e[0],e[3],e[6],0,e[1],e[4],e[7],0,e[2],e[5],e[8],0,0,0,0,1),this}extractBasis(t,e,n){return t.setFromMatrixColumn(this,0),e.setFromMatrixColumn(this,1),n.setFromMatrixColumn(this,2),this}makeBasis(t,e,n){return this.set(t.x,e.x,n.x,0,t.y,e.y,n.y,0,t.z,e.z,n.z,0,0,0,0,1),this}extractRotation(t){const e=this.elements,n=t.elements,i=1/Sn.setFromMatrixColumn(t,0).length(),r=1/Sn.setFromMatrixColumn(t,1).length(),s=1/Sn.setFromMatrixColumn(t,2).length();return e[0]=n[0]*i,e[1]=n[1]*i,e[2]=n[2]*i,e[3]=0,e[4]=n[4]*r,e[5]=n[5]*r,e[6]=n[6]*r,e[7]=0,e[8]=n[8]*s,e[9]=n[9]*s,e[10]=n[10]*s,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromEuler(t){const e=this.elements,n=t.x,i=t.y,r=t.z,s=Math.cos(n),a=Math.sin(n),o=Math.cos(i),l=Math.sin(i),c=Math.cos(r),h=Math.sin(r);if("XYZ"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=-o*h,e[8]=l,e[1]=n+i*l,e[5]=t-r*l,e[9]=-a*o,e[2]=r-t*l,e[6]=i+n*l,e[10]=s*o}else if("YXZ"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t+r*a,e[4]=i*a-n,e[8]=s*l,e[1]=s*h,e[5]=s*c,e[9]=-a,e[2]=n*a-i,e[6]=r+t*a,e[10]=s*o}else if("ZXY"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t-r*a,e[4]=-s*h,e[8]=i+n*a,e[1]=n+i*a,e[5]=s*c,e[9]=r-t*a,e[2]=-s*l,e[6]=a,e[10]=s*o}else if("ZYX"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=i*l-n,e[8]=t*l+r,e[1]=o*h,e[5]=r*l+t,e[9]=n*l-i,e[2]=-l,e[6]=a*o,e[10]=s*o}else if("YZX"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=r-t*h,e[8]=i*h+n,e[1]=h,e[5]=s*c,e[9]=-a*c,e[2]=-l*c,e[6]=n*h+i,e[10]=t-r*h}else if("XZY"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=-h,e[8]=l*c,e[1]=t*h+r,e[5]=s*c,e[9]=n*h-i,e[2]=i*h-n,e[6]=a*c,e[10]=r*h+t}return e[3]=0,e[7]=0,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromQuaternion(t){return this.compose(Tn,t,En)}lookAt(t,e,n){const i=this.elements;return Rn.subVectors(t,e),0===Rn.lengthSq()&&(Rn.z=1),Rn.normalize(),wn.crossVectors(n,Rn),0===wn.lengthSq()&&(1===Math.abs(n.z)?Rn.x+=1e-4:Rn.z+=1e-4,Rn.normalize(),wn.crossVectors(n,Rn)),wn.normalize(),An.crossVectors(Rn,wn),i[0]=wn.x,i[4]=An.x,i[8]=Rn.x,i[1]=wn.y,i[5]=An.y,i[9]=Rn.y,i[2]=wn.z,i[6]=An.z,i[10]=Rn.z,this}multiply(t){return this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[4],o=n[8],l=n[12],c=n[1],h=n[5],u=n[9],d=n[13],p=n[2],m=n[6],f=n[10],g=n[14],_=n[3],v=n[7],x=n[11],y=n[15],M=i[0],S=i[4],b=i[8],T=i[12],E=i[1],w=i[5],A=i[9],R=i[13],C=i[2],L=i[6],P=i[10],I=i[14],U=i[3],D=i[7],N=i[11],O=i[15];return r[0]=s*M+a*E+o*C+l*U,r[4]=s*S+a*w+o*L+l*D,r[8]=s*b+a*A+o*P+l*N,r[12]=s*T+a*R+o*I+l*O,r[1]=c*M+h*E+u*C+d*U,r[5]=c*S+h*w+u*L+d*D,r[9]=c*b+h*A+u*P+d*N,r[13]=c*T+h*R+u*I+d*O,r[2]=p*M+m*E+f*C+g*U,r[6]=p*S+m*w+f*L+g*D,r[10]=p*b+m*A+f*P+g*N,r[14]=p*T+m*R+f*I+g*O,r[3]=_*M+v*E+x*C+y*U,r[7]=_*S+v*w+x*L+y*D,r[11]=_*b+v*A+x*P+y*N,r[15]=_*T+v*R+x*I+y*O,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[4]*=t,e[8]*=t,e[12]*=t,e[1]*=t,e[5]*=t,e[9]*=t,e[13]*=t,e[2]*=t,e[6]*=t,e[10]*=t,e[14]*=t,e[3]*=t,e[7]*=t,e[11]*=t,e[15]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[4],i=t[8],r=t[12],s=t[1],a=t[5],o=t[9],l=t[13],c=t[2],h=t[6],u=t[10],d=t[14];return t[3]*(+r*o*h-i*l*h-r*a*u+n*l*u+i*a*d-n*o*d)+t[7]*(+e*o*d-e*l*u+r*s*u-i*s*d+i*l*c-r*o*c)+t[11]*(+e*l*h-e*a*d-r*s*h+n*s*d+r*a*c-n*l*c)+t[15]*(-i*a*c-e*o*h+e*a*u+i*s*h-n*s*u+n*o*c)}transpose(){const t=this.elements;let e;return e=t[1],t[1]=t[4],t[4]=e,e=t[2],t[2]=t[8],t[8]=e,e=t[6],t[6]=t[9],t[9]=e,e=t[3],t[3]=t[12],t[12]=e,e=t[7],t[7]=t[13],t[13]=e,e=t[11],t[11]=t[14],t[14]=e,this}setPosition(t,e,n){const i=this.elements;return t.isVector3?(i[12]=t.x,i[13]=t.y,i[14]=t.z):(i[12]=t,i[13]=e,i[14]=n),this}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=t[9],u=t[10],d=t[11],p=t[12],m=t[13],f=t[14],g=t[15],_=h*f*l-m*u*l+m*o*d-a*f*d-h*o*g+a*u*g,v=p*u*l-c*f*l-p*o*d+s*f*d+c*o*g-s*u*g,x=c*m*l-p*h*l+p*a*d-s*m*d-c*a*g+s*h*g,y=p*h*o-c*m*o-p*a*u+s*m*u+c*a*f-s*h*f,M=e*_+n*v+i*x+r*y;if(0===M)return this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);const S=1/M;return t[0]=_*S,t[1]=(m*u*r-h*f*r-m*i*d+n*f*d+h*i*g-n*u*g)*S,t[2]=(a*f*r-m*o*r+m*i*l-n*f*l-a*i*g+n*o*g)*S,t[3]=(h*o*r-a*u*r-h*i*l+n*u*l+a*i*d-n*o*d)*S,t[4]=v*S,t[5]=(c*f*r-p*u*r+p*i*d-e*f*d-c*i*g+e*u*g)*S,t[6]=(p*o*r-s*f*r-p*i*l+e*f*l+s*i*g-e*o*g)*S,t[7]=(s*u*r-c*o*r+c*i*l-e*u*l-s*i*d+e*o*d)*S,t[8]=x*S,t[9]=(p*h*r-c*m*r-p*n*d+e*m*d+c*n*g-e*h*g)*S,t[10]=(s*m*r-p*a*r+p*n*l-e*m*l-s*n*g+e*a*g)*S,t[11]=(c*a*r-s*h*r-c*n*l+e*h*l+s*n*d-e*a*d)*S,t[12]=y*S,t[13]=(c*m*i-p*h*i+p*n*u-e*m*u-c*n*f+e*h*f)*S,t[14]=(p*a*i-s*m*i-p*n*o+e*m*o+s*n*f-e*a*f)*S,t[15]=(s*h*i-c*a*i+c*n*o-e*h*o-s*n*u+e*a*u)*S,this}scale(t){const e=this.elements,n=t.x,i=t.y,r=t.z;return e[0]*=n,e[4]*=i,e[8]*=r,e[1]*=n,e[5]*=i,e[9]*=r,e[2]*=n,e[6]*=i,e[10]*=r,e[3]*=n,e[7]*=i,e[11]*=r,this}getMaxScaleOnAxis(){const t=this.elements,e=t[0]*t[0]+t[1]*t[1]+t[2]*t[2],n=t[4]*t[4]+t[5]*t[5]+t[6]*t[6],i=t[8]*t[8]+t[9]*t[9]+t[10]*t[10];return Math.sqrt(Math.max(e,n,i))}makeTranslation(t,e,n){return t.isVector3?this.set(1,0,0,t.x,0,1,0,t.y,0,0,1,t.z,0,0,0,1):this.set(1,0,0,t,0,1,0,e,0,0,1,n,0,0,0,1),this}makeRotationX(t){const e=Math.cos(t),n=Math.sin(t);return this.set(1,0,0,0,0,e,-n,0,0,n,e,0,0,0,0,1),this}makeRotationY(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,0,n,0,0,1,0,0,-n,0,e,0,0,0,0,1),this}makeRotationZ(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,-n,0,0,n,e,0,0,0,0,1,0,0,0,0,1),this}makeRotationAxis(t,e){const n=Math.cos(e),i=Math.sin(e),r=1-n,s=t.x,a=t.y,o=t.z,l=r*s,c=r*a;return this.set(l*s+n,l*a-i*o,l*o+i*a,0,l*a+i*o,c*a+n,c*o-i*s,0,l*o-i*a,c*o+i*s,r*o*o+n,0,0,0,0,1),this}makeScale(t,e,n){return this.set(t,0,0,0,0,e,0,0,0,0,n,0,0,0,0,1),this}makeShear(t,e,n,i,r,s){return this.set(1,n,r,0,t,1,s,0,e,i,1,0,0,0,0,1),this}compose(t,e,n){const i=this.elements,r=e._x,s=e._y,a=e._z,o=e._w,l=r+r,c=s+s,h=a+a,u=r*l,d=r*c,p=r*h,m=s*c,f=s*h,g=a*h,_=o*l,v=o*c,x=o*h,y=n.x,M=n.y,S=n.z;return i[0]=(1-(m+g))*y,i[1]=(d+x)*y,i[2]=(p-v)*y,i[3]=0,i[4]=(d-x)*M,i[5]=(1-(u+g))*M,i[6]=(f+_)*M,i[7]=0,i[8]=(p+v)*S,i[9]=(f-_)*S,i[10]=(1-(u+m))*S,i[11]=0,i[12]=t.x,i[13]=t.y,i[14]=t.z,i[15]=1,this}decompose(t,e,n){const i=this.elements;let r=Sn.set(i[0],i[1],i[2]).length();const s=Sn.set(i[4],i[5],i[6]).length(),a=Sn.set(i[8],i[9],i[10]).length();this.determinant()<0&&(r=-r),t.x=i[12],t.y=i[13],t.z=i[14],bn.copy(this);const o=1/r,l=1/s,c=1/a;return bn.elements[0]*=o,bn.elements[1]*=o,bn.elements[2]*=o,bn.elements[4]*=l,bn.elements[5]*=l,bn.elements[6]*=l,bn.elements[8]*=c,bn.elements[9]*=c,bn.elements[10]*=c,e.setFromRotationMatrix(bn),n.x=r,n.y=s,n.z=a,this}makePerspective(t,e,n,i,r,s,a=2e3){const o=this.elements,l=2*r/(e-t),c=2*r/(n-i),h=(e+t)/(e-t),u=(n+i)/(n-i);let d,p;if(a===$t)d=-(s+r)/(s-r),p=-2*s*r/(s-r);else{if(a!==Qt)throw new Error("THREE.Matrix4.makePerspective(): Invalid coordinate system: "+a);d=-s/(s-r),p=-s*r/(s-r)}return o[0]=l,o[4]=0,o[8]=h,o[12]=0,o[1]=0,o[5]=c,o[9]=u,o[13]=0,o[2]=0,o[6]=0,o[10]=d,o[14]=p,o[3]=0,o[7]=0,o[11]=-1,o[15]=0,this}makeOrthographic(t,e,n,i,r,s,a=2e3){const o=this.elements,l=1/(e-t),c=1/(n-i),h=1/(s-r),u=(e+t)*l,d=(n+i)*c;let p,m;if(a===$t)p=(s+r)*h,m=-2*h;else{if(a!==Qt)throw new Error("THREE.Matrix4.makeOrthographic(): Invalid coordinate system: "+a);p=r*h,m=-1*h}return o[0]=2*l,o[4]=0,o[8]=0,o[12]=-u,o[1]=0,o[5]=2*c,o[9]=0,o[13]=-d,o[2]=0,o[6]=0,o[10]=m,o[14]=-p,o[3]=0,o[7]=0,o[11]=0,o[15]=1,this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<16;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<16;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t[e+9]=n[9],t[e+10]=n[10],t[e+11]=n[11],t[e+12]=n[12],t[e+13]=n[13],t[e+14]=n[14],t[e+15]=n[15],t}}const Sn=new We,bn=new Mn,Tn=new We(0,0,0),En=new We(1,1,1),wn=new We,An=new We,Rn=new We,Cn=new Mn,Ln=new ke;class Pn{constructor(t=0,e=0,n=0,i=Pn.DEFAULT_ORDER){this.isEuler=!0,this._x=t,this._y=e,this._z=n,this._order=i}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get order(){return this._order}set order(t){this._order=t,this._onChangeCallback()}set(t,e,n,i=this._order){return this._x=t,this._y=e,this._z=n,this._order=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._order)}copy(t){return this._x=t._x,this._y=t._y,this._z=t._z,this._order=t._order,this._onChangeCallback(),this}setFromRotationMatrix(t,e=this._order,n=!0){const i=t.elements,r=i[0],s=i[4],a=i[8],o=i[1],l=i[5],c=i[9],h=i[2],u=i[6],d=i[10];switch(e){case"XYZ":this._y=Math.asin(ae(a,-1,1)),Math.abs(a)<.9999999?(this._x=Math.atan2(-c,d),this._z=Math.atan2(-s,r)):(this._x=Math.atan2(u,l),this._z=0);break;case"YXZ":this._x=Math.asin(-ae(c,-1,1)),Math.abs(c)<.9999999?(this._y=Math.atan2(a,d),this._z=Math.atan2(o,l)):(this._y=Math.atan2(-h,r),this._z=0);break;case"ZXY":this._x=Math.asin(ae(u,-1,1)),Math.abs(u)<.9999999?(this._y=Math.atan2(-h,d),this._z=Math.atan2(-s,l)):(this._y=0,this._z=Math.atan2(o,r));break;case"ZYX":this._y=Math.asin(-ae(h,-1,1)),Math.abs(h)<.9999999?(this._x=Math.atan2(u,d),this._z=Math.atan2(o,r)):(this._x=0,this._z=Math.atan2(-s,l));break;case"YZX":this._z=Math.asin(ae(o,-1,1)),Math.abs(o)<.9999999?(this._x=Math.atan2(-c,l),this._y=Math.atan2(-h,r)):(this._x=0,this._y=Math.atan2(a,d));break;case"XZY":this._z=Math.asin(-ae(s,-1,1)),Math.abs(s)<.9999999?(this._x=Math.atan2(u,l),this._y=Math.atan2(a,r)):(this._x=Math.atan2(-c,d),this._y=0);break;default:console.warn("THREE.Euler: .setFromRotationMatrix() encountered an unknown order: "+e)}return this._order=e,!0===n&&this._onChangeCallback(),this}setFromQuaternion(t,e,n){return Cn.makeRotationFromQuaternion(t),this.setFromRotationMatrix(Cn,e,n)}setFromVector3(t,e=this._order){return this.set(t.x,t.y,t.z,e)}reorder(t){return Ln.setFromEuler(this),this.setFromQuaternion(Ln,t)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._order===this._order}fromArray(t){return this._x=t[0],this._y=t[1],this._z=t[2],void 0!==t[3]&&(this._order=t[3]),this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._order,t}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}*[Symbol.iterator](){yield this._x,yield this._y,yield this._z,yield this._order}}Pn.DEFAULT_ORDER="XYZ";class In{constructor(){this.mask=1}set(t){this.mask=(1<>>0}enable(t){this.mask|=1<1){for(let t=0;t1){for(let t=0;t0&&(i.userData=this.userData),i.layers=this.layers.mask,i.matrix=this.matrix.toArray(),i.up=this.up.toArray(),!1===this.matrixAutoUpdate&&(i.matrixAutoUpdate=!1),this.isInstancedMesh&&(i.type="InstancedMesh",i.count=this.count,i.instanceMatrix=this.instanceMatrix.toJSON(),null!==this.instanceColor&&(i.instanceColor=this.instanceColor.toJSON())),this.isBatchedMesh&&(i.type="BatchedMesh",i.perObjectFrustumCulled=this.perObjectFrustumCulled,i.sortObjects=this.sortObjects,i.drawRanges=this._drawRanges,i.reservedRanges=this._reservedRanges,i.visibility=this._visibility,i.active=this._active,i.bounds=this._bounds.map((t=>({boxInitialized:t.boxInitialized,boxMin:t.box.min.toArray(),boxMax:t.box.max.toArray(),sphereInitialized:t.sphereInitialized,sphereRadius:t.sphere.radius,sphereCenter:t.sphere.center.toArray()}))),i.maxGeometryCount=this._maxGeometryCount,i.maxVertexCount=this._maxVertexCount,i.maxIndexCount=this._maxIndexCount,i.geometryInitialized=this._geometryInitialized,i.geometryCount=this._geometryCount,i.matricesTexture=this._matricesTexture.toJSON(t),null!==this.boundingSphere&&(i.boundingSphere={center:i.boundingSphere.center.toArray(),radius:i.boundingSphere.radius}),null!==this.boundingBox&&(i.boundingBox={min:i.boundingBox.min.toArray(),max:i.boundingBox.max.toArray()})),this.isScene)this.background&&(this.background.isColor?i.background=this.background.toJSON():this.background.isTexture&&(i.background=this.background.toJSON(t).uuid)),this.environment&&this.environment.isTexture&&!0!==this.environment.isRenderTargetTexture&&(i.environment=this.environment.toJSON(t).uuid);else if(this.isMesh||this.isLine||this.isPoints){i.geometry=r(t.geometries,this.geometry);const e=this.geometry.parameters;if(void 0!==e&&void 0!==e.shapes){const n=e.shapes;if(Array.isArray(n))for(let e=0,i=n.length;e0){i.children=[];for(let e=0;e0){i.animations=[];for(let e=0;e0&&(n.geometries=e),i.length>0&&(n.materials=i),r.length>0&&(n.textures=r),a.length>0&&(n.images=a),o.length>0&&(n.shapes=o),l.length>0&&(n.skeletons=l),c.length>0&&(n.animations=c),h.length>0&&(n.nodes=h)}return n.object=i,n;function s(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}}clone(t){return(new this.constructor).copy(this,t)}copy(t,e=!0){if(this.name=t.name,this.up.copy(t.up),this.position.copy(t.position),this.rotation.order=t.rotation.order,this.quaternion.copy(t.quaternion),this.scale.copy(t.scale),this.matrix.copy(t.matrix),this.matrixWorld.copy(t.matrixWorld),this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrixWorldAutoUpdate=t.matrixWorldAutoUpdate,this.matrixWorldNeedsUpdate=t.matrixWorldNeedsUpdate,this.layers.mask=t.layers.mask,this.visible=t.visible,this.castShadow=t.castShadow,this.receiveShadow=t.receiveShadow,this.frustumCulled=t.frustumCulled,this.renderOrder=t.renderOrder,this.animations=t.animations.slice(),this.userData=JSON.parse(JSON.stringify(t.userData)),!0===e)for(let e=0;e0?i.multiplyScalar(1/Math.sqrt(r)):i.set(0,0,0)}static getBarycoord(t,e,n,i,r){qn.subVectors(i,e),Yn.subVectors(n,e),Zn.subVectors(t,e);const s=qn.dot(qn),a=qn.dot(Yn),o=qn.dot(Zn),l=Yn.dot(Yn),c=Yn.dot(Zn),h=s*l-a*a;if(0===h)return r.set(0,0,0),null;const u=1/h,d=(l*o-a*c)*u,p=(s*c-a*o)*u;return r.set(1-d-p,p,d)}static containsPoint(t,e,n,i){return null!==this.getBarycoord(t,e,n,i,Jn)&&(Jn.x>=0&&Jn.y>=0&&Jn.x+Jn.y<=1)}static getUV(t,e,n,i,r,s,a,o){return!1===ii&&(console.warn("THREE.Triangle.getUV() has been renamed to THREE.Triangle.getInterpolation()."),ii=!0),this.getInterpolation(t,e,n,i,r,s,a,o)}static getInterpolation(t,e,n,i,r,s,a,o){return null===this.getBarycoord(t,e,n,i,Jn)?(o.x=0,o.y=0,"z"in o&&(o.z=0),"w"in o&&(o.w=0),null):(o.setScalar(0),o.addScaledVector(r,Jn.x),o.addScaledVector(s,Jn.y),o.addScaledVector(a,Jn.z),o)}static isFrontFacing(t,e,n,i){return qn.subVectors(n,e),Yn.subVectors(t,e),qn.cross(Yn).dot(i)<0}set(t,e,n){return this.a.copy(t),this.b.copy(e),this.c.copy(n),this}setFromPointsAndIndices(t,e,n,i){return this.a.copy(t[e]),this.b.copy(t[n]),this.c.copy(t[i]),this}setFromAttributeAndIndices(t,e,n,i){return this.a.fromBufferAttribute(t,e),this.b.fromBufferAttribute(t,n),this.c.fromBufferAttribute(t,i),this}clone(){return(new this.constructor).copy(this)}copy(t){return this.a.copy(t.a),this.b.copy(t.b),this.c.copy(t.c),this}getArea(){return qn.subVectors(this.c,this.b),Yn.subVectors(this.a,this.b),.5*qn.cross(Yn).length()}getMidpoint(t){return t.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(t){return ri.getNormal(this.a,this.b,this.c,t)}getPlane(t){return t.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(t,e){return ri.getBarycoord(t,this.a,this.b,this.c,e)}getUV(t,e,n,i,r){return!1===ii&&(console.warn("THREE.Triangle.getUV() has been renamed to THREE.Triangle.getInterpolation()."),ii=!0),ri.getInterpolation(t,this.a,this.b,this.c,e,n,i,r)}getInterpolation(t,e,n,i,r){return ri.getInterpolation(t,this.a,this.b,this.c,e,n,i,r)}containsPoint(t){return ri.containsPoint(t,this.a,this.b,this.c)}isFrontFacing(t){return ri.isFrontFacing(this.a,this.b,this.c,t)}intersectsBox(t){return t.intersectsTriangle(this)}closestPointToPoint(t,e){const n=this.a,i=this.b,r=this.c;let s,a;Kn.subVectors(i,n),$n.subVectors(r,n),ti.subVectors(t,n);const o=Kn.dot(ti),l=$n.dot(ti);if(o<=0&&l<=0)return e.copy(n);ei.subVectors(t,i);const c=Kn.dot(ei),h=$n.dot(ei);if(c>=0&&h<=c)return e.copy(i);const u=o*h-c*l;if(u<=0&&o>=0&&c<=0)return s=o/(o-c),e.copy(n).addScaledVector(Kn,s);ni.subVectors(t,r);const d=Kn.dot(ni),p=$n.dot(ni);if(p>=0&&d<=p)return e.copy(r);const m=d*l-o*p;if(m<=0&&l>=0&&p<=0)return a=l/(l-p),e.copy(n).addScaledVector($n,a);const f=c*p-d*h;if(f<=0&&h-c>=0&&d-p>=0)return Qn.subVectors(r,i),a=(h-c)/(h-c+(d-p)),e.copy(i).addScaledVector(Qn,a);const g=1/(f+m+u);return s=m*g,a=u*g,e.copy(n).addScaledVector(Kn,s).addScaledVector($n,a)}equals(t){return t.a.equals(this.a)&&t.b.equals(this.b)&&t.c.equals(this.c)}}const si={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},ai={h:0,s:0,l:0},oi={h:0,s:0,l:0};function li(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+6*(e-t)*n:n<.5?e:n<2/3?t+6*(e-t)*(2/3-n):t}class ci{constructor(t,e,n){return this.isColor=!0,this.r=1,this.g=1,this.b=1,this.set(t,e,n)}set(t,e,n){if(void 0===e&&void 0===n){const e=t;e&&e.isColor?this.copy(e):"number"==typeof e?this.setHex(e):"string"==typeof e&&this.setStyle(e)}else this.setRGB(t,e,n);return this}setScalar(t){return this.r=t,this.g=t,this.b=t,this}setHex(t,e=Gt){return t=Math.floor(t),this.r=(t>>16&255)/255,this.g=(t>>8&255)/255,this.b=(255&t)/255,Re.toWorkingColorSpace(this,e),this}setRGB(t,e,n,i=Re.workingColorSpace){return this.r=t,this.g=e,this.b=n,Re.toWorkingColorSpace(this,i),this}setHSL(t,e,n,i=Re.workingColorSpace){if(t=oe(t,1),e=ae(e,0,1),n=ae(n,0,1),0===e)this.r=this.g=this.b=n;else{const i=n<=.5?n*(1+e):n+e-n*e,r=2*n-i;this.r=li(r,i,t+1/3),this.g=li(r,i,t),this.b=li(r,i,t-1/3)}return Re.toWorkingColorSpace(this,i),this}setStyle(t,e=Gt){function n(e){void 0!==e&&parseFloat(e)<1&&console.warn("THREE.Color: Alpha component of "+t+" will be ignored.")}let i;if(i=/^(\w+)\(([^\)]*)\)/.exec(t)){let r;const s=i[1],a=i[2];switch(s){case"rgb":case"rgba":if(r=/^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(a))return n(r[4]),this.setRGB(Math.min(255,parseInt(r[1],10))/255,Math.min(255,parseInt(r[2],10))/255,Math.min(255,parseInt(r[3],10))/255,e);if(r=/^\s*(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(a))return n(r[4]),this.setRGB(Math.min(100,parseInt(r[1],10))/100,Math.min(100,parseInt(r[2],10))/100,Math.min(100,parseInt(r[3],10))/100,e);break;case"hsl":case"hsla":if(r=/^\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\%\s*,\s*(\d*\.?\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(a))return n(r[4]),this.setHSL(parseFloat(r[1])/360,parseFloat(r[2])/100,parseFloat(r[3])/100,e);break;default:console.warn("THREE.Color: Unknown color model "+t)}}else if(i=/^\#([A-Fa-f\d]+)$/.exec(t)){const n=i[1],r=n.length;if(3===r)return this.setRGB(parseInt(n.charAt(0),16)/15,parseInt(n.charAt(1),16)/15,parseInt(n.charAt(2),16)/15,e);if(6===r)return this.setHex(parseInt(n,16),e);console.warn("THREE.Color: Invalid hex color "+t)}else if(t&&t.length>0)return this.setColorName(t,e);return this}setColorName(t,e=Gt){const n=si[t.toLowerCase()];return void 0!==n?this.setHex(n,e):console.warn("THREE.Color: Unknown color "+t),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(t){return this.r=t.r,this.g=t.g,this.b=t.b,this}copySRGBToLinear(t){return this.r=Ce(t.r),this.g=Ce(t.g),this.b=Ce(t.b),this}copyLinearToSRGB(t){return this.r=Le(t.r),this.g=Le(t.g),this.b=Le(t.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(t=Gt){return Re.fromWorkingColorSpace(hi.copy(this),t),65536*Math.round(ae(255*hi.r,0,255))+256*Math.round(ae(255*hi.g,0,255))+Math.round(ae(255*hi.b,0,255))}getHexString(t=Gt){return("000000"+this.getHex(t).toString(16)).slice(-6)}getHSL(t,e=Re.workingColorSpace){Re.fromWorkingColorSpace(hi.copy(this),e);const n=hi.r,i=hi.g,r=hi.b,s=Math.max(n,i,r),a=Math.min(n,i,r);let o,l;const c=(a+s)/2;if(a===s)o=0,l=0;else{const t=s-a;switch(l=c<=.5?t/(s+a):t/(2-s-a),s){case n:o=(i-r)/t+(i0!=t>0&&this.version++,this._alphaTest=t}onBuild(){}onBeforeRender(){}onBeforeCompile(){}customProgramCacheKey(){return this.onBeforeCompile.toString()}setValues(t){if(void 0!==t)for(const e in t){const n=t[e];if(void 0===n){console.warn(`THREE.Material: parameter '${e}' has value of undefined.`);continue}const i=this[e];void 0!==i?i&&i.isColor?i.set(n):i&&i.isVector3&&n&&n.isVector3?i.copy(n):this[e]=n:console.warn(`THREE.Material: '${e}' is not a property of THREE.${this.type}.`)}}toJSON(t){const e=void 0===t||"string"==typeof t;e&&(t={textures:{},images:{}});const n={metadata:{version:4.6,type:"Material",generator:"Material.toJSON"}};function i(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}if(n.uuid=this.uuid,n.type=this.type,""!==this.name&&(n.name=this.name),this.color&&this.color.isColor&&(n.color=this.color.getHex()),void 0!==this.roughness&&(n.roughness=this.roughness),void 0!==this.metalness&&(n.metalness=this.metalness),void 0!==this.sheen&&(n.sheen=this.sheen),this.sheenColor&&this.sheenColor.isColor&&(n.sheenColor=this.sheenColor.getHex()),void 0!==this.sheenRoughness&&(n.sheenRoughness=this.sheenRoughness),this.emissive&&this.emissive.isColor&&(n.emissive=this.emissive.getHex()),this.emissiveIntensity&&1!==this.emissiveIntensity&&(n.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(n.specular=this.specular.getHex()),void 0!==this.specularIntensity&&(n.specularIntensity=this.specularIntensity),this.specularColor&&this.specularColor.isColor&&(n.specularColor=this.specularColor.getHex()),void 0!==this.shininess&&(n.shininess=this.shininess),void 0!==this.clearcoat&&(n.clearcoat=this.clearcoat),void 0!==this.clearcoatRoughness&&(n.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(n.clearcoatMap=this.clearcoatMap.toJSON(t).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(n.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(t).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(n.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(t).uuid,n.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),void 0!==this.iridescence&&(n.iridescence=this.iridescence),void 0!==this.iridescenceIOR&&(n.iridescenceIOR=this.iridescenceIOR),void 0!==this.iridescenceThicknessRange&&(n.iridescenceThicknessRange=this.iridescenceThicknessRange),this.iridescenceMap&&this.iridescenceMap.isTexture&&(n.iridescenceMap=this.iridescenceMap.toJSON(t).uuid),this.iridescenceThicknessMap&&this.iridescenceThicknessMap.isTexture&&(n.iridescenceThicknessMap=this.iridescenceThicknessMap.toJSON(t).uuid),void 0!==this.anisotropy&&(n.anisotropy=this.anisotropy),void 0!==this.anisotropyRotation&&(n.anisotropyRotation=this.anisotropyRotation),this.anisotropyMap&&this.anisotropyMap.isTexture&&(n.anisotropyMap=this.anisotropyMap.toJSON(t).uuid),this.map&&this.map.isTexture&&(n.map=this.map.toJSON(t).uuid),this.matcap&&this.matcap.isTexture&&(n.matcap=this.matcap.toJSON(t).uuid),this.alphaMap&&this.alphaMap.isTexture&&(n.alphaMap=this.alphaMap.toJSON(t).uuid),this.lightMap&&this.lightMap.isTexture&&(n.lightMap=this.lightMap.toJSON(t).uuid,n.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(n.aoMap=this.aoMap.toJSON(t).uuid,n.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(n.bumpMap=this.bumpMap.toJSON(t).uuid,n.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(n.normalMap=this.normalMap.toJSON(t).uuid,n.normalMapType=this.normalMapType,n.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(n.displacementMap=this.displacementMap.toJSON(t).uuid,n.displacementScale=this.displacementScale,n.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(n.roughnessMap=this.roughnessMap.toJSON(t).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(n.metalnessMap=this.metalnessMap.toJSON(t).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(n.emissiveMap=this.emissiveMap.toJSON(t).uuid),this.specularMap&&this.specularMap.isTexture&&(n.specularMap=this.specularMap.toJSON(t).uuid),this.specularIntensityMap&&this.specularIntensityMap.isTexture&&(n.specularIntensityMap=this.specularIntensityMap.toJSON(t).uuid),this.specularColorMap&&this.specularColorMap.isTexture&&(n.specularColorMap=this.specularColorMap.toJSON(t).uuid),this.envMap&&this.envMap.isTexture&&(n.envMap=this.envMap.toJSON(t).uuid,void 0!==this.combine&&(n.combine=this.combine)),void 0!==this.envMapIntensity&&(n.envMapIntensity=this.envMapIntensity),void 0!==this.reflectivity&&(n.reflectivity=this.reflectivity),void 0!==this.refractionRatio&&(n.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(n.gradientMap=this.gradientMap.toJSON(t).uuid),void 0!==this.transmission&&(n.transmission=this.transmission),this.transmissionMap&&this.transmissionMap.isTexture&&(n.transmissionMap=this.transmissionMap.toJSON(t).uuid),void 0!==this.thickness&&(n.thickness=this.thickness),this.thicknessMap&&this.thicknessMap.isTexture&&(n.thicknessMap=this.thicknessMap.toJSON(t).uuid),void 0!==this.attenuationDistance&&this.attenuationDistance!==1/0&&(n.attenuationDistance=this.attenuationDistance),void 0!==this.attenuationColor&&(n.attenuationColor=this.attenuationColor.getHex()),void 0!==this.size&&(n.size=this.size),null!==this.shadowSide&&(n.shadowSide=this.shadowSide),void 0!==this.sizeAttenuation&&(n.sizeAttenuation=this.sizeAttenuation),1!==this.blending&&(n.blending=this.blending),this.side!==s&&(n.side=this.side),!0===this.vertexColors&&(n.vertexColors=!0),this.opacity<1&&(n.opacity=this.opacity),!0===this.transparent&&(n.transparent=!0),this.blendSrc!==l&&(n.blendSrc=this.blendSrc),this.blendDst!==c&&(n.blendDst=this.blendDst),this.blendEquation!==o&&(n.blendEquation=this.blendEquation),null!==this.blendSrcAlpha&&(n.blendSrcAlpha=this.blendSrcAlpha),null!==this.blendDstAlpha&&(n.blendDstAlpha=this.blendDstAlpha),null!==this.blendEquationAlpha&&(n.blendEquationAlpha=this.blendEquationAlpha),this.blendColor&&this.blendColor.isColor&&(n.blendColor=this.blendColor.getHex()),0!==this.blendAlpha&&(n.blendAlpha=this.blendAlpha),3!==this.depthFunc&&(n.depthFunc=this.depthFunc),!1===this.depthTest&&(n.depthTest=this.depthTest),!1===this.depthWrite&&(n.depthWrite=this.depthWrite),!1===this.colorWrite&&(n.colorWrite=this.colorWrite),255!==this.stencilWriteMask&&(n.stencilWriteMask=this.stencilWriteMask),519!==this.stencilFunc&&(n.stencilFunc=this.stencilFunc),0!==this.stencilRef&&(n.stencilRef=this.stencilRef),255!==this.stencilFuncMask&&(n.stencilFuncMask=this.stencilFuncMask),this.stencilFail!==Yt&&(n.stencilFail=this.stencilFail),this.stencilZFail!==Yt&&(n.stencilZFail=this.stencilZFail),this.stencilZPass!==Yt&&(n.stencilZPass=this.stencilZPass),!0===this.stencilWrite&&(n.stencilWrite=this.stencilWrite),void 0!==this.rotation&&0!==this.rotation&&(n.rotation=this.rotation),!0===this.polygonOffset&&(n.polygonOffset=!0),0!==this.polygonOffsetFactor&&(n.polygonOffsetFactor=this.polygonOffsetFactor),0!==this.polygonOffsetUnits&&(n.polygonOffsetUnits=this.polygonOffsetUnits),void 0!==this.linewidth&&1!==this.linewidth&&(n.linewidth=this.linewidth),void 0!==this.dashSize&&(n.dashSize=this.dashSize),void 0!==this.gapSize&&(n.gapSize=this.gapSize),void 0!==this.scale&&(n.scale=this.scale),!0===this.dithering&&(n.dithering=!0),this.alphaTest>0&&(n.alphaTest=this.alphaTest),!0===this.alphaHash&&(n.alphaHash=!0),!0===this.alphaToCoverage&&(n.alphaToCoverage=!0),!0===this.premultipliedAlpha&&(n.premultipliedAlpha=!0),!0===this.forceSinglePass&&(n.forceSinglePass=!0),!0===this.wireframe&&(n.wireframe=!0),this.wireframeLinewidth>1&&(n.wireframeLinewidth=this.wireframeLinewidth),"round"!==this.wireframeLinecap&&(n.wireframeLinecap=this.wireframeLinecap),"round"!==this.wireframeLinejoin&&(n.wireframeLinejoin=this.wireframeLinejoin),!0===this.flatShading&&(n.flatShading=!0),!1===this.visible&&(n.visible=!1),!1===this.toneMapped&&(n.toneMapped=!1),!1===this.fog&&(n.fog=!1),Object.keys(this.userData).length>0&&(n.userData=this.userData),e){const e=i(t.textures),r=i(t.images);e.length>0&&(n.textures=e),r.length>0&&(n.images=r)}return n}clone(){return(new this.constructor).copy(this)}copy(t){this.name=t.name,this.blending=t.blending,this.side=t.side,this.vertexColors=t.vertexColors,this.opacity=t.opacity,this.transparent=t.transparent,this.blendSrc=t.blendSrc,this.blendDst=t.blendDst,this.blendEquation=t.blendEquation,this.blendSrcAlpha=t.blendSrcAlpha,this.blendDstAlpha=t.blendDstAlpha,this.blendEquationAlpha=t.blendEquationAlpha,this.blendColor.copy(t.blendColor),this.blendAlpha=t.blendAlpha,this.depthFunc=t.depthFunc,this.depthTest=t.depthTest,this.depthWrite=t.depthWrite,this.stencilWriteMask=t.stencilWriteMask,this.stencilFunc=t.stencilFunc,this.stencilRef=t.stencilRef,this.stencilFuncMask=t.stencilFuncMask,this.stencilFail=t.stencilFail,this.stencilZFail=t.stencilZFail,this.stencilZPass=t.stencilZPass,this.stencilWrite=t.stencilWrite;const e=t.clippingPlanes;let n=null;if(null!==e){const t=e.length;n=new Array(t);for(let i=0;i!==t;++i)n[i]=e[i].clone()}return this.clippingPlanes=n,this.clipIntersection=t.clipIntersection,this.clipShadows=t.clipShadows,this.shadowSide=t.shadowSide,this.colorWrite=t.colorWrite,this.precision=t.precision,this.polygonOffset=t.polygonOffset,this.polygonOffsetFactor=t.polygonOffsetFactor,this.polygonOffsetUnits=t.polygonOffsetUnits,this.dithering=t.dithering,this.alphaTest=t.alphaTest,this.alphaHash=t.alphaHash,this.alphaToCoverage=t.alphaToCoverage,this.premultipliedAlpha=t.premultipliedAlpha,this.forceSinglePass=t.forceSinglePass,this.visible=t.visible,this.toneMapped=t.toneMapped,this.userData=JSON.parse(JSON.stringify(t.userData)),this}dispose(){this.dispatchEvent({type:"dispose"})}set needsUpdate(t){!0===t&&this.version++}}class pi extends di{constructor(t){super(),this.isMeshBasicMaterial=!0,this.type="MeshBasicMaterial",this.color=new ci(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=h,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.map=t.map,this.lightMap=t.lightMap,this.lightMapIntensity=t.lightMapIntensity,this.aoMap=t.aoMap,this.aoMapIntensity=t.aoMapIntensity,this.specularMap=t.specularMap,this.alphaMap=t.alphaMap,this.envMap=t.envMap,this.combine=t.combine,this.reflectivity=t.reflectivity,this.refractionRatio=t.refractionRatio,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.wireframeLinecap=t.wireframeLinecap,this.wireframeLinejoin=t.wireframeLinejoin,this.fog=t.fog,this}}const mi=fi();function fi(){const t=new ArrayBuffer(4),e=new Float32Array(t),n=new Uint32Array(t),i=new Uint32Array(512),r=new Uint32Array(512);for(let t=0;t<256;++t){const e=t-127;e<-27?(i[t]=0,i[256|t]=32768,r[t]=24,r[256|t]=24):e<-14?(i[t]=1024>>-e-14,i[256|t]=1024>>-e-14|32768,r[t]=-e-1,r[256|t]=-e-1):e<=15?(i[t]=e+15<<10,i[256|t]=e+15<<10|32768,r[t]=13,r[256|t]=13):e<128?(i[t]=31744,i[256|t]=64512,r[t]=24,r[256|t]=24):(i[t]=31744,i[256|t]=64512,r[t]=13,r[256|t]=13)}const s=new Uint32Array(2048),a=new Uint32Array(64),o=new Uint32Array(64);for(let t=1;t<1024;++t){let e=t<<13,n=0;for(;0==(8388608&e);)e<<=1,n-=8388608;e&=-8388609,n+=947912704,s[t]=e|n}for(let t=1024;t<2048;++t)s[t]=939524096+(t-1024<<13);for(let t=1;t<31;++t)a[t]=t<<23;a[31]=1199570944,a[32]=2147483648;for(let t=33;t<63;++t)a[t]=2147483648+(t-32<<23);a[63]=3347054592;for(let t=1;t<64;++t)32!==t&&(o[t]=1024);return{floatView:e,uint32View:n,baseTable:i,shiftTable:r,mantissaTable:s,exponentTable:a,offsetTable:o}}function gi(t){Math.abs(t)>65504&&console.warn("THREE.DataUtils.toHalfFloat(): Value out of range."),t=ae(t,-65504,65504),mi.floatView[0]=t;const e=mi.uint32View[0],n=e>>23&511;return mi.baseTable[n]+((8388607&e)>>mi.shiftTable[n])}function _i(t){const e=t>>10;return mi.uint32View[0]=mi.mantissaTable[mi.offsetTable[e]+(1023&t)]+mi.exponentTable[e],mi.floatView[0]}const vi={toHalfFloat:gi,fromHalfFloat:_i},xi=new We,yi=new me;class Mi{constructor(t,e,n=!1){if(Array.isArray(t))throw new TypeError("THREE.BufferAttribute: array should be a Typed Array.");this.isBufferAttribute=!0,this.name="",this.array=t,this.itemSize=e,this.count=void 0!==t?t.length/e:0,this.normalized=n,this.usage=Zt,this._updateRange={offset:0,count:-1},this.updateRanges=[],this.gpuType=H,this.version=0}onUploadCallback(){}set needsUpdate(t){!0===t&&this.version++}get updateRange(){return console.warn("THREE.BufferAttribute: updateRange() is deprecated and will be removed in r169. Use addUpdateRange() instead."),this._updateRange}setUsage(t){return this.usage=t,this}addUpdateRange(t,e){this.updateRanges.push({start:t,count:e})}clearUpdateRanges(){this.updateRanges.length=0}copy(t){return this.name=t.name,this.array=new t.array.constructor(t.array),this.itemSize=t.itemSize,this.count=t.count,this.normalized=t.normalized,this.usage=t.usage,this.gpuType=t.gpuType,this}copyAt(t,e,n){t*=this.itemSize,n*=e.itemSize;for(let i=0,r=this.itemSize;i0&&(t.userData=this.userData),void 0!==this.parameters){const e=this.parameters;for(const n in e)void 0!==e[n]&&(t[n]=e[n]);return t}t.data={attributes:{}};const e=this.index;null!==e&&(t.data.index={type:e.array.constructor.name,array:Array.prototype.slice.call(e.array)});const n=this.attributes;for(const e in n){const i=n[e];t.data.attributes[e]=i.toJSON(t.data)}const i={};let r=!1;for(const e in this.morphAttributes){const n=this.morphAttributes[e],s=[];for(let e=0,i=n.length;e0&&(i[e]=s,r=!0)}r&&(t.data.morphAttributes=i,t.data.morphTargetsRelative=this.morphTargetsRelative);const s=this.groups;s.length>0&&(t.data.groups=JSON.parse(JSON.stringify(s)));const a=this.boundingSphere;return null!==a&&(t.data.boundingSphere={center:a.center.toArray(),radius:a.radius}),t}clone(){return(new this.constructor).copy(this)}copy(t){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;const e={};this.name=t.name;const n=t.index;null!==n&&this.setIndex(n.clone(e));const i=t.attributes;for(const t in i){const n=i[t];this.setAttribute(t,n.clone(e))}const r=t.morphAttributes;for(const t in r){const n=[],i=r[t];for(let t=0,r=i.length;t0){const n=t[e[0]];if(void 0!==n){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let t=0,e=n.length;t(t.far-t.near)**2)return}Ui.copy(r).invert(),Di.copy(t.ray).applyMatrix4(Ui),null!==n.boundingBox&&!1===Di.intersectsBox(n.boundingBox)||this._computeIntersections(t,e,Di)}}_computeIntersections(t,e,n){let i;const r=this.geometry,s=this.material,a=r.index,o=r.attributes.position,l=r.attributes.uv,c=r.attributes.uv1,h=r.attributes.normal,u=r.groups,d=r.drawRange;if(null!==a)if(Array.isArray(s))for(let r=0,o=u.length;rn.far?null:{distance:u,point:Zi.clone(),object:t}}(t,e,n,i,Fi,Bi,zi,Yi);if(d){r&&(Vi.fromBufferAttribute(r,c),ki.fromBufferAttribute(r,h),Wi.fromBufferAttribute(r,u),d.uv=ri.getInterpolation(Yi,Fi,Bi,zi,Vi,ki,Wi,new me)),o&&(Vi.fromBufferAttribute(o,c),ki.fromBufferAttribute(o,h),Wi.fromBufferAttribute(o,u),d.uv1=ri.getInterpolation(Yi,Fi,Bi,zi,Vi,ki,Wi,new me),d.uv2=d.uv1),l&&(Xi.fromBufferAttribute(l,c),ji.fromBufferAttribute(l,h),qi.fromBufferAttribute(l,u),d.normal=ri.getInterpolation(Yi,Fi,Bi,zi,Xi,ji,qi,new We),d.normal.dot(i.direction)>0&&d.normal.multiplyScalar(-1));const t={a:c,b:h,c:u,normal:new We,materialIndex:0};ri.getNormal(Fi,Bi,zi,t.normal),d.face=t}return d}class $i extends Ii{constructor(t=1,e=1,n=1,i=1,r=1,s=1){super(),this.type="BoxGeometry",this.parameters={width:t,height:e,depth:n,widthSegments:i,heightSegments:r,depthSegments:s};const a=this;i=Math.floor(i),r=Math.floor(r),s=Math.floor(s);const o=[],l=[],c=[],h=[];let u=0,d=0;function p(t,e,n,i,r,s,p,m,f,g,_){const v=s/f,x=p/g,y=s/2,M=p/2,S=m/2,b=f+1,T=g+1;let E=0,w=0;const A=new We;for(let s=0;s0?1:-1,c.push(A.x,A.y,A.z),h.push(o/f),h.push(1-s/g),E+=1}}for(let t=0;t0&&(e.defines=this.defines),e.vertexShader=this.vertexShader,e.fragmentShader=this.fragmentShader,e.lights=this.lights,e.clipping=this.clipping;const n={};for(const t in this.extensions)!0===this.extensions[t]&&(n[t]=!0);return Object.keys(n).length>0&&(e.extensions=n),e}}class rr extends jn{constructor(){super(),this.isCamera=!0,this.type="Camera",this.matrixWorldInverse=new Mn,this.projectionMatrix=new Mn,this.projectionMatrixInverse=new Mn,this.coordinateSystem=$t}copy(t,e){return super.copy(t,e),this.matrixWorldInverse.copy(t.matrixWorldInverse),this.projectionMatrix.copy(t.projectionMatrix),this.projectionMatrixInverse.copy(t.projectionMatrixInverse),this.coordinateSystem=t.coordinateSystem,this}getWorldDirection(t){return super.getWorldDirection(t).negate()}updateMatrixWorld(t){super.updateMatrixWorld(t),this.matrixWorldInverse.copy(this.matrixWorld).invert()}updateWorldMatrix(t,e){super.updateWorldMatrix(t,e),this.matrixWorldInverse.copy(this.matrixWorld).invert()}clone(){return(new this.constructor).copy(this)}}class sr extends rr{constructor(t=50,e=1,n=.1,i=2e3){super(),this.isPerspectiveCamera=!0,this.type="PerspectiveCamera",this.fov=t,this.zoom=1,this.near=n,this.far=i,this.focus=10,this.aspect=e,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}copy(t,e){return super.copy(t,e),this.fov=t.fov,this.zoom=t.zoom,this.near=t.near,this.far=t.far,this.focus=t.focus,this.aspect=t.aspect,this.view=null===t.view?null:Object.assign({},t.view),this.filmGauge=t.filmGauge,this.filmOffset=t.filmOffset,this}setFocalLength(t){const e=.5*this.getFilmHeight()/t;this.fov=2*re*Math.atan(e),this.updateProjectionMatrix()}getFocalLength(){const t=Math.tan(.5*ie*this.fov);return.5*this.getFilmHeight()/t}getEffectiveFOV(){return 2*re*Math.atan(Math.tan(.5*ie*this.fov)/this.zoom)}getFilmWidth(){return this.filmGauge*Math.min(this.aspect,1)}getFilmHeight(){return this.filmGauge/Math.max(this.aspect,1)}setViewOffset(t,e,n,i,r,s){this.aspect=t/e,null===this.view&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=t,this.view.fullHeight=e,this.view.offsetX=n,this.view.offsetY=i,this.view.width=r,this.view.height=s,this.updateProjectionMatrix()}clearViewOffset(){null!==this.view&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const t=this.near;let e=t*Math.tan(.5*ie*this.fov)/this.zoom,n=2*e,i=this.aspect*n,r=-.5*i;const s=this.view;if(null!==this.view&&this.view.enabled){const t=s.fullWidth,a=s.fullHeight;r+=s.offsetX*i/t,e-=s.offsetY*n/a,i*=s.width/t,n*=s.height/a}const a=this.filmOffset;0!==a&&(r+=t*a/this.getFilmWidth()),this.projectionMatrix.makePerspective(r,r+i,e,e-n,t,this.far,this.coordinateSystem),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(t){const e=super.toJSON(t);return e.object.fov=this.fov,e.object.zoom=this.zoom,e.object.near=this.near,e.object.far=this.far,e.object.focus=this.focus,e.object.aspect=this.aspect,null!==this.view&&(e.object.view=Object.assign({},this.view)),e.object.filmGauge=this.filmGauge,e.object.filmOffset=this.filmOffset,e}}const ar=-90;class or extends jn{constructor(t,e,n){super(),this.type="CubeCamera",this.renderTarget=n,this.coordinateSystem=null,this.activeMipmapLevel=0;const i=new sr(ar,1,t,e);i.layers=this.layers,this.add(i);const r=new sr(ar,1,t,e);r.layers=this.layers,this.add(r);const s=new sr(ar,1,t,e);s.layers=this.layers,this.add(s);const a=new sr(ar,1,t,e);a.layers=this.layers,this.add(a);const o=new sr(ar,1,t,e);o.layers=this.layers,this.add(o);const l=new sr(ar,1,t,e);l.layers=this.layers,this.add(l)}updateCoordinateSystem(){const t=this.coordinateSystem,e=this.children.concat(),[n,i,r,s,a,o]=e;for(const t of e)this.remove(t);if(t===$t)n.up.set(0,1,0),n.lookAt(1,0,0),i.up.set(0,1,0),i.lookAt(-1,0,0),r.up.set(0,0,-1),r.lookAt(0,1,0),s.up.set(0,0,1),s.lookAt(0,-1,0),a.up.set(0,1,0),a.lookAt(0,0,1),o.up.set(0,1,0),o.lookAt(0,0,-1);else{if(t!==Qt)throw new Error("THREE.CubeCamera.updateCoordinateSystem(): Invalid coordinate system: "+t);n.up.set(0,-1,0),n.lookAt(-1,0,0),i.up.set(0,-1,0),i.lookAt(1,0,0),r.up.set(0,0,1),r.lookAt(0,1,0),s.up.set(0,0,-1),s.lookAt(0,-1,0),a.up.set(0,-1,0),a.lookAt(0,0,1),o.up.set(0,-1,0),o.lookAt(0,0,-1)}for(const t of e)this.add(t),t.updateMatrixWorld()}update(t,e){null===this.parent&&this.updateMatrixWorld();const{renderTarget:n,activeMipmapLevel:i}=this;this.coordinateSystem!==t.coordinateSystem&&(this.coordinateSystem=t.coordinateSystem,this.updateCoordinateSystem());const[r,s,a,o,l,c]=this.children,h=t.getRenderTarget(),u=t.getActiveCubeFace(),d=t.getActiveMipmapLevel(),p=t.xr.enabled;t.xr.enabled=!1;const m=n.texture.generateMipmaps;n.texture.generateMipmaps=!1,t.setRenderTarget(n,0,i),t.render(e,r),t.setRenderTarget(n,1,i),t.render(e,s),t.setRenderTarget(n,2,i),t.render(e,a),t.setRenderTarget(n,3,i),t.render(e,o),t.setRenderTarget(n,4,i),t.render(e,l),n.texture.generateMipmaps=m,t.setRenderTarget(n,5,i),t.render(e,c),t.setRenderTarget(h,u,d),t.xr.enabled=p,n.texture.needsPMREMUpdate=!0}}class lr extends Fe{constructor(t,e,n,i,r,s,a,o,l,c){super(t=void 0!==t?t:[],e=void 0!==e?e:b,n,i,r,s,a,o,l,c),this.isCubeTexture=!0,this.flipY=!1}get images(){return this.image}set images(t){this.image=t}}class cr extends Ge{constructor(t=1,e={}){super(t,t,e),this.isWebGLCubeRenderTarget=!0;const n={width:t,height:t,depth:1},i=[n,n,n,n,n,n];void 0!==e.encoding&&(be("THREE.WebGLCubeRenderTarget: option.encoding has been replaced by option.colorSpace."),e.colorSpace=e.encoding===Bt?Gt:zt),this.texture=new lr(i,e.mapping,e.wrapS,e.wrapT,e.magFilter,e.minFilter,e.format,e.type,e.anisotropy,e.colorSpace),this.texture.isRenderTargetTexture=!0,this.texture.generateMipmaps=void 0!==e.generateMipmaps&&e.generateMipmaps,this.texture.minFilter=void 0!==e.minFilter?e.minFilter:D}fromEquirectangularTexture(t,e){this.texture.type=e.type,this.texture.colorSpace=e.colorSpace,this.texture.generateMipmaps=e.generateMipmaps,this.texture.minFilter=e.minFilter,this.texture.magFilter=e.magFilter;const n={uniforms:{tEquirect:{value:null}},vertexShader:"\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\tvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\n\t\t\t\t\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvWorldDirection = transformDirection( position, modelMatrix );\n\n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\n\t\t\t\t}\n\t\t\t",fragmentShader:"\n\n\t\t\t\tuniform sampler2D tEquirect;\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\t#include \n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvec3 direction = normalize( vWorldDirection );\n\n\t\t\t\t\tvec2 sampleUV = equirectUv( direction );\n\n\t\t\t\t\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\n\t\t\t\t}\n\t\t\t"},i=new $i(5,5,5),r=new ir({name:"CubemapFromEquirect",uniforms:Qi(n.uniforms),vertexShader:n.vertexShader,fragmentShader:n.fragmentShader,side:a,blending:0});r.uniforms.tEquirect.value=e;const s=new Ji(i,r),o=e.minFilter;e.minFilter===O&&(e.minFilter=D);return new or(1,10,this).update(t,s),e.minFilter=o,s.geometry.dispose(),s.material.dispose(),this}clear(t,e,n,i){const r=t.getRenderTarget();for(let r=0;r<6;r++)t.setRenderTarget(this,r),t.clear(e,n,i);t.setRenderTarget(r)}}const hr=new We,ur=new We,dr=new fe;class pr{constructor(t=new We(1,0,0),e=0){this.isPlane=!0,this.normal=t,this.constant=e}set(t,e){return this.normal.copy(t),this.constant=e,this}setComponents(t,e,n,i){return this.normal.set(t,e,n),this.constant=i,this}setFromNormalAndCoplanarPoint(t,e){return this.normal.copy(t),this.constant=-e.dot(this.normal),this}setFromCoplanarPoints(t,e,n){const i=hr.subVectors(n,e).cross(ur.subVectors(t,e)).normalize();return this.setFromNormalAndCoplanarPoint(i,t),this}copy(t){return this.normal.copy(t.normal),this.constant=t.constant,this}normalize(){const t=1/this.normal.length();return this.normal.multiplyScalar(t),this.constant*=t,this}negate(){return this.constant*=-1,this.normal.negate(),this}distanceToPoint(t){return this.normal.dot(t)+this.constant}distanceToSphere(t){return this.distanceToPoint(t.center)-t.radius}projectPoint(t,e){return e.copy(t).addScaledVector(this.normal,-this.distanceToPoint(t))}intersectLine(t,e){const n=t.delta(hr),i=this.normal.dot(n);if(0===i)return 0===this.distanceToPoint(t.start)?e.copy(t.start):null;const r=-(t.start.dot(this.normal)+this.constant)/i;return r<0||r>1?null:e.copy(t.start).addScaledVector(n,r)}intersectsLine(t){const e=this.distanceToPoint(t.start),n=this.distanceToPoint(t.end);return e<0&&n>0||n<0&&e>0}intersectsBox(t){return t.intersectsPlane(this)}intersectsSphere(t){return t.intersectsPlane(this)}coplanarPoint(t){return t.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(t,e){const n=e||dr.getNormalMatrix(t),i=this.coplanarPoint(hr).applyMatrix4(t),r=this.normal.applyMatrix3(n).normalize();return this.constant=-i.dot(r),this}translate(t){return this.constant-=t.dot(this.normal),this}equals(t){return t.normal.equals(this.normal)&&t.constant===this.constant}clone(){return(new this.constructor).copy(this)}}const mr=new dn,fr=new We;class gr{constructor(t=new pr,e=new pr,n=new pr,i=new pr,r=new pr,s=new pr){this.planes=[t,e,n,i,r,s]}set(t,e,n,i,r,s){const a=this.planes;return a[0].copy(t),a[1].copy(e),a[2].copy(n),a[3].copy(i),a[4].copy(r),a[5].copy(s),this}copy(t){const e=this.planes;for(let n=0;n<6;n++)e[n].copy(t.planes[n]);return this}setFromProjectionMatrix(t,e=2e3){const n=this.planes,i=t.elements,r=i[0],s=i[1],a=i[2],o=i[3],l=i[4],c=i[5],h=i[6],u=i[7],d=i[8],p=i[9],m=i[10],f=i[11],g=i[12],_=i[13],v=i[14],x=i[15];if(n[0].setComponents(o-r,u-l,f-d,x-g).normalize(),n[1].setComponents(o+r,u+l,f+d,x+g).normalize(),n[2].setComponents(o+s,u+c,f+p,x+_).normalize(),n[3].setComponents(o-s,u-c,f-p,x-_).normalize(),n[4].setComponents(o-a,u-h,f-m,x-v).normalize(),e===$t)n[5].setComponents(o+a,u+h,f+m,x+v).normalize();else{if(e!==Qt)throw new Error("THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: "+e);n[5].setComponents(a,h,m,v).normalize()}return this}intersectsObject(t){if(void 0!==t.boundingSphere)null===t.boundingSphere&&t.computeBoundingSphere(),mr.copy(t.boundingSphere).applyMatrix4(t.matrixWorld);else{const e=t.geometry;null===e.boundingSphere&&e.computeBoundingSphere(),mr.copy(e.boundingSphere).applyMatrix4(t.matrixWorld)}return this.intersectsSphere(mr)}intersectsSprite(t){return mr.center.set(0,0,0),mr.radius=.7071067811865476,mr.applyMatrix4(t.matrixWorld),this.intersectsSphere(mr)}intersectsSphere(t){const e=this.planes,n=t.center,i=-t.radius;for(let t=0;t<6;t++){if(e[t].distanceToPoint(n)0?t.max.x:t.min.x,fr.y=i.normal.y>0?t.max.y:t.min.y,fr.z=i.normal.z>0?t.max.z:t.min.z,i.distanceToPoint(fr)<0)return!1}return!0}containsPoint(t){const e=this.planes;for(let n=0;n<6;n++)if(e[n].distanceToPoint(t)<0)return!1;return!0}clone(){return(new this.constructor).copy(this)}}function _r(){let t=null,e=!1,n=null,i=null;function r(e,s){n(e,s),i=t.requestAnimationFrame(r)}return{start:function(){!0!==e&&null!==n&&(i=t.requestAnimationFrame(r),e=!0)},stop:function(){t.cancelAnimationFrame(i),e=!1},setAnimationLoop:function(t){n=t},setContext:function(e){t=e}}}function vr(t,e){const n=e.isWebGL2,i=new WeakMap;return{get:function(t){return t.isInterleavedBufferAttribute&&(t=t.data),i.get(t)},remove:function(e){e.isInterleavedBufferAttribute&&(e=e.data);const n=i.get(e);n&&(t.deleteBuffer(n.buffer),i.delete(e))},update:function(e,r){if(e.isGLBufferAttribute){const t=i.get(e);return void((!t||t.version 0\n\tvec4 plane;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#pragma unroll_loop_end\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\tif ( clipped ) discard;\n\t#endif\n#endif",clipping_planes_pars_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif",clipping_planes_pars_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n#endif",clipping_planes_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvClipPosition = - mvPosition.xyz;\n#endif",color_fragment:"#if defined( USE_COLOR_ALPHA )\n\tdiffuseColor *= vColor;\n#elif defined( USE_COLOR )\n\tdiffuseColor.rgb *= vColor;\n#endif",color_pars_fragment:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR )\n\tvarying vec3 vColor;\n#endif",color_pars_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvarying vec3 vColor;\n#endif",color_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvColor = vec4( 1.0 );\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvColor = vec3( 1.0 );\n#endif\n#ifdef USE_COLOR\n\tvColor *= color;\n#endif\n#ifdef USE_INSTANCING_COLOR\n\tvColor.xyz *= instanceColor.xyz;\n#endif",common:"#define PI 3.141592653589793\n#define PI2 6.283185307179586\n#define PI_HALF 1.5707963267948966\n#define RECIPROCAL_PI 0.3183098861837907\n#define RECIPROCAL_PI2 0.15915494309189535\n#define EPSILON 1e-6\n#ifndef saturate\n#define saturate( a ) clamp( a, 0.0, 1.0 )\n#endif\n#define whiteComplement( a ) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nvec3 pow2( const in vec3 x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat max3( const in vec3 v ) { return max( max( v.x, v.y ), v.z ); }\nfloat average( const in vec3 v ) { return dot( v, vec3( 0.3333333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract( sin( sn ) * c );\n}\n#ifdef HIGH_PRECISION\n\tfloat precisionSafeLength( vec3 v ) { return length( v ); }\n#else\n\tfloat precisionSafeLength( vec3 v ) {\n\t\tfloat maxComponent = max3( abs( v ) );\n\t\treturn length( v / maxComponent ) * maxComponent;\n\t}\n#endif\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\n#ifdef USE_ALPHAHASH\n\tvarying vec3 vPosition;\n#endif\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat luminance( const in vec3 rgb ) {\n\tconst vec3 weights = vec3( 0.2126729, 0.7151522, 0.0721750 );\n\treturn dot( weights, rgb );\n}\nbool isPerspectiveMatrix( mat4 m ) {\n\treturn m[ 2 ][ 3 ] == - 1.0;\n}\nvec2 equirectUv( in vec3 dir ) {\n\tfloat u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5;\n\tfloat v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\treturn vec2( u, v );\n}\nvec3 BRDF_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 f0, const in float f90, const in float dotVH ) {\n\tfloat fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH );\n\treturn f0 * ( 1.0 - fresnel ) + ( f90 * fresnel );\n}\nfloat F_Schlick( const in float f0, const in float f90, const in float dotVH ) {\n\tfloat fresnel = exp2( ( - 5.55473 * dotVH - 6.98316 ) * dotVH );\n\treturn f0 * ( 1.0 - fresnel ) + ( f90 * fresnel );\n} // validated",cube_uv_reflection_fragment:"#ifdef ENVMAP_TYPE_CUBE_UV\n\t#define cubeUV_minMipLevel 4.0\n\t#define cubeUV_minTileSize 16.0\n\tfloat getFace( vec3 direction ) {\n\t\tvec3 absDirection = abs( direction );\n\t\tfloat face = - 1.0;\n\t\tif ( absDirection.x > absDirection.z ) {\n\t\t\tif ( absDirection.x > absDirection.y )\n\t\t\t\tface = direction.x > 0.0 ? 0.0 : 3.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t} else {\n\t\t\tif ( absDirection.z > absDirection.y )\n\t\t\t\tface = direction.z > 0.0 ? 2.0 : 5.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t}\n\t\treturn face;\n\t}\n\tvec2 getUV( vec3 direction, float face ) {\n\t\tvec2 uv;\n\t\tif ( face == 0.0 ) {\n\t\t\tuv = vec2( direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 1.0 ) {\n\t\t\tuv = vec2( - direction.x, - direction.z ) / abs( direction.y );\n\t\t} else if ( face == 2.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.y ) / abs( direction.z );\n\t\t} else if ( face == 3.0 ) {\n\t\t\tuv = vec2( - direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 4.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.z ) / abs( direction.y );\n\t\t} else {\n\t\t\tuv = vec2( direction.x, direction.y ) / abs( direction.z );\n\t\t}\n\t\treturn 0.5 * ( uv + 1.0 );\n\t}\n\tvec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) {\n\t\tfloat face = getFace( direction );\n\t\tfloat filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 );\n\t\tmipInt = max( mipInt, cubeUV_minMipLevel );\n\t\tfloat faceSize = exp2( mipInt );\n\t\thighp vec2 uv = getUV( direction, face ) * ( faceSize - 2.0 ) + 1.0;\n\t\tif ( face > 2.0 ) {\n\t\t\tuv.y += faceSize;\n\t\t\tface -= 3.0;\n\t\t}\n\t\tuv.x += face * faceSize;\n\t\tuv.x += filterInt * 3.0 * cubeUV_minTileSize;\n\t\tuv.y += 4.0 * ( exp2( CUBEUV_MAX_MIP ) - faceSize );\n\t\tuv.x *= CUBEUV_TEXEL_WIDTH;\n\t\tuv.y *= CUBEUV_TEXEL_HEIGHT;\n\t\t#ifdef texture2DGradEXT\n\t\t\treturn texture2DGradEXT( envMap, uv, vec2( 0.0 ), vec2( 0.0 ) ).rgb;\n\t\t#else\n\t\t\treturn texture2D( envMap, uv ).rgb;\n\t\t#endif\n\t}\n\t#define cubeUV_r0 1.0\n\t#define cubeUV_m0 - 2.0\n\t#define cubeUV_r1 0.8\n\t#define cubeUV_m1 - 1.0\n\t#define cubeUV_r4 0.4\n\t#define cubeUV_m4 2.0\n\t#define cubeUV_r5 0.305\n\t#define cubeUV_m5 3.0\n\t#define cubeUV_r6 0.21\n\t#define cubeUV_m6 4.0\n\tfloat roughnessToMip( float roughness ) {\n\t\tfloat mip = 0.0;\n\t\tif ( roughness >= cubeUV_r1 ) {\n\t\t\tmip = ( cubeUV_r0 - roughness ) * ( cubeUV_m1 - cubeUV_m0 ) / ( cubeUV_r0 - cubeUV_r1 ) + cubeUV_m0;\n\t\t} else if ( roughness >= cubeUV_r4 ) {\n\t\t\tmip = ( cubeUV_r1 - roughness ) * ( cubeUV_m4 - cubeUV_m1 ) / ( cubeUV_r1 - cubeUV_r4 ) + cubeUV_m1;\n\t\t} else if ( roughness >= cubeUV_r5 ) {\n\t\t\tmip = ( cubeUV_r4 - roughness ) * ( cubeUV_m5 - cubeUV_m4 ) / ( cubeUV_r4 - cubeUV_r5 ) + cubeUV_m4;\n\t\t} else if ( roughness >= cubeUV_r6 ) {\n\t\t\tmip = ( cubeUV_r5 - roughness ) * ( cubeUV_m6 - cubeUV_m5 ) / ( cubeUV_r5 - cubeUV_r6 ) + cubeUV_m5;\n\t\t} else {\n\t\t\tmip = - 2.0 * log2( 1.16 * roughness );\t\t}\n\t\treturn mip;\n\t}\n\tvec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) {\n\t\tfloat mip = clamp( roughnessToMip( roughness ), cubeUV_m0, CUBEUV_MAX_MIP );\n\t\tfloat mipF = fract( mip );\n\t\tfloat mipInt = floor( mip );\n\t\tvec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt );\n\t\tif ( mipF == 0.0 ) {\n\t\t\treturn vec4( color0, 1.0 );\n\t\t} else {\n\t\t\tvec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 );\n\t\t\treturn vec4( mix( color0, color1, mipF ), 1.0 );\n\t\t}\n\t}\n#endif",defaultnormal_vertex:"vec3 transformedNormal = objectNormal;\n#ifdef USE_TANGENT\n\tvec3 transformedTangent = objectTangent;\n#endif\n#ifdef USE_BATCHING\n\tmat3 bm = mat3( batchingMatrix );\n\ttransformedNormal /= vec3( dot( bm[ 0 ], bm[ 0 ] ), dot( bm[ 1 ], bm[ 1 ] ), dot( bm[ 2 ], bm[ 2 ] ) );\n\ttransformedNormal = bm * transformedNormal;\n\t#ifdef USE_TANGENT\n\t\ttransformedTangent = bm * transformedTangent;\n\t#endif\n#endif\n#ifdef USE_INSTANCING\n\tmat3 im = mat3( instanceMatrix );\n\ttransformedNormal /= vec3( dot( im[ 0 ], im[ 0 ] ), dot( im[ 1 ], im[ 1 ] ), dot( im[ 2 ], im[ 2 ] ) );\n\ttransformedNormal = im * transformedNormal;\n\t#ifdef USE_TANGENT\n\t\ttransformedTangent = im * transformedTangent;\n\t#endif\n#endif\ntransformedNormal = normalMatrix * transformedNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n#ifdef USE_TANGENT\n\ttransformedTangent = ( modelViewMatrix * vec4( transformedTangent, 0.0 ) ).xyz;\n\t#ifdef FLIP_SIDED\n\t\ttransformedTangent = - transformedTangent;\n\t#endif\n#endif",displacementmap_pars_vertex:"#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif",displacementmap_vertex:"#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, vDisplacementMapUv ).x * displacementScale + displacementBias );\n#endif",emissivemap_fragment:"#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv );\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif",emissivemap_pars_fragment:"#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif",colorspace_fragment:"gl_FragColor = linearToOutputTexel( gl_FragColor );",colorspace_pars_fragment:"\nconst mat3 LINEAR_SRGB_TO_LINEAR_DISPLAY_P3 = mat3(\n\tvec3( 0.8224621, 0.177538, 0.0 ),\n\tvec3( 0.0331941, 0.9668058, 0.0 ),\n\tvec3( 0.0170827, 0.0723974, 0.9105199 )\n);\nconst mat3 LINEAR_DISPLAY_P3_TO_LINEAR_SRGB = mat3(\n\tvec3( 1.2249401, - 0.2249404, 0.0 ),\n\tvec3( - 0.0420569, 1.0420571, 0.0 ),\n\tvec3( - 0.0196376, - 0.0786361, 1.0982735 )\n);\nvec4 LinearSRGBToLinearDisplayP3( in vec4 value ) {\n\treturn vec4( value.rgb * LINEAR_SRGB_TO_LINEAR_DISPLAY_P3, value.a );\n}\nvec4 LinearDisplayP3ToLinearSRGB( in vec4 value ) {\n\treturn vec4( value.rgb * LINEAR_DISPLAY_P3_TO_LINEAR_SRGB, value.a );\n}\nvec4 LinearTransferOETF( in vec4 value ) {\n\treturn value;\n}\nvec4 sRGBTransferOETF( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );\n}\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn sRGBTransferOETF( value );\n}",envmap_fragment:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvec3 cameraToFrag;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToFrag = normalize( vWorldPosition - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToFrag, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif",envmap_common_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float envMapIntensity;\n\tuniform float flipEnvMap;\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\t\n#endif",envmap_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float reflectivity;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\tvarying vec3 vWorldPosition;\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif",envmap_pars_vertex:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( LAMBERT )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\t\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif",envmap_physical_pars_fragment:"#ifdef USE_ENVMAP\n\tvec3 getIBLIrradiance( const in vec3 normal ) {\n\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, worldNormal, 1.0 );\n\t\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t\t#else\n\t\t\treturn vec3( 0.0 );\n\t\t#endif\n\t}\n\tvec3 getIBLRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness ) {\n\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\tvec3 reflectVec = reflect( - viewDir, normal );\n\t\t\treflectVec = normalize( mix( reflectVec, normal, roughness * roughness) );\n\t\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, reflectVec, roughness );\n\t\t\treturn envMapColor.rgb * envMapIntensity;\n\t\t#else\n\t\t\treturn vec3( 0.0 );\n\t\t#endif\n\t}\n\t#ifdef USE_ANISOTROPY\n\t\tvec3 getIBLAnisotropyRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in vec3 bitangent, const in float anisotropy ) {\n\t\t\t#ifdef ENVMAP_TYPE_CUBE_UV\n\t\t\t\tvec3 bentNormal = cross( bitangent, viewDir );\n\t\t\t\tbentNormal = normalize( cross( bentNormal, bitangent ) );\n\t\t\t\tbentNormal = normalize( mix( bentNormal, normal, pow2( pow2( 1.0 - anisotropy * ( 1.0 - roughness ) ) ) ) );\n\t\t\t\treturn getIBLRadiance( viewDir, bentNormal, roughness );\n\t\t\t#else\n\t\t\t\treturn vec3( 0.0 );\n\t\t\t#endif\n\t\t}\n\t#endif\n#endif",envmap_vertex:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif",fog_vertex:"#ifdef USE_FOG\n\tvFogDepth = - mvPosition.z;\n#endif",fog_pars_vertex:"#ifdef USE_FOG\n\tvarying float vFogDepth;\n#endif",fog_fragment:"#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = 1.0 - exp( - fogDensity * fogDensity * vFogDepth * vFogDepth );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, vFogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif",fog_pars_fragment:"#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float vFogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif",gradientmap_pars_fragment:"#ifdef USE_GRADIENTMAP\n\tuniform sampler2D gradientMap;\n#endif\nvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\tfloat dotNL = dot( normal, lightDirection );\n\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t#ifdef USE_GRADIENTMAP\n\t\treturn vec3( texture2D( gradientMap, coord ).r );\n\t#else\n\t\tvec2 fw = fwidth( coord ) * 0.5;\n\t\treturn mix( vec3( 0.7 ), vec3( 1.0 ), smoothstep( 0.7 - fw.x, 0.7 + fw.x, coord.x ) );\n\t#endif\n}",lightmap_fragment:"#ifdef USE_LIGHTMAP\n\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\tvec3 lightMapIrradiance = lightMapTexel.rgb * lightMapIntensity;\n\treflectedLight.indirectDiffuse += lightMapIrradiance;\n#endif",lightmap_pars_fragment:"#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif",lights_lambert_fragment:"LambertMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularStrength = specularStrength;",lights_lambert_pars_fragment:"varying vec3 vViewPosition;\nstruct LambertMaterial {\n\tvec3 diffuseColor;\n\tfloat specularStrength;\n};\nvoid RE_Direct_Lambert( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Lambert( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Lambert\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Lambert",lights_pars_begin:"uniform bool receiveShadow;\nuniform vec3 ambientLightColor;\n#if defined( USE_LIGHT_PROBES )\n\tuniform vec3 lightProbe[ 9 ];\n#endif\nvec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {\n\tfloat x = normal.x, y = normal.y, z = normal.z;\n\tvec3 result = shCoefficients[ 0 ] * 0.886227;\n\tresult += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;\n\tresult += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;\n\tresult += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;\n\tresult += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;\n\tresult += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;\n\tresult += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );\n\tresult += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;\n\tresult += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );\n\treturn result;\n}\nvec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in vec3 normal ) {\n\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\tvec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe );\n\treturn irradiance;\n}\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\treturn irradiance;\n}\nfloat getDistanceAttenuation( const in float lightDistance, const in float cutoffDistance, const in float decayExponent ) {\n\t#if defined ( LEGACY_LIGHTS )\n\t\tif ( cutoffDistance > 0.0 && decayExponent > 0.0 ) {\n\t\t\treturn pow( saturate( - lightDistance / cutoffDistance + 1.0 ), decayExponent );\n\t\t}\n\t\treturn 1.0;\n\t#else\n\t\tfloat distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 );\n\t\tif ( cutoffDistance > 0.0 ) {\n\t\t\tdistanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t\t}\n\t\treturn distanceFalloff;\n\t#endif\n}\nfloat getSpotAttenuation( const in float coneCosine, const in float penumbraCosine, const in float angleCosine ) {\n\treturn smoothstep( coneCosine, penumbraCosine, angleCosine );\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalLightInfo( const in DirectionalLight directionalLight, out IncidentLight light ) {\n\t\tlight.color = directionalLight.color;\n\t\tlight.direction = directionalLight.direction;\n\t\tlight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointLightInfo( const in PointLight pointLight, const in vec3 geometryPosition, out IncidentLight light ) {\n\t\tvec3 lVector = pointLight.position - geometryPosition;\n\t\tlight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tlight.color = pointLight.color;\n\t\tlight.color *= getDistanceAttenuation( lightDistance, pointLight.distance, pointLight.decay );\n\t\tlight.visible = ( light.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotLightInfo( const in SpotLight spotLight, const in vec3 geometryPosition, out IncidentLight light ) {\n\t\tvec3 lVector = spotLight.position - geometryPosition;\n\t\tlight.direction = normalize( lVector );\n\t\tfloat angleCos = dot( light.direction, spotLight.direction );\n\t\tfloat spotAttenuation = getSpotAttenuation( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\tif ( spotAttenuation > 0.0 ) {\n\t\t\tfloat lightDistance = length( lVector );\n\t\t\tlight.color = spotLight.color * spotAttenuation;\n\t\t\tlight.color *= getDistanceAttenuation( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tlight.visible = ( light.color != vec3( 0.0 ) );\n\t\t} else {\n\t\t\tlight.color = vec3( 0.0 );\n\t\t\tlight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in vec3 normal ) {\n\t\tfloat dotNL = dot( normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\treturn irradiance;\n\t}\n#endif",lights_toon_fragment:"ToonMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;",lights_toon_pars_fragment:"varying vec3 vViewPosition;\nstruct ToonMaterial {\n\tvec3 diffuseColor;\n};\nvoid RE_Direct_Toon( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\tvec3 irradiance = getGradientIrradiance( geometryNormal, directLight.direction ) * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Toon\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Toon",lights_phong_fragment:"BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;",lights_phong_pars_fragment:"varying vec3 vViewPosition;\nstruct BlinnPhongMaterial {\n\tvec3 diffuseColor;\n\tvec3 specularColor;\n\tfloat specularShininess;\n\tfloat specularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_BlinnPhong( directLight.direction, geometryViewDir, geometryNormal, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong",lights_physical_fragment:"PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nvec3 dxy = max( abs( dFdx( nonPerturbedNormal ) ), abs( dFdy( nonPerturbedNormal ) ) );\nfloat geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z );\nmaterial.roughness = max( roughnessFactor, 0.0525 );material.roughness += geometryRoughness;\nmaterial.roughness = min( material.roughness, 1.0 );\n#ifdef IOR\n\tmaterial.ior = ior;\n\t#ifdef USE_SPECULAR\n\t\tfloat specularIntensityFactor = specularIntensity;\n\t\tvec3 specularColorFactor = specularColor;\n\t\t#ifdef USE_SPECULAR_COLORMAP\n\t\t\tspecularColorFactor *= texture2D( specularColorMap, vSpecularColorMapUv ).rgb;\n\t\t#endif\n\t\t#ifdef USE_SPECULAR_INTENSITYMAP\n\t\t\tspecularIntensityFactor *= texture2D( specularIntensityMap, vSpecularIntensityMapUv ).a;\n\t\t#endif\n\t\tmaterial.specularF90 = mix( specularIntensityFactor, 1.0, metalnessFactor );\n\t#else\n\t\tfloat specularIntensityFactor = 1.0;\n\t\tvec3 specularColorFactor = vec3( 1.0 );\n\t\tmaterial.specularF90 = 1.0;\n\t#endif\n\tmaterial.specularColor = mix( min( pow2( ( material.ior - 1.0 ) / ( material.ior + 1.0 ) ) * specularColorFactor, vec3( 1.0 ) ) * specularIntensityFactor, diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( 0.04 ), diffuseColor.rgb, metalnessFactor );\n\tmaterial.specularF90 = 1.0;\n#endif\n#ifdef USE_CLEARCOAT\n\tmaterial.clearcoat = clearcoat;\n\tmaterial.clearcoatRoughness = clearcoatRoughness;\n\tmaterial.clearcoatF0 = vec3( 0.04 );\n\tmaterial.clearcoatF90 = 1.0;\n\t#ifdef USE_CLEARCOATMAP\n\t\tmaterial.clearcoat *= texture2D( clearcoatMap, vClearcoatMapUv ).x;\n\t#endif\n\t#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\t\tmaterial.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vClearcoatRoughnessMapUv ).y;\n\t#endif\n\tmaterial.clearcoat = saturate( material.clearcoat );\tmaterial.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 );\n\tmaterial.clearcoatRoughness += geometryRoughness;\n\tmaterial.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 );\n#endif\n#ifdef USE_IRIDESCENCE\n\tmaterial.iridescence = iridescence;\n\tmaterial.iridescenceIOR = iridescenceIOR;\n\t#ifdef USE_IRIDESCENCEMAP\n\t\tmaterial.iridescence *= texture2D( iridescenceMap, vIridescenceMapUv ).r;\n\t#endif\n\t#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\t\tmaterial.iridescenceThickness = (iridescenceThicknessMaximum - iridescenceThicknessMinimum) * texture2D( iridescenceThicknessMap, vIridescenceThicknessMapUv ).g + iridescenceThicknessMinimum;\n\t#else\n\t\tmaterial.iridescenceThickness = iridescenceThicknessMaximum;\n\t#endif\n#endif\n#ifdef USE_SHEEN\n\tmaterial.sheenColor = sheenColor;\n\t#ifdef USE_SHEEN_COLORMAP\n\t\tmaterial.sheenColor *= texture2D( sheenColorMap, vSheenColorMapUv ).rgb;\n\t#endif\n\tmaterial.sheenRoughness = clamp( sheenRoughness, 0.07, 1.0 );\n\t#ifdef USE_SHEEN_ROUGHNESSMAP\n\t\tmaterial.sheenRoughness *= texture2D( sheenRoughnessMap, vSheenRoughnessMapUv ).a;\n\t#endif\n#endif\n#ifdef USE_ANISOTROPY\n\t#ifdef USE_ANISOTROPYMAP\n\t\tmat2 anisotropyMat = mat2( anisotropyVector.x, anisotropyVector.y, - anisotropyVector.y, anisotropyVector.x );\n\t\tvec3 anisotropyPolar = texture2D( anisotropyMap, vAnisotropyMapUv ).rgb;\n\t\tvec2 anisotropyV = anisotropyMat * normalize( 2.0 * anisotropyPolar.rg - vec2( 1.0 ) ) * anisotropyPolar.b;\n\t#else\n\t\tvec2 anisotropyV = anisotropyVector;\n\t#endif\n\tmaterial.anisotropy = length( anisotropyV );\n\tif( material.anisotropy == 0.0 ) {\n\t\tanisotropyV = vec2( 1.0, 0.0 );\n\t} else {\n\t\tanisotropyV /= material.anisotropy;\n\t\tmaterial.anisotropy = saturate( material.anisotropy );\n\t}\n\tmaterial.alphaT = mix( pow2( material.roughness ), 1.0, pow2( material.anisotropy ) );\n\tmaterial.anisotropyT = tbn[ 0 ] * anisotropyV.x + tbn[ 1 ] * anisotropyV.y;\n\tmaterial.anisotropyB = tbn[ 1 ] * anisotropyV.x - tbn[ 0 ] * anisotropyV.y;\n#endif",lights_physical_pars_fragment:"struct PhysicalMaterial {\n\tvec3 diffuseColor;\n\tfloat roughness;\n\tvec3 specularColor;\n\tfloat specularF90;\n\t#ifdef USE_CLEARCOAT\n\t\tfloat clearcoat;\n\t\tfloat clearcoatRoughness;\n\t\tvec3 clearcoatF0;\n\t\tfloat clearcoatF90;\n\t#endif\n\t#ifdef USE_IRIDESCENCE\n\t\tfloat iridescence;\n\t\tfloat iridescenceIOR;\n\t\tfloat iridescenceThickness;\n\t\tvec3 iridescenceFresnel;\n\t\tvec3 iridescenceF0;\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tvec3 sheenColor;\n\t\tfloat sheenRoughness;\n\t#endif\n\t#ifdef IOR\n\t\tfloat ior;\n\t#endif\n\t#ifdef USE_TRANSMISSION\n\t\tfloat transmission;\n\t\tfloat transmissionAlpha;\n\t\tfloat thickness;\n\t\tfloat attenuationDistance;\n\t\tvec3 attenuationColor;\n\t#endif\n\t#ifdef USE_ANISOTROPY\n\t\tfloat anisotropy;\n\t\tfloat alphaT;\n\t\tvec3 anisotropyT;\n\t\tvec3 anisotropyB;\n\t#endif\n};\nvec3 clearcoatSpecularDirect = vec3( 0.0 );\nvec3 clearcoatSpecularIndirect = vec3( 0.0 );\nvec3 sheenSpecularDirect = vec3( 0.0 );\nvec3 sheenSpecularIndirect = vec3(0.0 );\nvec3 Schlick_to_F0( const in vec3 f, const in float f90, const in float dotVH ) {\n float x = clamp( 1.0 - dotVH, 0.0, 1.0 );\n float x2 = x * x;\n float x5 = clamp( x * x2 * x2, 0.0, 0.9999 );\n return ( f - vec3( f90 ) * x5 ) / ( 1.0 - x5 );\n}\nfloat V_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\n#ifdef USE_ANISOTROPY\n\tfloat V_GGX_SmithCorrelated_Anisotropic( const in float alphaT, const in float alphaB, const in float dotTV, const in float dotBV, const in float dotTL, const in float dotBL, const in float dotNV, const in float dotNL ) {\n\t\tfloat gv = dotNL * length( vec3( alphaT * dotTV, alphaB * dotBV, dotNV ) );\n\t\tfloat gl = dotNV * length( vec3( alphaT * dotTL, alphaB * dotBL, dotNL ) );\n\t\tfloat v = 0.5 / ( gv + gl );\n\t\treturn saturate(v);\n\t}\n\tfloat D_GGX_Anisotropic( const in float alphaT, const in float alphaB, const in float dotNH, const in float dotTH, const in float dotBH ) {\n\t\tfloat a2 = alphaT * alphaB;\n\t\thighp vec3 v = vec3( alphaB * dotTH, alphaT * dotBH, a2 * dotNH );\n\t\thighp float v2 = dot( v, v );\n\t\tfloat w2 = a2 / v2;\n\t\treturn RECIPROCAL_PI * a2 * pow2 ( w2 );\n\t}\n#endif\n#ifdef USE_CLEARCOAT\n\tvec3 BRDF_GGX_Clearcoat( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material) {\n\t\tvec3 f0 = material.clearcoatF0;\n\t\tfloat f90 = material.clearcoatF90;\n\t\tfloat roughness = material.clearcoatRoughness;\n\t\tfloat alpha = pow2( roughness );\n\t\tvec3 halfDir = normalize( lightDir + viewDir );\n\t\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\t\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\t\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\t\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\t\tvec3 F = F_Schlick( f0, f90, dotVH );\n\t\tfloat V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\t\tfloat D = D_GGX( alpha, dotNH );\n\t\treturn F * ( V * D );\n\t}\n#endif\nvec3 BRDF_GGX( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, const in PhysicalMaterial material ) {\n\tvec3 f0 = material.specularColor;\n\tfloat f90 = material.specularF90;\n\tfloat roughness = material.roughness;\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotVH = saturate( dot( viewDir, halfDir ) );\n\tvec3 F = F_Schlick( f0, f90, dotVH );\n\t#ifdef USE_IRIDESCENCE\n\t\tF = mix( F, material.iridescenceFresnel, material.iridescence );\n\t#endif\n\t#ifdef USE_ANISOTROPY\n\t\tfloat dotTL = dot( material.anisotropyT, lightDir );\n\t\tfloat dotTV = dot( material.anisotropyT, viewDir );\n\t\tfloat dotTH = dot( material.anisotropyT, halfDir );\n\t\tfloat dotBL = dot( material.anisotropyB, lightDir );\n\t\tfloat dotBV = dot( material.anisotropyB, viewDir );\n\t\tfloat dotBH = dot( material.anisotropyB, halfDir );\n\t\tfloat V = V_GGX_SmithCorrelated_Anisotropic( material.alphaT, alpha, dotTV, dotBV, dotTL, dotBL, dotNV, dotNL );\n\t\tfloat D = D_GGX_Anisotropic( material.alphaT, alpha, dotNH, dotTH, dotBH );\n\t#else\n\t\tfloat V = V_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\t\tfloat D = D_GGX( alpha, dotNH );\n\t#endif\n\treturn F * ( V * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\n#if defined( USE_SHEEN )\nfloat D_Charlie( float roughness, float dotNH ) {\n\tfloat alpha = pow2( roughness );\n\tfloat invAlpha = 1.0 / alpha;\n\tfloat cos2h = dotNH * dotNH;\n\tfloat sin2h = max( 1.0 - cos2h, 0.0078125 );\n\treturn ( 2.0 + invAlpha ) * pow( sin2h, invAlpha * 0.5 ) / ( 2.0 * PI );\n}\nfloat V_Neubelt( float dotNV, float dotNL ) {\n\treturn saturate( 1.0 / ( 4.0 * ( dotNL + dotNV - dotNL * dotNV ) ) );\n}\nvec3 BRDF_Sheen( const in vec3 lightDir, const in vec3 viewDir, const in vec3 normal, vec3 sheenColor, const in float sheenRoughness ) {\n\tvec3 halfDir = normalize( lightDir + viewDir );\n\tfloat dotNL = saturate( dot( normal, lightDir ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat D = D_Charlie( sheenRoughness, dotNH );\n\tfloat V = V_Neubelt( dotNV, dotNL );\n\treturn sheenColor * ( D * V );\n}\n#endif\nfloat IBLSheenBRDF( const in vec3 normal, const in vec3 viewDir, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat r2 = roughness * roughness;\n\tfloat a = roughness < 0.25 ? -339.2 * r2 + 161.4 * roughness - 25.9 : -8.48 * r2 + 14.3 * roughness - 9.95;\n\tfloat b = roughness < 0.25 ? 44.0 * r2 - 23.7 * roughness + 3.26 : 1.97 * r2 - 3.27 * roughness + 0.72;\n\tfloat DG = exp( a * dotNV + b ) + ( roughness < 0.25 ? 0.0 : 0.1 * ( roughness - 0.25 ) );\n\treturn saturate( DG * RECIPROCAL_PI );\n}\nvec2 DFGApprox( const in vec3 normal, const in vec3 viewDir, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tconst vec4 c0 = vec4( - 1, - 0.0275, - 0.572, 0.022 );\n\tconst vec4 c1 = vec4( 1, 0.0425, 1.04, - 0.04 );\n\tvec4 r = roughness * c0 + c1;\n\tfloat a004 = min( r.x * r.x, exp2( - 9.28 * dotNV ) ) * r.x + r.y;\n\tvec2 fab = vec2( - 1.04, 1.04 ) * a004 + r.zw;\n\treturn fab;\n}\nvec3 EnvironmentBRDF( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness ) {\n\tvec2 fab = DFGApprox( normal, viewDir, roughness );\n\treturn specularColor * fab.x + specularF90 * fab.y;\n}\n#ifdef USE_IRIDESCENCE\nvoid computeMultiscatteringIridescence( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float iridescence, const in vec3 iridescenceF0, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n#else\nvoid computeMultiscattering( const in vec3 normal, const in vec3 viewDir, const in vec3 specularColor, const in float specularF90, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n#endif\n\tvec2 fab = DFGApprox( normal, viewDir, roughness );\n\t#ifdef USE_IRIDESCENCE\n\t\tvec3 Fr = mix( specularColor, iridescenceF0, iridescence );\n\t#else\n\t\tvec3 Fr = specularColor;\n\t#endif\n\tvec3 FssEss = Fr * fab.x + specularF90 * fab.y;\n\tfloat Ess = fab.x + fab.y;\n\tfloat Ems = 1.0 - Ess;\n\tvec3 Favg = Fr + ( 1.0 - Fr ) * 0.047619;\tvec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg );\n\tsingleScatter += FssEss;\n\tmultiScatter += Fms * Ems;\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometryNormal;\n\t\tvec3 viewDir = geometryViewDir;\n\t\tvec3 position = geometryPosition;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.roughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos + halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos - halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos - halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos + halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3( 0, 1, 0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometryNormal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifdef USE_CLEARCOAT\n\t\tfloat dotNLcc = saturate( dot( geometryClearcoatNormal, directLight.direction ) );\n\t\tvec3 ccIrradiance = dotNLcc * directLight.color;\n\t\tclearcoatSpecularDirect += ccIrradiance * BRDF_GGX_Clearcoat( directLight.direction, geometryViewDir, geometryClearcoatNormal, material );\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tsheenSpecularDirect += irradiance * BRDF_Sheen( directLight.direction, geometryViewDir, geometryNormal, material.sheenColor, material.sheenRoughness );\n\t#endif\n\treflectedLight.directSpecular += irradiance * BRDF_GGX( directLight.direction, geometryViewDir, geometryNormal, material );\n\treflectedLight.directDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {\n\t#ifdef USE_CLEARCOAT\n\t\tclearcoatSpecularIndirect += clearcoatRadiance * EnvironmentBRDF( geometryClearcoatNormal, geometryViewDir, material.clearcoatF0, material.clearcoatF90, material.clearcoatRoughness );\n\t#endif\n\t#ifdef USE_SHEEN\n\t\tsheenSpecularIndirect += irradiance * material.sheenColor * IBLSheenBRDF( geometryNormal, geometryViewDir, material.sheenRoughness );\n\t#endif\n\tvec3 singleScattering = vec3( 0.0 );\n\tvec3 multiScattering = vec3( 0.0 );\n\tvec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;\n\t#ifdef USE_IRIDESCENCE\n\t\tcomputeMultiscatteringIridescence( geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.iridescence, material.iridescenceFresnel, material.roughness, singleScattering, multiScattering );\n\t#else\n\t\tcomputeMultiscattering( geometryNormal, geometryViewDir, material.specularColor, material.specularF90, material.roughness, singleScattering, multiScattering );\n\t#endif\n\tvec3 totalScattering = singleScattering + multiScattering;\n\tvec3 diffuse = material.diffuseColor * ( 1.0 - max( max( totalScattering.r, totalScattering.g ), totalScattering.b ) );\n\treflectedLight.indirectSpecular += radiance * singleScattering;\n\treflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;\n\treflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}",lights_fragment_begin:"\nvec3 geometryPosition = - vViewPosition;\nvec3 geometryNormal = normal;\nvec3 geometryViewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );\nvec3 geometryClearcoatNormal = vec3( 0.0 );\n#ifdef USE_CLEARCOAT\n\tgeometryClearcoatNormal = clearcoatNormal;\n#endif\n#ifdef USE_IRIDESCENCE\n\tfloat dotNVi = saturate( dot( normal, geometryViewDir ) );\n\tif ( material.iridescenceThickness == 0.0 ) {\n\t\tmaterial.iridescence = 0.0;\n\t} else {\n\t\tmaterial.iridescence = saturate( material.iridescence );\n\t}\n\tif ( material.iridescence > 0.0 ) {\n\t\tmaterial.iridescenceFresnel = evalIridescence( 1.0, material.iridescenceIOR, dotNVi, material.iridescenceThickness, material.specularColor );\n\t\tmaterial.iridescenceF0 = Schlick_to_F0( material.iridescenceFresnel, 1.0, dotNVi );\n\t}\n#endif\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointLightInfo( pointLight, geometryPosition, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS )\n\t\tpointLightShadow = pointLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\tvec4 spotColor;\n\tvec3 spotLightCoord;\n\tbool inSpotLightMap;\n\t#if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotLightInfo( spotLight, geometryPosition, directLight );\n\t\t#if ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS )\n\t\t#define SPOT_LIGHT_MAP_INDEX UNROLLED_LOOP_INDEX\n\t\t#elif ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\t#define SPOT_LIGHT_MAP_INDEX NUM_SPOT_LIGHT_MAPS\n\t\t#else\n\t\t#define SPOT_LIGHT_MAP_INDEX ( UNROLLED_LOOP_INDEX - NUM_SPOT_LIGHT_SHADOWS + NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS )\n\t\t#endif\n\t\t#if ( SPOT_LIGHT_MAP_INDEX < NUM_SPOT_LIGHT_MAPS )\n\t\t\tspotLightCoord = vSpotLightCoord[ i ].xyz / vSpotLightCoord[ i ].w;\n\t\t\tinSpotLightMap = all( lessThan( abs( spotLightCoord * 2. - 1. ), vec3( 1.0 ) ) );\n\t\t\tspotColor = texture2D( spotLightMap[ SPOT_LIGHT_MAP_INDEX ], spotLightCoord.xy );\n\t\t\tdirectLight.color = inSpotLightMap ? directLight.color * spotColor.rgb : directLight.color;\n\t\t#endif\n\t\t#undef SPOT_LIGHT_MAP_INDEX\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\tspotLightShadow = spotLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotLightCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalLightInfo( directionalLight, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tdirectLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 iblIrradiance = vec3( 0.0 );\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\t#if defined( USE_LIGHT_PROBES )\n\t\tirradiance += getLightProbeIrradiance( lightProbe, geometryNormal );\n\t#endif\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometryNormal );\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearcoatRadiance = vec3( 0.0 );\n#endif",lights_fragment_maps:"#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\t\tvec3 lightMapIrradiance = lightMapTexel.rgb * lightMapIntensity;\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tiblIrradiance += getIBLIrradiance( geometryNormal );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\t#ifdef USE_ANISOTROPY\n\t\tradiance += getIBLAnisotropyRadiance( geometryViewDir, geometryNormal, material.roughness, material.anisotropyB, material.anisotropy );\n\t#else\n\t\tradiance += getIBLRadiance( geometryViewDir, geometryNormal, material.roughness );\n\t#endif\n\t#ifdef USE_CLEARCOAT\n\t\tclearcoatRadiance += getIBLRadiance( geometryViewDir, geometryClearcoatNormal, material.clearcoatRoughness );\n\t#endif\n#endif",lights_fragment_end:"#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n#endif",logdepthbuf_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif",logdepthbuf_pars_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tuniform float logDepthBufFC;\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif",logdepthbuf_pars_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t\tvarying float vIsPerspective;\n\t#else\n\t\tuniform float logDepthBufFC;\n\t#endif\n#endif",logdepthbuf_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t\tvIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );\n\t#else\n\t\tif ( isPerspectiveMatrix( projectionMatrix ) ) {\n\t\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\t\tgl_Position.z *= gl_Position.w;\n\t\t}\n\t#endif\n#endif",map_fragment:"#ifdef USE_MAP\n\tvec4 sampledDiffuseColor = texture2D( map, vMapUv );\n\t#ifdef DECODE_VIDEO_TEXTURE\n\t\tsampledDiffuseColor = vec4( mix( pow( sampledDiffuseColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), sampledDiffuseColor.rgb * 0.0773993808, vec3( lessThanEqual( sampledDiffuseColor.rgb, vec3( 0.04045 ) ) ) ), sampledDiffuseColor.w );\n\t\n\t#endif\n\tdiffuseColor *= sampledDiffuseColor;\n#endif",map_pars_fragment:"#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif",map_particle_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\t#if defined( USE_POINTS_UV )\n\t\tvec2 uv = vUv;\n\t#else\n\t\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n\t#endif\n#endif\n#ifdef USE_MAP\n\tdiffuseColor *= texture2D( map, uv );\n#endif\n#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, uv ).g;\n#endif",map_particle_pars_fragment:"#if defined( USE_POINTS_UV )\n\tvarying vec2 vUv;\n#else\n\t#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\t\tuniform mat3 uvTransform;\n\t#endif\n#endif\n#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif",metalnessmap_fragment:"float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vMetalnessMapUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif",metalnessmap_pars_fragment:"#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif",morphcolor_vertex:"#if defined( USE_MORPHCOLORS ) && defined( MORPHTARGETS_TEXTURE )\n\tvColor *= morphTargetBaseInfluence;\n\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t#if defined( USE_COLOR_ALPHA )\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ) * morphTargetInfluences[ i ];\n\t\t#elif defined( USE_COLOR )\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) vColor += getMorph( gl_VertexID, i, 2 ).rgb * morphTargetInfluences[ i ];\n\t\t#endif\n\t}\n#endif",morphnormal_vertex:"#ifdef USE_MORPHNORMALS\n\tobjectNormal *= morphTargetBaseInfluence;\n\t#ifdef MORPHTARGETS_TEXTURE\n\t\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) objectNormal += getMorph( gl_VertexID, i, 1 ).xyz * morphTargetInfluences[ i ];\n\t\t}\n\t#else\n\t\tobjectNormal += morphNormal0 * morphTargetInfluences[ 0 ];\n\t\tobjectNormal += morphNormal1 * morphTargetInfluences[ 1 ];\n\t\tobjectNormal += morphNormal2 * morphTargetInfluences[ 2 ];\n\t\tobjectNormal += morphNormal3 * morphTargetInfluences[ 3 ];\n\t#endif\n#endif",morphtarget_pars_vertex:"#ifdef USE_MORPHTARGETS\n\tuniform float morphTargetBaseInfluence;\n\t#ifdef MORPHTARGETS_TEXTURE\n\t\tuniform float morphTargetInfluences[ MORPHTARGETS_COUNT ];\n\t\tuniform sampler2DArray morphTargetsTexture;\n\t\tuniform ivec2 morphTargetsTextureSize;\n\t\tvec4 getMorph( const in int vertexIndex, const in int morphTargetIndex, const in int offset ) {\n\t\t\tint texelIndex = vertexIndex * MORPHTARGETS_TEXTURE_STRIDE + offset;\n\t\t\tint y = texelIndex / morphTargetsTextureSize.x;\n\t\t\tint x = texelIndex - y * morphTargetsTextureSize.x;\n\t\t\tivec3 morphUV = ivec3( x, y, morphTargetIndex );\n\t\t\treturn texelFetch( morphTargetsTexture, morphUV, 0 );\n\t\t}\n\t#else\n\t\t#ifndef USE_MORPHNORMALS\n\t\t\tuniform float morphTargetInfluences[ 8 ];\n\t\t#else\n\t\t\tuniform float morphTargetInfluences[ 4 ];\n\t\t#endif\n\t#endif\n#endif",morphtarget_vertex:"#ifdef USE_MORPHTARGETS\n\ttransformed *= morphTargetBaseInfluence;\n\t#ifdef MORPHTARGETS_TEXTURE\n\t\tfor ( int i = 0; i < MORPHTARGETS_COUNT; i ++ ) {\n\t\t\tif ( morphTargetInfluences[ i ] != 0.0 ) transformed += getMorph( gl_VertexID, i, 0 ).xyz * morphTargetInfluences[ i ];\n\t\t}\n\t#else\n\t\ttransformed += morphTarget0 * morphTargetInfluences[ 0 ];\n\t\ttransformed += morphTarget1 * morphTargetInfluences[ 1 ];\n\t\ttransformed += morphTarget2 * morphTargetInfluences[ 2 ];\n\t\ttransformed += morphTarget3 * morphTargetInfluences[ 3 ];\n\t\t#ifndef USE_MORPHNORMALS\n\t\t\ttransformed += morphTarget4 * morphTargetInfluences[ 4 ];\n\t\t\ttransformed += morphTarget5 * morphTargetInfluences[ 5 ];\n\t\t\ttransformed += morphTarget6 * morphTargetInfluences[ 6 ];\n\t\t\ttransformed += morphTarget7 * morphTargetInfluences[ 7 ];\n\t\t#endif\n\t#endif\n#endif",normal_fragment_begin:"float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\n#ifdef FLAT_SHADED\n\tvec3 fdx = dFdx( vViewPosition );\n\tvec3 fdy = dFdy( vViewPosition );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal *= faceDirection;\n\t#endif\n#endif\n#if defined( USE_NORMALMAP_TANGENTSPACE ) || defined( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY )\n\t#ifdef USE_TANGENT\n\t\tmat3 tbn = mat3( normalize( vTangent ), normalize( vBitangent ), normal );\n\t#else\n\t\tmat3 tbn = getTangentFrame( - vViewPosition, normal,\n\t\t#if defined( USE_NORMALMAP )\n\t\t\tvNormalMapUv\n\t\t#elif defined( USE_CLEARCOAT_NORMALMAP )\n\t\t\tvClearcoatNormalMapUv\n\t\t#else\n\t\t\tvUv\n\t\t#endif\n\t\t);\n\t#endif\n\t#if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )\n\t\ttbn[0] *= faceDirection;\n\t\ttbn[1] *= faceDirection;\n\t#endif\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\t#ifdef USE_TANGENT\n\t\tmat3 tbn2 = mat3( normalize( vTangent ), normalize( vBitangent ), normal );\n\t#else\n\t\tmat3 tbn2 = getTangentFrame( - vViewPosition, normal, vClearcoatNormalMapUv );\n\t#endif\n\t#if defined( DOUBLE_SIDED ) && ! defined( FLAT_SHADED )\n\t\ttbn2[0] *= faceDirection;\n\t\ttbn2[1] *= faceDirection;\n\t#endif\n#endif\nvec3 nonPerturbedNormal = normal;",normal_fragment_maps:"#ifdef USE_NORMALMAP_OBJECTSPACE\n\tnormal = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;\n\t#ifdef FLIP_SIDED\n\t\tnormal = - normal;\n\t#endif\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\tnormal = normalize( normalMatrix * normal );\n#elif defined( USE_NORMALMAP_TANGENTSPACE )\n\tvec3 mapN = texture2D( normalMap, vNormalMapUv ).xyz * 2.0 - 1.0;\n\tmapN.xy *= normalScale;\n\tnormal = normalize( tbn * mapN );\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( - vViewPosition, normal, dHdxy_fwd(), faceDirection );\n#endif",normal_pars_fragment:"#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif",normal_pars_vertex:"#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif",normal_vertex:"#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif",normalmap_pars_fragment:"#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n#endif\n#ifdef USE_NORMALMAP_OBJECTSPACE\n\tuniform mat3 normalMatrix;\n#endif\n#if ! defined ( USE_TANGENT ) && ( defined ( USE_NORMALMAP_TANGENTSPACE ) || defined ( USE_CLEARCOAT_NORMALMAP ) || defined( USE_ANISOTROPY ) )\n\tmat3 getTangentFrame( vec3 eye_pos, vec3 surf_norm, vec2 uv ) {\n\t\tvec3 q0 = dFdx( eye_pos.xyz );\n\t\tvec3 q1 = dFdy( eye_pos.xyz );\n\t\tvec2 st0 = dFdx( uv.st );\n\t\tvec2 st1 = dFdy( uv.st );\n\t\tvec3 N = surf_norm;\n\t\tvec3 q1perp = cross( q1, N );\n\t\tvec3 q0perp = cross( N, q0 );\n\t\tvec3 T = q1perp * st0.x + q0perp * st1.x;\n\t\tvec3 B = q1perp * st0.y + q0perp * st1.y;\n\t\tfloat det = max( dot( T, T ), dot( B, B ) );\n\t\tfloat scale = ( det == 0.0 ) ? 0.0 : inversesqrt( det );\n\t\treturn mat3( T * scale, B * scale, N );\n\t}\n#endif",clearcoat_normal_fragment_begin:"#ifdef USE_CLEARCOAT\n\tvec3 clearcoatNormal = nonPerturbedNormal;\n#endif",clearcoat_normal_fragment_maps:"#ifdef USE_CLEARCOAT_NORMALMAP\n\tvec3 clearcoatMapN = texture2D( clearcoatNormalMap, vClearcoatNormalMapUv ).xyz * 2.0 - 1.0;\n\tclearcoatMapN.xy *= clearcoatNormalScale;\n\tclearcoatNormal = normalize( tbn2 * clearcoatMapN );\n#endif",clearcoat_pars_fragment:"#ifdef USE_CLEARCOATMAP\n\tuniform sampler2D clearcoatMap;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform sampler2D clearcoatNormalMap;\n\tuniform vec2 clearcoatNormalScale;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform sampler2D clearcoatRoughnessMap;\n#endif",iridescence_pars_fragment:"#ifdef USE_IRIDESCENCEMAP\n\tuniform sampler2D iridescenceMap;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tuniform sampler2D iridescenceThicknessMap;\n#endif",opaque_fragment:"#ifdef OPAQUE\ndiffuseColor.a = 1.0;\n#endif\n#ifdef USE_TRANSMISSION\ndiffuseColor.a *= material.transmissionAlpha;\n#endif\ngl_FragColor = vec4( outgoingLight, diffuseColor.a );",packing:"vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nvec2 packDepthToRG( in highp float v ) {\n\treturn packDepthToRGBA( v ).yx;\n}\nfloat unpackRGToDepth( const in highp vec2 v ) {\n\treturn unpackRGBAToDepth( vec4( v.xy, 0.0, 0.0 ) );\n}\nvec4 pack2HalfToRGBA( vec2 v ) {\n\tvec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ) );\n\treturn vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w );\n}\nvec2 unpackRGBATo2Half( vec4 v ) {\n\treturn vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float depth, const in float near, const in float far ) {\n\treturn depth * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( ( near + viewZ ) * far ) / ( ( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float depth, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * depth - far );\n}",premultiplied_alpha_fragment:"#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif",project_vertex:"vec4 mvPosition = vec4( transformed, 1.0 );\n#ifdef USE_BATCHING\n\tmvPosition = batchingMatrix * mvPosition;\n#endif\n#ifdef USE_INSTANCING\n\tmvPosition = instanceMatrix * mvPosition;\n#endif\nmvPosition = modelViewMatrix * mvPosition;\ngl_Position = projectionMatrix * mvPosition;",dithering_fragment:"#ifdef DITHERING\n\tgl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif",dithering_pars_fragment:"#ifdef DITHERING\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif",roughnessmap_fragment:"float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv );\n\troughnessFactor *= texelRoughness.g;\n#endif",roughnessmap_pars_fragment:"#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif",shadowmap_pars_fragment:"#if NUM_SPOT_LIGHT_COORDS > 0\n\tvarying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ];\n#endif\n#if NUM_SPOT_LIGHT_MAPS > 0\n\tuniform sampler2D spotLightMap[ NUM_SPOT_LIGHT_MAPS ];\n#endif\n#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tvec2 texture2DDistribution( sampler2D shadow, vec2 uv ) {\n\t\treturn unpackRGBATo2Half( texture2D( shadow, uv ) );\n\t}\n\tfloat VSMShadow (sampler2D shadow, vec2 uv, float compare ){\n\t\tfloat occlusion = 1.0;\n\t\tvec2 distribution = texture2DDistribution( shadow, uv );\n\t\tfloat hard_shadow = step( compare , distribution.x );\n\t\tif (hard_shadow != 1.0 ) {\n\t\t\tfloat distance = compare - distribution.x ;\n\t\t\tfloat variance = max( 0.00000, distribution.y * distribution.y );\n\t\t\tfloat softness_probability = variance / (variance + distance * distance );\t\t\tsoftness_probability = clamp( ( softness_probability - 0.3 ) / ( 0.95 - 0.3 ), 0.0, 1.0 );\t\t\tocclusion = clamp( max( hard_shadow, softness_probability ), 0.0, 1.0 );\n\t\t}\n\t\treturn occlusion;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbool inFrustum = shadowCoord.x >= 0.0 && shadowCoord.x <= 1.0 && shadowCoord.y >= 0.0 && shadowCoord.y <= 1.0;\n\t\tbool frustumTest = inFrustum && shadowCoord.z <= 1.0;\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tfloat dx2 = dx0 / 2.0;\n\t\t\tfloat dy2 = dy0 / 2.0;\n\t\t\tfloat dx3 = dx1 / 2.0;\n\t\t\tfloat dy3 = dy1 / 2.0;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 17.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx = texelSize.x;\n\t\t\tfloat dy = texelSize.y;\n\t\t\tvec2 uv = shadowCoord.xy;\n\t\t\tvec2 f = fract( uv * shadowMapSize + 0.5 );\n\t\t\tuv -= f * texelSize;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, uv, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( dx, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 0.0, dy ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + texelSize, shadowCoord.z ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, dy ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( 0.0, -dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 0.0, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( mix( texture2DCompare( shadowMap, uv + vec2( -dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t f.x ),\n\t\t\t\t\t mix( texture2DCompare( shadowMap, uv + vec2( -dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t f.x ),\n\t\t\t\t\t f.y )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_VSM )\n\t\t\tshadow = VSMShadow( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT ) || defined( SHADOWMAP_TYPE_VSM )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif",shadowmap_pars_vertex:"#if NUM_SPOT_LIGHT_COORDS > 0\n\tuniform mat4 spotLightMatrix[ NUM_SPOT_LIGHT_COORDS ];\n\tvarying vec4 vSpotLightCoord[ NUM_SPOT_LIGHT_COORDS ];\n#endif\n#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n#endif",shadowmap_vertex:"#if ( defined( USE_SHADOWMAP ) && ( NUM_DIR_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0 ) ) || ( NUM_SPOT_LIGHT_COORDS > 0 )\n\tvec3 shadowWorldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\tvec4 shadowWorldPosition;\n#endif\n#if defined( USE_SHADOWMAP )\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );\n\t\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 );\n\t\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if NUM_SPOT_LIGHT_COORDS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_COORDS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition;\n\t\t#if ( defined( USE_SHADOWMAP ) && UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\t\tshadowWorldPosition.xyz += shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias;\n\t\t#endif\n\t\tvSpotLightCoord[ i ] = spotLightMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n#endif",shadowmask_pars_fragment:"float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tdirectionalLight = directionalLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tspotLight = spotLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotLightCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tpointLight = pointLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#endif\n\treturn shadow;\n}",skinbase_vertex:"#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif",skinning_pars_vertex:"#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\tuniform highp sampler2D boneTexture;\n\tmat4 getBoneMatrix( const in float i ) {\n\t\tint size = textureSize( boneTexture, 0 ).x;\n\t\tint j = int( i ) * 4;\n\t\tint x = j % size;\n\t\tint y = j / size;\n\t\tvec4 v1 = texelFetch( boneTexture, ivec2( x, y ), 0 );\n\t\tvec4 v2 = texelFetch( boneTexture, ivec2( x + 1, y ), 0 );\n\t\tvec4 v3 = texelFetch( boneTexture, ivec2( x + 2, y ), 0 );\n\t\tvec4 v4 = texelFetch( boneTexture, ivec2( x + 3, y ), 0 );\n\t\treturn mat4( v1, v2, v3, v4 );\n\t}\n#endif",skinning_vertex:"#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif",skinnormal_vertex:"#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n\t#ifdef USE_TANGENT\n\t\tobjectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#endif\n#endif",specularmap_fragment:"float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vSpecularMapUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif",specularmap_pars_fragment:"#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif",tonemapping_fragment:"#if defined( TONE_MAPPING )\n\tgl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif",tonemapping_pars_fragment:"#ifndef saturate\n#define saturate( a ) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn saturate( toneMappingExposure * color );\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\nvec3 RRTAndODTFit( vec3 v ) {\n\tvec3 a = v * ( v + 0.0245786 ) - 0.000090537;\n\tvec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;\n\treturn a / b;\n}\nvec3 ACESFilmicToneMapping( vec3 color ) {\n\tconst mat3 ACESInputMat = mat3(\n\t\tvec3( 0.59719, 0.07600, 0.02840 ),\t\tvec3( 0.35458, 0.90834, 0.13383 ),\n\t\tvec3( 0.04823, 0.01566, 0.83777 )\n\t);\n\tconst mat3 ACESOutputMat = mat3(\n\t\tvec3( 1.60475, -0.10208, -0.00327 ),\t\tvec3( -0.53108, 1.10813, -0.07276 ),\n\t\tvec3( -0.07367, -0.00605, 1.07602 )\n\t);\n\tcolor *= toneMappingExposure / 0.6;\n\tcolor = ACESInputMat * color;\n\tcolor = RRTAndODTFit( color );\n\tcolor = ACESOutputMat * color;\n\treturn saturate( color );\n}\nconst mat3 LINEAR_REC2020_TO_LINEAR_SRGB = mat3(\n\tvec3( 1.6605, - 0.1246, - 0.0182 ),\n\tvec3( - 0.5876, 1.1329, - 0.1006 ),\n\tvec3( - 0.0728, - 0.0083, 1.1187 )\n);\nconst mat3 LINEAR_SRGB_TO_LINEAR_REC2020 = mat3(\n\tvec3( 0.6274, 0.0691, 0.0164 ),\n\tvec3( 0.3293, 0.9195, 0.0880 ),\n\tvec3( 0.0433, 0.0113, 0.8956 )\n);\nvec3 agxDefaultContrastApprox( vec3 x ) {\n\tvec3 x2 = x * x;\n\tvec3 x4 = x2 * x2;\n\treturn + 15.5 * x4 * x2\n\t\t- 40.14 * x4 * x\n\t\t+ 31.96 * x4\n\t\t- 6.868 * x2 * x\n\t\t+ 0.4298 * x2\n\t\t+ 0.1191 * x\n\t\t- 0.00232;\n}\nvec3 AgXToneMapping( vec3 color ) {\n\tconst mat3 AgXInsetMatrix = mat3(\n\t\tvec3( 0.856627153315983, 0.137318972929847, 0.11189821299995 ),\n\t\tvec3( 0.0951212405381588, 0.761241990602591, 0.0767994186031903 ),\n\t\tvec3( 0.0482516061458583, 0.101439036467562, 0.811302368396859 )\n\t);\n\tconst mat3 AgXOutsetMatrix = mat3(\n\t\tvec3( 1.1271005818144368, - 0.1413297634984383, - 0.14132976349843826 ),\n\t\tvec3( - 0.11060664309660323, 1.157823702216272, - 0.11060664309660294 ),\n\t\tvec3( - 0.016493938717834573, - 0.016493938717834257, 1.2519364065950405 )\n\t);\n\tconst float AgxMinEv = - 12.47393;\tconst float AgxMaxEv = 4.026069;\n\tcolor = LINEAR_SRGB_TO_LINEAR_REC2020 * color;\n\tcolor *= toneMappingExposure;\n\tcolor = AgXInsetMatrix * color;\n\tcolor = max( color, 1e-10 );\tcolor = log2( color );\n\tcolor = ( color - AgxMinEv ) / ( AgxMaxEv - AgxMinEv );\n\tcolor = clamp( color, 0.0, 1.0 );\n\tcolor = agxDefaultContrastApprox( color );\n\tcolor = AgXOutsetMatrix * color;\n\tcolor = pow( max( vec3( 0.0 ), color ), vec3( 2.2 ) );\n\tcolor = LINEAR_REC2020_TO_LINEAR_SRGB * color;\n\treturn color;\n}\nvec3 CustomToneMapping( vec3 color ) { return color; }",transmission_fragment:"#ifdef USE_TRANSMISSION\n\tmaterial.transmission = transmission;\n\tmaterial.transmissionAlpha = 1.0;\n\tmaterial.thickness = thickness;\n\tmaterial.attenuationDistance = attenuationDistance;\n\tmaterial.attenuationColor = attenuationColor;\n\t#ifdef USE_TRANSMISSIONMAP\n\t\tmaterial.transmission *= texture2D( transmissionMap, vTransmissionMapUv ).r;\n\t#endif\n\t#ifdef USE_THICKNESSMAP\n\t\tmaterial.thickness *= texture2D( thicknessMap, vThicknessMapUv ).g;\n\t#endif\n\tvec3 pos = vWorldPosition;\n\tvec3 v = normalize( cameraPosition - pos );\n\tvec3 n = inverseTransformDirection( normal, viewMatrix );\n\tvec4 transmitted = getIBLVolumeRefraction(\n\t\tn, v, material.roughness, material.diffuseColor, material.specularColor, material.specularF90,\n\t\tpos, modelMatrix, viewMatrix, projectionMatrix, material.ior, material.thickness,\n\t\tmaterial.attenuationColor, material.attenuationDistance );\n\tmaterial.transmissionAlpha = mix( material.transmissionAlpha, transmitted.a, material.transmission );\n\ttotalDiffuse = mix( totalDiffuse, transmitted.rgb, material.transmission );\n#endif",transmission_pars_fragment:"#ifdef USE_TRANSMISSION\n\tuniform float transmission;\n\tuniform float thickness;\n\tuniform float attenuationDistance;\n\tuniform vec3 attenuationColor;\n\t#ifdef USE_TRANSMISSIONMAP\n\t\tuniform sampler2D transmissionMap;\n\t#endif\n\t#ifdef USE_THICKNESSMAP\n\t\tuniform sampler2D thicknessMap;\n\t#endif\n\tuniform vec2 transmissionSamplerSize;\n\tuniform sampler2D transmissionSamplerMap;\n\tuniform mat4 modelMatrix;\n\tuniform mat4 projectionMatrix;\n\tvarying vec3 vWorldPosition;\n\tfloat w0( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * ( a * ( - a + 3.0 ) - 3.0 ) + 1.0 );\n\t}\n\tfloat w1( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * a * ( 3.0 * a - 6.0 ) + 4.0 );\n\t}\n\tfloat w2( float a ){\n\t\treturn ( 1.0 / 6.0 ) * ( a * ( a * ( - 3.0 * a + 3.0 ) + 3.0 ) + 1.0 );\n\t}\n\tfloat w3( float a ) {\n\t\treturn ( 1.0 / 6.0 ) * ( a * a * a );\n\t}\n\tfloat g0( float a ) {\n\t\treturn w0( a ) + w1( a );\n\t}\n\tfloat g1( float a ) {\n\t\treturn w2( a ) + w3( a );\n\t}\n\tfloat h0( float a ) {\n\t\treturn - 1.0 + w1( a ) / ( w0( a ) + w1( a ) );\n\t}\n\tfloat h1( float a ) {\n\t\treturn 1.0 + w3( a ) / ( w2( a ) + w3( a ) );\n\t}\n\tvec4 bicubic( sampler2D tex, vec2 uv, vec4 texelSize, float lod ) {\n\t\tuv = uv * texelSize.zw + 0.5;\n\t\tvec2 iuv = floor( uv );\n\t\tvec2 fuv = fract( uv );\n\t\tfloat g0x = g0( fuv.x );\n\t\tfloat g1x = g1( fuv.x );\n\t\tfloat h0x = h0( fuv.x );\n\t\tfloat h1x = h1( fuv.x );\n\t\tfloat h0y = h0( fuv.y );\n\t\tfloat h1y = h1( fuv.y );\n\t\tvec2 p0 = ( vec2( iuv.x + h0x, iuv.y + h0y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p1 = ( vec2( iuv.x + h1x, iuv.y + h0y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p2 = ( vec2( iuv.x + h0x, iuv.y + h1y ) - 0.5 ) * texelSize.xy;\n\t\tvec2 p3 = ( vec2( iuv.x + h1x, iuv.y + h1y ) - 0.5 ) * texelSize.xy;\n\t\treturn g0( fuv.y ) * ( g0x * textureLod( tex, p0, lod ) + g1x * textureLod( tex, p1, lod ) ) +\n\t\t\tg1( fuv.y ) * ( g0x * textureLod( tex, p2, lod ) + g1x * textureLod( tex, p3, lod ) );\n\t}\n\tvec4 textureBicubic( sampler2D sampler, vec2 uv, float lod ) {\n\t\tvec2 fLodSize = vec2( textureSize( sampler, int( lod ) ) );\n\t\tvec2 cLodSize = vec2( textureSize( sampler, int( lod + 1.0 ) ) );\n\t\tvec2 fLodSizeInv = 1.0 / fLodSize;\n\t\tvec2 cLodSizeInv = 1.0 / cLodSize;\n\t\tvec4 fSample = bicubic( sampler, uv, vec4( fLodSizeInv, fLodSize ), floor( lod ) );\n\t\tvec4 cSample = bicubic( sampler, uv, vec4( cLodSizeInv, cLodSize ), ceil( lod ) );\n\t\treturn mix( fSample, cSample, fract( lod ) );\n\t}\n\tvec3 getVolumeTransmissionRay( const in vec3 n, const in vec3 v, const in float thickness, const in float ior, const in mat4 modelMatrix ) {\n\t\tvec3 refractionVector = refract( - v, normalize( n ), 1.0 / ior );\n\t\tvec3 modelScale;\n\t\tmodelScale.x = length( vec3( modelMatrix[ 0 ].xyz ) );\n\t\tmodelScale.y = length( vec3( modelMatrix[ 1 ].xyz ) );\n\t\tmodelScale.z = length( vec3( modelMatrix[ 2 ].xyz ) );\n\t\treturn normalize( refractionVector ) * thickness * modelScale;\n\t}\n\tfloat applyIorToRoughness( const in float roughness, const in float ior ) {\n\t\treturn roughness * clamp( ior * 2.0 - 2.0, 0.0, 1.0 );\n\t}\n\tvec4 getTransmissionSample( const in vec2 fragCoord, const in float roughness, const in float ior ) {\n\t\tfloat lod = log2( transmissionSamplerSize.x ) * applyIorToRoughness( roughness, ior );\n\t\treturn textureBicubic( transmissionSamplerMap, fragCoord.xy, lod );\n\t}\n\tvec3 volumeAttenuation( const in float transmissionDistance, const in vec3 attenuationColor, const in float attenuationDistance ) {\n\t\tif ( isinf( attenuationDistance ) ) {\n\t\t\treturn vec3( 1.0 );\n\t\t} else {\n\t\t\tvec3 attenuationCoefficient = -log( attenuationColor ) / attenuationDistance;\n\t\t\tvec3 transmittance = exp( - attenuationCoefficient * transmissionDistance );\t\t\treturn transmittance;\n\t\t}\n\t}\n\tvec4 getIBLVolumeRefraction( const in vec3 n, const in vec3 v, const in float roughness, const in vec3 diffuseColor,\n\t\tconst in vec3 specularColor, const in float specularF90, const in vec3 position, const in mat4 modelMatrix,\n\t\tconst in mat4 viewMatrix, const in mat4 projMatrix, const in float ior, const in float thickness,\n\t\tconst in vec3 attenuationColor, const in float attenuationDistance ) {\n\t\tvec3 transmissionRay = getVolumeTransmissionRay( n, v, thickness, ior, modelMatrix );\n\t\tvec3 refractedRayExit = position + transmissionRay;\n\t\tvec4 ndcPos = projMatrix * viewMatrix * vec4( refractedRayExit, 1.0 );\n\t\tvec2 refractionCoords = ndcPos.xy / ndcPos.w;\n\t\trefractionCoords += 1.0;\n\t\trefractionCoords /= 2.0;\n\t\tvec4 transmittedLight = getTransmissionSample( refractionCoords, roughness, ior );\n\t\tvec3 transmittance = diffuseColor * volumeAttenuation( length( transmissionRay ), attenuationColor, attenuationDistance );\n\t\tvec3 attenuatedColor = transmittance * transmittedLight.rgb;\n\t\tvec3 F = EnvironmentBRDF( n, v, specularColor, specularF90, roughness );\n\t\tfloat transmittanceFactor = ( transmittance.r + transmittance.g + transmittance.b ) / 3.0;\n\t\treturn vec4( ( 1.0 - F ) * attenuatedColor, 1.0 - ( 1.0 - transmittedLight.a ) * transmittanceFactor );\n\t}\n#endif",uv_pars_fragment:"#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvarying vec2 vUv;\n#endif\n#ifdef USE_MAP\n\tvarying vec2 vMapUv;\n#endif\n#ifdef USE_ALPHAMAP\n\tvarying vec2 vAlphaMapUv;\n#endif\n#ifdef USE_LIGHTMAP\n\tvarying vec2 vLightMapUv;\n#endif\n#ifdef USE_AOMAP\n\tvarying vec2 vAoMapUv;\n#endif\n#ifdef USE_BUMPMAP\n\tvarying vec2 vBumpMapUv;\n#endif\n#ifdef USE_NORMALMAP\n\tvarying vec2 vNormalMapUv;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tvarying vec2 vEmissiveMapUv;\n#endif\n#ifdef USE_METALNESSMAP\n\tvarying vec2 vMetalnessMapUv;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tvarying vec2 vRoughnessMapUv;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tvarying vec2 vAnisotropyMapUv;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tvarying vec2 vClearcoatMapUv;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tvarying vec2 vClearcoatNormalMapUv;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tvarying vec2 vClearcoatRoughnessMapUv;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tvarying vec2 vIridescenceMapUv;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tvarying vec2 vIridescenceThicknessMapUv;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tvarying vec2 vSheenColorMapUv;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tvarying vec2 vSheenRoughnessMapUv;\n#endif\n#ifdef USE_SPECULARMAP\n\tvarying vec2 vSpecularMapUv;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tvarying vec2 vSpecularColorMapUv;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tvarying vec2 vSpecularIntensityMapUv;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tuniform mat3 transmissionMapTransform;\n\tvarying vec2 vTransmissionMapUv;\n#endif\n#ifdef USE_THICKNESSMAP\n\tuniform mat3 thicknessMapTransform;\n\tvarying vec2 vThicknessMapUv;\n#endif",uv_pars_vertex:"#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvarying vec2 vUv;\n#endif\n#ifdef USE_MAP\n\tuniform mat3 mapTransform;\n\tvarying vec2 vMapUv;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform mat3 alphaMapTransform;\n\tvarying vec2 vAlphaMapUv;\n#endif\n#ifdef USE_LIGHTMAP\n\tuniform mat3 lightMapTransform;\n\tvarying vec2 vLightMapUv;\n#endif\n#ifdef USE_AOMAP\n\tuniform mat3 aoMapTransform;\n\tvarying vec2 vAoMapUv;\n#endif\n#ifdef USE_BUMPMAP\n\tuniform mat3 bumpMapTransform;\n\tvarying vec2 vBumpMapUv;\n#endif\n#ifdef USE_NORMALMAP\n\tuniform mat3 normalMapTransform;\n\tvarying vec2 vNormalMapUv;\n#endif\n#ifdef USE_DISPLACEMENTMAP\n\tuniform mat3 displacementMapTransform;\n\tvarying vec2 vDisplacementMapUv;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tuniform mat3 emissiveMapTransform;\n\tvarying vec2 vEmissiveMapUv;\n#endif\n#ifdef USE_METALNESSMAP\n\tuniform mat3 metalnessMapTransform;\n\tvarying vec2 vMetalnessMapUv;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tuniform mat3 roughnessMapTransform;\n\tvarying vec2 vRoughnessMapUv;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tuniform mat3 anisotropyMapTransform;\n\tvarying vec2 vAnisotropyMapUv;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tuniform mat3 clearcoatMapTransform;\n\tvarying vec2 vClearcoatMapUv;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform mat3 clearcoatNormalMapTransform;\n\tvarying vec2 vClearcoatNormalMapUv;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform mat3 clearcoatRoughnessMapTransform;\n\tvarying vec2 vClearcoatRoughnessMapUv;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tuniform mat3 sheenColorMapTransform;\n\tvarying vec2 vSheenColorMapUv;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tuniform mat3 sheenRoughnessMapTransform;\n\tvarying vec2 vSheenRoughnessMapUv;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tuniform mat3 iridescenceMapTransform;\n\tvarying vec2 vIridescenceMapUv;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tuniform mat3 iridescenceThicknessMapTransform;\n\tvarying vec2 vIridescenceThicknessMapUv;\n#endif\n#ifdef USE_SPECULARMAP\n\tuniform mat3 specularMapTransform;\n\tvarying vec2 vSpecularMapUv;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tuniform mat3 specularColorMapTransform;\n\tvarying vec2 vSpecularColorMapUv;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tuniform mat3 specularIntensityMapTransform;\n\tvarying vec2 vSpecularIntensityMapUv;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tuniform mat3 transmissionMapTransform;\n\tvarying vec2 vTransmissionMapUv;\n#endif\n#ifdef USE_THICKNESSMAP\n\tuniform mat3 thicknessMapTransform;\n\tvarying vec2 vThicknessMapUv;\n#endif",uv_vertex:"#if defined( USE_UV ) || defined( USE_ANISOTROPY )\n\tvUv = vec3( uv, 1 ).xy;\n#endif\n#ifdef USE_MAP\n\tvMapUv = ( mapTransform * vec3( MAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ALPHAMAP\n\tvAlphaMapUv = ( alphaMapTransform * vec3( ALPHAMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_LIGHTMAP\n\tvLightMapUv = ( lightMapTransform * vec3( LIGHTMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_AOMAP\n\tvAoMapUv = ( aoMapTransform * vec3( AOMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_BUMPMAP\n\tvBumpMapUv = ( bumpMapTransform * vec3( BUMPMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_NORMALMAP\n\tvNormalMapUv = ( normalMapTransform * vec3( NORMALMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_DISPLACEMENTMAP\n\tvDisplacementMapUv = ( displacementMapTransform * vec3( DISPLACEMENTMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_EMISSIVEMAP\n\tvEmissiveMapUv = ( emissiveMapTransform * vec3( EMISSIVEMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_METALNESSMAP\n\tvMetalnessMapUv = ( metalnessMapTransform * vec3( METALNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ROUGHNESSMAP\n\tvRoughnessMapUv = ( roughnessMapTransform * vec3( ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_ANISOTROPYMAP\n\tvAnisotropyMapUv = ( anisotropyMapTransform * vec3( ANISOTROPYMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOATMAP\n\tvClearcoatMapUv = ( clearcoatMapTransform * vec3( CLEARCOATMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tvClearcoatNormalMapUv = ( clearcoatNormalMapTransform * vec3( CLEARCOAT_NORMALMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tvClearcoatRoughnessMapUv = ( clearcoatRoughnessMapTransform * vec3( CLEARCOAT_ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_IRIDESCENCEMAP\n\tvIridescenceMapUv = ( iridescenceMapTransform * vec3( IRIDESCENCEMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_IRIDESCENCE_THICKNESSMAP\n\tvIridescenceThicknessMapUv = ( iridescenceThicknessMapTransform * vec3( IRIDESCENCE_THICKNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SHEEN_COLORMAP\n\tvSheenColorMapUv = ( sheenColorMapTransform * vec3( SHEEN_COLORMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SHEEN_ROUGHNESSMAP\n\tvSheenRoughnessMapUv = ( sheenRoughnessMapTransform * vec3( SHEEN_ROUGHNESSMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULARMAP\n\tvSpecularMapUv = ( specularMapTransform * vec3( SPECULARMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULAR_COLORMAP\n\tvSpecularColorMapUv = ( specularColorMapTransform * vec3( SPECULAR_COLORMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_SPECULAR_INTENSITYMAP\n\tvSpecularIntensityMapUv = ( specularIntensityMapTransform * vec3( SPECULAR_INTENSITYMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_TRANSMISSIONMAP\n\tvTransmissionMapUv = ( transmissionMapTransform * vec3( TRANSMISSIONMAP_UV, 1 ) ).xy;\n#endif\n#ifdef USE_THICKNESSMAP\n\tvThicknessMapUv = ( thicknessMapTransform * vec3( THICKNESSMAP_UV, 1 ) ).xy;\n#endif",worldpos_vertex:"#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP ) || defined ( USE_TRANSMISSION ) || NUM_SPOT_LIGHT_COORDS > 0\n\tvec4 worldPosition = vec4( transformed, 1.0 );\n\t#ifdef USE_BATCHING\n\t\tworldPosition = batchingMatrix * worldPosition;\n\t#endif\n\t#ifdef USE_INSTANCING\n\t\tworldPosition = instanceMatrix * worldPosition;\n\t#endif\n\tworldPosition = modelMatrix * worldPosition;\n#endif",background_vert:"varying vec2 vUv;\nuniform mat3 uvTransform;\nvoid main() {\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\tgl_Position = vec4( position.xy, 1.0, 1.0 );\n}",background_frag:"uniform sampler2D t2D;\nuniform float backgroundIntensity;\nvarying vec2 vUv;\nvoid main() {\n\tvec4 texColor = texture2D( t2D, vUv );\n\t#ifdef DECODE_VIDEO_TEXTURE\n\t\ttexColor = vec4( mix( pow( texColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), texColor.rgb * 0.0773993808, vec3( lessThanEqual( texColor.rgb, vec3( 0.04045 ) ) ) ), texColor.w );\n\t#endif\n\ttexColor.rgb *= backgroundIntensity;\n\tgl_FragColor = texColor;\n\t#include \n\t#include \n}",backgroundCube_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}",backgroundCube_frag:"#ifdef ENVMAP_TYPE_CUBE\n\tuniform samplerCube envMap;\n#elif defined( ENVMAP_TYPE_CUBE_UV )\n\tuniform sampler2D envMap;\n#endif\nuniform float flipEnvMap;\nuniform float backgroundBlurriness;\nuniform float backgroundIntensity;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 texColor = textureCube( envMap, vec3( flipEnvMap * vWorldDirection.x, vWorldDirection.yz ) );\n\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\tvec4 texColor = textureCubeUV( envMap, vWorldDirection, backgroundBlurriness );\n\t#else\n\t\tvec4 texColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t#endif\n\ttexColor.rgb *= backgroundIntensity;\n\tgl_FragColor = texColor;\n\t#include \n\t#include \n}",cube_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}",cube_frag:"uniform samplerCube tCube;\nuniform float tFlip;\nuniform float opacity;\nvarying vec3 vWorldDirection;\nvoid main() {\n\tvec4 texColor = textureCube( tCube, vec3( tFlip * vWorldDirection.x, vWorldDirection.yz ) );\n\tgl_FragColor = texColor;\n\tgl_FragColor.a *= opacity;\n\t#include \n\t#include \n}",depth_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvHighPrecisionZW = gl_Position.zw;\n}",depth_frag:"#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tfloat fragCoordZ = 0.5 * vHighPrecisionZW[0] / vHighPrecisionZW[1] + 0.5;\n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( fragCoordZ );\n\t#endif\n}",distanceRGBA_vert:"#define DISTANCE\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvWorldPosition = worldPosition.xyz;\n}",distanceRGBA_frag:"#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main () {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include \n\t#include \n\t#include \n\t#include \n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}",equirect_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n}",equirect_frag:"uniform sampler2D tEquirect;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 direction = normalize( vWorldDirection );\n\tvec2 sampleUV = equirectUv( direction );\n\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\t#include \n\t#include \n}",linedashed_vert:"uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tvLineDistance = scale * lineDistance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",linedashed_frag:"uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#if defined ( USE_ENVMAP ) || defined ( USE_SKINNING )\n\t\t#include \n\t\t#include \n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel = texture2D( lightMap, vLightMapUv );\n\t\treflectedLight.indirectDiffuse += lightMapTexel.rgb * lightMapIntensity * RECIPROCAL_PI;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_vert:"#define LAMBERT\nvarying vec3 vViewPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_frag:"#define LAMBERT\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_vert:"#define MATCAP\nvarying vec3 vViewPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n}",meshmatcap_frag:"#define MATCAP\nuniform vec3 diffuse;\nuniform float opacity;\nuniform sampler2D matcap;\nvarying vec3 vViewPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 viewDir = normalize( vViewPosition );\n\tvec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) );\n\tvec3 y = cross( viewDir, x );\n\tvec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5;\n\t#ifdef USE_MATCAP\n\t\tvec4 matcapColor = texture2D( matcap, uv );\n\t#else\n\t\tvec4 matcapColor = vec4( vec3( mix( 0.2, 0.8, uv.y ) ), 1.0 );\n\t#endif\n\tvec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshnormal_vert:"#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvarying vec3 vViewPosition;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}",meshnormal_frag:"#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP_TANGENTSPACE )\n\tvarying vec3 vViewPosition;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n\t#ifdef OPAQUE\n\t\tgl_FragColor.a = 1.0;\n\t#endif\n}",meshphong_vert:"#define PHONG\nvarying vec3 vViewPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphong_frag:"#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_vert:"#define STANDARD\nvarying vec3 vViewPosition;\n#ifdef USE_TRANSMISSION\n\tvarying vec3 vWorldPosition;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n#ifdef USE_TRANSMISSION\n\tvWorldPosition = worldPosition.xyz;\n#endif\n}",meshphysical_frag:"#define STANDARD\n#ifdef PHYSICAL\n\t#define IOR\n\t#define USE_SPECULAR\n#endif\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifdef IOR\n\tuniform float ior;\n#endif\n#ifdef USE_SPECULAR\n\tuniform float specularIntensity;\n\tuniform vec3 specularColor;\n\t#ifdef USE_SPECULAR_COLORMAP\n\t\tuniform sampler2D specularColorMap;\n\t#endif\n\t#ifdef USE_SPECULAR_INTENSITYMAP\n\t\tuniform sampler2D specularIntensityMap;\n\t#endif\n#endif\n#ifdef USE_CLEARCOAT\n\tuniform float clearcoat;\n\tuniform float clearcoatRoughness;\n#endif\n#ifdef USE_IRIDESCENCE\n\tuniform float iridescence;\n\tuniform float iridescenceIOR;\n\tuniform float iridescenceThicknessMinimum;\n\tuniform float iridescenceThicknessMaximum;\n#endif\n#ifdef USE_SHEEN\n\tuniform vec3 sheenColor;\n\tuniform float sheenRoughness;\n\t#ifdef USE_SHEEN_COLORMAP\n\t\tuniform sampler2D sheenColorMap;\n\t#endif\n\t#ifdef USE_SHEEN_ROUGHNESSMAP\n\t\tuniform sampler2D sheenRoughnessMap;\n\t#endif\n#endif\n#ifdef USE_ANISOTROPY\n\tuniform vec2 anisotropyVector;\n\t#ifdef USE_ANISOTROPYMAP\n\t\tuniform sampler2D anisotropyMap;\n\t#endif\n#endif\nvarying vec3 vViewPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 totalDiffuse = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;\n\tvec3 totalSpecular = reflectedLight.directSpecular + reflectedLight.indirectSpecular;\n\t#include \n\tvec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;\n\t#ifdef USE_SHEEN\n\t\tfloat sheenEnergyComp = 1.0 - 0.157 * max3( material.sheenColor );\n\t\toutgoingLight = outgoingLight * sheenEnergyComp + sheenSpecularDirect + sheenSpecularIndirect;\n\t#endif\n\t#ifdef USE_CLEARCOAT\n\t\tfloat dotNVcc = saturate( dot( geometryClearcoatNormal, geometryViewDir ) );\n\t\tvec3 Fcc = F_Schlick( material.clearcoatF0, material.clearcoatF90, dotNVcc );\n\t\toutgoingLight = outgoingLight * ( 1.0 - material.clearcoat * Fcc ) + ( clearcoatSpecularDirect + clearcoatSpecularIndirect ) * material.clearcoat;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshtoon_vert:"#define TOON\nvarying vec3 vViewPosition;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",meshtoon_frag:"#define TOON\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",points_vert:"uniform float size;\nuniform float scale;\n#include \n#include \n#include \n#include \n#include \n#include \n#ifdef USE_POINTS_UV\n\tvarying vec2 vUv;\n\tuniform mat3 uvTransform;\n#endif\nvoid main() {\n\t#ifdef USE_POINTS_UV\n\t\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_PointSize = size;\n\t#ifdef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n}",points_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",shadow_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",shadow_frag:"uniform vec3 color;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include \n\t#include \n\t#include \n}",sprite_vert:"uniform float rotation;\nuniform vec2 center;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );\n\tvec2 scale;\n\tscale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );\n\tscale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );\n\t#ifndef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) scale *= - mvPosition.z;\n\t#endif\n\tvec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;\n\tvec2 rotatedPosition;\n\trotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;\n\trotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;\n\tmvPosition.xy += rotatedPosition;\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include \n\t#include \n\t#include \n}",sprite_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\t#include \n\t#include \n\t#include \n\t#include \n}"},Mr={common:{diffuse:{value:new ci(16777215)},opacity:{value:1},map:{value:null},mapTransform:{value:new fe},alphaMap:{value:null},alphaMapTransform:{value:new fe},alphaTest:{value:0}},specularmap:{specularMap:{value:null},specularMapTransform:{value:new fe}},envmap:{envMap:{value:null},flipEnvMap:{value:-1},reflectivity:{value:1},ior:{value:1.5},refractionRatio:{value:.98}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1},aoMapTransform:{value:new fe}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1},lightMapTransform:{value:new fe}},bumpmap:{bumpMap:{value:null},bumpMapTransform:{value:new fe},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalMapTransform:{value:new fe},normalScale:{value:new me(1,1)}},displacementmap:{displacementMap:{value:null},displacementMapTransform:{value:new fe},displacementScale:{value:1},displacementBias:{value:0}},emissivemap:{emissiveMap:{value:null},emissiveMapTransform:{value:new fe}},metalnessmap:{metalnessMap:{value:null},metalnessMapTransform:{value:new fe}},roughnessmap:{roughnessMap:{value:null},roughnessMapTransform:{value:new fe}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new ci(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMap:{value:[]},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotLightMap:{value:[]},spotShadowMap:{value:[]},spotLightMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMap:{value:[]},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null}},points:{diffuse:{value:new ci(16777215)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},alphaMapTransform:{value:new fe},alphaTest:{value:0},uvTransform:{value:new fe}},sprite:{diffuse:{value:new ci(16777215)},opacity:{value:1},center:{value:new me(.5,.5)},rotation:{value:0},map:{value:null},mapTransform:{value:new fe},alphaMap:{value:null},alphaMapTransform:{value:new fe},alphaTest:{value:0}}},Sr={basic:{uniforms:tr([Mr.common,Mr.specularmap,Mr.envmap,Mr.aomap,Mr.lightmap,Mr.fog]),vertexShader:yr.meshbasic_vert,fragmentShader:yr.meshbasic_frag},lambert:{uniforms:tr([Mr.common,Mr.specularmap,Mr.envmap,Mr.aomap,Mr.lightmap,Mr.emissivemap,Mr.bumpmap,Mr.normalmap,Mr.displacementmap,Mr.fog,Mr.lights,{emissive:{value:new ci(0)}}]),vertexShader:yr.meshlambert_vert,fragmentShader:yr.meshlambert_frag},phong:{uniforms:tr([Mr.common,Mr.specularmap,Mr.envmap,Mr.aomap,Mr.lightmap,Mr.emissivemap,Mr.bumpmap,Mr.normalmap,Mr.displacementmap,Mr.fog,Mr.lights,{emissive:{value:new ci(0)},specular:{value:new ci(1118481)},shininess:{value:30}}]),vertexShader:yr.meshphong_vert,fragmentShader:yr.meshphong_frag},standard:{uniforms:tr([Mr.common,Mr.envmap,Mr.aomap,Mr.lightmap,Mr.emissivemap,Mr.bumpmap,Mr.normalmap,Mr.displacementmap,Mr.roughnessmap,Mr.metalnessmap,Mr.fog,Mr.lights,{emissive:{value:new ci(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:yr.meshphysical_vert,fragmentShader:yr.meshphysical_frag},toon:{uniforms:tr([Mr.common,Mr.aomap,Mr.lightmap,Mr.emissivemap,Mr.bumpmap,Mr.normalmap,Mr.displacementmap,Mr.gradientmap,Mr.fog,Mr.lights,{emissive:{value:new ci(0)}}]),vertexShader:yr.meshtoon_vert,fragmentShader:yr.meshtoon_frag},matcap:{uniforms:tr([Mr.common,Mr.bumpmap,Mr.normalmap,Mr.displacementmap,Mr.fog,{matcap:{value:null}}]),vertexShader:yr.meshmatcap_vert,fragmentShader:yr.meshmatcap_frag},points:{uniforms:tr([Mr.points,Mr.fog]),vertexShader:yr.points_vert,fragmentShader:yr.points_frag},dashed:{uniforms:tr([Mr.common,Mr.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:yr.linedashed_vert,fragmentShader:yr.linedashed_frag},depth:{uniforms:tr([Mr.common,Mr.displacementmap]),vertexShader:yr.depth_vert,fragmentShader:yr.depth_frag},normal:{uniforms:tr([Mr.common,Mr.bumpmap,Mr.normalmap,Mr.displacementmap,{opacity:{value:1}}]),vertexShader:yr.meshnormal_vert,fragmentShader:yr.meshnormal_frag},sprite:{uniforms:tr([Mr.sprite,Mr.fog]),vertexShader:yr.sprite_vert,fragmentShader:yr.sprite_frag},background:{uniforms:{uvTransform:{value:new fe},t2D:{value:null},backgroundIntensity:{value:1}},vertexShader:yr.background_vert,fragmentShader:yr.background_frag},backgroundCube:{uniforms:{envMap:{value:null},flipEnvMap:{value:-1},backgroundBlurriness:{value:0},backgroundIntensity:{value:1}},vertexShader:yr.backgroundCube_vert,fragmentShader:yr.backgroundCube_frag},cube:{uniforms:{tCube:{value:null},tFlip:{value:-1},opacity:{value:1}},vertexShader:yr.cube_vert,fragmentShader:yr.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:yr.equirect_vert,fragmentShader:yr.equirect_frag},distanceRGBA:{uniforms:tr([Mr.common,Mr.displacementmap,{referencePosition:{value:new We},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:yr.distanceRGBA_vert,fragmentShader:yr.distanceRGBA_frag},shadow:{uniforms:tr([Mr.lights,Mr.fog,{color:{value:new ci(0)},opacity:{value:1}}]),vertexShader:yr.shadow_vert,fragmentShader:yr.shadow_frag}};Sr.physical={uniforms:tr([Sr.standard.uniforms,{clearcoat:{value:0},clearcoatMap:{value:null},clearcoatMapTransform:{value:new fe},clearcoatNormalMap:{value:null},clearcoatNormalMapTransform:{value:new fe},clearcoatNormalScale:{value:new me(1,1)},clearcoatRoughness:{value:0},clearcoatRoughnessMap:{value:null},clearcoatRoughnessMapTransform:{value:new fe},iridescence:{value:0},iridescenceMap:{value:null},iridescenceMapTransform:{value:new fe},iridescenceIOR:{value:1.3},iridescenceThicknessMinimum:{value:100},iridescenceThicknessMaximum:{value:400},iridescenceThicknessMap:{value:null},iridescenceThicknessMapTransform:{value:new fe},sheen:{value:0},sheenColor:{value:new ci(0)},sheenColorMap:{value:null},sheenColorMapTransform:{value:new fe},sheenRoughness:{value:1},sheenRoughnessMap:{value:null},sheenRoughnessMapTransform:{value:new fe},transmission:{value:0},transmissionMap:{value:null},transmissionMapTransform:{value:new fe},transmissionSamplerSize:{value:new me},transmissionSamplerMap:{value:null},thickness:{value:0},thicknessMap:{value:null},thicknessMapTransform:{value:new fe},attenuationDistance:{value:0},attenuationColor:{value:new ci(0)},specularColor:{value:new ci(1,1,1)},specularColorMap:{value:null},specularColorMapTransform:{value:new fe},specularIntensity:{value:1},specularIntensityMap:{value:null},specularIntensityMapTransform:{value:new fe},anisotropyVector:{value:new me},anisotropyMap:{value:null},anisotropyMapTransform:{value:new fe}}]),vertexShader:yr.meshphysical_vert,fragmentShader:yr.meshphysical_frag};const br={r:0,b:0,g:0};function Tr(t,e,n,i,r,o,l){const c=new ci(0);let h,u,d=!0===o?0:1,p=null,m=0,f=null;function g(e,n){e.getRGB(br,er(t)),i.buffers.color.setClear(br.r,br.g,br.b,n,l)}return{getClearColor:function(){return c},setClearColor:function(t,e=1){c.set(t),d=e,g(c,d)},getClearAlpha:function(){return d},setClearAlpha:function(t){d=t,g(c,d)},render:function(o,_){let v=!1,x=!0===_.isScene?_.background:null;if(x&&x.isTexture){x=(_.backgroundBlurriness>0?n:e).get(x)}null===x?g(c,d):x&&x.isColor&&(g(x,1),v=!0);const y=t.xr.getEnvironmentBlendMode();"additive"===y?i.buffers.color.setClear(0,0,0,1,l):"alpha-blend"===y&&i.buffers.color.setClear(0,0,0,0,l),(t.autoClear||v)&&t.clear(t.autoClearColor,t.autoClearDepth,t.autoClearStencil),x&&(x.isCubeTexture||x.mapping===A)?(void 0===u&&(u=new Ji(new $i(1,1,1),new ir({name:"BackgroundCubeMaterial",uniforms:Qi(Sr.backgroundCube.uniforms),vertexShader:Sr.backgroundCube.vertexShader,fragmentShader:Sr.backgroundCube.fragmentShader,side:a,depthTest:!1,depthWrite:!1,fog:!1})),u.geometry.deleteAttribute("normal"),u.geometry.deleteAttribute("uv"),u.onBeforeRender=function(t,e,n){this.matrixWorld.copyPosition(n.matrixWorld)},Object.defineProperty(u.material,"envMap",{get:function(){return this.uniforms.envMap.value}}),r.update(u)),u.material.uniforms.envMap.value=x,u.material.uniforms.flipEnvMap.value=x.isCubeTexture&&!1===x.isRenderTargetTexture?-1:1,u.material.uniforms.backgroundBlurriness.value=_.backgroundBlurriness,u.material.uniforms.backgroundIntensity.value=_.backgroundIntensity,u.material.toneMapped=Re.getTransfer(x.colorSpace)!==Xt,p===x&&m===x.version&&f===t.toneMapping||(u.material.needsUpdate=!0,p=x,m=x.version,f=t.toneMapping),u.layers.enableAll(),o.unshift(u,u.geometry,u.material,0,0,null)):x&&x.isTexture&&(void 0===h&&(h=new Ji(new xr(2,2),new ir({name:"BackgroundMaterial",uniforms:Qi(Sr.background.uniforms),vertexShader:Sr.background.vertexShader,fragmentShader:Sr.background.fragmentShader,side:s,depthTest:!1,depthWrite:!1,fog:!1})),h.geometry.deleteAttribute("normal"),Object.defineProperty(h.material,"map",{get:function(){return this.uniforms.t2D.value}}),r.update(h)),h.material.uniforms.t2D.value=x,h.material.uniforms.backgroundIntensity.value=_.backgroundIntensity,h.material.toneMapped=Re.getTransfer(x.colorSpace)!==Xt,!0===x.matrixAutoUpdate&&x.updateMatrix(),h.material.uniforms.uvTransform.value.copy(x.matrix),p===x&&m===x.version&&f===t.toneMapping||(h.material.needsUpdate=!0,p=x,m=x.version,f=t.toneMapping),h.layers.enableAll(),o.unshift(h,h.geometry,h.material,0,0,null))}}}function Er(t,e,n,i){const r=t.getParameter(t.MAX_VERTEX_ATTRIBS),s=i.isWebGL2?null:e.get("OES_vertex_array_object"),a=i.isWebGL2||null!==s,o={},l=p(null);let c=l,h=!1;function u(e){return i.isWebGL2?t.bindVertexArray(e):s.bindVertexArrayOES(e)}function d(e){return i.isWebGL2?t.deleteVertexArray(e):s.deleteVertexArrayOES(e)}function p(t){const e=[],n=[],i=[];for(let t=0;t=0){const n=r[e];let i=s[e];if(void 0===i&&("instanceMatrix"===e&&t.instanceMatrix&&(i=t.instanceMatrix),"instanceColor"===e&&t.instanceColor&&(i=t.instanceColor)),void 0===n)return!0;if(n.attribute!==i)return!0;if(i&&n.data!==i.data)return!0;a++}}return c.attributesNum!==a||c.index!==i}(r,x,d,y),M&&function(t,e,n,i){const r={},s=e.attributes;let a=0;const o=n.getAttributes();for(const e in o){if(o[e].location>=0){let n=s[e];void 0===n&&("instanceMatrix"===e&&t.instanceMatrix&&(n=t.instanceMatrix),"instanceColor"===e&&t.instanceColor&&(n=t.instanceColor));const i={};i.attribute=n,n&&n.data&&(i.data=n.data),r[e]=i,a++}}c.attributes=r,c.attributesNum=a,c.index=i}(r,x,d,y)}else{const t=!0===l.wireframe;c.geometry===x.id&&c.program===d.id&&c.wireframe===t||(c.geometry=x.id,c.program=d.id,c.wireframe=t,M=!0)}null!==y&&n.update(y,t.ELEMENT_ARRAY_BUFFER),(M||h)&&(h=!1,function(r,s,a,o){if(!1===i.isWebGL2&&(r.isInstancedMesh||o.isInstancedBufferGeometry)&&null===e.get("ANGLE_instanced_arrays"))return;m();const l=o.attributes,c=a.getAttributes(),h=s.defaultAttributeValues;for(const e in c){const s=c[e];if(s.location>=0){let a=l[e];if(void 0===a&&("instanceMatrix"===e&&r.instanceMatrix&&(a=r.instanceMatrix),"instanceColor"===e&&r.instanceColor&&(a=r.instanceColor)),void 0!==a){const e=a.normalized,l=a.itemSize,c=n.get(a);if(void 0===c)continue;const h=c.buffer,u=c.type,d=c.bytesPerElement,p=!0===i.isWebGL2&&(u===t.INT||u===t.UNSIGNED_INT||a.gpuType===z);if(a.isInterleavedBufferAttribute){const n=a.data,i=n.stride,c=a.offset;if(n.isInstancedInterleavedBuffer){for(let t=0;t0&&t.getShaderPrecisionFormat(t.FRAGMENT_SHADER,t.HIGH_FLOAT).precision>0)return"highp";e="mediump"}return"mediump"===e&&t.getShaderPrecisionFormat(t.VERTEX_SHADER,t.MEDIUM_FLOAT).precision>0&&t.getShaderPrecisionFormat(t.FRAGMENT_SHADER,t.MEDIUM_FLOAT).precision>0?"mediump":"lowp"}const s="undefined"!=typeof WebGL2RenderingContext&&"WebGL2RenderingContext"===t.constructor.name;let a=void 0!==n.precision?n.precision:"highp";const o=r(a);o!==a&&(console.warn("THREE.WebGLRenderer:",a,"not supported, using",o,"instead."),a=o);const l=s||e.has("WEBGL_draw_buffers"),c=!0===n.logarithmicDepthBuffer,h=t.getParameter(t.MAX_TEXTURE_IMAGE_UNITS),u=t.getParameter(t.MAX_VERTEX_TEXTURE_IMAGE_UNITS),d=t.getParameter(t.MAX_TEXTURE_SIZE),p=t.getParameter(t.MAX_CUBE_MAP_TEXTURE_SIZE),m=t.getParameter(t.MAX_VERTEX_ATTRIBS),f=t.getParameter(t.MAX_VERTEX_UNIFORM_VECTORS),g=t.getParameter(t.MAX_VARYING_VECTORS),_=t.getParameter(t.MAX_FRAGMENT_UNIFORM_VECTORS),v=u>0,x=s||e.has("OES_texture_float");return{isWebGL2:s,drawBuffers:l,getMaxAnisotropy:function(){if(void 0!==i)return i;if(!0===e.has("EXT_texture_filter_anisotropic")){const n=e.get("EXT_texture_filter_anisotropic");i=t.getParameter(n.MAX_TEXTURE_MAX_ANISOTROPY_EXT)}else i=0;return i},getMaxPrecision:r,precision:a,logarithmicDepthBuffer:c,maxTextures:h,maxVertexTextures:u,maxTextureSize:d,maxCubemapSize:p,maxAttributes:m,maxVertexUniforms:f,maxVaryings:g,maxFragmentUniforms:_,vertexTextures:v,floatFragmentTextures:x,floatVertexTextures:v&&x,maxSamples:s?t.getParameter(t.MAX_SAMPLES):0}}function Rr(t){const e=this;let n=null,i=0,r=!1,s=!1;const a=new pr,o=new fe,l={value:null,needsUpdate:!1};function c(t,n,i,r){const s=null!==t?t.length:0;let c=null;if(0!==s){if(c=l.value,!0!==r||null===c){const e=i+4*s,r=n.matrixWorldInverse;o.getNormalMatrix(r),(null===c||c.length0);e.numPlanes=i,e.numIntersection=0}();else{const t=s?0:i,e=4*t;let r=m.clippingState||null;l.value=r,r=c(u,o,e,h);for(let t=0;t!==e;++t)r[t]=n[t];m.clippingState=r,this.numIntersection=d?this.numPlanes:0,this.numPlanes+=t}}}function Cr(t){let e=new WeakMap;function n(t,e){return e===E?t.mapping=b:e===w&&(t.mapping=T),t}function i(t){const n=t.target;n.removeEventListener("dispose",i);const r=e.get(n);void 0!==r&&(e.delete(n),r.dispose())}return{get:function(r){if(r&&r.isTexture){const s=r.mapping;if(s===E||s===w){if(e.has(r)){return n(e.get(r).texture,r.mapping)}{const s=r.image;if(s&&s.height>0){const a=new cr(s.height/2);return a.fromEquirectangularTexture(t,r),e.set(r,a),r.addEventListener("dispose",i),n(a.texture,r.mapping)}return null}}}return r},dispose:function(){e=new WeakMap}}}class Lr extends rr{constructor(t=-1,e=1,n=1,i=-1,r=.1,s=2e3){super(),this.isOrthographicCamera=!0,this.type="OrthographicCamera",this.zoom=1,this.view=null,this.left=t,this.right=e,this.top=n,this.bottom=i,this.near=r,this.far=s,this.updateProjectionMatrix()}copy(t,e){return super.copy(t,e),this.left=t.left,this.right=t.right,this.top=t.top,this.bottom=t.bottom,this.near=t.near,this.far=t.far,this.zoom=t.zoom,this.view=null===t.view?null:Object.assign({},t.view),this}setViewOffset(t,e,n,i,r,s){null===this.view&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=t,this.view.fullHeight=e,this.view.offsetX=n,this.view.offsetY=i,this.view.width=r,this.view.height=s,this.updateProjectionMatrix()}clearViewOffset(){null!==this.view&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const t=(this.right-this.left)/(2*this.zoom),e=(this.top-this.bottom)/(2*this.zoom),n=(this.right+this.left)/2,i=(this.top+this.bottom)/2;let r=n-t,s=n+t,a=i+e,o=i-e;if(null!==this.view&&this.view.enabled){const t=(this.right-this.left)/this.view.fullWidth/this.zoom,e=(this.top-this.bottom)/this.view.fullHeight/this.zoom;r+=t*this.view.offsetX,s=r+t*this.view.width,a-=e*this.view.offsetY,o=a-e*this.view.height}this.projectionMatrix.makeOrthographic(r,s,a,o,this.near,this.far,this.coordinateSystem),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(t){const e=super.toJSON(t);return e.object.zoom=this.zoom,e.object.left=this.left,e.object.right=this.right,e.object.top=this.top,e.object.bottom=this.bottom,e.object.near=this.near,e.object.far=this.far,null!==this.view&&(e.object.view=Object.assign({},this.view)),e}}const Pr=[.125,.215,.35,.446,.526,.582],Ir=20,Ur=new Lr,Dr=new ci;let Nr=null,Or=0,Fr=0;const Br=(1+Math.sqrt(5))/2,zr=1/Br,Gr=[new We(1,1,1),new We(-1,1,1),new We(1,1,-1),new We(-1,1,-1),new We(0,Br,zr),new We(0,Br,-zr),new We(zr,0,Br),new We(-zr,0,Br),new We(Br,zr,0),new We(-Br,zr,0)];class Hr{constructor(t){this._renderer=t,this._pingPongRenderTarget=null,this._lodMax=0,this._cubeSize=0,this._lodPlanes=[],this._sizeLods=[],this._sigmas=[],this._blurMaterial=null,this._cubemapMaterial=null,this._equirectMaterial=null,this._compileMaterial(this._blurMaterial)}fromScene(t,e=0,n=.1,i=100){Nr=this._renderer.getRenderTarget(),Or=this._renderer.getActiveCubeFace(),Fr=this._renderer.getActiveMipmapLevel(),this._setSize(256);const r=this._allocateTargets();return r.depthBuffer=!0,this._sceneToCubeUV(t,n,i,r),e>0&&this._blur(r,0,0,e),this._applyPMREM(r),this._cleanup(r),r}fromEquirectangular(t,e=null){return this._fromTexture(t,e)}fromCubemap(t,e=null){return this._fromTexture(t,e)}compileCubemapShader(){null===this._cubemapMaterial&&(this._cubemapMaterial=Xr(),this._compileMaterial(this._cubemapMaterial))}compileEquirectangularShader(){null===this._equirectMaterial&&(this._equirectMaterial=Wr(),this._compileMaterial(this._equirectMaterial))}dispose(){this._dispose(),null!==this._cubemapMaterial&&this._cubemapMaterial.dispose(),null!==this._equirectMaterial&&this._equirectMaterial.dispose()}_setSize(t){this._lodMax=Math.floor(Math.log2(t)),this._cubeSize=Math.pow(2,this._lodMax)}_dispose(){null!==this._blurMaterial&&this._blurMaterial.dispose(),null!==this._pingPongRenderTarget&&this._pingPongRenderTarget.dispose();for(let t=0;tt-4?o=Pr[a-t+4-1]:0===a&&(o=0),i.push(o);const l=1/(s-2),c=-l,h=1+l,u=[c,c,h,c,h,h,c,c,h,h,c,h],d=6,p=6,m=3,f=2,g=1,_=new Float32Array(m*p*d),v=new Float32Array(f*p*d),x=new Float32Array(g*p*d);for(let t=0;t2?0:-1,i=[e,n,0,e+2/3,n,0,e+2/3,n+1,0,e,n,0,e+2/3,n+1,0,e,n+1,0];_.set(i,m*p*t),v.set(u,f*p*t);const r=[t,t,t,t,t,t];x.set(r,g*p*t)}const y=new Ii;y.setAttribute("position",new Mi(_,m)),y.setAttribute("uv",new Mi(v,f)),y.setAttribute("faceIndex",new Mi(x,g)),e.push(y),r>4&&r--}return{lodPlanes:e,sizeLods:n,sigmas:i}}(i)),this._blurMaterial=function(t,e,n){const i=new Float32Array(Ir),r=new We(0,1,0),s=new ir({name:"SphericalGaussianBlur",defines:{n:Ir,CUBEUV_TEXEL_WIDTH:1/e,CUBEUV_TEXEL_HEIGHT:1/n,CUBEUV_MAX_MIP:`${t}.0`},uniforms:{envMap:{value:null},samples:{value:1},weights:{value:i},latitudinal:{value:!1},dTheta:{value:0},mipInt:{value:0},poleAxis:{value:r}},vertexShader:jr(),fragmentShader:"\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\t\t\tuniform int samples;\n\t\t\tuniform float weights[ n ];\n\t\t\tuniform bool latitudinal;\n\t\t\tuniform float dTheta;\n\t\t\tuniform float mipInt;\n\t\t\tuniform vec3 poleAxis;\n\n\t\t\t#define ENVMAP_TYPE_CUBE_UV\n\t\t\t#include \n\n\t\t\tvec3 getSample( float theta, vec3 axis ) {\n\n\t\t\t\tfloat cosTheta = cos( theta );\n\t\t\t\t// Rodrigues' axis-angle rotation\n\t\t\t\tvec3 sampleDirection = vOutputDirection * cosTheta\n\t\t\t\t\t+ cross( axis, vOutputDirection ) * sin( theta )\n\t\t\t\t\t+ axis * dot( axis, vOutputDirection ) * ( 1.0 - cosTheta );\n\n\t\t\t\treturn bilinearCubeUV( envMap, sampleDirection, mipInt );\n\n\t\t\t}\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 axis = latitudinal ? poleAxis : cross( poleAxis, vOutputDirection );\n\n\t\t\t\tif ( all( equal( axis, vec3( 0.0 ) ) ) ) {\n\n\t\t\t\t\taxis = vec3( vOutputDirection.z, 0.0, - vOutputDirection.x );\n\n\t\t\t\t}\n\n\t\t\t\taxis = normalize( axis );\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb += weights[ 0 ] * getSample( 0.0, axis );\n\n\t\t\t\tfor ( int i = 1; i < n; i++ ) {\n\n\t\t\t\t\tif ( i >= samples ) {\n\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\t}\n\n\t\t\t\t\tfloat theta = dTheta * float( i );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( -1.0 * theta, axis );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( theta, axis );\n\n\t\t\t\t}\n\n\t\t\t}\n\t\t",blending:0,depthTest:!1,depthWrite:!1});return s}(i,t,e)}return i}_compileMaterial(t){const e=new Ji(this._lodPlanes[0],t);this._renderer.compile(e,Ur)}_sceneToCubeUV(t,e,n,i){const r=new sr(90,1,e,n),s=[1,-1,1,1,1,1],o=[1,1,1,-1,-1,-1],l=this._renderer,c=l.autoClear,h=l.toneMapping;l.getClearColor(Dr),l.toneMapping=p,l.autoClear=!1;const u=new pi({name:"PMREM.Background",side:a,depthWrite:!1,depthTest:!1}),d=new Ji(new $i,u);let m=!1;const f=t.background;f?f.isColor&&(u.color.copy(f),t.background=null,m=!0):(u.color.copy(Dr),m=!0);for(let e=0;e<6;e++){const n=e%3;0===n?(r.up.set(0,s[e],0),r.lookAt(o[e],0,0)):1===n?(r.up.set(0,0,s[e]),r.lookAt(0,o[e],0)):(r.up.set(0,s[e],0),r.lookAt(0,0,o[e]));const a=this._cubeSize;kr(i,n*a,e>2?a:0,a,a),l.setRenderTarget(i),m&&l.render(d,r),l.render(t,r)}d.geometry.dispose(),d.material.dispose(),l.toneMapping=h,l.autoClear=c,t.background=f}_textureToCubeUV(t,e){const n=this._renderer,i=t.mapping===b||t.mapping===T;i?(null===this._cubemapMaterial&&(this._cubemapMaterial=Xr()),this._cubemapMaterial.uniforms.flipEnvMap.value=!1===t.isRenderTargetTexture?-1:1):null===this._equirectMaterial&&(this._equirectMaterial=Wr());const r=i?this._cubemapMaterial:this._equirectMaterial,s=new Ji(this._lodPlanes[0],r);r.uniforms.envMap.value=t;const a=this._cubeSize;kr(e,0,0,3*a,2*a),n.setRenderTarget(e),n.render(s,Ur)}_applyPMREM(t){const e=this._renderer,n=e.autoClear;e.autoClear=!1;for(let e=1;eIr&&console.warn(`sigmaRadians, ${r}, is too large and will clip, as it requested ${m} samples when the maximum is set to 20`);const f=[];let g=0;for(let t=0;t_-4?i-_+4:0),4*(this._cubeSize-v),3*v,2*v),o.setRenderTarget(e),o.render(c,Ur)}}function Vr(t,e,n){const i=new Ge(t,e,n);return i.texture.mapping=A,i.texture.name="PMREM.cubeUv",i.scissorTest=!0,i}function kr(t,e,n,i,r){t.viewport.set(e,n,i,r),t.scissor.set(e,n,i,r)}function Wr(){return new ir({name:"EquirectangularToCubeUV",uniforms:{envMap:{value:null}},vertexShader:jr(),fragmentShader:"\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\n\t\t\t#include \n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 outputDirection = normalize( vOutputDirection );\n\t\t\t\tvec2 uv = equirectUv( outputDirection );\n\n\t\t\t\tgl_FragColor = vec4( texture2D ( envMap, uv ).rgb, 1.0 );\n\n\t\t\t}\n\t\t",blending:0,depthTest:!1,depthWrite:!1})}function Xr(){return new ir({name:"CubemapToCubeUV",uniforms:{envMap:{value:null},flipEnvMap:{value:-1}},vertexShader:jr(),fragmentShader:"\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tuniform float flipEnvMap;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform samplerCube envMap;\n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = textureCube( envMap, vec3( flipEnvMap * vOutputDirection.x, vOutputDirection.yz ) );\n\n\t\t\t}\n\t\t",blending:0,depthTest:!1,depthWrite:!1})}function jr(){return"\n\n\t\tprecision mediump float;\n\t\tprecision mediump int;\n\n\t\tattribute float faceIndex;\n\n\t\tvarying vec3 vOutputDirection;\n\n\t\t// RH coordinate system; PMREM face-indexing convention\n\t\tvec3 getDirection( vec2 uv, float face ) {\n\n\t\t\tuv = 2.0 * uv - 1.0;\n\n\t\t\tvec3 direction = vec3( uv, 1.0 );\n\n\t\t\tif ( face == 0.0 ) {\n\n\t\t\t\tdirection = direction.zyx; // ( 1, v, u ) pos x\n\n\t\t\t} else if ( face == 1.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xz *= -1.0; // ( -u, 1, -v ) pos y\n\n\t\t\t} else if ( face == 2.0 ) {\n\n\t\t\t\tdirection.x *= -1.0; // ( -u, v, 1 ) pos z\n\n\t\t\t} else if ( face == 3.0 ) {\n\n\t\t\t\tdirection = direction.zyx;\n\t\t\t\tdirection.xz *= -1.0; // ( -1, v, -u ) neg x\n\n\t\t\t} else if ( face == 4.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xy *= -1.0; // ( -u, -1, v ) neg y\n\n\t\t\t} else if ( face == 5.0 ) {\n\n\t\t\t\tdirection.z *= -1.0; // ( u, v, -1 ) neg z\n\n\t\t\t}\n\n\t\t\treturn direction;\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvOutputDirection = getDirection( uv, faceIndex );\n\t\t\tgl_Position = vec4( position, 1.0 );\n\n\t\t}\n\t"}function qr(t){let e=new WeakMap,n=null;function i(t){const n=t.target;n.removeEventListener("dispose",i);const r=e.get(n);void 0!==r&&(e.delete(n),r.dispose())}return{get:function(r){if(r&&r.isTexture){const s=r.mapping,a=s===E||s===w,o=s===b||s===T;if(a||o){if(r.isRenderTargetTexture&&!0===r.needsPMREMUpdate){r.needsPMREMUpdate=!1;let i=e.get(r);return null===n&&(n=new Hr(t)),i=a?n.fromEquirectangular(r,i):n.fromCubemap(r,i),e.set(r,i),i.texture}if(e.has(r))return e.get(r).texture;{const s=r.image;if(a&&s&&s.height>0||o&&s&&function(t){let e=0;const n=6;for(let i=0;ie.maxTextureSize&&(E=Math.ceil(T/e.maxTextureSize),T=e.maxTextureSize);const w=new Float32Array(T*E*4*p),A=new He(w,T,E,p);A.type=H,A.needsUpdate=!0;const R=4*b;for(let L=0;L0)return t;const r=e*n;let s=ls[r];if(void 0===s&&(s=new Float32Array(r),ls[r]=s),0!==e){i.toArray(s,0);for(let i=1,r=0;i!==e;++i)r+=n,t[i].toArray(s,r)}return s}function ms(t,e){if(t.length!==e.length)return!1;for(let n=0,i=t.length;n":" "} ${r}: ${n[t]}`)}return i.join("\n")}(t.getShaderSource(e),i)}return r}function da(t,e){const n=function(t){const e=Re.getPrimaries(Re.workingColorSpace),n=Re.getPrimaries(t);let i;switch(e===n?i="":e===qt&&n===jt?i="LinearDisplayP3ToLinearSRGB":e===jt&&n===qt&&(i="LinearSRGBToLinearDisplayP3"),t){case Ht:case kt:return[i,"LinearTransferOETF"];case Gt:case Vt:return[i,"sRGBTransferOETF"];default:return console.warn("THREE.WebGLProgram: Unsupported color space:",t),[i,"LinearTransferOETF"]}}(e);return`vec4 ${t}( vec4 value ) { return ${n[0]}( ${n[1]}( value ) ); }`}function pa(t,e){let n;switch(e){case m:n="Linear";break;case f:n="Reinhard";break;case g:n="OptimizedCineon";break;case _:n="ACESFilmic";break;case x:n="AgX";break;case v:n="Custom";break;default:console.warn("THREE.WebGLProgram: Unsupported toneMapping:",e),n="Linear"}return"vec3 "+t+"( vec3 color ) { return "+n+"ToneMapping( color ); }"}function ma(t){return""!==t}function fa(t,e){const n=e.numSpotLightShadows+e.numSpotLightMaps-e.numSpotLightShadowsWithMaps;return t.replace(/NUM_DIR_LIGHTS/g,e.numDirLights).replace(/NUM_SPOT_LIGHTS/g,e.numSpotLights).replace(/NUM_SPOT_LIGHT_MAPS/g,e.numSpotLightMaps).replace(/NUM_SPOT_LIGHT_COORDS/g,n).replace(/NUM_RECT_AREA_LIGHTS/g,e.numRectAreaLights).replace(/NUM_POINT_LIGHTS/g,e.numPointLights).replace(/NUM_HEMI_LIGHTS/g,e.numHemiLights).replace(/NUM_DIR_LIGHT_SHADOWS/g,e.numDirLightShadows).replace(/NUM_SPOT_LIGHT_SHADOWS_WITH_MAPS/g,e.numSpotLightShadowsWithMaps).replace(/NUM_SPOT_LIGHT_SHADOWS/g,e.numSpotLightShadows).replace(/NUM_POINT_LIGHT_SHADOWS/g,e.numPointLightShadows)}function ga(t,e){return t.replace(/NUM_CLIPPING_PLANES/g,e.numClippingPlanes).replace(/UNION_CLIPPING_PLANES/g,e.numClippingPlanes-e.numClipIntersection)}const _a=/^[ \t]*#include +<([\w\d./]+)>/gm;function va(t){return t.replace(_a,ya)}const xa=new Map([["encodings_fragment","colorspace_fragment"],["encodings_pars_fragment","colorspace_pars_fragment"],["output_fragment","opaque_fragment"]]);function ya(t,e){let n=yr[e];if(void 0===n){const t=xa.get(e);if(void 0===t)throw new Error("Can not resolve #include <"+e+">");n=yr[t],console.warn('THREE.WebGLRenderer: Shader chunk "%s" has been deprecated. Use "%s" instead.',e,t)}return va(n)}const Ma=/#pragma unroll_loop_start\s+for\s*\(\s*int\s+i\s*=\s*(\d+)\s*;\s*i\s*<\s*(\d+)\s*;\s*i\s*\+\+\s*\)\s*{([\s\S]+?)}\s+#pragma unroll_loop_end/g;function Sa(t){return t.replace(Ma,ba)}function ba(t,e,n,i){let r="";for(let t=parseInt(e);t0&&(w+="\n"),R=[y,"#define SHADER_TYPE "+s.shaderType,"#define SHADER_NAME "+s.shaderName,S].filter(ma).join("\n"),R.length>0&&(R+="\n")):(w=[Ta(s),"#define SHADER_TYPE "+s.shaderType,"#define SHADER_NAME "+s.shaderName,S,s.extensionClipCullDistance?"#define USE_CLIP_DISTANCE":"",s.batching?"#define USE_BATCHING":"",s.instancing?"#define USE_INSTANCING":"",s.instancingColor?"#define USE_INSTANCING_COLOR":"",s.useFog&&s.fog?"#define USE_FOG":"",s.useFog&&s.fogExp2?"#define FOG_EXP2":"",s.map?"#define USE_MAP":"",s.envMap?"#define USE_ENVMAP":"",s.envMap?"#define "+_:"",s.lightMap?"#define USE_LIGHTMAP":"",s.aoMap?"#define USE_AOMAP":"",s.bumpMap?"#define USE_BUMPMAP":"",s.normalMap?"#define USE_NORMALMAP":"",s.normalMapObjectSpace?"#define USE_NORMALMAP_OBJECTSPACE":"",s.normalMapTangentSpace?"#define USE_NORMALMAP_TANGENTSPACE":"",s.displacementMap?"#define USE_DISPLACEMENTMAP":"",s.emissiveMap?"#define USE_EMISSIVEMAP":"",s.anisotropy?"#define USE_ANISOTROPY":"",s.anisotropyMap?"#define USE_ANISOTROPYMAP":"",s.clearcoatMap?"#define USE_CLEARCOATMAP":"",s.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",s.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",s.iridescenceMap?"#define USE_IRIDESCENCEMAP":"",s.iridescenceThicknessMap?"#define USE_IRIDESCENCE_THICKNESSMAP":"",s.specularMap?"#define USE_SPECULARMAP":"",s.specularColorMap?"#define USE_SPECULAR_COLORMAP":"",s.specularIntensityMap?"#define USE_SPECULAR_INTENSITYMAP":"",s.roughnessMap?"#define USE_ROUGHNESSMAP":"",s.metalnessMap?"#define USE_METALNESSMAP":"",s.alphaMap?"#define USE_ALPHAMAP":"",s.alphaHash?"#define USE_ALPHAHASH":"",s.transmission?"#define USE_TRANSMISSION":"",s.transmissionMap?"#define USE_TRANSMISSIONMAP":"",s.thicknessMap?"#define USE_THICKNESSMAP":"",s.sheenColorMap?"#define USE_SHEEN_COLORMAP":"",s.sheenRoughnessMap?"#define USE_SHEEN_ROUGHNESSMAP":"",s.mapUv?"#define MAP_UV "+s.mapUv:"",s.alphaMapUv?"#define ALPHAMAP_UV "+s.alphaMapUv:"",s.lightMapUv?"#define LIGHTMAP_UV "+s.lightMapUv:"",s.aoMapUv?"#define AOMAP_UV "+s.aoMapUv:"",s.emissiveMapUv?"#define EMISSIVEMAP_UV "+s.emissiveMapUv:"",s.bumpMapUv?"#define BUMPMAP_UV "+s.bumpMapUv:"",s.normalMapUv?"#define NORMALMAP_UV "+s.normalMapUv:"",s.displacementMapUv?"#define DISPLACEMENTMAP_UV "+s.displacementMapUv:"",s.metalnessMapUv?"#define METALNESSMAP_UV "+s.metalnessMapUv:"",s.roughnessMapUv?"#define ROUGHNESSMAP_UV "+s.roughnessMapUv:"",s.anisotropyMapUv?"#define ANISOTROPYMAP_UV "+s.anisotropyMapUv:"",s.clearcoatMapUv?"#define CLEARCOATMAP_UV "+s.clearcoatMapUv:"",s.clearcoatNormalMapUv?"#define CLEARCOAT_NORMALMAP_UV "+s.clearcoatNormalMapUv:"",s.clearcoatRoughnessMapUv?"#define CLEARCOAT_ROUGHNESSMAP_UV "+s.clearcoatRoughnessMapUv:"",s.iridescenceMapUv?"#define IRIDESCENCEMAP_UV "+s.iridescenceMapUv:"",s.iridescenceThicknessMapUv?"#define IRIDESCENCE_THICKNESSMAP_UV "+s.iridescenceThicknessMapUv:"",s.sheenColorMapUv?"#define SHEEN_COLORMAP_UV "+s.sheenColorMapUv:"",s.sheenRoughnessMapUv?"#define SHEEN_ROUGHNESSMAP_UV "+s.sheenRoughnessMapUv:"",s.specularMapUv?"#define SPECULARMAP_UV "+s.specularMapUv:"",s.specularColorMapUv?"#define SPECULAR_COLORMAP_UV "+s.specularColorMapUv:"",s.specularIntensityMapUv?"#define SPECULAR_INTENSITYMAP_UV "+s.specularIntensityMapUv:"",s.transmissionMapUv?"#define TRANSMISSIONMAP_UV "+s.transmissionMapUv:"",s.thicknessMapUv?"#define THICKNESSMAP_UV "+s.thicknessMapUv:"",s.vertexTangents&&!1===s.flatShading?"#define USE_TANGENT":"",s.vertexColors?"#define USE_COLOR":"",s.vertexAlphas?"#define USE_COLOR_ALPHA":"",s.vertexUv1s?"#define USE_UV1":"",s.vertexUv2s?"#define USE_UV2":"",s.vertexUv3s?"#define USE_UV3":"",s.pointsUvs?"#define USE_POINTS_UV":"",s.flatShading?"#define FLAT_SHADED":"",s.skinning?"#define USE_SKINNING":"",s.morphTargets?"#define USE_MORPHTARGETS":"",s.morphNormals&&!1===s.flatShading?"#define USE_MORPHNORMALS":"",s.morphColors&&s.isWebGL2?"#define USE_MORPHCOLORS":"",s.morphTargetsCount>0&&s.isWebGL2?"#define MORPHTARGETS_TEXTURE":"",s.morphTargetsCount>0&&s.isWebGL2?"#define MORPHTARGETS_TEXTURE_STRIDE "+s.morphTextureStride:"",s.morphTargetsCount>0&&s.isWebGL2?"#define MORPHTARGETS_COUNT "+s.morphTargetsCount:"",s.doubleSided?"#define DOUBLE_SIDED":"",s.flipSided?"#define FLIP_SIDED":"",s.shadowMapEnabled?"#define USE_SHADOWMAP":"",s.shadowMapEnabled?"#define "+f:"",s.sizeAttenuation?"#define USE_SIZEATTENUATION":"",s.numLightProbes>0?"#define USE_LIGHT_PROBES":"",s.useLegacyLights?"#define LEGACY_LIGHTS":"",s.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",s.logarithmicDepthBuffer&&s.rendererExtensionFragDepth?"#define USE_LOGDEPTHBUF_EXT":"","uniform mat4 modelMatrix;","uniform mat4 modelViewMatrix;","uniform mat4 projectionMatrix;","uniform mat4 viewMatrix;","uniform mat3 normalMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;","#ifdef USE_INSTANCING","\tattribute mat4 instanceMatrix;","#endif","#ifdef USE_INSTANCING_COLOR","\tattribute vec3 instanceColor;","#endif","attribute vec3 position;","attribute vec3 normal;","attribute vec2 uv;","#ifdef USE_UV1","\tattribute vec2 uv1;","#endif","#ifdef USE_UV2","\tattribute vec2 uv2;","#endif","#ifdef USE_UV3","\tattribute vec2 uv3;","#endif","#ifdef USE_TANGENT","\tattribute vec4 tangent;","#endif","#if defined( USE_COLOR_ALPHA )","\tattribute vec4 color;","#elif defined( USE_COLOR )","\tattribute vec3 color;","#endif","#if ( defined( USE_MORPHTARGETS ) && ! defined( MORPHTARGETS_TEXTURE ) )","\tattribute vec3 morphTarget0;","\tattribute vec3 morphTarget1;","\tattribute vec3 morphTarget2;","\tattribute vec3 morphTarget3;","\t#ifdef USE_MORPHNORMALS","\t\tattribute vec3 morphNormal0;","\t\tattribute vec3 morphNormal1;","\t\tattribute vec3 morphNormal2;","\t\tattribute vec3 morphNormal3;","\t#else","\t\tattribute vec3 morphTarget4;","\t\tattribute vec3 morphTarget5;","\t\tattribute vec3 morphTarget6;","\t\tattribute vec3 morphTarget7;","\t#endif","#endif","#ifdef USE_SKINNING","\tattribute vec4 skinIndex;","\tattribute vec4 skinWeight;","#endif","\n"].filter(ma).join("\n"),R=[y,Ta(s),"#define SHADER_TYPE "+s.shaderType,"#define SHADER_NAME "+s.shaderName,S,s.useFog&&s.fog?"#define USE_FOG":"",s.useFog&&s.fogExp2?"#define FOG_EXP2":"",s.map?"#define USE_MAP":"",s.matcap?"#define USE_MATCAP":"",s.envMap?"#define USE_ENVMAP":"",s.envMap?"#define "+g:"",s.envMap?"#define "+_:"",s.envMap?"#define "+v:"",x?"#define CUBEUV_TEXEL_WIDTH "+x.texelWidth:"",x?"#define CUBEUV_TEXEL_HEIGHT "+x.texelHeight:"",x?"#define CUBEUV_MAX_MIP "+x.maxMip+".0":"",s.lightMap?"#define USE_LIGHTMAP":"",s.aoMap?"#define USE_AOMAP":"",s.bumpMap?"#define USE_BUMPMAP":"",s.normalMap?"#define USE_NORMALMAP":"",s.normalMapObjectSpace?"#define USE_NORMALMAP_OBJECTSPACE":"",s.normalMapTangentSpace?"#define USE_NORMALMAP_TANGENTSPACE":"",s.emissiveMap?"#define USE_EMISSIVEMAP":"",s.anisotropy?"#define USE_ANISOTROPY":"",s.anisotropyMap?"#define USE_ANISOTROPYMAP":"",s.clearcoat?"#define USE_CLEARCOAT":"",s.clearcoatMap?"#define USE_CLEARCOATMAP":"",s.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",s.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",s.iridescence?"#define USE_IRIDESCENCE":"",s.iridescenceMap?"#define USE_IRIDESCENCEMAP":"",s.iridescenceThicknessMap?"#define USE_IRIDESCENCE_THICKNESSMAP":"",s.specularMap?"#define USE_SPECULARMAP":"",s.specularColorMap?"#define USE_SPECULAR_COLORMAP":"",s.specularIntensityMap?"#define USE_SPECULAR_INTENSITYMAP":"",s.roughnessMap?"#define USE_ROUGHNESSMAP":"",s.metalnessMap?"#define USE_METALNESSMAP":"",s.alphaMap?"#define USE_ALPHAMAP":"",s.alphaTest?"#define USE_ALPHATEST":"",s.alphaHash?"#define USE_ALPHAHASH":"",s.sheen?"#define USE_SHEEN":"",s.sheenColorMap?"#define USE_SHEEN_COLORMAP":"",s.sheenRoughnessMap?"#define USE_SHEEN_ROUGHNESSMAP":"",s.transmission?"#define USE_TRANSMISSION":"",s.transmissionMap?"#define USE_TRANSMISSIONMAP":"",s.thicknessMap?"#define USE_THICKNESSMAP":"",s.vertexTangents&&!1===s.flatShading?"#define USE_TANGENT":"",s.vertexColors||s.instancingColor?"#define USE_COLOR":"",s.vertexAlphas?"#define USE_COLOR_ALPHA":"",s.vertexUv1s?"#define USE_UV1":"",s.vertexUv2s?"#define USE_UV2":"",s.vertexUv3s?"#define USE_UV3":"",s.pointsUvs?"#define USE_POINTS_UV":"",s.gradientMap?"#define USE_GRADIENTMAP":"",s.flatShading?"#define FLAT_SHADED":"",s.doubleSided?"#define DOUBLE_SIDED":"",s.flipSided?"#define FLIP_SIDED":"",s.shadowMapEnabled?"#define USE_SHADOWMAP":"",s.shadowMapEnabled?"#define "+f:"",s.premultipliedAlpha?"#define PREMULTIPLIED_ALPHA":"",s.numLightProbes>0?"#define USE_LIGHT_PROBES":"",s.useLegacyLights?"#define LEGACY_LIGHTS":"",s.decodeVideoTexture?"#define DECODE_VIDEO_TEXTURE":"",s.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",s.logarithmicDepthBuffer&&s.rendererExtensionFragDepth?"#define USE_LOGDEPTHBUF_EXT":"","uniform mat4 viewMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;",s.toneMapping!==p?"#define TONE_MAPPING":"",s.toneMapping!==p?yr.tonemapping_pars_fragment:"",s.toneMapping!==p?pa("toneMapping",s.toneMapping):"",s.dithering?"#define DITHERING":"",s.opaque?"#define OPAQUE":"",yr.colorspace_pars_fragment,da("linearToOutputTexel",s.outputColorSpace),s.useDepthPacking?"#define DEPTH_PACKING "+s.depthPacking:"","\n"].filter(ma).join("\n")),c=va(c),c=fa(c,s),c=ga(c,s),m=va(m),m=fa(m,s),m=ga(m,s),c=Sa(c),m=Sa(m),s.isWebGL2&&!0!==s.isRawShaderMaterial&&(C="#version 300 es\n",w=[M,"precision mediump sampler2DArray;","#define attribute in","#define varying out","#define texture2D texture"].join("\n")+"\n"+w,R=["precision mediump sampler2DArray;","#define varying in",s.glslVersion===Jt?"":"layout(location = 0) out highp vec4 pc_fragColor;",s.glslVersion===Jt?"":"#define gl_FragColor pc_fragColor","#define gl_FragDepthEXT gl_FragDepth","#define texture2D texture","#define textureCube texture","#define texture2DProj textureProj","#define texture2DLodEXT textureLod","#define texture2DProjLodEXT textureProjLod","#define textureCubeLodEXT textureLod","#define texture2DGradEXT textureGrad","#define texture2DProjGradEXT textureProjGrad","#define textureCubeGradEXT textureGrad"].join("\n")+"\n"+R);const L=C+w+c,P=C+R+m,I=la(o,o.VERTEX_SHADER,L),U=la(o,o.FRAGMENT_SHADER,P);function D(e){if(t.debug.checkShaderErrors){const n=o.getProgramInfoLog(E).trim(),i=o.getShaderInfoLog(I).trim(),r=o.getShaderInfoLog(U).trim();let s=!0,a=!0;if(!1===o.getProgramParameter(E,o.LINK_STATUS))if(s=!1,"function"==typeof t.debug.onShaderError)t.debug.onShaderError(o,E,I,U);else{const t=ua(o,I,"vertex"),e=ua(o,U,"fragment");console.error("THREE.WebGLProgram: Shader Error "+o.getError()+" - VALIDATE_STATUS "+o.getProgramParameter(E,o.VALIDATE_STATUS)+"\n\nProgram Info Log: "+n+"\n"+t+"\n"+e)}else""!==n?console.warn("THREE.WebGLProgram: Program Info Log:",n):""!==i&&""!==r||(a=!1);a&&(e.diagnostics={runnable:s,programLog:n,vertexShader:{log:i,prefix:w},fragmentShader:{log:r,prefix:R}})}o.deleteShader(I),o.deleteShader(U),N=new oa(o,E),O=function(t,e){const n={},i=t.getProgramParameter(e,t.ACTIVE_ATTRIBUTES);for(let r=0;r0,Z=s.clearcoat>0,J=s.iridescence>0,K=s.sheen>0,$=s.transmission>0,Q=Y&&!!s.anisotropyMap,tt=Z&&!!s.clearcoatMap,et=Z&&!!s.clearcoatNormalMap,nt=Z&&!!s.clearcoatRoughnessMap,it=J&&!!s.iridescenceMap,rt=J&&!!s.iridescenceThicknessMap,st=K&&!!s.sheenColorMap,at=K&&!!s.sheenRoughnessMap,ot=!!s.specularMap,lt=!!s.specularColorMap,ct=!!s.specularIntensityMap,ht=$&&!!s.transmissionMap,ut=$&&!!s.thicknessMap,dt=!!s.gradientMap,pt=!!s.alphaMap,mt=s.alphaTest>0,ft=!!s.alphaHash,gt=!!s.extensions,_t=!!M.attributes.uv1,vt=!!M.attributes.uv2,xt=!!M.attributes.uv3;let yt=p;return s.toneMapped&&(null!==D&&!0!==D.isXRRenderTarget||(yt=t.toneMapping)),{isWebGL2:u,shaderID:E,shaderType:s.type,shaderName:s.name,vertexShader:C,fragmentShader:L,defines:s.defines,customVertexShaderID:P,customFragmentShaderID:I,isRawShaderMaterial:!0===s.isRawShaderMaterial,glslVersion:s.glslVersion,precision:f,batching:O,instancing:N,instancingColor:N&&null!==x.instanceColor,supportsVertexTextures:m,outputColorSpace:null===D?t.outputColorSpace:!0===D.isXRRenderTarget?D.texture.colorSpace:Ht,map:F,matcap:B,envMap:z,envMapMode:z&&b.mapping,envMapCubeUVHeight:T,aoMap:G,lightMap:H,bumpMap:V,normalMap:k,displacementMap:m&&W,emissiveMap:X,normalMapObjectSpace:k&&1===s.normalMapType,normalMapTangentSpace:k&&0===s.normalMapType,metalnessMap:j,roughnessMap:q,anisotropy:Y,anisotropyMap:Q,clearcoat:Z,clearcoatMap:tt,clearcoatNormalMap:et,clearcoatRoughnessMap:nt,iridescence:J,iridescenceMap:it,iridescenceThicknessMap:rt,sheen:K,sheenColorMap:st,sheenRoughnessMap:at,specularMap:ot,specularColorMap:lt,specularIntensityMap:ct,transmission:$,transmissionMap:ht,thicknessMap:ut,gradientMap:dt,opaque:!1===s.transparent&&1===s.blending,alphaMap:pt,alphaTest:mt,alphaHash:ft,combine:s.combine,mapUv:F&&_(s.map.channel),aoMapUv:G&&_(s.aoMap.channel),lightMapUv:H&&_(s.lightMap.channel),bumpMapUv:V&&_(s.bumpMap.channel),normalMapUv:k&&_(s.normalMap.channel),displacementMapUv:W&&_(s.displacementMap.channel),emissiveMapUv:X&&_(s.emissiveMap.channel),metalnessMapUv:j&&_(s.metalnessMap.channel),roughnessMapUv:q&&_(s.roughnessMap.channel),anisotropyMapUv:Q&&_(s.anisotropyMap.channel),clearcoatMapUv:tt&&_(s.clearcoatMap.channel),clearcoatNormalMapUv:et&&_(s.clearcoatNormalMap.channel),clearcoatRoughnessMapUv:nt&&_(s.clearcoatRoughnessMap.channel),iridescenceMapUv:it&&_(s.iridescenceMap.channel),iridescenceThicknessMapUv:rt&&_(s.iridescenceThicknessMap.channel),sheenColorMapUv:st&&_(s.sheenColorMap.channel),sheenRoughnessMapUv:at&&_(s.sheenRoughnessMap.channel),specularMapUv:ot&&_(s.specularMap.channel),specularColorMapUv:lt&&_(s.specularColorMap.channel),specularIntensityMapUv:ct&&_(s.specularIntensityMap.channel),transmissionMapUv:ht&&_(s.transmissionMap.channel),thicknessMapUv:ut&&_(s.thicknessMap.channel),alphaMapUv:pt&&_(s.alphaMap.channel),vertexTangents:!!M.attributes.tangent&&(k||Y),vertexColors:s.vertexColors,vertexAlphas:!0===s.vertexColors&&!!M.attributes.color&&4===M.attributes.color.itemSize,vertexUv1s:_t,vertexUv2s:vt,vertexUv3s:xt,pointsUvs:!0===x.isPoints&&!!M.attributes.uv&&(F||pt),fog:!!y,useFog:!0===s.fog,fogExp2:y&&y.isFogExp2,flatShading:!0===s.flatShading,sizeAttenuation:!0===s.sizeAttenuation,logarithmicDepthBuffer:d,skinning:!0===x.isSkinnedMesh,morphTargets:void 0!==M.morphAttributes.position,morphNormals:void 0!==M.morphAttributes.normal,morphColors:void 0!==M.morphAttributes.color,morphTargetsCount:R,morphTextureStride:U,numDirLights:l.directional.length,numPointLights:l.point.length,numSpotLights:l.spot.length,numSpotLightMaps:l.spotLightMap.length,numRectAreaLights:l.rectArea.length,numHemiLights:l.hemi.length,numDirLightShadows:l.directionalShadowMap.length,numPointLightShadows:l.pointShadowMap.length,numSpotLightShadows:l.spotShadowMap.length,numSpotLightShadowsWithMaps:l.numSpotLightShadowsWithMaps,numLightProbes:l.numLightProbes,numClippingPlanes:o.numPlanes,numClipIntersection:o.numIntersection,dithering:s.dithering,shadowMapEnabled:t.shadowMap.enabled&&h.length>0,shadowMapType:t.shadowMap.type,toneMapping:yt,useLegacyLights:t._useLegacyLights,decodeVideoTexture:F&&!0===s.map.isVideoTexture&&Re.getTransfer(s.map.colorSpace)===Xt,premultipliedAlpha:s.premultipliedAlpha,doubleSided:2===s.side,flipSided:s.side===a,useDepthPacking:s.depthPacking>=0,depthPacking:s.depthPacking||0,index0AttributeName:s.index0AttributeName,extensionDerivatives:gt&&!0===s.extensions.derivatives,extensionFragDepth:gt&&!0===s.extensions.fragDepth,extensionDrawBuffers:gt&&!0===s.extensions.drawBuffers,extensionShaderTextureLOD:gt&&!0===s.extensions.shaderTextureLOD,extensionClipCullDistance:gt&&s.extensions.clipCullDistance&&i.has("WEBGL_clip_cull_distance"),rendererExtensionFragDepth:u||i.has("EXT_frag_depth"),rendererExtensionDrawBuffers:u||i.has("WEBGL_draw_buffers"),rendererExtensionShaderTextureLod:u||i.has("EXT_shader_texture_lod"),rendererExtensionParallelShaderCompile:i.has("KHR_parallel_shader_compile"),customProgramCacheKey:s.customProgramCacheKey()}},getProgramCacheKey:function(e){const n=[];if(e.shaderID?n.push(e.shaderID):(n.push(e.customVertexShaderID),n.push(e.customFragmentShaderID)),void 0!==e.defines)for(const t in e.defines)n.push(t),n.push(e.defines[t]);return!1===e.isRawShaderMaterial&&(!function(t,e){t.push(e.precision),t.push(e.outputColorSpace),t.push(e.envMapMode),t.push(e.envMapCubeUVHeight),t.push(e.mapUv),t.push(e.alphaMapUv),t.push(e.lightMapUv),t.push(e.aoMapUv),t.push(e.bumpMapUv),t.push(e.normalMapUv),t.push(e.displacementMapUv),t.push(e.emissiveMapUv),t.push(e.metalnessMapUv),t.push(e.roughnessMapUv),t.push(e.anisotropyMapUv),t.push(e.clearcoatMapUv),t.push(e.clearcoatNormalMapUv),t.push(e.clearcoatRoughnessMapUv),t.push(e.iridescenceMapUv),t.push(e.iridescenceThicknessMapUv),t.push(e.sheenColorMapUv),t.push(e.sheenRoughnessMapUv),t.push(e.specularMapUv),t.push(e.specularColorMapUv),t.push(e.specularIntensityMapUv),t.push(e.transmissionMapUv),t.push(e.thicknessMapUv),t.push(e.combine),t.push(e.fogExp2),t.push(e.sizeAttenuation),t.push(e.morphTargetsCount),t.push(e.morphAttributeCount),t.push(e.numDirLights),t.push(e.numPointLights),t.push(e.numSpotLights),t.push(e.numSpotLightMaps),t.push(e.numHemiLights),t.push(e.numRectAreaLights),t.push(e.numDirLightShadows),t.push(e.numPointLightShadows),t.push(e.numSpotLightShadows),t.push(e.numSpotLightShadowsWithMaps),t.push(e.numLightProbes),t.push(e.shadowMapType),t.push(e.toneMapping),t.push(e.numClippingPlanes),t.push(e.numClipIntersection),t.push(e.depthPacking)}(n,e),function(t,e){l.disableAll(),e.isWebGL2&&l.enable(0);e.supportsVertexTextures&&l.enable(1);e.instancing&&l.enable(2);e.instancingColor&&l.enable(3);e.matcap&&l.enable(4);e.envMap&&l.enable(5);e.normalMapObjectSpace&&l.enable(6);e.normalMapTangentSpace&&l.enable(7);e.clearcoat&&l.enable(8);e.iridescence&&l.enable(9);e.alphaTest&&l.enable(10);e.vertexColors&&l.enable(11);e.vertexAlphas&&l.enable(12);e.vertexUv1s&&l.enable(13);e.vertexUv2s&&l.enable(14);e.vertexUv3s&&l.enable(15);e.vertexTangents&&l.enable(16);e.anisotropy&&l.enable(17);e.alphaHash&&l.enable(18);e.batching&&l.enable(19);t.push(l.mask),l.disableAll(),e.fog&&l.enable(0);e.useFog&&l.enable(1);e.flatShading&&l.enable(2);e.logarithmicDepthBuffer&&l.enable(3);e.skinning&&l.enable(4);e.morphTargets&&l.enable(5);e.morphNormals&&l.enable(6);e.morphColors&&l.enable(7);e.premultipliedAlpha&&l.enable(8);e.shadowMapEnabled&&l.enable(9);e.useLegacyLights&&l.enable(10);e.doubleSided&&l.enable(11);e.flipSided&&l.enable(12);e.useDepthPacking&&l.enable(13);e.dithering&&l.enable(14);e.transmission&&l.enable(15);e.sheen&&l.enable(16);e.opaque&&l.enable(17);e.pointsUvs&&l.enable(18);e.decodeVideoTexture&&l.enable(19);t.push(l.mask)}(n,e),n.push(t.outputColorSpace)),n.push(e.customProgramCacheKey),n.join()},getUniforms:function(t){const e=g[t.type];let n;if(e){const t=Sr[e];n=nr.clone(t.uniforms)}else n=t.uniforms;return n},acquireProgram:function(e,n){let i;for(let t=0,e=h.length;t0?i.push(h):!0===a.transparent?r.push(h):n.push(h)},unshift:function(t,e,a,o,l,c){const h=s(t,e,a,o,l,c);a.transmission>0?i.unshift(h):!0===a.transparent?r.unshift(h):n.unshift(h)},finish:function(){for(let n=e,i=t.length;n1&&n.sort(t||Pa),i.length>1&&i.sort(e||Ia),r.length>1&&r.sort(e||Ia)}}}function Da(){let t=new WeakMap;return{get:function(e,n){const i=t.get(e);let r;return void 0===i?(r=new Ua,t.set(e,[r])):n>=i.length?(r=new Ua,i.push(r)):r=i[n],r},dispose:function(){t=new WeakMap}}}function Na(){const t={};return{get:function(e){if(void 0!==t[e.id])return t[e.id];let n;switch(e.type){case"DirectionalLight":n={direction:new We,color:new ci};break;case"SpotLight":n={position:new We,direction:new We,color:new ci,distance:0,coneCos:0,penumbraCos:0,decay:0};break;case"PointLight":n={position:new We,color:new ci,distance:0,decay:0};break;case"HemisphereLight":n={direction:new We,skyColor:new ci,groundColor:new ci};break;case"RectAreaLight":n={color:new ci,position:new We,halfWidth:new We,halfHeight:new We}}return t[e.id]=n,n}}}let Oa=0;function Fa(t,e){return(e.castShadow?2:0)-(t.castShadow?2:0)+(e.map?1:0)-(t.map?1:0)}function Ba(t,e){const n=new Na,i=function(){const t={};return{get:function(e){if(void 0!==t[e.id])return t[e.id];let n;switch(e.type){case"DirectionalLight":case"SpotLight":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new me};break;case"PointLight":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new me,shadowCameraNear:1,shadowCameraFar:1e3}}return t[e.id]=n,n}}}(),r={version:0,hash:{directionalLength:-1,pointLength:-1,spotLength:-1,rectAreaLength:-1,hemiLength:-1,numDirectionalShadows:-1,numPointShadows:-1,numSpotShadows:-1,numSpotMaps:-1,numLightProbes:-1},ambient:[0,0,0],probe:[],directional:[],directionalShadow:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotLightMap:[],spotShadow:[],spotShadowMap:[],spotLightMatrix:[],rectArea:[],rectAreaLTC1:null,rectAreaLTC2:null,point:[],pointShadow:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[],numSpotLightShadowsWithMaps:0,numLightProbes:0};for(let t=0;t<9;t++)r.probe.push(new We);const s=new We,a=new Mn,o=new Mn;return{setup:function(s,a){let o=0,l=0,c=0;for(let t=0;t<9;t++)r.probe[t].set(0,0,0);let h=0,u=0,d=0,p=0,m=0,f=0,g=0,_=0,v=0,x=0,y=0;s.sort(Fa);const M=!0===a?Math.PI:1;for(let t=0,e=s.length;t0&&(e.isWebGL2?!0===t.has("OES_texture_float_linear")?(r.rectAreaLTC1=Mr.LTC_FLOAT_1,r.rectAreaLTC2=Mr.LTC_FLOAT_2):(r.rectAreaLTC1=Mr.LTC_HALF_1,r.rectAreaLTC2=Mr.LTC_HALF_2):!0===t.has("OES_texture_float_linear")?(r.rectAreaLTC1=Mr.LTC_FLOAT_1,r.rectAreaLTC2=Mr.LTC_FLOAT_2):!0===t.has("OES_texture_half_float_linear")?(r.rectAreaLTC1=Mr.LTC_HALF_1,r.rectAreaLTC2=Mr.LTC_HALF_2):console.error("THREE.WebGLRenderer: Unable to use RectAreaLight. Missing WebGL extensions.")),r.ambient[0]=o,r.ambient[1]=l,r.ambient[2]=c;const S=r.hash;S.directionalLength===h&&S.pointLength===u&&S.spotLength===d&&S.rectAreaLength===p&&S.hemiLength===m&&S.numDirectionalShadows===f&&S.numPointShadows===g&&S.numSpotShadows===_&&S.numSpotMaps===v&&S.numLightProbes===y||(r.directional.length=h,r.spot.length=d,r.rectArea.length=p,r.point.length=u,r.hemi.length=m,r.directionalShadow.length=f,r.directionalShadowMap.length=f,r.pointShadow.length=g,r.pointShadowMap.length=g,r.spotShadow.length=_,r.spotShadowMap.length=_,r.directionalShadowMatrix.length=f,r.pointShadowMatrix.length=g,r.spotLightMatrix.length=_+v-x,r.spotLightMap.length=v,r.numSpotLightShadowsWithMaps=x,r.numLightProbes=y,S.directionalLength=h,S.pointLength=u,S.spotLength=d,S.rectAreaLength=p,S.hemiLength=m,S.numDirectionalShadows=f,S.numPointShadows=g,S.numSpotShadows=_,S.numSpotMaps=v,S.numLightProbes=y,r.version=Oa++)},setupView:function(t,e){let n=0,i=0,l=0,c=0,h=0;const u=e.matrixWorldInverse;for(let e=0,d=t.length;e=s.length?(a=new za(t,e),s.push(a)):a=s[r],a},dispose:function(){n=new WeakMap}}}class Ha extends di{constructor(t){super(),this.isMeshDepthMaterial=!0,this.type="MeshDepthMaterial",this.depthPacking=3200,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.setValues(t)}copy(t){return super.copy(t),this.depthPacking=t.depthPacking,this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this}}class Va extends di{constructor(t){super(),this.isMeshDistanceMaterial=!0,this.type="MeshDistanceMaterial",this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.setValues(t)}copy(t){return super.copy(t),this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this}}function ka(t,e,i){let o=new gr;const l=new me,c=new me,h=new Be,u=new Ha({depthPacking:3201}),d=new Va,p={},m=i.maxTextureSize,f={[s]:a,[a]:s,2:2},g=new ir({defines:{VSM_SAMPLES:8},uniforms:{shadow_pass:{value:null},resolution:{value:new me},radius:{value:4}},vertexShader:"void main() {\n\tgl_Position = vec4( position, 1.0 );\n}",fragmentShader:"uniform sampler2D shadow_pass;\nuniform vec2 resolution;\nuniform float radius;\n#include \nvoid main() {\n\tconst float samples = float( VSM_SAMPLES );\n\tfloat mean = 0.0;\n\tfloat squared_mean = 0.0;\n\tfloat uvStride = samples <= 1.0 ? 0.0 : 2.0 / ( samples - 1.0 );\n\tfloat uvStart = samples <= 1.0 ? 0.0 : - 1.0;\n\tfor ( float i = 0.0; i < samples; i ++ ) {\n\t\tfloat uvOffset = uvStart + i * uvStride;\n\t\t#ifdef HORIZONTAL_PASS\n\t\t\tvec2 distribution = unpackRGBATo2Half( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( uvOffset, 0.0 ) * radius ) / resolution ) );\n\t\t\tmean += distribution.x;\n\t\t\tsquared_mean += distribution.y * distribution.y + distribution.x * distribution.x;\n\t\t#else\n\t\t\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( 0.0, uvOffset ) * radius ) / resolution ) );\n\t\t\tmean += depth;\n\t\t\tsquared_mean += depth * depth;\n\t\t#endif\n\t}\n\tmean = mean / samples;\n\tsquared_mean = squared_mean / samples;\n\tfloat std_dev = sqrt( squared_mean - mean * mean );\n\tgl_FragColor = pack2HalfToRGBA( vec2( mean, std_dev ) );\n}"}),_=g.clone();_.defines.HORIZONTAL_PASS=1;const v=new Ii;v.setAttribute("position",new Mi(new Float32Array([-1,-1,.5,3,-1,.5,-1,3,.5]),3));const x=new Ji(v,g),y=this;this.enabled=!1,this.autoUpdate=!0,this.needsUpdate=!1,this.type=n;let M=this.type;function S(n,i){const r=e.update(x);g.defines.VSM_SAMPLES!==n.blurSamples&&(g.defines.VSM_SAMPLES=n.blurSamples,_.defines.VSM_SAMPLES=n.blurSamples,g.needsUpdate=!0,_.needsUpdate=!0),null===n.mapPass&&(n.mapPass=new Ge(l.x,l.y)),g.uniforms.shadow_pass.value=n.map.texture,g.uniforms.resolution.value=n.mapSize,g.uniforms.radius.value=n.radius,t.setRenderTarget(n.mapPass),t.clear(),t.renderBufferDirect(i,null,r,g,x,null),_.uniforms.shadow_pass.value=n.mapPass.texture,_.uniforms.resolution.value=n.mapSize,_.uniforms.radius.value=n.radius,t.setRenderTarget(n.map),t.clear(),t.renderBufferDirect(i,null,r,_,x,null)}function b(e,n,i,s){let a=null;const o=!0===i.isPointLight?e.customDistanceMaterial:e.customDepthMaterial;if(void 0!==o)a=o;else if(a=!0===i.isPointLight?d:u,t.localClippingEnabled&&!0===n.clipShadows&&Array.isArray(n.clippingPlanes)&&0!==n.clippingPlanes.length||n.displacementMap&&0!==n.displacementScale||n.alphaMap&&n.alphaTest>0||n.map&&n.alphaTest>0){const t=a.uuid,e=n.uuid;let i=p[t];void 0===i&&(i={},p[t]=i);let r=i[e];void 0===r&&(r=a.clone(),i[e]=r,n.addEventListener("dispose",E)),a=r}if(a.visible=n.visible,a.wireframe=n.wireframe,a.side=s===r?null!==n.shadowSide?n.shadowSide:n.side:null!==n.shadowSide?n.shadowSide:f[n.side],a.alphaMap=n.alphaMap,a.alphaTest=n.alphaTest,a.map=n.map,a.clipShadows=n.clipShadows,a.clippingPlanes=n.clippingPlanes,a.clipIntersection=n.clipIntersection,a.displacementMap=n.displacementMap,a.displacementScale=n.displacementScale,a.displacementBias=n.displacementBias,a.wireframeLinewidth=n.wireframeLinewidth,a.linewidth=n.linewidth,!0===i.isPointLight&&!0===a.isMeshDistanceMaterial){t.properties.get(a).light=i}return a}function T(n,i,s,a,l){if(!1===n.visible)return;if(n.layers.test(i.layers)&&(n.isMesh||n.isLine||n.isPoints)&&(n.castShadow||n.receiveShadow&&l===r)&&(!n.frustumCulled||o.intersectsObject(n))){n.modelViewMatrix.multiplyMatrices(s.matrixWorldInverse,n.matrixWorld);const r=e.update(n),o=n.material;if(Array.isArray(o)){const e=r.groups;for(let c=0,h=e.length;cm||l.y>m)&&(l.x>m&&(c.x=Math.floor(m/g.x),l.x=c.x*g.x,u.mapSize.x=c.x),l.y>m&&(c.y=Math.floor(m/g.y),l.y=c.y*g.y,u.mapSize.y=c.y)),null===u.map||!0===p||!0===f){const t=this.type!==r?{minFilter:P,magFilter:P}:{};null!==u.map&&u.map.dispose(),u.map=new Ge(l.x,l.y,t),u.map.texture.name=a.name+".shadowMap",u.camera.updateProjectionMatrix()}t.setRenderTarget(u.map),t.clear();const _=u.getViewportCount();for(let t=0;t<_;t++){const e=u.getViewport(t);h.set(c.x*e.x,c.y*e.y,c.x*e.z,c.y*e.w),d.viewport(h),u.updateMatrices(a,t),o=u.getFrustum(),T(n,i,u.camera,a,this.type)}!0!==u.isPointLightShadow&&this.type===r&&S(u,i),u.needsUpdate=!1}M=this.type,y.needsUpdate=!1,t.setRenderTarget(s,a,u)}}function Wa(t,e,n){const i=n.isWebGL2;const r=new function(){let e=!1;const n=new Be;let i=null;const r=new Be(0,0,0,0);return{setMask:function(n){i===n||e||(t.colorMask(n,n,n,n),i=n)},setLocked:function(t){e=t},setClear:function(e,i,s,a,o){!0===o&&(e*=a,i*=a,s*=a),n.set(e,i,s,a),!1===r.equals(n)&&(t.clearColor(e,i,s,a),r.copy(n))},reset:function(){e=!1,i=null,r.set(-1,0,0,0)}}},s=new function(){let e=!1,n=null,i=null,r=null;return{setTest:function(e){e?j(t.DEPTH_TEST):q(t.DEPTH_TEST)},setMask:function(i){n===i||e||(t.depthMask(i),n=i)},setFunc:function(e){if(i!==e){switch(e){case 0:t.depthFunc(t.NEVER);break;case 1:t.depthFunc(t.ALWAYS);break;case 2:t.depthFunc(t.LESS);break;case 3:default:t.depthFunc(t.LEQUAL);break;case 4:t.depthFunc(t.EQUAL);break;case 5:t.depthFunc(t.GEQUAL);break;case 6:t.depthFunc(t.GREATER);break;case 7:t.depthFunc(t.NOTEQUAL)}i=e}},setLocked:function(t){e=t},setClear:function(e){r!==e&&(t.clearDepth(e),r=e)},reset:function(){e=!1,n=null,i=null,r=null}}},h=new function(){let e=!1,n=null,i=null,r=null,s=null,a=null,o=null,l=null,c=null;return{setTest:function(n){e||(n?j(t.STENCIL_TEST):q(t.STENCIL_TEST))},setMask:function(i){n===i||e||(t.stencilMask(i),n=i)},setFunc:function(e,n,a){i===e&&r===n&&s===a||(t.stencilFunc(e,n,a),i=e,r=n,s=a)},setOp:function(e,n,i){a===e&&o===n&&l===i||(t.stencilOp(e,n,i),a=e,o=n,l=i)},setLocked:function(t){e=t},setClear:function(e){c!==e&&(t.clearStencil(e),c=e)},reset:function(){e=!1,n=null,i=null,r=null,s=null,a=null,o=null,l=null,c=null}}},u=new WeakMap,d=new WeakMap;let p={},m={},f=new WeakMap,g=[],_=null,v=!1,x=null,y=null,M=null,S=null,b=null,T=null,E=null,w=new ci(0,0,0),A=0,R=!1,C=null,L=null,P=null,I=null,U=null;const D=t.getParameter(t.MAX_COMBINED_TEXTURE_IMAGE_UNITS);let N=!1,O=0;const F=t.getParameter(t.VERSION);-1!==F.indexOf("WebGL")?(O=parseFloat(/^WebGL (\d)/.exec(F)[1]),N=O>=1):-1!==F.indexOf("OpenGL ES")&&(O=parseFloat(/^OpenGL ES (\d)/.exec(F)[1]),N=O>=2);let B=null,z={};const G=t.getParameter(t.SCISSOR_BOX),H=t.getParameter(t.VIEWPORT),V=(new Be).fromArray(G),k=(new Be).fromArray(H);function W(e,n,r,s){const a=new Uint8Array(4),o=t.createTexture();t.bindTexture(e,o),t.texParameteri(e,t.TEXTURE_MIN_FILTER,t.NEAREST),t.texParameteri(e,t.TEXTURE_MAG_FILTER,t.NEAREST);for(let o=0;oi||t.height>i)&&(r=i/Math.max(t.width,t.height)),r<1||!0===e){if("undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap){const i=e?he:Math.floor,s=i(r*t.width),a=i(r*t.height);void 0===u&&(u=m(s,a));const o=n?m(s,a):u;o.width=s,o.height=a;return o.getContext("2d").drawImage(t,0,0,s,a),console.warn("THREE.WebGLRenderer: Texture has been resized from ("+t.width+"x"+t.height+") to ("+s+"x"+a+")."),o}return"data"in t&&console.warn("THREE.WebGLRenderer: Image in DataTexture is too big ("+t.width+"x"+t.height+")."),t}return t}function g(t){return ce(t.width)&&ce(t.height)}function _(t,e){return t.generateMipmaps&&e&&t.minFilter!==P&&t.minFilter!==D}function v(e){t.generateMipmap(e)}function x(n,i,r,s,a=!1){if(!1===o)return i;if(null!==n){if(void 0!==t[n])return t[n];console.warn("THREE.WebGLRenderer: Attempt to use non-existing WebGL internal format '"+n+"'")}let l=i;if(i===t.RED&&(r===t.FLOAT&&(l=t.R32F),r===t.HALF_FLOAT&&(l=t.R16F),r===t.UNSIGNED_BYTE&&(l=t.R8)),i===t.RED_INTEGER&&(r===t.UNSIGNED_BYTE&&(l=t.R8UI),r===t.UNSIGNED_SHORT&&(l=t.R16UI),r===t.UNSIGNED_INT&&(l=t.R32UI),r===t.BYTE&&(l=t.R8I),r===t.SHORT&&(l=t.R16I),r===t.INT&&(l=t.R32I)),i===t.RG&&(r===t.FLOAT&&(l=t.RG32F),r===t.HALF_FLOAT&&(l=t.RG16F),r===t.UNSIGNED_BYTE&&(l=t.RG8)),i===t.RGBA){const e=a?Wt:Re.getTransfer(s);r===t.FLOAT&&(l=t.RGBA32F),r===t.HALF_FLOAT&&(l=t.RGBA16F),r===t.UNSIGNED_BYTE&&(l=e===Xt?t.SRGB8_ALPHA8:t.RGBA8),r===t.UNSIGNED_SHORT_4_4_4_4&&(l=t.RGBA4),r===t.UNSIGNED_SHORT_5_5_5_1&&(l=t.RGB5_A1)}return l!==t.R16F&&l!==t.R32F&&l!==t.RG16F&&l!==t.RG32F&&l!==t.RGBA16F&&l!==t.RGBA32F||e.get("EXT_color_buffer_float"),l}function y(t,e,n){return!0===_(t,n)||t.isFramebufferTexture&&t.minFilter!==P&&t.minFilter!==D?Math.log2(Math.max(e.width,e.height))+1:void 0!==t.mipmaps&&t.mipmaps.length>0?t.mipmaps.length:t.isCompressedTexture&&Array.isArray(t.image)?e.mipmaps.length:1}function M(e){return e===P||e===I||e===U?t.NEAREST:t.LINEAR}function S(t){const e=t.target;e.removeEventListener("dispose",S),function(t){const e=i.get(t);if(void 0===e.__webglInit)return;const n=t.source,r=d.get(n);if(r){const i=r[e.__cacheKey];i.usedTimes--,0===i.usedTimes&&T(t),0===Object.keys(r).length&&d.delete(n)}i.remove(t)}(e),e.isVideoTexture&&h.delete(e)}function b(e){const n=e.target;n.removeEventListener("dispose",b),function(e){const n=e.texture,r=i.get(e),s=i.get(n);void 0!==s.__webglTexture&&(t.deleteTexture(s.__webglTexture),a.memory.textures--);e.depthTexture&&e.depthTexture.dispose();if(e.isWebGLCubeRenderTarget)for(let e=0;e<6;e++){if(Array.isArray(r.__webglFramebuffer[e]))for(let n=0;n0&&s.__version!==e.version){const t=e.image;if(null===t)console.warn("THREE.WebGLRenderer: Texture marked for update but no image data found.");else{if(!1!==t.complete)return void J(s,e,r);console.warn("THREE.WebGLRenderer: Texture marked for update but image is incomplete")}}n.bindTexture(t.TEXTURE_2D,s.__webglTexture,t.TEXTURE0+r)}const A={[R]:t.REPEAT,[C]:t.CLAMP_TO_EDGE,[L]:t.MIRRORED_REPEAT},z={[P]:t.NEAREST,[I]:t.NEAREST_MIPMAP_NEAREST,[U]:t.NEAREST_MIPMAP_LINEAR,[D]:t.LINEAR,[N]:t.LINEAR_MIPMAP_NEAREST,[O]:t.LINEAR_MIPMAP_LINEAR},k={512:t.NEVER,519:t.ALWAYS,513:t.LESS,515:t.LEQUAL,514:t.EQUAL,518:t.GEQUAL,516:t.GREATER,517:t.NOTEQUAL};function W(n,s,a){if(a?(t.texParameteri(n,t.TEXTURE_WRAP_S,A[s.wrapS]),t.texParameteri(n,t.TEXTURE_WRAP_T,A[s.wrapT]),n!==t.TEXTURE_3D&&n!==t.TEXTURE_2D_ARRAY||t.texParameteri(n,t.TEXTURE_WRAP_R,A[s.wrapR]),t.texParameteri(n,t.TEXTURE_MAG_FILTER,z[s.magFilter]),t.texParameteri(n,t.TEXTURE_MIN_FILTER,z[s.minFilter])):(t.texParameteri(n,t.TEXTURE_WRAP_S,t.CLAMP_TO_EDGE),t.texParameteri(n,t.TEXTURE_WRAP_T,t.CLAMP_TO_EDGE),n!==t.TEXTURE_3D&&n!==t.TEXTURE_2D_ARRAY||t.texParameteri(n,t.TEXTURE_WRAP_R,t.CLAMP_TO_EDGE),s.wrapS===C&&s.wrapT===C||console.warn("THREE.WebGLRenderer: Texture is not power of two. Texture.wrapS and Texture.wrapT should be set to THREE.ClampToEdgeWrapping."),t.texParameteri(n,t.TEXTURE_MAG_FILTER,M(s.magFilter)),t.texParameteri(n,t.TEXTURE_MIN_FILTER,M(s.minFilter)),s.minFilter!==P&&s.minFilter!==D&&console.warn("THREE.WebGLRenderer: Texture is not power of two. Texture.minFilter should be set to THREE.NearestFilter or THREE.LinearFilter.")),s.compareFunction&&(t.texParameteri(n,t.TEXTURE_COMPARE_MODE,t.COMPARE_REF_TO_TEXTURE),t.texParameteri(n,t.TEXTURE_COMPARE_FUNC,k[s.compareFunction])),!0===e.has("EXT_texture_filter_anisotropic")){const a=e.get("EXT_texture_filter_anisotropic");if(s.magFilter===P)return;if(s.minFilter!==U&&s.minFilter!==O)return;if(s.type===H&&!1===e.has("OES_texture_float_linear"))return;if(!1===o&&s.type===V&&!1===e.has("OES_texture_half_float_linear"))return;(s.anisotropy>1||i.get(s).__currentAnisotropy)&&(t.texParameterf(n,a.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(s.anisotropy,r.getMaxAnisotropy())),i.get(s).__currentAnisotropy=s.anisotropy)}}function Z(e,n){let i=!1;void 0===e.__webglInit&&(e.__webglInit=!0,n.addEventListener("dispose",S));const r=n.source;let s=d.get(r);void 0===s&&(s={},d.set(r,s));const o=function(t){const e=[];return e.push(t.wrapS),e.push(t.wrapT),e.push(t.wrapR||0),e.push(t.magFilter),e.push(t.minFilter),e.push(t.anisotropy),e.push(t.internalFormat),e.push(t.format),e.push(t.type),e.push(t.generateMipmaps),e.push(t.premultiplyAlpha),e.push(t.flipY),e.push(t.unpackAlignment),e.push(t.colorSpace),e.join()}(n);if(o!==e.__cacheKey){void 0===s[o]&&(s[o]={texture:t.createTexture(),usedTimes:0},a.memory.textures++,i=!0),s[o].usedTimes++;const r=s[e.__cacheKey];void 0!==r&&(s[e.__cacheKey].usedTimes--,0===r.usedTimes&&T(n)),e.__cacheKey=o,e.__webglTexture=s[o].texture}return i}function J(e,a,l){let c=t.TEXTURE_2D;(a.isDataArrayTexture||a.isCompressedArrayTexture)&&(c=t.TEXTURE_2D_ARRAY),a.isData3DTexture&&(c=t.TEXTURE_3D);const h=Z(e,a),u=a.source;n.bindTexture(c,e.__webglTexture,t.TEXTURE0+l);const d=i.get(u);if(u.version!==d.__version||!0===h){n.activeTexture(t.TEXTURE0+l);const e=Re.getPrimaries(Re.workingColorSpace),i=a.colorSpace===zt?null:Re.getPrimaries(a.colorSpace),p=a.colorSpace===zt||e===i?t.NONE:t.BROWSER_DEFAULT_WEBGL;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,a.flipY),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,a.premultiplyAlpha),t.pixelStorei(t.UNPACK_ALIGNMENT,a.unpackAlignment),t.pixelStorei(t.UNPACK_COLORSPACE_CONVERSION_WEBGL,p);const m=function(t){return!o&&(t.wrapS!==C||t.wrapT!==C||t.minFilter!==P&&t.minFilter!==D)}(a)&&!1===g(a.image);let M=f(a.image,m,!1,r.maxTextureSize);M=nt(a,M);const S=g(M)||o,b=s.convert(a.format,a.colorSpace);let T,E=s.convert(a.type),w=x(a.internalFormat,b,E,a.colorSpace,a.isVideoTexture);W(c,a,S);const A=a.mipmaps,R=o&&!0!==a.isVideoTexture&&w!==at,L=void 0===d.__version||!0===h,I=y(a,M,S);if(a.isDepthTexture)w=t.DEPTH_COMPONENT,o?w=a.type===H?t.DEPTH_COMPONENT32F:a.type===G?t.DEPTH_COMPONENT24:a.type===X?t.DEPTH24_STENCIL8:t.DEPTH_COMPONENT16:a.type===H&&console.error("WebGLRenderer: Floating point depth texture requires WebGL2."),a.format===q&&w===t.DEPTH_COMPONENT&&a.type!==B&&a.type!==G&&(console.warn("THREE.WebGLRenderer: Use UnsignedShortType or UnsignedIntType for DepthFormat DepthTexture."),a.type=G,E=s.convert(a.type)),a.format===Y&&w===t.DEPTH_COMPONENT&&(w=t.DEPTH_STENCIL,a.type!==X&&(console.warn("THREE.WebGLRenderer: Use UnsignedInt248Type for DepthStencilFormat DepthTexture."),a.type=X,E=s.convert(a.type))),L&&(R?n.texStorage2D(t.TEXTURE_2D,1,w,M.width,M.height):n.texImage2D(t.TEXTURE_2D,0,w,M.width,M.height,0,b,E,null));else if(a.isDataTexture)if(A.length>0&&S){R&&L&&n.texStorage2D(t.TEXTURE_2D,I,w,A[0].width,A[0].height);for(let e=0,i=A.length;e>=1,i>>=1}}else if(A.length>0&&S){R&&L&&n.texStorage2D(t.TEXTURE_2D,I,w,A[0].width,A[0].height);for(let e=0,i=A.length;e>h),i=Math.max(1,r.height>>h);c===t.TEXTURE_3D||c===t.TEXTURE_2D_ARRAY?n.texImage3D(c,h,p,e,i,r.depth,0,u,d,null):n.texImage2D(c,h,p,e,i,0,u,d,null)}n.bindFramebuffer(t.FRAMEBUFFER,e),et(r)?l.framebufferTexture2DMultisampleEXT(t.FRAMEBUFFER,o,c,i.get(a).__webglTexture,0,tt(r)):(c===t.TEXTURE_2D||c>=t.TEXTURE_CUBE_MAP_POSITIVE_X&&c<=t.TEXTURE_CUBE_MAP_NEGATIVE_Z)&&t.framebufferTexture2D(t.FRAMEBUFFER,o,c,i.get(a).__webglTexture,h),n.bindFramebuffer(t.FRAMEBUFFER,null)}function $(e,n,i){if(t.bindRenderbuffer(t.RENDERBUFFER,e),n.depthBuffer&&!n.stencilBuffer){let r=!0===o?t.DEPTH_COMPONENT24:t.DEPTH_COMPONENT16;if(i||et(n)){const e=n.depthTexture;e&&e.isDepthTexture&&(e.type===H?r=t.DEPTH_COMPONENT32F:e.type===G&&(r=t.DEPTH_COMPONENT24));const i=tt(n);et(n)?l.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,i,r,n.width,n.height):t.renderbufferStorageMultisample(t.RENDERBUFFER,i,r,n.width,n.height)}else t.renderbufferStorage(t.RENDERBUFFER,r,n.width,n.height);t.framebufferRenderbuffer(t.FRAMEBUFFER,t.DEPTH_ATTACHMENT,t.RENDERBUFFER,e)}else if(n.depthBuffer&&n.stencilBuffer){const r=tt(n);i&&!1===et(n)?t.renderbufferStorageMultisample(t.RENDERBUFFER,r,t.DEPTH24_STENCIL8,n.width,n.height):et(n)?l.renderbufferStorageMultisampleEXT(t.RENDERBUFFER,r,t.DEPTH24_STENCIL8,n.width,n.height):t.renderbufferStorage(t.RENDERBUFFER,t.DEPTH_STENCIL,n.width,n.height),t.framebufferRenderbuffer(t.FRAMEBUFFER,t.DEPTH_STENCIL_ATTACHMENT,t.RENDERBUFFER,e)}else{const e=!0===n.isWebGLMultipleRenderTargets?n.texture:[n.texture];for(let r=0;r0&&!0===e.has("WEBGL_multisampled_render_to_texture")&&!1!==n.__useRenderToTexture}function nt(t,n){const i=t.colorSpace,r=t.format,s=t.type;return!0===t.isCompressedTexture||!0===t.isVideoTexture||t.format===Kt||i!==Ht&&i!==zt&&(Re.getTransfer(i)===Xt?!1===o?!0===e.has("EXT_sRGB")&&r===j?(t.format=Kt,t.minFilter=D,t.generateMipmaps=!1):n=Ie.sRGBToLinear(n):r===j&&s===F||console.warn("THREE.WebGLTextures: sRGB encoded textures have to use RGBAFormat and UnsignedByteType."):console.error("THREE.WebGLTextures: Unsupported texture color space:",i)),n}this.allocateTextureUnit=function(){const t=E;return t>=r.maxTextures&&console.warn("THREE.WebGLTextures: Trying to use "+t+" texture units while this GPU supports only "+r.maxTextures),E+=1,t},this.resetTextureUnits=function(){E=0},this.setTexture2D=w,this.setTexture2DArray=function(e,r){const s=i.get(e);e.version>0&&s.__version!==e.version?J(s,e,r):n.bindTexture(t.TEXTURE_2D_ARRAY,s.__webglTexture,t.TEXTURE0+r)},this.setTexture3D=function(e,r){const s=i.get(e);e.version>0&&s.__version!==e.version?J(s,e,r):n.bindTexture(t.TEXTURE_3D,s.__webglTexture,t.TEXTURE0+r)},this.setTextureCube=function(e,a){const l=i.get(e);e.version>0&&l.__version!==e.version?function(e,a,l){if(6!==a.image.length)return;const c=Z(e,a),h=a.source;n.bindTexture(t.TEXTURE_CUBE_MAP,e.__webglTexture,t.TEXTURE0+l);const u=i.get(h);if(h.version!==u.__version||!0===c){n.activeTexture(t.TEXTURE0+l);const e=Re.getPrimaries(Re.workingColorSpace),i=a.colorSpace===zt?null:Re.getPrimaries(a.colorSpace),d=a.colorSpace===zt||e===i?t.NONE:t.BROWSER_DEFAULT_WEBGL;t.pixelStorei(t.UNPACK_FLIP_Y_WEBGL,a.flipY),t.pixelStorei(t.UNPACK_PREMULTIPLY_ALPHA_WEBGL,a.premultiplyAlpha),t.pixelStorei(t.UNPACK_ALIGNMENT,a.unpackAlignment),t.pixelStorei(t.UNPACK_COLORSPACE_CONVERSION_WEBGL,d);const p=a.isCompressedTexture||a.image[0].isCompressedTexture,m=a.image[0]&&a.image[0].isDataTexture,M=[];for(let t=0;t<6;t++)M[t]=p||m?m?a.image[t].image:a.image[t]:f(a.image[t],!1,!0,r.maxCubemapSize),M[t]=nt(a,M[t]);const S=M[0],b=g(S)||o,T=s.convert(a.format,a.colorSpace),E=s.convert(a.type),w=x(a.internalFormat,T,E,a.colorSpace),A=o&&!0!==a.isVideoTexture,R=void 0===u.__version||!0===c;let C,L=y(a,S,b);if(W(t.TEXTURE_CUBE_MAP,a,b),p){A&&R&&n.texStorage2D(t.TEXTURE_CUBE_MAP,L,w,S.width,S.height);for(let e=0;e<6;e++){C=M[e].mipmaps;for(let i=0;i0&&L++,n.texStorage2D(t.TEXTURE_CUBE_MAP,L,w,M[0].width,M[0].height));for(let e=0;e<6;e++)if(m){A?n.texSubImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+e,0,0,0,M[e].width,M[e].height,T,E,M[e].data):n.texImage2D(t.TEXTURE_CUBE_MAP_POSITIVE_X+e,0,w,M[e].width,M[e].height,0,T,E,M[e].data);for(let i=0;i0){c.__webglFramebuffer[e]=[];for(let n=0;n0){c.__webglFramebuffer=[];for(let e=0;e0&&!1===et(e)){const i=d?l:[l];c.__webglMultisampledFramebuffer=t.createFramebuffer(),c.__webglColorRenderbuffer=[],n.bindFramebuffer(t.FRAMEBUFFER,c.__webglMultisampledFramebuffer);for(let n=0;n0)for(let i=0;i0)for(let n=0;n0&&!1===et(e)){const r=e.isWebGLMultipleRenderTargets?e.texture:[e.texture],s=e.width,a=e.height;let o=t.COLOR_BUFFER_BIT;const l=[],h=e.stencilBuffer?t.DEPTH_STENCIL_ATTACHMENT:t.DEPTH_ATTACHMENT,u=i.get(e),d=!0===e.isWebGLMultipleRenderTargets;if(d)for(let e=0;eo+c?(l.inputState.pinching=!1,this.dispatchEvent({type:"pinchend",handedness:t.handedness,target:this})):!l.inputState.pinching&&a<=o-c&&(l.inputState.pinching=!0,this.dispatchEvent({type:"pinchstart",handedness:t.handedness,target:this}))}else null!==o&&t.gripSpace&&(r=e.getPose(t.gripSpace,n),null!==r&&(o.matrix.fromArray(r.transform.matrix),o.matrix.decompose(o.position,o.rotation,o.scale),o.matrixWorldNeedsUpdate=!0,r.linearVelocity?(o.hasLinearVelocity=!0,o.linearVelocity.copy(r.linearVelocity)):o.hasLinearVelocity=!1,r.angularVelocity?(o.hasAngularVelocity=!0,o.angularVelocity.copy(r.angularVelocity)):o.hasAngularVelocity=!1));null!==a&&(i=e.getPose(t.targetRaySpace,n),null===i&&null!==r&&(i=r),null!==i&&(a.matrix.fromArray(i.transform.matrix),a.matrix.decompose(a.position,a.rotation,a.scale),a.matrixWorldNeedsUpdate=!0,i.linearVelocity?(a.hasLinearVelocity=!0,a.linearVelocity.copy(i.linearVelocity)):a.hasLinearVelocity=!1,i.angularVelocity?(a.hasAngularVelocity=!0,a.angularVelocity.copy(i.angularVelocity)):a.hasAngularVelocity=!1,this.dispatchEvent(Za)))}return null!==a&&(a.visible=null!==i),null!==o&&(o.visible=null!==r),null!==l&&(l.visible=null!==s),this}_getHandJoint(t,e){if(void 0===t.joints[e.jointName]){const n=new Ya;n.matrixAutoUpdate=!1,n.visible=!1,t.joints[e.jointName]=n,t.add(n)}return t.joints[e.jointName]}}class Ka extends te{constructor(t,e){super();const n=this;let i=null,r=1,s=null,a="local-floor",o=1,l=null,c=null,h=null,u=null,d=null,p=null;const m=e.getContextAttributes();let f=null,g=null;const _=[],v=[],x=new me;let y=null;const M=new sr;M.layers.enable(1),M.viewport=new Be;const S=new sr;S.layers.enable(2),S.viewport=new Be;const b=[M,S],T=new qa;T.layers.enable(1),T.layers.enable(2);let E=null,w=null;function A(t){const e=v.indexOf(t.inputSource);if(-1===e)return;const n=_[e];void 0!==n&&(n.update(t.inputSource,t.frame,l||s),n.dispatchEvent({type:t.type,data:t.inputSource}))}function R(){i.removeEventListener("select",A),i.removeEventListener("selectstart",A),i.removeEventListener("selectend",A),i.removeEventListener("squeeze",A),i.removeEventListener("squeezestart",A),i.removeEventListener("squeezeend",A),i.removeEventListener("end",R),i.removeEventListener("inputsourceschange",C);for(let t=0;t<_.length;t++){const e=v[t];null!==e&&(v[t]=null,_[t].disconnect(e))}E=null,w=null,t.setRenderTarget(f),d=null,u=null,h=null,i=null,g=null,D.stop(),n.isPresenting=!1,t.setPixelRatio(y),t.setSize(x.width,x.height,!1),n.dispatchEvent({type:"sessionend"})}function C(t){for(let e=0;e=0&&(v[i]=null,_[i].disconnect(n))}for(let e=0;e=v.length){v.push(n),i=t;break}if(null===v[t]){v[t]=n,i=t;break}}if(-1===i)break}const r=_[i];r&&r.connect(n)}}this.cameraAutoUpdate=!0,this.enabled=!1,this.isPresenting=!1,this.getController=function(t){let e=_[t];return void 0===e&&(e=new Ja,_[t]=e),e.getTargetRaySpace()},this.getControllerGrip=function(t){let e=_[t];return void 0===e&&(e=new Ja,_[t]=e),e.getGripSpace()},this.getHand=function(t){let e=_[t];return void 0===e&&(e=new Ja,_[t]=e),e.getHandSpace()},this.setFramebufferScaleFactor=function(t){r=t,!0===n.isPresenting&&console.warn("THREE.WebXRManager: Cannot change framebuffer scale while presenting.")},this.setReferenceSpaceType=function(t){a=t,!0===n.isPresenting&&console.warn("THREE.WebXRManager: Cannot change reference space type while presenting.")},this.getReferenceSpace=function(){return l||s},this.setReferenceSpace=function(t){l=t},this.getBaseLayer=function(){return null!==u?u:d},this.getBinding=function(){return h},this.getFrame=function(){return p},this.getSession=function(){return i},this.setSession=async function(c){if(i=c,null!==i){if(f=t.getRenderTarget(),i.addEventListener("select",A),i.addEventListener("selectstart",A),i.addEventListener("selectend",A),i.addEventListener("squeeze",A),i.addEventListener("squeezestart",A),i.addEventListener("squeezeend",A),i.addEventListener("end",R),i.addEventListener("inputsourceschange",C),!0!==m.xrCompatible&&await e.makeXRCompatible(),y=t.getPixelRatio(),t.getSize(x),void 0===i.renderState.layers||!1===t.capabilities.isWebGL2){const n={antialias:void 0!==i.renderState.layers||m.antialias,alpha:!0,depth:m.depth,stencil:m.stencil,framebufferScaleFactor:r};d=new XRWebGLLayer(i,e,n),i.updateRenderState({baseLayer:d}),t.setPixelRatio(1),t.setSize(d.framebufferWidth,d.framebufferHeight,!1),g=new Ge(d.framebufferWidth,d.framebufferHeight,{format:j,type:F,colorSpace:t.outputColorSpace,stencilBuffer:m.stencil})}else{let n=null,s=null,a=null;m.depth&&(a=m.stencil?e.DEPTH24_STENCIL8:e.DEPTH_COMPONENT24,n=m.stencil?Y:q,s=m.stencil?X:G);const o={colorFormat:e.RGBA8,depthFormat:a,scaleFactor:r};h=new XRWebGLBinding(i,e),u=h.createProjectionLayer(o),i.updateRenderState({layers:[u]}),t.setPixelRatio(1),t.setSize(u.textureWidth,u.textureHeight,!1),g=new Ge(u.textureWidth,u.textureHeight,{format:j,type:F,depthTexture:new ns(u.textureWidth,u.textureHeight,s,void 0,void 0,void 0,void 0,void 0,void 0,n),stencilBuffer:m.stencil,colorSpace:t.outputColorSpace,samples:m.antialias?4:0});t.properties.get(g).__ignoreDepthValues=u.ignoreDepthValues}g.isXRRenderTarget=!0,this.setFoveation(o),l=null,s=await i.requestReferenceSpace(a),D.setContext(i),D.start(),n.isPresenting=!0,n.dispatchEvent({type:"sessionstart"})}},this.getEnvironmentBlendMode=function(){if(null!==i)return i.environmentBlendMode};const L=new We,P=new We;function I(t,e){null===e?t.matrixWorld.copy(t.matrix):t.matrixWorld.multiplyMatrices(e.matrixWorld,t.matrix),t.matrixWorldInverse.copy(t.matrixWorld).invert()}this.updateCamera=function(t){if(null===i)return;T.near=S.near=M.near=t.near,T.far=S.far=M.far=t.far,E===T.near&&w===T.far||(i.updateRenderState({depthNear:T.near,depthFar:T.far}),E=T.near,w=T.far);const e=t.parent,n=T.cameras;I(T,e);for(let t=0;t0&&(i.alphaTest.value=r.alphaTest);const s=e.get(r).envMap;if(s&&(i.envMap.value=s,i.flipEnvMap.value=s.isCubeTexture&&!1===s.isRenderTargetTexture?-1:1,i.reflectivity.value=r.reflectivity,i.ior.value=r.ior,i.refractionRatio.value=r.refractionRatio),r.lightMap){i.lightMap.value=r.lightMap;const e=!0===t._useLegacyLights?Math.PI:1;i.lightMapIntensity.value=r.lightMapIntensity*e,n(r.lightMap,i.lightMapTransform)}r.aoMap&&(i.aoMap.value=r.aoMap,i.aoMapIntensity.value=r.aoMapIntensity,n(r.aoMap,i.aoMapTransform))}return{refreshFogUniforms:function(e,n){n.color.getRGB(e.fogColor.value,er(t)),n.isFog?(e.fogNear.value=n.near,e.fogFar.value=n.far):n.isFogExp2&&(e.fogDensity.value=n.density)},refreshMaterialUniforms:function(t,r,s,o,l){r.isMeshBasicMaterial||r.isMeshLambertMaterial?i(t,r):r.isMeshToonMaterial?(i(t,r),function(t,e){e.gradientMap&&(t.gradientMap.value=e.gradientMap)}(t,r)):r.isMeshPhongMaterial?(i(t,r),function(t,e){t.specular.value.copy(e.specular),t.shininess.value=Math.max(e.shininess,1e-4)}(t,r)):r.isMeshStandardMaterial?(i(t,r),function(t,i){t.metalness.value=i.metalness,i.metalnessMap&&(t.metalnessMap.value=i.metalnessMap,n(i.metalnessMap,t.metalnessMapTransform));t.roughness.value=i.roughness,i.roughnessMap&&(t.roughnessMap.value=i.roughnessMap,n(i.roughnessMap,t.roughnessMapTransform));const r=e.get(i).envMap;r&&(t.envMapIntensity.value=i.envMapIntensity)}(t,r),r.isMeshPhysicalMaterial&&function(t,e,i){t.ior.value=e.ior,e.sheen>0&&(t.sheenColor.value.copy(e.sheenColor).multiplyScalar(e.sheen),t.sheenRoughness.value=e.sheenRoughness,e.sheenColorMap&&(t.sheenColorMap.value=e.sheenColorMap,n(e.sheenColorMap,t.sheenColorMapTransform)),e.sheenRoughnessMap&&(t.sheenRoughnessMap.value=e.sheenRoughnessMap,n(e.sheenRoughnessMap,t.sheenRoughnessMapTransform)));e.clearcoat>0&&(t.clearcoat.value=e.clearcoat,t.clearcoatRoughness.value=e.clearcoatRoughness,e.clearcoatMap&&(t.clearcoatMap.value=e.clearcoatMap,n(e.clearcoatMap,t.clearcoatMapTransform)),e.clearcoatRoughnessMap&&(t.clearcoatRoughnessMap.value=e.clearcoatRoughnessMap,n(e.clearcoatRoughnessMap,t.clearcoatRoughnessMapTransform)),e.clearcoatNormalMap&&(t.clearcoatNormalMap.value=e.clearcoatNormalMap,n(e.clearcoatNormalMap,t.clearcoatNormalMapTransform),t.clearcoatNormalScale.value.copy(e.clearcoatNormalScale),e.side===a&&t.clearcoatNormalScale.value.negate()));e.iridescence>0&&(t.iridescence.value=e.iridescence,t.iridescenceIOR.value=e.iridescenceIOR,t.iridescenceThicknessMinimum.value=e.iridescenceThicknessRange[0],t.iridescenceThicknessMaximum.value=e.iridescenceThicknessRange[1],e.iridescenceMap&&(t.iridescenceMap.value=e.iridescenceMap,n(e.iridescenceMap,t.iridescenceMapTransform)),e.iridescenceThicknessMap&&(t.iridescenceThicknessMap.value=e.iridescenceThicknessMap,n(e.iridescenceThicknessMap,t.iridescenceThicknessMapTransform)));e.transmission>0&&(t.transmission.value=e.transmission,t.transmissionSamplerMap.value=i.texture,t.transmissionSamplerSize.value.set(i.width,i.height),e.transmissionMap&&(t.transmissionMap.value=e.transmissionMap,n(e.transmissionMap,t.transmissionMapTransform)),t.thickness.value=e.thickness,e.thicknessMap&&(t.thicknessMap.value=e.thicknessMap,n(e.thicknessMap,t.thicknessMapTransform)),t.attenuationDistance.value=e.attenuationDistance,t.attenuationColor.value.copy(e.attenuationColor));e.anisotropy>0&&(t.anisotropyVector.value.set(e.anisotropy*Math.cos(e.anisotropyRotation),e.anisotropy*Math.sin(e.anisotropyRotation)),e.anisotropyMap&&(t.anisotropyMap.value=e.anisotropyMap,n(e.anisotropyMap,t.anisotropyMapTransform)));t.specularIntensity.value=e.specularIntensity,t.specularColor.value.copy(e.specularColor),e.specularColorMap&&(t.specularColorMap.value=e.specularColorMap,n(e.specularColorMap,t.specularColorMapTransform));e.specularIntensityMap&&(t.specularIntensityMap.value=e.specularIntensityMap,n(e.specularIntensityMap,t.specularIntensityMapTransform))}(t,r,l)):r.isMeshMatcapMaterial?(i(t,r),function(t,e){e.matcap&&(t.matcap.value=e.matcap)}(t,r)):r.isMeshDepthMaterial?i(t,r):r.isMeshDistanceMaterial?(i(t,r),function(t,n){const i=e.get(n).light;t.referencePosition.value.setFromMatrixPosition(i.matrixWorld),t.nearDistance.value=i.shadow.camera.near,t.farDistance.value=i.shadow.camera.far}(t,r)):r.isMeshNormalMaterial?i(t,r):r.isLineBasicMaterial?(function(t,e){t.diffuse.value.copy(e.color),t.opacity.value=e.opacity,e.map&&(t.map.value=e.map,n(e.map,t.mapTransform))}(t,r),r.isLineDashedMaterial&&function(t,e){t.dashSize.value=e.dashSize,t.totalSize.value=e.dashSize+e.gapSize,t.scale.value=e.scale}(t,r)):r.isPointsMaterial?function(t,e,i,r){t.diffuse.value.copy(e.color),t.opacity.value=e.opacity,t.size.value=e.size*i,t.scale.value=.5*r,e.map&&(t.map.value=e.map,n(e.map,t.uvTransform));e.alphaMap&&(t.alphaMap.value=e.alphaMap,n(e.alphaMap,t.alphaMapTransform));e.alphaTest>0&&(t.alphaTest.value=e.alphaTest)}(t,r,s,o):r.isSpriteMaterial?function(t,e){t.diffuse.value.copy(e.color),t.opacity.value=e.opacity,t.rotation.value=e.rotation,e.map&&(t.map.value=e.map,n(e.map,t.mapTransform));e.alphaMap&&(t.alphaMap.value=e.alphaMap,n(e.alphaMap,t.alphaMapTransform));e.alphaTest>0&&(t.alphaTest.value=e.alphaTest)}(t,r):r.isShadowMaterial?(t.color.value.copy(r.color),t.opacity.value=r.opacity):r.isShaderMaterial&&(r.uniformsNeedUpdate=!1)}}}function Qa(t,e,n,i){let r={},s={},a=[];const o=n.isWebGL2?t.getParameter(t.MAX_UNIFORM_BUFFER_BINDINGS):0;function l(t,e,n,i){const r=t.value,s=e+"_"+n;if(void 0===i[s])return i[s]="number"==typeof r||"boolean"==typeof r?r:r.clone(),!0;{const t=i[s];if("number"==typeof r||"boolean"==typeof r){if(t!==r)return i[s]=r,!0}else if(!1===t.equals(r))return t.copy(r),!0}return!1}function c(t){const e={boundary:0,storage:0};return"number"==typeof t||"boolean"==typeof t?(e.boundary=4,e.storage=4):t.isVector2?(e.boundary=8,e.storage=8):t.isVector3||t.isColor?(e.boundary=16,e.storage=12):t.isVector4?(e.boundary=16,e.storage=16):t.isMatrix3?(e.boundary=48,e.storage=48):t.isMatrix4?(e.boundary=64,e.storage=64):t.isTexture?console.warn("THREE.WebGLRenderer: Texture samplers can not be part of an uniforms group."):console.warn("THREE.WebGLRenderer: Unsupported uniform value type.",t),e}function h(e){const n=e.target;n.removeEventListener("dispose",h);const i=a.indexOf(n.__bindingPointIndex);a.splice(i,1),t.deleteBuffer(r[n.id]),delete r[n.id],delete s[n.id]}return{bind:function(t,e){const n=e.program;i.uniformBlockBinding(t,n)},update:function(n,u){let d=r[n.id];void 0===d&&(!function(t){const e=t.uniforms;let n=0;const i=16;for(let t=0,r=e.length;t0&&(n+=i-r);t.__size=n,t.__cache={}}(n),d=function(e){const n=function(){for(let t=0;t0),u=!!n.morphAttributes.position,d=!!n.morphAttributes.normal,m=!!n.morphAttributes.color;let f=p;i.toneMapped&&(null!==w&&!0!==w.isXRRenderTarget||(f=S.toneMapping));const g=n.morphAttributes.position||n.morphAttributes.normal||n.morphAttributes.color,_=void 0!==g?g.length:0,v=mt.get(i),y=x.state.lights;if(!0===nt&&(!0===it||t!==R)){const e=t===R&&i.id===A;Et.setState(i,t,e)}let M=!1;i.version===v.__version?v.needsLights&&v.lightsStateVersion!==y.state.version||v.outputColorSpace!==o||r.isBatchedMesh&&!1===v.batching?M=!0:r.isBatchedMesh||!0!==v.batching?r.isInstancedMesh&&!1===v.instancing?M=!0:r.isInstancedMesh||!0!==v.instancing?r.isSkinnedMesh&&!1===v.skinning?M=!0:r.isSkinnedMesh||!0!==v.skinning?r.isInstancedMesh&&!0===v.instancingColor&&null===r.instanceColor||r.isInstancedMesh&&!1===v.instancingColor&&null!==r.instanceColor||v.envMap!==l||!0===i.fog&&v.fog!==s?M=!0:void 0===v.numClippingPlanes||v.numClippingPlanes===Et.numPlanes&&v.numIntersection===Et.numIntersection?(v.vertexAlphas!==c||v.vertexTangents!==h||v.morphTargets!==u||v.morphNormals!==d||v.morphColors!==m||v.toneMapping!==f||!0===ut.isWebGL2&&v.morphTargetsCount!==_)&&(M=!0):M=!0:M=!0:M=!0:M=!0:(M=!0,v.__version=i.version);let b=v.currentProgram;!0===M&&(b=Qt(i,e,r));let T=!1,E=!1,C=!1;const L=b.getUniforms(),P=v.uniforms;dt.useProgram(b.program)&&(T=!0,E=!0,C=!0);i.id!==A&&(A=i.id,E=!0);if(T||R!==t){L.setValue(Dt,"projectionMatrix",t.projectionMatrix),L.setValue(Dt,"viewMatrix",t.matrixWorldInverse);const e=L.map.cameraPosition;void 0!==e&&e.setValue(Dt,ot.setFromMatrixPosition(t.matrixWorld)),ut.logarithmicDepthBuffer&&L.setValue(Dt,"logDepthBufFC",2/(Math.log(t.far+1)/Math.LN2)),(i.isMeshPhongMaterial||i.isMeshToonMaterial||i.isMeshLambertMaterial||i.isMeshBasicMaterial||i.isMeshStandardMaterial||i.isShaderMaterial)&&L.setValue(Dt,"isOrthographic",!0===t.isOrthographicCamera),R!==t&&(R=t,E=!0,C=!0)}if(r.isSkinnedMesh){L.setOptional(Dt,r,"bindMatrix"),L.setOptional(Dt,r,"bindMatrixInverse");const t=r.skeleton;t&&(ut.floatVertexTextures?(null===t.boneTexture&&t.computeBoneTexture(),L.setValue(Dt,"boneTexture",t.boneTexture,ft)):console.warn("THREE.WebGLRenderer: SkinnedMesh can only be used with WebGL 2. With WebGL 1 OES_texture_float and vertex textures support is required."))}r.isBatchedMesh&&(L.setOptional(Dt,r,"batchingTexture"),L.setValue(Dt,"batchingTexture",r._matricesTexture,ft));const I=n.morphAttributes;(void 0!==I.position||void 0!==I.normal||void 0!==I.color&&!0===ut.isWebGL2)&&Rt.update(r,n,b);(E||v.receiveShadow!==r.receiveShadow)&&(v.receiveShadow=r.receiveShadow,L.setValue(Dt,"receiveShadow",r.receiveShadow));i.isMeshGouraudMaterial&&null!==i.envMap&&(P.envMap.value=l,P.flipEnvMap.value=l.isCubeTexture&&!1===l.isRenderTargetTexture?-1:1);E&&(L.setValue(Dt,"toneMappingExposure",S.toneMappingExposure),v.needsLights&&(D=C,(U=P).ambientLightColor.needsUpdate=D,U.lightProbe.needsUpdate=D,U.directionalLights.needsUpdate=D,U.directionalLightShadows.needsUpdate=D,U.pointLights.needsUpdate=D,U.pointLightShadows.needsUpdate=D,U.spotLights.needsUpdate=D,U.spotLightShadows.needsUpdate=D,U.rectAreaLights.needsUpdate=D,U.hemisphereLights.needsUpdate=D),s&&!0===i.fog&&St.refreshFogUniforms(P,s),St.refreshMaterialUniforms(P,i,z,N,rt),oa.upload(Dt,te(v),P,ft));var U,D;i.isShaderMaterial&&!0===i.uniformsNeedUpdate&&(oa.upload(Dt,te(v),P,ft),i.uniformsNeedUpdate=!1);i.isSpriteMaterial&&L.setValue(Dt,"center",r.center);if(L.setValue(Dt,"modelViewMatrix",r.modelViewMatrix),L.setValue(Dt,"normalMatrix",r.normalMatrix),L.setValue(Dt,"modelMatrix",r.matrixWorld),i.isShaderMaterial||i.isRawShaderMaterial){const t=i.uniformsGroups;for(let e=0,n=t.length;e{function n(){i.forEach((function(t){mt.get(t).currentProgram.isReady()&&i.delete(t)})),0!==i.size?setTimeout(n,10):e(t)}null!==ht.get("KHR_parallel_shader_compile")?n():setTimeout(n,10)}))};let Xt=null;function jt(){Yt.stop()}function qt(){Yt.start()}const Yt=new _r;function Zt(t,e,n,i){if(!1===t.visible)return;if(t.layers.test(e.layers))if(t.isGroup)n=t.renderOrder;else if(t.isLOD)!0===t.autoUpdate&&t.update(e);else if(t.isLight)x.pushLight(t),t.castShadow&&x.pushShadow(t);else if(t.isSprite){if(!t.frustumCulled||et.intersectsSprite(t)){i&&ot.setFromMatrixPosition(t.matrixWorld).applyMatrix4(st);const e=yt.update(t),r=t.material;r.visible&&v.push(t,e,r,n,ot.z,null)}}else if((t.isMesh||t.isLine||t.isPoints)&&(!t.frustumCulled||et.intersectsObject(t))){const e=yt.update(t),r=t.material;if(i&&(void 0!==t.boundingSphere?(null===t.boundingSphere&&t.computeBoundingSphere(),ot.copy(t.boundingSphere.center)):(null===e.boundingSphere&&e.computeBoundingSphere(),ot.copy(e.boundingSphere.center)),ot.applyMatrix4(t.matrixWorld).applyMatrix4(st)),Array.isArray(r)){const i=e.groups;for(let s=0,a=i.length;s0&&function(t,e,n,i){const r=!0===n.isScene?n.overrideMaterial:null;if(null!==r)return;const s=ut.isWebGL2;null===rt&&(rt=new Ge(1,1,{generateMipmaps:!0,type:ht.has("EXT_color_buffer_half_float")?V:F,minFilter:O,samples:s?4:0}));S.getDrawingBufferSize(at),s?rt.setSize(at.x,at.y):rt.setSize(he(at.x),he(at.y));const o=S.getRenderTarget();S.setRenderTarget(rt),S.getClearColor(I),U=S.getClearAlpha(),U<1&&S.setClearColor(16777215,.5);S.clear();const l=S.toneMapping;S.toneMapping=p,Kt(t,n,i),ft.updateMultisampleRenderTarget(rt),ft.updateRenderTargetMipmap(rt);let c=!1;for(let t=0,r=e.length;t0&&Kt(r,e,n),s.length>0&&Kt(s,e,n),o.length>0&&Kt(o,e,n),dt.buffers.depth.setTest(!0),dt.buffers.depth.setMask(!0),dt.buffers.color.setMask(!0),dt.setPolygonOffset(!1)}function Kt(t,e,n){const i=!0===e.isScene?e.overrideMaterial:null;for(let r=0,s=t.length;r0?M[M.length-1]:null,y.pop(),v=y.length>0?y[y.length-1]:null},this.getActiveCubeFace=function(){return T},this.getActiveMipmapLevel=function(){return E},this.getRenderTarget=function(){return w},this.setRenderTargetTextures=function(t,e,n){mt.get(t.texture).__webglTexture=e,mt.get(t.depthTexture).__webglTexture=n;const i=mt.get(t);i.__hasExternalTextures=!0,i.__hasExternalTextures&&(i.__autoAllocateDepthBuffer=void 0===n,i.__autoAllocateDepthBuffer||!0===ht.has("WEBGL_multisampled_render_to_texture")&&(console.warn("THREE.WebGLRenderer: Render-to-texture extension was disabled because an external texture was provided"),i.__useRenderToTexture=!1))},this.setRenderTargetFramebuffer=function(t,e){const n=mt.get(t);n.__webglFramebuffer=e,n.__useDefaultFramebuffer=void 0===e},this.setRenderTarget=function(t,e=0,n=0){w=t,T=e,E=n;let i=!0,r=null,s=!1,a=!1;if(t){const o=mt.get(t);void 0!==o.__useDefaultFramebuffer?(dt.bindFramebuffer(Dt.FRAMEBUFFER,null),i=!1):void 0===o.__webglFramebuffer?ft.setupRenderTarget(t):o.__hasExternalTextures&&ft.rebindTextures(t,mt.get(t.texture).__webglTexture,mt.get(t.depthTexture).__webglTexture);const l=t.texture;(l.isData3DTexture||l.isDataArrayTexture||l.isCompressedArrayTexture)&&(a=!0);const c=mt.get(t).__webglFramebuffer;t.isWebGLCubeRenderTarget?(r=Array.isArray(c[e])?c[e][n]:c[e],s=!0):r=ut.isWebGL2&&t.samples>0&&!1===ft.useMultisampledRTT(t)?mt.get(t).__webglMultisampledFramebuffer:Array.isArray(c)?c[n]:c,C.copy(t.viewport),L.copy(t.scissor),P=t.scissorTest}else C.copy($).multiplyScalar(z).floor(),L.copy(Q).multiplyScalar(z).floor(),P=tt;if(dt.bindFramebuffer(Dt.FRAMEBUFFER,r)&&ut.drawBuffers&&i&&dt.drawBuffers(t,r),dt.viewport(C),dt.scissor(L),dt.setScissorTest(P),s){const i=mt.get(t.texture);Dt.framebufferTexture2D(Dt.FRAMEBUFFER,Dt.COLOR_ATTACHMENT0,Dt.TEXTURE_CUBE_MAP_POSITIVE_X+e,i.__webglTexture,n)}else if(a){const i=mt.get(t.texture),r=e||0;Dt.framebufferTextureLayer(Dt.FRAMEBUFFER,Dt.COLOR_ATTACHMENT0,i.__webglTexture,n||0,r)}A=-1},this.readRenderTargetPixels=function(t,e,n,i,r,s,a){if(!t||!t.isWebGLRenderTarget)return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.");let o=mt.get(t).__webglFramebuffer;if(t.isWebGLCubeRenderTarget&&void 0!==a&&(o=o[a]),o){dt.bindFramebuffer(Dt.FRAMEBUFFER,o);try{const a=t.texture,o=a.format,l=a.type;if(o!==j&&Pt.convert(o)!==Dt.getParameter(Dt.IMPLEMENTATION_COLOR_READ_FORMAT))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.");const c=l===V&&(ht.has("EXT_color_buffer_half_float")||ut.isWebGL2&&ht.has("EXT_color_buffer_float"));if(!(l===F||Pt.convert(l)===Dt.getParameter(Dt.IMPLEMENTATION_COLOR_READ_TYPE)||l===H&&(ut.isWebGL2||ht.has("OES_texture_float")||ht.has("WEBGL_color_buffer_float"))||c))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.");e>=0&&e<=t.width-i&&n>=0&&n<=t.height-r&&Dt.readPixels(e,n,i,r,Pt.convert(o),Pt.convert(l),s)}finally{const t=null!==w?mt.get(w).__webglFramebuffer:null;dt.bindFramebuffer(Dt.FRAMEBUFFER,t)}}},this.copyFramebufferToTexture=function(t,e,n=0){const i=Math.pow(2,-n),r=Math.floor(e.image.width*i),s=Math.floor(e.image.height*i);ft.setTexture2D(e,0),Dt.copyTexSubImage2D(Dt.TEXTURE_2D,n,0,0,t.x,t.y,r,s),dt.unbindTexture()},this.copyTextureToTexture=function(t,e,n,i=0){const r=e.image.width,s=e.image.height,a=Pt.convert(n.format),o=Pt.convert(n.type);ft.setTexture2D(n,0),Dt.pixelStorei(Dt.UNPACK_FLIP_Y_WEBGL,n.flipY),Dt.pixelStorei(Dt.UNPACK_PREMULTIPLY_ALPHA_WEBGL,n.premultiplyAlpha),Dt.pixelStorei(Dt.UNPACK_ALIGNMENT,n.unpackAlignment),e.isDataTexture?Dt.texSubImage2D(Dt.TEXTURE_2D,i,t.x,t.y,r,s,a,o,e.image.data):e.isCompressedTexture?Dt.compressedTexSubImage2D(Dt.TEXTURE_2D,i,t.x,t.y,e.mipmaps[0].width,e.mipmaps[0].height,a,e.mipmaps[0].data):Dt.texSubImage2D(Dt.TEXTURE_2D,i,t.x,t.y,a,o,e.image),0===i&&n.generateMipmaps&&Dt.generateMipmap(Dt.TEXTURE_2D),dt.unbindTexture()},this.copyTextureToTexture3D=function(t,e,n,i,r=0){if(S.isWebGL1Renderer)return void console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: can only be used with WebGL2.");const s=t.max.x-t.min.x+1,a=t.max.y-t.min.y+1,o=t.max.z-t.min.z+1,l=Pt.convert(i.format),c=Pt.convert(i.type);let h;if(i.isData3DTexture)ft.setTexture3D(i,0),h=Dt.TEXTURE_3D;else{if(!i.isDataArrayTexture&&!i.isCompressedArrayTexture)return void console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: only supports THREE.DataTexture3D and THREE.DataTexture2DArray.");ft.setTexture2DArray(i,0),h=Dt.TEXTURE_2D_ARRAY}Dt.pixelStorei(Dt.UNPACK_FLIP_Y_WEBGL,i.flipY),Dt.pixelStorei(Dt.UNPACK_PREMULTIPLY_ALPHA_WEBGL,i.premultiplyAlpha),Dt.pixelStorei(Dt.UNPACK_ALIGNMENT,i.unpackAlignment);const u=Dt.getParameter(Dt.UNPACK_ROW_LENGTH),d=Dt.getParameter(Dt.UNPACK_IMAGE_HEIGHT),p=Dt.getParameter(Dt.UNPACK_SKIP_PIXELS),m=Dt.getParameter(Dt.UNPACK_SKIP_ROWS),f=Dt.getParameter(Dt.UNPACK_SKIP_IMAGES),g=n.isCompressedTexture?n.mipmaps[r]:n.image;Dt.pixelStorei(Dt.UNPACK_ROW_LENGTH,g.width),Dt.pixelStorei(Dt.UNPACK_IMAGE_HEIGHT,g.height),Dt.pixelStorei(Dt.UNPACK_SKIP_PIXELS,t.min.x),Dt.pixelStorei(Dt.UNPACK_SKIP_ROWS,t.min.y),Dt.pixelStorei(Dt.UNPACK_SKIP_IMAGES,t.min.z),n.isDataTexture||n.isData3DTexture?Dt.texSubImage3D(h,r,e.x,e.y,e.z,s,a,o,l,c,g.data):n.isCompressedArrayTexture?(console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: untested support for compressed srcTexture."),Dt.compressedTexSubImage3D(h,r,e.x,e.y,e.z,s,a,o,l,g.data)):Dt.texSubImage3D(h,r,e.x,e.y,e.z,s,a,o,l,c,g),Dt.pixelStorei(Dt.UNPACK_ROW_LENGTH,u),Dt.pixelStorei(Dt.UNPACK_IMAGE_HEIGHT,d),Dt.pixelStorei(Dt.UNPACK_SKIP_PIXELS,p),Dt.pixelStorei(Dt.UNPACK_SKIP_ROWS,m),Dt.pixelStorei(Dt.UNPACK_SKIP_IMAGES,f),0===r&&i.generateMipmaps&&Dt.generateMipmap(h),dt.unbindTexture()},this.initTexture=function(t){t.isCubeTexture?ft.setTextureCube(t,0):t.isData3DTexture?ft.setTexture3D(t,0):t.isDataArrayTexture||t.isCompressedArrayTexture?ft.setTexture2DArray(t,0):ft.setTexture2D(t,0),dt.unbindTexture()},this.resetState=function(){T=0,E=0,w=null,dt.reset(),It.reset()},"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("observe",{detail:this}))}get coordinateSystem(){return $t}get outputColorSpace(){return this._outputColorSpace}set outputColorSpace(t){this._outputColorSpace=t;const e=this.getContext();e.drawingBufferColorSpace=t===Vt?"display-p3":"srgb",e.unpackColorSpace=Re.workingColorSpace===kt?"display-p3":"srgb"}get outputEncoding(){return console.warn("THREE.WebGLRenderer: Property .outputEncoding has been removed. Use .outputColorSpace instead."),this.outputColorSpace===Gt?Bt:Ft}set outputEncoding(t){console.warn("THREE.WebGLRenderer: Property .outputEncoding has been removed. Use .outputColorSpace instead."),this.outputColorSpace=t===Bt?Gt:Ht}get useLegacyLights(){return console.warn("THREE.WebGLRenderer: The property .useLegacyLights has been deprecated. Migrate your lighting according to the following guide: https://discourse.threejs.org/t/updates-to-lighting-in-three-js-r155/53733."),this._useLegacyLights}set useLegacyLights(t){console.warn("THREE.WebGLRenderer: The property .useLegacyLights has been deprecated. Migrate your lighting according to the following guide: https://discourse.threejs.org/t/updates-to-lighting-in-three-js-r155/53733."),this._useLegacyLights=t}}class eo extends to{}eo.prototype.isWebGL1Renderer=!0;class no{constructor(t,e=25e-5){this.isFogExp2=!0,this.name="",this.color=new ci(t),this.density=e}clone(){return new no(this.color,this.density)}toJSON(){return{type:"FogExp2",name:this.name,color:this.color.getHex(),density:this.density}}}class io{constructor(t,e=1,n=1e3){this.isFog=!0,this.name="",this.color=new ci(t),this.near=e,this.far=n}clone(){return new io(this.color,this.near,this.far)}toJSON(){return{type:"Fog",name:this.name,color:this.color.getHex(),near:this.near,far:this.far}}}class ro extends jn{constructor(){super(),this.isScene=!0,this.type="Scene",this.background=null,this.environment=null,this.fog=null,this.backgroundBlurriness=0,this.backgroundIntensity=1,this.overrideMaterial=null,"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("observe",{detail:this}))}copy(t,e){return super.copy(t,e),null!==t.background&&(this.background=t.background.clone()),null!==t.environment&&(this.environment=t.environment.clone()),null!==t.fog&&(this.fog=t.fog.clone()),this.backgroundBlurriness=t.backgroundBlurriness,this.backgroundIntensity=t.backgroundIntensity,null!==t.overrideMaterial&&(this.overrideMaterial=t.overrideMaterial.clone()),this.matrixAutoUpdate=t.matrixAutoUpdate,this}toJSON(t){const e=super.toJSON(t);return null!==this.fog&&(e.object.fog=this.fog.toJSON()),this.backgroundBlurriness>0&&(e.object.backgroundBlurriness=this.backgroundBlurriness),1!==this.backgroundIntensity&&(e.object.backgroundIntensity=this.backgroundIntensity),e}}class so{constructor(t,e){this.isInterleavedBuffer=!0,this.array=t,this.stride=e,this.count=void 0!==t?t.length/e:0,this.usage=Zt,this._updateRange={offset:0,count:-1},this.updateRanges=[],this.version=0,this.uuid=se()}onUploadCallback(){}set needsUpdate(t){!0===t&&this.version++}get updateRange(){return console.warn("THREE.InterleavedBuffer: updateRange() is deprecated and will be removed in r169. Use addUpdateRange() instead."),this._updateRange}setUsage(t){return this.usage=t,this}addUpdateRange(t,e){this.updateRanges.push({start:t,count:e})}clearUpdateRanges(){this.updateRanges.length=0}copy(t){return this.array=new t.array.constructor(t.array),this.count=t.count,this.stride=t.stride,this.usage=t.usage,this}copyAt(t,e,n){t*=this.stride,n*=e.stride;for(let i=0,r=this.stride;it.far||e.push({distance:o,point:ho.clone(),uv:ri.getInterpolation(ho,_o,vo,xo,yo,Mo,So,new me),face:null,object:this})}copy(t,e){return super.copy(t,e),void 0!==t.center&&this.center.copy(t.center),this.material=t.material,this}}function To(t,e,n,i,r,s){mo.subVectors(t,n).addScalar(.5).multiply(i),void 0!==r?(fo.x=s*mo.x-r*mo.y,fo.y=r*mo.x+s*mo.y):fo.copy(mo),t.copy(e),t.x+=fo.x,t.y+=fo.y,t.applyMatrix4(go)}const Eo=new We,wo=new We;class Ao extends jn{constructor(){super(),this._currentLevel=0,this.type="LOD",Object.defineProperties(this,{levels:{enumerable:!0,value:[]},isLOD:{value:!0}}),this.autoUpdate=!0}copy(t){super.copy(t,!1);const e=t.levels;for(let t=0,n=e.length;t0){let n,i;for(n=1,i=e.length;n0){Eo.setFromMatrixPosition(this.matrixWorld);const n=t.ray.origin.distanceTo(Eo);this.getObjectForDistance(n).raycast(t,e)}}update(t){const e=this.levels;if(e.length>1){Eo.setFromMatrixPosition(t.matrixWorld),wo.setFromMatrixPosition(this.matrixWorld);const n=Eo.distanceTo(wo)/t.zoom;let i,r;for(e[0].object.visible=!0,i=1,r=e.length;i=t))break;e[i-1].object.visible=!1,e[i].object.visible=!0}for(this._currentLevel=i-1;i=n.length&&n.push({start:-1,count:-1,z:-1});const r=n[this.index];i.push(r),this.index++,r.start=t.start,r.count=t.count,r.z=e}reset(){this.list.length=0,this.index=0}}const el="batchId",nl=new Mn,il=new Mn,rl=new Mn,sl=new Mn,al=new gr,ol=new qe,ll=new dn,cl=new We,hl=new tl,ul=new Ji,dl=[];function pl(t,e,n=0){const i=e.itemSize;if(t.isInterleavedBufferAttribute||t.array.constructor!==e.array.constructor){const r=t.count;for(let s=0;s65536?new Uint32Array(r):new Uint16Array(r);e.setIndex(new Mi(t,1))}const s=i>65536?new Uint32Array(n):new Uint16Array(n);e.setAttribute(el,new Mi(s,1)),this._geometryInitialized=!0}}_validateGeometry(t){if(t.getAttribute(el))throw new Error(`BatchedMesh: Geometry cannot use attribute "${el}"`);const e=this.geometry;if(Boolean(t.getIndex())!==Boolean(e.getIndex()))throw new Error('BatchedMesh: All geometries must consistently have "index".');for(const n in e.attributes){if(n===el)continue;if(!t.hasAttribute(n))throw new Error(`BatchedMesh: Added geometry missing "${n}". All geometries must have consistent attributes.`);const i=t.getAttribute(n),r=e.getAttribute(n);if(i.itemSize!==r.itemSize||i.normalized!==r.normalized)throw new Error("BatchedMesh: All attributes must have a consistent itemSize and normalized value.")}}setCustomSort(t){return this.customSort=t,this}computeBoundingBox(){null===this.boundingBox&&(this.boundingBox=new qe);const t=this._geometryCount,e=this.boundingBox,n=this._active;e.makeEmpty();for(let i=0;i=this._maxGeometryCount)throw new Error("BatchedMesh: Maximum geometry count reached.");const i={vertexStart:-1,vertexCount:-1,indexStart:-1,indexCount:-1};let r=null;const s=this._reservedRanges,a=this._drawRanges,o=this._bounds;0!==this._geometryCount&&(r=s[s.length-1]),i.vertexCount=-1===e?t.getAttribute("position").count:e,i.vertexStart=null===r?0:r.vertexStart+r.vertexCount;const l=t.getIndex(),c=null!==l;if(c&&(i.indexCount=-1===n?l.count:n,i.indexStart=null===r?0:r.indexStart+r.indexCount),-1!==i.indexStart&&i.indexStart+i.indexCount>this._maxIndexCount||i.vertexStart+i.vertexCount>this._maxVertexCount)throw new Error("BatchedMesh: Reserved space request exceeds the maximum buffer size.");const h=this._visibility,u=this._active,d=this._matricesTexture,p=this._matricesTexture.image.data;h.push(!0),u.push(!0);const m=this._geometryCount;this._geometryCount++,rl.toArray(p,16*m),d.needsUpdate=!0,s.push(i),a.push({start:c?i.indexStart:i.vertexStart,count:-1}),o.push({boxInitialized:!1,box:new qe,sphereInitialized:!1,sphere:new dn});const f=this.geometry.getAttribute(el);for(let t=0;t=this._geometryCount)throw new Error("BatchedMesh: Maximum geometry count reached.");this._validateGeometry(e);const n=this.geometry,i=null!==n.getIndex(),r=n.getIndex(),s=e.getIndex(),a=this._reservedRanges[t];if(i&&s.count>a.indexCount||e.attributes.position.count>a.vertexCount)throw new Error("BatchedMesh: Reserved space not large enough for provided geometry.");const o=a.vertexStart,l=a.vertexCount;for(const t in n.attributes){if(t===el)continue;const i=e.getAttribute(t),r=n.getAttribute(t);pl(i,r,o);const s=i.itemSize;for(let t=i.count,e=l;t=e.length||!1===e[t]||(e[t]=!1,this._visibilityChanged=!0),this}getBoundingBoxAt(t,e){if(!1===this._active[t])return this;const n=this._bounds[t],i=n.box,r=this.geometry;if(!1===n.boxInitialized){i.makeEmpty();const e=r.index,s=r.attributes.position,a=this._drawRanges[t];for(let t=a.start,n=a.start+a.count;t=this._geometryCount||!1===n[t]||(e.toArray(r,16*t),i.needsUpdate=!0),this}getMatrixAt(t,e){const n=this._active,i=this._matricesTexture.image.data;return t>=this._geometryCount||!1===n[t]?null:e.fromArray(i,16*t)}setVisibleAt(t,e){const n=this._visibility,i=this._active;return t>=this._geometryCount||!1===i[t]||n[t]===e||(n[t]=e,this._visibilityChanged=!0),this}getVisibleAt(t){const e=this._visibility,n=this._active;return!(t>=this._geometryCount||!1===n[t])&&e[t]}raycast(t,e){const n=this._visibility,i=this._active,r=this._drawRanges,s=this._geometryCount,a=this.matrixWorld,o=this.geometry;ul.material=this.material,ul.geometry.index=o.index,ul.geometry.attributes=o.attributes,null===ul.geometry.boundingBox&&(ul.geometry.boundingBox=new qe),null===ul.geometry.boundingSphere&&(ul.geometry.boundingSphere=new dn);for(let o=0;o({...t}))),this._reservedRanges=t._reservedRanges.map((t=>({...t}))),this._visibility=t._visibility.slice(),this._active=t._active.slice(),this._bounds=t._bounds.map((t=>({boxInitialized:t.boxInitialized,box:t.box.clone(),sphereInitialized:t.sphereInitialized,sphere:t.sphere.clone()}))),this._maxGeometryCount=t._maxGeometryCount,this._maxVertexCount=t._maxVertexCount,this._maxIndexCount=t._maxIndexCount,this._geometryInitialized=t._geometryInitialized,this._geometryCount=t._geometryCount,this._multiDrawCounts=t._multiDrawCounts.slice(),this._multiDrawStarts=t._multiDrawStarts.slice(),this._matricesTexture=t._matricesTexture.clone(),this._matricesTexture.image.data=this._matricesTexture.image.slice(),this}dispose(){return this.geometry.dispose(),this._matricesTexture.dispose(),this._matricesTexture=null,this}onBeforeRender(t,e,n,i,r){if(!this._visibilityChanged&&!this.perObjectFrustumCulled&&!this.sortObjects)return;const s=i.getIndex(),a=null===s?1:s.array.BYTES_PER_ELEMENT,o=this._visibility,l=this._multiDrawStarts,c=this._multiDrawCounts,h=this._drawRanges,u=this.perObjectFrustumCulled;u&&(sl.multiplyMatrices(n.projectionMatrix,n.matrixWorldInverse).multiply(this.matrixWorld),al.setFromProjectionMatrix(sl,t.isWebGPURenderer?Qt:$t));let d=0;if(this.sortObjects){il.copy(this.matrixWorld).invert(),cl.setFromMatrixPosition(n.matrixWorld).applyMatrix4(il);for(let t=0,e=o.length;to)continue;u.applyMatrix4(this.matrixWorld);const s=t.ray.origin.distanceTo(u);st.far||e.push({distance:s,point:h.clone().applyMatrix4(this.matrixWorld),index:n,face:null,faceIndex:null,object:this})}}else{for(let n=Math.max(0,s.start),i=Math.min(m.count,s.start+s.count)-1;no)continue;u.applyMatrix4(this.matrixWorld);const i=t.ray.origin.distanceTo(u);it.far||e.push({distance:i,point:h.clone().applyMatrix4(this.matrixWorld),index:n,face:null,faceIndex:null,object:this})}}}updateMorphTargets(){const t=this.geometry.morphAttributes,e=Object.keys(t);if(e.length>0){const n=t[e[0]];if(void 0!==n){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let t=0,e=n.length;t0){const n=t[e[0]];if(void 0!==n){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let t=0,e=n.length;tr.far)return;s.push({distance:l,distanceToRay:Math.sqrt(o),point:n,index:e,face:null,object:a})}}class Ul extends Fe{constructor(t,e,n,i,r,s,a,o,l,c,h,u){super(null,s,a,o,l,c,i,r,h,u),this.isCompressedTexture=!0,this.image={width:e,height:n},this.mipmaps=t,this.flipY=!1,this.generateMipmaps=!1}}class Dl{constructor(){this.type="Curve",this.arcLengthDivisions=200}getPoint(){return console.warn("THREE.Curve: .getPoint() not implemented."),null}getPointAt(t,e){const n=this.getUtoTmapping(t);return this.getPoint(n,e)}getPoints(t=5){const e=[];for(let n=0;n<=t;n++)e.push(this.getPoint(n/t));return e}getSpacedPoints(t=5){const e=[];for(let n=0;n<=t;n++)e.push(this.getPointAt(n/t));return e}getLength(){const t=this.getLengths();return t[t.length-1]}getLengths(t=this.arcLengthDivisions){if(this.cacheArcLengths&&this.cacheArcLengths.length===t+1&&!this.needsUpdate)return this.cacheArcLengths;this.needsUpdate=!1;const e=[];let n,i=this.getPoint(0),r=0;e.push(0);for(let s=1;s<=t;s++)n=this.getPoint(s/t),r+=n.distanceTo(i),e.push(r),i=n;return this.cacheArcLengths=e,e}updateArcLengths(){this.needsUpdate=!0,this.getLengths()}getUtoTmapping(t,e){const n=this.getLengths();let i=0;const r=n.length;let s;s=e||t*n[r-1];let a,o=0,l=r-1;for(;o<=l;)if(i=Math.floor(o+(l-o)/2),a=n[i]-s,a<0)o=i+1;else{if(!(a>0)){l=i;break}l=i-1}if(i=l,n[i]===s)return i/(r-1);const c=n[i];return(i+(s-c)/(n[i+1]-c))/(r-1)}getTangent(t,e){const n=1e-4;let i=t-n,r=t+n;i<0&&(i=0),r>1&&(r=1);const s=this.getPoint(i),a=this.getPoint(r),o=e||(s.isVector2?new me:new We);return o.copy(a).sub(s).normalize(),o}getTangentAt(t,e){const n=this.getUtoTmapping(t);return this.getTangent(n,e)}computeFrenetFrames(t,e){const n=new We,i=[],r=[],s=[],a=new We,o=new Mn;for(let e=0;e<=t;e++){const n=e/t;i[e]=this.getTangentAt(n,new We)}r[0]=new We,s[0]=new We;let l=Number.MAX_VALUE;const c=Math.abs(i[0].x),h=Math.abs(i[0].y),u=Math.abs(i[0].z);c<=l&&(l=c,n.set(1,0,0)),h<=l&&(l=h,n.set(0,1,0)),u<=l&&n.set(0,0,1),a.crossVectors(i[0],n).normalize(),r[0].crossVectors(i[0],a),s[0].crossVectors(i[0],r[0]);for(let e=1;e<=t;e++){if(r[e]=r[e-1].clone(),s[e]=s[e-1].clone(),a.crossVectors(i[e-1],i[e]),a.length()>Number.EPSILON){a.normalize();const t=Math.acos(ae(i[e-1].dot(i[e]),-1,1));r[e].applyMatrix4(o.makeRotationAxis(a,t))}s[e].crossVectors(i[e],r[e])}if(!0===e){let e=Math.acos(ae(r[0].dot(r[t]),-1,1));e/=t,i[0].dot(a.crossVectors(r[0],r[t]))>0&&(e=-e);for(let n=1;n<=t;n++)r[n].applyMatrix4(o.makeRotationAxis(i[n],e*n)),s[n].crossVectors(i[n],r[n])}return{tangents:i,normals:r,binormals:s}}clone(){return(new this.constructor).copy(this)}copy(t){return this.arcLengthDivisions=t.arcLengthDivisions,this}toJSON(){const t={metadata:{version:4.6,type:"Curve",generator:"Curve.toJSON"}};return t.arcLengthDivisions=this.arcLengthDivisions,t.type=this.type,t}fromJSON(t){return this.arcLengthDivisions=t.arcLengthDivisions,this}}class Nl extends Dl{constructor(t=0,e=0,n=1,i=1,r=0,s=2*Math.PI,a=!1,o=0){super(),this.isEllipseCurve=!0,this.type="EllipseCurve",this.aX=t,this.aY=e,this.xRadius=n,this.yRadius=i,this.aStartAngle=r,this.aEndAngle=s,this.aClockwise=a,this.aRotation=o}getPoint(t,e){const n=e||new me,i=2*Math.PI;let r=this.aEndAngle-this.aStartAngle;const s=Math.abs(r)i;)r-=i;r0?0:(Math.floor(Math.abs(l)/r)+1)*r:0===c&&l===r-1&&(l=r-2,c=1),this.closed||l>0?a=i[(l-1)%r]:(Bl.subVectors(i[0],i[1]).add(i[0]),a=Bl);const h=i[l%r],u=i[(l+1)%r];if(this.closed||l+2i.length-2?i.length-1:s+1],h=i[s>i.length-3?i.length-1:s+2];return n.set(kl(a,o.x,l.x,c.x,h.x),kl(a,o.y,l.y,c.y,h.y)),n}copy(t){super.copy(t),this.points=[];for(let e=0,n=t.points.length;e=n){const t=i[r]-n,s=this.curves[r],a=s.getLength(),o=0===a?0:1-t/a;return s.getPointAt(o,e)}r++}return null}getLength(){const t=this.getCurveLengths();return t[t.length-1]}updateArcLengths(){this.needsUpdate=!0,this.cacheLengths=null,this.getCurveLengths()}getCurveLengths(){if(this.cacheLengths&&this.cacheLengths.length===this.curves.length)return this.cacheLengths;const t=[];let e=0;for(let n=0,i=this.curves.length;n1&&!e[e.length-1].equals(e[0])&&e.push(e[0]),e}copy(t){super.copy(t),this.curves=[];for(let e=0,n=t.curves.length;e0){const t=l.getPoint(0);t.equals(this.currentPoint)||this.lineTo(t.x,t.y)}this.curves.push(l);const c=l.getPoint(1);return this.currentPoint.copy(c),this}copy(t){return super.copy(t),this.currentPoint.copy(t.currentPoint),this}toJSON(){const t=super.toJSON();return t.currentPoint=this.currentPoint.toArray(),t}fromJSON(t){return super.fromJSON(t),this.currentPoint.fromArray(t.currentPoint),this}}class nc extends Ii{constructor(t=[new me(0,-.5),new me(.5,0),new me(0,.5)],e=12,n=0,i=2*Math.PI){super(),this.type="LatheGeometry",this.parameters={points:t,segments:e,phiStart:n,phiLength:i},e=Math.floor(e),i=ae(i,0,2*Math.PI);const r=[],s=[],a=[],o=[],l=[],c=1/e,h=new We,u=new me,d=new We,p=new We,m=new We;let f=0,g=0;for(let e=0;e<=t.length-1;e++)switch(e){case 0:f=t[e+1].x-t[e].x,g=t[e+1].y-t[e].y,d.x=1*g,d.y=-f,d.z=0*g,m.copy(d),d.normalize(),o.push(d.x,d.y,d.z);break;case t.length-1:o.push(m.x,m.y,m.z);break;default:f=t[e+1].x-t[e].x,g=t[e+1].y-t[e].y,d.x=1*g,d.y=-f,d.z=0*g,p.copy(d),d.x+=m.x,d.y+=m.y,d.z+=m.z,d.normalize(),o.push(d.x,d.y,d.z),m.copy(p)}for(let r=0;r<=e;r++){const d=n+r*c*i,p=Math.sin(d),m=Math.cos(d);for(let n=0;n<=t.length-1;n++){h.x=t[n].x*p,h.y=t[n].y,h.z=t[n].x*m,s.push(h.x,h.y,h.z),u.x=r/e,u.y=n/(t.length-1),a.push(u.x,u.y);const i=o[3*n+0]*p,c=o[3*n+1],d=o[3*n+0]*m;l.push(i,c,d)}}for(let n=0;n0&&_(!0),e>0&&_(!1)),this.setIndex(c),this.setAttribute("position",new Ti(h,3)),this.setAttribute("normal",new Ti(u,3)),this.setAttribute("uv",new Ti(d,2))}copy(t){return super.copy(t),this.parameters=Object.assign({},t.parameters),this}static fromJSON(t){return new sc(t.radiusTop,t.radiusBottom,t.height,t.radialSegments,t.heightSegments,t.openEnded,t.thetaStart,t.thetaLength)}}class ac extends sc{constructor(t=1,e=1,n=32,i=1,r=!1,s=0,a=2*Math.PI){super(0,t,e,n,i,r,s,a),this.type="ConeGeometry",this.parameters={radius:t,height:e,radialSegments:n,heightSegments:i,openEnded:r,thetaStart:s,thetaLength:a}}static fromJSON(t){return new ac(t.radius,t.height,t.radialSegments,t.heightSegments,t.openEnded,t.thetaStart,t.thetaLength)}}class oc extends Ii{constructor(t=[],e=[],n=1,i=0){super(),this.type="PolyhedronGeometry",this.parameters={vertices:t,indices:e,radius:n,detail:i};const r=[],s=[];function a(t,e,n,i){const r=i+1,s=[];for(let i=0;i<=r;i++){s[i]=[];const a=t.clone().lerp(n,i/r),o=e.clone().lerp(n,i/r),l=r-i;for(let t=0;t<=l;t++)s[i][t]=0===t&&i===r?a:a.clone().lerp(o,t/l)}for(let t=0;t.9&&a<.1&&(e<.2&&(s[t+0]+=1),n<.2&&(s[t+2]+=1),i<.2&&(s[t+4]+=1))}}()}(),this.setAttribute("position",new Ti(r,3)),this.setAttribute("normal",new Ti(r.slice(),3)),this.setAttribute("uv",new Ti(s,2)),0===i?this.computeVertexNormals():this.normalizeNormals()}copy(t){return super.copy(t),this.parameters=Object.assign({},t.parameters),this}static fromJSON(t){return new oc(t.vertices,t.indices,t.radius,t.details)}}class lc extends oc{constructor(t=1,e=0){const n=(1+Math.sqrt(5))/2,i=1/n;super([-1,-1,-1,-1,-1,1,-1,1,-1,-1,1,1,1,-1,-1,1,-1,1,1,1,-1,1,1,1,0,-i,-n,0,-i,n,0,i,-n,0,i,n,-i,-n,0,-i,n,0,i,-n,0,i,n,0,-n,0,-i,n,0,-i,-n,0,i,n,0,i],[3,11,7,3,7,15,3,15,13,7,19,17,7,17,6,7,6,15,17,4,8,17,8,10,17,10,6,8,0,16,8,16,2,8,2,10,0,12,1,0,1,18,0,18,16,6,10,2,6,2,13,6,13,15,2,16,18,2,18,3,2,3,13,18,1,9,18,9,11,18,11,3,4,14,12,4,12,0,4,0,8,11,9,5,11,5,19,11,19,7,19,5,14,19,14,4,19,4,17,1,12,14,1,14,5,1,5,9],t,e),this.type="DodecahedronGeometry",this.parameters={radius:t,detail:e}}static fromJSON(t){return new lc(t.radius,t.detail)}}const cc=new We,hc=new We,uc=new We,dc=new ri;class pc extends Ii{constructor(t=null,e=1){if(super(),this.type="EdgesGeometry",this.parameters={geometry:t,thresholdAngle:e},null!==t){const n=4,i=Math.pow(10,n),r=Math.cos(ie*e),s=t.getIndex(),a=t.getAttribute("position"),o=s?s.count:a.count,l=[0,0,0],c=["a","b","c"],h=new Array(3),u={},d=[];for(let t=0;t80*n){o=c=t[0],l=h=t[1];for(let e=n;ec&&(c=u),d>h&&(h=d);p=Math.max(c-o,h-l),p=0!==p?32767/p:0}return vc(s,a,n,o,l,p,0),a};function gc(t,e,n,i,r){let s,a;if(r===function(t,e,n,i){let r=0;for(let s=e,a=n-i;s0)for(s=e;s=e;s-=i)a=Fc(s,t[s],t[s+1],a);return a&&Pc(a,a.next)&&(Bc(a),a=a.next),a}function _c(t,e){if(!t)return t;e||(e=t);let n,i=t;do{if(n=!1,i.steiner||!Pc(i,i.next)&&0!==Lc(i.prev,i,i.next))i=i.next;else{if(Bc(i),i=e=i.prev,i===i.next)break;n=!0}}while(n||i!==e);return e}function vc(t,e,n,i,r,s,a){if(!t)return;!a&&s&&function(t,e,n,i){let r=t;do{0===r.z&&(r.z=wc(r.x,r.y,e,n,i)),r.prevZ=r.prev,r.nextZ=r.next,r=r.next}while(r!==t);r.prevZ.nextZ=null,r.prevZ=null,function(t){let e,n,i,r,s,a,o,l,c=1;do{for(n=t,t=null,s=null,a=0;n;){for(a++,i=n,o=0,e=0;e0||l>0&&i;)0!==o&&(0===l||!i||n.z<=i.z)?(r=n,n=n.nextZ,o--):(r=i,i=i.nextZ,l--),s?s.nextZ=r:t=r,r.prevZ=s,s=r;n=i}s.nextZ=null,c*=2}while(a>1)}(r)}(t,i,r,s);let o,l,c=t;for(;t.prev!==t.next;)if(o=t.prev,l=t.next,s?yc(t,i,r,s):xc(t))e.push(o.i/n|0),e.push(t.i/n|0),e.push(l.i/n|0),Bc(t),t=l.next,c=l.next;else if((t=l)===c){a?1===a?vc(t=Mc(_c(t),e,n),e,n,i,r,s,2):2===a&&Sc(t,e,n,i,r,s):vc(_c(t),e,n,i,r,s,1);break}}function xc(t){const e=t.prev,n=t,i=t.next;if(Lc(e,n,i)>=0)return!1;const r=e.x,s=n.x,a=i.x,o=e.y,l=n.y,c=i.y,h=rs?r>a?r:a:s>a?s:a,p=o>l?o>c?o:c:l>c?l:c;let m=i.next;for(;m!==e;){if(m.x>=h&&m.x<=d&&m.y>=u&&m.y<=p&&Rc(r,o,s,l,a,c,m.x,m.y)&&Lc(m.prev,m,m.next)>=0)return!1;m=m.next}return!0}function yc(t,e,n,i){const r=t.prev,s=t,a=t.next;if(Lc(r,s,a)>=0)return!1;const o=r.x,l=s.x,c=a.x,h=r.y,u=s.y,d=a.y,p=ol?o>c?o:c:l>c?l:c,g=h>u?h>d?h:d:u>d?u:d,_=wc(p,m,e,n,i),v=wc(f,g,e,n,i);let x=t.prevZ,y=t.nextZ;for(;x&&x.z>=_&&y&&y.z<=v;){if(x.x>=p&&x.x<=f&&x.y>=m&&x.y<=g&&x!==r&&x!==a&&Rc(o,h,l,u,c,d,x.x,x.y)&&Lc(x.prev,x,x.next)>=0)return!1;if(x=x.prevZ,y.x>=p&&y.x<=f&&y.y>=m&&y.y<=g&&y!==r&&y!==a&&Rc(o,h,l,u,c,d,y.x,y.y)&&Lc(y.prev,y,y.next)>=0)return!1;y=y.nextZ}for(;x&&x.z>=_;){if(x.x>=p&&x.x<=f&&x.y>=m&&x.y<=g&&x!==r&&x!==a&&Rc(o,h,l,u,c,d,x.x,x.y)&&Lc(x.prev,x,x.next)>=0)return!1;x=x.prevZ}for(;y&&y.z<=v;){if(y.x>=p&&y.x<=f&&y.y>=m&&y.y<=g&&y!==r&&y!==a&&Rc(o,h,l,u,c,d,y.x,y.y)&&Lc(y.prev,y,y.next)>=0)return!1;y=y.nextZ}return!0}function Mc(t,e,n){let i=t;do{const r=i.prev,s=i.next.next;!Pc(r,s)&&Ic(r,i,i.next,s)&&Nc(r,s)&&Nc(s,r)&&(e.push(r.i/n|0),e.push(i.i/n|0),e.push(s.i/n|0),Bc(i),Bc(i.next),i=t=s),i=i.next}while(i!==t);return _c(i)}function Sc(t,e,n,i,r,s){let a=t;do{let t=a.next.next;for(;t!==a.prev;){if(a.i!==t.i&&Cc(a,t)){let o=Oc(a,t);return a=_c(a,a.next),o=_c(o,o.next),vc(a,e,n,i,r,s,0),void vc(o,e,n,i,r,s,0)}t=t.next}a=a.next}while(a!==t)}function bc(t,e){return t.x-e.x}function Tc(t,e){const n=function(t,e){let n,i=e,r=-1/0;const s=t.x,a=t.y;do{if(a<=i.y&&a>=i.next.y&&i.next.y!==i.y){const t=i.x+(a-i.y)*(i.next.x-i.x)/(i.next.y-i.y);if(t<=s&&t>r&&(r=t,n=i.x=i.x&&i.x>=l&&s!==i.x&&Rc(an.x||i.x===n.x&&Ec(n,i)))&&(n=i,u=h)),i=i.next}while(i!==o);return n}(t,e);if(!n)return e;const i=Oc(n,t);return _c(i,i.next),_c(n,n.next)}function Ec(t,e){return Lc(t.prev,t,e.prev)<0&&Lc(e.next,t,t.next)<0}function wc(t,e,n,i,r){return(t=1431655765&((t=858993459&((t=252645135&((t=16711935&((t=(t-n)*r|0)|t<<8))|t<<4))|t<<2))|t<<1))|(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=(e-i)*r|0)|e<<8))|e<<4))|e<<2))|e<<1))<<1}function Ac(t){let e=t,n=t;do{(e.x=(t-a)*(s-o)&&(t-a)*(i-o)>=(n-a)*(e-o)&&(n-a)*(s-o)>=(r-a)*(i-o)}function Cc(t,e){return t.next.i!==e.i&&t.prev.i!==e.i&&!function(t,e){let n=t;do{if(n.i!==t.i&&n.next.i!==t.i&&n.i!==e.i&&n.next.i!==e.i&&Ic(n,n.next,t,e))return!0;n=n.next}while(n!==t);return!1}(t,e)&&(Nc(t,e)&&Nc(e,t)&&function(t,e){let n=t,i=!1;const r=(t.x+e.x)/2,s=(t.y+e.y)/2;do{n.y>s!=n.next.y>s&&n.next.y!==n.y&&r<(n.next.x-n.x)*(s-n.y)/(n.next.y-n.y)+n.x&&(i=!i),n=n.next}while(n!==t);return i}(t,e)&&(Lc(t.prev,t,e.prev)||Lc(t,e.prev,e))||Pc(t,e)&&Lc(t.prev,t,t.next)>0&&Lc(e.prev,e,e.next)>0)}function Lc(t,e,n){return(e.y-t.y)*(n.x-e.x)-(e.x-t.x)*(n.y-e.y)}function Pc(t,e){return t.x===e.x&&t.y===e.y}function Ic(t,e,n,i){const r=Dc(Lc(t,e,n)),s=Dc(Lc(t,e,i)),a=Dc(Lc(n,i,t)),o=Dc(Lc(n,i,e));return r!==s&&a!==o||(!(0!==r||!Uc(t,n,e))||(!(0!==s||!Uc(t,i,e))||(!(0!==a||!Uc(n,t,i))||!(0!==o||!Uc(n,e,i)))))}function Uc(t,e,n){return e.x<=Math.max(t.x,n.x)&&e.x>=Math.min(t.x,n.x)&&e.y<=Math.max(t.y,n.y)&&e.y>=Math.min(t.y,n.y)}function Dc(t){return t>0?1:t<0?-1:0}function Nc(t,e){return Lc(t.prev,t,t.next)<0?Lc(t,e,t.next)>=0&&Lc(t,t.prev,e)>=0:Lc(t,e,t.prev)<0||Lc(t,t.next,e)<0}function Oc(t,e){const n=new zc(t.i,t.x,t.y),i=new zc(e.i,e.x,e.y),r=t.next,s=e.prev;return t.next=e,e.prev=t,n.next=r,r.prev=n,i.next=n,n.prev=i,s.next=i,i.prev=s,i}function Fc(t,e,n,i){const r=new zc(t,e,n);return i?(r.next=i.next,r.prev=i,i.next.prev=r,i.next=r):(r.prev=r,r.next=r),r}function Bc(t){t.next.prev=t.prev,t.prev.next=t.next,t.prevZ&&(t.prevZ.nextZ=t.nextZ),t.nextZ&&(t.nextZ.prevZ=t.prevZ)}function zc(t,e,n){this.i=t,this.x=e,this.y=n,this.prev=null,this.next=null,this.z=0,this.prevZ=null,this.nextZ=null,this.steiner=!1}class Gc{static area(t){const e=t.length;let n=0;for(let i=e-1,r=0;r2&&t[e-1].equals(t[0])&&t.pop()}function Vc(t,e){for(let n=0;nNumber.EPSILON){const u=Math.sqrt(h),d=Math.sqrt(l*l+c*c),p=e.x-o/u,m=e.y+a/u,f=((n.x-c/d-p)*c-(n.y+l/d-m)*l)/(a*c-o*l);i=p+a*f-t.x,r=m+o*f-t.y;const g=i*i+r*r;if(g<=2)return new me(i,r);s=Math.sqrt(g/2)}else{let t=!1;a>Number.EPSILON?l>Number.EPSILON&&(t=!0):a<-Number.EPSILON?l<-Number.EPSILON&&(t=!0):Math.sign(o)===Math.sign(c)&&(t=!0),t?(i=-o,r=a,s=Math.sqrt(h)):(i=a,r=o,s=Math.sqrt(h/2))}return new me(i/s,r/s)}const P=[];for(let t=0,e=w.length,n=e-1,i=t+1;t=0;t--){const e=t/p,n=h*Math.cos(e*Math.PI/2),i=u*Math.sin(e*Math.PI/2)+d;for(let t=0,e=w.length;t=0;){const i=n;let r=n-1;r<0&&(r=t.length-1);for(let t=0,n=o+2*p;t0)&&d.push(e,r,l),(t!==n-1||o0!=t>0&&this.version++,this._anisotropy=t}get clearcoat(){return this._clearcoat}set clearcoat(t){this._clearcoat>0!=t>0&&this.version++,this._clearcoat=t}get iridescence(){return this._iridescence}set iridescence(t){this._iridescence>0!=t>0&&this.version++,this._iridescence=t}get sheen(){return this._sheen}set sheen(t){this._sheen>0!=t>0&&this.version++,this._sheen=t}get transmission(){return this._transmission}set transmission(t){this._transmission>0!=t>0&&this.version++,this._transmission=t}copy(t){return super.copy(t),this.defines={STANDARD:"",PHYSICAL:""},this.anisotropy=t.anisotropy,this.anisotropyRotation=t.anisotropyRotation,this.anisotropyMap=t.anisotropyMap,this.clearcoat=t.clearcoat,this.clearcoatMap=t.clearcoatMap,this.clearcoatRoughness=t.clearcoatRoughness,this.clearcoatRoughnessMap=t.clearcoatRoughnessMap,this.clearcoatNormalMap=t.clearcoatNormalMap,this.clearcoatNormalScale.copy(t.clearcoatNormalScale),this.ior=t.ior,this.iridescence=t.iridescence,this.iridescenceMap=t.iridescenceMap,this.iridescenceIOR=t.iridescenceIOR,this.iridescenceThicknessRange=[...t.iridescenceThicknessRange],this.iridescenceThicknessMap=t.iridescenceThicknessMap,this.sheen=t.sheen,this.sheenColor.copy(t.sheenColor),this.sheenColorMap=t.sheenColorMap,this.sheenRoughness=t.sheenRoughness,this.sheenRoughnessMap=t.sheenRoughnessMap,this.transmission=t.transmission,this.transmissionMap=t.transmissionMap,this.thickness=t.thickness,this.thicknessMap=t.thicknessMap,this.attenuationDistance=t.attenuationDistance,this.attenuationColor.copy(t.attenuationColor),this.specularIntensity=t.specularIntensity,this.specularIntensityMap=t.specularIntensityMap,this.specularColor.copy(t.specularColor),this.specularColorMap=t.specularColorMap,this}}class oh extends di{constructor(t){super(),this.isMeshPhongMaterial=!0,this.type="MeshPhongMaterial",this.color=new ci(16777215),this.specular=new ci(1118481),this.shininess=30,this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new ci(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=0,this.normalScale=new me(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=h,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.flatShading=!1,this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.specular.copy(t.specular),this.shininess=t.shininess,this.map=t.map,this.lightMap=t.lightMap,this.lightMapIntensity=t.lightMapIntensity,this.aoMap=t.aoMap,this.aoMapIntensity=t.aoMapIntensity,this.emissive.copy(t.emissive),this.emissiveMap=t.emissiveMap,this.emissiveIntensity=t.emissiveIntensity,this.bumpMap=t.bumpMap,this.bumpScale=t.bumpScale,this.normalMap=t.normalMap,this.normalMapType=t.normalMapType,this.normalScale.copy(t.normalScale),this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.specularMap=t.specularMap,this.alphaMap=t.alphaMap,this.envMap=t.envMap,this.combine=t.combine,this.reflectivity=t.reflectivity,this.refractionRatio=t.refractionRatio,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.wireframeLinecap=t.wireframeLinecap,this.wireframeLinejoin=t.wireframeLinejoin,this.flatShading=t.flatShading,this.fog=t.fog,this}}class lh extends di{constructor(t){super(),this.isMeshToonMaterial=!0,this.defines={TOON:""},this.type="MeshToonMaterial",this.color=new ci(16777215),this.map=null,this.gradientMap=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new ci(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=0,this.normalScale=new me(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.alphaMap=null,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.map=t.map,this.gradientMap=t.gradientMap,this.lightMap=t.lightMap,this.lightMapIntensity=t.lightMapIntensity,this.aoMap=t.aoMap,this.aoMapIntensity=t.aoMapIntensity,this.emissive.copy(t.emissive),this.emissiveMap=t.emissiveMap,this.emissiveIntensity=t.emissiveIntensity,this.bumpMap=t.bumpMap,this.bumpScale=t.bumpScale,this.normalMap=t.normalMap,this.normalMapType=t.normalMapType,this.normalScale.copy(t.normalScale),this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.alphaMap=t.alphaMap,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.wireframeLinecap=t.wireframeLinecap,this.wireframeLinejoin=t.wireframeLinejoin,this.fog=t.fog,this}}class ch extends di{constructor(t){super(),this.isMeshNormalMaterial=!0,this.type="MeshNormalMaterial",this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=0,this.normalScale=new me(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.flatShading=!1,this.setValues(t)}copy(t){return super.copy(t),this.bumpMap=t.bumpMap,this.bumpScale=t.bumpScale,this.normalMap=t.normalMap,this.normalMapType=t.normalMapType,this.normalScale.copy(t.normalScale),this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.flatShading=t.flatShading,this}}class hh extends di{constructor(t){super(),this.isMeshLambertMaterial=!0,this.type="MeshLambertMaterial",this.color=new ci(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new ci(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=0,this.normalScale=new me(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=h,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.flatShading=!1,this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.color.copy(t.color),this.map=t.map,this.lightMap=t.lightMap,this.lightMapIntensity=t.lightMapIntensity,this.aoMap=t.aoMap,this.aoMapIntensity=t.aoMapIntensity,this.emissive.copy(t.emissive),this.emissiveMap=t.emissiveMap,this.emissiveIntensity=t.emissiveIntensity,this.bumpMap=t.bumpMap,this.bumpScale=t.bumpScale,this.normalMap=t.normalMap,this.normalMapType=t.normalMapType,this.normalScale.copy(t.normalScale),this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.specularMap=t.specularMap,this.alphaMap=t.alphaMap,this.envMap=t.envMap,this.combine=t.combine,this.reflectivity=t.reflectivity,this.refractionRatio=t.refractionRatio,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this.wireframeLinecap=t.wireframeLinecap,this.wireframeLinejoin=t.wireframeLinejoin,this.flatShading=t.flatShading,this.fog=t.fog,this}}class uh extends di{constructor(t){super(),this.isMeshMatcapMaterial=!0,this.defines={MATCAP:""},this.type="MeshMatcapMaterial",this.color=new ci(16777215),this.matcap=null,this.map=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalMapType=0,this.normalScale=new me(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.alphaMap=null,this.flatShading=!1,this.fog=!0,this.setValues(t)}copy(t){return super.copy(t),this.defines={MATCAP:""},this.color.copy(t.color),this.matcap=t.matcap,this.map=t.map,this.bumpMap=t.bumpMap,this.bumpScale=t.bumpScale,this.normalMap=t.normalMap,this.normalMapType=t.normalMapType,this.normalScale.copy(t.normalScale),this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.alphaMap=t.alphaMap,this.flatShading=t.flatShading,this.fog=t.fog,this}}class dh extends fl{constructor(t){super(),this.isLineDashedMaterial=!0,this.type="LineDashedMaterial",this.scale=1,this.dashSize=3,this.gapSize=1,this.setValues(t)}copy(t){return super.copy(t),this.scale=t.scale,this.dashSize=t.dashSize,this.gapSize=t.gapSize,this}}function ph(t,e,n){return!t||!n&&t.constructor===e?t:"number"==typeof e.BYTES_PER_ELEMENT?new e(t):Array.prototype.slice.call(t)}function mh(t){return ArrayBuffer.isView(t)&&!(t instanceof DataView)}function fh(t){const e=t.length,n=new Array(e);for(let t=0;t!==e;++t)n[t]=t;return n.sort((function(e,n){return t[e]-t[n]})),n}function gh(t,e,n){const i=t.length,r=new t.constructor(i);for(let s=0,a=0;a!==i;++s){const i=n[s]*e;for(let n=0;n!==e;++n)r[a++]=t[i+n]}return r}function _h(t,e,n,i){let r=1,s=t[0];for(;void 0!==s&&void 0===s[i];)s=t[r++];if(void 0===s)return;let a=s[i];if(void 0!==a)if(Array.isArray(a))do{a=s[i],void 0!==a&&(e.push(s.time),n.push.apply(n,a)),s=t[r++]}while(void 0!==s);else if(void 0!==a.toArray)do{a=s[i],void 0!==a&&(e.push(s.time),a.toArray(n,n.length)),s=t[r++]}while(void 0!==s);else do{a=s[i],void 0!==a&&(e.push(s.time),n.push(a)),s=t[r++]}while(void 0!==s)}const vh={convertArray:ph,isTypedArray:mh,getKeyframeOrder:fh,sortedArray:gh,flattenJSON:_h,subclip:function(t,e,n,i,r=30){const s=t.clone();s.name=e;const a=[];for(let t=0;t=i)){l.push(e.times[t]);for(let n=0;ns.tracks[t].times[0]&&(o=s.tracks[t].times[0]);for(let t=0;t=i.times[u]){const t=u*l+o,e=t+l-o;d=i.values.slice(t,e)}else{const t=i.createInterpolant(),e=o,n=l-o;t.evaluate(s),d=t.resultBuffer.slice(e,n)}if("quaternion"===r){(new ke).fromArray(d).normalize().conjugate().toArray(d)}const p=a.times.length;for(let t=0;t=r)break t;{const a=e[1];t=r)break e}s=n,n=0}}for(;n>>1;te;)--s;if(++s,0!==r||s!==i){r>=s&&(s=Math.max(s,1),r=s-1);const t=this.getValueSize();this.times=n.slice(r,s),this.values=this.values.slice(r*t,s*t)}return this}validate(){let t=!0;const e=this.getValueSize();e-Math.floor(e)!=0&&(console.error("THREE.KeyframeTrack: Invalid value size in track.",this),t=!1);const n=this.times,i=this.values,r=n.length;0===r&&(console.error("THREE.KeyframeTrack: Track is empty.",this),t=!1);let s=null;for(let e=0;e!==r;e++){const i=n[e];if("number"==typeof i&&isNaN(i)){console.error("THREE.KeyframeTrack: Time is not a valid number.",this,e,i),t=!1;break}if(null!==s&&s>i){console.error("THREE.KeyframeTrack: Out of order keys.",this,e,i,s),t=!1;break}s=i}if(void 0!==i&&mh(i))for(let e=0,n=i.length;e!==n;++e){const n=i[e];if(isNaN(n)){console.error("THREE.KeyframeTrack: Value is not a valid number.",this,e,n),t=!1;break}}return t}optimize(){const t=this.times.slice(),e=this.values.slice(),n=this.getValueSize(),i=this.getInterpolation()===Pt,r=t.length-1;let s=1;for(let a=1;a0){t[s]=t[r];for(let t=r*n,i=s*n,a=0;a!==n;++a)e[i+a]=e[t+a];++s}return s!==t.length?(this.times=t.slice(0,s),this.values=e.slice(0,s*n)):(this.times=t,this.values=e),this}clone(){const t=this.times.slice(),e=this.values.slice(),n=new(0,this.constructor)(this.name,t,e);return n.createInterpolant=this.createInterpolant,n}}bh.prototype.TimeBufferType=Float32Array,bh.prototype.ValueBufferType=Float32Array,bh.prototype.DefaultInterpolation=Lt;class Th extends bh{}Th.prototype.ValueTypeName="bool",Th.prototype.ValueBufferType=Array,Th.prototype.DefaultInterpolation=Ct,Th.prototype.InterpolantFactoryMethodLinear=void 0,Th.prototype.InterpolantFactoryMethodSmooth=void 0;class Eh extends bh{}Eh.prototype.ValueTypeName="color";class wh extends bh{}wh.prototype.ValueTypeName="number";class Ah extends xh{constructor(t,e,n,i){super(t,e,n,i)}interpolate_(t,e,n,i){const r=this.resultBuffer,s=this.sampleValues,a=this.valueSize,o=(n-e)/(i-e);let l=t*a;for(let t=l+a;l!==t;l+=4)ke.slerpFlat(r,0,s,l-a,s,l,o);return r}}class Rh extends bh{InterpolantFactoryMethodLinear(t){return new Ah(this.times,this.values,this.getValueSize(),t)}}Rh.prototype.ValueTypeName="quaternion",Rh.prototype.DefaultInterpolation=Lt,Rh.prototype.InterpolantFactoryMethodSmooth=void 0;class Ch extends bh{}Ch.prototype.ValueTypeName="string",Ch.prototype.ValueBufferType=Array,Ch.prototype.DefaultInterpolation=Ct,Ch.prototype.InterpolantFactoryMethodLinear=void 0,Ch.prototype.InterpolantFactoryMethodSmooth=void 0;class Lh extends bh{}Lh.prototype.ValueTypeName="vector";class Ph{constructor(t,e=-1,n,i=2500){this.name=t,this.tracks=n,this.duration=e,this.blendMode=i,this.uuid=se(),this.duration<0&&this.resetDuration()}static parse(t){const e=[],n=t.tracks,i=1/(t.fps||1);for(let t=0,r=n.length;t!==r;++t)e.push(Ih(n[t]).scale(i));const r=new this(t.name,t.duration,e,t.blendMode);return r.uuid=t.uuid,r}static toJSON(t){const e=[],n=t.tracks,i={name:t.name,duration:t.duration,tracks:e,uuid:t.uuid,blendMode:t.blendMode};for(let t=0,i=n.length;t!==i;++t)e.push(bh.toJSON(n[t]));return i}static CreateFromMorphTargetSequence(t,e,n,i){const r=e.length,s=[];for(let t=0;t1){const t=s[1];let e=i[t];e||(i[t]=e=[]),e.push(n)}}const s=[];for(const t in i)s.push(this.CreateFromMorphTargetSequence(t,i[t],e,n));return s}static parseAnimation(t,e){if(!t)return console.error("THREE.AnimationClip: No animation in JSONLoader data."),null;const n=function(t,e,n,i,r){if(0!==n.length){const s=[],a=[];_h(n,s,a,i),0!==s.length&&r.push(new t(e,s,a))}},i=[],r=t.name||"default",s=t.fps||30,a=t.blendMode;let o=t.length||-1;const l=t.hierarchy||[];for(let t=0;t{e&&e(r),this.manager.itemEnd(t)}),0),r;if(void 0!==Fh[t])return void Fh[t].push({onLoad:e,onProgress:n,onError:i});Fh[t]=[],Fh[t].push({onLoad:e,onProgress:n,onError:i});const s=new Request(t,{headers:new Headers(this.requestHeader),credentials:this.withCredentials?"include":"same-origin"}),a=this.mimeType,o=this.responseType;fetch(s).then((e=>{if(200===e.status||0===e.status){if(0===e.status&&console.warn("THREE.FileLoader: HTTP Status 0 received."),"undefined"==typeof ReadableStream||void 0===e.body||void 0===e.body.getReader)return e;const n=Fh[t],i=e.body.getReader(),r=e.headers.get("Content-Length")||e.headers.get("X-File-Size"),s=r?parseInt(r):0,a=0!==s;let o=0;const l=new ReadableStream({start(t){!function e(){i.read().then((({done:i,value:r})=>{if(i)t.close();else{o+=r.byteLength;const i=new ProgressEvent("progress",{lengthComputable:a,loaded:o,total:s});for(let t=0,e=n.length;t{switch(o){case"arraybuffer":return t.arrayBuffer();case"blob":return t.blob();case"document":return t.text().then((t=>(new DOMParser).parseFromString(t,a)));case"json":return t.json();default:if(void 0===a)return t.text();{const e=/charset="?([^;"\s]*)"?/i.exec(a),n=e&&e[1]?e[1].toLowerCase():void 0,i=new TextDecoder(n);return t.arrayBuffer().then((t=>i.decode(t)))}}})).then((e=>{Uh.add(t,e);const n=Fh[t];delete Fh[t];for(let t=0,i=n.length;t{const n=Fh[t];if(void 0===n)throw this.manager.itemError(t),e;delete Fh[t];for(let t=0,i=n.length;t{this.manager.itemEnd(t)})),this.manager.itemStart(t)}setResponseType(t){return this.responseType=t,this}setMimeType(t){return this.mimeType=t,this}}class Gh extends Oh{constructor(t){super(t)}load(t,e,n,i){void 0!==this.path&&(t=this.path+t),t=this.manager.resolveURL(t);const r=this,s=Uh.get(t);if(void 0!==s)return r.manager.itemStart(t),setTimeout((function(){e&&e(s),r.manager.itemEnd(t)}),0),s;const a=ye("img");function o(){c(),Uh.add(t,this),e&&e(this),r.manager.itemEnd(t)}function l(e){c(),i&&i(e),r.manager.itemError(t),r.manager.itemEnd(t)}function c(){a.removeEventListener("load",o,!1),a.removeEventListener("error",l,!1)}return a.addEventListener("load",o,!1),a.addEventListener("error",l,!1),"data:"!==t.slice(0,5)&&void 0!==this.crossOrigin&&(a.crossOrigin=this.crossOrigin),r.manager.itemStart(t),a.src=t,a}}class Hh extends jn{constructor(t,e=1){super(),this.isLight=!0,this.type="Light",this.color=new ci(t),this.intensity=e}dispose(){}copy(t,e){return super.copy(t,e),this.color.copy(t.color),this.intensity=t.intensity,this}toJSON(t){const e=super.toJSON(t);return e.object.color=this.color.getHex(),e.object.intensity=this.intensity,void 0!==this.groundColor&&(e.object.groundColor=this.groundColor.getHex()),void 0!==this.distance&&(e.object.distance=this.distance),void 0!==this.angle&&(e.object.angle=this.angle),void 0!==this.decay&&(e.object.decay=this.decay),void 0!==this.penumbra&&(e.object.penumbra=this.penumbra),void 0!==this.shadow&&(e.object.shadow=this.shadow.toJSON()),e}}class Vh extends Hh{constructor(t,e,n){super(t,n),this.isHemisphereLight=!0,this.type="HemisphereLight",this.position.copy(jn.DEFAULT_UP),this.updateMatrix(),this.groundColor=new ci(e)}copy(t,e){return super.copy(t,e),this.groundColor.copy(t.groundColor),this}}const kh=new Mn,Wh=new We,Xh=new We;class jh{constructor(t){this.camera=t,this.bias=0,this.normalBias=0,this.radius=1,this.blurSamples=8,this.mapSize=new me(512,512),this.map=null,this.mapPass=null,this.matrix=new Mn,this.autoUpdate=!0,this.needsUpdate=!1,this._frustum=new gr,this._frameExtents=new me(1,1),this._viewportCount=1,this._viewports=[new Be(0,0,1,1)]}getViewportCount(){return this._viewportCount}getFrustum(){return this._frustum}updateMatrices(t){const e=this.camera,n=this.matrix;Wh.setFromMatrixPosition(t.matrixWorld),e.position.copy(Wh),Xh.setFromMatrixPosition(t.target.matrixWorld),e.lookAt(Xh),e.updateMatrixWorld(),kh.multiplyMatrices(e.projectionMatrix,e.matrixWorldInverse),this._frustum.setFromProjectionMatrix(kh),n.set(.5,0,0,.5,0,.5,0,.5,0,0,.5,.5,0,0,0,1),n.multiply(kh)}getViewport(t){return this._viewports[t]}getFrameExtents(){return this._frameExtents}dispose(){this.map&&this.map.dispose(),this.mapPass&&this.mapPass.dispose()}copy(t){return this.camera=t.camera.clone(),this.bias=t.bias,this.radius=t.radius,this.mapSize.copy(t.mapSize),this}clone(){return(new this.constructor).copy(this)}toJSON(){const t={};return 0!==this.bias&&(t.bias=this.bias),0!==this.normalBias&&(t.normalBias=this.normalBias),1!==this.radius&&(t.radius=this.radius),512===this.mapSize.x&&512===this.mapSize.y||(t.mapSize=this.mapSize.toArray()),t.camera=this.camera.toJSON(!1).object,delete t.camera.matrix,t}}class qh extends jh{constructor(){super(new sr(50,1,.5,500)),this.isSpotLightShadow=!0,this.focus=1}updateMatrices(t){const e=this.camera,n=2*re*t.angle*this.focus,i=this.mapSize.width/this.mapSize.height,r=t.distance||e.far;n===e.fov&&i===e.aspect&&r===e.far||(e.fov=n,e.aspect=i,e.far=r,e.updateProjectionMatrix()),super.updateMatrices(t)}copy(t){return super.copy(t),this.focus=t.focus,this}}class Yh extends Hh{constructor(t,e,n=0,i=Math.PI/3,r=0,s=2){super(t,e),this.isSpotLight=!0,this.type="SpotLight",this.position.copy(jn.DEFAULT_UP),this.updateMatrix(),this.target=new jn,this.distance=n,this.angle=i,this.penumbra=r,this.decay=s,this.map=null,this.shadow=new qh}get power(){return this.intensity*Math.PI}set power(t){this.intensity=t/Math.PI}dispose(){this.shadow.dispose()}copy(t,e){return super.copy(t,e),this.distance=t.distance,this.angle=t.angle,this.penumbra=t.penumbra,this.decay=t.decay,this.target=t.target.clone(),this.shadow=t.shadow.clone(),this}}const Zh=new Mn,Jh=new We,Kh=new We;class $h extends jh{constructor(){super(new sr(90,1,.5,500)),this.isPointLightShadow=!0,this._frameExtents=new me(4,2),this._viewportCount=6,this._viewports=[new Be(2,1,1,1),new Be(0,1,1,1),new Be(3,1,1,1),new Be(1,1,1,1),new Be(3,0,1,1),new Be(1,0,1,1)],this._cubeDirections=[new We(1,0,0),new We(-1,0,0),new We(0,0,1),new We(0,0,-1),new We(0,1,0),new We(0,-1,0)],this._cubeUps=[new We(0,1,0),new We(0,1,0),new We(0,1,0),new We(0,1,0),new We(0,0,1),new We(0,0,-1)]}updateMatrices(t,e=0){const n=this.camera,i=this.matrix,r=t.distance||n.far;r!==n.far&&(n.far=r,n.updateProjectionMatrix()),Jh.setFromMatrixPosition(t.matrixWorld),n.position.copy(Jh),Kh.copy(n.position),Kh.add(this._cubeDirections[e]),n.up.copy(this._cubeUps[e]),n.lookAt(Kh),n.updateMatrixWorld(),i.makeTranslation(-Jh.x,-Jh.y,-Jh.z),Zh.multiplyMatrices(n.projectionMatrix,n.matrixWorldInverse),this._frustum.setFromProjectionMatrix(Zh)}}class Qh extends Hh{constructor(t,e,n=0,i=2){super(t,e),this.isPointLight=!0,this.type="PointLight",this.distance=n,this.decay=i,this.shadow=new $h}get power(){return 4*this.intensity*Math.PI}set power(t){this.intensity=t/(4*Math.PI)}dispose(){this.shadow.dispose()}copy(t,e){return super.copy(t,e),this.distance=t.distance,this.decay=t.decay,this.shadow=t.shadow.clone(),this}}class tu extends jh{constructor(){super(new Lr(-5,5,5,-5,.5,500)),this.isDirectionalLightShadow=!0}}class eu extends Hh{constructor(t,e){super(t,e),this.isDirectionalLight=!0,this.type="DirectionalLight",this.position.copy(jn.DEFAULT_UP),this.updateMatrix(),this.target=new jn,this.shadow=new tu}dispose(){this.shadow.dispose()}copy(t){return super.copy(t),this.target=t.target.clone(),this.shadow=t.shadow.clone(),this}}class nu extends Hh{constructor(t,e){super(t,e),this.isAmbientLight=!0,this.type="AmbientLight"}}class iu extends Hh{constructor(t,e,n=10,i=10){super(t,e),this.isRectAreaLight=!0,this.type="RectAreaLight",this.width=n,this.height=i}get power(){return this.intensity*this.width*this.height*Math.PI}set power(t){this.intensity=t/(this.width*this.height*Math.PI)}copy(t){return super.copy(t),this.width=t.width,this.height=t.height,this}toJSON(t){const e=super.toJSON(t);return e.object.width=this.width,e.object.height=this.height,e}}class ru{constructor(){this.isSphericalHarmonics3=!0,this.coefficients=[];for(let t=0;t<9;t++)this.coefficients.push(new We)}set(t){for(let e=0;e<9;e++)this.coefficients[e].copy(t[e]);return this}zero(){for(let t=0;t<9;t++)this.coefficients[t].set(0,0,0);return this}getAt(t,e){const n=t.x,i=t.y,r=t.z,s=this.coefficients;return e.copy(s[0]).multiplyScalar(.282095),e.addScaledVector(s[1],.488603*i),e.addScaledVector(s[2],.488603*r),e.addScaledVector(s[3],.488603*n),e.addScaledVector(s[4],n*i*1.092548),e.addScaledVector(s[5],i*r*1.092548),e.addScaledVector(s[6],.315392*(3*r*r-1)),e.addScaledVector(s[7],n*r*1.092548),e.addScaledVector(s[8],.546274*(n*n-i*i)),e}getIrradianceAt(t,e){const n=t.x,i=t.y,r=t.z,s=this.coefficients;return e.copy(s[0]).multiplyScalar(.886227),e.addScaledVector(s[1],1.023328*i),e.addScaledVector(s[2],1.023328*r),e.addScaledVector(s[3],1.023328*n),e.addScaledVector(s[4],.858086*n*i),e.addScaledVector(s[5],.858086*i*r),e.addScaledVector(s[6],.743125*r*r-.247708),e.addScaledVector(s[7],.858086*n*r),e.addScaledVector(s[8],.429043*(n*n-i*i)),e}add(t){for(let e=0;e<9;e++)this.coefficients[e].add(t.coefficients[e]);return this}addScaledSH(t,e){for(let n=0;n<9;n++)this.coefficients[n].addScaledVector(t.coefficients[n],e);return this}scale(t){for(let e=0;e<9;e++)this.coefficients[e].multiplyScalar(t);return this}lerp(t,e){for(let n=0;n<9;n++)this.coefficients[n].lerp(t.coefficients[n],e);return this}equals(t){for(let e=0;e<9;e++)if(!this.coefficients[e].equals(t.coefficients[e]))return!1;return!0}copy(t){return this.set(t.coefficients)}clone(){return(new this.constructor).copy(this)}fromArray(t,e=0){const n=this.coefficients;for(let i=0;i<9;i++)n[i].fromArray(t,e+3*i);return this}toArray(t=[],e=0){const n=this.coefficients;for(let i=0;i<9;i++)n[i].toArray(t,e+3*i);return t}static getBasisAt(t,e){const n=t.x,i=t.y,r=t.z;e[0]=.282095,e[1]=.488603*i,e[2]=.488603*r,e[3]=.488603*n,e[4]=1.092548*n*i,e[5]=1.092548*i*r,e[6]=.315392*(3*r*r-1),e[7]=1.092548*n*r,e[8]=.546274*(n*n-i*i)}}class su extends Hh{constructor(t=new ru,e=1){super(void 0,e),this.isLightProbe=!0,this.sh=t}copy(t){return super.copy(t),this.sh.copy(t.sh),this}fromJSON(t){return this.intensity=t.intensity,this.sh.fromArray(t.sh),this}toJSON(t){const e=super.toJSON(t);return e.object.sh=this.sh.toArray(),e}}class au extends Oh{constructor(t){super(t),this.textures={}}load(t,e,n,i){const r=this,s=new zh(r.manager);s.setPath(r.path),s.setRequestHeader(r.requestHeader),s.setWithCredentials(r.withCredentials),s.load(t,(function(n){try{e(r.parse(JSON.parse(n)))}catch(e){i?i(e):console.error(e),r.manager.itemError(t)}}),n,i)}parse(t){const e=this.textures;function n(t){return void 0===e[t]&&console.warn("THREE.MaterialLoader: Undefined texture",t),e[t]}const i=au.createMaterialFromType(t.type);if(void 0!==t.uuid&&(i.uuid=t.uuid),void 0!==t.name&&(i.name=t.name),void 0!==t.color&&void 0!==i.color&&i.color.setHex(t.color),void 0!==t.roughness&&(i.roughness=t.roughness),void 0!==t.metalness&&(i.metalness=t.metalness),void 0!==t.sheen&&(i.sheen=t.sheen),void 0!==t.sheenColor&&(i.sheenColor=(new ci).setHex(t.sheenColor)),void 0!==t.sheenRoughness&&(i.sheenRoughness=t.sheenRoughness),void 0!==t.emissive&&void 0!==i.emissive&&i.emissive.setHex(t.emissive),void 0!==t.specular&&void 0!==i.specular&&i.specular.setHex(t.specular),void 0!==t.specularIntensity&&(i.specularIntensity=t.specularIntensity),void 0!==t.specularColor&&void 0!==i.specularColor&&i.specularColor.setHex(t.specularColor),void 0!==t.shininess&&(i.shininess=t.shininess),void 0!==t.clearcoat&&(i.clearcoat=t.clearcoat),void 0!==t.clearcoatRoughness&&(i.clearcoatRoughness=t.clearcoatRoughness),void 0!==t.iridescence&&(i.iridescence=t.iridescence),void 0!==t.iridescenceIOR&&(i.iridescenceIOR=t.iridescenceIOR),void 0!==t.iridescenceThicknessRange&&(i.iridescenceThicknessRange=t.iridescenceThicknessRange),void 0!==t.transmission&&(i.transmission=t.transmission),void 0!==t.thickness&&(i.thickness=t.thickness),void 0!==t.attenuationDistance&&(i.attenuationDistance=t.attenuationDistance),void 0!==t.attenuationColor&&void 0!==i.attenuationColor&&i.attenuationColor.setHex(t.attenuationColor),void 0!==t.anisotropy&&(i.anisotropy=t.anisotropy),void 0!==t.anisotropyRotation&&(i.anisotropyRotation=t.anisotropyRotation),void 0!==t.fog&&(i.fog=t.fog),void 0!==t.flatShading&&(i.flatShading=t.flatShading),void 0!==t.blending&&(i.blending=t.blending),void 0!==t.combine&&(i.combine=t.combine),void 0!==t.side&&(i.side=t.side),void 0!==t.shadowSide&&(i.shadowSide=t.shadowSide),void 0!==t.opacity&&(i.opacity=t.opacity),void 0!==t.transparent&&(i.transparent=t.transparent),void 0!==t.alphaTest&&(i.alphaTest=t.alphaTest),void 0!==t.alphaHash&&(i.alphaHash=t.alphaHash),void 0!==t.depthFunc&&(i.depthFunc=t.depthFunc),void 0!==t.depthTest&&(i.depthTest=t.depthTest),void 0!==t.depthWrite&&(i.depthWrite=t.depthWrite),void 0!==t.colorWrite&&(i.colorWrite=t.colorWrite),void 0!==t.blendSrc&&(i.blendSrc=t.blendSrc),void 0!==t.blendDst&&(i.blendDst=t.blendDst),void 0!==t.blendEquation&&(i.blendEquation=t.blendEquation),void 0!==t.blendSrcAlpha&&(i.blendSrcAlpha=t.blendSrcAlpha),void 0!==t.blendDstAlpha&&(i.blendDstAlpha=t.blendDstAlpha),void 0!==t.blendEquationAlpha&&(i.blendEquationAlpha=t.blendEquationAlpha),void 0!==t.blendColor&&void 0!==i.blendColor&&i.blendColor.setHex(t.blendColor),void 0!==t.blendAlpha&&(i.blendAlpha=t.blendAlpha),void 0!==t.stencilWriteMask&&(i.stencilWriteMask=t.stencilWriteMask),void 0!==t.stencilFunc&&(i.stencilFunc=t.stencilFunc),void 0!==t.stencilRef&&(i.stencilRef=t.stencilRef),void 0!==t.stencilFuncMask&&(i.stencilFuncMask=t.stencilFuncMask),void 0!==t.stencilFail&&(i.stencilFail=t.stencilFail),void 0!==t.stencilZFail&&(i.stencilZFail=t.stencilZFail),void 0!==t.stencilZPass&&(i.stencilZPass=t.stencilZPass),void 0!==t.stencilWrite&&(i.stencilWrite=t.stencilWrite),void 0!==t.wireframe&&(i.wireframe=t.wireframe),void 0!==t.wireframeLinewidth&&(i.wireframeLinewidth=t.wireframeLinewidth),void 0!==t.wireframeLinecap&&(i.wireframeLinecap=t.wireframeLinecap),void 0!==t.wireframeLinejoin&&(i.wireframeLinejoin=t.wireframeLinejoin),void 0!==t.rotation&&(i.rotation=t.rotation),void 0!==t.linewidth&&(i.linewidth=t.linewidth),void 0!==t.dashSize&&(i.dashSize=t.dashSize),void 0!==t.gapSize&&(i.gapSize=t.gapSize),void 0!==t.scale&&(i.scale=t.scale),void 0!==t.polygonOffset&&(i.polygonOffset=t.polygonOffset),void 0!==t.polygonOffsetFactor&&(i.polygonOffsetFactor=t.polygonOffsetFactor),void 0!==t.polygonOffsetUnits&&(i.polygonOffsetUnits=t.polygonOffsetUnits),void 0!==t.dithering&&(i.dithering=t.dithering),void 0!==t.alphaToCoverage&&(i.alphaToCoverage=t.alphaToCoverage),void 0!==t.premultipliedAlpha&&(i.premultipliedAlpha=t.premultipliedAlpha),void 0!==t.forceSinglePass&&(i.forceSinglePass=t.forceSinglePass),void 0!==t.visible&&(i.visible=t.visible),void 0!==t.toneMapped&&(i.toneMapped=t.toneMapped),void 0!==t.userData&&(i.userData=t.userData),void 0!==t.vertexColors&&("number"==typeof t.vertexColors?i.vertexColors=t.vertexColors>0:i.vertexColors=t.vertexColors),void 0!==t.uniforms)for(const e in t.uniforms){const r=t.uniforms[e];switch(i.uniforms[e]={},r.type){case"t":i.uniforms[e].value=n(r.value);break;case"c":i.uniforms[e].value=(new ci).setHex(r.value);break;case"v2":i.uniforms[e].value=(new me).fromArray(r.value);break;case"v3":i.uniforms[e].value=(new We).fromArray(r.value);break;case"v4":i.uniforms[e].value=(new Be).fromArray(r.value);break;case"m3":i.uniforms[e].value=(new fe).fromArray(r.value);break;case"m4":i.uniforms[e].value=(new Mn).fromArray(r.value);break;default:i.uniforms[e].value=r.value}}if(void 0!==t.defines&&(i.defines=t.defines),void 0!==t.vertexShader&&(i.vertexShader=t.vertexShader),void 0!==t.fragmentShader&&(i.fragmentShader=t.fragmentShader),void 0!==t.glslVersion&&(i.glslVersion=t.glslVersion),void 0!==t.extensions)for(const e in t.extensions)i.extensions[e]=t.extensions[e];if(void 0!==t.lights&&(i.lights=t.lights),void 0!==t.clipping&&(i.clipping=t.clipping),void 0!==t.size&&(i.size=t.size),void 0!==t.sizeAttenuation&&(i.sizeAttenuation=t.sizeAttenuation),void 0!==t.map&&(i.map=n(t.map)),void 0!==t.matcap&&(i.matcap=n(t.matcap)),void 0!==t.alphaMap&&(i.alphaMap=n(t.alphaMap)),void 0!==t.bumpMap&&(i.bumpMap=n(t.bumpMap)),void 0!==t.bumpScale&&(i.bumpScale=t.bumpScale),void 0!==t.normalMap&&(i.normalMap=n(t.normalMap)),void 0!==t.normalMapType&&(i.normalMapType=t.normalMapType),void 0!==t.normalScale){let e=t.normalScale;!1===Array.isArray(e)&&(e=[e,e]),i.normalScale=(new me).fromArray(e)}return void 0!==t.displacementMap&&(i.displacementMap=n(t.displacementMap)),void 0!==t.displacementScale&&(i.displacementScale=t.displacementScale),void 0!==t.displacementBias&&(i.displacementBias=t.displacementBias),void 0!==t.roughnessMap&&(i.roughnessMap=n(t.roughnessMap)),void 0!==t.metalnessMap&&(i.metalnessMap=n(t.metalnessMap)),void 0!==t.emissiveMap&&(i.emissiveMap=n(t.emissiveMap)),void 0!==t.emissiveIntensity&&(i.emissiveIntensity=t.emissiveIntensity),void 0!==t.specularMap&&(i.specularMap=n(t.specularMap)),void 0!==t.specularIntensityMap&&(i.specularIntensityMap=n(t.specularIntensityMap)),void 0!==t.specularColorMap&&(i.specularColorMap=n(t.specularColorMap)),void 0!==t.envMap&&(i.envMap=n(t.envMap)),void 0!==t.envMapIntensity&&(i.envMapIntensity=t.envMapIntensity),void 0!==t.reflectivity&&(i.reflectivity=t.reflectivity),void 0!==t.refractionRatio&&(i.refractionRatio=t.refractionRatio),void 0!==t.lightMap&&(i.lightMap=n(t.lightMap)),void 0!==t.lightMapIntensity&&(i.lightMapIntensity=t.lightMapIntensity),void 0!==t.aoMap&&(i.aoMap=n(t.aoMap)),void 0!==t.aoMapIntensity&&(i.aoMapIntensity=t.aoMapIntensity),void 0!==t.gradientMap&&(i.gradientMap=n(t.gradientMap)),void 0!==t.clearcoatMap&&(i.clearcoatMap=n(t.clearcoatMap)),void 0!==t.clearcoatRoughnessMap&&(i.clearcoatRoughnessMap=n(t.clearcoatRoughnessMap)),void 0!==t.clearcoatNormalMap&&(i.clearcoatNormalMap=n(t.clearcoatNormalMap)),void 0!==t.clearcoatNormalScale&&(i.clearcoatNormalScale=(new me).fromArray(t.clearcoatNormalScale)),void 0!==t.iridescenceMap&&(i.iridescenceMap=n(t.iridescenceMap)),void 0!==t.iridescenceThicknessMap&&(i.iridescenceThicknessMap=n(t.iridescenceThicknessMap)),void 0!==t.transmissionMap&&(i.transmissionMap=n(t.transmissionMap)),void 0!==t.thicknessMap&&(i.thicknessMap=n(t.thicknessMap)),void 0!==t.anisotropyMap&&(i.anisotropyMap=n(t.anisotropyMap)),void 0!==t.sheenColorMap&&(i.sheenColorMap=n(t.sheenColorMap)),void 0!==t.sheenRoughnessMap&&(i.sheenRoughnessMap=n(t.sheenRoughnessMap)),i}setTextures(t){return this.textures=t,this}static createMaterialFromType(t){return new{ShadowMaterial:ih,SpriteMaterial:lo,RawShaderMaterial:rh,ShaderMaterial:ir,PointsMaterial:wl,MeshPhysicalMaterial:ah,MeshStandardMaterial:sh,MeshPhongMaterial:oh,MeshToonMaterial:lh,MeshNormalMaterial:ch,MeshLambertMaterial:hh,MeshDepthMaterial:Ha,MeshDistanceMaterial:Va,MeshBasicMaterial:pi,MeshMatcapMaterial:uh,LineDashedMaterial:dh,LineBasicMaterial:fl,Material:di}[t]}}class ou{static decodeText(t){if("undefined"!=typeof TextDecoder)return(new TextDecoder).decode(t);let e="";for(let n=0,i=t.length;n0){this.source.connect(this.filters[0]);for(let t=1,e=this.filters.length;t0){this.source.disconnect(this.filters[0]);for(let t=1,e=this.filters.length;t0&&this._mixBufferRegionAdditive(n,i,this._addIndex*e,1,e);for(let t=e,r=e+e;t!==r;++t)if(n[t]!==n[t+e]){a.setValue(n,i);break}}saveOriginalState(){const t=this.binding,e=this.buffer,n=this.valueSize,i=n*this._origIndex;t.getValue(e,i);for(let t=n,r=i;t!==r;++t)e[t]=e[i+t%n];this._setIdentity(),this.cumulativeWeight=0,this.cumulativeWeightAdditive=0}restoreOriginalState(){const t=3*this.valueSize;this.binding.setValue(this.buffer,t)}_setAdditiveIdentityNumeric(){const t=this._addIndex*this.valueSize,e=t+this.valueSize;for(let n=t;n=.5)for(let i=0;i!==r;++i)t[e+i]=t[n+i]}_slerp(t,e,n,i){ke.slerpFlat(t,e,t,e,t,n,i)}_slerpAdditive(t,e,n,i,r){const s=this._workIndex*r;ke.multiplyQuaternionsFlat(t,s,t,e,t,n),ke.slerpFlat(t,e,t,e,t,s,i)}_lerp(t,e,n,i,r){const s=1-i;for(let a=0;a!==r;++a){const r=e+a;t[r]=t[r]*s+t[n+a]*i}}_lerpAdditive(t,e,n,i,r){for(let s=0;s!==r;++s){const r=e+s;t[r]=t[r]+t[n+s]*i}}}const Lu="\\[\\]\\.:\\/",Pu=new RegExp("["+Lu+"]","g"),Iu="[^"+Lu+"]",Uu="[^"+Lu.replace("\\.","")+"]",Du=new RegExp("^"+/((?:WC+[\/:])*)/.source.replace("WC",Iu)+/(WCOD+)?/.source.replace("WCOD",Uu)+/(?:\.(WC+)(?:\[(.+)\])?)?/.source.replace("WC",Iu)+/\.(WC+)(?:\[(.+)\])?/.source.replace("WC",Iu)+"$"),Nu=["material","materials","bones","map"];class Ou{constructor(t,e,n){this.path=e,this.parsedPath=n||Ou.parseTrackName(e),this.node=Ou.findNode(t,this.parsedPath.nodeName),this.rootNode=t,this.getValue=this._getValue_unbound,this.setValue=this._setValue_unbound}static create(t,e,n){return t&&t.isAnimationObjectGroup?new Ou.Composite(t,e,n):new Ou(t,e,n)}static sanitizeNodeName(t){return t.replace(/\s/g,"_").replace(Pu,"")}static parseTrackName(t){const e=Du.exec(t);if(null===e)throw new Error("PropertyBinding: Cannot parse trackName: "+t);const n={nodeName:e[2],objectName:e[3],objectIndex:e[4],propertyName:e[5],propertyIndex:e[6]},i=n.nodeName&&n.nodeName.lastIndexOf(".");if(void 0!==i&&-1!==i){const t=n.nodeName.substring(i+1);-1!==Nu.indexOf(t)&&(n.nodeName=n.nodeName.substring(0,i),n.objectName=t)}if(null===n.propertyName||0===n.propertyName.length)throw new Error("PropertyBinding: can not parse propertyName from trackName: "+t);return n}static findNode(t,e){if(void 0===e||""===e||"."===e||-1===e||e===t.name||e===t.uuid)return t;if(t.skeleton){const n=t.skeleton.getBoneByName(e);if(void 0!==n)return n}if(t.children){const n=function(t){for(let i=0;i0){const t=this._interpolants,e=this._propertyBindings;if(this.blendMode===Ot)for(let n=0,i=t.length;n!==i;++n)t[n].evaluate(s),e[n].accumulateAdditive(a);else for(let n=0,r=t.length;n!==r;++n)t[n].evaluate(s),e[n].accumulate(i,a)}}_updateWeight(t){let e=0;if(this.enabled){e=this.weight;const n=this._weightInterpolant;if(null!==n){const i=n.evaluate(t)[0];e*=i,t>n.parameterPositions[1]&&(this.stopFading(),0===i&&(this.enabled=!1))}}return this._effectiveWeight=e,e}_updateTimeScale(t){let e=0;if(!this.paused){e=this.timeScale;const n=this._timeScaleInterpolant;if(null!==n){e*=n.evaluate(t)[0],t>n.parameterPositions[1]&&(this.stopWarping(),0===e?this.paused=!0:this.timeScale=e)}}return this._effectiveTimeScale=e,e}_updateTime(t){const e=this._clip.duration,n=this.loop;let i=this.time+t,r=this._loopCount;const s=2202===n;if(0===t)return-1===r?i:s&&1==(1&r)?e-i:i;if(2200===n){-1===r&&(this._loopCount=0,this._setEndings(!0,!0,!1));t:{if(i>=e)i=e;else{if(!(i<0)){this.time=i;break t}i=0}this.clampWhenFinished?this.paused=!0:this.enabled=!1,this.time=i,this._mixer.dispatchEvent({type:"finished",action:this,direction:t<0?-1:1})}}else{if(-1===r&&(t>=0?(r=0,this._setEndings(!0,0===this.repetitions,s)):this._setEndings(0===this.repetitions,!0,s)),i>=e||i<0){const n=Math.floor(i/e);i-=e*n,r+=Math.abs(n);const a=this.repetitions-r;if(a<=0)this.clampWhenFinished?this.paused=!0:this.enabled=!1,i=t>0?e:0,this.time=i,this._mixer.dispatchEvent({type:"finished",action:this,direction:t>0?1:-1});else{if(1===a){const e=t<0;this._setEndings(e,!e,s)}else this._setEndings(!1,!1,s);this._loopCount=r,this.time=i,this._mixer.dispatchEvent({type:"loop",action:this,loopDelta:n})}}else this.time=i;if(s&&1==(1&r))return e-i}return i}_setEndings(t,e,n){const i=this._interpolantSettings;n?(i.endingStart=Ut,i.endingEnd=Ut):(i.endingStart=t?this.zeroSlopeAtStart?Ut:It:Dt,i.endingEnd=e?this.zeroSlopeAtEnd?Ut:It:Dt)}_scheduleFading(t,e,n){const i=this._mixer,r=i.time;let s=this._weightInterpolant;null===s&&(s=i._lendControlInterpolant(),this._weightInterpolant=s);const a=s.parameterPositions,o=s.sampleValues;return a[0]=r,o[0]=e,a[1]=r+t,o[1]=n,this}}const Bu=new Float32Array(1);class zu{constructor(t){this.value=t}clone(){return new zu(void 0===this.value.clone?this.value:this.value.clone())}}let Gu=0;function Hu(t,e){return t.distance-e.distance}function Vu(t,e,n,i){if(t.layers.test(e.layers)&&t.raycast(e,n),!0===i){const i=t.children;for(let t=0,r=i.length;t=0;--e)t[e].stop();return this}update(t){t*=this.timeScale;const e=this._actions,n=this._nActiveActions,i=this.time+=t,r=Math.sign(t),s=this._accuIndex^=1;for(let a=0;a!==n;++a){e[a]._update(i,t,r,s)}const a=this._bindings,o=this._nActiveBindings;for(let t=0;t!==o;++t)a[t].apply(s);return this}setTime(t){this.time=0;for(let t=0;t=r){const s=r++,c=t[s];e[c.uuid]=l,t[l]=c,e[o]=s,t[s]=a;for(let t=0,e=i;t!==e;++t){const e=n[t],i=e[s],r=e[l];e[l]=i,e[s]=r}}}this.nCachedObjects_=r}uncache(){const t=this._objects,e=this._indicesByUUID,n=this._bindings,i=n.length;let r=this.nCachedObjects_,s=t.length;for(let a=0,o=arguments.length;a!==o;++a){const o=arguments[a].uuid,l=e[o];if(void 0!==l)if(delete e[o],l0&&(e[a.uuid]=l),t[l]=a,t.pop();for(let t=0,e=i;t!==e;++t){const e=n[t];e[l]=e[r],e.pop()}}}this.nCachedObjects_=r}subscribe_(t,e){const n=this._bindingsIndicesByPath;let i=n[t];const r=this._bindings;if(void 0!==i)return r[i];const s=this._paths,a=this._parsedPaths,o=this._objects,l=o.length,c=this.nCachedObjects_,h=new Array(l);i=r.length,n[t]=i,s.push(t),a.push(e),r.push(h);for(let n=c,i=o.length;n!==i;++n){const i=o[n];h[n]=new Ou(i,t,e)}return h}unsubscribe_(t){const e=this._bindingsIndicesByPath,n=e[t];if(void 0!==n){const i=this._paths,r=this._parsedPaths,s=this._bindings,a=s.length-1,o=s[a];e[t[a]]=n,s[n]=o,s.pop(),r[n]=r[a],r.pop(),i[n]=i[a],i.pop()}}},t.AnimationUtils=vh,t.ArcCurve=Ol,t.ArrayCamera=qa,t.ArrowHelper=class extends jn{constructor(t=new We(0,0,1),e=new We(0,0,0),n=1,i=16776960,r=.2*n,s=.2*r){super(),this.type="ArrowHelper",void 0===ld&&(ld=new Ii,ld.setAttribute("position",new Ti([0,0,0,0,1,0],3)),cd=new sc(0,.5,1,5,1),cd.translate(0,-.5,0)),this.position.copy(e),this.line=new Ml(ld,new fl({color:i,toneMapped:!1})),this.line.matrixAutoUpdate=!1,this.add(this.line),this.cone=new Ji(cd,new pi({color:i,toneMapped:!1})),this.cone.matrixAutoUpdate=!1,this.add(this.cone),this.setDirection(t),this.setLength(n,r,s)}setDirection(t){if(t.y>.99999)this.quaternion.set(0,0,0,1);else if(t.y<-.99999)this.quaternion.set(1,0,0,0);else{od.set(t.z,0,-t.x).normalize();const e=Math.acos(t.y);this.quaternion.setFromAxisAngle(od,e)}}setLength(t,e=.2*t,n=.2*e){this.line.scale.set(1,Math.max(1e-4,t-e),1),this.line.updateMatrix(),this.cone.scale.set(n,e,n),this.cone.position.y=t,this.cone.updateMatrix()}setColor(t){this.line.material.color.set(t),this.cone.material.color.set(t)}copy(t){return super.copy(t,!1),this.line.copy(t.line),this.cone.copy(t.cone),this}dispose(){this.line.geometry.dispose(),this.line.material.dispose(),this.cone.geometry.dispose(),this.cone.material.dispose()}},t.AttachedBindMode=y,t.Audio=Tu,t.AudioAnalyser=class{constructor(t,e=2048){this.analyser=t.context.createAnalyser(),this.analyser.fftSize=e,this.data=new Uint8Array(this.analyser.frequencyBinCount),t.getOutput().connect(this.analyser)}getFrequencyData(){return this.analyser.getByteFrequencyData(this.data),this.data}getAverageFrequency(){let t=0;const e=this.getFrequencyData();for(let n=0;nthis.max.x||t.ythis.max.y)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y}getParameter(t,e){return e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y)}clampPoint(t,e){return e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return this.clampPoint(t,ku).distanceTo(t)}intersect(t){return this.min.max(t.min),this.max.min(t.max),this.isEmpty()&&this.makeEmpty(),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}},t.Box3=qe,t.Box3Helper=class extends Tl{constructor(t,e=16776960){const n=new Uint16Array([0,1,1,2,2,3,3,0,4,5,5,6,6,7,7,4,0,4,1,5,2,6,3,7]),i=new Ii;i.setIndex(new Mi(n,1)),i.setAttribute("position",new Ti([1,1,1,-1,1,1,-1,-1,1,1,-1,1,1,1,-1,-1,1,-1,-1,-1,-1,1,-1,-1],3)),super(i,new fl({color:e,toneMapped:!1})),this.box=t,this.type="Box3Helper",this.geometry.computeBoundingSphere()}updateMatrixWorld(t){const e=this.box;e.isEmpty()||(e.getCenter(this.position),e.getSize(this.scale),this.scale.multiplyScalar(.5),super.updateMatrixWorld(t))}dispose(){this.geometry.dispose(),this.material.dispose()}},t.BoxGeometry=$i,t.BoxHelper=class extends Tl{constructor(t,e=16776960){const n=new Uint16Array([0,1,1,2,2,3,3,0,4,5,5,6,6,7,7,4,0,4,1,5,2,6,3,7]),i=new Float32Array(24),r=new Ii;r.setIndex(new Mi(n,1)),r.setAttribute("position",new Mi(i,3)),super(r,new fl({color:e,toneMapped:!1})),this.object=t,this.type="BoxHelper",this.matrixAutoUpdate=!1,this.update()}update(t){if(void 0!==t&&console.warn("THREE.BoxHelper: .update() has no longer arguments."),void 0!==this.object&&ad.setFromObject(this.object),ad.isEmpty())return;const e=ad.min,n=ad.max,i=this.geometry.attributes.position,r=i.array;r[0]=n.x,r[1]=n.y,r[2]=n.z,r[3]=e.x,r[4]=n.y,r[5]=n.z,r[6]=e.x,r[7]=e.y,r[8]=n.z,r[9]=n.x,r[10]=e.y,r[11]=n.z,r[12]=n.x,r[13]=n.y,r[14]=e.z,r[15]=e.x,r[16]=n.y,r[17]=e.z,r[18]=e.x,r[19]=e.y,r[20]=e.z,r[21]=n.x,r[22]=e.y,r[23]=e.z,i.needsUpdate=!0,this.geometry.computeBoundingSphere()}setFromObject(t){return this.object=t,this.update(),this}copy(t,e){return super.copy(t,e),this.object=t.object,this}dispose(){this.geometry.dispose(),this.material.dispose()}},t.BufferAttribute=Mi,t.BufferGeometry=Ii,t.BufferGeometryLoader=cu,t.ByteType=1010,t.Cache=Uh,t.Camera=rr,t.CameraHelper=class extends Tl{constructor(t){const e=new Ii,n=new fl({color:16777215,vertexColors:!0,toneMapped:!1}),i=[],r=[],s={};function a(t,e){o(t),o(e)}function o(t){i.push(0,0,0),r.push(0,0,0),void 0===s[t]&&(s[t]=[]),s[t].push(i.length/3-1)}a("n1","n2"),a("n2","n4"),a("n4","n3"),a("n3","n1"),a("f1","f2"),a("f2","f4"),a("f4","f3"),a("f3","f1"),a("n1","f1"),a("n2","f2"),a("n3","f3"),a("n4","f4"),a("p","n1"),a("p","n2"),a("p","n3"),a("p","n4"),a("u1","u2"),a("u2","u3"),a("u3","u1"),a("c","t"),a("p","c"),a("cn1","cn2"),a("cn3","cn4"),a("cf1","cf2"),a("cf3","cf4"),e.setAttribute("position",new Ti(i,3)),e.setAttribute("color",new Ti(r,3)),super(e,n),this.type="CameraHelper",this.camera=t,this.camera.updateProjectionMatrix&&this.camera.updateProjectionMatrix(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.pointMap=s,this.update();const l=new ci(16755200),c=new ci(16711680),h=new ci(43775),u=new ci(16777215),d=new ci(3355443);this.setColors(l,c,h,u,d)}setColors(t,e,n,i,r){const s=this.geometry.getAttribute("color");s.setXYZ(0,t.r,t.g,t.b),s.setXYZ(1,t.r,t.g,t.b),s.setXYZ(2,t.r,t.g,t.b),s.setXYZ(3,t.r,t.g,t.b),s.setXYZ(4,t.r,t.g,t.b),s.setXYZ(5,t.r,t.g,t.b),s.setXYZ(6,t.r,t.g,t.b),s.setXYZ(7,t.r,t.g,t.b),s.setXYZ(8,t.r,t.g,t.b),s.setXYZ(9,t.r,t.g,t.b),s.setXYZ(10,t.r,t.g,t.b),s.setXYZ(11,t.r,t.g,t.b),s.setXYZ(12,t.r,t.g,t.b),s.setXYZ(13,t.r,t.g,t.b),s.setXYZ(14,t.r,t.g,t.b),s.setXYZ(15,t.r,t.g,t.b),s.setXYZ(16,t.r,t.g,t.b),s.setXYZ(17,t.r,t.g,t.b),s.setXYZ(18,t.r,t.g,t.b),s.setXYZ(19,t.r,t.g,t.b),s.setXYZ(20,t.r,t.g,t.b),s.setXYZ(21,t.r,t.g,t.b),s.setXYZ(22,t.r,t.g,t.b),s.setXYZ(23,t.r,t.g,t.b),s.setXYZ(24,e.r,e.g,e.b),s.setXYZ(25,e.r,e.g,e.b),s.setXYZ(26,e.r,e.g,e.b),s.setXYZ(27,e.r,e.g,e.b),s.setXYZ(28,e.r,e.g,e.b),s.setXYZ(29,e.r,e.g,e.b),s.setXYZ(30,e.r,e.g,e.b),s.setXYZ(31,e.r,e.g,e.b),s.setXYZ(32,n.r,n.g,n.b),s.setXYZ(33,n.r,n.g,n.b),s.setXYZ(34,n.r,n.g,n.b),s.setXYZ(35,n.r,n.g,n.b),s.setXYZ(36,n.r,n.g,n.b),s.setXYZ(37,n.r,n.g,n.b),s.setXYZ(38,i.r,i.g,i.b),s.setXYZ(39,i.r,i.g,i.b),s.setXYZ(40,r.r,r.g,r.b),s.setXYZ(41,r.r,r.g,r.b),s.setXYZ(42,r.r,r.g,r.b),s.setXYZ(43,r.r,r.g,r.b),s.setXYZ(44,r.r,r.g,r.b),s.setXYZ(45,r.r,r.g,r.b),s.setXYZ(46,r.r,r.g,r.b),s.setXYZ(47,r.r,r.g,r.b),s.setXYZ(48,r.r,r.g,r.b),s.setXYZ(49,r.r,r.g,r.b),s.needsUpdate=!0}update(){const t=this.geometry,e=this.pointMap;rd.projectionMatrixInverse.copy(this.camera.projectionMatrixInverse),sd("c",e,t,rd,0,0,-1),sd("t",e,t,rd,0,0,1),sd("n1",e,t,rd,-1,-1,-1),sd("n2",e,t,rd,1,-1,-1),sd("n3",e,t,rd,-1,1,-1),sd("n4",e,t,rd,1,1,-1),sd("f1",e,t,rd,-1,-1,1),sd("f2",e,t,rd,1,-1,1),sd("f3",e,t,rd,-1,1,1),sd("f4",e,t,rd,1,1,1),sd("u1",e,t,rd,.7,1.1,-1),sd("u2",e,t,rd,-.7,1.1,-1),sd("u3",e,t,rd,0,2,-1),sd("cf1",e,t,rd,-1,0,1),sd("cf2",e,t,rd,1,0,1),sd("cf3",e,t,rd,0,-1,1),sd("cf4",e,t,rd,0,1,1),sd("cn1",e,t,rd,-1,0,-1),sd("cn2",e,t,rd,1,0,-1),sd("cn3",e,t,rd,0,-1,-1),sd("cn4",e,t,rd,0,1,-1),t.getAttribute("position").needsUpdate=!0}dispose(){this.geometry.dispose(),this.material.dispose()}},t.CanvasTexture=class extends Fe{constructor(t,e,n,i,r,s,a,o,l){super(t,e,n,i,r,s,a,o,l),this.isCanvasTexture=!0,this.needsUpdate=!0}},t.CapsuleGeometry=ic,t.CatmullRomCurve3=Vl,t.CineonToneMapping=g,t.CircleGeometry=rc,t.ClampToEdgeWrapping=C,t.Clock=vu,t.Color=ci,t.ColorKeyframeTrack=Eh,t.ColorManagement=Re,t.CompressedArrayTexture=class extends Ul{constructor(t,e,n,i,r,s){super(t,e,n,r,s),this.isCompressedArrayTexture=!0,this.image.depth=i,this.wrapR=C}},t.CompressedCubeTexture=class extends Ul{constructor(t,e,n){super(void 0,t[0].width,t[0].height,e,n,b),this.isCompressedCubeTexture=!0,this.isCubeTexture=!0,this.image=t}},t.CompressedTexture=Ul,t.CompressedTextureLoader=class extends Oh{constructor(t){super(t)}load(t,e,n,i){const r=this,s=[],a=new Ul,o=new zh(this.manager);o.setPath(this.path),o.setResponseType("arraybuffer"),o.setRequestHeader(this.requestHeader),o.setWithCredentials(r.withCredentials);let l=0;function c(c){o.load(t[c],(function(t){const n=r.parse(t,!0);s[c]={width:n.width,height:n.height,format:n.format,mipmaps:n.mipmaps},l+=1,6===l&&(1===n.mipmapCount&&(a.minFilter=D),a.image=s,a.format=n.format,a.needsUpdate=!0,e&&e(a))}),n,i)}if(Array.isArray(t))for(let e=0,n=t.length;e{e&&e(n),r.manager.itemEnd(t)})).catch((t=>{i&&i(t)})):(setTimeout((function(){e&&e(s),r.manager.itemEnd(t)}),0),s);const a={};a.credentials="anonymous"===this.crossOrigin?"same-origin":"include",a.headers=this.requestHeader;const o=fetch(t,a).then((function(t){return t.blob()})).then((function(t){return createImageBitmap(t,Object.assign(r.options,{colorSpaceConversion:"none"}))})).then((function(n){return Uh.add(t,n),e&&e(n),r.manager.itemEnd(t),n})).catch((function(e){i&&i(e),Uh.remove(t),r.manager.itemError(t),r.manager.itemEnd(t)}));Uh.add(t,o),r.manager.itemStart(t)}},t.ImageLoader=Gh,t.ImageUtils=Ie,t.IncrementStencilOp=7682,t.IncrementWrapStencilOp=34055,t.InstancedBufferAttribute=ko,t.InstancedBufferGeometry=lu,t.InstancedInterleavedBuffer=class extends so{constructor(t,e,n=1){super(t,e),this.isInstancedInterleavedBuffer=!0,this.meshPerAttribute=n}copy(t){return super.copy(t),this.meshPerAttribute=t.meshPerAttribute,this}clone(t){const e=super.clone(t);return e.meshPerAttribute=this.meshPerAttribute,e}toJSON(t){const e=super.toJSON(t);return e.isInstancedInterleavedBuffer=!0,e.meshPerAttribute=this.meshPerAttribute,e}},t.InstancedMesh=Ko,t.Int16BufferAttribute=class extends Mi{constructor(t,e,n){super(new Int16Array(t),e,n)}},t.Int32BufferAttribute=class extends Mi{constructor(t,e,n){super(new Int32Array(t),e,n)}},t.Int8BufferAttribute=class extends Mi{constructor(t,e,n){super(new Int8Array(t),e,n)}},t.IntType=z,t.InterleavedBuffer=so,t.InterleavedBufferAttribute=oo,t.Interpolant=xh,t.InterpolateDiscrete=Ct,t.InterpolateLinear=Lt,t.InterpolateSmooth=Pt,t.InvertStencilOp=5386,t.KeepStencilOp=Yt,t.KeyframeTrack=bh,t.LOD=Ao,t.LatheGeometry=nc,t.Layers=In,t.LessCompare=513,t.LessDepth=2,t.LessEqualCompare=515,t.LessEqualDepth=3,t.LessEqualStencilFunc=515,t.LessStencilFunc=513,t.Light=Hh,t.LightProbe=su,t.Line=Ml,t.Line3=class{constructor(t=new We,e=new We){this.start=t,this.end=e}set(t,e){return this.start.copy(t),this.end.copy(e),this}copy(t){return this.start.copy(t.start),this.end.copy(t.end),this}getCenter(t){return t.addVectors(this.start,this.end).multiplyScalar(.5)}delta(t){return t.subVectors(this.end,this.start)}distanceSq(){return this.start.distanceToSquared(this.end)}distance(){return this.start.distanceTo(this.end)}at(t,e){return this.delta(e).multiplyScalar(t).add(this.start)}closestPointToPointParameter(t,e){Wu.subVectors(t,this.start),Xu.subVectors(this.end,this.start);const n=Xu.dot(Xu);let i=Xu.dot(Wu)/n;return e&&(i=ae(i,0,1)),i}closestPointToPoint(t,e,n){const i=this.closestPointToPointParameter(t,e);return this.delta(n).multiplyScalar(i).add(this.start)}applyMatrix4(t){return this.start.applyMatrix4(t),this.end.applyMatrix4(t),this}equals(t){return t.start.equals(this.start)&&t.end.equals(this.end)}clone(){return(new this.constructor).copy(this)}},t.LineBasicMaterial=fl,t.LineCurve=Yl,t.LineCurve3=Zl,t.LineDashedMaterial=dh,t.LineLoop=El,t.LineSegments=Tl,t.LinearDisplayP3ColorSpace=kt,t.LinearEncoding=Ft,t.LinearFilter=D,t.LinearInterpolant=Mh,t.LinearMipMapLinearFilter=1008,t.LinearMipMapNearestFilter=1007,t.LinearMipmapLinearFilter=O,t.LinearMipmapNearestFilter=N,t.LinearSRGBColorSpace=Ht,t.LinearToneMapping=m,t.LinearTransfer=Wt,t.Loader=Oh,t.LoaderUtils=ou,t.LoadingManager=Dh,t.LoopOnce=2200,t.LoopPingPong=2202,t.LoopRepeat=2201,t.LuminanceAlphaFormat=1025,t.LuminanceFormat=1024,t.MOUSE={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},t.Material=di,t.MaterialLoader=au,t.MathUtils=pe,t.Matrix3=fe,t.Matrix4=Mn,t.MaxEquation=104,t.Mesh=Ji,t.MeshBasicMaterial=pi,t.MeshDepthMaterial=Ha,t.MeshDistanceMaterial=Va,t.MeshLambertMaterial=hh,t.MeshMatcapMaterial=uh,t.MeshNormalMaterial=ch,t.MeshPhongMaterial=oh,t.MeshPhysicalMaterial=ah,t.MeshStandardMaterial=sh,t.MeshToonMaterial=lh,t.MinEquation=103,t.MirroredRepeatWrapping=L,t.MixOperation=u,t.MultiplyBlending=4,t.MultiplyOperation=h,t.NearestFilter=P,t.NearestMipMapLinearFilter=1005,t.NearestMipMapNearestFilter=1004,t.NearestMipmapLinearFilter=U,t.NearestMipmapNearestFilter=I,t.NeverCompare=512,t.NeverDepth=0,t.NeverStencilFunc=512,t.NoBlending=0,t.NoColorSpace=zt,t.NoToneMapping=p,t.NormalAnimationBlendMode=Nt,t.NormalBlending=1,t.NotEqualCompare=517,t.NotEqualDepth=7,t.NotEqualStencilFunc=517,t.NumberKeyframeTrack=wh,t.Object3D=jn,t.ObjectLoader=class extends Oh{constructor(t){super(t)}load(t,e,n,i){const r=this,s=""===this.path?ou.extractUrlBase(t):this.path;this.resourcePath=this.resourcePath||s;const a=new zh(this.manager);a.setPath(this.path),a.setRequestHeader(this.requestHeader),a.setWithCredentials(this.withCredentials),a.load(t,(function(n){let s=null;try{s=JSON.parse(n)}catch(e){return void 0!==i&&i(e),void console.error("THREE:ObjectLoader: Can't parse "+t+".",e.message)}const a=s.metadata;if(void 0===a||void 0===a.type||"geometry"===a.type.toLowerCase())return void 0!==i&&i(new Error("THREE.ObjectLoader: Can't load "+t)),void console.error("THREE.ObjectLoader: Can't load "+t);r.parse(s,e)}),n,i)}async loadAsync(t,e){const n=""===this.path?ou.extractUrlBase(t):this.path;this.resourcePath=this.resourcePath||n;const i=new zh(this.manager);i.setPath(this.path),i.setRequestHeader(this.requestHeader),i.setWithCredentials(this.withCredentials);const r=await i.loadAsync(t,e),s=JSON.parse(r),a=s.metadata;if(void 0===a||void 0===a.type||"geometry"===a.type.toLowerCase())throw new Error("THREE.ObjectLoader: Can't load "+t);return await this.parseAsync(s)}parse(t,e){const n=this.parseAnimations(t.animations),i=this.parseShapes(t.shapes),r=this.parseGeometries(t.geometries,i),s=this.parseImages(t.images,(function(){void 0!==e&&e(l)})),a=this.parseTextures(t.textures,s),o=this.parseMaterials(t.materials,a),l=this.parseObject(t.object,r,o,a,n),c=this.parseSkeletons(t.skeletons,l);if(this.bindSkeletons(l,c),void 0!==e){let t=!1;for(const e in s)if(s[e].data instanceof HTMLImageElement){t=!0;break}!1===t&&e(l)}return l}async parseAsync(t){const e=this.parseAnimations(t.animations),n=this.parseShapes(t.shapes),i=this.parseGeometries(t.geometries,n),r=await this.parseImagesAsync(t.images),s=this.parseTextures(t.textures,r),a=this.parseMaterials(t.materials,s),o=this.parseObject(t.object,i,a,s,e),l=this.parseSkeletons(t.skeletons,o);return this.bindSkeletons(o,l),o}parseShapes(t){const e={};if(void 0!==t)for(let n=0,i=t.length;n0){const n=new Dh(e);r=new Gh(n),r.setCrossOrigin(this.crossOrigin);for(let e=0,n=t.length;e0){i=new Gh(this.manager),i.setCrossOrigin(this.crossOrigin);for(let e=0,i=t.length;e{const e=new qe;e.min.fromArray(t.boxMin),e.max.fromArray(t.boxMax);const n=new dn;return n.radius=t.sphereRadius,n.center.fromArray(t.sphereCenter),{boxInitialized:t.boxInitialized,box:e,sphereInitialized:t.sphereInitialized,sphere:n}})),s._maxGeometryCount=t.maxGeometryCount,s._maxVertexCount=t.maxVertexCount,s._maxIndexCount=t.maxIndexCount,s._geometryInitialized=t.geometryInitialized,s._geometryCount=t.geometryCount,s._matricesTexture=h(t.matricesTexture.uuid);break;case"LOD":s=new Ao;break;case"Line":s=new Ml(l(t.geometry),c(t.material));break;case"LineLoop":s=new El(l(t.geometry),c(t.material));break;case"LineSegments":s=new Tl(l(t.geometry),c(t.material));break;case"PointCloud":case"Points":s=new Pl(l(t.geometry),c(t.material));break;case"Sprite":s=new bo(c(t.material));break;case"Group":s=new Ya;break;case"Bone":s=new Bo;break;default:s=new jn}if(s.uuid=t.uuid,void 0!==t.name&&(s.name=t.name),void 0!==t.matrix?(s.matrix.fromArray(t.matrix),void 0!==t.matrixAutoUpdate&&(s.matrixAutoUpdate=t.matrixAutoUpdate),s.matrixAutoUpdate&&s.matrix.decompose(s.position,s.quaternion,s.scale)):(void 0!==t.position&&s.position.fromArray(t.position),void 0!==t.rotation&&s.rotation.fromArray(t.rotation),void 0!==t.quaternion&&s.quaternion.fromArray(t.quaternion),void 0!==t.scale&&s.scale.fromArray(t.scale)),void 0!==t.up&&s.up.fromArray(t.up),void 0!==t.castShadow&&(s.castShadow=t.castShadow),void 0!==t.receiveShadow&&(s.receiveShadow=t.receiveShadow),t.shadow&&(void 0!==t.shadow.bias&&(s.shadow.bias=t.shadow.bias),void 0!==t.shadow.normalBias&&(s.shadow.normalBias=t.shadow.normalBias),void 0!==t.shadow.radius&&(s.shadow.radius=t.shadow.radius),void 0!==t.shadow.mapSize&&s.shadow.mapSize.fromArray(t.shadow.mapSize),void 0!==t.shadow.camera&&(s.shadow.camera=this.parseObject(t.shadow.camera))),void 0!==t.visible&&(s.visible=t.visible),void 0!==t.frustumCulled&&(s.frustumCulled=t.frustumCulled),void 0!==t.renderOrder&&(s.renderOrder=t.renderOrder),void 0!==t.userData&&(s.userData=t.userData),void 0!==t.layers&&(s.layers.mask=t.layers),void 0!==t.children){const a=t.children;for(let t=0;t1)for(let n=0;nNumber.EPSILON){if(l<0&&(n=e[s],o=-o,a=e[r],l=-l),t.ya.y)continue;if(t.y===n.y){if(t.x===n.x)return!0}else{const e=l*(t.x-n.x)-o*(t.y-n.y);if(0===e)return!0;if(e<0)continue;i=!i}}else{if(t.y!==n.y)continue;if(a.x<=t.x&&t.x<=n.x||n.x<=t.x&&t.x<=a.x)return!0}}return i}const n=Gc.isClockWise,i=this.subPaths;if(0===i.length)return[];let r,s,a;const o=[];if(1===i.length)return s=i[0],a=new mc,a.curves=s.curves,o.push(a),o;let l=!n(i[0].getPoints());l=t?!l:l;const c=[],h=[];let u,d,p=[],m=0;h[m]=void 0,p[m]=[];for(let e=0,a=i.length;e1){let t=!1,n=0;for(let t=0,e=h.length;t0&&!1===t&&(p=c)}for(let t=0,e=h.length;t=t.HAVE_CURRENT_DATA&&(this.needsUpdate=!0)}},t.WebGL1Renderer=eo,t.WebGL3DRenderTarget=class extends Ge{constructor(t=1,e=1,n=1,i={}){super(t,e,i),this.isWebGL3DRenderTarget=!0,this.depth=n,this.texture=new Ve(null,t,e,n),this.texture.isRenderTargetTexture=!0}},t.WebGLArrayRenderTarget=class extends Ge{constructor(t=1,e=1,n=1,i={}){super(t,e,i),this.isWebGLArrayRenderTarget=!0,this.depth=n,this.texture=new He(null,t,e,n),this.texture.isRenderTargetTexture=!0}},t.WebGLCoordinateSystem=$t,t.WebGLCubeRenderTarget=cr,t.WebGLMultipleRenderTargets=class extends Ge{constructor(t=1,e=1,n=1,i={}){super(t,e,i),this.isWebGLMultipleRenderTargets=!0;const r=this.texture;this.texture=[];for(let t=0;t +

Handbuch

+ Was Grotti ist + Ein Druck von A bis Z + Ablauf (Kobra 3) + Seiten + Wenn etwas hängt + Begriffe +

Versionen

+ Changelog · v{{ version }} +

Technik

+ {% for name in docs %} + {{ name | replace('-', ' ') }} + {% endfor %} + +{% endmacro %} diff --git a/web/templates/aktivitaet.html b/web/templates/aktivitaet.html new file mode 100644 index 0000000..eb938d4 --- /dev/null +++ b/web/templates/aktivitaet.html @@ -0,0 +1,183 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Aktivität

+

Ereignisse von Grotti und den Druckern. Neueste oben.

+
+
+ + +
+
+ +
+ + + +
+
+
+ +

+
+
+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/dateien.html b/web/templates/dateien.html new file mode 100644 index 0000000..76690a0 --- /dev/null +++ b/web/templates/dateien.html @@ -0,0 +1,1306 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Dateien

+

+
+
+ + + + +
+
+ +
+
+
+

Bibliothek

+ 0 +
+
+ + + +
+

+
+
+
+
+
+

Dateien hierher ziehen zum Hochladen.

+
+ +
+
+ + +
+ +
+

1Datei

+
Datei aus der Bibliothek wählen.
+
+ +
+

2Drucker

+
+

+
+ +
+

3Wann

+
+ + + +
+
+ + + +
+ Optionen +
+
+

Verbrauch geht von der Rolle am Drucker. Wechsel unter Material.

+ + + + + +
+
+ + + +
+ +

+
+ + + +

+
+
+ + +{% endblock %} +{% block script %} + + + + +{% endblock %} diff --git a/web/templates/doku.html b/web/templates/doku.html new file mode 100644 index 0000000..c3ce583 --- /dev/null +++ b/web/templates/doku.html @@ -0,0 +1,100 @@ +{% extends "layout.html" %} +{% from "_doc_toc.html" import doc_toc with context %} +{% block body %} +
+ {{ doc_toc('start') }} +
+

Grotti v{{ version }}

+

Grotti steuert die Drucker im Haus von einer Seite aus: sehen, was läuft, Dateien schicken, Aufträge einreihen und nachlesen, was passiert ist.

+

Sie läuft auf diesem Rechner und spricht jeden Drucker über Moonraker an (Rinkhals auf Kobra 3 und Kobra S1). Daten liegen in PostgreSQL. Alles, was die Seiten zeigen, gibt es auch unter /api/v1.

+
Sicherheit: Ein Druck startet nur nach ausdrücklicher Bestätigung. Es gibt keine freie G-code-Konsole und kein FIRMWARE_RESTART.
+ +

Ein Druck von A bis Z

+
+ Datei + Drucker + Jetzt · Wenn frei · Nächster freier + Druck + Platte räumen +
+
    +
  1. Datei wählen. Unter Dateien links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.
  2. +
  3. Drucker wählen. Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.
  4. +
  5. Wann. Jetzt startet sofort. Wenn frei reiht für diesen Drucker ein. Nächster freier lässt Grotti den ersten freien passenden Drucker nehmen.
  6. +
  7. Starten. „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.
  8. +
  9. Während des Drucks zeigt die Wand Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.
  10. +
  11. Danach ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte Platte geräumt gedrückt wurde, oder ein Ablauf sie selbst freischiebt.
  12. +
+ +

Ablauf (Kobra 3)

+

Ein Ablauf ist eine Kette, die der Kobra 3 alleine abarbeitet und beliebig oft wiederholt:

+
+ Drucken + Abkühlen + Schieben + Tasten + nochmal +
+
    +
  • Abkühlen wartet, bis Bett und Düse unter der Grenze sind. Danach löst sich PLA meist von selbst.
  • +
  • Schieben fährt mit dem Extruder knapp über dem Bett von hinten nach vorn und schiebt das Teil in die Kiste.
  • +
  • Tasten tippt mit aktivem LeviQ-Netz (ohne BED_MESH_CLEAR, ohne G28 Z). Tastpunkt einstellbar. Maß: Result is z= über der lokalen Platte. Über 1 mm stoppt den Ablauf.
  • +
+

Einreihen unter Dateien → Ablauf. Der aktuelle Schritt steht auf der Druckerkarte und in der Seitenleiste. Abbrechen geht an beiden Stellen. Jeder Schritt landet unter Aktivität.

+ +

Seiten

+
+
Drucker
Die Wand. Eine Karte je Drucker mit Zustand, Fertig-Uhrzeit, Temperaturen und Ablauf-Schritten. Klick öffnet das Cockpit mit Kamera, Steuerung, Bettnetz und 3D-Pfad.
+
Dateien
Bibliothek und Senden. Spule und ACE-Plätze unter Optionen. Warnt, wenn das Filament nicht reicht.
+
Kameras
Alle Kamerabilder nebeneinander.
+
Aktivität
Zeitstrahl: Starts, Schritte, Fertig, Fehler, Abbrüche, offline. Filter nach Drucker, Art und Zeitraum.
+
Protokoll
Abgeschlossene Läufe mit Dauer, Gramm, Kosten und Urteil.
+
Material
Sorten, Spulen, Farben, ACE-Plätze, Etiketten.
+
Projekte
Aufträge mit Notiz und Kalkulation.
+
Einstellungen
Drucker, Telegram-Alerts, Energie.
+
+ +

Wenn etwas hängt

+
+
Auftrag wartet und startet nicht
Auf der Karte steht Platte belegt. Platte räumen, dann Platte geräumt drücken.
+
Drucker im Fehlerzustand
Auf der Karte Fehler löschen. Grotti räumt hängende Fehler vor einem Start selbst, wenn kein Druck aktiv ist.
+
Ablauf gestoppt
Die Seitenleiste zeigt den Grund. Ursache beheben, Ablauf neu einreihen. Unter Aktivität steht, welcher Schritt zuletzt lief.
+
Drucker offline
Strom und WLAN prüfen. Grotti meldet, wenn er wieder da ist.
+
ACE startet nicht
Unter Optionen die ACE-Zuordnung prüfen. Ohne Zuordnung kommt oft „cannot parse“.
+
+ +

Begriffe

+
+
Auftrag
Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.
+
Ablauf
Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.
+
Wenn frei
Startet, sobald der Drucker bereit und die Platte geräumt ist.
+
Platte belegt
Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.
+
Alert
Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.
+
Ereignis
Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.
+
+ +

Technik

+

Die Notizen zu Aufbau und Schnittstellen:

+ +

Was sich je Version geändert hat, steht im Changelog.

+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/doku_page.html b/web/templates/doku_page.html new file mode 100644 index 0000000..f507280 --- /dev/null +++ b/web/templates/doku_page.html @@ -0,0 +1,11 @@ +{% extends "layout.html" %} +{% from "_doc_toc.html" import doc_toc with context %} +{% block body %} +
+ {{ doc_toc(current) }} +
+

Doku · {{ title }}

+ {{ content }} +
+
+{% endblock %} diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html new file mode 100644 index 0000000..8d6a8c7 --- /dev/null +++ b/web/templates/einstellungen.html @@ -0,0 +1,349 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Einstellungen

+

Drucker, Benachrichtigungen und Energiekosten.

+
+
+ +
+

Dieser Browser

+

Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.

+
+ + +
+

Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: ?shelf=1

+ +
+ +
+

Automatik

+ +

Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.

+
+

+
+ +
+

Öffentliche Adresse

+

FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).

+
+ + +
+

Ohne Schema wird https:// ergänzt. Mit Port: http://10.10.1.5:8770.

+
+

+
+ +
+

Drucker

+

Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.

+
+ + + +
NameHostFamilieGesperrt
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+

+
+ +
+

Telegram

+ +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+

Ereignisse stehen immer unter Aktivität. Die Häkchen steuern nur Telegram.

+
+ + +
+

+
+ +
+

Energie

+

Für Kosten im Protokoll. Filament zählt erst mit zugeordneter Spule.

+
+
+
+
+
+
+
+
+
+

+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/etikett.html b/web/templates/etikett.html new file mode 100644 index 0000000..f87912d --- /dev/null +++ b/web/templates/etikett.html @@ -0,0 +1,113 @@ + + + + + +{{ title }} + + + +
+ + Zurück +
+
+
+

{{ spool.name }}

+

{{ spool.chargen_nr or spool.code }}

+

+ {% if spool.colors %} + + {% for color in spool.colors %} + + {% endfor %} + + {% endif %} + {{ spool.color or spool.material_type }} +

+

{{ spool.material_type }}{% if spool.brand %} · {{ spool.brand }}{% endif %}

+

{{ '%.0f'|format(spool.remaining_g) }} g{% if spool.ace_slot %} · ACE {{ spool.ace_slot }}{% endif %}

+ {% if spool.hersteller_charge %} +

Hersteller: {{ spool.hersteller_charge }}

+ {% endif %} +
+ +
+ + + + diff --git a/web/templates/ideen.html b/web/templates/ideen.html new file mode 100644 index 0000000..c0cf4e6 --- /dev/null +++ b/web/templates/ideen.html @@ -0,0 +1,112 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Ideen

+

Backlog und Notizen für später.

+
+
+
+
+ + + +
TitelStand
+
+
+

Neue Idee

+
+ +
+
+
+
+
+ + + +
+
+

+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/kameras.html b/web/templates/kameras.html new file mode 100644 index 0000000..357a4b5 --- /dev/null +++ b/web/templates/kameras.html @@ -0,0 +1,102 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Kameras

+

Livebilder aller Drucker.

+
+ +
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..5a35365 --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,431 @@ + + + + + + +{{ title }} · Grotti + + + + + + +
+
+{% block body %}{% endblock %} +
+
+ + + +{% block script %}{% endblock %} + + diff --git a/web/templates/material.html b/web/templates/material.html new file mode 100644 index 0000000..133236a --- /dev/null +++ b/web/templates/material.html @@ -0,0 +1,838 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Material

+

Spulen am Drucker und im Regal. Sorten sind die Profile dazu.

+
+
+ + +
+
+ +
+ + +
+ +
+
+
+ + + + +
+ + +
+

+ +
+
+
+

Spulen

+ 0 +
+ + + +
SpuleChargeSorteFarbeOrtRest
+
+ + +
+
+ + +{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/projekte.html b/web/templates/projekte.html new file mode 100644 index 0000000..e3f4f4a --- /dev/null +++ b/web/templates/projekte.html @@ -0,0 +1,195 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Projekte

+

Gruppiere Dateien und Läufe.

+
+
+
+
+

Liste

+
+ + + +
NameStatusDateien
+
+
+
+

Neu

+
+ +
+
+
+
+
+ + + +
+
+

+

Kalkulation

+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/protokoll.html b/web/templates/protokoll.html new file mode 100644 index 0000000..9248553 --- /dev/null +++ b/web/templates/protokoll.html @@ -0,0 +1,221 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Protokoll

+

Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.

+
+
+ +
+
+
+ + + +
+

+
+ + + +
DruckerDateiZustandDauerGrammKostenUrteilSpule
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/web/templates/wand.html b/web/templates/wand.html new file mode 100644 index 0000000..127001c --- /dev/null +++ b/web/templates/wand.html @@ -0,0 +1,1310 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Drucker

+
lade …
+
+
+ + +
+
+

+
+ +{% endblock %} +{% block script %} + + + +{% endblock %} diff --git a/web/templates/warteschlange.html b/web/templates/warteschlange.html new file mode 100644 index 0000000..7b2b3c6 --- /dev/null +++ b/web/templates/warteschlange.html @@ -0,0 +1,218 @@ +{% extends "layout.html" %} +{% block body %} +
+
+

Warteschlange

+

Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.

+
+
+ +
+
+
+ + + + + +
+

+
+ + + + + + + + + + + + +
StatusDateiZielSchritteMeldung
+
+{% endblock %} +{% block script %} + +{% endblock %}