Compare commits
No commits in common. "main" and "htmx" have entirely different histories.
12 changed files with 370 additions and 554 deletions
75
.continue/agents/new-config.yaml
Normal file
75
.continue/agents/new-config.yaml
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
name: Local Qwen Setup
|
||||
|
||||
models:
|
||||
- name: Qwen3.5 Chat
|
||||
provider: openai
|
||||
model: qwen
|
||||
apiBase: http://localhost:8000/v1
|
||||
apiKey: none
|
||||
roles:
|
||||
- chat
|
||||
- edit
|
||||
- apply
|
||||
defaultCompletionOptions:
|
||||
temperature: 0.1
|
||||
top_p: 0.9
|
||||
max_tokens: 1024
|
||||
stop:
|
||||
- "</s>"
|
||||
|
||||
- name: Qwen3.5 Autocomplete
|
||||
provider: openai
|
||||
model: qwen
|
||||
apiBase: http://localhost:8000/v1
|
||||
apiKey: none
|
||||
roles:
|
||||
- autocomplete
|
||||
defaultCompletionOptions:
|
||||
temperature: 0.05
|
||||
max_tokens: 256
|
||||
|
||||
context:
|
||||
- provider: code
|
||||
- provider: docs
|
||||
- provider: diff
|
||||
- provider: terminal
|
||||
|
||||
slashCommands:
|
||||
- name: fix
|
||||
description: Corriger un bug de manière minimale
|
||||
prompt: |
|
||||
Corrige uniquement le problème identifié.
|
||||
Contraintes:
|
||||
- modification minimale
|
||||
- pas de refactor global
|
||||
- conserve l'architecture existante
|
||||
|
||||
- name: test
|
||||
description: Générer un test pytest
|
||||
prompt: |
|
||||
Écris un test pytest minimal qui reproduit ce bug.
|
||||
Ne corrige pas le code.
|
||||
|
||||
- name: explain
|
||||
description: Expliquer du code
|
||||
prompt: |
|
||||
Explique ce code de manière concise et technique.
|
||||
|
||||
- name: improve
|
||||
description: Amélioration contrôlée
|
||||
prompt: |
|
||||
Propose une amélioration ciblée.
|
||||
Ne fais pas de refactor global.
|
||||
|
||||
systemMessage: |
|
||||
Tu es un expert en développement logiciel.
|
||||
Spécialités:
|
||||
- Python / FastAPI
|
||||
- SIG / géospatial
|
||||
- traitement de nuages de points (PDAL, Potree)
|
||||
|
||||
Règles strictes:
|
||||
- ne jamais refactoriser massivement
|
||||
- toujours proposer des modifications minimales
|
||||
- privilégier des solutions robustes et simples
|
||||
- si incertain, poser une question au lieu d'inventer
|
||||
77
.gitignore
vendored
Normal file
77
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Docker
|
||||
Dockerfile.builder
|
||||
Dockerfile.entwine
|
||||
Dockerfile.frontend
|
||||
docker-compose.yml
|
||||
|
||||
# Répertoires Potree
|
||||
backend/static/potree/
|
||||
frontend/static/potree/
|
||||
|
||||
# Données LiDAR (fichiers volumineux)
|
||||
backend/data/ept/*/ept-data/*.laz
|
||||
backend/data/ept/*/ept-data/*.las
|
||||
backend/data/uploads/*.laz
|
||||
backend/data/uploads/*.las
|
||||
backend/data/uploads/delete.txt
|
||||
|
||||
# Fichiers de données temporaires
|
||||
backend/data/ept/*/*.json
|
||||
backend/data/ept/*/*/*.json
|
||||
backend/data/ept/*/*/*/*.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Fichiers temporaires
|
||||
*.tmp
|
||||
*.temp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Fichiers de configuration locale
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Fichiers générés
|
||||
*.pkl
|
||||
*.h5
|
||||
*.npz
|
||||
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
"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},
|
||||
)
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,137 +10,110 @@
|
|||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-base-200 min-h-screen">
|
||||
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||
</div>
|
||||
<div class="flex-none gap-4 items-center">
|
||||
<div
|
||||
id="health-indicator"
|
||||
hx-get="/health-check"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm text-base-content/50"
|
||||
>vérification…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||
hx-get="/upload"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/"
|
||||
>📤 Upload</a>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/viewer"
|
||||
>🗂️ Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Colonne gauche : upload -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-4">
|
||||
<!-- ✅ FIX 404 : la route est /backend-config (pas /admin/backend-config) -->
|
||||
<div id="backend-config-panel"
|
||||
hx-get="/backend-config"
|
||||
hx-target="#backend-config-panel"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="load">
|
||||
{% include "partials/backend_config.html" %}
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">📤 Upload</h2>
|
||||
<p class="text-sm text-base-content/60 mb-3">
|
||||
Formats acceptés : LAS, LAZ, PLY, XYZ, PTS
|
||||
</p>
|
||||
<form
|
||||
hx-post="/upload"
|
||||
hx-target="#upload-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".las,.laz,.ply,.xyz,.pts"
|
||||
class="file-input file-input-bordered w-full mb-4"
|
||||
required
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="btn">
|
||||
📤 Uploader & convertir
|
||||
</button>
|
||||
<div id="upload-spinner" class="loading loading-spinner loading-sm htmx-indicator"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-result"></div>
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 shadow-md px-6 mb-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-xl font-bold tracking-tight">☁️ PointCloud Viewer</span>
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite : viewer + liste + crop -->
|
||||
<div class="lg:col-span-2 flex flex-col gap-4">
|
||||
|
||||
<div class="flex-none gap-4 items-center">
|
||||
<div
|
||||
id="viewer-container"
|
||||
class="card bg-base-100 shadow min-h-[600px] flex items-center justify-center"
|
||||
>
|
||||
<p class="text-base-content/40 text-sm">
|
||||
Uploadez un fichier pour lancer la visualisation
|
||||
</p>
|
||||
id="health-indicator"
|
||||
hx-get="/health-check"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
class="text-sm text-base-content/50"
|
||||
>vérification…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tablist" class="tabs tabs-boxed mb-6 w-fit ml-4">
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'upload' %}tab-active{% endif %}"
|
||||
hx-get="/upload"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/"
|
||||
>📤 Upload</a>
|
||||
<a
|
||||
role="tab"
|
||||
class="tab {% if active_tab == 'admin' %}tab-active{% endif %}"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/viewer"
|
||||
>🗂️ Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="main-content" class="container mx-auto px-4 mt-6 max-w-7xl pb-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Colonne gauche : upload -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-4">
|
||||
<!-- Configuration Backend -->
|
||||
<div id="backend-config-panel" hx-get="/admin/backend-config" hx-target="#backend-config-panel" hx-swap="innerHTML">
|
||||
{% include "partials/backend_config.html" %}
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">📤 Upload</h2>
|
||||
<p class="text-sm text-base-content/60 mb-3">
|
||||
Formats acceptés : LAS, LAZ, PLY, XYZ, PTS
|
||||
</p>
|
||||
<form
|
||||
hx-post="/upload"
|
||||
hx-target="#upload-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".las,.laz,.ply,.xyz,.pts"
|
||||
class="file-input file-input-bordered w-full mb-4"
|
||||
required
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
hx_indicator="#upload-spinner"
|
||||
>
|
||||
📤 Uploader & convertir
|
||||
</button>
|
||||
<div id="upload-spinner" class="loading loading-spinner loading-sm"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ FIX DUPLICATION : viewer-panel charge au démarrage dans #cloud-list-body,
|
||||
pas dans #viewer-panel lui-même (évite de réécrire la card entière) -->
|
||||
<div id="viewer-panel" class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">🗂️ Nuages de points</h2>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#cloud-list-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#cloud-list-spinner"
|
||||
>
|
||||
🔄 Actualiser
|
||||
</button>
|
||||
<span id="cloud-list-spinner" class="loading loading-spinner loading-sm htmx-indicator"></span>
|
||||
</div>
|
||||
<!-- ✅ Seul ce div est rechargé par HTMX -->
|
||||
<div
|
||||
id="cloud-list-body"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#cloud-list-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="load"
|
||||
>
|
||||
{% include "partials/cloud_list_body.html" %}
|
||||
</div>
|
||||
<!-- Colonne droite : viewer -->
|
||||
<div class="lg:col-span-2">
|
||||
<div
|
||||
id="viewer-container"
|
||||
class="card bg-base-100 shadow min-h-[600px] flex items-center justify-center"
|
||||
>
|
||||
<p class="text-base-content/40 text-sm">
|
||||
Uploadez un fichier pour lancer la visualisation
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="viewer-panel"
|
||||
class="card bg-base-100 shadow mt-4"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#viewer-panel"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="load"
|
||||
>
|
||||
{% include "partials/cloud_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ CROP PANEL : en dehors de viewer-panel, jamais écrasé par l'actualisation -->
|
||||
<div id="crop-panel"></div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,26 +6,22 @@
|
|||
type="button"
|
||||
class="btn btn-ghost"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#cloud-list-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#cloud-list-spinner"
|
||||
hx-target="#viewer-panel"
|
||||
hx-trigger="click"
|
||||
>
|
||||
🔄 Actualiser
|
||||
</button>
|
||||
<span id="cloud-list-spinner" class="loading loading-spinner loading-sm htmx-indicator"></span>
|
||||
</div>
|
||||
|
||||
<!-- ✅ CORRECTION BUG 1 : cible séparée du bouton -->
|
||||
<div id="cloud-list-body">
|
||||
{% if error %}
|
||||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% elif not pointclouds %}
|
||||
{% elif not pointclouds %}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">
|
||||
Aucun nuage disponible sur le serveur.
|
||||
</p>
|
||||
{% else %}
|
||||
{% else %}
|
||||
<p class="text-xs text-base-content/40 mb-3">{{ pointclouds|length }} nuage(s)</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
|
|
@ -40,42 +36,25 @@
|
|||
</thead>
|
||||
<tbody id="cloud-table-body">
|
||||
{% for pc in pointclouds %}
|
||||
<tr>
|
||||
<td class="font-mono text-xs">{{ pc.id }}</td>
|
||||
<td>{{ pc.size_mb }} MB</td>
|
||||
<td>{{ pc.file_count }}</td>
|
||||
<td>{{ pc.created|datetimeformat }}</td>
|
||||
<td class="flex gap-1 flex-wrap">
|
||||
<a href="/admin/debug/{{ pc.id }}" class="btn btn-sm btn-ghost">🔍</a>
|
||||
<a href="/viewer/{{ pc.id }}" class="btn btn-sm" target="_blank">👁️</a>
|
||||
<!-- ✅ NOUVEAU : bouton Crop qui charge la section crop dans #crop-panel -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-warning"
|
||||
hx-get="/crop?pc_id={{ pc.id }}"
|
||||
hx-target="#crop-panel"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('crop-panel').scrollIntoView({behavior:'smooth'})"
|
||||
>✂️</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error"
|
||||
hx-delete="/admin/delete/{{ pc.id }}"
|
||||
hx-target="#cloud-list-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Supprimer le nuage {{ pc.id }} ?"
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ pc.id }}</td>
|
||||
<td>{{ pc.size_mb }} MB</td>
|
||||
<td>{{ pc.file_count }}</td>
|
||||
<td>{{ pc.created|datetimeformat }}</td>
|
||||
<td>
|
||||
<a href="/admin/debug/{{ pc.id }}" class="btn btn-sm btn-ghost">🔍</a>
|
||||
<a href="/viewer/{{ pc.id }}" class="btn btn-sm" target="_blank">👁️</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-error"
|
||||
onclick="if(confirm('Supprimer ce nuage ?')) window.location.href='/admin/delete/{{ pc.id }}'"
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ NOUVEAU : section crop injectée dynamiquement ici -->
|
||||
<div id="crop-panel" class="mt-4"></div>
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
{% elif not pointclouds %}
|
||||
<p class="text-base-content/40 text-sm text-center py-8">
|
||||
Aucun nuage disponible sur le serveur.
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
<p class="text-xs text-base-content/40 mb-3">{{ pointclouds|length }} nuage(s)</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Taille</th>
|
||||
<th>Fichiers</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pc in pointclouds %}
|
||||
<tr>
|
||||
<td class="font-mono text-xs max-w-[120px] truncate" title="{{ pc.id }}">{{ pc.id }}</td>
|
||||
<td>{{ pc.size_mb }} MB</td>
|
||||
<td>{{ pc.file_count }}</td>
|
||||
<td>{{ pc.created|datetimeformat }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<a href="/admin/debug/{{ pc.id }}" class="btn btn-xs btn-ghost" title="Debug">🔍</a>
|
||||
<a href="/viewer/{{ pc.id }}" class="btn btn-xs" target="_blank" title="Visualiser">👁️</a>
|
||||
<!-- ✅ Bouton crop : charge crop_section.html dans #crop-panel (hors de ce div) -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-warning"
|
||||
title="Cropper ce nuage"
|
||||
hx-get="/crop?pc_id={{ pc.id }}"
|
||||
hx-target="#crop-panel"
|
||||
hx-swap="innerHTML"
|
||||
hx-on:htmx:after-swap="document.getElementById('crop-panel').scrollIntoView({behavior:'smooth'})"
|
||||
>✂️ Crop</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error"
|
||||
title="Supprimer"
|
||||
hx-delete="/admin/delete/{{ pc.id }}"
|
||||
hx-target="#cloud-list-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Supprimer le nuage {{ pc.id }} ?"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
<!-- crop_section.html — injecté dans #crop-panel (hors de viewer-panel) -->
|
||||
<div class="card bg-base-100 shadow border border-warning/30" x-data="cropForm()">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="card-title text-base">
|
||||
✂️ Crop du nuage
|
||||
<span class="badge badge-warning font-mono text-xs">{{ pc_id }}</span>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick="document.getElementById('crop-panel').innerHTML = ''"
|
||||
>✕ Fermer</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-base-content/60 mb-4">
|
||||
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).
|
||||
</p>
|
||||
|
||||
<!-- Schéma visuel de la box -->
|
||||
<div class="bg-base-200 rounded-lg p-4 mb-4 flex items-center justify-center gap-8">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-base-content/50 mb-1">Vue de dessus (XY)</div>
|
||||
<svg width="110" height="75" viewBox="0 0 110 75">
|
||||
<ellipse cx="55" cy="37" rx="50" ry="30" fill="none" stroke="currentColor" stroke-opacity="0.15" stroke-width="1" stroke-dasharray="3 2"/>
|
||||
<rect x="18" y="14" width="74" height="46" rx="2" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 2"/>
|
||||
<text x="100" y="40" font-size="9" fill="currentColor" opacity="0.4">X</text>
|
||||
<text x="52" y="9" font-size="9" fill="currentColor" opacity="0.4">Y</text>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-base-content/50 mb-1">Vue de côté (XZ)</div>
|
||||
<svg width="110" height="75" viewBox="0 0 110 75">
|
||||
<ellipse cx="55" cy="37" rx="50" ry="25" fill="none" stroke="currentColor" stroke-opacity="0.15" stroke-width="1" stroke-dasharray="3 2"/>
|
||||
<rect x="18" y="16" width="74" height="42" rx="2" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4 2"/>
|
||||
<text x="100" y="40" font-size="9" fill="currentColor" opacity="0.4">X</text>
|
||||
<text x="52" y="9" font-size="9" fill="currentColor" opacity="0.4">Z</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<form
|
||||
hx-post="/admin/crop/{{ pc_id }}"
|
||||
hx-target="#crop-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#crop-spinner"
|
||||
>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
|
||||
<!-- X -->
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-2">Axe X</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label class="label py-0 pb-1"><span class="label-text text-xs">Min X</span></label>
|
||||
<input type="number" step="any" name="minX" x-model="box.minX"
|
||||
class="input input-bordered input-sm w-full font-mono" placeholder="ex: 100.0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label py-0 pb-1"><span class="label-text text-xs">Max X</span></label>
|
||||
<input type="number" step="any" name="maxX" x-model="box.maxX"
|
||||
class="input input-bordered input-sm w-full font-mono" placeholder="ex: 500.0" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Y -->
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-2">Axe Y</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label class="label py-0 pb-1"><span class="label-text text-xs">Min Y</span></label>
|
||||
<input type="number" step="any" name="minY" x-model="box.minY"
|
||||
class="input input-bordered input-sm w-full font-mono" placeholder="ex: 200.0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label py-0 pb-1"><span class="label-text text-xs">Max Y</span></label>
|
||||
<input type="number" step="any" name="maxY" x-model="box.maxY"
|
||||
class="input input-bordered input-sm w-full font-mono" placeholder="ex: 800.0" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Z -->
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wider mb-2">Axe Z (altitude)</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label class="label py-0 pb-1"><span class="label-text text-xs">Min Z</span></label>
|
||||
<input type="number" step="any" name="minZ" x-model="box.minZ"
|
||||
class="input input-bordered input-sm w-full font-mono" placeholder="ex: 10.0" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label py-0 pb-1"><span class="label-text text-xs">Max Z</span></label>
|
||||
<input type="number" step="any" name="maxZ" x-model="box.maxZ"
|
||||
class="input input-bordered input-sm w-full font-mono" placeholder="ex: 50.0" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Résumé dimensions en temps réel -->
|
||||
<div class="bg-base-200 rounded p-3 mb-4 text-xs font-mono" x-show="isValid()">
|
||||
<span class="text-base-content/60 mr-2">Dimensions :</span>
|
||||
<span class="text-warning">ΔX=<span x-text="delta('X')"></span></span>
|
||||
<span class="mx-2 text-base-content/30">|</span>
|
||||
<span class="text-warning">ΔY=<span x-text="delta('Y')"></span></span>
|
||||
<span class="mx-2 text-base-content/30">|</span>
|
||||
<span class="text-warning">ΔZ=<span x-text="delta('Z')"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-warning"
|
||||
:disabled="!isValid()"
|
||||
>
|
||||
✂️ Lancer le crop
|
||||
</button>
|
||||
<div id="crop-spinner" class="loading loading-spinner loading-sm htmx-indicator text-warning"></div>
|
||||
<span class="text-xs text-base-content/50">Traitement PDAL — peut prendre plusieurs minutes</span>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div id="crop-result" class="mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function cropForm() {
|
||||
return {
|
||||
box: { minX: '', minY: '', minZ: '', maxX: '', maxY: '', maxZ: '' },
|
||||
isValid() {
|
||||
const keys = ['minX', 'minY', 'minZ', 'maxX', 'maxY', 'maxZ'];
|
||||
if (keys.some(k => this.box[k] === '' || isNaN(parseFloat(this.box[k])))) return false;
|
||||
return (
|
||||
parseFloat(this.box.minX) < parseFloat(this.box.maxX) &&
|
||||
parseFloat(this.box.minY) < parseFloat(this.box.maxY) &&
|
||||
parseFloat(this.box.minZ) < parseFloat(this.box.maxZ)
|
||||
);
|
||||
},
|
||||
delta(axis) {
|
||||
const min = parseFloat(this.box['min' + axis]);
|
||||
const max = parseFloat(this.box['max' + axis]);
|
||||
if (isNaN(min) || isNaN(max) || min >= max) return '—';
|
||||
return (max - min).toFixed(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<!-- upload_form.html — partial HTMX uniquement (sans navbar ni layout) -->
|
||||
<!-- Injecté dans #main-content lors du clic sur le tab Upload -->
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Colonne gauche : upload -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-4">
|
||||
<!-- Configuration Backend -->
|
||||
<div id="backend-config-panel" hx-get="/admin/backend-config" hx-target="this" hx-swap="innerHTML" hx-trigger="load">
|
||||
{% include "partials/backend_config.html" %}
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base mb-2">📤 Upload</h2>
|
||||
<p class="text-sm text-base-content/60 mb-3">
|
||||
Formats acceptés : LAS, LAZ, PLY, XYZ, PTS
|
||||
</p>
|
||||
<form
|
||||
hx-post="/upload"
|
||||
hx-target="#upload-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-indicator="#upload-spinner"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
accept=".las,.laz,.ply,.xyz,.pts"
|
||||
class="file-input file-input-bordered w-full mb-4"
|
||||
required
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn"
|
||||
>
|
||||
📤 Uploader & convertir
|
||||
</button>
|
||||
<div id="upload-spinner" class="loading loading-spinner loading-sm htmx-indicator"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite : viewer -->
|
||||
<div class="lg:col-span-2">
|
||||
<div
|
||||
id="viewer-container"
|
||||
class="card bg-base-100 shadow min-h-[600px] flex items-center justify-center"
|
||||
>
|
||||
<p class="text-base-content/40 text-sm">
|
||||
Uploadez un fichier pour lancer la visualisation
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="viewer-panel"
|
||||
class="card bg-base-100 shadow mt-4"
|
||||
hx-get="/viewer/list"
|
||||
hx-target="#cloud-list-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="load"
|
||||
>
|
||||
{% include "partials/cloud_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue