From b22231c8b600e1b83850e57f22adc3ee60212138 Mon Sep 17 00:00:00 2001 From: Tie Date: Wed, 1 Apr 2026 21:13:58 +0200 Subject: [PATCH 01/25] Initial commit --- .continue/agents/new-config.yaml | 75 ++++ .gitignore | 77 +++++ Roo.md | 21 ++ agents.md | 145 ++++++++ architecture.md | 321 ++++++++++++++++++ architecture_readme.md | 91 +++++ backend/__init__.py | 0 backend/config.py | 32 ++ backend/main.py | 94 +++++ backend/routes/admin.py | 184 ++++++++++ backend/routes/upload.py | 47 +++ backend/routes/viewer.py | 53 +++ backend/services/converter.py | 75 ++++ backend/services/html_generator.py | 87 +++++ backend/services/manifest.py | 24 ++ backend/utils/disk.py | 34 ++ frontend/api_client.py | 57 ++++ frontend/components/Button.html | 14 + frontend/components/Card.html | 8 + frontend/components/CloudRow.html | 25 ++ frontend/components/Layout.html | 17 + frontend/components/Navbar.html | 35 ++ frontend/components/Spinner.html | 1 + frontend/config.py | 31 ++ frontend/main.py | 54 +++ frontend/routes/__init__.py | 4 + frontend/routes/admin.py | 131 +++++++ frontend/routes/crop.py | 51 +++ frontend/routes/upload.py | 59 ++++ frontend/routes/viewer.py | 61 ++++ frontend/templates/index.html | 119 +++++++ .../templates/partials/backend_config.html | 71 ++++ frontend/templates/partials/cloud_list.html | 60 ++++ frontend/templates/partials/crop.html | 135 ++++++++ frontend/templates/partials/debug_panel.html | 71 ++++ .../templates/partials/health_status.html | 14 + .../templates/partials/upload_result.html | 27 ++ frontend/templates/partials/viewer.html | 14 + pyproject.toml | 16 + requirements.txt | 8 + 40 files changed, 2443 insertions(+) create mode 100644 .continue/agents/new-config.yaml create mode 100644 .gitignore create mode 100644 Roo.md create mode 100644 agents.md create mode 100644 architecture.md create mode 100644 architecture_readme.md create mode 100644 backend/__init__.py create mode 100644 backend/config.py create mode 100644 backend/main.py create mode 100644 backend/routes/admin.py create mode 100644 backend/routes/upload.py create mode 100644 backend/routes/viewer.py create mode 100644 backend/services/converter.py create mode 100644 backend/services/html_generator.py create mode 100644 backend/services/manifest.py create mode 100644 backend/utils/disk.py create mode 100644 frontend/api_client.py create mode 100644 frontend/components/Button.html create mode 100644 frontend/components/Card.html create mode 100644 frontend/components/CloudRow.html create mode 100644 frontend/components/Layout.html create mode 100644 frontend/components/Navbar.html create mode 100644 frontend/components/Spinner.html create mode 100644 frontend/config.py create mode 100644 frontend/main.py create mode 100644 frontend/routes/__init__.py create mode 100644 frontend/routes/admin.py create mode 100644 frontend/routes/crop.py create mode 100644 frontend/routes/upload.py create mode 100644 frontend/routes/viewer.py create mode 100644 frontend/templates/index.html create mode 100644 frontend/templates/partials/backend_config.html create mode 100644 frontend/templates/partials/cloud_list.html create mode 100644 frontend/templates/partials/crop.html create mode 100644 frontend/templates/partials/debug_panel.html create mode 100644 frontend/templates/partials/health_status.html create mode 100644 frontend/templates/partials/upload_result.html create mode 100644 frontend/templates/partials/viewer.html create mode 100644 pyproject.toml create mode 100644 requirements.txt 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 From 7eb31f4834d39c386d640d907180252a0413ec0d Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:28:39 +0200 Subject: [PATCH 02/25] Upload files to "backend/routes" --- backend/routes/admin.py | 184 +++++++++++++++++++++++++++++++++++++++ backend/routes/upload.py | 47 ++++++++++ backend/routes/viewer.py | 53 +++++++++++ 3 files changed, 284 insertions(+) create mode 100644 backend/routes/admin.py create mode 100644 backend/routes/upload.py create mode 100644 backend/routes/viewer.py 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)) From deec53617f2ef4cddcfe64c3f7c599784803cfc7 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:29:10 +0200 Subject: [PATCH 03/25] Upload files to "services" --- services/converter.py | 75 ++++++++++++++++++++++++++++++++ services/html_generator.py | 87 ++++++++++++++++++++++++++++++++++++++ services/manifest.py | 24 +++++++++++ 3 files changed, 186 insertions(+) create mode 100644 services/converter.py create mode 100644 services/html_generator.py create mode 100644 services/manifest.py diff --git a/services/converter.py b/services/converter.py new file mode 100644 index 0000000..48071c0 --- /dev/null +++ b/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/services/html_generator.py b/services/html_generator.py new file mode 100644 index 0000000..2924e0a --- /dev/null +++ b/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/services/manifest.py b/services/manifest.py new file mode 100644 index 0000000..a2e652d --- /dev/null +++ b/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 From a39ba7596a13c72ba8af1a968a45fd7ca9d31dfa Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:29:53 +0200 Subject: [PATCH 04/25] Upload files to "backend/utils" --- backend/utils/disk.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 backend/utils/disk.py 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 From 74f5bab0fea01774093f4b7ee0f8f642eb738cfb Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:31:44 +0200 Subject: [PATCH 05/25] Delete services/manifest.py --- services/manifest.py | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 services/manifest.py diff --git a/services/manifest.py b/services/manifest.py deleted file mode 100644 index a2e652d..0000000 --- a/services/manifest.py +++ /dev/null @@ -1,24 +0,0 @@ -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 From 97a44bc38a39e0fc812b500761788520ae0642b1 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:31:51 +0200 Subject: [PATCH 06/25] Delete services/html_generator.py --- services/html_generator.py | 87 -------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 services/html_generator.py diff --git a/services/html_generator.py b/services/html_generator.py deleted file mode 100644 index 2924e0a..0000000 --- a/services/html_generator.py +++ /dev/null @@ -1,87 +0,0 @@ -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 From ce60acb1a2ebe653644366fab58a7a7b33568a68 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:31:58 +0200 Subject: [PATCH 07/25] Delete services/converter.py --- services/converter.py | 75 ------------------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 services/converter.py diff --git a/services/converter.py b/services/converter.py deleted file mode 100644 index 48071c0..0000000 --- a/services/converter.py +++ /dev/null @@ -1,75 +0,0 @@ -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 From b25bf2151debbfcbbb28b22114ae00319ba84285 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:32:19 +0200 Subject: [PATCH 08/25] Upload files to "backend/services" --- backend/services/converter.py | 75 ++++++++++++++++++++++++++ backend/services/html_generator.py | 87 ++++++++++++++++++++++++++++++ backend/services/manifest.py | 24 +++++++++ 3 files changed, 186 insertions(+) create mode 100644 backend/services/converter.py create mode 100644 backend/services/html_generator.py create mode 100644 backend/services/manifest.py 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 From 26088d26a2317c3fa3ae0010fca37f4bcec1d3c5 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:32:50 +0200 Subject: [PATCH 09/25] Upload files to "backend" --- backend/__init__.py | Bin 0 -> 1024 bytes backend/config.py | 32 +++++++++++++++ backend/main.py | 94 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 backend/__init__.py create mode 100644 backend/config.py create mode 100644 backend/main.py diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..06d7405020018ddf3cacee90fd4af10487da3d20 GIT binary patch literal 1024 ScmZQz7zLvtFd70QH3R?z00031 literal 0 HcmV?d00001 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 ✅

+
    +
  • /docs — Documentation API
  • +
  • /health — État du service
  • +
+

Configuration

+
    +
  • entwine : {"✅ " + entwine_path if entwine_path else "❌ Non trouvé"}
  • +
  • pdal path : {"✅ " + pdal["path"] if pdal["path"] else "❌ Non trouvé"}
  • +
  • pdal version : {pdal["version"] if pdal["version"] else "❌ Inconnue"}
  • +
  • Espace disque : {get_disk_usage()} GB libres
  • +
+ +""" \ No newline at end of file From d0c29f3c5097d4d5e1816857510af4d72e3b2807 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:33:24 +0200 Subject: [PATCH 10/25] Upload files to "frontend" --- frontend/api_client.py | 57 ++++++++++++++++++++++++++++++++++++++++++ frontend/config.py | 31 +++++++++++++++++++++++ frontend/main.py | 54 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 frontend/api_client.py create mode 100644 frontend/config.py create mode 100644 frontend/main.py 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/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 From 0bce1a25c7d02a54f318294f4ac28b2ee8312059 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:33:59 +0200 Subject: [PATCH 11/25] Upload files to "frontend/templates/partials" --- .../templates/partials/backend_config.html | 71 +++++++++ frontend/templates/partials/cloud_list.html | 60 ++++++++ frontend/templates/partials/crop.html | 135 ++++++++++++++++++ frontend/templates/partials/debug_panel.html | 71 +++++++++ .../templates/partials/health_status.html | 14 ++ 5 files changed, 351 insertions(+) create mode 100644 frontend/templates/partials/backend_config.html create mode 100644 frontend/templates/partials/cloud_list.html create mode 100644 frontend/templates/partials/crop.html create mode 100644 frontend/templates/partials/debug_panel.html create mode 100644 frontend/templates/partials/health_status.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 %} From 41872246cebd212e30a9fea2cae464e8f23a3ca7 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:34:16 +0200 Subject: [PATCH 12/25] Upload files to "frontend/templates/partials" --- .../templates/partials/upload_result.html | 27 +++++++++++++++++++ frontend/templates/partials/viewer.html | 14 ++++++++++ 2 files changed, 41 insertions(+) create mode 100644 frontend/templates/partials/upload_result.html create mode 100644 frontend/templates/partials/viewer.html 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 +
+ +
From d1686cdc8ec8ae0bc64fc723664d6f48b549f348 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:34:30 +0200 Subject: [PATCH 13/25] Upload files to "frontend/templates" --- frontend/templates/index.html | 119 ++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 frontend/templates/index.html 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" %} +
+
+ +
+
+ + + From e6824ac5b27d65047ce5cc938f4ce65638eebe95 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:34:58 +0200 Subject: [PATCH 14/25] Upload files to "frontend/routes" --- frontend/routes/__init__.py | 4 ++ frontend/routes/admin.py | 131 ++++++++++++++++++++++++++++++++++++ frontend/routes/crop.py | 51 ++++++++++++++ frontend/routes/upload.py | 59 ++++++++++++++++ frontend/routes/viewer.py | 61 +++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 frontend/routes/__init__.py create mode 100644 frontend/routes/admin.py create mode 100644 frontend/routes/crop.py create mode 100644 frontend/routes/upload.py create mode 100644 frontend/routes/viewer.py 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}, + ) From 317cf341565b8c925a0151c3c2364ea8963ea897 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:35:26 +0200 Subject: [PATCH 15/25] Upload files to "frontend/components" --- frontend/components/Button.html | 14 +++++++++++++ frontend/components/Card.html | 8 +++++++ frontend/components/CloudRow.html | 25 ++++++++++++++++++++++ frontend/components/Layout.html | 17 +++++++++++++++ frontend/components/Navbar.html | 35 +++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 frontend/components/Button.html create mode 100644 frontend/components/Card.html create mode 100644 frontend/components/CloudRow.html create mode 100644 frontend/components/Layout.html create mode 100644 frontend/components/Navbar.html 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 }} +
From 6e7ecfa2b7a809f993bf2a256832b4cc451df8d8 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:35:37 +0200 Subject: [PATCH 16/25] Upload files to "frontend/components" --- frontend/components/Spinner.html | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/components/Spinner.html 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 @@ +
From 721dd0404ce06d4c78069414dea639f040424b15 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:36:06 +0200 Subject: [PATCH 17/25] Upload files to "/" --- Dockerfile.builder | 66 +++++++++ agents.md | 145 +++++++++++++++++++ architecture.md | 321 +++++++++++++++++++++++++++++++++++++++++ architecture_readme.md | 91 ++++++++++++ docker-compose.yml | 30 ++++ 5 files changed, 653 insertions(+) create mode 100644 Dockerfile.builder create mode 100644 agents.md create mode 100644 architecture.md create mode 100644 architecture_readme.md create mode 100644 docker-compose.yml diff --git a/Dockerfile.builder b/Dockerfile.builder new file mode 100644 index 0000000..fd0451f --- /dev/null +++ b/Dockerfile.builder @@ -0,0 +1,66 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt update && apt install -y --no-install-suggests --no-install-recommends \ + libjpeg-dev \ + zlib1g-dev \ + build-essential \ + ca-certificates \ + curl \ + gnupg \ + cmake \ + ninja-build \ + git + +RUN apt install -y libjpeg62 libpng-dev libtiff-dev libz-dev libproj-dev liblzma-dev libjbig-dev libzstd-dev libgeotiff-dev libwebp-dev liblzma-dev nlohmann-json3-dev + +ENV GDAL_VERSION 3.12.2 + +ADD http://download.osgeo.org/gdal/${GDAL_VERSION}/gdal-${GDAL_VERSION}.tar.gz /usr/local/src/ + +RUN cd /usr/local/src && tar -xvf gdal-${GDAL_VERSION}.tar.gz && cd gdal-${GDAL_VERSION} \ + && cmake -S . -B build \ + -G Ninja \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DBUILD_SHARED_LIBS=ON \ + && cmake --build build --config Release \ + && cmake --install build \ + && ldconfig + #&& rm -Rf /usr/local/src/* + +RUN git clone https://github.com/LASzip/LASzip.git && cd LASzip \ + && cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DBUILD_SHARED_LIBS=ON \ + && cmake --build build \ + && cmake --install build --config Release \ + && ldconfig + +RUN git clone https://github.com/PDAL/PDAL.git && cd PDAL \ + && cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_PREFIX_PATH=/usr/local \ + -DBUILD_PLUGIN_LASZIP=ON \ + -DGDAL_DIR=/usr/local/lib/cmake/gdal \ + && cmake --build build \ + && cmake --install build --config Release \ + && ldconfig + +RUN git clone https://github.com/connormanning/entwine.git && cd entwine \ + && cmake -S . -B build \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_PREFIX_PATH=/usr/local \ + && cmake --build build \ + && cmake --install build --config Release \ + && ldconfig + +RUN apt remove -y libgeotiff-dev libpng-dev libtiff-dev libjpeg-dev libz-dev libproj-dev liblzma-dev libjbig-dev libzstd-dev libgeotiff-dev libwebp-dev liblzma-dev nlohmann-json3-dev ninja-build cmake build-essential zlib1g-dev \ + && apt autoremove -y \ + && rm -rf /usr/local/src/* /var/lib/apt/lists/* /tmp/* /var/tmp/* \ No newline at end of file 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..55aca16 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + + backend_entwine: + build: + context: . + dockerfile: Dockerfile.entwine + container_name: pointcloud-backend-htmx + ports: + - "8090:8000" + volumes: + - ./backend:/app/backend + environment: + - HOST_DATA_DIR=/app/backend/data + restart: unless-stopped + + frontend_htmx: + build: + context: . + dockerfile: Dockerfile.frontend + container_name: pointcloud-frontend-htmx + ports: + - "8091:8080" + volumes: + - ./frontend:/app/frontend + environment: + - MAX_UPLOAD_SIZE_GB=10 + - BACKEND_URL=http://backend_entwine:8000 + depends_on: + - backend_entwine + restart: unless-stopped From 4eb3347a069ee99b0d49b8daae1e0503263aea5f Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 21:36:22 +0200 Subject: [PATCH 18/25] Upload files to "/" --- Dockerfile.entwine | 26 ++++++++++++++++++++++++++ Dockerfile.frontend | 25 +++++++++++++++++++++++++ Roo.md | 21 +++++++++++++++++++++ pyproject.toml | 16 ++++++++++++++++ requirements.txt | 8 ++++++++ 5 files changed, 96 insertions(+) create mode 100644 Dockerfile.entwine create mode 100644 Dockerfile.frontend create mode 100644 Roo.md create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/Dockerfile.entwine b/Dockerfile.entwine new file mode 100644 index 0000000..be7b44e --- /dev/null +++ b/Dockerfile.entwine @@ -0,0 +1,26 @@ +FROM entwine:python3.12 + +RUN apt update && apt install -y --no-install-suggests --no-install-recommends build-essential libpng-dev libtiff-dev libz-dev libproj-dev liblzma-dev libjbig-dev libzstd-dev libgeotiff-dev libwebp-dev + +# installer uv +RUN pip install uv + +# Do you need open3d ? + +# copier dépendances +COPY pyproject.toml requirements.txt ./ + +RUN uv add -r requirements.txt + +RUN apt remove -y build-essential \ + && apt autoremove -y \ + && rm -rf /usr/local/src/* /var/lib/apt/lists/* /tmp/* /var/tmp/* + +# copier code +COPY backend ./backend + +WORKDIR /app/backend + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..b171605 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt update && apt install libjpeg-dev zlib1g-dev build-essential -y + +RUN pip install uv + +COPY pyproject.toml requirements.txt ./ + +RUN uv add -r requirements.txt + +COPY frontend ./frontend + +WORKDIR /app/frontend + +EXPOSE 8080 + +#ENV STREAMLIT_SERVER_MAX_UPLOAD_SIZE=3000 + +# Variable d'environnement pour l'URL du backend +# Peut être définie via docker-compose.yml +ENV BACKEND_URL="http://backend_entwine:8090" + +CMD ["uv", "run", "uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file 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/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 From e031b8583dff370baa08f7b96dc7b63c52f69fb6 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 23:24:06 +0200 Subject: [PATCH 19/25] Upload files to "frontend/templates/partials" From c9685c4ce2a2fa28ca86c8572e531bff00b3d7e8 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 23:25:13 +0200 Subject: [PATCH 20/25] Upload files to "frontend/routes" --- frontend/routes/crop.py | 52 ++++++++--------------------------------- 1 file changed, 10 insertions(+), 42 deletions(-) diff --git a/frontend/routes/crop.py b/frontend/routes/crop.py index d52651c..3c225da 100644 --- a/frontend/routes/crop.py +++ b/frontend/routes/crop.py @@ -1,6 +1,7 @@ -from fastapi import APIRouter, Request, HTTPException +# routes/crop.py + +from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -import api_client import config router = APIRouter() @@ -8,44 +9,11 @@ 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}" + """ + Retourne le partial crop_section.html pour injection dans #crop-panel. + Appelé par le bouton ✂️ dans cloud_list_body.html. + """ 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 + "partials/crop_section.html", + {"request": request, "pc_id": pc_id}, + ) \ No newline at end of file From c4b3c4171820013e8fbc5f6f052d4b2f7c9d68d6 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 23:26:17 +0200 Subject: [PATCH 21/25] Upload files to "frontend/templates/partials" --- frontend/templates/partials/cloud_list.html | 63 ++++--- .../templates/partials/cloud_list_body.html | 61 +++++++ frontend/templates/partials/crop_section.html | 157 ++++++++++++++++++ frontend/templates/partials/upload_form.html | 71 ++++++++ 4 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 frontend/templates/partials/cloud_list_body.html create mode 100644 frontend/templates/partials/crop_section.html create mode 100644 frontend/templates/partials/upload_form.html diff --git a/frontend/templates/partials/cloud_list.html b/frontend/templates/partials/cloud_list.html index facbc7c..eae5342 100644 --- a/frontend/templates/partials/cloud_list.html +++ b/frontend/templates/partials/cloud_list.html @@ -6,22 +6,26 @@ type="button" class="btn btn-ghost" hx-get="/viewer/list" - hx-target="#viewer-panel" - hx-trigger="click" + hx-target="#cloud-list-body" + hx-swap="innerHTML" + hx-indicator="#cloud-list-spinner" > 🔄 Actualiser + - {% if error %} + +
+ {% if error %}
{{ error }}
- {% elif not pointclouds %} + {% elif not pointclouds %}

Aucun nuage disponible sur le serveur.

- {% else %} + {% else %}

{{ pointclouds|length }} nuage(s)

@@ -36,25 +40,42 @@ {% for pc in pointclouds %} - - - - - - - + + + + + + + {% endfor %}
{{ pc.id }}{{ pc.size_mb }} MB{{ pc.file_count }}{{ pc.created|datetimeformat }} - 🔍 - 👁️ - -
{{ pc.id }}{{ pc.size_mb }} MB{{ pc.file_count }}{{ pc.created|datetimeformat }} + 🔍 + 👁️ + + + +
- {% endif %} + {% endif %} +
+ + + +
\ No newline at end of file diff --git a/frontend/templates/partials/cloud_list_body.html b/frontend/templates/partials/cloud_list_body.html new file mode 100644 index 0000000..4e55c85 --- /dev/null +++ b/frontend/templates/partials/cloud_list_body.html @@ -0,0 +1,61 @@ +{% 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 %} \ No newline at end of file diff --git a/frontend/templates/partials/crop_section.html b/frontend/templates/partials/crop_section.html new file mode 100644 index 0000000..20ca5df --- /dev/null +++ b/frontend/templates/partials/crop_section.html @@ -0,0 +1,157 @@ + +
+
+ + +
+

+ ✂️ Crop du nuage + {{ pc_id }} +

+ +
+ +

+ Renseignez les coordonnées de la boîte 3D à découper. Les valeurs sont dans + le système de coordonnées du nuage de points (en mètres ou en unités du fichier source). +

+ + +
+
+
Vue de dessus (XY)
+ + + + X + Y + +
+
+
Vue de côté (XZ)
+ + + + X + Z + +
+
+ + +
+ +
+ + +
+
Axe X
+
+
+ + +
+
+ + +
+
+
+ + +
+
Axe Y
+
+
+ + +
+
+ + +
+
+
+ + +
+
Axe Z (altitude)
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ Dimensions : + ΔX= + | + ΔY= + | + ΔZ= +
+ +
+ +
+ Traitement PDAL — peut prendre plusieurs minutes +
+ +
+ +
+
+
+ + \ No newline at end of file diff --git a/frontend/templates/partials/upload_form.html b/frontend/templates/partials/upload_form.html new file mode 100644 index 0000000..700ee0e --- /dev/null +++ b/frontend/templates/partials/upload_form.html @@ -0,0 +1,71 @@ + + + +
+ + +
+ +
+ {% 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" %} +
+
+ +
\ No newline at end of file From ff2413df86e4e05c9b16feb29f825bd3ae3b1794 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 23:27:04 +0200 Subject: [PATCH 22/25] Upload files to "frontend/routes" --- frontend/routes/admin.py | 105 ++++++++++++++++++++++++-------------- frontend/routes/viewer.py | 31 ++++------- 2 files changed, 78 insertions(+), 58 deletions(-) diff --git a/frontend/routes/admin.py b/frontend/routes/admin.py index c54f04c..0fc7631 100644 --- a/frontend/routes/admin.py +++ b/frontend/routes/admin.py @@ -25,47 +25,54 @@ async def save_backend_config(request: Request): 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) +@router.get("/admin/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() + """ + Liste via l'api_client (tab Admin). + Retourne cloud_list_body.html pour injection dans #cloud-list-body. + """ + try: + pointclouds = await api_client.list_pointclouds() + except Exception as e: + return request.app.state.templates.TemplateResponse( + "partials/cloud_list_body.html", + {"request": request, "pointclouds": [], "error": str(e)}, + ) return request.app.state.templates.TemplateResponse( - "partials/cloud_list.html", + "partials/cloud_list_body.html", {"request": request, "pointclouds": pointclouds}, ) -@router.get("/debug/{pc_id}", response_class=HTMLResponse) +@router.get("/admin/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) @@ -75,40 +82,62 @@ async def admin_debug(request: Request, pc_id: str): ) -@router.delete("/delete/{pc_id}", response_class=HTMLResponse) +@router.delete("/admin/delete/{pc_id}", response_class=HTMLResponse) async def admin_delete(request: Request, pc_id: str): - """Supprime un nuage de points""" + """Supprime un nuage de points et rafraîchit la liste""" 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}, - ) + await api_client.delete_pointcloud(pc_id) except Exception as e: return request.app.state.templates.TemplateResponse( - "partials/upload_result.html", - {"request": request, "error": str(e), "result": None}, + "partials/cloud_list_body.html", + {"request": request, "pointclouds": [], "error": f"Erreur suppression : {str(e)}"}, ) + # Après suppression, on retourne la liste mise à jour + from pathlib import Path as _Path + pointclouds = [] + 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(): + total_size = sum(f.stat().st_size for f in item.rglob("*") if f.is_file()) + file_count = sum(1 for f in item.rglob("*") if f.is_file()) + if file_count > 0: + pointclouds.append({ + "id": item.name, + "size_mb": round(total_size / (1024 * 1024), 2), + "file_count": file_count, + "manifest": {}, + "created": item.stat().st_ctime, + }) + + return request.app.state.templates.TemplateResponse( + "partials/cloud_list_body.html", + {"request": request, "pointclouds": pointclouds}, + ) + + +@router.post("/admin/crop/{pc_id}", response_class=HTMLResponse) +async def admin_crop(request: Request, pc_id: str): + """Crop via api_client — lit le body form (appelé depuis crop_section.html)""" + form_data = await request.form() + try: + box = { + "minX": float(form_data.get("minX", 0)), + "minY": float(form_data.get("minY", 0)), + "minZ": float(form_data.get("minZ", 0)), + "maxX": float(form_data.get("maxX", 0)), + "maxY": float(form_data.get("maxY", 0)), + "maxZ": float(form_data.get("maxZ", 0)), + } + except (TypeError, ValueError) as e: + return request.app.state.templates.TemplateResponse( + "partials/upload_result.html", + {"request": request, "error": f"Coordonnées invalides : {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", diff --git a/frontend/routes/viewer.py b/frontend/routes/viewer.py index dba7780..6f9e8d7 100644 --- a/frontend/routes/viewer.py +++ b/frontend/routes/viewer.py @@ -3,8 +3,6 @@ from fastapi.responses import HTMLResponse import config import api_client from pathlib import Path -import shutil -import os from datetime import datetime router = APIRouter() @@ -12,42 +10,35 @@ router = APIRouter() @router.get("/viewer/list", response_class=HTMLResponse) async def viewer_list(request: Request): - """Liste les nuages de points disponibles - Endpoint autonome frontend""" + """ + Liste les nuages de points — retourne uniquement le contenu intérieur + (cloud_list_body.html) pour injection dans #cloud-list-body. + La card enveloppe et le bouton Actualiser vivent dans index.html, pas ici. + """ 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, + "manifest": {}, "created": item.stat().st_ctime, }) - + return request.app.state.templates.TemplateResponse( - "partials/cloud_list.html", + "partials/cloud_list_body.html", {"request": request, "pointclouds": pointclouds}, ) @@ -58,4 +49,4 @@ async def viewer(request: Request, pc_id: str): return request.app.state.templates.TemplateResponse( "partials/viewer.html", {"request": request, "pc_id": pc_id, "embed_url": embed_url}, - ) + ) \ No newline at end of file From 731b67e07a2c2b5c214fca02cc2fd4befc558cf9 Mon Sep 17 00:00:00 2001 From: Thierry Date: Wed, 1 Apr 2026 23:27:38 +0200 Subject: [PATCH 23/25] Upload files to "frontend/templates" --- frontend/templates/index.html | 213 +++++++++++++++++++--------------- 1 file changed, 120 insertions(+), 93 deletions(-) diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 20671cb..2525998 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -10,110 +10,137 @@ - -