feat: implement 3D IFC model viewer with Web-IFC and Three.js integration
This commit is contained in:
parent
ee6964eb5b
commit
91283f866b
28 changed files with 155564 additions and 31 deletions
|
|
@ -71,7 +71,7 @@ COPY streetup/maps/ /app/maps/
|
||||||
COPY streetup/notifications/ /app/notifications/
|
COPY streetup/notifications/ /app/notifications/
|
||||||
COPY streetup/projects/ /app/projects/
|
COPY streetup/projects/ /app/projects/
|
||||||
COPY streetup/reporting/ /app/reporting/
|
COPY streetup/reporting/ /app/reporting/
|
||||||
COPY streetup/schools/ /app/schools/
|
COPY streetup/bim/ /app/bim/
|
||||||
COPY streetup/sign/ /app/sign/
|
COPY streetup/sign/ /app/sign/
|
||||||
COPY streetup/stock/ /app/stock/
|
COPY streetup/stock/ /app/stock/
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgis/postgis:18-3.6
|
image: postgis/postgis:18-3.6
|
||||||
container_name: streetup_db
|
container_name: streetup_core_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5434:5432" # Mappé sur le port 5434 de l'hôte pour éviter tout conflit avec PostgreSQL local
|
- "5436:5432" # Mappé sur le port 5436 de l'hôte (5434=streetup-bm, 5435=valogeo)
|
||||||
env_file:
|
env_file:
|
||||||
- ./streetup/.env
|
- ./streetup/.env
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DB_NAME:-streetup}
|
POSTGRES_DB: ${DB_NAME:-streetup}
|
||||||
POSTGRES_USER: ${DB_USER:-admin}
|
POSTGRES_USER: ${DB_USER:-admin}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:?La variable DB_PASSWORD est obligatoire dans le fichier .env}
|
||||||
volumes:
|
volumes:
|
||||||
- streetup_db_data:/var/lib/postgresql
|
- streetup_core_db_data:/var/lib/postgresql
|
||||||
- ./streetup/scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
- ./streetup/scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "pg_isready -U admin -d streetup" ]
|
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER:-admin} -d ${DB_NAME:-streetup}" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
@ -27,15 +27,15 @@ services:
|
||||||
args:
|
args:
|
||||||
# Mettre "true" pour installer ultralytics, OpenCV et PyTorch dans l'image
|
# Mettre "true" pour installer ultralytics, OpenCV et PyTorch dans l'image
|
||||||
INSTALL_AI_LIBS: "false"
|
INSTALL_AI_LIBS: "false"
|
||||||
container_name: streetup_web
|
container_name: streetup_core_web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: python manage.py runserver 0.0.0.0:8000
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8003:8000" # Mappé sur le port 8003 de l'hôte (8000=streetup-bm, 8001=python dev, 8002=valogeo)
|
||||||
volumes:
|
volumes:
|
||||||
- ./streetup:/app # Volume mount pour le hot-reload local
|
- ./streetup:/app # Volume mount pour le hot-reload local
|
||||||
- streetup_media:/app/media
|
- streetup_core_media:/app/media
|
||||||
- streetup_private_media:/app/private_media
|
- streetup_core_private_media:/app/private_media
|
||||||
env_file:
|
env_file:
|
||||||
- ./streetup/.env # Chargement du fichier d'environnement local
|
- ./streetup/.env # Chargement du fichier d'environnement local
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -54,22 +54,20 @@ services:
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:latest
|
image: caddy:latest
|
||||||
container_name: streetup_caddy
|
container_name: streetup_core_caddy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8083:80" # Mappé sur le port 8083 de l'hôte (80 et 443 occupés par streetup_caddy)
|
||||||
- "443:443"
|
|
||||||
- "443:443/udp"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
- streetup_caddy_data:/data
|
- streetup_core_caddy_data:/data
|
||||||
- streetup_caddy_config:/config
|
- streetup_core_caddy_config:/config
|
||||||
depends_on:
|
depends_on:
|
||||||
- web
|
- web
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
streetup_db_data:
|
streetup_core_db_data:
|
||||||
streetup_media:
|
streetup_core_media:
|
||||||
streetup_private_media:
|
streetup_core_private_media:
|
||||||
streetup_caddy_data:
|
streetup_core_caddy_data:
|
||||||
streetup_caddy_config:
|
streetup_core_caddy_config:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from django.template.loader import select_template
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from common.models import UserConfig, Thematic
|
from common.models import UserConfig, Thematic
|
||||||
from common.utils import check_thematic_access
|
from common.utils import check_thematic_access
|
||||||
from assets.models import AssetCategory, ASSET_STATUS_CHOICES
|
from assets.models import AssetCategory, ASSET_STATUS_CHOICES
|
||||||
|
|
@ -895,6 +896,7 @@ def get_user_specific_tables(user, accessible_thematics):
|
||||||
return specific_tables
|
return specific_tables
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def unified_assets_index(request):
|
def unified_assets_index(request):
|
||||||
"""Unified asset/location index page combining all accessible thematics."""
|
"""Unified asset/location index page combining all accessible thematics."""
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
|
|
@ -935,10 +937,17 @@ def unified_assets_index(request):
|
||||||
else:
|
else:
|
||||||
selected_locations_ids = draft_data.get('locations', []) or []
|
selected_locations_ids = draft_data.get('locations', []) or []
|
||||||
|
|
||||||
# Thématiques présélectionnées (GET ou fixées par le mode intervention)
|
# Thématiques présélectionnées (GET, intervention, thématique par défaut ou toutes les thématiques accessibles)
|
||||||
selected_thematics = request.GET.getlist('thematic') or (
|
if 'thematic' in request.GET:
|
||||||
[locked_thematic_code] if locked_thematic_code else []
|
selected_thematics = request.GET.getlist('thematic')
|
||||||
)
|
elif locked_thematic_code:
|
||||||
|
selected_thematics = [locked_thematic_code]
|
||||||
|
elif user_config and user_config.default_thematic and user_config.default_thematic in accessible_thematics:
|
||||||
|
selected_thematics = [user_config.default_thematic.code]
|
||||||
|
elif accessible_thematics:
|
||||||
|
selected_thematics = [t.code for t in accessible_thematics]
|
||||||
|
else:
|
||||||
|
selected_thematics = []
|
||||||
selected_categories = request.GET.getlist('category')
|
selected_categories = request.GET.getlist('category')
|
||||||
|
|
||||||
# Config JS par thématique (URLs GeoJSON, nom, icône)
|
# Config JS par thématique (URLs GeoJSON, nom, icône)
|
||||||
|
|
|
||||||
|
|
@ -197,8 +197,8 @@ def its_locations_geojson(request):
|
||||||
|
|
||||||
# Ne garder que les locations dont le centre de contrôle a un contrat actif lié à ces contrats
|
# Ne garder que les locations dont le centre de contrôle a un contrat actif lié à ces contrats
|
||||||
its_locations = its_locations.filter(
|
its_locations = its_locations.filter(
|
||||||
its_contracts__status='active', # ITSContract.status
|
its_locations_contracts__status='active', # ITSLocationContract.status
|
||||||
its_contracts__contract_id__in=accessible_contract_ids # FK contract
|
its_locations_contracts__contract_id__in=accessible_contract_ids # FK contract
|
||||||
).distinct()
|
).distinct()
|
||||||
else:
|
else:
|
||||||
# Non authentifié : ne rien exposer (option sûre)
|
# Non authentifié : ne rien exposer (option sûre)
|
||||||
|
|
|
||||||
1
streetup/bim/__init__.py
Normal file
1
streetup/bim/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""BIM (Building Information Modeling) application for StreetUp."""
|
||||||
8
streetup/bim/apps.py
Normal file
8
streetup/bim/apps.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class BimConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "bim"
|
||||||
|
verbose_name = _("BIM & IFC Viewer")
|
||||||
1
streetup/bim/management/__init__.py
Normal file
1
streetup/bim/management/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Management package for bim app
|
||||||
1
streetup/bim/management/commands/__init__.py
Normal file
1
streetup/bim/management/commands/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Commands package
|
||||||
82
streetup/bim/management/commands/create_sample_ifc.py
Normal file
82
streetup/bim/management/commands/create_sample_ifc.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import os
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files import File
|
||||||
|
|
||||||
|
from documents.models import ManagedDocument, DocumentVersion, DocumentShare, DocumentFolder
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Crée automatiquement un document IFC d'exemple dans la gestion documentaire et l'attache à un dossier partagé."
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
users = User.objects.filter(is_active=True)
|
||||||
|
if not users.exists():
|
||||||
|
self.stderr.write(self.style.ERROR("Aucun utilisateur actif disponible."))
|
||||||
|
return
|
||||||
|
|
||||||
|
creator = users.filter(is_superuser=True).first() or users.first()
|
||||||
|
|
||||||
|
samples_dir = os.path.join(os.path.dirname(__file__), "..", "..", "samples")
|
||||||
|
sample_file_path = os.path.join(samples_dir, "Duplex_Architecture.ifc")
|
||||||
|
|
||||||
|
if not os.path.exists(sample_file_path):
|
||||||
|
self.stderr.write(self.style.ERROR(f"Fichier d'exemple non trouvé à l'emplacement : {sample_file_path}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure a root folder "Maquettes 3D (BIM)" exists so the document is visible in folder navigation
|
||||||
|
folder, _ = DocumentFolder.objects.get_or_create(
|
||||||
|
slug="maquettes-3d-bim",
|
||||||
|
defaults={
|
||||||
|
"name": "Maquettes 3D (BIM)",
|
||||||
|
"created_by": creator,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Share folder with all active users
|
||||||
|
for user in users:
|
||||||
|
DocumentFolder.objects.filter(pk=folder.pk).update()
|
||||||
|
folder.shares.get_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={"permission": ManagedDocument.PERMISSION_VIEW}
|
||||||
|
)
|
||||||
|
|
||||||
|
title = "Duplex_Architecture"
|
||||||
|
doc, created = ManagedDocument.objects.get_or_create(
|
||||||
|
title=title,
|
||||||
|
defaults={
|
||||||
|
"description": "Exemple de maquette 3D au format IFC pour tests du viewer BIM (openBIM Duplex).",
|
||||||
|
"created_by": creator,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach folder to document
|
||||||
|
doc.folders.add(folder)
|
||||||
|
|
||||||
|
if not doc.current_version:
|
||||||
|
with open(sample_file_path, "rb") as f:
|
||||||
|
version = DocumentVersion.objects.create(
|
||||||
|
document=doc,
|
||||||
|
version_number=1,
|
||||||
|
file=File(f, name="Duplex_Architecture.ifc"),
|
||||||
|
uploaded_by=creator,
|
||||||
|
description="Version initiale d'exemple IFC"
|
||||||
|
)
|
||||||
|
doc.current_version = version
|
||||||
|
doc.save(update_fields=["current_version"])
|
||||||
|
|
||||||
|
# Share document with all active users so everyone can find it in Document Management
|
||||||
|
for user in users:
|
||||||
|
DocumentShare.objects.get_or_create(
|
||||||
|
document=doc,
|
||||||
|
user=user,
|
||||||
|
defaults={"permission": ManagedDocument.PERMISSION_VIEW}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Document d'exemple IFC disponible dans le dossier 'Maquettes 3D (BIM)' (UUID: {doc.uuid}) - Partagé avec {users.count()} utilisateurs."
|
||||||
|
)
|
||||||
|
)
|
||||||
38907
streetup/bim/samples/Duplex_Architecture.ifc
Normal file
38907
streetup/bim/samples/Duplex_Architecture.ifc
Normal file
File diff suppressed because it is too large
Load diff
47318
streetup/bim/samples/SampleHouse_IFC4.ifc
Normal file
47318
streetup/bim/samples/SampleHouse_IFC4.ifc
Normal file
File diff suppressed because one or more lines are too long
541
streetup/bim/static/bim/css/ifc_viewer.css
Normal file
541
streetup/bim/static/bim/css/ifc_viewer.css
Normal file
|
|
@ -0,0 +1,541 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
StreetUp BIM & IFC Viewer - Modern Dark Workstation Design
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bim-bg-dark: #0f172a;
|
||||||
|
--bim-bg-canvas: #1e293b;
|
||||||
|
--bim-panel-bg: rgba(15, 23, 42, 0.85);
|
||||||
|
--bim-panel-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--bim-accent-primary: #3b82f6;
|
||||||
|
--bim-accent-hover: #2563eb;
|
||||||
|
--bim-accent-light: #60a5fa;
|
||||||
|
--bim-highlight: #f59e0b;
|
||||||
|
--bim-text-main: #f8fafc;
|
||||||
|
--bim-text-muted: #94a3b8;
|
||||||
|
--bim-sidebar-width: 320px;
|
||||||
|
--bim-header-height: 56px;
|
||||||
|
--bim-toolbar-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.bim-viewer-body {
|
||||||
|
background-color: var(--bim-bg-dark);
|
||||||
|
color: var(--bim-text-main);
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App Wrapper */
|
||||||
|
.bim-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Navbar */
|
||||||
|
.bim-header {
|
||||||
|
height: var(--bim-header-height);
|
||||||
|
background: var(--bim-panel-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--bim-panel-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-header-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-header-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bim-text-main);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: var(--bim-accent-light);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset Camera Controls */
|
||||||
|
.bim-preset-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--bim-panel-border);
|
||||||
|
color: var(--bim-text-main);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-preset-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.25);
|
||||||
|
border-color: var(--bim-accent-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-preset-btn.active {
|
||||||
|
background: var(--bim-accent-primary);
|
||||||
|
border-color: var(--bim-accent-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Workspace */
|
||||||
|
.bim-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3D Canvas Container */
|
||||||
|
.bim-canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: radial-gradient(circle at center, #1e293b 0%, #0f172a 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#bim-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebars (Left Spatial Tree / Right Property Inspector) */
|
||||||
|
.bim-sidebar {
|
||||||
|
width: var(--bim-sidebar-width);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bim-panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-color: var(--bim-panel-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 50;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar-left {
|
||||||
|
border-right: 1px solid var(--bim-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar-right {
|
||||||
|
border-left: 1px solid var(--bim-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar.collapsed-left {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar.collapsed-right {
|
||||||
|
transform: translateX(100%);
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar-header {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--bim-panel-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
.bim-sidebar-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.bim-sidebar-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.bim-sidebar-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.bim-sidebar-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spatial Tree & Layer UI */
|
||||||
|
.bim-tree-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-item {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-node:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-toggle {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-checkbox {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
accent-color: var(--bim-accent-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-label {
|
||||||
|
flex: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tree-children {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Property Inspector UI */
|
||||||
|
.bim-prop-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--bim-panel-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-prop-group-title {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bim-accent-light);
|
||||||
|
border-bottom: 1px solid var(--bim-panel-border);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-prop-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-prop-table tr {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-prop-table tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-prop-key {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
width: 45%;
|
||||||
|
font-weight: 500;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-prop-val {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
color: var(--bim-text-main);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floating Bottom Toolbar */
|
||||||
|
.bim-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bim-panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--bim-panel-border);
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tool-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tool-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--bim-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tool-btn.active {
|
||||||
|
background: var(--bim-accent-primary);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 12px rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tool-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--bim-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
.bim-tool-btn[data-tooltip]::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 125%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #090d16;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
border: 1px solid var(--bim-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-tool-btn:hover[data-tooltip]::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Overlay */
|
||||||
|
.bim-loader-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-loader-overlay.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--bim-accent-primary);
|
||||||
|
animation: bim-spin 1s linear infinite;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bim-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-loader-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-loader-progress-bar {
|
||||||
|
width: 240px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-loader-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-loader-text {
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clipping Controls Popup */
|
||||||
|
.bim-clipping-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 5.25rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bim-panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--bim-panel-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 320px;
|
||||||
|
z-index: 85;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-clipping-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-slider-group {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-slider-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-slider-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bim-text-muted);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-slider {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--bim-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Measurement Banner & HTML Labels */
|
||||||
|
.bim-measure-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--bim-accent-primary);
|
||||||
|
color: var(--bim-text-main);
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 90;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-measure-banner.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bim-measure-badge {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid #60a5fa;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
979
streetup/bim/static/bim/js/ifc_viewer.js
Normal file
979
streetup/bim/static/bim/js/ifc_viewer.js
Normal file
|
|
@ -0,0 +1,979 @@
|
||||||
|
/**
|
||||||
|
* StreetUp BIM & IFC Viewer
|
||||||
|
* Powered by Three.js & Web-IFC (That Open Company Engine)
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Global App State
|
||||||
|
const state = {
|
||||||
|
config: window.BIM_CONFIG || {},
|
||||||
|
container: null,
|
||||||
|
canvas: null,
|
||||||
|
scene: null,
|
||||||
|
camera: null,
|
||||||
|
renderer: null,
|
||||||
|
controls: null,
|
||||||
|
raycaster: new THREE.Raycaster(),
|
||||||
|
mouse: new THREE.Vector2(),
|
||||||
|
|
||||||
|
// Web-IFC Engine instance
|
||||||
|
ifcApi: null,
|
||||||
|
modelID: 0,
|
||||||
|
ifcData: null,
|
||||||
|
|
||||||
|
// Meshes & Spatial Data
|
||||||
|
meshes: [], // All loaded 3D meshes: [{ expressID, typeName, storeyName, mesh, origMaterial }]
|
||||||
|
spatialTree: {}, // Storeys & Categories tree structure
|
||||||
|
expressIDToMesh: new Map(),
|
||||||
|
selectedMesh: null,
|
||||||
|
selectedOrigMat: null,
|
||||||
|
|
||||||
|
// Viewport bounds
|
||||||
|
boundingBox: new THREE.Box3(),
|
||||||
|
center: new THREE.Vector3(),
|
||||||
|
size: new THREE.Vector3(),
|
||||||
|
|
||||||
|
// Clipping Planes
|
||||||
|
clippingPlanes: {
|
||||||
|
z: new THREE.Plane(new THREE.Vector3(0, -1, 0), 1000),
|
||||||
|
x: new THREE.Plane(new THREE.Vector3(-1, 0, 0), 1000),
|
||||||
|
y: new THREE.Plane(new THREE.Vector3(0, 0, -1), 1000)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Modes
|
||||||
|
isWireframe: false,
|
||||||
|
isGhosting: false,
|
||||||
|
|
||||||
|
// 3D Measurement System State
|
||||||
|
isMeasuring: false,
|
||||||
|
measureStartPoint: null,
|
||||||
|
measureStartSphere: null,
|
||||||
|
measureTempLine: null,
|
||||||
|
measureGroup: new THREE.Group(),
|
||||||
|
measureBadges: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Material Palette for IFC Element Types
|
||||||
|
const IFC_MATERIALS = {
|
||||||
|
IFCWALL: new THREE.MeshStandardMaterial({ color: 0xe2e8f0, roughness: 0.5, metalness: 0.1, side: THREE.DoubleSide }),
|
||||||
|
IFCWALLSTANDARDCASE: new THREE.MeshStandardMaterial({ color: 0xe2e8f0, roughness: 0.5, metalness: 0.1, side: THREE.DoubleSide }),
|
||||||
|
IFCSLAB: new THREE.MeshStandardMaterial({ color: 0x94a3b8, roughness: 0.7, metalness: 0.1, side: THREE.DoubleSide }),
|
||||||
|
IFCWINDOW: new THREE.MeshStandardMaterial({ color: 0x38bdf8, roughness: 0.1, metalness: 0.8, transparent: true, opacity: 0.5, side: THREE.DoubleSide }),
|
||||||
|
IFCDOOR: new THREE.MeshStandardMaterial({ color: 0xb45309, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide }),
|
||||||
|
IFCCOLUMN: new THREE.MeshStandardMaterial({ color: 0x64748b, roughness: 0.4, metalness: 0.3, side: THREE.DoubleSide }),
|
||||||
|
IFCBEAM: new THREE.MeshStandardMaterial({ color: 0x475569, roughness: 0.4, metalness: 0.3, side: THREE.DoubleSide }),
|
||||||
|
IFCROOF: new THREE.MeshStandardMaterial({ color: 0x991b1b, roughness: 0.6, metalness: 0.1, side: THREE.DoubleSide }),
|
||||||
|
IFCPIPESEGMENT: new THREE.MeshStandardMaterial({ color: 0x0284c7, roughness: 0.2, metalness: 0.6, side: THREE.DoubleSide }),
|
||||||
|
IFCDUCTSEGMENT: new THREE.MeshStandardMaterial({ color: 0xd97706, roughness: 0.3, metalness: 0.5, side: THREE.DoubleSide }),
|
||||||
|
IFCFURNISHINGELEMENT: new THREE.MeshStandardMaterial({ color: 0xa855f7, roughness: 0.5, metalness: 0.2, side: THREE.DoubleSide }),
|
||||||
|
DEFAULT: new THREE.MeshStandardMaterial({ color: 0xc0c6d0, roughness: 0.5, metalness: 0.1, side: THREE.DoubleSide })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection Highlight Material
|
||||||
|
const HIGHLIGHT_MATERIAL = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0xf59e0b,
|
||||||
|
emissive: 0xd97706,
|
||||||
|
emissiveIntensity: 0.4,
|
||||||
|
roughness: 0.3,
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Application
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initThree();
|
||||||
|
if (window.WebIFC && window.WebIFC.IfcAPI) {
|
||||||
|
initWebIfc();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('web-ifc-loaded', () => {
|
||||||
|
initWebIfc();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindUIEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up Three.js Canvas, Camera, Lights, and OrbitControls
|
||||||
|
*/
|
||||||
|
function initThree() {
|
||||||
|
state.canvas = document.getElementById('bim-canvas');
|
||||||
|
state.container = state.canvas.parentElement;
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
state.scene = new THREE.Scene();
|
||||||
|
state.scene.background = new THREE.Color(0x0f172a);
|
||||||
|
|
||||||
|
// Add 3D Measures Group to Scene
|
||||||
|
state.measureGroup.name = "BIM_MEASURES";
|
||||||
|
state.scene.add(state.measureGroup);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
const aspect = state.container.clientWidth / state.container.clientHeight;
|
||||||
|
state.camera = new THREE.PerspectiveCamera(45, aspect, 0.1, 1000);
|
||||||
|
state.camera.position.set(20, 20, 20);
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
state.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas: state.canvas,
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: "high-performance"
|
||||||
|
});
|
||||||
|
state.renderer.setSize(state.container.clientWidth, state.container.clientHeight);
|
||||||
|
state.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
state.renderer.shadowMap.enabled = true;
|
||||||
|
state.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||||
|
state.renderer.localClippingEnabled = true;
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
state.controls = new THREE.OrbitControls(state.camera, state.renderer.domElement);
|
||||||
|
state.controls.enableDamping = true;
|
||||||
|
state.controls.dampingFactor = 0.05;
|
||||||
|
state.controls.screenSpacePanning = true;
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
|
||||||
|
state.scene.add(ambientLight);
|
||||||
|
|
||||||
|
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
dirLight1.position.set(50, 100, 50);
|
||||||
|
dirLight1.castShadow = true;
|
||||||
|
dirLight1.shadow.mapSize.width = 2048;
|
||||||
|
dirLight1.shadow.mapSize.height = 2048;
|
||||||
|
state.scene.add(dirLight1);
|
||||||
|
|
||||||
|
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.4);
|
||||||
|
dirLight2.position.set(-50, -50, -50);
|
||||||
|
state.scene.add(dirLight2);
|
||||||
|
|
||||||
|
// Grid & Axis Helpers
|
||||||
|
const gridHelper = new THREE.GridHelper(100, 50, 0x3b82f6, 0x334155);
|
||||||
|
gridHelper.position.y = -0.01;
|
||||||
|
state.scene.add(gridHelper);
|
||||||
|
|
||||||
|
// Resize Listener
|
||||||
|
window.addEventListener('resize', onWindowResize);
|
||||||
|
|
||||||
|
// Animation Loop
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
state.controls.update();
|
||||||
|
updateBadgePositions();
|
||||||
|
state.renderer.render(state.scene, state.camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Web-IFC Engine & Fetch File
|
||||||
|
*/
|
||||||
|
async function initWebIfc() {
|
||||||
|
const loaderStatus = document.getElementById('loader-status');
|
||||||
|
const loaderProgress = document.getElementById('loader-progress');
|
||||||
|
const loaderOverlay = document.getElementById('bim-loader');
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoader(10, "Initialisation du moteur WebAssembly (Web-IFC)...");
|
||||||
|
|
||||||
|
// Instancier l'API WebIFC
|
||||||
|
state.ifcApi = new WebIFC.IfcAPI();
|
||||||
|
state.ifcApi.SetWasmPath(state.config.wasmPath || "/static/bim/vendor/", true);
|
||||||
|
await state.ifcApi.Init();
|
||||||
|
|
||||||
|
updateLoader(25, "Téléchargement du fichier IFC...");
|
||||||
|
|
||||||
|
// Fetch IFC file
|
||||||
|
const response = await fetch(state.config.downloadUrl);
|
||||||
|
if (!response.ok) throw new Error("Impossible de télécharger le fichier IFC (HTTP " + response.status + ")");
|
||||||
|
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
const totalBytes = contentLength ? parseInt(contentLength, 10) : 0;
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
let receivedBytes = 0;
|
||||||
|
let chunks = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
chunks.push(value);
|
||||||
|
receivedBytes += value.length;
|
||||||
|
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
const percent = 25 + Math.round((receivedBytes / totalBytes) * 45);
|
||||||
|
updateLoader(percent, `Téléchargement: ${(receivedBytes / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine chunks
|
||||||
|
const rawData = new Uint8Array(receivedBytes);
|
||||||
|
let offset = 0;
|
||||||
|
for (let chunk of chunks) {
|
||||||
|
rawData.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLoader(75, "Analyse des géométries 3D & structure spatiale...");
|
||||||
|
|
||||||
|
// Open Model in WebIFC
|
||||||
|
state.modelID = state.ifcApi.OpenModel(rawData);
|
||||||
|
|
||||||
|
// Build Model Geometries & Scene
|
||||||
|
loadIfcGeometries();
|
||||||
|
|
||||||
|
// Build Spatial Tree UI
|
||||||
|
buildSpatialTreeUI();
|
||||||
|
|
||||||
|
// Calculate Model Bounding Box & Fit Camera
|
||||||
|
fitCameraToModel();
|
||||||
|
|
||||||
|
updateLoader(100, "Prêt !");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loaderOverlay) loaderOverlay.classList.add('hidden');
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erreur d'initialisation IFC:", err);
|
||||||
|
if (loaderStatus) loaderStatus.textContent = "Erreur : " + err.message;
|
||||||
|
if (loaderStatus) loaderStatus.style.color = "#ef4444";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLoader(percent, text) {
|
||||||
|
const fill = document.getElementById('loader-progress');
|
||||||
|
const status = document.getElementById('loader-status');
|
||||||
|
if (fill) fill.style.width = percent + '%';
|
||||||
|
if (status && text) status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load IFC Geometries from WebIFC and Create Three.js Meshes
|
||||||
|
*/
|
||||||
|
function loadIfcGeometries() {
|
||||||
|
const modelID = state.modelID;
|
||||||
|
const api = state.ifcApi;
|
||||||
|
|
||||||
|
// Group containing all model meshes
|
||||||
|
const modelGroup = new THREE.Group();
|
||||||
|
modelGroup.name = "IFC_MODEL";
|
||||||
|
|
||||||
|
// Stream all geometries from WebIFC
|
||||||
|
api.StreamAllMeshes(modelID, (flatMesh) => {
|
||||||
|
const expressID = flatMesh.expressID;
|
||||||
|
const placedGeometries = flatMesh.geometries;
|
||||||
|
|
||||||
|
// Fetch IFC Entity Type Name (e.g. IFCWALL, IFCSLAB)
|
||||||
|
let typeName = "IFCELEMENT";
|
||||||
|
try {
|
||||||
|
const typeID = api.GetLineType(modelID, expressID);
|
||||||
|
typeName = api.GetNameFromTypeCode(typeID) || "IFCELEMENT";
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Assign Material based on Type
|
||||||
|
const mat = (IFC_MATERIALS[typeName] || IFC_MATERIALS.DEFAULT).clone();
|
||||||
|
|
||||||
|
for (let i = 0; i < placedGeometries.size(); i++) {
|
||||||
|
const placedGeom = placedGeometries.get(i);
|
||||||
|
const geom = api.GetGeometry(modelID, placedGeom.geometryExpressID);
|
||||||
|
|
||||||
|
// Build Three.js BufferGeometry
|
||||||
|
const bufferGeom = buildBufferGeometry(api, geom);
|
||||||
|
if (!bufferGeom) continue;
|
||||||
|
|
||||||
|
// Matrix Transformation
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
matrix.fromArray(placedGeom.flatTransformation);
|
||||||
|
bufferGeom.applyMatrix4(matrix);
|
||||||
|
|
||||||
|
// Create Mesh
|
||||||
|
const mesh = new THREE.Mesh(bufferGeom, mat);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
mesh.userData = {
|
||||||
|
expressID: expressID,
|
||||||
|
typeName: typeName,
|
||||||
|
modelID: modelID
|
||||||
|
};
|
||||||
|
|
||||||
|
modelGroup.add(mesh);
|
||||||
|
|
||||||
|
// Track mesh
|
||||||
|
const meshRecord = {
|
||||||
|
expressID: expressID,
|
||||||
|
typeName: typeName,
|
||||||
|
mesh: mesh,
|
||||||
|
origMaterial: mat
|
||||||
|
};
|
||||||
|
state.meshes.push(meshRecord);
|
||||||
|
state.expressIDToMesh.set(expressID, meshRecord);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.scene.add(modelGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts WebIFC Geometry Data to Three.js BufferGeometry
|
||||||
|
*/
|
||||||
|
function buildBufferGeometry(api, geom) {
|
||||||
|
const vertexData = api.GetVertexArray(geom.GetVertexData(), geom.GetVertexDataSize());
|
||||||
|
const indexData = api.GetIndexArray(geom.GetIndexData(), geom.GetIndexDataSize());
|
||||||
|
|
||||||
|
if (!vertexData || vertexData.length === 0) return null;
|
||||||
|
|
||||||
|
const bufferGeom = new THREE.BufferGeometry();
|
||||||
|
|
||||||
|
// Web-IFC vertices layout: [px, py, pz, nx, ny, nz]
|
||||||
|
const numVertices = vertexData.length / 6;
|
||||||
|
const positions = new Float32Array(numVertices * 3);
|
||||||
|
const normals = new Float32Array(numVertices * 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < numVertices; i++) {
|
||||||
|
positions[i * 3] = vertexData[i * 6];
|
||||||
|
positions[i * 3 + 1] = vertexData[i * 6 + 1];
|
||||||
|
positions[i * 3 + 2] = vertexData[i * 6 + 2];
|
||||||
|
|
||||||
|
normals[i * 3] = vertexData[i * 6 + 3];
|
||||||
|
normals[i * 3 + 1] = vertexData[i * 6 + 4];
|
||||||
|
normals[i * 3 + 2] = vertexData[i * 6 + 5];
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
bufferGeom.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
|
||||||
|
|
||||||
|
if (indexData && indexData.length > 0) {
|
||||||
|
bufferGeom.setIndex(new THREE.BufferAttribute(indexData, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bufferGeom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Model Bounding Box and Frame Camera
|
||||||
|
*/
|
||||||
|
function fitCameraToModel() {
|
||||||
|
state.boundingBox.makeEmpty();
|
||||||
|
for (let rec of state.meshes) {
|
||||||
|
rec.mesh.geometry.computeBoundingBox();
|
||||||
|
state.boundingBox.expandByObject(rec.mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.boundingBox.isEmpty()) return;
|
||||||
|
|
||||||
|
state.boundingBox.getCenter(state.center);
|
||||||
|
state.boundingBox.getSize(state.size);
|
||||||
|
|
||||||
|
const maxDim = Math.max(state.size.x, state.size.y, state.size.z);
|
||||||
|
const fov = state.camera.fov * (Math.PI / 180);
|
||||||
|
let cameraDistance = Math.abs(maxDim / (2 * Math.tan(fov / 2))) * 1.5;
|
||||||
|
|
||||||
|
state.camera.position.set(
|
||||||
|
state.center.x + cameraDistance * 0.7,
|
||||||
|
state.center.y + cameraDistance * 0.6,
|
||||||
|
state.center.z + cameraDistance * 0.7
|
||||||
|
);
|
||||||
|
state.controls.target.copy(state.center);
|
||||||
|
state.controls.update();
|
||||||
|
|
||||||
|
// Configure Clipping Sliders Bounds
|
||||||
|
const sliderZ = document.getElementById('slider-clip-z');
|
||||||
|
const sliderX = document.getElementById('slider-clip-x');
|
||||||
|
const sliderY = document.getElementById('slider-clip-y');
|
||||||
|
|
||||||
|
if (sliderZ) { sliderZ.value = 100; updateClippingPlane('z', 100); }
|
||||||
|
if (sliderX) { sliderX.value = 100; updateClippingPlane('x', 100); }
|
||||||
|
if (sliderY) { sliderY.value = 100; updateClippingPlane('y', 100); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Spatial Tree & Category Layers UI (Left Sidebar)
|
||||||
|
*/
|
||||||
|
function buildSpatialTreeUI() {
|
||||||
|
const container = document.getElementById('spatial-tree-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Group meshes by IFC Category Type Name
|
||||||
|
const categoriesMap = new Map();
|
||||||
|
for (let rec of state.meshes) {
|
||||||
|
if (!categoriesMap.has(rec.typeName)) {
|
||||||
|
categoriesMap.set(rec.typeName, []);
|
||||||
|
}
|
||||||
|
categoriesMap.get(rec.typeName).push(rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul class="bim-tree-list">';
|
||||||
|
|
||||||
|
// Category Layers Section
|
||||||
|
html += `
|
||||||
|
<li class="bim-tree-item">
|
||||||
|
<div class="bim-tree-node fw-bold text-light">
|
||||||
|
<span class="bim-tree-toggle"><i class="bi bi-folder2-open"></i></span>
|
||||||
|
<span class="bim-tree-label">Catégories IFC (Calques)</span>
|
||||||
|
<span class="bim-tree-count">${categoriesMap.size}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="bim-tree-children">
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (let [typeName, meshList] of categoriesMap.entries()) {
|
||||||
|
const prettyName = formatTypeName(typeName);
|
||||||
|
html += `
|
||||||
|
<li class="bim-tree-item" data-category="${typeName}">
|
||||||
|
<div class="bim-tree-node">
|
||||||
|
<input type="checkbox" class="bim-tree-checkbox category-checkbox" data-category="${typeName}" checked>
|
||||||
|
<span class="bim-tree-label" title="${typeName}">${prettyName}</span>
|
||||||
|
<span class="bim-tree-count">${meshList.length}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</ul></li></ul>`;
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Bind Category Checkbox Listeners
|
||||||
|
container.querySelectorAll('.category-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => {
|
||||||
|
const catName = e.target.getAttribute('data-category');
|
||||||
|
const isVisible = e.target.checked;
|
||||||
|
|
||||||
|
for (let rec of state.meshes) {
|
||||||
|
if (rec.typeName === catName) {
|
||||||
|
rec.mesh.visible = isVisible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search Filter
|
||||||
|
const searchInput = document.getElementById('tree-search-input');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
const query = e.target.value.toLowerCase().trim();
|
||||||
|
container.querySelectorAll('li[data-category]').forEach(li => {
|
||||||
|
const text = li.textContent.toLowerCase();
|
||||||
|
if (!query || text.includes(query)) {
|
||||||
|
li.style.display = '';
|
||||||
|
} else {
|
||||||
|
li.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTypeName(typeName) {
|
||||||
|
const mapping = {
|
||||||
|
IFCWALL: "Murs (IfcWall)",
|
||||||
|
IFCWALLSTANDARDCASE: "Murs Standard (IfcWall)",
|
||||||
|
IFCSLAB: "Dalles / Planchers (IfcSlab)",
|
||||||
|
IFCWINDOW: "Fenêtres (IfcWindow)",
|
||||||
|
IFCDOOR: "Portes (IfcDoor)",
|
||||||
|
IFCCOLUMN: "Poteaux (IfcColumn)",
|
||||||
|
IFCBEAM: "Poutres (IfcBeam)",
|
||||||
|
IFCROOF: "Toitures (IfcRoof)",
|
||||||
|
IFCPIPESEGMENT: "Tuyauterie (IfcPipeSegment)",
|
||||||
|
IFCDUCTSEGMENT: "Gaines de ventilation",
|
||||||
|
IFCFURNISHINGELEMENT: "Mobilier / Équipement"
|
||||||
|
};
|
||||||
|
return mapping[typeName] || typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raycasting & Selection Handler
|
||||||
|
*/
|
||||||
|
function onCanvasClick(event) {
|
||||||
|
// Prevent action if clicked outside 3D canvas
|
||||||
|
if (event.target.id !== 'bim-canvas') return;
|
||||||
|
|
||||||
|
const rect = state.canvas.getBoundingClientRect();
|
||||||
|
state.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
state.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
state.raycaster.setFromCamera(state.mouse, state.camera);
|
||||||
|
|
||||||
|
// Filter visible meshes only
|
||||||
|
const visibleMeshes = state.meshes.filter(r => r.mesh.visible).map(r => r.mesh);
|
||||||
|
const intersects = state.raycaster.intersectObjects(visibleMeshes, false);
|
||||||
|
|
||||||
|
// 3D Measurement Mode
|
||||||
|
if (state.isMeasuring) {
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const hitPoint = intersects[0].point.clone();
|
||||||
|
handleMeasurementClick(hitPoint);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Object Selection
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const hitMesh = intersects[0].object;
|
||||||
|
selectMesh(hitMesh);
|
||||||
|
} else {
|
||||||
|
deselectMesh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic Preview Line & Cursor update on Mouse Move (Measurement Mode)
|
||||||
|
*/
|
||||||
|
function onCanvasMouseMove(event) {
|
||||||
|
if (!state.isMeasuring || !state.measureStartPoint) return;
|
||||||
|
if (event.target.id !== 'bim-canvas') return;
|
||||||
|
|
||||||
|
const rect = state.canvas.getBoundingClientRect();
|
||||||
|
state.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
state.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
state.raycaster.setFromCamera(state.mouse, state.camera);
|
||||||
|
const visibleMeshes = state.meshes.filter(r => r.mesh.visible).map(r => r.mesh);
|
||||||
|
const intersects = state.raycaster.intersectObjects(visibleMeshes, false);
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const tempPoint = intersects[0].point;
|
||||||
|
const distance = state.measureStartPoint.distanceTo(tempPoint);
|
||||||
|
|
||||||
|
// Update preview line
|
||||||
|
if (state.measureTempLine) {
|
||||||
|
state.scene.remove(state.measureTempLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineGeom = new THREE.BufferGeometry().setFromPoints([state.measureStartPoint, tempPoint]);
|
||||||
|
const lineMat = new THREE.LineDashedMaterial({ color: 0x3b82f6, dashSize: 0.2, gapSize: 0.1, depthTest: false });
|
||||||
|
state.measureTempLine = new THREE.Line(lineGeom, lineMat);
|
||||||
|
state.measureTempLine.computeLineDistances();
|
||||||
|
state.measureTempLine.renderOrder = 999;
|
||||||
|
state.scene.add(state.measureTempLine);
|
||||||
|
|
||||||
|
const bannerText = document.getElementById('measure-banner-text');
|
||||||
|
if (bannerText) {
|
||||||
|
bannerText.textContent = `Point 1 sélectionné. Distance : ${distance.toFixed(2)} m (Cliquez pour poser le 2nd point)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3D Measurement System Logic
|
||||||
|
*/
|
||||||
|
function toggleMeasurementMode(enable) {
|
||||||
|
state.isMeasuring = (typeof enable === 'boolean') ? enable : !state.isMeasuring;
|
||||||
|
const btn = document.getElementById('tool-measure');
|
||||||
|
const banner = document.getElementById('measure-banner');
|
||||||
|
const bannerText = document.getElementById('measure-banner-text');
|
||||||
|
|
||||||
|
if (btn) btn.classList.toggle('active', state.isMeasuring);
|
||||||
|
if (banner) banner.classList.toggle('hidden', !state.isMeasuring);
|
||||||
|
if (state.canvas) {
|
||||||
|
state.canvas.style.cursor = state.isMeasuring ? 'crosshair' : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isMeasuring) {
|
||||||
|
resetCurrentMeasurement();
|
||||||
|
if (bannerText) bannerText.textContent = "Mode Mesure : Cliquez sur un premier point 3D sur la maquette...";
|
||||||
|
} else {
|
||||||
|
resetCurrentMeasurement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMeasurementClick(hitPoint) {
|
||||||
|
const bannerText = document.getElementById('measure-banner-text');
|
||||||
|
|
||||||
|
if (!state.measureStartPoint) {
|
||||||
|
// First Point Clicked
|
||||||
|
state.measureStartPoint = hitPoint.clone();
|
||||||
|
|
||||||
|
// Create Green Start Sphere Marker
|
||||||
|
const geom = new THREE.SphereGeometry(0.12, 16, 16);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color: 0x22c55e, depthTest: false });
|
||||||
|
state.measureStartSphere = new THREE.Mesh(geom, mat);
|
||||||
|
state.measureStartSphere.position.copy(hitPoint);
|
||||||
|
state.measureStartSphere.renderOrder = 999;
|
||||||
|
state.scene.add(state.measureStartSphere);
|
||||||
|
|
||||||
|
if (bannerText) {
|
||||||
|
bannerText.textContent = "Premier point posé ! Cliquez sur un second point 3D...";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Second Point Clicked -> Complete Measurement!
|
||||||
|
const p1 = state.measureStartPoint.clone();
|
||||||
|
const p2 = hitPoint.clone();
|
||||||
|
const distance = p1.distanceTo(p2);
|
||||||
|
|
||||||
|
// Create Final 3D Line
|
||||||
|
const lineGeom = new THREE.BufferGeometry().setFromPoints([p1, p2]);
|
||||||
|
const lineMat = new THREE.LineBasicMaterial({ color: 0x06b6d4, linewidth: 3, depthTest: false });
|
||||||
|
const line = new THREE.Line(lineGeom, lineMat);
|
||||||
|
line.renderOrder = 999;
|
||||||
|
state.measureGroup.add(line);
|
||||||
|
|
||||||
|
// Create Red End Sphere Marker
|
||||||
|
const geom2 = new THREE.SphereGeometry(0.12, 16, 16);
|
||||||
|
const mat2 = new THREE.MeshBasicMaterial({ color: 0xef4444, depthTest: false });
|
||||||
|
const endSphere = new THREE.Mesh(geom2, mat2);
|
||||||
|
endSphere.position.copy(p2);
|
||||||
|
endSphere.renderOrder = 999;
|
||||||
|
state.measureGroup.add(endSphere);
|
||||||
|
|
||||||
|
// Transfer start sphere to measureGroup
|
||||||
|
if (state.measureStartSphere) {
|
||||||
|
state.scene.remove(state.measureStartSphere);
|
||||||
|
state.measureGroup.add(state.measureStartSphere);
|
||||||
|
state.measureStartSphere = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTML overlay badge at midpoint
|
||||||
|
const midpoint = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
|
||||||
|
createDistanceBadge(midpoint, distance);
|
||||||
|
|
||||||
|
// Reset temp line
|
||||||
|
if (state.measureTempLine) {
|
||||||
|
state.scene.remove(state.measureTempLine);
|
||||||
|
state.measureTempLine = null;
|
||||||
|
}
|
||||||
|
state.measureStartPoint = null;
|
||||||
|
|
||||||
|
if (bannerText) {
|
||||||
|
bannerText.textContent = `Mesure effectuée : ${distance.toFixed(2)} m ! Cliquez à nouveau pour mesurer un autre segment.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCurrentMeasurement() {
|
||||||
|
if (state.measureStartSphere) {
|
||||||
|
state.scene.remove(state.measureStartSphere);
|
||||||
|
state.measureStartSphere = null;
|
||||||
|
}
|
||||||
|
if (state.measureTempLine) {
|
||||||
|
state.scene.remove(state.measureTempLine);
|
||||||
|
state.measureTempLine = null;
|
||||||
|
}
|
||||||
|
state.measureStartPoint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllMeasurements() {
|
||||||
|
resetCurrentMeasurement();
|
||||||
|
|
||||||
|
if (state.measureGroup) {
|
||||||
|
state.scene.remove(state.measureGroup);
|
||||||
|
state.measureGroup = new THREE.Group();
|
||||||
|
state.measureGroup.name = "BIM_MEASURES";
|
||||||
|
state.scene.add(state.measureGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove badge elements
|
||||||
|
if (state.measureBadges) {
|
||||||
|
for (let badge of state.measureBadges) {
|
||||||
|
if (badge && badge.parentElement) {
|
||||||
|
badge.parentElement.removeChild(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.measureBadges = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bannerText = document.getElementById('measure-banner-text');
|
||||||
|
if (bannerText && state.isMeasuring) {
|
||||||
|
bannerText.textContent = "Toutes les mesures ont été effacées. Cliquez sur un premier point 3D...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDistanceBadge(midpoint, distance) {
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'bim-measure-badge';
|
||||||
|
badge.textContent = `${distance.toFixed(2)} m`;
|
||||||
|
badge.style.position = 'absolute';
|
||||||
|
|
||||||
|
state.container.appendChild(badge);
|
||||||
|
|
||||||
|
badge.userData = { worldPos: midpoint };
|
||||||
|
state.measureBadges.push(badge);
|
||||||
|
|
||||||
|
updateBadgePositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBadgePositions() {
|
||||||
|
if (!state.measureBadges || state.measureBadges.length === 0 || !state.camera || !state.container) return;
|
||||||
|
const rect = state.container.getBoundingClientRect();
|
||||||
|
|
||||||
|
for (let badge of state.measureBadges) {
|
||||||
|
if (!badge || !badge.parentElement) continue;
|
||||||
|
const pos = badge.userData.worldPos.clone();
|
||||||
|
pos.project(state.camera);
|
||||||
|
|
||||||
|
const x = (pos.x * 0.5 + 0.5) * rect.width;
|
||||||
|
const y = (-(pos.y * 0.5) + 0.5) * rect.height;
|
||||||
|
|
||||||
|
badge.style.left = `${x}px`;
|
||||||
|
badge.style.top = `${y}px`;
|
||||||
|
badge.style.transform = 'translate(-50%, -50%)';
|
||||||
|
badge.style.display = (pos.z > 1.0) ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight Selected 3D Object and Inspect Properties
|
||||||
|
*/
|
||||||
|
function selectMesh(mesh) {
|
||||||
|
// Restore previous selection
|
||||||
|
if (state.selectedMesh && state.selectedMesh !== mesh) {
|
||||||
|
state.selectedMesh.material = state.selectedOrigMat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressID = mesh.userData.expressID;
|
||||||
|
const rec = state.expressIDToMesh.get(expressID);
|
||||||
|
|
||||||
|
state.selectedMesh = mesh;
|
||||||
|
state.selectedOrigMat = rec ? rec.origMaterial : mesh.material;
|
||||||
|
|
||||||
|
// Apply Highlight Material
|
||||||
|
mesh.material = HIGHLIGHT_MATERIAL;
|
||||||
|
|
||||||
|
// Render Property Inspector in Right Sidebar
|
||||||
|
renderPropertyInspector(expressID, mesh.userData.typeName);
|
||||||
|
|
||||||
|
// Auto Open Right Sidebar if Collapsed
|
||||||
|
const sidebarRight = document.getElementById('sidebar-right');
|
||||||
|
if (sidebarRight && sidebarRight.classList.contains('collapsed-right')) {
|
||||||
|
sidebarRight.classList.remove('collapsed-right');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectMesh() {
|
||||||
|
if (state.selectedMesh) {
|
||||||
|
state.selectedMesh.material = state.selectedOrigMat;
|
||||||
|
state.selectedMesh = null;
|
||||||
|
state.selectedOrigMat = null;
|
||||||
|
}
|
||||||
|
renderEmptyInspector();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query Web-IFC Properties and Render in Inspector
|
||||||
|
*/
|
||||||
|
function renderPropertyInspector(expressID, typeName) {
|
||||||
|
const inspectorContainer = document.getElementById('property-inspector-content');
|
||||||
|
if (!inspectorContainer) return;
|
||||||
|
|
||||||
|
let props = null;
|
||||||
|
let psets = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
props = state.ifcApi.getItemProperties(state.modelID, expressID);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Erreur lecture propriétés:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="bim-prop-group">
|
||||||
|
<div class="bim-prop-group-title"><i class="bi bi-cube"></i> Élément IFC</div>
|
||||||
|
<table class="bim-prop-table">
|
||||||
|
<tr><td class="bim-prop-key">Express ID</td><td class="bim-prop-val">${expressID}</td></tr>
|
||||||
|
<tr><td class="bim-prop-key">Catégorie</td><td class="bim-prop-val"><span class="badge bg-primary">${typeName}</span></td></tr>
|
||||||
|
<tr><td class="bim-prop-key">Nom</td><td class="bim-prop-val">${props && props.Name ? props.Name.value : 'N/A'}</td></tr>
|
||||||
|
<tr><td class="bim-prop-key">GlobalId</td><td class="bim-prop-val"><code>${props && props.GlobalId ? props.GlobalId.value : 'N/A'}</code></td></tr>
|
||||||
|
<tr><td class="bim-prop-key">Description</td><td class="bim-prop-val">${props && props.Description ? props.Description.value : '-'}</td></tr>
|
||||||
|
<tr><td class="bim-prop-key">Tag IFC</td><td class="bim-prop-val">${props && props.Tag ? props.Tag.value : '-'}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fetch PropertySets (Psets) if available
|
||||||
|
try {
|
||||||
|
const psetLines = state.ifcApi.getPropertySets(state.modelID, expressID);
|
||||||
|
if (psetLines && psetLines.length > 0) {
|
||||||
|
for (let psetRef of psetLines) {
|
||||||
|
const psetData = state.ifcApi.getItemProperties(state.modelID, psetRef.expressID);
|
||||||
|
if (!psetData) continue;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="bim-prop-group">
|
||||||
|
<div class="bim-prop-group-title"><i class="bi bi-file-earmark-code"></i> ${psetData.Name ? psetData.Name.value : 'PropertySet'}</div>
|
||||||
|
<table class="bim-prop-table">
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (psetData.HasProperties) {
|
||||||
|
for (let propRef of psetData.HasProperties) {
|
||||||
|
const pVal = state.ifcApi.getItemProperties(state.modelID, propRef.value);
|
||||||
|
if (pVal && pVal.Name && pVal.NominalValue) {
|
||||||
|
html += `<tr><td class="bim-prop-key">${pVal.Name.value}</td><td class="bim-prop-val">${pVal.NominalValue.value}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</table></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
inspectorContainer.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyInspector() {
|
||||||
|
const inspectorContainer = document.getElementById('property-inspector-content');
|
||||||
|
if (!inspectorContainer) return;
|
||||||
|
inspectorContainer.innerHTML = `
|
||||||
|
<div class="text-center text-muted p-4">
|
||||||
|
<i class="bi bi-cursor fs-1 d-block mb-2 opacity-50"></i>
|
||||||
|
<p class="small mb-0">Cliquez sur n'importe quel élément 3D de la maquette pour inspecter ses propriétés et jeux de données (PropertySets).</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Section Clipping Plane Positions
|
||||||
|
*/
|
||||||
|
function updateClippingPlane(axis, percent) {
|
||||||
|
const plane = state.clippingPlanes[axis];
|
||||||
|
if (!plane) return;
|
||||||
|
|
||||||
|
const p = percent / 100;
|
||||||
|
if (axis === 'z') {
|
||||||
|
plane.constant = state.boundingBox.min.y + state.size.y * p;
|
||||||
|
} else if (axis === 'x') {
|
||||||
|
plane.constant = state.boundingBox.min.x + state.size.x * p;
|
||||||
|
} else if (axis === 'y') {
|
||||||
|
plane.constant = state.boundingBox.min.z + state.size.z * p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Event Bindings & Toolbar Setup
|
||||||
|
*/
|
||||||
|
function bindUIEvents() {
|
||||||
|
state.canvas.addEventListener('click', onCanvasClick);
|
||||||
|
state.canvas.addEventListener('mousemove', onCanvasMouseMove);
|
||||||
|
|
||||||
|
// Sidebars Toggle
|
||||||
|
const sidebarLeft = document.getElementById('sidebar-left');
|
||||||
|
const sidebarRight = document.getElementById('sidebar-right');
|
||||||
|
|
||||||
|
document.getElementById('toggle-left-sidebar')?.addEventListener('click', () => {
|
||||||
|
sidebarLeft.classList.toggle('collapsed-left');
|
||||||
|
});
|
||||||
|
document.getElementById('tool-toggle-left')?.addEventListener('click', () => {
|
||||||
|
sidebarLeft.classList.toggle('collapsed-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('toggle-right-sidebar')?.addEventListener('click', () => {
|
||||||
|
sidebarRight.classList.toggle('collapsed-right');
|
||||||
|
});
|
||||||
|
document.getElementById('tool-toggle-right')?.addEventListener('click', () => {
|
||||||
|
sidebarRight.classList.toggle('collapsed-right');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Camera Presets
|
||||||
|
document.getElementById('btn-view-iso')?.addEventListener('click', () => setPresetCamera('iso'));
|
||||||
|
document.getElementById('btn-view-top')?.addEventListener('click', () => setPresetCamera('top'));
|
||||||
|
document.getElementById('btn-view-front')?.addEventListener('click', () => setPresetCamera('front'));
|
||||||
|
document.getElementById('btn-view-right')?.addEventListener('click', () => setPresetCamera('right'));
|
||||||
|
document.getElementById('btn-view-fit')?.addEventListener('click', () => fitCameraToModel());
|
||||||
|
document.getElementById('tool-reset')?.addEventListener('click', () => fitCameraToModel());
|
||||||
|
|
||||||
|
// Clipping Panel & Sliders
|
||||||
|
const clippingPanel = document.getElementById('clipping-panel');
|
||||||
|
document.getElementById('tool-clipping')?.addEventListener('click', () => {
|
||||||
|
clippingPanel.classList.toggle('active');
|
||||||
|
const isActive = clippingPanel.classList.contains('active');
|
||||||
|
state.renderer.localClippingEnabled = isActive;
|
||||||
|
|
||||||
|
// Apply clipping planes to materials
|
||||||
|
state.meshes.forEach(r => {
|
||||||
|
r.mesh.material.clippingPlanes = isActive ? [state.clippingPlanes.z, state.clippingPlanes.x, state.clippingPlanes.y] : [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('close-clipping-panel')?.addEventListener('click', () => {
|
||||||
|
clippingPanel.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('slider-clip-z')?.addEventListener('input', (e) => {
|
||||||
|
updateClippingPlane('z', e.target.value);
|
||||||
|
document.getElementById('val-clip-z').textContent = e.target.value + '%';
|
||||||
|
});
|
||||||
|
document.getElementById('slider-clip-x')?.addEventListener('input', (e) => {
|
||||||
|
updateClippingPlane('x', e.target.value);
|
||||||
|
document.getElementById('val-clip-x').textContent = e.target.value + '%';
|
||||||
|
});
|
||||||
|
document.getElementById('slider-clip-y')?.addEventListener('input', (e) => {
|
||||||
|
updateClippingPlane('y', e.target.value);
|
||||||
|
document.getElementById('val-clip-y').textContent = e.target.value + '%';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wireframe Toggle
|
||||||
|
document.getElementById('tool-wireframe')?.addEventListener('click', (e) => {
|
||||||
|
state.isWireframe = !state.isWireframe;
|
||||||
|
e.currentTarget.classList.toggle('active', state.isWireframe);
|
||||||
|
state.meshes.forEach(r => {
|
||||||
|
r.origMaterial.wireframe = state.isWireframe;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ghosting / Transparency Toggle
|
||||||
|
document.getElementById('tool-ghosting')?.addEventListener('click', (e) => {
|
||||||
|
state.isGhosting = !state.isGhosting;
|
||||||
|
e.currentTarget.classList.toggle('active', state.isGhosting);
|
||||||
|
state.meshes.forEach(r => {
|
||||||
|
if (r.typeName !== 'IFCWINDOW') {
|
||||||
|
r.origMaterial.transparent = state.isGhosting;
|
||||||
|
r.origMaterial.opacity = state.isGhosting ? 0.3 : 1.0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3D Distance Measurement Tool Events
|
||||||
|
document.getElementById('tool-measure')?.addEventListener('click', () => {
|
||||||
|
toggleMeasurementMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-clear-measures')?.addEventListener('click', () => {
|
||||||
|
clearAllMeasurements();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && state.isMeasuring) {
|
||||||
|
toggleMeasurementMode(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change Preset Camera Angles
|
||||||
|
*/
|
||||||
|
function setPresetCamera(viewType) {
|
||||||
|
const center = state.center;
|
||||||
|
const maxDim = Math.max(state.size.x, state.size.y, state.size.z) || 20;
|
||||||
|
const dist = maxDim * 1.5;
|
||||||
|
|
||||||
|
// Highlight active preset button
|
||||||
|
document.querySelectorAll('.bim-preset-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
|
switch (viewType) {
|
||||||
|
case 'iso':
|
||||||
|
state.camera.position.set(center.x + dist * 0.7, center.y + dist * 0.6, center.z + dist * 0.7);
|
||||||
|
document.getElementById('btn-view-iso')?.classList.add('active');
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
state.camera.position.set(center.x, center.y + dist * 1.2, center.z + 0.01);
|
||||||
|
document.getElementById('btn-view-top')?.classList.add('active');
|
||||||
|
break;
|
||||||
|
case 'front':
|
||||||
|
state.camera.position.set(center.x, center.y + dist * 0.2, center.z + dist);
|
||||||
|
document.getElementById('btn-view-front')?.classList.add('active');
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
state.camera.position.set(center.x + dist, center.y + dist * 0.2, center.z);
|
||||||
|
document.getElementById('btn-view-right')?.classList.add('active');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.controls.target.copy(center);
|
||||||
|
state.controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowResize() {
|
||||||
|
if (!state.container || !state.camera || !state.renderer) return;
|
||||||
|
const aspect = state.container.clientWidth / state.container.clientHeight;
|
||||||
|
state.camera.aspect = aspect;
|
||||||
|
state.camera.updateProjectionMatrix();
|
||||||
|
state.renderer.setSize(state.container.clientWidth, state.container.clientHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
1045
streetup/bim/static/bim/vendor/OrbitControls.js
vendored
Normal file
1045
streetup/bim/static/bim/vendor/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
6
streetup/bim/static/bim/vendor/three.min.js
vendored
Normal file
6
streetup/bim/static/bim/vendor/three.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
66252
streetup/bim/static/bim/vendor/web-ifc-api.js
vendored
Normal file
66252
streetup/bim/static/bim/vendor/web-ifc-api.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
streetup/bim/static/bim/vendor/web-ifc-mt.wasm
vendored
Normal file
BIN
streetup/bim/static/bim/vendor/web-ifc-mt.wasm
vendored
Normal file
Binary file not shown.
BIN
streetup/bim/static/bim/vendor/web-ifc.wasm
vendored
Normal file
BIN
streetup/bim/static/bim/vendor/web-ifc.wasm
vendored
Normal file
Binary file not shown.
197
streetup/bim/templates/bim/viewer.html
Normal file
197
streetup/bim/templates/bim/viewer.html
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
{% load static i18n %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ document.title }} - Visionneur BIM IFC | StreetUp</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap Icons & Fonts -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Viewer CSS -->
|
||||||
|
<link rel="stylesheet" href="{% static 'bim/css/ifc_viewer.css' %}">
|
||||||
|
</head>
|
||||||
|
<body class="bim-viewer-body">
|
||||||
|
<div class="bim-wrapper">
|
||||||
|
<!-- Header Navbar -->
|
||||||
|
<header class="bim-header">
|
||||||
|
<div class="bim-header-brand">
|
||||||
|
<a href="{% url 'documents:detail' document.pk %}" class="bim-preset-btn" title="{% translate 'Retour au document' %}">
|
||||||
|
<i class="bi bi-arrow-left"></i>
|
||||||
|
</a>
|
||||||
|
<i class="bi bi-box-seam fs-5 text-primary"></i>
|
||||||
|
<h1 class="bim-header-title" title="{{ document.title }}">{{ document.title }}</h1>
|
||||||
|
<span class="bim-badge">IFC v{{ version.version_number }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera View Presets -->
|
||||||
|
<div class="bim-header-actions">
|
||||||
|
<button type="button" class="bim-preset-btn active" id="btn-view-iso" title="{% translate 'Vue Isométrique' %}">
|
||||||
|
<i class="bi bi-box"></i> {% translate "Iso" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-preset-btn" id="btn-view-top" title="{% translate 'Vue de Dessus' %}">
|
||||||
|
<i class="bi bi-square"></i> {% translate "Haut" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-preset-btn" id="btn-view-front" title="{% translate 'Vue de Face' %}">
|
||||||
|
<i class="bi bi-layout-sidebar-inset"></i> {% translate "Face" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-preset-btn" id="btn-view-right" title="{% translate 'Vue de Droite' %}">
|
||||||
|
<i class="bi bi-layout-sidebar"></i> {% translate "Droite" %}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-preset-btn" id="btn-view-fit" title="{% translate 'Recentrer la vue' %}">
|
||||||
|
<i class="bi bi-aspect-ratio"></i> {% translate "Recentrer" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Workspace -->
|
||||||
|
<main class="bim-main">
|
||||||
|
<!-- Left Sidebar: Spatial Tree & Layers -->
|
||||||
|
<aside class="bim-sidebar bim-sidebar-left" id="sidebar-left">
|
||||||
|
<div class="bim-sidebar-header">
|
||||||
|
<span class="bim-sidebar-title">
|
||||||
|
<i class="bi bi-layers"></i> {% translate "Arborescence & Calques" %}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="bim-preset-btn p-1" id="toggle-left-sidebar" title="Masquer">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bim-sidebar-content">
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="tree-search-input" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="{% translate 'Filtrer un élément ou niveau...' %}" style="border-color: var(--bim-panel-border) !important; font-size: 0.825rem;">
|
||||||
|
</div>
|
||||||
|
<div id="spatial-tree-container">
|
||||||
|
<p class="text-muted small p-2">{% translate "Analyse de la structure IFC en cours..." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 3D Canvas Container -->
|
||||||
|
<div class="bim-canvas-container">
|
||||||
|
<canvas id="bim-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- Floating Toolbar -->
|
||||||
|
<div class="bim-toolbar">
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-toggle-left" data-tooltip="{% translate 'Arborescence / Calques' %}">
|
||||||
|
<i class="bi bi-list-nested"></i>
|
||||||
|
</button>
|
||||||
|
<div class="bim-tool-divider"></div>
|
||||||
|
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-clipping" data-tooltip="{% translate 'Plan de coupe 3D' %}">
|
||||||
|
<i class="bi bi-scissors"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-ghosting" data-tooltip="{% translate 'Mode Transparence' %}">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-wireframe" data-tooltip="{% translate 'Mode Filaire' %}">
|
||||||
|
<i class="bi bi-grid-3x3"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-measure" data-tooltip="{% translate 'Mesure de distance 3D' %}">
|
||||||
|
<i class="bi bi-rulers"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="bim-tool-divider"></div>
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-reset" data-tooltip="{% translate 'Réinitialiser la vue' %}">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="bim-tool-btn" id="tool-toggle-right" data-tooltip="{% translate 'Propriétés de l\'objet' %}">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measure Guidance Banner -->
|
||||||
|
<div class="bim-measure-banner hidden" id="measure-banner">
|
||||||
|
<i class="bi bi-rulers text-primary"></i>
|
||||||
|
<span id="measure-banner-text">{% translate "Mode Mesure : Cliquez sur un premier point 3D sur la maquette..." %}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger ms-2 py-0 px-1" id="btn-clear-measures" title="{% translate 'Effacer toutes les mesures' %}">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Clipping Controls Panel -->
|
||||||
|
<div class="bim-clipping-panel" id="clipping-panel">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<strong class="small text-light"><i class="bi bi-scissors"></i> {% translate "Plans de coupe 3D" %}</strong>
|
||||||
|
<button type="button" class="btn-close btn-close-white btn-sm" id="close-clipping-panel"></button>
|
||||||
|
</div>
|
||||||
|
<div class="bim-slider-group">
|
||||||
|
<div class="bim-slider-label">
|
||||||
|
<span>{% translate "Coupe horizontale (Étage Z)" %}</span>
|
||||||
|
<span id="val-clip-z">100%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value="100" class="bim-slider" id="slider-clip-z">
|
||||||
|
</div>
|
||||||
|
<div class="bim-slider-group">
|
||||||
|
<div class="bim-slider-label">
|
||||||
|
<span>{% translate "Coupe transversale (Axe X)" %}</span>
|
||||||
|
<span id="val-clip-x">100%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value="100" class="bim-slider" id="slider-clip-x">
|
||||||
|
</div>
|
||||||
|
<div class="bim-slider-group">
|
||||||
|
<div class="bim-slider-label">
|
||||||
|
<span>{% translate "Coupe longitudinale (Axe Y)" %}</span>
|
||||||
|
<span id="val-clip-y">100%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" min="0" max="100" value="100" class="bim-slider" id="slider-clip-y">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div class="bim-loader-overlay" id="bim-loader">
|
||||||
|
<div class="bim-spinner"></div>
|
||||||
|
<div class="bim-loader-title">{% translate "Chargement de la maquette BIM" %}</div>
|
||||||
|
<div class="bim-loader-progress-bar">
|
||||||
|
<div class="bim-loader-progress-fill" id="loader-progress"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bim-loader-text" id="loader-status">{% translate "Téléchargement du fichier IFC..." %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Sidebar: Property Inspector -->
|
||||||
|
<aside class="bim-sidebar bim-sidebar-right" id="sidebar-right">
|
||||||
|
<div class="bim-sidebar-header">
|
||||||
|
<span class="bim-sidebar-title">
|
||||||
|
<i class="bi bi-info-square"></i> {% translate "Propriétés de l'objet" %}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="bim-preset-btn p-1" id="toggle-right-sidebar" title="Masquer">
|
||||||
|
<i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bim-sidebar-content" id="property-inspector-content">
|
||||||
|
<div class="text-center text-muted p-4">
|
||||||
|
<i class="bi bi-cursor fs-1 d-block mb-2 opacity-50"></i>
|
||||||
|
<p class="small mb-0">{% translate "Cliquez sur n'importe quel élément 3D de la maquette pour inspecter ses propriétés et jeux de données (PropertySets)." %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vendor JavaScript Libraries (Three.js & Web-IFC) -->
|
||||||
|
<script src="{% static 'bim/vendor/three.min.js' %}"></script>
|
||||||
|
<script src="{% static 'bim/vendor/OrbitControls.js' %}"></script>
|
||||||
|
<script type="module">
|
||||||
|
import * as WebIFC from "{% static 'bim/vendor/web-ifc-api.js' %}";
|
||||||
|
window.WebIFC = WebIFC;
|
||||||
|
document.dispatchEvent(new Event('web-ifc-loaded'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Viewer Configuration Context -->
|
||||||
|
<script>
|
||||||
|
window.BIM_CONFIG = {
|
||||||
|
docCode: "{{ doc_code }}",
|
||||||
|
downloadUrl: "{% url 'bim:download_ifc' doc_code %}",
|
||||||
|
infoUrl: "{% url 'bim:document_info' doc_code %}",
|
||||||
|
wasmPath: "{% static 'bim/vendor/' %}"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- BIM Viewer Application Script -->
|
||||||
|
<script src="{% static 'bim/js/ifc_viewer.js' %}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
streetup/bim/tests.py
Normal file
65
streetup/bim/tests.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
from documents.models import ManagedDocument, DocumentVersion
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class BimViewerTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="testuser", email="test@streetup.be", password="password123", is_superuser=True)
|
||||||
|
self.other_user = User.objects.create_user(username="otheruser", email="other@streetup.be", password="password123")
|
||||||
|
|
||||||
|
# Create sample IFC ManagedDocument
|
||||||
|
self.doc = ManagedDocument.objects.create(
|
||||||
|
title="Maquette Test BIM",
|
||||||
|
description="Fichier IFC de test",
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
dummy_ifc_content = b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n#1= IFCORGANIZATION('Test',$,$,$,$);\nENDSEC;\nEND-ISO-10303-21;\n"
|
||||||
|
self.version = DocumentVersion.objects.create(
|
||||||
|
document=self.doc,
|
||||||
|
version_number=1,
|
||||||
|
file=SimpleUploadedFile("model.ifc", dummy_ifc_content, content_type="application/x-step"),
|
||||||
|
uploaded_by=self.user,
|
||||||
|
)
|
||||||
|
self.doc.current_version = self.version
|
||||||
|
self.doc.title = "Maquette Test BIM"
|
||||||
|
self.doc.save()
|
||||||
|
|
||||||
|
def test_unauthenticated_user_redirected(self):
|
||||||
|
url = reverse("bim:viewer", kwargs={"doc_code": self.doc.uuid})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_owner_can_access_viewer(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
url = reverse("bim:viewer", kwargs={"doc_code": self.doc.uuid})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_info_api(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
url = reverse("bim:document_info", kwargs={"doc_code": self.doc.uuid})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
json_data = response.json()
|
||||||
|
self.assertEqual(json_data["title"], "Maquette Test BIM")
|
||||||
|
self.assertEqual(json_data["uuid"], str(self.doc.uuid))
|
||||||
|
|
||||||
|
def test_download_api(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
url = reverse("bim:download_ifc", kwargs={"doc_code": self.doc.uuid})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"ISO-10303-21", b"".join(response.streaming_content))
|
||||||
|
|
||||||
|
def test_unauthorized_user_denied(self):
|
||||||
|
self.client.force_login(self.other_user)
|
||||||
|
url = reverse("bim:viewer", kwargs={"doc_code": self.doc.uuid})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
10
streetup/bim/urls.py
Normal file
10
streetup/bim/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "bim"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("viewer/<uuid:doc_code>/", views.ifc_viewer_view, name="viewer"),
|
||||||
|
path("api/document/<uuid:doc_code>/download/", views.ifc_file_download_view, name="download_ifc"),
|
||||||
|
path("api/document/<uuid:doc_code>/info/", views.ifc_document_info_api, name="document_info"),
|
||||||
|
]
|
||||||
92
streetup/bim/views.py
Normal file
92
streetup/bim/views.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.http import FileResponse, JsonResponse, Http404
|
||||||
|
from django.shortcuts import render, get_object_or_404
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
|
||||||
|
from documents.models import ManagedDocument
|
||||||
|
from documents.permissions import user_has_document_permission
|
||||||
|
|
||||||
|
|
||||||
|
def _get_document_by_code(doc_code):
|
||||||
|
"""Utility to fetch ManagedDocument by UUID string/object or integer PK."""
|
||||||
|
try:
|
||||||
|
return ManagedDocument.objects.get(uuid=doc_code)
|
||||||
|
except (ManagedDocument.DoesNotExist, ValueError):
|
||||||
|
# Fallback to PK lookup if integer passed
|
||||||
|
if str(doc_code).isdigit():
|
||||||
|
return get_object_or_404(ManagedDocument, pk=int(doc_code))
|
||||||
|
raise Http404("Document non trouvé")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def ifc_viewer_view(request, doc_code):
|
||||||
|
"""Renders the full-screen 3D IFC Viewer page for a given document code."""
|
||||||
|
document = _get_document_by_code(doc_code)
|
||||||
|
|
||||||
|
if not user_has_document_permission(request.user, document, required_permission=ManagedDocument.PERMISSION_VIEW):
|
||||||
|
raise PermissionDenied("Vous n'avez pas la permission de visualiser ce document.")
|
||||||
|
|
||||||
|
version = document.latest_version
|
||||||
|
if not version or not version.file:
|
||||||
|
raise Http404("Aucun fichier disponible pour ce document.")
|
||||||
|
|
||||||
|
filename = version.filename
|
||||||
|
ext = os.path.splitext(filename)[1].lower()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"document": document,
|
||||||
|
"version": version,
|
||||||
|
"filename": filename,
|
||||||
|
"ext": ext,
|
||||||
|
"doc_code": document.uuid,
|
||||||
|
}
|
||||||
|
return render(request, "bim/viewer.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def ifc_file_download_view(request, doc_code):
|
||||||
|
"""Streams the IFC file for consumption by the WebGL 3D viewer."""
|
||||||
|
document = _get_document_by_code(doc_code)
|
||||||
|
|
||||||
|
if not user_has_document_permission(request.user, document, required_permission=ManagedDocument.PERMISSION_VIEW):
|
||||||
|
raise PermissionDenied("Accès refusé.")
|
||||||
|
|
||||||
|
version = document.latest_version
|
||||||
|
if not version or not version.file:
|
||||||
|
raise Http404("Fichier non trouvé.")
|
||||||
|
|
||||||
|
response = FileResponse(
|
||||||
|
version.file.open("rb"),
|
||||||
|
content_type="application/octet-stream",
|
||||||
|
filename=version.filename
|
||||||
|
)
|
||||||
|
# Enable CORS headers if viewer runs under different subdomains / frames
|
||||||
|
response["Access-Control-Allow-Origin"] = "*"
|
||||||
|
response["Accept-Ranges"] = "bytes"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def ifc_document_info_api(request, doc_code):
|
||||||
|
"""API endpoint providing document metadata for the BIM viewer UI."""
|
||||||
|
document = _get_document_by_code(doc_code)
|
||||||
|
|
||||||
|
if not user_has_document_permission(request.user, document, required_permission=ManagedDocument.PERMISSION_VIEW):
|
||||||
|
return JsonResponse({"error": "Accès refusé"}, status=403)
|
||||||
|
|
||||||
|
version = document.latest_version
|
||||||
|
file_size = version.file.size if version and version.file else 0
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
"uuid": str(document.uuid),
|
||||||
|
"id": document.id,
|
||||||
|
"title": document.title,
|
||||||
|
"description": document.description or "",
|
||||||
|
"filename": version.filename if version else "",
|
||||||
|
"version_number": version.version_number if version else 1,
|
||||||
|
"file_size": file_size,
|
||||||
|
"created_at": document.created_at.isoformat() if document.created_at else None,
|
||||||
|
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
|
||||||
|
})
|
||||||
|
|
@ -17,6 +17,12 @@ def check_thematic_access(thematic_code, edit_permission_required=False):
|
||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@functools.wraps(view_func)
|
@functools.wraps(view_func)
|
||||||
def _wrapped_view(request, *args, **kwargs):
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
|
if not getattr(request.user, 'is_authenticated', False):
|
||||||
|
return HttpResponseForbidden("Access denied: Authentication required.")
|
||||||
|
|
||||||
|
if getattr(request.user, 'is_superuser', False):
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
user_config = UserConfig.objects.filter(user=request.user).first() # Get user access configuration
|
user_config = UserConfig.objects.filter(user=request.user).first() # Get user access configuration
|
||||||
thematic = get_object_or_404(Thematic, code=thematic_code) # Get thematic from the given parameter
|
thematic = get_object_or_404(Thematic, code=thematic_code) # Get thematic from the given parameter
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@
|
||||||
<i class="bi bi-map"></i> {% translate "Afficher sur la carte" %}
|
<i class="bi bi-map"></i> {% translate "Afficher sur la carte" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_ifc or document.get_file_extension.lower == '.ifc' %}
|
||||||
|
<a class="btn btn-outline-primary" href="{% url 'bim:viewer' document.uuid %}" target="_blank">
|
||||||
|
<i class="bi bi-box-seam"></i> {% translate "Visualiser la maquette 3D (BIM)" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if is_dxf and intersection %}
|
{% if is_dxf and intersection %}
|
||||||
<a class="btn btn-outline-info" href="{% url 'assets:import_dxf' intersection.id %}?dxf_file={{ document_filename }}">
|
<a class="btn btn-outline-info" href="{% url 'assets:import_dxf' intersection.id %}?dxf_file={{ document_filename }}">
|
||||||
<i class="bi bi-file-earmark-code"></i> {% translate "Importer les couches vectorielles" %}
|
<i class="bi bi-file-earmark-code"></i> {% translate "Importer les couches vectorielles" %}
|
||||||
|
|
|
||||||
|
|
@ -949,11 +949,13 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
|
||||||
|
|
||||||
# Check if the document has a current version file ending in .dxf and is attached to a TrafficLightIntersection
|
# Check if the document has a current version file ending in .dxf and is attached to a TrafficLightIntersection
|
||||||
is_dxf = False
|
is_dxf = False
|
||||||
|
is_ifc = False
|
||||||
intersection = None
|
intersection = None
|
||||||
document_filename = ""
|
document_filename = ""
|
||||||
latest_version = document.latest_version
|
latest_version = document.latest_version
|
||||||
if latest_version and latest_version.file:
|
if latest_version and latest_version.file:
|
||||||
is_dxf = latest_version.file.name.lower().endswith('.dxf')
|
is_dxf = latest_version.file.name.lower().endswith('.dxf')
|
||||||
|
is_ifc = latest_version.file.name.lower().endswith('.ifc')
|
||||||
document_filename = os.path.basename(latest_version.file.name)
|
document_filename = os.path.basename(latest_version.file.name)
|
||||||
if is_dxf:
|
if is_dxf:
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
@ -969,6 +971,7 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
|
||||||
intersection = attachment.content_object
|
intersection = attachment.content_object
|
||||||
|
|
||||||
context["is_dxf"] = is_dxf
|
context["is_dxf"] = is_dxf
|
||||||
|
context["is_ifc"] = is_ifc
|
||||||
context["intersection"] = intersection
|
context["intersection"] = intersection
|
||||||
context["document_filename"] = document_filename
|
context["document_filename"] = document_filename
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,13 @@ def index(request):
|
||||||
has_home_access = any(v.code == 'asset_management' for v in accessible_views_list)
|
has_home_access = any(v.code == 'asset_management' for v in accessible_views_list)
|
||||||
|
|
||||||
if not has_home_access:
|
if not has_home_access:
|
||||||
# Rediriger vers la première vue accessible
|
# Rediriger vers la première vue accessible valide
|
||||||
first_view = accessible_views_list[0]
|
for first_view in accessible_views_list:
|
||||||
if first_view and first_view.url_name:
|
if first_view and first_view.url_name:
|
||||||
return redirect(first_view.url_name)
|
try:
|
||||||
|
return redirect(first_view.url_name)
|
||||||
|
except NoReverseMatch:
|
||||||
|
continue
|
||||||
|
|
||||||
# Resolve widgets for the user
|
# Resolve widgets for the user
|
||||||
company_member = CompanyMember.objects.filter(
|
company_member = CompanyMember.objects.filter(
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ REGIONAL_STANDARD = getenv("REGIONAL_STANDARD", "BE")
|
||||||
ALLOWED_HOSTS = getenv("ALLOWED_HOSTS").split(',')
|
ALLOWED_HOSTS = getenv("ALLOWED_HOSTS").split(',')
|
||||||
CSRF_TRUSTED_ORIGINS = getenv("CSRF_TRUSTED_ORIGINS").split(',')
|
CSRF_TRUSTED_ORIGINS = getenv("CSRF_TRUSTED_ORIGINS").split(',')
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = 'streetup_core_sessionid'
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|
@ -66,6 +68,7 @@ INSTALLED_APPS = [
|
||||||
'authorizations.apps.AuthorizationsConfig',
|
'authorizations.apps.AuthorizationsConfig',
|
||||||
'reporting.apps.ReportingConfig',
|
'reporting.apps.ReportingConfig',
|
||||||
'panoramax.apps.PanoramaxConfig',
|
'panoramax.apps.PanoramaxConfig',
|
||||||
|
'bim.apps.BimConfig',
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ urlpatterns = [
|
||||||
path("road-detection/", include("road_detection.urls")),
|
path("road-detection/", include("road_detection.urls")),
|
||||||
path("authorizations/", include("authorizations.urls")),
|
path("authorizations/", include("authorizations.urls")),
|
||||||
path("panoramax/", include("panoramax.urls")),
|
path("panoramax/", include("panoramax.urls")),
|
||||||
|
path("bim/", include("bim.urls")),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('accounts/login/', auth_views.LoginView.as_view(template_name='accounts/login.html', next_page='/'), name='login'),
|
path('accounts/login/', auth_views.LoginView.as_view(template_name='accounts/login.html', next_page='/'), name='login'),
|
||||||
path('accounts/logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'),
|
path('accounts/logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue