commit b22231c8b600e1b83850e57f22adc3ee60212138 Author: Tie Date: Wed Apr 1 21:13:58 2026 +0200 Initial commit diff --git a/.continue/agents/new-config.yaml b/.continue/agents/new-config.yaml new file mode 100644 index 0000000..f45d8c2 --- /dev/null +++ b/.continue/agents/new-config.yaml @@ -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: + - "" + + - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dec50f5 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Roo.md b/Roo.md new file mode 100644 index 0000000..6fff475 --- /dev/null +++ b/Roo.md @@ -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) + + diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..2374931 --- /dev/null +++ b/agents.md @@ -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. + diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..6aa8ed7 --- /dev/null +++ b/architecture.md @@ -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
Jinja2 + HTMX + AlpineJS] + UI[Interface
DaisyUI + Tailwind] + API_CLIENT[api_client.py
httpx Async] + CONFIG[config.py
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
PDAL + Entwine] + ROUTES[Routes
upload.py
viewer.py
admin.py] + SERVICES[Services
converter.py
manifest.py
html_generator.py] + UTILS[Utils
disk.py] + STATIC[Static Files
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
build EPT] + PDAL[PDAL
filter/crop LAS] + end + + subgraph "Stockage" + UPLOADS[uploads/
fichiers LAS/LAZ] + EPT[ept/
nuages convertis] + CONFIG_FILE[config/
backend.json] + end + + subgraph "External" + POT[Potree Viewer
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 diff --git a/architecture_readme.md b/architecture_readme.md new file mode 100644 index 0000000..610ce22 --- /dev/null +++ b/architecture_readme.md @@ -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. \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..45e24f4 --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..8980be4 --- /dev/null +++ b/backend/main.py @@ -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""" + + + + + Backend - PointCloud + + + +

Backend PointCloud OK ✅

+ +

Configuration

+ + +""" \ No newline at end of file diff --git a/backend/routes/admin.py b/backend/routes/admin.py new file mode 100644 index 0000000..f9358e4 --- /dev/null +++ b/backend/routes/admin.py @@ -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)}") diff --git a/backend/routes/upload.py b/backend/routes/upload.py new file mode 100644 index 0000000..cf45471 --- /dev/null +++ b/backend/routes/upload.py @@ -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), + }) \ No newline at end of file diff --git a/backend/routes/viewer.py b/backend/routes/viewer.py new file mode 100644 index 0000000..919dfd4 --- /dev/null +++ b/backend/routes/viewer.py @@ -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)) diff --git a/backend/services/converter.py b/backend/services/converter.py new file mode 100644 index 0000000..48071c0 --- /dev/null +++ b/backend/services/converter.py @@ -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 -o + 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 \ No newline at end of file diff --git a/backend/services/html_generator.py b/backend/services/html_generator.py new file mode 100644 index 0000000..2924e0a --- /dev/null +++ b/backend/services/html_generator.py @@ -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 "

Erreur : ept.json introuvable pour cet ID

" + + 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""" + + + + + EPT Viewer - {pc_id} + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + +""" \ No newline at end of file diff --git a/backend/services/manifest.py b/backend/services/manifest.py new file mode 100644 index 0000000..a2e652d --- /dev/null +++ b/backend/services/manifest.py @@ -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 {} \ No newline at end of file diff --git a/backend/utils/disk.py b/backend/utils/disk.py new file mode 100644 index 0000000..9f7e603 --- /dev/null +++ b/backend/utils/disk.py @@ -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 \ No newline at end of file diff --git a/frontend/api_client.py b/frontend/api_client.py new file mode 100644 index 0000000..03c1592 --- /dev/null +++ b/frontend/api_client.py @@ -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() diff --git a/frontend/components/Button.html b/frontend/components/Button.html new file mode 100644 index 0000000..ae04128 --- /dev/null +++ b/frontend/components/Button.html @@ -0,0 +1,14 @@ + diff --git a/frontend/components/Card.html b/frontend/components/Card.html new file mode 100644 index 0000000..3092c52 --- /dev/null +++ b/frontend/components/Card.html @@ -0,0 +1,8 @@ +
+
+ {% if title %} +

{{ title }}

+ {% endif %} + {{ content }} +
+
diff --git a/frontend/components/CloudRow.html b/frontend/components/CloudRow.html new file mode 100644 index 0000000..eb10056 --- /dev/null +++ b/frontend/components/CloudRow.html @@ -0,0 +1,25 @@ +
+
+
+
+
+
+ {{ cloud_name }} +
+
+
+

{{ cloud_name }}

+

{{ cloud_path }}

+
+
+ +
+
+
diff --git a/frontend/components/Layout.html b/frontend/components/Layout.html new file mode 100644 index 0000000..21a452c --- /dev/null +++ b/frontend/components/Layout.html @@ -0,0 +1,17 @@ + + + + + + {{ title }} + + + + + + +
+ {{ content }} +
+ + diff --git a/frontend/components/Navbar.html b/frontend/components/Navbar.html new file mode 100644 index 0000000..b59c113 --- /dev/null +++ b/frontend/components/Navbar.html @@ -0,0 +1,35 @@ + + + + +
+ {{ content }} +
diff --git a/frontend/components/Spinner.html b/frontend/components/Spinner.html new file mode 100644 index 0000000..13b73c7 --- /dev/null +++ b/frontend/components/Spinner.html @@ -0,0 +1 @@ +
diff --git a/frontend/config.py b/frontend/config.py new file mode 100644 index 0000000..daafc5b --- /dev/null +++ b/frontend/config.py @@ -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") diff --git a/frontend/main.py b/frontend/main.py new file mode 100644 index 0000000..523957c --- /dev/null +++ b/frontend/main.py @@ -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) \ No newline at end of file diff --git a/frontend/routes/__init__.py b/frontend/routes/__init__.py new file mode 100644 index 0000000..2f141a8 --- /dev/null +++ b/frontend/routes/__init__.py @@ -0,0 +1,4 @@ +from . import upload +from . import viewer +from . import admin +from . import crop \ No newline at end of file diff --git a/frontend/routes/admin.py b/frontend/routes/admin.py new file mode 100644 index 0000000..c54f04c --- /dev/null +++ b/frontend/routes/admin.py @@ -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}, + ) \ No newline at end of file diff --git a/frontend/routes/crop.py b/frontend/routes/crop.py new file mode 100644 index 0000000..d52651c --- /dev/null +++ b/frontend/routes/crop.py @@ -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}, + ) \ No newline at end of file diff --git a/frontend/routes/upload.py b/frontend/routes/upload.py new file mode 100644 index 0000000..c0fbfbb --- /dev/null +++ b/frontend/routes/upload.py @@ -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}, + ) \ No newline at end of file diff --git a/frontend/routes/viewer.py b/frontend/routes/viewer.py new file mode 100644 index 0000000..dba7780 --- /dev/null +++ b/frontend/routes/viewer.py @@ -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}, + ) diff --git a/frontend/templates/index.html b/frontend/templates/index.html new file mode 100644 index 0000000..20671cb --- /dev/null +++ b/frontend/templates/index.html @@ -0,0 +1,119 @@ + + + + + + PointCloud Viewer + + + + + + + + + + + +
+
+ + +
+ +
+ {% include "partials/backend_config.html" %} +
+ +
+
+

📤 Upload

+

+ Formats acceptés : LAS, LAZ, PLY, XYZ, PTS +

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

+ Uploadez un fichier pour lancer la visualisation +

+
+
+ {% include "partials/cloud_list.html" %} +
+
+ +
+
+ + + diff --git a/frontend/templates/partials/backend_config.html b/frontend/templates/partials/backend_config.html new file mode 100644 index 0000000..af3336f --- /dev/null +++ b/frontend/templates/partials/backend_config.html @@ -0,0 +1,71 @@ +
+
+

+ ⚙️ + Configuration Backend +

+ + {% if success %} +
+ ✓ Configuration sauvegardée ! +
+ {% endif %} + + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+ +
+ + + + Ex: http://localhost:8091 ou http://backend_entwine:8000 + +
+ + +
+ + + + Ex: http://localhost:8090 ou http://potree_server:8090 + +
+
+ +
+ +
+
+
diff --git a/frontend/templates/partials/cloud_list.html b/frontend/templates/partials/cloud_list.html new file mode 100644 index 0000000..facbc7c --- /dev/null +++ b/frontend/templates/partials/cloud_list.html @@ -0,0 +1,60 @@ +
+
+

🗂️ Nuages de points

+
+ +
+ + {% if error %} +
+ {{ error }} +
+ {% elif not pointclouds %} +

+ Aucun nuage disponible sur le serveur. +

+ {% else %} +

{{ pointclouds|length }} nuage(s)

+
+ + + + + + + + + + + + {% for pc in pointclouds %} + + + + + + + + {% endfor %} + +
IDTailleFichiersCréé leActions
{{ pc.id }}{{ pc.size_mb }} MB{{ pc.file_count }}{{ pc.created|datetimeformat }} + 🔍 + 👁️ + +
+
+ {% endif %} +
+
diff --git a/frontend/templates/partials/crop.html b/frontend/templates/partials/crop.html new file mode 100644 index 0000000..2433660 --- /dev/null +++ b/frontend/templates/partials/crop.html @@ -0,0 +1,135 @@ + + + + + + Crop — {{ pc_id }} + + + + + + + + + + +
+
+
+

📐 Crop du Nuage de Points

+ ← Retour +
+ +
+
+
+
+ Nuage : {{ pc_id }} + + Sélectionnez une zone dans le viewer pour cropper le nuage + +
+ + +
+
+
+ +
+ +
+ +
+
+

📦 Coordonnées de la Box 3D

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + Le traitement peut prendre plusieurs minutes selon la taille du nuage + +
+
+
+ +
+
+
+ + + diff --git a/frontend/templates/partials/debug_panel.html b/frontend/templates/partials/debug_panel.html new file mode 100644 index 0000000..c55b929 --- /dev/null +++ b/frontend/templates/partials/debug_panel.html @@ -0,0 +1,71 @@ + + + + + + Debug — {{ pc_id }} + + + + + + + + + + +
+
+
+
+

Debug : {{ pc_id }}

+ +
+ {% if error %} +
+ {{ error }} +
+ {% else %} +
{{ data | tojson(indent=2) }}
+ {% endif %} +
+
+
+ + + diff --git a/frontend/templates/partials/health_status.html b/frontend/templates/partials/health_status.html new file mode 100644 index 0000000..8eda014 --- /dev/null +++ b/frontend/templates/partials/health_status.html @@ -0,0 +1,14 @@ +{% if ok %} +
+ {% if entwine_available %} + backend ✓ + entwine ✓ + {% else %} + backend ✓ + entwine absent + {% endif %} + {{ disk_free_gb }} GB libres +
+{% else %} + backend inaccessible +{% endif %} diff --git a/frontend/templates/partials/upload_result.html b/frontend/templates/partials/upload_result.html new file mode 100644 index 0000000..f01a59c --- /dev/null +++ b/frontend/templates/partials/upload_result.html @@ -0,0 +1,27 @@ +{% if error %} +
+ {{ error }} +
+{% else %} +
+
+
+ ✓ Conversion EPT terminée +
+
+
ID{{ result.id }}
+
Fichier{{ result.filename }}
+
Taille{{ result.size_mb }} MB
+
Conversion{{ result.conversion_time_seconds }}s
+
+ +
+
+{% endif %} diff --git a/frontend/templates/partials/viewer.html b/frontend/templates/partials/viewer.html new file mode 100644 index 0000000..d99ac0c --- /dev/null +++ b/frontend/templates/partials/viewer.html @@ -0,0 +1,14 @@ +
+
+ + Nuage actif : {{ pc_id }} + + ↗ Plein écran +
+ +
diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..44eaacb --- /dev/null +++ b/pyproject.toml @@ -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", +] + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c53bad3 --- /dev/null +++ b/requirements.txt @@ -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 + \ No newline at end of file