feat: implement 3D IFC model viewer with Web-IFC and Three.js integration

This commit is contained in:
kdeterme 2026-07-28 14:20:09 +02:00
parent ee6964eb5b
commit 91283f866b
28 changed files with 155564 additions and 31 deletions

View file

@ -71,7 +71,7 @@ COPY streetup/maps/ /app/maps/
COPY streetup/notifications/ /app/notifications/
COPY streetup/projects/ /app/projects/
COPY streetup/reporting/ /app/reporting/
COPY streetup/schools/ /app/schools/
COPY streetup/bim/ /app/bim/
COPY streetup/sign/ /app/sign/
COPY streetup/stock/ /app/stock/

View file

@ -1,21 +1,21 @@
services:
db:
image: postgis/postgis:18-3.6
container_name: streetup_db
container_name: streetup_core_db
restart: unless-stopped
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:
- ./streetup/.env
environment:
POSTGRES_DB: ${DB_NAME:-streetup}
POSTGRES_USER: ${DB_USER:-admin}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_PASSWORD: ${DB_PASSWORD:?La variable DB_PASSWORD est obligatoire dans le fichier .env}
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
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
timeout: 5s
retries: 5
@ -27,15 +27,15 @@ services:
args:
# Mettre "true" pour installer ultralytics, OpenCV et PyTorch dans l'image
INSTALL_AI_LIBS: "false"
container_name: streetup_web
container_name: streetup_core_web
restart: unless-stopped
command: python manage.py runserver 0.0.0.0:8000
ports:
- "8000:8000"
- "8003:8000" # Mappé sur le port 8003 de l'hôte (8000=streetup-bm, 8001=python dev, 8002=valogeo)
volumes:
- ./streetup:/app # Volume mount pour le hot-reload local
- streetup_media:/app/media
- streetup_private_media:/app/private_media
- streetup_core_media:/app/media
- streetup_core_private_media:/app/private_media
env_file:
- ./streetup/.env # Chargement du fichier d'environnement local
environment:
@ -54,22 +54,20 @@ services:
caddy:
image: caddy:latest
container_name: streetup_caddy
container_name: streetup_core_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
- "8083:80" # Mappé sur le port 8083 de l'hôte (80 et 443 occupés par streetup_caddy)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- streetup_caddy_data:/data
- streetup_caddy_config:/config
- streetup_core_caddy_data:/data
- streetup_core_caddy_config:/config
depends_on:
- web
volumes:
streetup_db_data:
streetup_media:
streetup_private_media:
streetup_caddy_data:
streetup_caddy_config:
streetup_core_db_data:
streetup_core_media:
streetup_core_private_media:
streetup_core_caddy_data:
streetup_core_caddy_config:

View file

@ -9,6 +9,7 @@ from django.template.loader import select_template
from django.urls import reverse
from django.utils.translation import gettext as _
from django.contrib.auth.decorators import login_required
from common.models import UserConfig, Thematic
from common.utils import check_thematic_access
from assets.models import AssetCategory, ASSET_STATUS_CHOICES
@ -895,6 +896,7 @@ def get_user_specific_tables(user, accessible_thematics):
return specific_tables
@login_required
def unified_assets_index(request):
"""Unified asset/location index page combining all accessible thematics."""
from django.utils.translation import get_language
@ -935,10 +937,17 @@ def unified_assets_index(request):
else:
selected_locations_ids = draft_data.get('locations', []) or []
# Thématiques présélectionnées (GET ou fixées par le mode intervention)
selected_thematics = request.GET.getlist('thematic') or (
[locked_thematic_code] if locked_thematic_code else []
)
# Thématiques présélectionnées (GET, intervention, thématique par défaut ou toutes les thématiques accessibles)
if 'thematic' in request.GET:
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')
# Config JS par thématique (URLs GeoJSON, nom, icône)

View file

@ -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
its_locations = its_locations.filter(
its_contracts__status='active', # ITSContract.status
its_contracts__contract_id__in=accessible_contract_ids # FK contract
its_locations_contracts__status='active', # ITSLocationContract.status
its_locations_contracts__contract_id__in=accessible_contract_ids # FK contract
).distinct()
else:
# Non authentifié : ne rien exposer (option sûre)

1
streetup/bim/__init__.py Normal file
View file

@ -0,0 +1 @@
"""BIM (Building Information Modeling) application for StreetUp."""

8
streetup/bim/apps.py Normal file
View 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")

View file

@ -0,0 +1 @@
# Management package for bim app

View file

@ -0,0 +1 @@
# Commands package

View 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."
)
)

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View 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;
}

View 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);
}
})();

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View 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
View 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
View 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
View 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,
})

View file

@ -17,6 +17,12 @@ def check_thematic_access(thematic_code, edit_permission_required=False):
def decorator(view_func):
@functools.wraps(view_func)
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
thematic = get_object_or_404(Thematic, code=thematic_code) # Get thematic from the given parameter

View file

@ -28,6 +28,11 @@
<i class="bi bi-map"></i> {% translate "Afficher sur la carte" %}
</a>
{% 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 %}
<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" %}

View file

@ -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
is_dxf = False
is_ifc = False
intersection = None
document_filename = ""
latest_version = document.latest_version
if latest_version and latest_version.file:
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)
if is_dxf:
from django.contrib.contenttypes.models import ContentType
@ -969,6 +971,7 @@ class DocumentDetailView(DocumentsAppAccessMixin, LoginRequiredMixin, DetailView
intersection = attachment.content_object
context["is_dxf"] = is_dxf
context["is_ifc"] = is_ifc
context["intersection"] = intersection
context["document_filename"] = document_filename

View file

@ -50,10 +50,13 @@ def index(request):
has_home_access = any(v.code == 'asset_management' for v in accessible_views_list)
if not has_home_access:
# Rediriger vers la première vue accessible
first_view = accessible_views_list[0]
# Rediriger vers la première vue accessible valide
for first_view in accessible_views_list:
if first_view and first_view.url_name:
try:
return redirect(first_view.url_name)
except NoReverseMatch:
continue
# Resolve widgets for the user
company_member = CompanyMember.objects.filter(

View file

@ -43,6 +43,8 @@ REGIONAL_STANDARD = getenv("REGIONAL_STANDARD", "BE")
ALLOWED_HOSTS = getenv("ALLOWED_HOSTS").split(',')
CSRF_TRUSTED_ORIGINS = getenv("CSRF_TRUSTED_ORIGINS").split(',')
SESSION_COOKIE_NAME = 'streetup_core_sessionid'
# Application definition
INSTALLED_APPS = [
@ -66,6 +68,7 @@ INSTALLED_APPS = [
'authorizations.apps.AuthorizationsConfig',
'reporting.apps.ReportingConfig',
'panoramax.apps.PanoramaxConfig',
'bim.apps.BimConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',

View file

@ -47,6 +47,7 @@ urlpatterns = [
path("road-detection/", include("road_detection.urls")),
path("authorizations/", include("authorizations.urls")),
path("panoramax/", include("panoramax.urls")),
path("bim/", include("bim.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/logout/', auth_views.LogoutView.as_view(next_page='login'), name='logout'),