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