Initial commit

This commit is contained in:
Tie 2026-04-01 21:13:58 +02:00
commit b22231c8b6
40 changed files with 2443 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View file

32
backend/config.py Normal file
View 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
View 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
View 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
View 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
View 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))

View 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

View 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>"""

View 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
View 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
View 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()

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1 @@
<div id="{{ id }}" class="loading loading-spinner loading-sm"></div>

31
frontend/config.py Normal file
View 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
View 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)

View file

@ -0,0 +1,4 @@
from . import upload
from . import viewer
from . import admin
from . import crop

131
frontend/routes/admin.py Normal file
View 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
View 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
View 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
View 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},
)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 %}

View 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 %}

View 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
View 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
View 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