feat: [bim] Phase 2 - IFC 파일 업로드 및 실제 BIM 모델 렌더링

- web-ifc WASM 엔진으로 IFC 파일 파싱 (지연 로딩)
- IFC 지오메트리 → Three.js BufferGeometry 직접 변환
- 요소 클릭 시 IFC 속성 조회 (이름, 타입, GlobalId 등)
- 드래그 앤 드롭 + 파일 업로드 버튼
- 로딩 오버레이 (엔진 초기화, 파싱, 모델 생성 단계별)
- 데모 모델 ↔ IFC 모델 전환
- IFC 모델 자동 카메라 피팅
- IFC 요소 타입별 통계 표시
This commit is contained in:
김보곤
2026-03-12 13:17:11 +09:00
parent c7fd350a4c
commit 60475f641a

View File

@@ -16,15 +16,13 @@
@verbatim
const { useState, useEffect, useRef, useCallback } = React;
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
/* ════════════════════════════════════════════════
상수
════════════════════════════════════════════════ */
const COLORS = {
floor: 0x81C784, roof: 0x4CAF50, column: 0xFF9800,
beam: 0xFFC107, wall: 0x42A5F5, window: 0x00BCD4,
door: 0x8B4513, stair: 0xFFAB91, ground: 0xE8E0D0,
floor: 0x81C784, roof: 0x4CAF50, column: 0xFF9800,
beam: 0xFFC107, wall: 0x42A5F5, window: 0x00BCD4,
door: 0x8B4513, stair: 0xFFAB91, ground: 0xE8E0D0,
};
const TYPE_LABEL = {
floor:'바닥 슬래브', roof:'지붕', column:'기둥', beam:'보',
@@ -35,16 +33,121 @@
wall:'ri-layout-masonry-line', window:'ri-window-line', roof:'ri-home-4-line',
stair:'ri-stairs-line', door:'ri-door-open-line',
};
const MODEL_INFO = {
projectName: 'SAM 물류센터 (데모)',
modelName: '구조 + 건축 통합 모델',
buildingType: '물류창고 / 사무동',
floors: '지상 3층',
area: '60m × 30m (1,800㎡)',
height: '12.0m',
updatedAt: '2026-03-12 10:30',
const DEMO_INFO = {
projectName: 'SAM 물류센터 (데모)', modelName: '구조 + 건축 통합 모델',
buildingType: '물류창고 / 사무동', floors: '지상 3층',
area: '60m × 30m (1,800㎡)', height: '12.0m', updatedAt: '2026-03-12',
};
const IFC_TYPE_KR = {
IFCWALL:'벽체', IFCWALLSTANDARDCASE:'벽체', IFCWALLELEMENTEDCASE:'복합벽체',
IFCCOLUMN:'기둥', IFCBEAM:'보', IFCSLAB:'슬래브',
IFCWINDOW:'창호', IFCDOOR:'문', IFCROOF:'지붕',
IFCSTAIR:'계단', IFCSTAIRFLIGHT:'계단참', IFCRAILING:'난간',
IFCPLATE:'플레이트', IFCMEMBER:'부재', IFCCURTAINWALL:'커튼월',
IFCFOOTING:'기초', IFCPILE:'말뚝', IFCBUILDINGELEMENTPROXY:'기타요소',
IFCFURNISHINGELEMENT:'가구', IFCFLOWSEGMENT:'배관/덕트',
IFCFLOWTERMINAL:'설비단말', IFCFLOWFITTING:'배관피팅',
IFCSPACE:'공간', IFCSITE:'대지', IFCBUILDING:'건물',
IFCBUILDINGSTOREY:'층', IFCOPENINGELEMENT:'개구부', IFCCOVERING:'마감재',
};
const WEBIFC_VER = '0.0.66';
const WEBIFC_CDN = `https://cdn.jsdelivr.net/npm/web-ifc@${WEBIFC_VER}/`;
/* ════════════════════════════════════════════════
web-ifc 지연 로더
════════════════════════════════════════════════ */
function loadWebIFCLib() {
if (window._wifcPromise) return window._wifcPromise;
window._wifcPromise = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.type = 'module';
s.textContent = `
import * as WebIFC from '${WEBIFC_CDN}web-ifc-api.js';
window._WebIFC = WebIFC;
window.dispatchEvent(new CustomEvent('_wifcOk'));
`;
const ok = () => { window.removeEventListener('_wifcOk', ok); resolve(window._WebIFC); };
window.addEventListener('_wifcOk', ok);
document.head.appendChild(s);
setTimeout(() => { window.removeEventListener('_wifcOk', ok); reject(new Error('web-ifc 로드 타임아웃')); }, 120000);
});
return window._wifcPromise;
}
/* ════════════════════════════════════════════════
IFC Helper — web-ifc 파서 래퍼
════════════════════════════════════════════════ */
class IFCHelper {
constructor() { this.api = null; this.ready = false; this.modelID = null; this.typeMap = {}; this.typeCache = new Map(); }
async init(onMsg) {
if (this.ready) return;
if (onMsg) onMsg('IFC 엔진 초기화 중... (최초 1회)');
const mod = await loadWebIFCLib();
this.api = new mod.IfcAPI();
this.api.SetWasmPath(WEBIFC_CDN);
await this.api.Init();
for (const [k, v] of Object.entries(mod)) {
if (k.startsWith('IFC') && typeof v === 'number') this.typeMap[v] = k;
}
this.ready = true;
}
parse(buffer, onMsg) {
if (onMsg) onMsg('IFC 파일 파싱 중...');
const data = new Uint8Array(buffer);
this.modelID = this.api.OpenModel(data, { COORDINATE_TO_ORIGIN: true, USE_FAST_BOOLS: true });
if (onMsg) onMsg('3D 지오메트리 추출 중...');
const result = [];
this.api.StreamAllMeshes(this.modelID, (fm) => {
const eid = fm.expressID;
for (let i = 0; i < fm.geometries.size(); i++) {
const pg = fm.geometries.get(i);
const geo = this.api.GetGeometry(this.modelID, pg.geometryExpressID);
const verts = this.api.GetVertexArray(geo.GetVertexData(), geo.GetVertexDataSize());
const idx = this.api.GetIndexArray(geo.GetIndexData(), geo.GetIndexDataSize());
result.push({
expressID: eid,
vertices: new Float32Array(verts),
indices: new Uint32Array(idx),
color: { r: pg.color.x, g: pg.color.y, b: pg.color.z, a: pg.color.w },
transform: Array.from(pg.flatTransformation),
});
geo.delete();
}
});
this.typeCache.clear();
return result;
}
getElementInfo(eid) {
if (!this.api || this.modelID === null) return null;
if (this.typeCache.has(eid)) return this.typeCache.get(eid);
try {
const props = this.api.GetLine(this.modelID, eid);
const code = this.api.GetLineType(this.modelID, eid);
const ifcType = this.typeMap[code] || 'Unknown';
const info = {
expressID: eid, ifcType, typeLabel: IFC_TYPE_KR[ifcType] || ifcType,
name: this._v(props.Name), objectType: this._v(props.ObjectType),
description: this._v(props.Description), globalId: this._v(props.GlobalId),
tag: this._v(props.Tag), isIFC: true,
};
this.typeCache.set(eid, info);
return info;
} catch { return { expressID: eid, ifcType: 'Unknown', typeLabel: '알 수 없음', name: '', isIFC: true }; }
}
_v(p) { if (!p) return ''; if (typeof p === 'object' && p.value !== undefined) return String(p.value); return typeof p === 'string' ? p : ''; }
close() {
if (this.api && this.modelID !== null) { this.api.CloseModel(this.modelID); this.modelID = null; }
this.typeCache.clear();
}
}
/* ════════════════════════════════════════════════
Three.js BIM Scene Manager
════════════════════════════════════════════════ */
@@ -55,233 +158,281 @@ class BimScene {
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.meshes = [];
this.selected = null;
this.groups = {};
this.onSelect = null;
this.onProgress = null;
this._animId = null;
this._onClick = this.onClick.bind(this);
this._onResize = this.onResize.bind(this);
this.targetPos = null; this.targetLook = null;
// IFC
this.ifcHelper = null;
this.ifcGroup = null;
this.expressIDMap = new Map();
this.mode = 'demo';
this.selectedMeshes = [];
this.ifcCounts = {};
this.ground = null;
}
init() {
const w = this.el.clientWidth, h = this.el.clientHeight;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f4f8);
this.scene.fog = new THREE.FogExp2(0xf0f4f8, 0.004);
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 500);
this.camera.position.set(55, 35, 55);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.el.appendChild(this.renderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.target.set(30, 6, 15);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.update();
// Lights
this.scene.add(new THREE.HemisphereLight(0xb1e1ff, 0xb97a20, 0.55));
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
sun.position.set(45, 50, 35);
sun.castShadow = true;
sun.shadow.mapSize.width = 2048;
sun.shadow.mapSize.height = 2048;
const sc = sun.shadow.camera;
sc.left = -65; sc.right = 65; sc.top = 50; sc.bottom = -20; sc.near = 1; sc.far = 150;
sun.position.set(45, 50, 35); sun.castShadow = true;
sun.shadow.mapSize.width = 2048; sun.shadow.mapSize.height = 2048;
const sc = sun.shadow.camera; sc.left = -65; sc.right = 65; sc.top = 50; sc.bottom = -20; sc.near = 1; sc.far = 150;
this.scene.add(sun);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.25));
// Grid
// Grid + Axes
const grid = new THREE.GridHelper(140, 28, 0xc0c0c0, 0xe0e0e0);
grid.position.set(30, -0.01, 15);
this.scene.add(grid);
// Axes
const axes = new THREE.AxesHelper(4);
axes.position.set(-3, 0, -3);
this.scene.add(axes);
this.createBuilding();
grid.position.set(30, -0.01, 15); this.scene.add(grid);
const axes = new THREE.AxesHelper(4); axes.position.set(-3, 0, -3); this.scene.add(axes);
// Ground
this.ground = new THREE.Mesh(new THREE.PlaneGeometry(140, 100), new THREE.MeshPhongMaterial({ color: COLORS.ground }));
this.ground.rotation.x = -Math.PI / 2; this.ground.position.set(30, -0.02, 15); this.ground.receiveShadow = true;
this.scene.add(this.ground);
this.createDemoBuilding();
this.renderer.domElement.addEventListener('click', this._onClick);
window.addEventListener('resize', this._onResize);
this.animate();
}
mat(color, transparent) {
return new THREE.MeshPhongMaterial({
color, transparent: !!transparent, opacity: transparent ? 0.35 : 1,
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
});
return new THREE.MeshPhongMaterial({ color, transparent: !!transparent, opacity: transparent ? 0.35 : 1, side: transparent ? THREE.DoubleSide : THREE.FrontSide });
}
box(gx, gy, gz, pos, userData, group) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gx, gy, gz), this.mat(COLORS[userData.type], userData.type === 'window'));
mesh.position.set(pos[0], pos[1], pos[2]);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = userData;
mesh.castShadow = true; mesh.receiveShadow = true; mesh.userData = userData;
if (!this.groups[group]) { this.groups[group] = new THREE.Group(); this.groups[group].name = group; this.scene.add(this.groups[group]); }
this.groups[group].add(mesh);
this.meshes.push(mesh);
this.groups[group].add(mesh); this.meshes.push(mesh);
return mesh;
}
createBuilding() {
const W = 60, D = 30, FH = 4, NF = 3;
const CX = 7, CZ = 4, SX = 10, SZ = 10;
// Ground
const gnd = new THREE.Mesh(new THREE.PlaneGeometry(140, 100), new THREE.MeshPhongMaterial({ color: COLORS.ground }));
gnd.rotation.x = -Math.PI / 2; gnd.position.set(30, -0.02, 15); gnd.receiveShadow = true;
this.scene.add(gnd);
// ─── Slabs ───
createDemoBuilding() {
const W = 60, D = 30, FH = 4, NF = 3, CX = 7, CZ = 4, SX = 10, SZ = 10;
for (let f = 0; f <= NF; f++) {
const isRoof = f === NF;
const label = f === 0 ? '기초 슬래브' : isRoof ? '지붕 슬래브' : `${f}F 바닥 슬래브`;
const th = isRoof ? 0.4 : 0.3;
const isR = f === NF, label = f === 0 ? '기초 슬래브' : isR ? '지붕 슬래브' : `${f}F 바닥 슬래브`, th = isR ? 0.4 : 0.3;
this.box(W + 0.6, th, D + 0.6, [W / 2, f * FH, D / 2],
{ type: isRoof ? 'roof' : 'floor', name: label, material: '철근콘크리트 (24MPa)', floor: isRoof ? 'RF' : (f === 0 ? 'GL' : f + 'F'), dimensions: `${W}m × ${D}m × ${th * 1000}mm` },
isRoof ? 'roof' : 'floor');
{ type: isR ? 'roof' : 'floor', name: label, material: '철근콘크리트 (24MPa)', floor: isR ? 'RF' : (f === 0 ? 'GL' : f + 'F'), dimensions: `${W}m × ${D}m × ${th * 1000}mm` }, isR ? 'roof' : 'floor');
}
// ─── Columns ───
let cn = 0;
for (let f = 0; f < NF; f++) {
for (let ix = 0; ix < CX; ix++) {
for (let iz = 0; iz < CZ; iz++) {
cn++;
const ch = FH - 0.3;
this.box(0.45, ch, 0.45, [ix * SX, f * FH + 0.15 + ch / 2, iz * SZ],
{ type: 'column', name: `C-${String(cn).padStart(3, '0')}`, material: 'H형강 (SS400)', floor: `${f + 1}F`, dimensions: '400×400×3,700mm', grid: `(${ix + 1},${iz + 1})` },
'column');
}
}
for (let f = 0; f < NF; f++) for (let ix = 0; ix < CX; ix++) for (let iz = 0; iz < CZ; iz++) {
cn++; const ch = FH - 0.3;
this.box(0.45, ch, 0.45, [ix * SX, f * FH + 0.15 + ch / 2, iz * SZ],
{ type: 'column', name: `C-${String(cn).padStart(3, '0')}`, material: 'H형강 (SS400)', floor: `${f + 1}F`, dimensions: '400×400×3,700mm', grid: `(${ix + 1},${iz + 1})` }, 'column');
}
// ─── Beams ───
for (let f = 1; f <= NF; f++) {
const by = f * FH - 0.25;
for (let iz = 0; iz < CZ; iz++) {
this.box(W, 0.5, 0.3, [W / 2, by, iz * SZ],
{ type: 'beam', name: `GB-X${iz + 1}-${f}F`, material: 'H형강 (SS400)', floor: `${f}F`, dimensions: `${W}m × 300×500mm`, direction: 'X방향 대보' },
'beam');
}
for (let ix = 0; ix < CX; ix++) {
this.box(0.3, 0.45, D, [ix * SX, by, D / 2],
{ type: 'beam', name: `GB-Z${ix + 1}-${f}F`, material: 'H형강 (SS400)', floor: `${f}F`, dimensions: `${D}m × 300×450mm`, direction: 'Z방향 대보' },
'beam');
}
for (let iz = 0; iz < CZ; iz++) this.box(W, 0.5, 0.3, [W / 2, by, iz * SZ],
{ type: 'beam', name: `GB-X${iz + 1}-${f}F`, material: 'H형강 (SS400)', floor: `${f}F`, dimensions: `${W}m × 300×500mm` }, 'beam');
for (let ix = 0; ix < CX; ix++) this.box(0.3, 0.45, D, [ix * SX, by, D / 2],
{ type: 'beam', name: `GB-Z${ix + 1}-${f}F`, material: 'H형강 (SS400)', floor: `${f}F`, dimensions: `${D}m × 300×450mm` }, 'beam');
}
// ─── Walls & Windows ───
const segW = SX - 1;
for (let f = 0; f < NF; f++) {
const baseY = f * FH + 0.3;
const wh = FH - 0.6;
const segW = SX - 1;
// Front (z = 0) — windows
const baseY = f * FH + 0.3, wh = FH - 0.6;
for (let s = 0; s < CX - 1; s++) {
const cx = s * SX + SX / 2;
const bh = 1.0, winH = 2.0, ah = wh - bh - winH;
this.box(segW, bh, 0.25, [cx, baseY + bh / 2, 0],
{ type: 'wall', name: `W-F-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F`, dimensions: `${segW}m × ${bh}m`, face: '전면(남)' }, 'wall');
this.box(segW - 0.6, winH, 0.06, [cx, baseY + bh + winH / 2, 0],
{ type: 'window', name: `WIN-F-${f + 1}F-${s + 1}`, material: '복층유리 (24mm)', floor: `${f + 1}F`, dimensions: `${(segW - 0.6).toFixed(1)}m × ${winH}m`, face: '전면(남)' }, 'window');
if (ah > 0.1)
this.box(segW, ah, 0.25, [cx, baseY + bh + winH + ah / 2, 0],
{ type: 'wall', name: `W-F-${f + 1}F-${s + 1}t`, material: 'ALC 패널', floor: `${f + 1}F`, face: '전면(남)' }, 'wall');
const cx = s * SX + SX / 2, bh = 1.0, winH = 2.0, ah = wh - bh - winH;
this.box(segW, bh, 0.25, [cx, baseY + bh / 2, 0], { type: 'wall', name: `W-F-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F` }, 'wall');
this.box(segW - 0.6, winH, 0.06, [cx, baseY + bh + winH / 2, 0], { type: 'window', name: `WIN-F-${f + 1}F-${s + 1}`, material: '복층유리 (24mm)', floor: `${f + 1}F`, dimensions: `${(segW - 0.6).toFixed(1)}m × ${winH}m` }, 'window');
if (ah > 0.1) this.box(segW, ah, 0.25, [cx, baseY + bh + winH + ah / 2, 0], { type: 'wall', name: `W-F-${f + 1}F-${s + 1}t`, material: 'ALC 패널', floor: `${f + 1}F` }, 'wall');
}
// Back (z = D)
for (let s = 0; s < CX - 1; s++) {
const cx = s * SX + SX / 2;
if (f === 0 && s % 2 === 0) {
// Loading dock door
const dh = 3.5, dw = 5;
this.box(dw, dh, 0.12, [cx, baseY + dh / 2, D],
{ type: 'door', name: `DOCK-${Math.floor(s / 2) + 1}`, material: '스틸 오버헤드도어', floor: '1F', dimensions: `${dw}m × ${dh}m`, face: '후면(북) 하역장' }, 'wall');
const aboveH = wh - dh;
if (aboveH > 0.1)
this.box(segW, aboveH, 0.25, [cx, baseY + dh + aboveH / 2, D],
{ type: 'wall', name: `W-B-1F-${s + 1}t`, material: 'ALC 패널', floor: '1F', face: '후면(북)' }, 'wall');
this.box(dw, dh, 0.12, [cx, baseY + dh / 2, D], { type: 'door', name: `DOCK-${Math.floor(s / 2) + 1}`, material: '스틸 오버헤드도어', floor: '1F', dimensions: `${dw}m × ${dh}m` }, 'wall');
const abH = wh - dh; if (abH > 0.1) this.box(segW, abH, 0.25, [cx, baseY + dh + abH / 2, D], { type: 'wall', name: `W-B-1F-${s + 1}t`, material: 'ALC 패널', floor: '1F' }, 'wall');
} else {
this.box(segW, wh, 0.25, [cx, baseY + wh / 2, D],
{ type: 'wall', name: `W-B-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F`, dimensions: `${segW}m × ${wh.toFixed(1)}m`, face: '후면(북)' }, 'wall');
this.box(segW, wh, 0.25, [cx, baseY + wh / 2, D], { type: 'wall', name: `W-B-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F` }, 'wall');
}
}
// Sides (x = 0, x = W)
[0, W].forEach((x, si) => {
const face = si === 0 ? '좌측(서)' : '우측(동)';
for (let s = 0; s < CZ - 1; s++) {
const cz = s * SZ + SZ / 2;
this.box(0.25, wh, segW, [x, baseY + wh / 2, cz],
{ type: 'wall', name: `W-S${si}-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F`, dimensions: `${segW}m × ${wh.toFixed(1)}m`, face }, 'wall');
}
for (let s = 0; s < CZ - 1; s++) this.box(0.25, wh, segW, [x, baseY + wh / 2, s * SZ + SZ / 2],
{ type: 'wall', name: `W-S${si}-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F` }, 'wall');
});
}
// ─── Stair Core ───
const sx = 53, sz = 13, sw = 5, sd = 7;
for (let f = 0; f < NF; f++) {
const baseY = f * FH + 0.3, wh = FH - 0.6, my = baseY + wh / 2;
[[sw, wh, 0.2, [sx, my, sz - sd / 2]], [sw, wh, 0.2, [sx, my, sz + sd / 2]],
[0.2, wh, sd, [sx - sw / 2, my, sz]], [0.2, wh, sd, [sx + sw / 2, my, sz]]].forEach((p, i) => {
this.box(p[0], p[1], p[2], p[3],
{ type: 'stair', name: `STAIR-${f + 1}F-${i + 1}`, material: '철근콘크리트', floor: `${f + 1}F`, dimensions: '계단/EV 코어' }, 'stair');
this.box(p[0], p[1], p[2], p[3], { type: 'stair', name: `STAIR-${f + 1}F-${i + 1}`, material: '철근콘크리트', floor: `${f + 1}F` }, 'stair');
});
// Stair landing slab
this.box(sw - 0.4, 0.15, sd - 0.4, [sx, baseY + wh / 2, sz],
{ type: 'stair', name: `STAIR-LAND-${f + 1}F`, material: '철근콘크리트', floor: `${f + 1}F`, dimensions: '계단 참 슬래브' }, 'stair');
this.box(sw - 0.4, 0.15, sd - 0.4, [sx, baseY + wh / 2, sz], { type: 'stair', name: `STAIR-LAND-${f + 1}F`, material: '철근콘크리트', floor: `${f + 1}F` }, 'stair');
}
}
/* ── IFC 로딩 ── */
async loadIFC(buffer) {
const msg = (t) => { if (this.onProgress) this.onProgress(t); };
if (!this.ifcHelper) this.ifcHelper = new IFCHelper();
await this.ifcHelper.init(msg);
const geometries = this.ifcHelper.parse(buffer, msg);
msg('3D 씬 구성 중...');
this.clearIFCModel();
this.setDemoVisible(false);
this.ifcGroup = new THREE.Group(); this.ifcGroup.name = 'ifc-model';
this.expressIDMap.clear();
const ifcMeshes = [];
for (const geo of geometries) {
const vc = geo.vertices.length / 6;
const pos = new Float32Array(vc * 3), nrm = new Float32Array(vc * 3);
for (let j = 0; j < vc; j++) {
pos[j*3] = geo.vertices[j*6]; pos[j*3+1] = geo.vertices[j*6+1]; pos[j*3+2] = geo.vertices[j*6+2];
nrm[j*3] = geo.vertices[j*6+3]; nrm[j*3+1] = geo.vertices[j*6+4]; nrm[j*3+2] = geo.vertices[j*6+5];
}
const bg = new THREE.BufferGeometry();
bg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
bg.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
bg.setIndex(new THREE.BufferAttribute(geo.indices, 1));
bg.applyMatrix4(new THREE.Matrix4().fromArray(geo.transform));
const mtl = new THREE.MeshPhongMaterial({
color: new THREE.Color(geo.color.r, geo.color.g, geo.color.b),
transparent: geo.color.a < 0.99, opacity: geo.color.a,
side: geo.color.a < 0.99 ? THREE.DoubleSide : THREE.FrontSide,
});
const m = new THREE.Mesh(bg, mtl);
m.castShadow = true; m.receiveShadow = true;
m.userData = { expressID: geo.expressID, isIFC: true };
this.ifcGroup.add(m);
ifcMeshes.push(m);
if (!this.expressIDMap.has(geo.expressID)) this.expressIDMap.set(geo.expressID, []);
this.expressIDMap.get(geo.expressID).push(m);
}
this.meshes = ifcMeshes;
this.scene.add(this.ifcGroup);
this.mode = 'ifc';
this.computeIFCCounts();
this.fitToModel(this.ifcGroup);
return { meshCount: geometries.length, elementCount: this.expressIDMap.size };
}
computeIFCCounts() {
const c = {};
this.expressIDMap.forEach((_, eid) => {
const info = this.ifcHelper?.getElementInfo(eid);
const label = info?.typeLabel || 'Unknown';
c[label] = (c[label] || 0) + 1;
});
this.ifcCounts = c;
}
fitToModel(obj) {
const box = new THREE.Box3().setFromObject(obj);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.5;
this.targetPos = new THREE.Vector3(center.x + dist * 0.6, center.y + dist * 0.4, center.z + dist * 0.6);
this.targetLook = center.clone();
}
setDemoVisible(vis) {
Object.values(this.groups).forEach(g => { g.visible = vis; });
if (this.ground) this.ground.visible = vis;
}
switchToDemo() {
this.clearSelection();
this.clearIFCModel();
this.setDemoVisible(true);
this.mode = 'demo';
this.meshes = [];
Object.values(this.groups).forEach(g => g.traverse(c => { if (c.isMesh) this.meshes.push(c); }));
this.setView('perspective');
}
clearIFCModel() {
if (this.ifcGroup) {
this.scene.remove(this.ifcGroup);
this.ifcGroup.traverse(c => { if (c.geometry) c.geometry.dispose(); if (c.material) c.material.dispose(); });
this.ifcGroup = null;
}
this.expressIDMap.clear();
if (this.ifcHelper) this.ifcHelper.close();
}
/* ── 클릭 선택 ── */
onClick(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const hits = this.raycaster.intersectObjects(this.meshes);
if (this.selected) { this.selected.material.emissive.setHex(0); this.selected = null; }
this.clearSelection();
if (hits.length > 0) {
this.selected = hits[0].object;
this.selected.material.emissive.setHex(0x444400);
if (this.onSelect) this.onSelect(this.selected.userData);
const hit = hits[0].object;
if (hit.userData.isIFC) {
const eid = hit.userData.expressID;
const group = this.expressIDMap.get(eid) || [hit];
group.forEach(m => { m.material.emissive.setHex(0x444400); this.selectedMeshes.push(m); });
const info = this.ifcHelper?.getElementInfo(eid);
if (this.onSelect) this.onSelect(info || hit.userData);
} else {
hit.material.emissive.setHex(0x444400);
this.selectedMeshes.push(hit);
if (this.onSelect) this.onSelect(hit.userData);
}
} else {
if (this.onSelect) this.onSelect(null);
}
}
clearSelection() { this.selectedMeshes.forEach(m => m.material.emissive.setHex(0)); this.selectedMeshes = []; }
setView(p) {
const t = new THREE.Vector3(30, 6, 15);
const pos = { perspective: [55, 35, 55], top: [30, 55, 15.01], front: [30, 8, -35], right: [85, 8, 15], back: [30, 8, 55] };
const center = this.mode === 'ifc' && this.ifcGroup
? new THREE.Box3().setFromObject(this.ifcGroup).getCenter(new THREE.Vector3())
: new THREE.Vector3(30, 6, 15);
const d = this.mode === 'ifc' && this.ifcGroup
? new THREE.Box3().setFromObject(this.ifcGroup).getSize(new THREE.Vector3()).length() * 0.8
: 40;
const pos = {
perspective: [center.x + d, center.y + d * 0.6, center.z + d],
top: [center.x, center.y + d * 1.4, center.z + 0.01],
front: [center.x, center.y, center.z - d * 1.2],
right: [center.x + d * 1.2, center.y, center.z],
back: [center.x, center.y, center.z + d * 1.2],
};
this.targetPos = new THREE.Vector3(...(pos[p] || pos.perspective));
this.targetLook = t.clone();
this.targetLook = center.clone();
}
toggleGroup(name, vis) { if (this.groups[name]) this.groups[name].visible = vis; }
toggleWireframe(on) { this.meshes.forEach(m => { m.material.wireframe = on; }); }
getCounts() {
if (this.mode === 'ifc') return this.ifcCounts;
const c = {};
this.meshes.forEach(m => { const t = m.userData.type; c[t] = (c[t] || 0) + 1; });
this.meshes.forEach(m => { const t = m.userData.type; if (t) c[t] = (c[t] || 0) + 1; });
return c;
}
onResize() {
const w = this.el.clientWidth, h = this.el.clientHeight;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
@@ -300,6 +451,7 @@ class BimScene {
cancelAnimationFrame(this._animId);
this.renderer.domElement.removeEventListener('click', this._onClick);
window.removeEventListener('resize', this._onResize);
this.clearIFCModel();
this.renderer.dispose();
if (this.el.contains(this.renderer.domElement)) this.el.removeChild(this.renderer.domElement);
}
@@ -309,14 +461,12 @@ class BimScene {
React Components
════════════════════════════════════════════════ */
/* ─── 사이드바 ─── */
function BimSidebar() {
const [profile, setProfile] = useState(null);
useEffect(() => {
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
.then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {});
}, []);
const menus = [
{ icon: 'ri-building-2-line', label: 'BIM 관리', active: true, children: [{ label: 'BIM 뷰어', active: true }] },
{ icon: 'ri-line-chart-line', label: '시공관리' },
@@ -324,33 +474,26 @@ function BimSidebar() {
{ icon: 'ri-shield-check-line', label: '안전관리' },
{ icon: 'ri-folder-line', label: '자료실' },
];
return (
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0" style={{ width: 200 }}>
{/* Back */}
<a href="/juil/construction-pmis" className="flex items-center gap-2 px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 border-b border-gray-100 transition">
<i className="ri-arrow-left-s-line text-lg"></i> PMIS 대시보드
</a>
{/* Profile */}
<div className="p-3 border-b border-gray-100 text-center">
<div className="w-12 h-12 mx-auto mb-1 rounded-full bg-gray-100 border-2 border-gray-200 flex items-center justify-center">
{profile?.profile_photo_path
? <img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover" />
: <i className="ri-user-3-line text-xl text-gray-300"></i>}
{profile?.profile_photo_path ? <img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover" /> : <i className="ri-user-3-line text-xl text-gray-300"></i>}
</div>
<div className="text-sm font-bold text-gray-800">{profile?.name || '...'}</div>
<div className="text-xs text-gray-500 mt-0.5">{profile?.department || ''}</div>
</div>
{/* Menus */}
<div className="flex-1 overflow-auto py-1">
{menus.map(m => (
<div key={m.label}>
<div className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${m.active ? 'bg-blue-50 text-blue-700 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}>
<i className={`${m.icon} text-base`}></i>
{m.label}
<i className={`${m.icon} text-base`}></i> {m.label}
<i className={`ml-auto ${m.active ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>
</div>
{m.active && m.children && m.children.map(c => (
{m.active && m.children?.map(c => (
<div key={c.label} className={`pl-10 pr-4 py-2 text-sm ${c.active ? 'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600' : 'text-gray-500 hover:text-blue-600'}`}>
{c.label}
</div>
@@ -362,8 +505,8 @@ function BimSidebar() {
);
}
/* ─── 하단 툴바 ─── */
function BimToolbar({ onView, visibility, onToggle, wireframe, onWireframe }) {
function BimToolbar({ onView, visibility, onToggle, wireframe, onWireframe, mode, onUpload, onSwitchDemo }) {
const fileRef = useRef(null);
const views = [
{ id: 'perspective', icon: 'ri-box-3-line', label: '투시도' },
{ id: 'front', icon: 'ri-layout-bottom-2-line', label: '정면' },
@@ -372,70 +515,83 @@ function BimToolbar({ onView, visibility, onToggle, wireframe, onWireframe }) {
{ id: 'back', icon: 'ri-arrow-go-back-line', label: '배면' },
];
const toggles = ['column', 'beam', 'floor', 'wall', 'window', 'roof', 'stair'];
const handleFile = (e) => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; };
return (
<div className="absolute bottom-0 left-0 right-0 bg-white/95 backdrop-blur border-t border-gray-200 px-4 py-2 flex items-center gap-4 text-xs z-10">
{/* View Presets */}
<div className="absolute bottom-0 left-0 right-0 bg-white/95 backdrop-blur border-t border-gray-200 px-4 py-2 flex items-center gap-3 text-xs z-10 flex-wrap">
{/* IFC 업로드 */}
<div className="flex items-center gap-1">
<input ref={fileRef} type="file" accept=".ifc" className="hidden" onChange={handleFile} />
<button onClick={() => fileRef.current?.click()}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 transition font-semibold">
<i className="ri-upload-2-line text-sm"></i> IFC 업로드
</button>
{mode === 'ifc' && (
<button onClick={onSwitchDemo} className="flex items-center gap-1 px-2 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition">
<i className="ri-building-line text-sm"></i> 데모 모델
</button>
)}
</div>
<div className="w-px h-6 bg-gray-300"></div>
{/* 시점 */}
<div className="flex items-center gap-1">
<span className="text-gray-500 font-semibold mr-1">시점</span>
{views.map(v => (
<button key={v.id} onClick={() => onView(v.id)}
className="flex items-center gap-1 px-2 py-1.5 rounded hover:bg-blue-50 hover:text-blue-700 text-gray-600 transition"
title={v.label}>
<i className={`${v.icon} text-sm`}></i>
<span className="hidden xl:inline">{v.label}</span>
className="flex items-center gap-1 px-2 py-1.5 rounded hover:bg-blue-50 hover:text-blue-700 text-gray-600 transition" title={v.label}>
<i className={`${v.icon} text-sm`}></i><span className="hidden xl:inline">{v.label}</span>
</button>
))}
</div>
<div className="w-px h-6 bg-gray-300"></div>
{/* Element Toggles */}
<div className="flex items-center gap-1">
<span className="text-gray-500 font-semibold mr-1">요소</span>
{toggles.map(t => (
<button key={t} onClick={() => onToggle(t)}
className={`flex items-center gap-1 px-2 py-1.5 rounded transition ${visibility[t] !== false ? 'bg-gray-100 text-gray-700' : 'bg-gray-50 text-gray-300 line-through'}`}
title={TYPE_LABEL[t]}>
<i className={`${TYPE_ICONS[t] || 'ri-checkbox-blank-line'} text-sm`}></i>
<span className="hidden xl:inline">{TYPE_LABEL[t]}</span>
</button>
))}
</div>
{mode === 'demo' && (<>
<div className="w-px h-6 bg-gray-300"></div>
<div className="flex items-center gap-1">
<span className="text-gray-500 font-semibold mr-1">요소</span>
{toggles.map(t => (
<button key={t} onClick={() => onToggle(t)}
className={`flex items-center gap-1 px-2 py-1.5 rounded transition ${visibility[t] !== false ? 'bg-gray-100 text-gray-700' : 'bg-gray-50 text-gray-300 line-through'}`} title={TYPE_LABEL[t]}>
<i className={`${TYPE_ICONS[t] || 'ri-checkbox-blank-line'} text-sm`}></i><span className="hidden xl:inline">{TYPE_LABEL[t]}</span>
</button>
))}
</div>
</>)}
<div className="w-px h-6 bg-gray-300"></div>
{/* Wireframe */}
<button onClick={onWireframe}
className={`flex items-center gap-1 px-2 py-1.5 rounded transition ${wireframe ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'}`}>
<i className="ri-pencil-ruler-2-line text-sm"></i>
<span className="hidden xl:inline">와이어프레임</span>
<i className="ri-pencil-ruler-2-line text-sm"></i><span className="hidden xl:inline">와이어프레임</span>
</button>
</div>
);
}
/* ─── 우측 정보 패널 ─── */
function BimInfoPanel({ selected, counts }) {
function BimInfoPanel({ selected, counts, mode, ifcModelInfo }) {
const demoInfo = mode === 'demo';
return (
<div className="bg-white border-l border-gray-200 shadow-sm flex flex-col shrink-0 overflow-auto" style={{ width: 280 }}>
{/* 모델 정보 */}
<div className="p-4 border-b border-gray-100">
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-1 mb-3">
<i className="ri-information-line text-blue-500"></i> 모델 정보
<i className={`${demoInfo ? 'ri-information-line text-blue-500' : 'ri-file-3d-line text-green-500'}`}></i>
{demoInfo ? '모델 정보' : 'IFC 모델'}
</h3>
<table className="w-full text-xs">
<tbody>
{[['현장', MODEL_INFO.projectName], ['모델', MODEL_INFO.modelName], ['용도', MODEL_INFO.buildingType],
['규모', MODEL_INFO.floors], ['면적', MODEL_INFO.area], ['높이', MODEL_INFO.height],
['수정일', MODEL_INFO.updatedAt]].map(([k, v]) => (
<tr key={k} className="border-b border-gray-50">
<td className="py-1.5 pr-2 text-gray-500 font-medium whitespace-nowrap">{k}</td>
<td className="py-1.5 text-gray-800">{v}</td>
</tr>
{demoInfo ? (
<table className="w-full text-xs"><tbody>
{[['현장', DEMO_INFO.projectName], ['모델', DEMO_INFO.modelName], ['용도', DEMO_INFO.buildingType],
['규모', DEMO_INFO.floors], ['면적', DEMO_INFO.area], ['높이', DEMO_INFO.height], ['수정일', DEMO_INFO.updatedAt]].map(([k, v]) => (
<tr key={k} className="border-b border-gray-50"><td className="py-1.5 pr-2 text-gray-500 font-medium whitespace-nowrap">{k}</td><td className="py-1.5 text-gray-800">{v}</td></tr>
))}
</tbody>
</table>
</tbody></table>
) : ifcModelInfo && (
<table className="w-full text-xs"><tbody>
{[['파일명', ifcModelInfo.name], ['크기', ifcModelInfo.size], ['요소 수', ifcModelInfo.elements], ['메시 수', ifcModelInfo.meshes]].map(([k, v]) => (
<tr key={k} className="border-b border-gray-50"><td className="py-1.5 pr-2 text-gray-500 font-medium whitespace-nowrap">{k}</td><td className="py-1.5 text-gray-800">{v}</td></tr>
))}
</tbody></table>
)}
</div>
{/* 선택 요소 */}
@@ -446,26 +602,26 @@ function BimInfoPanel({ selected, counts }) {
{selected ? (
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#' + (COLORS[selected.type] || 0x999999).toString(16).padStart(6, '0') }}></span>
<span className="text-sm font-bold text-gray-800">{selected.name}</span>
{!selected.isIFC && <span className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#' + (COLORS[selected.type] || 0x999999).toString(16).padStart(6, '0') }}></span>}
<span className="text-sm font-bold text-gray-800">{selected.name || selected.typeLabel || '(이름 없음)'}</span>
</div>
<table className="w-full text-xs">
<tbody>
{[['유형', TYPE_LABEL[selected.type] || selected.type],
['재질', selected.material],
['', selected.floor],
['치수', selected.dimensions],
['면', selected.face],
['방향', selected.direction],
['격자', selected.grid],
<table className="w-full text-xs"><tbody>
{selected.isIFC ? (
[['IFC 타입', selected.ifcType], ['유형', selected.typeLabel], ['이름', selected.name],
['객체 타입', selected.objectType], ['설명', selected.description],
['GlobalId', selected.globalId], ['Tag', selected.tag], ['Express ID', selected.expressID],
].filter(([, v]) => v).map(([k, v]) => (
<tr key={k} className="border-b border-gray-200/50">
<td className="py-1.5 pr-2 text-gray-500 font-medium">{k}</td>
<td className="py-1.5 text-gray-700">{v}</td>
</tr>
))}
</tbody>
</table>
<tr key={k} className="border-b border-gray-200/50"><td className="py-1.5 pr-2 text-gray-500 font-medium">{k}</td><td className="py-1.5 text-gray-700 break-all">{v}</td></tr>
))
) : (
[['유형', TYPE_LABEL[selected.type] || selected.type], ['재질', selected.material],
['층', selected.floor], ['치수', selected.dimensions], ['면', selected.face],
['방향', selected.direction], ['격자', selected.grid],
].filter(([, v]) => v).map(([k, v]) => (
<tr key={k} className="border-b border-gray-200/50"><td className="py-1.5 pr-2 text-gray-500 font-medium">{k}</td><td className="py-1.5 text-gray-700">{v}</td></tr>
))
)}
</tbody></table>
</div>
) : (
<div className="text-xs text-gray-400 text-center py-6">
@@ -484,7 +640,7 @@ function BimInfoPanel({ selected, counts }) {
{Object.entries(counts).map(([type, count]) => (
<div key={type} className="flex items-center justify-between text-xs py-1">
<span className="flex items-center gap-2 text-gray-600">
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: '#' + (COLORS[type] || 0x999999).toString(16).padStart(6, '0') }}></span>
{!mode || mode === 'demo' ? <span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: '#' + (COLORS[type] || 0x999999).toString(16).padStart(6, '0') }}></span> : <span className="w-2.5 h-2.5 rounded-sm bg-blue-400"></span>}
{TYPE_LABEL[type] || type}
</span>
<span className="font-mono text-gray-800 font-semibold">{count}</span>
@@ -500,6 +656,19 @@ function BimInfoPanel({ selected, counts }) {
);
}
function LoadingOverlay({ message }) {
if (!message) return null;
return (
<div className="absolute inset-0 z-50 bg-black/40 flex items-center justify-center">
<div className="bg-white rounded-xl shadow-2xl p-8 flex flex-col items-center gap-4 max-w-sm">
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
<p className="text-sm text-gray-700 font-medium text-center">{message}</p>
<p className="text-xs text-gray-400">잠시만 기다려 주세요...</p>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
Root Component
════════════════════════════════════════════════ */
@@ -510,6 +679,10 @@ function BimViewerApp() {
const [counts, setCounts] = useState({});
const [visibility, setVisibility] = useState({});
const [wireframe, setWireframe] = useState(false);
const [mode, setMode] = useState('demo');
const [loading, setLoading] = useState(null);
const [ifcModelInfo, setIfcModelInfo] = useState(null);
const [dragOver, setDragOver] = useState(false);
useEffect(() => {
if (!vpRef.current) return;
@@ -521,27 +694,80 @@ function BimViewerApp() {
return () => bim.dispose();
}, []);
const handleUpload = useCallback(async (file) => {
if (!file || !file.name.toLowerCase().endsWith('.ifc')) {
alert('IFC 파일(.ifc)만 업로드 가능합니다.');
return;
}
setLoading('파일 읽는 중...');
setSelected(null);
try {
const buffer = await file.arrayBuffer();
const bim = sceneRef.current;
bim.onProgress = setLoading;
const result = await bim.loadIFC(buffer);
setCounts(bim.getCounts());
setMode('ifc');
setIfcModelInfo({
name: file.name,
size: (file.size / 1024 / 1024).toFixed(1) + ' MB',
elements: result.elementCount.toLocaleString(),
meshes: result.meshCount.toLocaleString(),
});
} catch (err) {
console.error('IFC 로드 실패:', err);
alert('IFC 파일 로드에 실패했습니다.\n\n' + err.message);
} finally {
setLoading(null);
}
}, []);
const handleSwitchDemo = useCallback(() => {
sceneRef.current?.switchToDemo();
setCounts(sceneRef.current?.getCounts() || {});
setMode('demo');
setSelected(null);
setIfcModelInfo(null);
}, []);
const handleView = useCallback(p => sceneRef.current?.setView(p), []);
const handleToggle = useCallback(t => {
setVisibility(prev => {
const next = { ...prev, [t]: prev[t] === false ? true : false };
sceneRef.current?.toggleGroup(t, next[t] !== false);
return next;
});
setVisibility(prev => { const next = { ...prev, [t]: prev[t] === false ? true : false }; sceneRef.current?.toggleGroup(t, next[t] !== false); return next; });
}, []);
const handleWire = useCallback(() => {
setWireframe(prev => { const n = !prev; sceneRef.current?.toggleWireframe(n); return n; });
}, []);
// Drag & Drop
const onDragOver = useCallback((e) => { e.preventDefault(); setDragOver(true); }, []);
const onDragLeave = useCallback(() => setDragOver(false), []);
const onDrop = useCallback((e) => {
e.preventDefault(); setDragOver(false);
const file = e.dataTransfer?.files?.[0];
if (file) handleUpload(file);
}, [handleUpload]);
return (
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
<BimSidebar />
<div className="flex-1 flex flex-col relative overflow-hidden">
{/* 3D Viewport */}
<div className="flex-1 flex flex-col relative overflow-hidden"
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div ref={vpRef} className="flex-1" style={{ minHeight: 0 }} />
<BimToolbar onView={handleView} visibility={visibility} onToggle={handleToggle} wireframe={wireframe} onWireframe={handleWire} />
{/* Drag overlay */}
{dragOver && (
<div className="absolute inset-0 z-40 bg-blue-500/20 border-4 border-dashed border-blue-500 flex items-center justify-center pointer-events-none">
<div className="bg-white rounded-xl shadow-lg px-8 py-6 text-center">
<i className="ri-upload-cloud-2-line text-4xl text-blue-500 block mb-2"></i>
<p className="text-sm font-semibold text-gray-700">IFC 파일을 여기에 놓으세요</p>
</div>
</div>
)}
<LoadingOverlay message={loading} />
<BimToolbar onView={handleView} visibility={visibility} onToggle={handleToggle}
wireframe={wireframe} onWireframe={handleWire} mode={mode}
onUpload={handleUpload} onSwitchDemo={handleSwitchDemo} />
</div>
<BimInfoPanel selected={selected} counts={counts} />
<BimInfoPanel selected={selected} counts={counts} mode={mode} ifcModelInfo={ifcModelInfo} />
</div>
);
}