Initial commit
This commit is contained in:
commit
b22231c8b6
40 changed files with 2443 additions and 0 deletions
75
.continue/agents/new-config.yaml
Normal file
75
.continue/agents/new-config.yaml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
name: Local Qwen Setup
|
||||||
|
|
||||||
|
models:
|
||||||
|
- name: Qwen3.5 Chat
|
||||||
|
provider: openai
|
||||||
|
model: qwen
|
||||||
|
apiBase: http://localhost:8000/v1
|
||||||
|
apiKey: none
|
||||||
|
roles:
|
||||||
|
- chat
|
||||||
|
- edit
|
||||||
|
- apply
|
||||||
|
defaultCompletionOptions:
|
||||||
|
temperature: 0.1
|
||||||
|
top_p: 0.9
|
||||||
|
max_tokens: 1024
|
||||||
|
stop:
|
||||||
|
- "</s>"
|
||||||
|
|
||||||
|
- name: Qwen3.5 Autocomplete
|
||||||
|
provider: openai
|
||||||
|
model: qwen
|
||||||
|
apiBase: http://localhost:8000/v1
|
||||||
|
apiKey: none
|
||||||
|
roles:
|
||||||
|
- autocomplete
|
||||||
|
defaultCompletionOptions:
|
||||||
|
temperature: 0.05
|
||||||
|
max_tokens: 256
|
||||||
|
|
||||||
|
context:
|
||||||
|
- provider: code
|
||||||
|
- provider: docs
|
||||||
|
- provider: diff
|
||||||
|
- provider: terminal
|
||||||
|
|
||||||
|
slashCommands:
|
||||||
|
- name: fix
|
||||||
|
description: Corriger un bug de manière minimale
|
||||||
|
prompt: |
|
||||||
|
Corrige uniquement le problème identifié.
|
||||||
|
Contraintes:
|
||||||
|
- modification minimale
|
||||||
|
- pas de refactor global
|
||||||
|
- conserve l'architecture existante
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
description: Générer un test pytest
|
||||||
|
prompt: |
|
||||||
|
Écris un test pytest minimal qui reproduit ce bug.
|
||||||
|
Ne corrige pas le code.
|
||||||
|
|
||||||
|
- name: explain
|
||||||
|
description: Expliquer du code
|
||||||
|
prompt: |
|
||||||
|
Explique ce code de manière concise et technique.
|
||||||
|
|
||||||
|
- name: improve
|
||||||
|
description: Amélioration contrôlée
|
||||||
|
prompt: |
|
||||||
|
Propose une amélioration ciblée.
|
||||||
|
Ne fais pas de refactor global.
|
||||||
|
|
||||||
|
systemMessage: |
|
||||||
|
Tu es un expert en développement logiciel.
|
||||||
|
Spécialités:
|
||||||
|
- Python / FastAPI
|
||||||
|
- SIG / géospatial
|
||||||
|
- traitement de nuages de points (PDAL, Potree)
|
||||||
|
|
||||||
|
Règles strictes:
|
||||||
|
- ne jamais refactoriser massivement
|
||||||
|
- toujours proposer des modifications minimales
|
||||||
|
- privilégier des solutions robustes et simples
|
||||||
|
- si incertain, poser une question au lieu d'inventer
|
||||||
77
.gitignore
vendored
Normal file
77
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile.builder
|
||||||
|
Dockerfile.entwine
|
||||||
|
Dockerfile.frontend
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Répertoires Potree
|
||||||
|
backend/static/potree/
|
||||||
|
frontend/static/potree/
|
||||||
|
|
||||||
|
# Données LiDAR (fichiers volumineux)
|
||||||
|
backend/data/ept/*/ept-data/*.laz
|
||||||
|
backend/data/ept/*/ept-data/*.las
|
||||||
|
backend/data/uploads/*.laz
|
||||||
|
backend/data/uploads/*.las
|
||||||
|
backend/data/uploads/delete.txt
|
||||||
|
|
||||||
|
# Fichiers de données temporaires
|
||||||
|
backend/data/ept/*/*.json
|
||||||
|
backend/data/ept/*/*/*.json
|
||||||
|
backend/data/ept/*/*/*/*.json
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Fichiers temporaires
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Fichiers de configuration locale
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Fichiers générés
|
||||||
|
*.pkl
|
||||||
|
*.h5
|
||||||
|
*.npz
|
||||||
21
Roo.md
Normal file
21
Roo.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# 📄 Contexte du Projet - Roo Code
|
||||||
|
|
||||||
|
## 🎯 Objectif
|
||||||
|
Application web pour la gestion, manipulation et visualisation de nuages de points 3D.
|
||||||
|
|
||||||
|
## 🛠️ Stack Technique
|
||||||
|
### Backend
|
||||||
|
- **Langage :** Python 3.10+
|
||||||
|
- **Framework :** FastAPI
|
||||||
|
- **Traitement :** PDAL (filtrage, densification)
|
||||||
|
- **Stockage :** Entwine (nuages de points)
|
||||||
|
- **Base de données :** pas de base de données pour l'instant. SQLite + Spatialite plus tard
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Templates :** Jinja2
|
||||||
|
- **Interactivité :** HTMX
|
||||||
|
- **UI :** DaisyUI (Tailwind CSS)
|
||||||
|
- **Logique :** AlpineJS
|
||||||
|
- **Visualisation :** Potree Viewer (WebGL)
|
||||||
|
|
||||||
|
|
||||||
145
agents.md
Normal file
145
agents.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# 🤖 Configuration des Agents Cline - Analyse & Débogage
|
||||||
|
|
||||||
|
## 📋 Contexte du Projet
|
||||||
|
**Nom du projet :** PointCloud Classifier
|
||||||
|
**Objectif :** Développer une application web pour l'upload, la manipulation, le traitement et l'affichage de nuages de points 3D.
|
||||||
|
**Utilisation principale :** Visualisation technique, gestion de données géospatiales, administration.
|
||||||
|
|
||||||
|
## 📋 Vue d'ensemble
|
||||||
|
Ce fichier définit les agents IA utilisés pour analyser, comprendre, déboguer et faire évoluer le repository de l'application **point-cloud-classifier**.
|
||||||
|
- **Objectif principal :** Identifier les bugs, optimiser le code et comprendre l'architecture.
|
||||||
|
- **Environnement :** VS Code avec Cline.
|
||||||
|
- **Langage principal :** Python, JavaScript, TypeScript
|
||||||
|
- **Framework :** FastAPI, HTMX, Django, Svelte, AlpineJS, DaisyUi, jinja2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend (Serveur & Traitement)
|
||||||
|
- **Langage :** Python 3.12+
|
||||||
|
- **Framework Web :** FastAPI (API REST, Serveur)
|
||||||
|
- **Traitement Nuages :** PDAL (Point Data Abstraction Library) pour le filtrage/rééchantillonnage.
|
||||||
|
- **Stockage Nuages :** Entwine (Format de stockage optimisé pour les nuages de points): format EPT.
|
||||||
|
- **Base de Données :** SQLite avec Spatialite pour les métadonnées.
|
||||||
|
- **Sécurité :** Gestion sécurisée des uploads (taille, type MIME, virus scan).
|
||||||
|
|
||||||
|
### Frontend (Interface Utilisateur)
|
||||||
|
- **Templates :** Jinja2 (Intégration avec FastAPI).
|
||||||
|
- **Interactivité :** HTMX (Chargement dynamique sans rechargement complet).
|
||||||
|
- **Composants UI :** DaisyUI (Thèmes Tailwind CSS).
|
||||||
|
- **Logique Client :** AlpineJS (Gestion d'état légère).
|
||||||
|
- **Visualisation :** Potree Viewer (Bibliothèque WebGL pour l'affichage 3D).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Définition des Agents
|
||||||
|
|
||||||
|
### 1. Agent: Architecte Système
|
||||||
|
- **Rôle :** Analyseur de structure et de dépendances.
|
||||||
|
- **Description :** Il examine le code global, les fichiers de configuration et les imports pour comprendre comment les modules interagissent.
|
||||||
|
- **Objectif :** Produire une carte mentale de l'architecture et identifier les points de rupture potentiels.
|
||||||
|
- **Outils :**
|
||||||
|
- Lecture de fichiers (`tree`, `ls`, `find`)
|
||||||
|
- Analyse de dépendances (`uv pip list`, `npm list`)
|
||||||
|
- Recherche de patterns de code
|
||||||
|
- **Contraintes :**
|
||||||
|
- Ne pas modifier le code sans validation.
|
||||||
|
- Identifier les fichiers critiques avant toute suggestion.
|
||||||
|
- **Entrées attendues :** Structure du repository, logs d'erreur.
|
||||||
|
- **Sorties attendues :** Résumé de l'architecture, liste des fichiers suspects.
|
||||||
|
|
||||||
|
### 2. Agent: Développeur de Debugging
|
||||||
|
- **Rôle :** Expert en résolution de problèmes et d'erreurs.
|
||||||
|
- **Description :** Il analyse les logs, les erreurs de compilation et les comportements inattendus pour trouver la cause racine.
|
||||||
|
- **Objectif :** Proposer des corrections de code précises et tester les hypothèses.
|
||||||
|
- **Outils :**
|
||||||
|
- Lecture de logs (`tail -f`, `grep`)
|
||||||
|
- Exécution de commandes de test
|
||||||
|
- Modification de code (avec validation)
|
||||||
|
- **Contraintes :**
|
||||||
|
- Toujours expliquer la cause du bug avant de proposer une solution.
|
||||||
|
- Ne jamais supprimer des lignes de code sans justification.
|
||||||
|
- **Entrées attendues :** Erreurs, logs, messages d'exception.
|
||||||
|
- **Sorties attendues :** Code corrigé, explication du bug, tests de validation.
|
||||||
|
|
||||||
|
### 3. Agent: Backend Python & Traitement
|
||||||
|
- **Rôle :** Développeur API et Pipeline de données.
|
||||||
|
- **Responsabilités :**
|
||||||
|
- Créer les endpoints FastAPI (Upload, Traitement, Récupération).
|
||||||
|
- Intégrer PDAL pour les traitements (filtrage, densification).
|
||||||
|
- Gérer le stockage Entwine.
|
||||||
|
- Gérer les métadonnées dans la DB.
|
||||||
|
- **Contraintes :**
|
||||||
|
- Utiliser des types de données Python stricts (Pydantic).
|
||||||
|
- Gérer les erreurs de traitement PDAL (ex: fichiers corrompus).
|
||||||
|
- Optimiser la mémoire pour les gros fichiers.
|
||||||
|
|
||||||
|
### 4. Agent: Frontend UI/UX
|
||||||
|
- **Rôle :** Développeur Interface et Visualisation.
|
||||||
|
- **Responsabilités :**
|
||||||
|
- Créer les templates Jinja2 avec DaisyUI.
|
||||||
|
- Implémenter l'intégration Potree Viewer.
|
||||||
|
- Gérer les interactions HTMX (formulaires, notifications).
|
||||||
|
- Utiliser AlpineJS pour la gestion des States Management, des menus et modales.
|
||||||
|
- **Contraintes :**
|
||||||
|
- Le code doit être responsive.
|
||||||
|
- Utiliser les composants DaisyUI existants (boutons, cartes, tableaux).
|
||||||
|
- S'assurer que Potree est chargé correctement.
|
||||||
|
|
||||||
|
### 5. Agent: Intégration & Qualité
|
||||||
|
- **Rôle :** Vérificateur de compatibilité.
|
||||||
|
- **Responsabilités :**
|
||||||
|
- Vérifier que les templates Jinja2 sont bien rendus par FastAPI.
|
||||||
|
- S'assurer que les assets (JS/CSS) sont bien servis.
|
||||||
|
- Valider les permissions de fichiers.
|
||||||
|
- **Contraintes :**
|
||||||
|
- Ne jamais modifier le code Potree sans vérifier la compatibilité.
|
||||||
|
- Respecter les conventions de nommage FastAPI.
|
||||||
|
|
||||||
|
### 6. Agent: Vérificateur de Qualité
|
||||||
|
- **Rôle :** Revueur de code et garant de la sécurité.
|
||||||
|
- **Description :** Il vérifie que les modifications respectent les standards de l'équipe et ne créent pas de nouvelles vulnérabilités.
|
||||||
|
- **Objectif :** S'assurer que le code est propre, sécurisé et maintenable.
|
||||||
|
- **Outils :**
|
||||||
|
- Linters (`flake8`, `eslint`, `prettier`)
|
||||||
|
- Analyseurs de sécurité (`bandit`, `snyk`)
|
||||||
|
- Tests unitaires
|
||||||
|
- **Contraintes :**
|
||||||
|
- Signaler toute régression potentielle.
|
||||||
|
- Suggérer des améliorations de performance.
|
||||||
|
- **Entrées attendues :** Code modifié, résultats de tests.
|
||||||
|
- **Sorties attendues :** Rapport de qualité, suggestions d'amélioration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Flux de travail (Workflow)
|
||||||
|
|
||||||
|
1. **Analyse initiale :** L'Agent Architecte scanne le repository et identifie les fichiers clés.
|
||||||
|
2. **Détection du problème :** L'Agent Debugging analyse les erreurs et propose des hypothèses.
|
||||||
|
3. **Isolation :** L'agent détermine si le problème vient du Backend (Python/PDAL) ou du Frontend (HTMX/Potree).
|
||||||
|
4. **Correction :** L'Agent Debugging modifie le code en proposant une solution de code respectant la stack.
|
||||||
|
5. **Validation :** L'Agent vérifie la syntaxe et les imports.
|
||||||
|
6. **Rapport :** Un résumé des actions est généré pour l'utilisateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 Règles de Sécurité & Bonnes Pratiques
|
||||||
|
|
||||||
|
1. **Uploads :**
|
||||||
|
- Ne jamais exécuter le code uploadé directement.
|
||||||
|
- Limiter la taille des fichiers uploadés (ex: max 10GB par défaut).
|
||||||
|
- Vérifier l'extension des fichiers (`.las`, `.laz`, `.ply`, `.pcd`).
|
||||||
|
|
||||||
|
2. **Potree :**
|
||||||
|
- Utiliser la version stable de Potree.
|
||||||
|
- Ne pas exposer les clés API de visualisation.
|
||||||
|
|
||||||
|
3. **Code :**
|
||||||
|
- Utiliser `async/await` pour FastAPI.
|
||||||
|
- Utiliser `@app.get`, `@app.post` pour les routes.
|
||||||
|
- Utiliser `x-data` pour AlpineJS.
|
||||||
|
- Utiliser `hx-get`, `hx-post` pour HTMX.
|
||||||
|
|
||||||
|
4. **Fichiers :**
|
||||||
|
- Ne pas modifier les fichiers système (`/etc`, `/usr`).
|
||||||
|
- Ne pas exécuter de commandes shell dangereuses sans confirmation.
|
||||||
|
|
||||||
321
architecture.md
Normal file
321
architecture.md
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
# 🏗️ Architecture PointCloud Viewer - Diagramme Mermaid
|
||||||
|
|
||||||
|
## Vue d'ensemble de l'architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph "Infrastructure Docker"
|
||||||
|
subgraph "Frontend Service:8091"
|
||||||
|
FE[FastAPI Frontend]
|
||||||
|
FE -- HTMX --> UI[Interface UI]
|
||||||
|
UI -- AlpineJS --> FE
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend Service:8000"
|
||||||
|
BE[FastAPI Backend]
|
||||||
|
BE -- API --> FE
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "External Services"
|
||||||
|
POT[Potree Viewer:8090]
|
||||||
|
PDAL[PDAL Tool]
|
||||||
|
ENT[Entwine Tool]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Data Storage"
|
||||||
|
UPLOADS[./backend/data/uploads/]
|
||||||
|
EPT_DIR[./backend/data/ept/]
|
||||||
|
CONFIG[./frontend/config/]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Templates & Static"
|
||||||
|
TEMPLATES[./frontend/templates/]
|
||||||
|
COMPONENTS[./frontend/components/]
|
||||||
|
STATIC[./backend/static/potree/]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flux principal d'upload et conversion
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as Utilisateur
|
||||||
|
participant FE as Frontend (8091)
|
||||||
|
participant BE as Backend (8000)
|
||||||
|
participant ENT as Entwine
|
||||||
|
participant FS as Système de Fichiers
|
||||||
|
|
||||||
|
User->>FE: 1. Télécharge fichier LAS/LAZ/PLY
|
||||||
|
FE->>FE: 2. Vérifie format supporté
|
||||||
|
FE->>BE: 3. POST /upload avec fichier
|
||||||
|
BE->>BE: 4. Génère UUID (pc_id)
|
||||||
|
BE->>FS: 5. Écrit fichier dans uploads/
|
||||||
|
BE->>ENT: 6. LANCE entwine build
|
||||||
|
ENT->>FS: 7. Convertit vers format EPT
|
||||||
|
ENT->>FS: 8. Crée dossier ept/{pc_id}/
|
||||||
|
ENT-->>BE: 9. Retourne résultat
|
||||||
|
BE->>BE: 10. Sauvegarde manifest.json
|
||||||
|
BE-->>FE: 11. Retourne JSON avec pc_id
|
||||||
|
FE->>FE: 12. Affiche résultat HTMX
|
||||||
|
FE->>POT: 13. Génère page viewer
|
||||||
|
FE-->>User: 14. Affiche visualisation Potree
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flux de visualisation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Frontend
|
||||||
|
A[Page /viewer/list] --> B[HTMX Fetch /viewer/list]
|
||||||
|
B --> C[cloud_list.html]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend
|
||||||
|
D[API /viewer/list] --> E[Lecture EPT_DIR]
|
||||||
|
E --> F[manifest.json]
|
||||||
|
F --> G[Statistiques]
|
||||||
|
end
|
||||||
|
|
||||||
|
C -->|Affichage| H[Tableau Nuages]
|
||||||
|
|
||||||
|
H -->|Click| I[Page /viewer/{pc_id}]
|
||||||
|
I -->|HTMX| J[viewer.html partial]
|
||||||
|
J -->|Embed| K[Potree Viewer]
|
||||||
|
|
||||||
|
K -->|Chargement| L[/ept_data/{pc_id}/ept.json]
|
||||||
|
L --> M[Visualisation 3D]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flux de crop (réduction du nuage)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User as Utilisateur
|
||||||
|
participant FE as Frontend
|
||||||
|
participant BE as Backend
|
||||||
|
participant PDAL as PDAL
|
||||||
|
participant FS as Système Fichiers
|
||||||
|
|
||||||
|
User->>FE: 1. Sélectionne nuage + box 3D
|
||||||
|
FE->>BE: 2. POST /admin/crop/{pc_id} avec box
|
||||||
|
BE->>PDAL: 3. LANCE pdal filter
|
||||||
|
PDAL->>FS: 4. Lit fichier source LAS
|
||||||
|
PDAL->>FS: 5. Applique box de filtrage
|
||||||
|
PDAL->>FS: 6. Écrit fichier LAS cropped
|
||||||
|
PDAL-->>BE: 7. Retourne fichier cropped
|
||||||
|
BE->>FS: 8. Lance entwine sur fichier cropped
|
||||||
|
ENT->>FS: 9. Convertit vers EPT
|
||||||
|
BE->>FS: 10. Sauvegarde nouveau nuage
|
||||||
|
BE-->>FE: 11. Retourne nouveau pc_id
|
||||||
|
FE->>FE: 12. Affiche nouveau nuage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture des routes
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
mindmap
|
||||||
|
root((Architecture))
|
||||||
|
Frontend
|
||||||
|
/upload
|
||||||
|
GET / - Redirection
|
||||||
|
GET /upload - Page upload
|
||||||
|
POST /upload - Upload fichier
|
||||||
|
GET /health-check - Vérification
|
||||||
|
/viewer
|
||||||
|
GET /viewer/list - Liste nuages
|
||||||
|
GET /viewer/{pc_id} - Visualisation
|
||||||
|
/admin
|
||||||
|
GET /admin/backend-config
|
||||||
|
POST /admin/backend-config
|
||||||
|
GET /admin/list
|
||||||
|
GET /admin/debug/{pc_id}
|
||||||
|
DELETE /admin/delete/{pc_id}
|
||||||
|
POST /admin/crop/{pc_id}
|
||||||
|
Backend
|
||||||
|
/upload
|
||||||
|
POST /upload - Conversion entwine
|
||||||
|
/viewer
|
||||||
|
GET /viewer/list - Liste EPT
|
||||||
|
GET /viewer/{pc_id} - Page viewer
|
||||||
|
GET /viewer-embed/{pc_id} - Embed
|
||||||
|
/admin
|
||||||
|
GET /debug/{pc_id} - Debug info
|
||||||
|
DELETE /delete/{pc_id} - Suppression
|
||||||
|
POST /admin/crop/{pc_id} - Crop PDAL
|
||||||
|
/health - Health check
|
||||||
|
/ - Page HTML
|
||||||
|
/ept_data - Serveur statique
|
||||||
|
/static - Assets
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure des dossiers EPT
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
EPT[EPT Directory]
|
||||||
|
EPT --> MANIFEST[manifest.json]
|
||||||
|
EPT --> EPT_JSON[ept.json]
|
||||||
|
EPT --> EPT_BUILD[ept-build.json]
|
||||||
|
EPT --> EPT_DATA[ept-data/]
|
||||||
|
EPT_DATA --> TUILES[.las.tileset]
|
||||||
|
EPT_DATA --> META[meta.json]
|
||||||
|
EPT --> EPT_HIERARCHY[ept-hierarchy/]
|
||||||
|
EPT_HIERARCHY --> NOEUDS[.node]
|
||||||
|
EPT --> EPT_SOURCES[ept-sources/]
|
||||||
|
EPT_SOURCES --> SOURCES[.las]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagramme complet des interactions
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph "Utilisateur"
|
||||||
|
U[Utilisateur]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Frontend Service [8091]"
|
||||||
|
direction TB
|
||||||
|
FE[FastAPI<br/>Jinja2 + HTMX + AlpineJS]
|
||||||
|
UI[Interface<br/>DaisyUI + Tailwind]
|
||||||
|
API_CLIENT[api_client.py<br/>httpx Async]
|
||||||
|
CONFIG[config.py<br/>BACKEND_URL + POTREE_URL]
|
||||||
|
|
||||||
|
U -->|Navigate| UI
|
||||||
|
UI -->|HTMX| FE
|
||||||
|
FE -->|Templates| UI
|
||||||
|
FE -->|Routes| API_CLIENT
|
||||||
|
API_CLIENT -->|HTTP| BE
|
||||||
|
FE -->|Config| CONFIG
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Backend Service [8000]"
|
||||||
|
direction TB
|
||||||
|
BE[FastAPI<br/>PDAL + Entwine]
|
||||||
|
ROUTES[Routes<br/>upload.py<br/>viewer.py<br/>admin.py]
|
||||||
|
SERVICES[Services<br/>converter.py<br/>manifest.py<br/>html_generator.py]
|
||||||
|
UTILS[Utils<br/>disk.py]
|
||||||
|
STATIC[Static Files<br/>Potree Viewer]
|
||||||
|
|
||||||
|
BE -->|Include| ROUTES
|
||||||
|
ROUTES -->|Call| SERVICES
|
||||||
|
SERVICES -->|Read| UTILS
|
||||||
|
BE -->|Serve| STATIC
|
||||||
|
BE -->|Mount| EPT_DIR[/ept_data/]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Outils Externes"
|
||||||
|
ENT[Entwine<br/>build EPT]
|
||||||
|
PDAL[PDAL<br/>filter/crop LAS]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Stockage"
|
||||||
|
UPLOADS[uploads/<br/>fichiers LAS/LAZ]
|
||||||
|
EPT[ept/<br/>nuages convertis]
|
||||||
|
CONFIG_FILE[config/<br/>backend.json]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "External"
|
||||||
|
POT[Potree Viewer<br/>WebGL 3D]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Flux Upload
|
||||||
|
UI -->|1. Choix fichier| U
|
||||||
|
U -->|2. POST /upload| API_CLIENT
|
||||||
|
API_CLIENT -->|3. POST /upload| BE
|
||||||
|
BE -->|4. Enregistrer| UPLOADS
|
||||||
|
BE -->|5. Lancer| ENT
|
||||||
|
ENT -->|6. Convertir| EPT
|
||||||
|
BE -->|7. Sauvegarder| SERVICES
|
||||||
|
SERVICES -->|8. manifest.json| EPT
|
||||||
|
|
||||||
|
%% Flux Liste
|
||||||
|
UI -->|9. /viewer/list| API_CLIENT
|
||||||
|
API_CLIENT -->|10. GET /viewer/list| BE
|
||||||
|
BE -->|11. Lire| EPT
|
||||||
|
BE -->|12. Retourne| API_CLIENT
|
||||||
|
API_CLIENT -->|13. Affiche| UI
|
||||||
|
|
||||||
|
%% Flux Visualisation
|
||||||
|
UI -->|14. /viewer/{pc_id}| FE
|
||||||
|
FE -->|15. Embed| POT
|
||||||
|
POT -->|16. Charger| EPT_DIR
|
||||||
|
EPT_DIR -->|17. ept.json| POT
|
||||||
|
POT -->|18. Visualiser| U
|
||||||
|
|
||||||
|
%% Flux Crop
|
||||||
|
UI -->|19. /admin/crop| API_CLIENT
|
||||||
|
API_CLIENT -->|20. POST /admin/crop| BE
|
||||||
|
BE -->|21. Lancer| PDAL
|
||||||
|
PDAL -->|22. Filtre| UPLOADS
|
||||||
|
BE -->|23. Convertir| ENT
|
||||||
|
ENT -->|24. Nouveau EPT| EPT
|
||||||
|
BE -->|25. Retourne| API_CLIENT
|
||||||
|
API_CLIENT -->|26. Affiche| UI
|
||||||
|
|
||||||
|
%% Styles
|
||||||
|
classDef user fill:#e1f5ff,stroke:#1890ff,stroke-width:2px
|
||||||
|
classDef frontend fill:#fff7e6,stroke:#fa8c16,stroke-width:2px
|
||||||
|
classDef backend fill:#f6ffed,stroke:#52c41a,stroke-width:2px
|
||||||
|
classDef tools fill:#f0f5ff,stroke:#2f54eb,stroke-width:2px
|
||||||
|
classDef storage fill:#fff0f6,stroke:#eb2f96,stroke-width:2px
|
||||||
|
classDef external fill:#f9f0ff,stroke:#722ed1,stroke-width:2px
|
||||||
|
|
||||||
|
class U user
|
||||||
|
class FE,UI,API_CLIENT,CONFIG frontend
|
||||||
|
class BE,ROUTES,SERVICES,UTILS,STATIC backend
|
||||||
|
class ENT,PDAL tools
|
||||||
|
class UPLOADS,EPT,CONFIG_FILE storage
|
||||||
|
class POT external
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fonctionnalités de l'application
|
||||||
|
|
||||||
|
### 1. Upload et Conversion
|
||||||
|
- Téléchargement de fichiers LAS, LAZ, PLY, XYZ, PTS
|
||||||
|
- Conversion automatique vers format EPT (Entwine Point Tile)
|
||||||
|
- Génération de manifeste pour chaque nuage
|
||||||
|
- Suivi du temps de conversion
|
||||||
|
|
||||||
|
### 2. Visualisation 3D
|
||||||
|
- Intégration Potree Viewer (WebGL)
|
||||||
|
- Chargement direct des tuiles EPT
|
||||||
|
- Configuration de la taille de point et forme
|
||||||
|
- Support des modes embed et standalone
|
||||||
|
|
||||||
|
### 3. Administration
|
||||||
|
- Liste de tous les nuages de points
|
||||||
|
- Informations détaillées (taille, nombre de fichiers, date)
|
||||||
|
- Debug panel pour inspection
|
||||||
|
- Suppression de nuages
|
||||||
|
|
||||||
|
### 4. Traitement PDAL
|
||||||
|
- Crop 3D des nuages de points
|
||||||
|
- Définition de box de sélection
|
||||||
|
- Conversion du résultat en EPT
|
||||||
|
|
||||||
|
### 5. Configuration Dynamique
|
||||||
|
- URL du backend configurable
|
||||||
|
- URL de Potree configurable
|
||||||
|
- Sauvegarde dans fichier JSON
|
||||||
|
- Variables d'environnement
|
||||||
|
|
||||||
|
### 6. Monitoring
|
||||||
|
- Endpoint /health pour vérification
|
||||||
|
- Indicateur de disponibilité Entwine
|
||||||
|
- Affichage de l'espace disque libre
|
||||||
|
- Version de PDAL
|
||||||
|
|
||||||
|
### 7. Relevant Files and Code:
|
||||||
|
|
||||||
|
- __backend/main.py__: Point d'entrée backend, mounting de /ept_data, /static, /potree
|
||||||
|
- __backend/routes/upload.py__: POST /upload - conversion entwine avec UUID
|
||||||
|
- __backend/routes/viewer.py__: GET /viewer/list, /viewer/{pc_id}, /viewer-embed/{pc_id}
|
||||||
|
- __backend/services/converter.py__: run_entwine() - commande entwine build
|
||||||
|
- __backend/services/manifest.py__: save_manifest(), read_manifest()
|
||||||
|
- __backend/services/html_generator.py__: generate_viewer_html() - template Potree
|
||||||
|
- __frontend/api_client.py__: check_health(), upload_file(), get_debug(), delete_pointcloud(), crop_pointcloud()
|
||||||
|
- __frontend/routes/upload.py__: POST /upload → api_client.upload_file()
|
||||||
|
- __frontend/routes/crop.py__: POST /admin/crop/{pc_id} → api_client.crop_pointcloud()
|
||||||
|
- __frontend/templates/index.html__: Interface principale avec HTMX tabs (Upload, Admin)
|
||||||
|
- __docker-compose.yml__: 2 services, volumes mount, environment variables
|
||||||
91
architecture_readme.md
Normal file
91
architecture_readme.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# 📄 Structure du Projet et Diagrammes Mermaid
|
||||||
|
|
||||||
|
Fichier `architecture.md` contenant une description complète de l'application avec **6 diagrammes Mermaid** :
|
||||||
|
|
||||||
|
## 📁 Structure du Dossier
|
||||||
|
|
||||||
|
```
|
||||||
|
Point-Cloud-Classifier-HTMX/
|
||||||
|
├── agents.md
|
||||||
|
├── docker-compose.yml # Configuration Docker (2 services)
|
||||||
|
├── Dockerfile.builder
|
||||||
|
├── Dockerfile.entwine # Backend avec Entwine
|
||||||
|
├── Dockerfile.frontend # Frontend avec HTMX
|
||||||
|
├── pyproject.toml
|
||||||
|
├── requirements.txt
|
||||||
|
├── Roo.md # Contexte du projet
|
||||||
|
├── architecture.md # 📄 NOUVEAU - Documentation complète
|
||||||
|
├── backend/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config.py # Configuration backend
|
||||||
|
│ ├── main.py # FastAPI backend
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── uploads/ # Fichiers LAS bruts
|
||||||
|
│ │ └── ept/ # Nuages convertis
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── upload.py # Routes upload
|
||||||
|
│ │ ├── viewer.py # Routes visualisation
|
||||||
|
│ │ └── admin.py # Routes admin
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── converter.py # Conversion Entwine
|
||||||
|
│ │ ├── manifest.py # Gestion manifest.json
|
||||||
|
│ │ └── html_generator.py # Génération HTML Potree
|
||||||
|
│ └── static/
|
||||||
|
│ └── potree/ # Assets Potree
|
||||||
|
└── frontend/
|
||||||
|
├── api_client.py # Client HTTP async
|
||||||
|
├── config.py # Configuration frontend
|
||||||
|
├── main.py # FastAPI frontend
|
||||||
|
├── components/ # Composants HTMX
|
||||||
|
├── routes/
|
||||||
|
│ ├── upload.py
|
||||||
|
│ ├── viewer.py
|
||||||
|
│ ├── admin.py
|
||||||
|
│ └── crop.py
|
||||||
|
├── static/
|
||||||
|
│ └── potree/
|
||||||
|
└── templates/
|
||||||
|
├── index.html
|
||||||
|
└── partials/
|
||||||
|
├── cloud_list.html
|
||||||
|
├── viewer.html
|
||||||
|
├── crop.html
|
||||||
|
├── backend_config.html
|
||||||
|
├── debug_panel.html
|
||||||
|
├── health_status.html
|
||||||
|
└── upload_result.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Diagrammes Mermaid
|
||||||
|
|
||||||
|
### 1. Vue d'ensemble de l'architecture
|
||||||
|
Architecture globale avec Frontend (8091), Backend (8000), Outils externes (Potree, PDAL, Entwine) et Stockage.
|
||||||
|
|
||||||
|
### 2. Flux principal d'upload et conversion
|
||||||
|
SequenceDiagram montrant les 14 étapes : Upload → Vérification → Conversion Entwine → Manifeste → Visualisation.
|
||||||
|
|
||||||
|
### 3. Flux de visualisation
|
||||||
|
Flowchart LR de la liste des nuages → sélection → embed Potree → chargement EPT → visualisation 3D.
|
||||||
|
|
||||||
|
### 4. Flux de crop (réduction du nuage)
|
||||||
|
SequenceDiagram du traitement PDAL : Sélection box 3D → Filtre LAS → Conversion EPT → Nouveau nuage.
|
||||||
|
|
||||||
|
### 5. Architecture des routes
|
||||||
|
Mindmap détaillant toutes les routes Frontend et Backend avec leurs endpoints.
|
||||||
|
|
||||||
|
### 6. Diagramme complet des interactions
|
||||||
|
Flowchart TB complet avec 26 étapes numérotées, classes colorées par type (utilisateur, frontend, backend, outils, stockage, externe).
|
||||||
|
|
||||||
|
### 7. Structure des dossiers EPT
|
||||||
|
Graph TD montrant la structure interne d'un dossier EPT (manifest, ept.json, tuiles, hiérarchie, sources).
|
||||||
|
|
||||||
|
## 🎯 Fonctionnalités Documentées
|
||||||
|
|
||||||
|
1. **Upload et Conversion** - LAS/LAZ/PLY → EPT
|
||||||
|
2. **Visualisation 3D** - Potree Viewer WebGL
|
||||||
|
3. **Administration** - Liste, Debug, Suppression
|
||||||
|
4. **Traitement PDAL** - Crop 3D
|
||||||
|
5. **Configuration Dynamique** - URLs configurables
|
||||||
|
6. **Monitoring** - Health check, espace disque
|
||||||
|
|
||||||
|
Le fichier `architecture.md` est prêt à être utilisé comme contexte pour un agent AI et pour la revue des fonctionnalités.
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
32
backend/config.py
Normal file
32
backend/config.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# URL Potree - configurée via variable d'environnement POTREE_URL
|
||||||
|
# Pour le développement local : POTREE_URL=http://localhost:8090
|
||||||
|
# Pour la production : POTREE_URL=http://potree_server:8090
|
||||||
|
|
||||||
|
def load_potree_config():
|
||||||
|
"""Charge la configuration Potree depuis les variables d'environnement."""
|
||||||
|
return os.getenv(
|
||||||
|
"POTREE_URL", "http://localhost:8090"
|
||||||
|
).strip().rstrip("/")
|
||||||
|
|
||||||
|
def get_entwine_path():
|
||||||
|
"""Retourne le chemin de entwine ou None si non trouvé"""
|
||||||
|
path = os.getenv("ENTWINE_PATH")
|
||||||
|
if path:
|
||||||
|
return path.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
UPLOADS_DIR = DATA_DIR / "uploads"
|
||||||
|
EPT_DIR = DATA_DIR / "ept" # était POTREE_DIR
|
||||||
|
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
EPT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
SUPPORTED_FORMATS = [".las", ".laz", ".ply", ".xyz", ".pts"]
|
||||||
|
|
||||||
|
POTREE_URL = load_potree_config()
|
||||||
|
ENTWINE_PATH = get_entwine_path()
|
||||||
94
backend/main.py
Normal file
94
backend/main.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from config import EPT_DIR
|
||||||
|
from routes import upload, viewer, admin
|
||||||
|
from utils.disk import get_disk_usage, get_entwine_path
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
ENTWINE_PATH = get_entwine_path()
|
||||||
|
|
||||||
|
app = FastAPI(title="PointCloud Backend")
|
||||||
|
app.mount("/ept_data", StaticFiles(directory=str(EPT_DIR)), name="ept_data")
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
app.mount("/potree", StaticFiles(directory="static/potree"), name="potree")
|
||||||
|
|
||||||
|
# ── Fichiers Potree dans /static ─────────────────────────────────────────────
|
||||||
|
app.mount("/static/potree", StaticFiles(directory="static/potree"), name="static_potree")
|
||||||
|
|
||||||
|
app.include_router(upload.router)
|
||||||
|
app.include_router(viewer.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
@app.get("/api")
|
||||||
|
def home():
|
||||||
|
return RedirectResponse(url="/docs")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"entwine_available": ENTWINE_PATH is not None,
|
||||||
|
"entwine_path": ENTWINE_PATH,
|
||||||
|
"disk_free_gb": get_disk_usage(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_pdal_info() -> dict:
|
||||||
|
"""Retourne version et path de pdal via subprocess."""
|
||||||
|
info = {"path": None, "version": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["which", "pdal"],
|
||||||
|
capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
info["path"] = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pdal", "--version"],
|
||||||
|
capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Sortie : "---\npdal 2.10.0 (git-version: 22e6b2)\n---"
|
||||||
|
lines = [l.strip() for l in result.stdout.strip().splitlines()
|
||||||
|
if l.strip() and not l.startswith("-")]
|
||||||
|
if lines:
|
||||||
|
info["version"] = lines[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def home():
|
||||||
|
entwine_path = get_entwine_path()
|
||||||
|
pdal = get_pdal_info()
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Backend - PointCloud</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-base-100 p-4">
|
||||||
|
<h2>Backend PointCloud OK ✅</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/docs">/docs</a> — Documentation API</li>
|
||||||
|
<li><a href="/health">/health</a> — État du service</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Configuration</h3>
|
||||||
|
<ul>
|
||||||
|
<li>entwine : {"✅ " + entwine_path if entwine_path else "❌ Non trouvé"}</li>
|
||||||
|
<li>pdal path : {"✅ " + pdal["path"] if pdal["path"] else "❌ Non trouvé"}</li>
|
||||||
|
<li>pdal version : {pdal["version"] if pdal["version"] else "❌ Inconnue"}</li>
|
||||||
|
<li>Espace disque : {get_disk_usage()} GB libres</li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
184
backend/routes/admin.py
Normal file
184
backend/routes/admin.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from config import EPT_DIR, UPLOADS_DIR, ENTWINE_PATH, DATA_DIR
|
||||||
|
from services.manifest import read_manifest
|
||||||
|
from services.converter import ENTWINE_AVAILABLE, ENTWINE_PATH, run_entwine
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/backend-config")
|
||||||
|
def backend_config():
|
||||||
|
"""Retourne la configuration du backend"""
|
||||||
|
import os
|
||||||
|
try:
|
||||||
|
stat = shutil.disk_usage(DATA_DIR)
|
||||||
|
disk_free_gb = round(stat.free / (1024**3), 2)
|
||||||
|
except:
|
||||||
|
disk_free_gb = "?"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entwine_available": ENTWINE_AVAILABLE,
|
||||||
|
"entwine_path": ENTWINE_PATH,
|
||||||
|
"pdal_available": shutil.which("pdal") is not None,
|
||||||
|
"disk_free_gb": disk_free_gb,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/{pc_id}")
|
||||||
|
def debug(pc_id: str):
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
if not out_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"ID {pc_id} non trouvé")
|
||||||
|
|
||||||
|
manifest = read_manifest(out_dir)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
total_size = 0
|
||||||
|
for p in out_dir.rglob("*"):
|
||||||
|
if p.is_file():
|
||||||
|
size = p.stat().st_size
|
||||||
|
total_size += size
|
||||||
|
files.append({
|
||||||
|
"path": str(p.relative_to(out_dir)),
|
||||||
|
"size_mb": round(size / (1024 * 1024), 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
entry_file = manifest.get("entry_file")
|
||||||
|
entry_exists = False
|
||||||
|
if entry_file:
|
||||||
|
entry_exists = (EPT_DIR / entry_file).exists()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pc_id": pc_id,
|
||||||
|
"exists": True,
|
||||||
|
"manifest": manifest,
|
||||||
|
"entry_exists": entry_exists,
|
||||||
|
"stats": {
|
||||||
|
"total_files": len(files),
|
||||||
|
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||||
|
},
|
||||||
|
"files": sorted(files, key=lambda x: x["size_mb"], reverse=True)[:20],
|
||||||
|
"entwine_available": ENTWINE_AVAILABLE,
|
||||||
|
"entwine_path": ENTWINE_PATH,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/delete/{pc_id}")
|
||||||
|
def delete_pointcloud(pc_id: str):
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
if not out_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"ID {pc_id} non trouvé")
|
||||||
|
|
||||||
|
try:
|
||||||
|
for ext in [".las", ".laz", ".ply", ".xyz", ".pts"]:
|
||||||
|
original = UPLOADS_DIR / f"{pc_id}{ext}"
|
||||||
|
if original.exists():
|
||||||
|
original.unlink()
|
||||||
|
|
||||||
|
shutil.rmtree(out_dir)
|
||||||
|
return {"ok": True, "message": f"Nuage {pc_id} supprimé"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur suppression : {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crop/{pc_id}")
|
||||||
|
def crop_pointcloud(pc_id: str, box: dict):
|
||||||
|
"""
|
||||||
|
Crop le nuage de points avec PDAL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pc_id: ID du nuage de points à cropper
|
||||||
|
box: dict avec les coordonnées de la box 3D
|
||||||
|
{"minX", "minY", "minZ", "maxX", "maxY", "maxZ"}
|
||||||
|
"""
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
if not out_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"ID {pc_id} non trouvé")
|
||||||
|
|
||||||
|
manifest = read_manifest(out_dir)
|
||||||
|
if not manifest or not manifest.get("ept_dir"):
|
||||||
|
raise HTTPException(status_code=400, detail="Manifeste invalide")
|
||||||
|
|
||||||
|
ept_dir = EPT_DIR / manifest["ept_dir"]
|
||||||
|
|
||||||
|
# Vérifier que PDAL est disponible
|
||||||
|
try:
|
||||||
|
pdal_path = subprocess.run(
|
||||||
|
["which", "pdal"],
|
||||||
|
capture_output=True, text=True, check=False
|
||||||
|
).stdout.strip()
|
||||||
|
if not pdal_path:
|
||||||
|
raise HTTPException(500, "PDAL non disponible")
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(500, "PDAL non disponible")
|
||||||
|
|
||||||
|
# Construire le pipeline PDAL pour le crop
|
||||||
|
pipeline = {
|
||||||
|
"pipeline": [
|
||||||
|
{
|
||||||
|
"type": "readers.las",
|
||||||
|
"filename": str(ept_dir / "ept.json"),
|
||||||
|
"skip_z": False,
|
||||||
|
"force_z": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "filters.crop",
|
||||||
|
"crop_box": [
|
||||||
|
box.get("minX", 0),
|
||||||
|
box.get("minY", 0),
|
||||||
|
box.get("minZ", 0),
|
||||||
|
box.get("maxX", 0),
|
||||||
|
box.get("maxY", 0),
|
||||||
|
box.get("maxZ", 0)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "writers.ept",
|
||||||
|
"filename": str(out_dir / "cropped.las"),
|
||||||
|
"force_z": True
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[pdal_path, "--pipeline=JSON", "--stdin"],
|
||||||
|
input=pipeline,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=7200,
|
||||||
|
env=os.environ.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
500,
|
||||||
|
f"PDAL crop failed (code {result.returncode}):\n{result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convertir le fichier LAS croppé en EPT
|
||||||
|
cropped_las = out_dir / "cropped.las"
|
||||||
|
if not cropped_las.exists():
|
||||||
|
raise HTTPException(500, "Fichier LAS croppé non généré")
|
||||||
|
|
||||||
|
# Supprimer le fichier original
|
||||||
|
for ext in [".las", ".laz", ".ply", ".xyz", ".pts"]:
|
||||||
|
original = UPLOADS_DIR / f"{pc_id}{ext}"
|
||||||
|
if original.exists():
|
||||||
|
original.unlink()
|
||||||
|
|
||||||
|
# Convertir en EPT
|
||||||
|
run_entwine(cropped_las, out_dir)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"id": pc_id, # Même ID, le nuage a été mis à jour
|
||||||
|
"size_mb": round(cropped_las.stat().st_size / (1024 * 1024), 2),
|
||||||
|
"conversion_time_seconds": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Erreur crop : {str(e)}")
|
||||||
47
backend/routes/upload.py
Normal file
47
backend/routes/upload.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import uuid, shutil, time
|
||||||
|
from pathlib import Path
|
||||||
|
from config import UPLOADS_DIR, EPT_DIR, SUPPORTED_FORMATS
|
||||||
|
from services.converter import run_entwine, ENTWINE_AVAILABLE
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload(file: UploadFile = File(...)):
|
||||||
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
if suffix not in SUPPORTED_FORMATS:
|
||||||
|
raise HTTPException(400, f"Format non supporté: {suffix}")
|
||||||
|
if not ENTWINE_AVAILABLE:
|
||||||
|
raise HTTPException(500, "entwine non disponible sur ce serveur")
|
||||||
|
|
||||||
|
pc_id = str(uuid.uuid4())[:8]
|
||||||
|
upload_path = UPLOADS_DIR / f"{pc_id}{suffix}"
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
|
||||||
|
file_size = 0
|
||||||
|
with open(upload_path, "wb") as f:
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
f.write(chunk)
|
||||||
|
file_size += len(chunk)
|
||||||
|
|
||||||
|
if file_size == 0:
|
||||||
|
upload_path.unlink()
|
||||||
|
raise HTTPException(400, "Fichier vide")
|
||||||
|
|
||||||
|
if out_dir.exists():
|
||||||
|
shutil.rmtree(out_dir, ignore_errors=True)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
result = run_entwine(upload_path, out_dir)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"id": pc_id,
|
||||||
|
"filename": file.filename,
|
||||||
|
"size_mb": round(file_size / (1024 * 1024), 2),
|
||||||
|
"viewer_path": f"/viewer/{pc_id}",
|
||||||
|
"embed_path": f"/viewer-embed/{pc_id}",
|
||||||
|
"ept_dir": result.get("ept_dir"),
|
||||||
|
"conversion_time_seconds": round(time.time() - start, 2),
|
||||||
|
})
|
||||||
53
backend/routes/viewer.py
Normal file
53
backend/routes/viewer.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import JSONResponse, HTMLResponse
|
||||||
|
from config import EPT_DIR, POTREE_URL
|
||||||
|
import config
|
||||||
|
from services.manifest import read_manifest
|
||||||
|
from services.html_generator import generate_viewer_html
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/viewer/list", response_class=HTMLResponse)
|
||||||
|
def list_pointclouds(request: Request):
|
||||||
|
"""Liste les nuages de points disponibles"""
|
||||||
|
from fastapi import Request
|
||||||
|
pointclouds = []
|
||||||
|
|
||||||
|
for item in sorted(EPT_DIR.iterdir(), key=lambda x: x.stat().st_ctime, reverse=True):
|
||||||
|
if item.is_dir():
|
||||||
|
total_size = 0
|
||||||
|
file_count = 0
|
||||||
|
for f in item.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
total_size += f.stat().st_size
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
|
if file_count > 0:
|
||||||
|
pointclouds.append({
|
||||||
|
"id": item.name,
|
||||||
|
"size_mb": round(total_size / (1024 * 1024), 2),
|
||||||
|
"file_count": file_count,
|
||||||
|
"created": item.stat().st_ctime,
|
||||||
|
})
|
||||||
|
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/cloud_list.html",
|
||||||
|
{"request": request, "pointclouds": pointclouds},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/viewer/{pc_id}")
|
||||||
|
def viewer(pc_id: str, potree_url: str = Query(default=POTREE_URL, description="URL du serveur Potree")):
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
if not out_dir.exists():
|
||||||
|
raise HTTPException(404, f"ID {pc_id} non trouvé")
|
||||||
|
manifest = read_manifest(out_dir)
|
||||||
|
return HTMLResponse(generate_viewer_html(pc_id, manifest.get("ept_dir"), embed=False, potree_url=potree_url))
|
||||||
|
|
||||||
|
@router.get("/viewer-embed/{pc_id}")
|
||||||
|
def viewer_embed(pc_id: str, potree_url: str = Query(default=POTREE_URL, description="URL du serveur Potree")):
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
if not out_dir.exists():
|
||||||
|
raise HTTPException(404, f"ID {pc_id} non trouvé")
|
||||||
|
manifest = read_manifest(out_dir)
|
||||||
|
return HTMLResponse(generate_viewer_html(pc_id, manifest.get("ept_dir"), embed=True, potree_url=potree_url))
|
||||||
75
backend/services/converter.py
Normal file
75
backend/services/converter.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from config import EPT_DIR
|
||||||
|
from services.manifest import save_manifest
|
||||||
|
from utils.disk import get_entwine_path
|
||||||
|
|
||||||
|
ENTWINE_PATH = get_entwine_path()
|
||||||
|
ENTWINE_AVAILABLE = ENTWINE_PATH is not None
|
||||||
|
|
||||||
|
def run_entwine(input_path: Path, out_dir: Path) -> dict:
|
||||||
|
if not ENTWINE_AVAILABLE:
|
||||||
|
raise HTTPException(status_code=500,
|
||||||
|
detail="entwine n'est pas installé ou introuvable dans le PATH")
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# entwine build -i <input> -o <output_dir>
|
||||||
|
cmd = [
|
||||||
|
ENTWINE_PATH, "build",
|
||||||
|
"-i", str(input_path.absolute()),
|
||||||
|
"-o", str(out_dir.absolute()),
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Exécution: {' '.join(cmd)}")
|
||||||
|
print(f"CMD: {cmd}")
|
||||||
|
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=7200,
|
||||||
|
env=os.environ.copy()
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise HTTPException(status_code=500,
|
||||||
|
detail=f"entwine failed (code {proc.returncode}):\n{proc.stdout}")
|
||||||
|
|
||||||
|
result = _analyze_ept_output(out_dir, proc.stdout)
|
||||||
|
save_manifest(out_dir, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_ept_output(out_dir: Path, entwine_output: str) -> dict:
|
||||||
|
"""
|
||||||
|
Entwine produit un dossier EPT dont la structure est :
|
||||||
|
out_dir/
|
||||||
|
ept.json ← fichier d'entrée principal
|
||||||
|
ept-data/ ← tuiles binaires
|
||||||
|
ept-hierarchy/ ← hiérarchie des noeuds
|
||||||
|
"""
|
||||||
|
ept_json = out_dir / "ept.json"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"format": "ept",
|
||||||
|
"entry_file": None,
|
||||||
|
"entry_type": None,
|
||||||
|
"stdout": entwine_output[-2000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
if ept_json.exists():
|
||||||
|
result["entry_file"] = ept_json.relative_to(EPT_DIR).as_posix()
|
||||||
|
result["entry_type"] = "ept.json"
|
||||||
|
# Le dossier EPT = le dossier contenant ept.json
|
||||||
|
result["ept_dir"] = str(ept_json.parent.relative_to(EPT_DIR))
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500,
|
||||||
|
detail=f"entwine a terminé mais ept.json introuvable dans {out_dir}")
|
||||||
|
|
||||||
|
return result
|
||||||
87
backend/services/html_generator.py
Normal file
87
backend/services/html_generator.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from config import EPT_DIR, POTREE_URL
|
||||||
|
import config
|
||||||
|
|
||||||
|
def generate_viewer_html(pc_id: str, ept_dir: Optional[str],
|
||||||
|
embed: bool = False, potree_url: Optional[str] = None) -> str:
|
||||||
|
# Fallback : cherche ept.json si le manifest est absent
|
||||||
|
if not ept_dir:
|
||||||
|
out_dir = EPT_DIR / pc_id
|
||||||
|
ept_json = out_dir / "ept.json"
|
||||||
|
if ept_json.exists():
|
||||||
|
ept_dir = pc_id
|
||||||
|
else:
|
||||||
|
return "<h3>Erreur : ept.json introuvable pour cet ID</h3>"
|
||||||
|
|
||||||
|
height_style = "100vh" if embed else "800px"
|
||||||
|
base_url = "/static/potree"
|
||||||
|
potree_url = potree_url or config.POTREE_URL
|
||||||
|
|
||||||
|
# L'URL vers ept.json servi via le montage statique /ept_data
|
||||||
|
ept_json_url = f"/ept_data/{ept_dir}/ept.json"
|
||||||
|
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>EPT Viewer - {pc_id}</title>
|
||||||
|
<link rel="stylesheet" href="{base_url}/build/potree/potree.css">
|
||||||
|
<link rel="stylesheet" href="{base_url}/libs/jquery-ui/jquery-ui.min.css">
|
||||||
|
<link rel="stylesheet" href="{base_url}/libs/spectrum/spectrum.css">
|
||||||
|
<link rel="stylesheet" href="{base_url}/libs/jstree/themes/mixed/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="{base_url}/libs/jquery/jquery-3.1.1.min.js"></script>
|
||||||
|
<script src="{base_url}/libs/spectrum/spectrum.js"></script>
|
||||||
|
<script src="{base_url}/libs/jquery-ui/jquery-ui.min.js"></script>
|
||||||
|
<script src="{base_url}/libs/other/BinaryHeap.js"></script>
|
||||||
|
<script src="{base_url}/libs/tween/tween.min.js"></script>
|
||||||
|
<script src="{base_url}/libs/d3/d3.js"></script>
|
||||||
|
<script src="{base_url}/libs/proj4/proj4.js"></script>
|
||||||
|
<script src="{base_url}/libs/openlayers3/ol.js"></script>
|
||||||
|
<script src="{base_url}/libs/i18next/i18next.js"></script>
|
||||||
|
<script src="{base_url}/libs/jstree/jstree.js"></script>
|
||||||
|
<script src="{base_url}/libs/copc/index.js"></script>
|
||||||
|
<script src="{base_url}/build/potree/potree.js"></script>
|
||||||
|
<script src="{base_url}/libs/plasio/js/laslaz.js"></script>
|
||||||
|
|
||||||
|
<div class="potree_container" style="position:absolute;width:100%;height:{height_style};left:0;top:0;">
|
||||||
|
<div id="potree_render_area"></div>
|
||||||
|
<div id="potree_sidebar_container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
window.viewer = new Potree.Viewer(document.getElementById("potree_render_area"));
|
||||||
|
viewer.setEDLEnabled(true);
|
||||||
|
viewer.setFOV(60);
|
||||||
|
viewer.setPointBudget(1_000_000);
|
||||||
|
viewer.loadSettingsFromURL();
|
||||||
|
viewer.setBackground("skybox");
|
||||||
|
viewer.setDescription("EPT - {pc_id}");
|
||||||
|
|
||||||
|
viewer.loadGUI(() => {{
|
||||||
|
viewer.setLanguage('en');
|
||||||
|
$("#menu_tools").next().show();
|
||||||
|
viewer.toggleSidebar();
|
||||||
|
}});
|
||||||
|
|
||||||
|
// Potree 2.x charge EPT directement via l'URL de ept.json
|
||||||
|
const eptUrl = "{ept_json_url}";
|
||||||
|
console.log("Chargement EPT depuis:", eptUrl);
|
||||||
|
|
||||||
|
Potree.loadPointCloud(eptUrl, "{pc_id}", e => {{
|
||||||
|
let pointcloud = e.pointcloud;
|
||||||
|
let material = pointcloud.material;
|
||||||
|
material.size = 1;
|
||||||
|
material.pointSizeType = Potree.PointSizeType.ADAPTIVE;
|
||||||
|
material.shape = Potree.PointShape.SQUARE;
|
||||||
|
|
||||||
|
viewer.scene.addPointCloud(pointcloud);
|
||||||
|
viewer.fitToScreen();
|
||||||
|
console.log("EPT chargé avec succès");
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
24
backend/services/manifest.py
Normal file
24
backend/services/manifest.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def save_manifest(out_dir: Path, data: dict):
|
||||||
|
manifest = {
|
||||||
|
"conversion_time": time.time(),
|
||||||
|
"format": data.get("format", "ept"), # était "version"
|
||||||
|
"entry_file": data.get("entry_file"),
|
||||||
|
"entry_type": data.get("entry_type"),
|
||||||
|
"ept_dir": data.get("ept_dir"),
|
||||||
|
}
|
||||||
|
with open(out_dir / "manifest.json", 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
|
||||||
|
def read_manifest(out_dir: Path) -> dict:
|
||||||
|
manifest_file = out_dir / "manifest.json"
|
||||||
|
if not manifest_file.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(manifest_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except:
|
||||||
|
return {}
|
||||||
34
backend/utils/disk.py
Normal file
34
backend/utils/disk.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from config import DATA_DIR, BASE_DIR
|
||||||
|
|
||||||
|
def get_disk_usage() -> float | str:
|
||||||
|
try:
|
||||||
|
stat = shutil.disk_usage(DATA_DIR)
|
||||||
|
return round(stat.free / (1024**3), 2)
|
||||||
|
except:
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
def get_entwine_path() -> str | None:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["which", "entwine"],
|
||||||
|
capture_output=True, text=True, check=False)
|
||||||
|
if result.returncode == 0:
|
||||||
|
path = result.stdout.strip()
|
||||||
|
if path and Path(path).exists():
|
||||||
|
return path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
common_paths = [
|
||||||
|
"/usr/local/bin/entwine",
|
||||||
|
"/usr/bin/entwine",
|
||||||
|
str(BASE_DIR / "entwine"),
|
||||||
|
str(BASE_DIR / "bin" / "entwine"),
|
||||||
|
]
|
||||||
|
for path in common_paths:
|
||||||
|
if Path(path).exists():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
57
frontend/api_client.py
Normal file
57
frontend/api_client.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# api_client.py - httpx async
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config import BACKEND_URL
|
||||||
|
|
||||||
|
TIMEOUT_HEALTH = 5
|
||||||
|
TIMEOUT_UPLOAD = 3600
|
||||||
|
TIMEOUT_DEFAULT = 15
|
||||||
|
|
||||||
|
|
||||||
|
async def check_health() -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(f"{BACKEND_URL}/health", timeout=TIMEOUT_HEALTH)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_file(filename: str, data: bytes) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{BACKEND_URL}/upload",
|
||||||
|
files={"file": (filename, data)},
|
||||||
|
timeout=TIMEOUT_UPLOAD,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"Erreur backend ({r.status_code}) : {r.text}")
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_debug(pc_id: str) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.get(
|
||||||
|
f"{BACKEND_URL}/debug/{pc_id}", timeout=TIMEOUT_DEFAULT
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_pointcloud(pc_id: str) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.delete(
|
||||||
|
f"{BACKEND_URL}/delete/{pc_id}", timeout=TIMEOUT_DEFAULT
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def crop_pointcloud(pc_id: str, payload: dict) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{BACKEND_URL}/crop/{pc_id}",
|
||||||
|
json=payload,
|
||||||
|
timeout=TIMEOUT_UPLOAD,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(f"Erreur crop ({r.status_code}) : {r.text}")
|
||||||
|
return r.json()
|
||||||
14
frontend/components/Button.html
Normal file
14
frontend/components/Button.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<button
|
||||||
|
type="{{ type or 'button' }}"
|
||||||
|
class="btn {{ class_extra or '' }}"
|
||||||
|
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||||
|
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||||
|
{% if hx_post %}hx-post="{{ hx_post }}"{% endif %}
|
||||||
|
{% if hx_put %}hx-put="{{ hx_put }}"{% endif %}
|
||||||
|
{% if hx_delete %}hx-delete="{{ hx_delete }}"{% endif %}
|
||||||
|
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||||
|
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||||
|
{% if disabled %}disabled{% endif %}
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
8
frontend/components/Card.html
Normal file
8
frontend/components/Card.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<div class="card bg-base-100 shadow {{ class_extra }}">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if title %}
|
||||||
|
<h2 class="card-title text-base mb-2">{{ title }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
25
frontend/components/CloudRow.html
Normal file
25
frontend/components/CloudRow.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-10 rounded-full">
|
||||||
|
<img src="{{ cloud_url }}" alt="{{ cloud_name }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">{{ cloud_name }}</h3>
|
||||||
|
<p class="text-sm text-base-content/60">{{ cloud_path }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{{ cloud_url }}" class="btn btn-sm btn-ghost" target="_blank">
|
||||||
|
📂 Ouvrir
|
||||||
|
</a>
|
||||||
|
<a href="{{ cloud_url }}/viewer" class="btn btn-sm">
|
||||||
|
👁️ Visualiser
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
17
frontend/components/Layout.html
Normal file
17
frontend/components/Layout.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 min-h-screen">
|
||||||
|
<Navbar active_tab="{{ active_tab }}" />
|
||||||
|
<div class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
35
frontend/components/Navbar.html
Normal file
35
frontend/components/Navbar.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none gap-4 items-center">
|
||||||
|
<div
|
||||||
|
id="health-indicator"
|
||||||
|
hx-get="/health-check"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="text-sm text-base-content/50"
|
||||||
|
>vérification…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||||
|
hx-get="/"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/"
|
||||||
|
>📤 Upload</a>
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/viewer"
|
||||||
|
>🗂️ Admin</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-content" class="px-4">
|
||||||
|
{{ content }}
|
||||||
|
</div>
|
||||||
1
frontend/components/Spinner.html
Normal file
1
frontend/components/Spinner.html
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<div id="{{ id }}" class="loading loading-spinner loading-sm"></div>
|
||||||
31
frontend/config.py
Normal file
31
frontend/config.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# URL du backend - configurée uniquement via variable d'environnement BACKEND_URL
|
||||||
|
# Pour le développement local : BACKEND_URL=http://localhost:8091
|
||||||
|
# Pour la production : BACKEND_URL=http://backend_entwine:8000
|
||||||
|
|
||||||
|
# URL Potree - configurée via variable d'environnement POTREE_URL
|
||||||
|
# Pour le développement local : POTREE_URL=http://localhost:8090
|
||||||
|
# Pour la production : POTREE_URL=http://potree_server:8090
|
||||||
|
|
||||||
|
def load_backend_config():
|
||||||
|
"""Charge la configuration du backend depuis les variables d'environnement."""
|
||||||
|
return os.getenv(
|
||||||
|
"BACKEND_URL", "http://localhost:8091"
|
||||||
|
).strip().rstrip("/")
|
||||||
|
|
||||||
|
def load_potree_config():
|
||||||
|
"""Charge la configuration Potree depuis les variables d'environnement."""
|
||||||
|
return os.getenv(
|
||||||
|
"POTREE_URL", "http://localhost:8090"
|
||||||
|
).strip().rstrip("/")
|
||||||
|
|
||||||
|
BACKEND_URL = load_backend_config()
|
||||||
|
POTREE_URL = load_potree_config()
|
||||||
|
|
||||||
|
SUPPORTED_FORMATS = [".las", ".laz", ".ply", ".xyz", ".pts"]
|
||||||
|
SUPPORTED_EXTENSIONS = SUPPORTED_FORMATS # Alias pour compatibilité
|
||||||
|
|
||||||
|
# Chemin du dossier EPT (nuages de points convertis)
|
||||||
|
EPT_DIR = Path("/app/backend/data/ept")
|
||||||
54
frontend/main.py
Normal file
54
frontend/main.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# main.py - FastAPI + Jinja2
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from routes import upload, viewer, admin, crop
|
||||||
|
|
||||||
|
# -- Middleware ------------------------------------------------
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="PointCloud Frontend")
|
||||||
|
|
||||||
|
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 * 1024 # 10 GB à ajuster
|
||||||
|
|
||||||
|
class LimitUploadSize(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if request.method == "POST":
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length and int(content_length) > MAX_UPLOAD_BYTES:
|
||||||
|
return Response(
|
||||||
|
content=f"Fichier trop volumineux. Maximum : {MAX_UPLOAD_BYTES // (1024**3)} GB",
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
app.add_middleware(LimitUploadSize)
|
||||||
|
|
||||||
|
# ── Fichiers statiques ────────────────────────────────────────────────────────
|
||||||
|
app.mount(
|
||||||
|
"/static",
|
||||||
|
StaticFiles(directory=Path(__file__).parent / "static"),
|
||||||
|
name="static",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Jinja2 pour les pages et partials ────────────────────────────────────────
|
||||||
|
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
||||||
|
templates.env.filters["datetimeformat"] = lambda ts: (
|
||||||
|
datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d %H:%M") if ts else "—"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rend les templates accessibles aux routes via app.state
|
||||||
|
app.state.templates = templates
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
app.include_router(upload.router)
|
||||||
|
app.include_router(viewer.router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
app.include_router(crop.router)
|
||||||
4
frontend/routes/__init__.py
Normal file
4
frontend/routes/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from . import upload
|
||||||
|
from . import viewer
|
||||||
|
from . import admin
|
||||||
|
from . import crop
|
||||||
131
frontend/routes/admin.py
Normal file
131
frontend/routes/admin.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import api_client
|
||||||
|
import config
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backend-config", response_class=HTMLResponse)
|
||||||
|
async def backend_config(request: Request):
|
||||||
|
"""Affiche le formulaire de configuration du backend"""
|
||||||
|
current_backend_url = config.BACKEND_URL
|
||||||
|
current_potree_url = config.POTREE_URL
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/backend_config.html",
|
||||||
|
{"request": request, "current_backend_url": current_backend_url, "current_potree_url": current_potree_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backend-config", response_class=HTMLResponse)
|
||||||
|
async def save_backend_config(request: Request):
|
||||||
|
"""Sauvegarde les nouvelles URLs du backend et Potree"""
|
||||||
|
form_data = await request.form()
|
||||||
|
new_backend_url = form_data.get("backend_url", "").strip().rstrip("/")
|
||||||
|
new_potree_url = form_data.get("potree_url", "").strip().rstrip("/")
|
||||||
|
|
||||||
|
if not new_backend_url:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/backend_config.html",
|
||||||
|
{"request": request, "current_backend_url": config.BACKEND_URL, "current_potree_url": config.POTREE_URL, "error": "URL du backend vide"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sauvegarder dans le fichier JSON
|
||||||
|
config_file = Path(__file__).parent.parent / "config" / "backend.json"
|
||||||
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
config_data = {}
|
||||||
|
if new_backend_url:
|
||||||
|
config_data["backend_url"] = new_backend_url
|
||||||
|
if new_potree_url:
|
||||||
|
config_data["potree_url"] = new_potree_url
|
||||||
|
|
||||||
|
with open(config_file, "w") as f:
|
||||||
|
json.dump(config_data, f, indent=2)
|
||||||
|
|
||||||
|
# Mettre à jour les variables module
|
||||||
|
config.BACKEND_URL = new_backend_url
|
||||||
|
config.POTREE_URL = new_potree_url
|
||||||
|
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/backend_config.html",
|
||||||
|
{"request": request, "current_backend_url": new_backend_url, "current_potree_url": new_potree_url, "success": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_class=HTMLResponse)
|
||||||
|
async def admin_list(request: Request):
|
||||||
|
"""Affiche la liste de tous les nuages de points"""
|
||||||
|
pointclouds = await api_client.list_pointclouds()
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/cloud_list.html",
|
||||||
|
{"request": request, "pointclouds": pointclouds},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/debug/{pc_id}", response_class=HTMLResponse)
|
||||||
|
async def admin_debug(request: Request, pc_id: str):
|
||||||
|
"""Affiche les informations de debug pour un nuage"""
|
||||||
|
debug_info = await api_client.get_debug(pc_id)
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/debug_panel.html",
|
||||||
|
{"request": request, "pc_id": pc_id, "data": debug_info},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/delete/{pc_id}", response_class=HTMLResponse)
|
||||||
|
async def admin_delete(request: Request, pc_id: str):
|
||||||
|
"""Supprime un nuage de points"""
|
||||||
|
try:
|
||||||
|
result = await api_client.delete_pointcloud(pc_id)
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "result": {
|
||||||
|
"id": pc_id,
|
||||||
|
"filename": f"{pc_id}.las",
|
||||||
|
"size_mb": 0,
|
||||||
|
"conversion_time_seconds": 0,
|
||||||
|
}, "error": None},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": str(e), "result": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crop/{pc_id}", response_class=HTMLResponse)
|
||||||
|
async def admin_crop(request: Request, pc_id: str, box: dict):
|
||||||
|
"""
|
||||||
|
Crop le nuage de points avec PDAL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pc_id: ID du nuage de points à cropper
|
||||||
|
box: dict avec les coordonnées de la box 3D
|
||||||
|
{"minX", "minY", "minZ", "maxX", "maxY", "maxZ"}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await api_client.crop_pointcloud(pc_id, box)
|
||||||
|
|
||||||
|
if result.get("ok"):
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "result": {
|
||||||
|
"id": result.get("id"),
|
||||||
|
"filename": f"{pc_id}_cropped.las",
|
||||||
|
"size_mb": result.get("size_mb", 0),
|
||||||
|
"conversion_time_seconds": result.get("conversion_time_seconds", 0),
|
||||||
|
}, "error": None},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": result.get("detail", "Erreur inconnue"), "result": None},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": str(e), "result": None},
|
||||||
|
)
|
||||||
51
frontend/routes/crop.py
Normal file
51
frontend/routes/crop.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import api_client
|
||||||
|
import config
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/crop", response_class=HTMLResponse)
|
||||||
|
async def crop_ui(request: Request, pc_id: str):
|
||||||
|
"""Interface utilisateur pour le crop du nuage de points"""
|
||||||
|
embed_url = f"{config.BACKEND_URL}/viewer-embed/{pc_id}"
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/crop.html",
|
||||||
|
{"request": request, "pc_id": pc_id, "embed_url": embed_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/crop", response_class=HTMLResponse)
|
||||||
|
async def crop(request: Request, pc_id: str, box: dict):
|
||||||
|
"""
|
||||||
|
Traite la requête de crop :
|
||||||
|
1. Envoie la box 3D au backend
|
||||||
|
2. Le backend utilise PDAL pour cropper le nuage
|
||||||
|
3. Retourne le nouveau pc_id pour l'affichage
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Envoi de la requête de crop au backend
|
||||||
|
result = await api_client.crop_pointcloud(pc_id, box)
|
||||||
|
|
||||||
|
if result.get("ok"):
|
||||||
|
new_pc_id = result.get("id")
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "result": {
|
||||||
|
"id": new_pc_id,
|
||||||
|
"filename": f"{pc_id}_cropped.las",
|
||||||
|
"size_mb": result.get("size_mb", 0),
|
||||||
|
"conversion_time_seconds": result.get("conversion_time_seconds", 0),
|
||||||
|
}, "error": None},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": result.get("detail", "Erreur inconnue"), "result": None},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": str(e), "result": None},
|
||||||
|
)
|
||||||
59
frontend/routes/upload.py
Normal file
59
frontend/routes/upload.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# routes/upload.py
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, UploadFile, File, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from pathlib import Path
|
||||||
|
import api_client
|
||||||
|
import config
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=RedirectResponse)
|
||||||
|
async def root():
|
||||||
|
"""Redirige / vers /upload"""
|
||||||
|
return RedirectResponse(url="/upload")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/upload", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request):
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"index.html",
|
||||||
|
{"request": request, "active_tab": "upload"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health-check", response_class=HTMLResponse)
|
||||||
|
async def health_check(request: Request):
|
||||||
|
try:
|
||||||
|
data = await api_client.check_health()
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/health_status.html",
|
||||||
|
{"request": request, "ok": True, "entwine_available": data.get("entwine_available", False), "disk_free_gb": data.get("disk_free_gb", "?")},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/health_status.html",
|
||||||
|
{"request": request, "ok": False, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_class=HTMLResponse)
|
||||||
|
async def upload(request: Request, file: UploadFile = File(...)):
|
||||||
|
suffix = Path(file.filename).suffix.lower()
|
||||||
|
if suffix not in config.SUPPORTED_EXTENSIONS:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": f"Format non supporté : {suffix}. Formats acceptés : {', '.join(config.SUPPORTED_EXTENSIONS)}", "result": None},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = await api_client.upload_file(file.filename, await file.read())
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "result": data, "error": None},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/upload_result.html",
|
||||||
|
{"request": request, "error": str(e), "result": None},
|
||||||
|
)
|
||||||
61
frontend/routes/viewer.py
Normal file
61
frontend/routes/viewer.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
import config
|
||||||
|
import api_client
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/viewer/list", response_class=HTMLResponse)
|
||||||
|
async def viewer_list(request: Request):
|
||||||
|
"""Liste les nuages de points disponibles - Endpoint autonome frontend"""
|
||||||
|
pointclouds = []
|
||||||
|
|
||||||
|
# Récupérer la liste des nuages directement depuis le système de fichiers
|
||||||
|
ept_dir = Path(config.EPT_DIR)
|
||||||
|
if ept_dir.exists():
|
||||||
|
for item in sorted(ept_dir.iterdir(), key=lambda x: x.stat().st_ctime, reverse=True):
|
||||||
|
if item.is_dir():
|
||||||
|
# Lire le manifeste
|
||||||
|
manifest_path = item / "manifest.json"
|
||||||
|
manifest = {}
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest = {"ept_dir": item.name}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Calculer la taille et le nombre de fichiers
|
||||||
|
total_size = 0
|
||||||
|
file_count = 0
|
||||||
|
for f in item.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
total_size += f.stat().st_size
|
||||||
|
file_count += 1
|
||||||
|
|
||||||
|
if file_count > 0:
|
||||||
|
pointclouds.append({
|
||||||
|
"id": item.name,
|
||||||
|
"size_mb": round(total_size / (1024 * 1024), 2),
|
||||||
|
"file_count": file_count,
|
||||||
|
"manifest": manifest,
|
||||||
|
"created": item.stat().st_ctime,
|
||||||
|
})
|
||||||
|
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/cloud_list.html",
|
||||||
|
{"request": request, "pointclouds": pointclouds},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/viewer/{pc_id}", response_class=HTMLResponse)
|
||||||
|
async def viewer(request: Request, pc_id: str):
|
||||||
|
embed_url = f"{config.POTREE_URL}/viewer-embed/{pc_id}"
|
||||||
|
return request.app.state.templates.TemplateResponse(
|
||||||
|
"partials/viewer.html",
|
||||||
|
{"request": request, "pc_id": pc_id, "embed_url": embed_url},
|
||||||
|
)
|
||||||
119
frontend/templates/index.html
Normal file
119
frontend/templates/index.html
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PointCloud Viewer</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 min-h-screen">
|
||||||
|
<!-- Navbar -->
|
||||||
|
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none gap-4 items-center">
|
||||||
|
<div
|
||||||
|
id="health-indicator"
|
||||||
|
hx-get="/health-check"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="text-sm text-base-content/50"
|
||||||
|
>vérification…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||||
|
hx-get="/upload"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/"
|
||||||
|
>📤 Upload</a>
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/viewer"
|
||||||
|
>🗂️ Admin</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- Colonne gauche : upload -->
|
||||||
|
<div class="lg:col-span-1 flex flex-col gap-4">
|
||||||
|
<!-- Configuration Backend -->
|
||||||
|
<div id="backend-config-panel" hx-get="/admin/backend-config" hx-target="#backend-config-panel" hx-swap="innerHTML">
|
||||||
|
{% include "partials/backend_config.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base mb-2">📤 Upload</h2>
|
||||||
|
<p class="text-sm text-base-content/60 mb-3">
|
||||||
|
Formats acceptés : LAS, LAZ, PLY, XYZ, PTS
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post="/upload"
|
||||||
|
hx-target="#upload-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-encoding="multipart/form-data"
|
||||||
|
hx-indicator="#upload-spinner"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="file"
|
||||||
|
accept=".las,.laz,.ply,.xyz,.pts"
|
||||||
|
class="file-input file-input-bordered w-full mb-4"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn"
|
||||||
|
hx_indicator="#upload-spinner"
|
||||||
|
>
|
||||||
|
📤 Uploader & convertir
|
||||||
|
</button>
|
||||||
|
<div id="upload-spinner" class="loading loading-spinner loading-sm"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonne droite : viewer -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div
|
||||||
|
id="viewer-container"
|
||||||
|
class="card bg-base-100 shadow min-h-[600px] flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<p class="text-base-content/40 text-sm">
|
||||||
|
Uploadez un fichier pour lancer la visualisation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="viewer-panel"
|
||||||
|
class="card bg-base-100 shadow mt-4"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#viewer-panel"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-trigger="load"
|
||||||
|
>
|
||||||
|
{% include "partials/cloud_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
71
frontend/templates/partials/backend_config.html
Normal file
71
frontend/templates/partials/backend_config.html
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<div class="card bg-base-200 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title">
|
||||||
|
<span class="badge badge-ghost">⚙️</span>
|
||||||
|
Configuration Backend
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="alert alert-success mb-4">
|
||||||
|
<span>✓ Configuration sauvegardée !</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error mb-4">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Configuration Backend URL -->
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">URL du Backend</span>
|
||||||
|
<span class="label-text-alt text-xs">
|
||||||
|
Pour les appels API
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="backend_url"
|
||||||
|
value="{{ current_backend_url or 'http://localhost:8091' }}"
|
||||||
|
class="input input-bordered"
|
||||||
|
placeholder="http://localhost:8091"
|
||||||
|
>
|
||||||
|
<span class="label-text-alt text-xs">
|
||||||
|
Ex: http://localhost:8091 ou http://backend_entwine:8000
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Potree URL -->
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">URL Potree</span>
|
||||||
|
<span class="label-text-alt text-xs">
|
||||||
|
Pour charger le viewer 3D
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="potree_url"
|
||||||
|
value="{{ current_potree_url or 'http://localhost:8090' }}"
|
||||||
|
class="input input-bordered"
|
||||||
|
placeholder="http://localhost:8090"
|
||||||
|
>
|
||||||
|
<span class="label-text-alt text-xs">
|
||||||
|
Ex: http://localhost:8090 ou http://potree_server:8090
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
💾 Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
60
frontend/templates/partials/cloud_list.html
Normal file
60
frontend/templates/partials/cloud_list.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-base mb-2">🗂️ Nuages de points</h2>
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#viewer-panel"
|
||||||
|
hx-trigger="click"
|
||||||
|
>
|
||||||
|
🔄 Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% elif not pointclouds %}
|
||||||
|
<p class="text-base-content/40 text-sm text-center py-8">
|
||||||
|
Aucun nuage disponible sur le serveur.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-base-content/40 mb-3">{{ pointclouds|length }} nuage(s)</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Taille</th>
|
||||||
|
<th>Fichiers</th>
|
||||||
|
<th>Créé le</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="cloud-table-body">
|
||||||
|
{% for pc in pointclouds %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ pc.id }}</td>
|
||||||
|
<td>{{ pc.size_mb }} MB</td>
|
||||||
|
<td>{{ pc.file_count }}</td>
|
||||||
|
<td>{{ pc.created|datetimeformat }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/debug/{{ pc.id }}" class="btn btn-sm btn-ghost">🔍</a>
|
||||||
|
<a href="/viewer/{{ pc.id }}" class="btn btn-sm" target="_blank">👁️</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-error"
|
||||||
|
onclick="if(confirm('Supprimer ce nuage ?')) window.location.href='/admin/delete/{{ pc.id }}'"
|
||||||
|
>🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
135
frontend/templates/partials/crop.html
Normal file
135
frontend/templates/partials/crop.html
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Crop — {{ pc_id }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 min-h-screen">
|
||||||
|
<!-- Navbar -->
|
||||||
|
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none gap-4 items-center">
|
||||||
|
<div
|
||||||
|
id="health-indicator"
|
||||||
|
hx-get="/health-check"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="text-sm text-base-content/50"
|
||||||
|
>vérification…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||||
|
hx-get="/"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/"
|
||||||
|
>📤 Upload</a>
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/viewer"
|
||||||
|
>🗂️ Admin</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||||
|
<div class="w-full max-w-4xl mx-auto space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-2xl font-bold">📐 Crop du Nuage de Points</h2>
|
||||||
|
<a href="/viewer/list" class="btn btn-ghost btn-sm">← Retour</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge badge-info">Nuage : {{ pc_id }}</span>
|
||||||
|
<span class="text-sm text-base-content/60">
|
||||||
|
Sélectionnez une zone dans le viewer pour cropper le nuage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/viewer/{{ pc_id }}" class="btn btn-primary" hx-get="/viewer/{{ pc_id }}" hx-target="#viewer-container">
|
||||||
|
👁️ Ouvrir Viewer
|
||||||
|
</a>
|
||||||
|
<a href="{{ embed_url }}" target="_blank" class="btn btn-ghost btn-sm">
|
||||||
|
↗ Plein écran
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewer-container" class="w-full rounded-lg border border-base-300">
|
||||||
|
<iframe
|
||||||
|
src="{{ embed_url }}"
|
||||||
|
class="w-full"
|
||||||
|
style="height: 680px;"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="font-semibold">📦 Coordonnées de la Box 3D</h3>
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<label class="text-base-content/60">Min X</label>
|
||||||
|
<input type="number" step="0.001" id="minX" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-base-content/60">Min Y</label>
|
||||||
|
<input type="number" step="0.001" id="minY" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-base-content/60">Min Z</label>
|
||||||
|
<input type="number" step="0.001" id="minZ" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-base-content/60">Max X</label>
|
||||||
|
<input type="number" step="0.001" id="maxX" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-base-content/60">Max Y</label>
|
||||||
|
<input type="number" step="0.001" id="maxY" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-base-content/60">Max Z</label>
|
||||||
|
<input type="number" step="0.001" id="maxZ" class="input input-bordered input-sm w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
hx-post="/crop/{{ pc_id }}"
|
||||||
|
hx-target="#crop-result"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
>
|
||||||
|
✂️ Cropper
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-base-content/60">
|
||||||
|
Le traitement peut prendre plusieurs minutes selon la taille du nuage
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="crop-result" class="w-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
71
frontend/templates/partials/debug_panel.html
Normal file
71
frontend/templates/partials/debug_panel.html
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Debug — {{ pc_id }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.min.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-base-200 min-h-screen">
|
||||||
|
<!-- Navbar -->
|
||||||
|
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none gap-4 items-center">
|
||||||
|
<div
|
||||||
|
id="health-indicator"
|
||||||
|
hx-get="/health-check"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="text-sm text-base-content/50"
|
||||||
|
>vérification…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||||
|
hx-get="/"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/"
|
||||||
|
>📤 Upload</a>
|
||||||
|
<a
|
||||||
|
role="tab"
|
||||||
|
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#main-content"
|
||||||
|
hx-push-url="/viewer"
|
||||||
|
>🗂️ Admin</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||||
|
<div class="card bg-base-100 shadow mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="font-semibold text-sm">Debug : <code>{{ pc_id }}</code></h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
hx-get="/viewer/list"
|
||||||
|
hx-target="#main-content"
|
||||||
|
>
|
||||||
|
✕ Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<pre class="bg-base-200 rounded p-3 text-xs overflow-auto max-h-80">{{ data | tojson(indent=2) }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
frontend/templates/partials/health_status.html
Normal file
14
frontend/templates/partials/health_status.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{% if ok %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if entwine_available %}
|
||||||
|
<span class="badge badge-success">backend ✓</span>
|
||||||
|
<span class="badge badge-success">entwine ✓</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-success">backend ✓</span>
|
||||||
|
<span class="badge badge-warning">entwine absent</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-base-content/40">{{ disk_free_gb }} GB libres</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-error">backend inaccessible</span>
|
||||||
|
{% endif %}
|
||||||
27
frontend/templates/partials/upload_result.html
Normal file
27
frontend/templates/partials/upload_result.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="badge badge-success">✓ Conversion EPT terminée</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm space-y-1 mb-4">
|
||||||
|
<div><span class="text-base-content/50">ID</span><code class="ml-2">{{ result.id }}</code></div>
|
||||||
|
<div><span class="text-base-content/50">Fichier</span><span class="ml-2">{{ result.filename }}</span></div>
|
||||||
|
<div><span class="text-base-content/50">Taille</span><span class="ml-2">{{ result.size_mb }} MB</span></div>
|
||||||
|
<div><span class="text-base-content/50">Conversion</span><span class="ml-2">{{ result.conversion_time_seconds }}s</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/viewer/{{ result.id }}" class="btn btn-primary" hx-get="/viewer/{{ result.id }}" hx-target="#viewer-container">
|
||||||
|
👁️ Visualiser
|
||||||
|
</a>
|
||||||
|
<a href="/viewer/{{ result.id }}" target="_blank" class="btn btn-ghost btn-sm">
|
||||||
|
↗ Nouvel onglet
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
14
frontend/templates/partials/viewer.html
Normal file
14
frontend/templates/partials/viewer.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<div class="w-full flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between px-1">
|
||||||
|
<span class="text-sm text-base-content/50">
|
||||||
|
Nuage actif : <code>{{ pc_id }}</code>
|
||||||
|
</span>
|
||||||
|
<a href="{{ embed_url }}" target="_blank" class="btn btn-ghost btn-xs">↗ Plein écran</a>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
src="{{ embed_url }}"
|
||||||
|
class="w-full rounded-lg border border-base-300"
|
||||||
|
style="height: 680px;"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[project]
|
||||||
|
name = "point-cloud-classifier"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi==0.115.0",
|
||||||
|
"python-multipart==0.0.9",
|
||||||
|
"httpx==0.27.2",
|
||||||
|
"jinja2==3.1.4",
|
||||||
|
"jinjax==0.44.0",
|
||||||
|
"aiofiles==24.1.0",
|
||||||
|
"uvicorn==0.30.6",
|
||||||
|
]
|
||||||
|
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
python-multipart==0.0.9
|
||||||
|
httpx==0.27.2
|
||||||
|
jinja2==3.1.4
|
||||||
|
jinjax==0.44.0
|
||||||
|
aiofiles==24.1.0
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue