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/backend/__init__.py b/backend/__init__.py index 06d7405..e69de29 100644 Binary files a/backend/__init__.py and b/backend/__init__.py differ diff --git a/frontend/routes/admin.py b/frontend/routes/admin.py index 0fc7631..c54f04c 100644 --- a/frontend/routes/admin.py +++ b/frontend/routes/admin.py @@ -25,54 +25,47 @@ 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("/admin/list", response_class=HTMLResponse) +@router.get("/list", response_class=HTMLResponse) async def admin_list(request: Request): - """ - 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)}, - ) + """Affiche la liste de tous les nuages de points""" + pointclouds = await api_client.list_pointclouds() return request.app.state.templates.TemplateResponse( - "partials/cloud_list_body.html", + "partials/cloud_list.html", {"request": request, "pointclouds": pointclouds}, ) -@router.get("/admin/debug/{pc_id}", response_class=HTMLResponse) +@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) @@ -82,62 +75,40 @@ async def admin_debug(request: Request, pc_id: str): ) -@router.delete("/admin/delete/{pc_id}", response_class=HTMLResponse) +@router.delete("/delete/{pc_id}", response_class=HTMLResponse) async def admin_delete(request: Request, pc_id: str): - """Supprime un nuage de points et rafraîchit la liste""" + """Supprime un nuage de points""" try: - await api_client.delete_pointcloud(pc_id) - except Exception as e: - return request.app.state.templates.TemplateResponse( - "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: + result = await api_client.delete_pointcloud(pc_id) return request.app.state.templates.TemplateResponse( "partials/upload_result.html", - {"request": request, "error": f"Coordonnées invalides : {str(e)}", "result": None}, + {"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", diff --git a/frontend/routes/crop.py b/frontend/routes/crop.py index 3c225da..d52651c 100644 --- a/frontend/routes/crop.py +++ b/frontend/routes/crop.py @@ -1,7 +1,6 @@ -# routes/crop.py - -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, HTTPException from fastapi.responses import HTMLResponse +import api_client import config router = APIRouter() @@ -9,11 +8,44 @@ router = APIRouter() @router.get("/crop", response_class=HTMLResponse) async def crop_ui(request: Request, pc_id: str): - """ - Retourne le partial crop_section.html pour injection dans #crop-panel. - Appelé par le bouton ✂️ dans cloud_list_body.html. - """ + """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_section.html", - {"request": request, "pc_id": pc_id}, - ) \ No newline at end of file + "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 index 040edc3..c0fbfbb 100644 --- a/frontend/routes/upload.py +++ b/frontend/routes/upload.py @@ -17,17 +17,6 @@ async def root(): @router.get("/upload", response_class=HTMLResponse) async def index(request: Request): - """ - ✅ CORRECTION BUG 2 : si la requête vient de HTMX (header HX-Request), - on retourne uniquement le partial upload_form.html (sans navbar, sans layout). - Si c'est une navigation directe dans le navigateur, on retourne index.html complet. - """ - is_htmx = request.headers.get("HX-Request") == "true" - if is_htmx: - return request.app.state.templates.TemplateResponse( - "partials/upload_form.html", - {"request": request}, - ) return request.app.state.templates.TemplateResponse( "index.html", {"request": request, "active_tab": "upload"}, diff --git a/frontend/routes/viewer.py b/frontend/routes/viewer.py index 6f9e8d7..dba7780 100644 --- a/frontend/routes/viewer.py +++ b/frontend/routes/viewer.py @@ -3,6 +3,8 @@ from fastapi.responses import HTMLResponse import config import api_client from pathlib import Path +import shutil +import os from datetime import datetime router = APIRouter() @@ -10,35 +12,42 @@ router = APIRouter() @router.get("/viewer/list", response_class=HTMLResponse) async def viewer_list(request: Request): - """ - 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. - """ + """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": manifest, "created": item.stat().st_ctime, }) - + return request.app.state.templates.TemplateResponse( - "partials/cloud_list_body.html", + "partials/cloud_list.html", {"request": request, "pointclouds": pointclouds}, ) @@ -49,4 +58,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 + ) diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 2525998..20671cb 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -10,137 +10,110 @@ - - - - -
- 📤 Upload - 🗂️ Admin -
- -
-
- - -
- -
- {% include "partials/backend_config.html" %} -
- -
-
-

📤 Upload

-

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

-
- -
- -
-
-
-
-
- -
+ +
- - -
\ No newline at end of file diff --git a/frontend/templates/partials/cloud_list_body.html b/frontend/templates/partials/cloud_list_body.html deleted file mode 100644 index 4e55c85..0000000 --- a/frontend/templates/partials/cloud_list_body.html +++ /dev/null @@ -1,61 +0,0 @@ -{% 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 deleted file mode 100644 index 20ca5df..0000000 --- a/frontend/templates/partials/crop_section.html +++ /dev/null @@ -1,157 +0,0 @@ - -
-
- - -
-

- ✂️ 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 deleted file mode 100644 index 700ee0e..0000000 --- a/frontend/templates/partials/upload_form.html +++ /dev/null @@ -1,71 +0,0 @@ - - - -
- - -
- -
- {% 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