Files
sam-manage/resources/views/juil/bim-viewer.blade.php
김보곤 c8fd3e2739 feat: [pmis] 시공관리 하위메뉴 6개 추가 및 인원관리 페이지 구현
- 시공관리 하위메뉴: 인원관리, 장비관리, 자재관리, 공사량관리, 출면일보, 작업일보
- 인원관리 4개 탭 구현: 인원등록, 출역현황, 투입현황(업체별), 투입현황(근로자별)
- PMIS 사이드바에 시공관리 children 메뉴 추가 (대시보드, BIM 뷰어 포함)
- 나머지 5개 메뉴 placeholder 페이지 생성
2026-03-12 13:52:20 +09:00

911 lines
48 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@extends('layouts.app')
@section('title', 'BIM 뷰어')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
@include('partials.react-cdn')
<script type="text/babel">
@verbatim
const { useState, useEffect, useRef, useCallback } = React;
/* ════════════════════════════════════════════════
상수
════════════════════════════════════════════════ */
const COLORS = {
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:'보',
wall:'벽체', window:'창호', door:'출입문', stair:'계단실',
};
const TYPE_ICONS = {
column:'ri-layout-grid-line', beam:'ri-ruler-line', floor:'ri-layout-bottom-line',
wall:'ri-layout-masonry-line', window:'ri-window-line', roof:'ri-home-4-line',
stair:'ri-stairs-line', door:'ri-door-open-line',
};
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();
}
}
/* ════════════════════════════════════════════════
IFC 파일 생성기 — 데모 건물 → IFC2X3 STEP 형식
════════════════════════════════════════════════ */
function generateDemoIFC(meshes) {
let _n = 0;
const N = () => '#' + (++_n);
const out = [];
const W = (id, s) => { out.push(`${id}= ${s};`); return id; };
const C64 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$';
const G = () => { let s=''; for(let i=0;i<22;i++) s+=C64[Math.random()*64|0]; return `'${s}'`; };
const F = v => v.toFixed(4);
// Setup
const per=W(N(),`IFCPERSON($,$,'SAM',$,$,$,$,$)`);
const org=W(N(),`IFCORGANIZATION($,'CodeBridgeX',$,$,$)`);
const po=W(N(),`IFCPERSONANDORGANIZATION(${per},${org},$)`);
const app=W(N(),`IFCAPPLICATION(${org},'1.0','SAM BIM','SAM')`);
const oh=W(N(),`IFCOWNERHISTORY(${po},${app},$,.NOCHANGE.,$,${po},${app},${Date.now()/1000|0})`);
// Units
const uL=W(N(),`IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.)`);
const uA=W(N(),`IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.)`);
const uV=W(N(),`IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.)`);
const uR=W(N(),`IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.)`);
const ua=W(N(),`IFCUNITASSIGNMENT((${uL},${uA},${uV},${uR}))`);
// Shared directions / origin
const dX=W(N(),`IFCDIRECTION((1.,0.,0.))`);
const dZ=W(N(),`IFCDIRECTION((0.,0.,1.))`);
const O=W(N(),`IFCCARTESIANPOINT((0.,0.,0.))`);
const ax0=W(N(),`IFCAXIS2PLACEMENT3D(${O},${dZ},${dX})`);
// Context
const ctx=W(N(),`IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,${ax0},$)`);
const bctx=W(N(),`IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,${ctx},$,.MODEL_VIEW.,$)`);
// Project → Site → Building
const proj=W(N(),`IFCPROJECT(${G()},${oh},'SAM Demo',$,$,'SAM 물류센터',$,(${ctx}),${ua})`);
const sPl=W(N(),`IFCLOCALPLACEMENT($,${ax0})`);
const site=W(N(),`IFCSITE(${G()},${oh},'Site',$,$,${sPl},$,$,.ELEMENT.,$,$,$,$,$)`);
const bPl=W(N(),`IFCLOCALPLACEMENT(${sPl},${ax0})`);
const bldg=W(N(),`IFCBUILDING(${G()},${oh},'SAM 물류센터',$,$,${bPl},$,$,.ELEMENT.,$,$,$)`);
// Storeys (1F~3F + RF)
const FH=4, NF=3, storeys=[], stPl=[];
for(let f=0;f<=NF;f++){
const z=f*FH, nm=f<NF?`${f+1}F`:'RF';
const pt=W(N(),`IFCCARTESIANPOINT((0.,0.,${F(z)}))`);
const ax=W(N(),`IFCAXIS2PLACEMENT3D(${pt},${dZ},${dX})`);
const pl=W(N(),`IFCLOCALPLACEMENT(${bPl},${ax})`);
storeys.push(W(N(),`IFCBUILDINGSTOREY(${G()},${oh},'${nm}',$,$,${pl},$,$,.ELEMENT.,${F(z)})`));
stPl.push(pl);
}
W(N(),`IFCRELAGGREGATES(${G()},${oh},$,$,${proj},(${site}))`);
W(N(),`IFCRELAGGREGATES(${G()},${oh},$,$,${site},(${bldg}))`);
W(N(),`IFCRELAGGREGATES(${G()},${oh},$,$,${bldg},(${storeys.join(',')}))`);
// Elements
const floorMap={'GL':0,'1F':0,'2F':1,'3F':2,'RF':3};
const typeMap={column:'IFCCOLUMN',beam:'IFCBEAM',floor:'IFCSLAB',roof:'IFCSLAB',
wall:'IFCWALLSTANDARDCASE',window:'IFCWINDOW',door:'IFCDOOR',stair:'IFCBUILDINGELEMENTPROXY'};
const byStorey={0:[],1:[],2:[],3:[]};
for(const mesh of meshes){
if(!mesh.geometry?.parameters) continue;
const {width:gx,height:gy,depth:gz}=mesh.geometry.parameters;
const {x:px,y:py,z:pz}=mesh.position;
const ud=mesh.userData, fi=floorMap[ud.floor]??0, sz=fi*FH;
const relZ=py-gy/2-sz;
const pt=W(N(),`IFCCARTESIANPOINT((${F(px)},${F(pz)},${F(relZ)}))`);
const ax=W(N(),`IFCAXIS2PLACEMENT3D(${pt},${dZ},${dX})`);
const pl=W(N(),`IFCLOCALPLACEMENT(${stPl[fi]},${ax})`);
const pf=W(N(),`IFCRECTANGLEPROFILEDEF(.AREA.,$,$,${F(gx)},${F(gz)})`);
const sol=W(N(),`IFCEXTRUDEDAREASOLID(${pf},$,${dZ},${F(gy)})`);
const sr=W(N(),`IFCSHAPEREPRESENTATION(${bctx},'Body','SweptSolid',(${sol}))`);
const ps=W(N(),`IFCPRODUCTDEFINITIONSHAPE($,$,(${sr}))`);
const it=typeMap[ud.type]||'IFCBUILDINGELEMENTPROXY';
const nm=(ud.name||'').replace(/'/g,''), desc=(ud.material||ud.type||'').replace(/'/g,'');
let elem;
if(it==='IFCWINDOW') elem=W(N(),`IFCWINDOW(${G()},${oh},'${nm}','${desc}',$,${pl},${ps},$,${F(gy)},${F(gx)})`);
else if(it==='IFCDOOR') elem=W(N(),`IFCDOOR(${G()},${oh},'${nm}','${desc}',$,${pl},${ps},$,${F(gy)},${F(gx)})`);
else elem=W(N(),`${it}(${G()},${oh},'${nm}','${desc}',$,${pl},${ps},$)`);
if(!byStorey[fi]) byStorey[fi]=[];
byStorey[fi].push(elem);
}
for(const [fi,elems] of Object.entries(byStorey)){
if(elems.length>0) W(N(),`IFCRELCONTAINEDINSPATIALSTRUCTURE(${G()},${oh},$,$,(${elems.join(',')}),${storeys[fi]})`);
}
const hdr=[
"ISO-10303-21;","HEADER;",
"FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1');",
`FILE_NAME('SAM_Demo_Building.ifc','${new Date().toISOString().slice(0,19)}',('SAM BIM Viewer'),('CodeBridgeX'),'','SAM','');`,
"FILE_SCHEMA(('IFC2X3'));","ENDSEC;","DATA;",
];
return [...hdr,...out,"ENDSEC;","END-ISO-10303-21;"].join('\n');
}
function downloadFile(name, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = name;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/* ════════════════════════════════════════════════
Three.js BIM Scene Manager
════════════════════════════════════════════════ */
class BimScene {
constructor(el) {
this.el = el;
this.scene = null; this.camera = null; this.renderer = null; this.controls = null;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.meshes = [];
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;
this.ifcBuffer = 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;
this.scene.add(sun);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.25));
// Grid + Axes
const grid = new THREE.GridHelper(140, 28, 0xc0c0c0, 0xe0e0e0);
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 });
}
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;
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);
return mesh;
}
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 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: 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');
}
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 = 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` }, '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');
}
const segW = SX - 1;
for (let f = 0; f < NF; f++) {
const baseY = f * FH + 0.3, wh = FH - 0.6;
for (let s = 0; s < CX - 1; s++) {
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');
}
for (let s = 0; s < CX - 1; s++) {
const cx = s * SX + SX / 2;
if (f === 0 && s % 2 === 0) {
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` }, '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` }, 'wall');
}
}
[0, W].forEach((x, si) => {
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');
});
}
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` }, '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) {
this.ifcBuffer = 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();
this.ifcBuffer = null;
if (this.ifcHelper) this.ifcHelper.close();
}
exportIFC() {
if (this.mode === 'ifc' && this.ifcBuffer) {
downloadFile('model.ifc', new Uint8Array(this.ifcBuffer), 'application/octet-stream');
} else {
const ifc = generateDemoIFC(this.meshes);
downloadFile('SAM_Demo_Building.ifc', ifc, 'text/plain');
}
}
/* ── 클릭 선택 ── */
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);
this.clearSelection();
if (hits.length > 0) {
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 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 = 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; 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.renderer.setSize(w, h);
}
animate() {
this._animId = requestAnimationFrame(() => this.animate());
if (this.targetPos) {
this.camera.position.lerp(this.targetPos, 0.07);
this.controls.target.lerp(this.targetLook, 0.07);
if (this.camera.position.distanceTo(this.targetPos) < 0.2) this.targetPos = null;
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
dispose() {
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);
}
}
/* ════════════════════════════════════════════════
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: '시공관리', children: [
{ label: '인원관리', url: '/juil/construction-pmis/workforce' },
{ label: '장비관리', url: '/juil/construction-pmis/equipment' },
{ label: '자재관리', url: '/juil/construction-pmis/materials' },
{ label: '공사량관리', url: '/juil/construction-pmis/work-volume' },
{ label: '출면일보', url: '/juil/construction-pmis/daily-attendance' },
{ label: '작업일보', url: '/juil/construction-pmis/daily-report' },
]},
{ icon: 'ri-file-list-3-line', label: '품질관리' },
{ 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 }}>
<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>
<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>}
</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>
<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={`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?.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>
))}
</div>
))}
</div>
</div>
);
}
function BimToolbar({ onView, visibility, onToggle, wireframe, onWireframe, mode, onUpload, onSwitchDemo, onExportIFC }) {
const fileRef = useRef(null);
const views = [
{ id: 'perspective', icon: 'ri-box-3-line', label: '투시도' },
{ id: 'front', icon: 'ri-layout-bottom-2-line', label: '정면' },
{ id: 'right', icon: 'ri-layout-right-2-line', label: '우측' },
{ id: 'top', icon: 'ri-layout-top-2-line', label: '상부' },
{ 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-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>
</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>
<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>
</button>
<div className="w-px h-6 bg-gray-300"></div>
<button onClick={onExportIFC}
className="flex items-center gap-1 px-3 py-1.5 rounded bg-green-600 text-white hover:bg-green-700 transition font-semibold">
<i className="ri-download-2-line text-sm"></i> IFC 다운로드
</button>
</div>
);
}
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={`${demoInfo ? 'ri-information-line text-blue-500' : 'ri-file-3d-line text-green-500'}`}></i>
{demoInfo ? '모델 정보' : 'IFC 모델'}
</h3>
{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>
) : 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>
{/* 선택 요소 */}
<div className="p-4 border-b border-gray-100 flex-1">
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-1 mb-3">
<i className="ri-cursor-line text-orange-500"></i> 선택된 요소
</h3>
{selected ? (
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
{!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>
{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 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">
<i className="ri-cursor-line text-2xl block mb-2"></i>
건물 요소를 클릭하면<br />상세 정보가 표시됩니다
</div>
)}
</div>
{/* 요소 통계 */}
<div className="p-4">
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-1 mb-3">
<i className="ri-bar-chart-box-line text-green-500"></i> 요소 통계
</h3>
<div className="space-y-1">
{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">
{!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>
</div>
))}
<div className="flex items-center justify-between text-xs pt-2 mt-1 border-t border-gray-200 font-bold">
<span className="text-gray-700">전체</span>
<span className="font-mono text-blue-700">{Object.values(counts).reduce((a, b) => a + b, 0)}</span>
</div>
</div>
</div>
</div>
);
}
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
════════════════════════════════════════════════ */
function BimViewerApp() {
const vpRef = useRef(null);
const sceneRef = useRef(null);
const [selected, setSelected] = useState(null);
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;
const bim = new BimScene(vpRef.current);
bim.init();
bim.onSelect = setSelected;
setCounts(bim.getCounts());
sceneRef.current = bim;
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; });
}, []);
const handleWire = useCallback(() => {
setWireframe(prev => { const n = !prev; sceneRef.current?.toggleWireframe(n); return n; });
}, []);
const handleExportIFC = useCallback(() => { sceneRef.current?.exportIFC(); }, []);
// 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"
onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div ref={vpRef} className="flex-1" style={{ minHeight: 0 }} />
{/* 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} onExportIFC={handleExportIFC} />
</div>
<BimInfoPanel selected={selected} counts={counts} mode={mode} ifcModelInfo={ifcModelInfo} />
</div>
);
}
ReactDOM.render(<BimViewerApp />, document.getElementById('root'));
@endverbatim
</script>
@endpush