diff --git a/resources/views/juil/bim-viewer.blade.php b/resources/views/juil/bim-viewer.blade.php index bbaf2932..c872e18d 100644 --- a/resources/views/juil/bim-viewer.blade.php +++ b/resources/views/juil/bim-viewer.blade.php @@ -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 (
- {/* Back */} PMIS 대시보드 - {/* Profile */}
- {profile?.profile_photo_path - ? - : } + {profile?.profile_photo_path ? : }
{profile?.name || '...'}
{profile?.department || ''}
- {/* Menus */}
{menus.map(m => (
- - {m.label} + {m.label}
- {m.active && m.children && m.children.map(c => ( + {m.active && m.children?.map(c => (
{c.label}
@@ -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 ( -
- {/* View Presets */} +
+ {/* IFC 업로드 */} +
+ + + {mode === 'ifc' && ( + + )} +
+ +
+ + {/* 시점 */}
시점 {views.map(v => ( ))}
-
- - {/* Element Toggles */} -
- 요소 - {toggles.map(t => ( - - ))} -
+ {mode === 'demo' && (<> +
+
+ 요소 + {toggles.map(t => ( + + ))} +
+ )}
- - {/* Wireframe */}
); } -/* ─── 우측 정보 패널 ─── */ -function BimInfoPanel({ selected, counts }) { +function BimInfoPanel({ selected, counts, mode, ifcModelInfo }) { + const demoInfo = mode === 'demo'; return (
{/* 모델 정보 */}

- 모델 정보 + + {demoInfo ? '모델 정보' : 'IFC 모델'}

- - - {[['현장', MODEL_INFO.projectName], ['모델', MODEL_INFO.modelName], ['용도', MODEL_INFO.buildingType], - ['규모', MODEL_INFO.floors], ['면적', MODEL_INFO.area], ['높이', MODEL_INFO.height], - ['수정일', MODEL_INFO.updatedAt]].map(([k, v]) => ( - - - - + {demoInfo ? ( +
{k}{v}
+ {[['현장', DEMO_INFO.projectName], ['모델', DEMO_INFO.modelName], ['용도', DEMO_INFO.buildingType], + ['규모', DEMO_INFO.floors], ['면적', DEMO_INFO.area], ['높이', DEMO_INFO.height], ['수정일', DEMO_INFO.updatedAt]].map(([k, v]) => ( + ))} - -
{k}{v}
+ + ) : ifcModelInfo && ( + + {[['파일명', ifcModelInfo.name], ['크기', ifcModelInfo.size], ['요소 수', ifcModelInfo.elements], ['메시 수', ifcModelInfo.meshes]].map(([k, v]) => ( + + ))} +
{k}{v}
+ )}
{/* 선택 요소 */} @@ -446,26 +602,26 @@ function BimInfoPanel({ selected, counts }) { {selected ? (
- - {selected.name} + {!selected.isIFC && } + {selected.name || selected.typeLabel || '(이름 없음)'}
- - - {[['유형', TYPE_LABEL[selected.type] || selected.type], - ['재질', selected.material], - ['층', selected.floor], - ['치수', selected.dimensions], - ['면', selected.face], - ['방향', selected.direction], - ['격자', selected.grid], +
+ {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]) => ( - - - - - ))} - -
{k}{v}
+ {k}{v} + )) + ) : ( + [['유형', TYPE_LABEL[selected.type] || selected.type], ['재질', selected.material], + ['층', selected.floor], ['치수', selected.dimensions], ['면', selected.face], + ['방향', selected.direction], ['격자', selected.grid], + ].filter(([, v]) => v).map(([k, v]) => ( + {k}{v} + )) + )} +
) : (
@@ -484,7 +640,7 @@ function BimInfoPanel({ selected, counts }) { {Object.entries(counts).map(([type, count]) => (
- + {!mode || mode === 'demo' ? : } {TYPE_LABEL[type] || type} {count} @@ -500,6 +656,19 @@ function BimInfoPanel({ selected, counts }) { ); } +function LoadingOverlay({ message }) { + if (!message) return null; + return ( +
+
+
+

{message}

+

잠시만 기다려 주세요...

+
+
+ ); +} + /* ════════════════════════════════════════════════ 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 (
-
- {/* 3D Viewport */} +
- + {/* Drag overlay */} + {dragOver && ( +
+
+ +

IFC 파일을 여기에 놓으세요

+
+
+ )} + +
- +
); }