Files
sam-manage/resources/views/juil/bim-viewer.blade.php
김보곤 19b3db0499 feat: [bim] BIM 생성기 메뉴 추가
- 방화셔터/방화문/방화댐퍼 파라메트릭 3D 설계
- 실시간 Three.js 프리뷰 + IfcProduct JSON 생성
- IFC STEP 파일 내보내기
- PMIS 사이드바에 BIM 생성기 메뉴 등록
2026-03-13 17:41:30 +09:00

1311 lines
78 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>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FontLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/geometries/TextGeometry.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, fireShutter: 0xE53935,
ground: 0xE8E0D0,
};
const TYPE_LABEL = {
floor:'바닥 슬래브', roof:'지붕', column:'기둥', beam:'보',
wall:'벽체', window:'창호', door:'출입문', stair:'계단실',
fireShutter:'방화셔터',
};
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', fireShutter:'ri-fire-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:'기타요소',
IFCFIREPROTECTIONDEVICE:'방화셔터', IFCFIRESUPPRESSIONTERMINAL:'소화설비',
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',
fireShutter:'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.003);
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 500);
this.camera.position.set(80, 50, 80);
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 isTransparent = userData.type === 'window' || userData.transparent;
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gx, gy, gz), this.mat(COLORS[userData.type], isTransparent));
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;
}
// H형강 — 상/하 플랜지 + 웹 3파트 구성
// axis: 'x'=X방향, 'z'=Z방향, 'y'=수직(기둥)
// h=단면 높이, w=플랜지 폭, tw=웹 두께, tf=플랜지 두께
hbeam(length, h, w, tw, tf, pos, axis, userData, group) {
const fl = { ...userData, part: 'flange' };
const webH = h - 2 * tf;
if (axis === 'x') {
this.box(length, tf, w, [pos[0], pos[1] + h / 2 - tf / 2, pos[2]], fl, group);
this.box(length, tf, w, [pos[0], pos[1] - h / 2 + tf / 2, pos[2]], fl, group);
this.box(length, webH, tw, pos, userData, group);
} else if (axis === 'z') {
this.box(w, tf, length, [pos[0], pos[1] + h / 2 - tf / 2, pos[2]], fl, group);
this.box(w, tf, length, [pos[0], pos[1] - h / 2 + tf / 2, pos[2]], fl, group);
this.box(tw, webH, length, pos, userData, group);
} else { // 'y' — 기둥 (수직, H단면은 X-Z 평면)
this.box(w, length, tf, [pos[0], pos[1], pos[2] + h / 2 - tf / 2], fl, group);
this.box(w, length, tf, [pos[0], pos[1], pos[2] - h / 2 + tf / 2], fl, group);
this.box(tw, length, webH, pos, userData, group);
}
}
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');
}
// H형강 기둥 — CTW: 웹 두께, CTF: 플랜지 두께
const CTW = 0.04, CTF = 0.05;
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.hbeam(ch, 0.45, 0.45, CTW, CTF, [ix * SX, f * FH + 0.15 + ch / 2, iz * SZ], 'y',
{ type: 'column', name: `C-${String(cn).padStart(3, '0')}`, material: 'H형강 H400×400 (SS400)', floor: `${f + 1}F`, dimensions: 'H400×400×40×50 L=3,700mm', grid: `(${ix + 1},${iz + 1})` }, 'column');
}
// H형강 보 — tw: 웹 두께, tf: 플랜지 두께 (시각적 가시성 위해 확대)
const BTW = 0.05, BTF = 0.06;
for (let f = 1; f <= NF; f++) {
const by = f * FH - 0.25;
for (let iz = 0; iz < CZ; iz++) this.hbeam(W, 0.5, 0.3, BTW, BTF, [W / 2, by, iz * SZ], 'x',
{ type: 'beam', name: `GB-X${iz + 1}-${f}F`, material: 'H형강 H500×300 (SS400)', floor: `${f}F`, dimensions: `${W}m × H500×300×50×60` }, 'beam');
for (let ix = 0; ix < CX; ix++) this.hbeam(D, 0.45, 0.3, BTW, BTF, [ix * SX, by, D / 2], 'z',
{ type: 'beam', name: `GB-Z${ix + 1}-${f}F`, material: 'H형강 H450×300 (SS400)', floor: `${f}F`, dimensions: `${D}m × H450×300×50×60` }, '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');
}
// 방화셔터 — Jamb(문틀) + 상부하우징 + 닫힌 셔터패널
const JW = 0.2, JD = 0.25, HH = 0.35; // Jamb 폭/깊이, Housing 높이
const fsCommon = { type: 'fireShutter', material: '방화셔터 (내화 2시간)' };
for (let f = 0; f < NF; f++) {
const baseY = f * FH + 0.3, sh = FH - 0.6;
// ── 계단실 입구 방화셔터 (Z방향 개구부) ──
const stX = sx - sw / 2 - 0.1, stW = sd - 0.6;
const stZ1 = sz - stW / 2, stZ2 = sz + stW / 2;
// 좌 Jamb
this.box(JD, sh, JW, [stX, baseY + sh / 2, stZ1 - JW / 2],
{ ...fsCommon, name: `FS-STAIR-${f + 1}F-JL`, floor: `${f + 1}F`, part: '좌측 Jamb' }, 'fireShutter');
// 우 Jamb
this.box(JD, sh, JW, [stX, baseY + sh / 2, stZ2 + JW / 2],
{ ...fsCommon, name: `FS-STAIR-${f + 1}F-JR`, floor: `${f + 1}F`, part: '우측 Jamb' }, 'fireShutter');
// 상부 하우징 (셔터박스)
this.box(JD + 0.1, HH, stW + JW * 2, [stX, baseY + sh - HH / 2, sz],
{ ...fsCommon, name: `FS-STAIR-${f + 1}F-BOX`, floor: `${f + 1}F`, part: '상부 하우징' }, 'fireShutter');
// 셔터 패널 (닫힘 상태, 반투명)
this.box(0.06, sh - HH, stW, [stX, baseY + (sh - HH) / 2, sz],
{ ...fsCommon, name: `FS-STAIR-${f + 1}F`, floor: `${f + 1}F`, transparent: true, dimensions: `${stW.toFixed(1)}m × ${(sh - HH).toFixed(1)}m`, zone: '계단실 입구' }, 'fireShutter');
// ── 방화구획 경계 셔터 (Z방향, X=20m/40m) ──
[20, 40].forEach((zoneX, zi) => {
const zW = D - 1, zZ1 = D / 2 - zW / 2, zZ2 = D / 2 + zW / 2;
// 좌 Jamb
this.box(JD, sh, JW, [zoneX, baseY + sh / 2, zZ1 - JW / 2],
{ ...fsCommon, name: `FS-ZONE${zi + 1}-${f + 1}F-JL`, floor: `${f + 1}F`, part: '좌측 Jamb' }, 'fireShutter');
// 우 Jamb
this.box(JD, sh, JW, [zoneX, baseY + sh / 2, zZ2 + JW / 2],
{ ...fsCommon, name: `FS-ZONE${zi + 1}-${f + 1}F-JR`, floor: `${f + 1}F`, part: '우측 Jamb' }, 'fireShutter');
// 상부 하우징
this.box(JD + 0.1, HH, zW + JW * 2, [zoneX, baseY + sh - HH / 2, D / 2],
{ ...fsCommon, name: `FS-ZONE${zi + 1}-${f + 1}F-BOX`, floor: `${f + 1}F`, part: '상부 하우징' }, 'fireShutter');
// 셔터 패널 (닫힘, 반투명)
this.box(0.06, sh - HH, zW, [zoneX, baseY + (sh - HH) / 2, D / 2],
{ ...fsCommon, name: `FS-ZONE${zi + 1}-${f + 1}F`, floor: `${f + 1}F`, transparent: true, dimensions: `${zW}m × ${(sh - HH).toFixed(1)}m`, zone: `방화구획 ${zi + 1}구간 경계` }, 'fireShutter');
});
}
}
/* ── 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) return;
if (m.userData.part) return; // 부품(플랜지, Jamb, 하우징) 제외 — 본체만 카운트
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;
}
// SAM 쇼 이펙트 애니메이션
if (this._show) this._updateShow();
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
/* ── SAM 쇼 이펙트 (20가지) ── */
playShow(id = 1) {
if (this._show) return;
if (!this._font) {
const loader = new THREE.FontLoader();
loader.load('https://cdn.jsdelivr.net/npm/three@0.128.0/examples/fonts/helvetiker_bold.typeface.json', font => {
this._font = font; this._initEffect(id);
});
} else this._initEffect(id);
}
_makeTextSprite(text, color, size) {
const canvas = document.createElement('canvas');
const s = Math.max(256, size * 64);
canvas.width = s; canvas.height = s * 0.5;
const ctx = canvas.getContext('2d');
ctx.font = `bold ${s * 0.38}px "Malgun Gothic", sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = '#' + color.toString(16).padStart(6, '0');
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
const tex = new THREE.CanvasTexture(canvas);
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0, depthWrite: false });
const sprite = new THREE.Sprite(mat);
sprite.scale.set(size * 2, size, 1);
return sprite;
}
_mkItem(text, color, size, pos) {
if (text === 'SAM') {
const geo = new THREE.TextGeometry('SAM', {
font: this._font, size, height: size * 0.3,
curveSegments: 10, bevelEnabled: true,
bevelThickness: size * 0.04, bevelSize: size * 0.02, bevelSegments: 2,
});
geo.computeBoundingBox(); geo.center();
const mat = new THREE.MeshPhongMaterial({
color, emissive: color, emissiveIntensity: 0.3,
specular: 0xffffff, shininess: 100, transparent: true, opacity: 0,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(pos); this.scene.add(mesh);
return { obj: mesh, type: '3d' };
}
const spr = this._makeTextSprite(text, color, size);
spr.position.copy(pos); this.scene.add(spr);
return { obj: spr, type: 'sprite' };
}
_mkFlash(pos) {
const g = new THREE.SphereGeometry(3,32,32);
const m = new THREE.MeshBasicMaterial({ color:0xffffff, transparent:true, opacity:0 });
const mesh = new THREE.Mesh(g,m); mesh.position.copy(pos); this.scene.add(mesh); return mesh;
}
_setOp(it, v) { it.obj.material.opacity = Math.max(0, Math.min(1, v)); }
_initEffect(id) {
const cx=30, cy=15, cz=15, C=new THREE.Vector3(cx,cy,cz);
const P=[0x1565C0,0xE53935,0x43A047,0xFB8C00,0x8E24AA,0x00ACC1,0xD81B60,0xFFD600,0x3949AB,0x00E676,0xFF6D00,0x6200EA,0x00BFA5,0xF50057,0x2962FF];
const items=[], extras=[];
let dur, upd;
const self=this;
const mkMix = (n, sMin, sMax) => {
for(let i=0;i<n;i++){
const text=i%3===0?'SAM':'샘', sz=sMin+Math.random()*(sMax-sMin), col=P[i%P.length];
const it=self._mkItem(text, col, sz, new THREE.Vector3(cx,cy-80,cz));
it.size=sz; it.color=col; it.idx=i; items.push(it);
}
};
switch(id) {
case 1: { // ── 별 모임 ──
dur=5500; const R=70;
for(let i=0;i<12;i++){
const sz=1.5+Math.random()*5, col=P[i%P.length];
const phi=Math.random()*Math.PI*2, theta=Math.acos(2*Math.random()-1);
const sp=new THREE.Vector3(cx+R*Math.sin(theta)*Math.cos(phi),cy+R*Math.sin(theta)*Math.sin(phi)*0.5+5,cz+R*Math.cos(theta));
const it=this._mkItem('SAM',col,sz,sp);
it.startPos=sp.clone(); it.targetPos=C.clone().add(new THREE.Vector3((Math.random()-0.5)*6,(Math.random()-0.5)*4,(Math.random()-0.5)*6));
it.delay=Math.random()*1200; it.size=sz; it.spinSpeed=(Math.random()-0.5)*4;
it.burstSpeed=0.8+Math.random()*0.4; it.burstYDir=Math.random()-0.3;
items.push(it);
}
for(let i=0;i<16;i++){
const sz=2+Math.random()*6, col=P[(i+7)%P.length];
const phi=Math.random()*Math.PI*2, theta=Math.acos(2*Math.random()-1);
const sp=new THREE.Vector3(cx+R*Math.sin(theta)*Math.cos(phi),cy+R*Math.sin(theta)*Math.sin(phi)*0.5+5,cz+R*Math.cos(theta));
const it=this._mkItem('샘',col,sz,sp);
it.startPos=sp.clone(); it.targetPos=C.clone().add(new THREE.Vector3((Math.random()-0.5)*6,(Math.random()-0.5)*4,(Math.random()-0.5)*6));
it.delay=Math.random()*1200; it.size=sz; it.spinSpeed=(Math.random()-0.5)*3;
it.burstSpeed=0.8+Math.random()*0.4; it.burstYDir=Math.random()-0.3;
items.push(it);
}
const fl=this._mkFlash(C); extras.push(fl);
upd=(s,el)=>{
if(el<3500){
s.items.forEach(it=>{const lt=Math.max(0,el-it.delay)/(3500-it.delay);if(lt<=0)return;const t=Math.min(lt,1),e=1-Math.pow(1-t,3);it.obj.position.lerpVectors(it.startPos,it.targetPos,e);this._setOp(it,Math.min(t*3,1));if(it.type==='3d')it.obj.rotation.y+=it.spinSpeed*0.016;});
}else if(el<4000){
const ht=(el-3500)/500,comp=1-ht*0.6;
s.items.forEach((it,i)=>{const a=i/s.items.length*Math.PI*2+ht*Math.PI*6,r=(3+it.size*0.5)*comp;it.obj.position.set(C.x+Math.cos(a)*r,C.y+Math.sin(a*2)*2*comp,C.z+Math.sin(a)*r);this._setOp(it,1);if(it.type==='3d'){it.obj.material.emissiveIntensity=0.3+ht*0.7;it.obj.rotation.y+=it.spinSpeed*0.02;}});
fl.scale.setScalar(1+ht*8);fl.material.opacity=ht*0.15;
}else{
const bt=(el-4000)/1500;
fl.material.opacity=bt<0.15?bt/0.15*0.9:Math.max(0,0.9*(1-(bt-0.15)/0.3));fl.scale.setScalar(10+bt*100);
s.items.forEach((it,i)=>{const a=i/s.items.length*Math.PI*2;it.obj.position.set(C.x+Math.cos(a)*(10+bt*80*it.burstSpeed),C.y+it.burstYDir*bt*50,C.z+Math.sin(a)*(10+bt*80*it.burstSpeed));this._setOp(it,Math.max(0,1-bt*2.5));if(it.type==='3d'){it.obj.rotation.y+=it.spinSpeed*0.05;it.obj.scale.setScalar(1+bt*0.5);}});
}
};
break;
}
case 2: { // ── 불꽃놀이 ──
dur=6500;
[{x:cx-15,z:cz,t:0},{x:cx+10,z:cz-10,t:800},{x:cx+5,z:cz+12,t:1600},{x:cx-8,z:cz+8,t:2400},{x:cx+15,z:cz-5,t:3200}].forEach((b,bi)=>{
for(let i=0;i<6;i++){const sz=1+Math.random()*3,col=P[(bi*6+i)%P.length];const it=this._mkItem(i%2===0?'SAM':'샘',col,sz,new THREE.Vector3(b.x,-10,b.z));it.size=sz;it.burst=b;it.angle=i/6*Math.PI*2+Math.random()*0.5;it.speed=0.6+Math.random()*0.4;items.push(it);}
});
upd=(s,el)=>{s.items.forEach(it=>{const le=el-it.burst.t;if(le<0){this._setOp(it,0);return;}if(le<800){const t=le/800;it.obj.position.set(it.burst.x,-10+t*45,it.burst.z);this._setOp(it,Math.min(t*4,1));}else if(le<2800){const t=(le-800)/2000,r=t*25*it.speed,g=t*t*15;it.obj.position.set(it.burst.x+Math.cos(it.angle)*r,35+Math.sin(it.angle)*r*0.6-g,it.burst.z+Math.sin(it.angle)*r*0.8);this._setOp(it,Math.max(0,1-t*1.2));if(it.type==='3d')it.obj.rotation.y+=0.05;}else this._setOp(it,0);});};
break;
}
case 3: { // ── 매트릭스 ──
dur=6000; const greens=[0x00FF41,0x00E676,0x4CAF50,0x1B5E20,0x76FF03];
for(let c=0;c<8;c++)for(let r=0;r<4;r++){const sz=1.5+Math.random()*2,col=greens[(c+r)%5],x=cx-20+c*6+(Math.random()-0.5)*2,z=cz-8+(Math.random()-0.5)*4;const it=this._mkItem((c+r)%3===0?'SAM':'샘',col,sz,new THREE.Vector3(x,60,z));it.startY=50+Math.random()*20;it.endY=-15-Math.random()*10;it.delay=c*200+r*400+Math.random()*300;it.fallSpeed=0.8+Math.random()*0.4;items.push(it);}
upd=(s,el)=>{s.items.forEach(it=>{const lt=Math.max(0,el-it.delay)/(dur-it.delay);if(lt<=0){this._setOp(it,0);return;}const t=Math.min(lt*it.fallSpeed,1);it.obj.position.y=it.startY+(it.endY-it.startY)*t;this._setOp(it,t<0.1?t*10:t>0.7?(1-t)/0.3:1);if(it.type==='3d')it.obj.rotation.z+=0.01;});};
break;
}
case 4: { // ── 토네이도 ──
dur=5500; mkMix(24,1.5,4);
items.forEach((it,i)=>{it.baseAngle=i/24*Math.PI*2;it.height=Math.random();it.radius=3+it.height*20;});
upd=(s,el)=>{const t=el/dur,spin=t*Math.PI*8;s.items.forEach(it=>{const h=it.height,y=cy-10+h*50*Math.min(t*2,1),r=it.radius*(0.3+h*0.7)*Math.min(t*1.5,1),a=it.baseAngle+spin*(1+h*0.5);it.obj.position.set(C.x+Math.cos(a)*r,y,C.z+Math.sin(a)*r);this._setOp(it,t<0.15?t/0.15:t>0.8?(1-t)/0.2:1);if(it.type==='3d')it.obj.rotation.y=a;});};
break;
}
case 5: { // ── 은하수 ──
dur=7000; mkMix(30,1,4);
items.forEach((it,i)=>{const arm=i%3,dist=3+(i/30)*30;it.dist=dist;it.baseAngle=arm*Math.PI*2/3+dist*0.15;it.yOff=(Math.random()-0.5)*2;it.twinkle=2+Math.random()*4;});
upd=(s,el)=>{const t=el/dur,rot=t*Math.PI*1.5;s.items.forEach(it=>{const a=it.baseAngle+rot;it.obj.position.set(C.x+Math.cos(a)*it.dist,C.y+it.yOff,C.z+Math.sin(a)*it.dist);this._setOp(it,(t<0.15?t/0.15:t>0.85?(1-t)/0.15:1)*(0.5+0.5*Math.sin(el*0.001*it.twinkle)));if(it.type==='3d')it.obj.rotation.y=a;});};
break;
}
case 6: { // ── DNA 나선 ──
dur=6000;
for(let i=0;i<24;i++){const sz=1.5+Math.random()*2.5,col=i%2===0?P[0]:P[1];const it=this._mkItem(i%3===0?'SAM':'샘',col,sz,new THREE.Vector3(cx,-20,cz));it.strand=i%2;it.pos_t=Math.floor(i/2)/12;items.push(it);}
upd=(s,el)=>{const t=el/dur,rise=Math.min(t*1.5,1),rot=t*Math.PI*6;s.items.forEach(it=>{const h=it.pos_t*40*rise-5,a=it.pos_t*Math.PI*4+rot+it.strand*Math.PI;it.obj.position.set(C.x+Math.cos(a)*8,C.y+h-15,C.z+Math.sin(a)*8);this._setOp(it,t<0.1?t/0.1:t>0.85?(1-t)/0.15:1);if(it.type==='3d')it.obj.rotation.y=a+Math.PI/2;});};
break;
}
case 7: { // ── 파도 ──
dur=6000;
for(let r=0;r<5;r++)for(let c=0;c<5;c++){const sz=1.5+Math.random()*2,col=P[(r*5+c)%P.length],x=cx-12+c*6,z=cz-12+r*6;const it=this._mkItem((r+c)%3===0?'SAM':'샘',col,sz,new THREE.Vector3(x,cy,z));it.gx=c;it.gz=r;it.baseX=x;it.baseZ=z;items.push(it);}
upd=(s,el)=>{const t=el/dur;s.items.forEach(it=>{const d=Math.sqrt((it.gx-2)**2+(it.gz-2)**2);it.obj.position.y=C.y+Math.sin(el*0.003-d*1.5)*8;this._setOp(it,t<0.1?t/0.1:t>0.85?(1-t)/0.15:1);if(it.type==='3d')it.obj.rotation.x=Math.sin(el*0.003-d*1.5)*0.3;});};
break;
}
case 8: { // ── 폭포 ──
dur=6000; mkMix(20,1.5,4);
items.forEach((it,i)=>{it.col_i=i%4;it.delay=Math.random()*2000;it.startX=cx-6+it.col_i*4+(Math.random()-0.5)*2;it.startZ=cz+(Math.random()-0.5)*4;it.speed=0.7+Math.random()*0.6;});
upd=(s,el)=>{const gt=el/dur;s.items.forEach(it=>{const lt=Math.max(0,el-it.delay),cyc=(lt*it.speed*0.001)%1,y=45-cyc*60,splash=cyc>0.85?Math.sin((cyc-0.85)/0.15*Math.PI)*5:0;it.obj.position.set(it.startX+Math.sin(lt*0.002)*2,y+splash,it.startZ);const op=cyc<0.05?cyc/0.05:cyc>0.9?(1-cyc)/0.1:1;this._setOp(it,op*(gt>0.85?(1-gt)/0.15:Math.min(gt*5,1)));});};
break;
}
case 9: { // ── 오로라 ──
dur=7000; const ac=[0x00E676,0x00BCD4,0x7C4DFF,0xE040FB,0x00E5FF];
for(let i=0;i<25;i++){const sz=2+Math.random()*3,col=ac[i%5],x=cx-30+(i%5)*15+(Math.random()-0.5)*6,y=cy+10+Math.floor(i/5)*4;const it=this._mkItem(i%3===0?'SAM':'샘',col,sz,new THREE.Vector3(x,y,cz));it.baseX=x;it.baseY=y;it.phase=Math.random()*Math.PI*2;it.wAmp=3+Math.random()*4;it.wFreq=0.5+Math.random();items.push(it);}
upd=(s,el)=>{const t=el/dur;s.items.forEach(it=>{it.obj.position.set(it.baseX+Math.sin(el*0.0005+it.phase*2)*5,it.baseY+Math.sin(el*0.001*it.wFreq+it.phase)*it.wAmp,cz+Math.sin(el*0.0008+it.phase)*3);this._setOp(it,(t<0.15?t/0.15:t>0.85?(1-t)/0.15:1)*(0.5+0.5*Math.sin(el*0.002+it.phase)));});};
break;
}
case 10: { // ── 네온사인 ──
dur=5000;
const big=this._mkItem('SAM',0xFF1744,8,C.clone());big.size=8;items.push(big);
for(let i=0;i<8;i++){const sz=1.5+Math.random()*2,col=P[i%P.length];const it=this._mkItem(i%2===0?'SAM':'샘',col,sz,C.clone());it.orbitR=12+Math.random()*8;it.orbitA=i/8*Math.PI*2;it.orbitS=1+Math.random()*0.5;items.push(it);}
upd=(s,el)=>{const t=el/dur,big=s.items[0],flk=t<0.3?(Math.sin(el*0.05)>0?1:0.1):1,fade=t>0.85?(1-t)/0.15:1;this._setOp(big,flk*fade);if(big.type==='3d'){big.obj.material.emissiveIntensity=0.3+flk*0.7;big.obj.scale.setScalar(1+Math.sin(el*0.003)*0.05);}for(let i=1;i<s.items.length;i++){const it=s.items[i],a=it.orbitA+t*Math.PI*4*it.orbitS;it.obj.position.set(C.x+Math.cos(a)*it.orbitR,C.y+Math.sin(a*2)*3,C.z+Math.sin(a)*it.orbitR);this._setOp(it,t<0.3?0:t<0.4?(t-0.3)/0.1:fade);if(it.type==='3d')it.obj.rotation.y=a;}};
break;
}
case 11: { // ── 스타워즈 ──
dur=7000;
for(let i=0;i<10;i++){const sz=2+Math.random()*3;const it=this._mkItem(i%3===0?'SAM':'샘',0xFFD600,sz,new THREE.Vector3(cx+(Math.random()-0.5)*10,cy-30,cz));it.row=i;it.xOff=(Math.random()-0.5)*20;it.size=sz;items.push(it);}
upd=(s,el)=>{const t=el/dur;s.items.forEach(it=>{const lt=Math.max(0,t-it.row*0.08),y=cy-25+lt*80,z=cz-lt*60,sc=Math.max(0.3,1-lt*0.8);it.obj.position.set(cx+it.xOff*sc,y,z);if(it.type==='3d')it.obj.scale.setScalar(sc);else{const s2=it.size*sc;it.obj.scale.set(s2*2,s2,1);}this._setOp(it,lt<=0?0:lt>0.9?Math.max(0,(1-lt)/0.1):Math.min(lt*5,1));});};
break;
}
case 12: { // ── 빅뱅 ──
dur=5500; mkMix(28,1,5);
items.forEach(it=>{const phi=Math.random()*Math.PI*2,theta=Math.acos(2*Math.random()-1);it.dir=new THREE.Vector3(Math.sin(theta)*Math.cos(phi),Math.sin(theta)*Math.sin(phi)*0.6,Math.cos(theta));it.speed=0.5+Math.random()*0.5;it.obj.position.copy(C);});
const fl12=this._mkFlash(C);extras.push(fl12);
upd=(s,el)=>{const t=el/dur;fl12.scale.setScalar(1+Math.min(t*20,1)*50);fl12.material.opacity=t<0.1?(0.1-t)/0.1*0.8:0;s.items.forEach(it=>{const ex=Math.pow(t,0.5)*60*it.speed;it.obj.position.set(C.x+it.dir.x*ex,C.y+it.dir.y*ex,C.z+it.dir.z*ex);this._setOp(it,t<0.05?t/0.05:t>0.7?(1-t)/0.3:1);if(it.type==='3d'){it.obj.rotation.y+=0.03;it.obj.scale.setScalar(0.3+t*0.7);}});};
break;
}
case 13: { // ── 반딧불이 ──
dur=8000; mkMix(20,1.5,4);
items.forEach(it=>{it.basePos=new THREE.Vector3(cx+(Math.random()-0.5)*40,cy+(Math.random()-0.5)*20,cz+(Math.random()-0.5)*40);it.obj.position.copy(it.basePos);it.phase=Math.random()*Math.PI*2;it.glowS=1.5+Math.random()*3;it.wandS=0.3+Math.random()*0.5;});
upd=(s,el)=>{const t=el/dur;s.items.forEach(it=>{it.obj.position.set(it.basePos.x+Math.sin(el*0.001*it.wandS+it.phase)*5,it.basePos.y+Math.cos(el*0.0008*it.wandS+it.phase*1.3)*3,it.basePos.z+Math.sin(el*0.0012*it.wandS+it.phase*0.7)*5);this._setOp(it,(0.3+0.7*Math.max(0,Math.sin(el*0.001*it.glowS+it.phase)))*(t<0.1?t/0.1:t>0.85?(1-t)/0.15:1));});};
break;
}
case 14: { // ── 도미노 ──
dur=5000;
for(let i=0;i<16;i++){const sz=2+Math.random()*2,col=P[i%P.length],a=i/16*Math.PI*1.5-Math.PI*0.3,r=20,x=cx+Math.cos(a)*r,z=cz+Math.sin(a)*r;const it=this._mkItem(i%3===0?'SAM':'샘',col,sz,new THREE.Vector3(x,cy+10,z));it.baseX=x;it.baseZ=z;items.push(it);}
upd=(s,el)=>{const t=el/dur;s.items.forEach((it,i)=>{const lt=Math.max(0,t-i*0.05),fall=lt<0.1?0:Math.min((lt-0.1)/0.15,1),fa=fall*Math.PI/2;it.obj.position.y=cy+10-Math.sin(fa)*12;if(it.type==='3d')it.obj.rotation.z=-fa;this._setOp(it,lt<=0?0:t>0.85?(1-t)/0.15:Math.min(lt*5,1));});};
break;
}
case 15: { // ── 하트비트 ──
dur=6000;
const big15=this._mkItem('SAM',0xE53935,6,C.clone());big15.size=6;items.push(big15);
for(let i=0;i<12;i++){const it=this._mkItem('샘',P[i%P.length],2+Math.random()*2,C.clone());it.ringA=i/12*Math.PI*2;items.push(it);}
upd=(s,el)=>{const t=el/dur,beats=el*0.0012,bp=beats%1,pulse=bp<0.15?1+Math.sin(bp/0.15*Math.PI)*0.4:1,fade=t<0.1?t/0.1:t>0.85?(1-t)/0.15:1;const big=s.items[0];if(big.type==='3d')big.obj.scale.setScalar(pulse);this._setOp(big,fade);for(let i=1;i<s.items.length;i++){const it=s.items[i],r=5+bp*25,a=it.ringA+beats*0.3;it.obj.position.set(C.x+Math.cos(a)*r,C.y+Math.sin(a*3)*2,C.z+Math.sin(a)*r);this._setOp(it,fade*Math.max(0,1-bp*1.5));}};
break;
}
case 16: { // ── 블랙홀 ──
dur=6000; mkMix(22,1.5,4);
items.forEach((it,i)=>{const a=i/22*Math.PI*2,r=30+Math.random()*15;it.baseA=a;it.baseR=r;it.orbitS=0.5+Math.random()*0.3;it.obj.position.set(cx+Math.cos(a)*r,cy+(Math.random()-0.5)*6,cz+Math.sin(a)*r);});
const fl16=this._mkFlash(C);fl16.material.color.setHex(0x6200EA);extras.push(fl16);
upd=(s,el)=>{const t=el/dur,accel=1+t*4;s.items.forEach(it=>{const r=it.baseR*Math.max(0.01,1-t*1.1),a=it.baseA+t*Math.PI*6*accel*it.orbitS;it.obj.position.set(C.x+Math.cos(a)*r,C.y+Math.sin(el*0.005)*r*0.1,C.z+Math.sin(a)*r);if(it.type==='3d'){const st=r<5?(5-r)/5*3:0;it.obj.scale.set(1+st,Math.max(0.2,1-st*0.3),1);it.obj.rotation.y=a;}this._setOp(it,t>0.9?0:r<2?r/2:Math.min(t*3,1));});fl16.scale.setScalar(1+t*5);fl16.material.opacity=t<0.5?t*0.3:t>0.9?(1-t)/0.1*0.5:0.15;};
break;
}
case 17: { // ── 비눗방울 ──
dur=7000; mkMix(20,2,5);
items.forEach(it=>{it.sX=cx+(Math.random()-0.5)*30;it.sZ=cz+(Math.random()-0.5)*20;it.sY=cy-15-Math.random()*10;it.obj.position.set(it.sX,it.sY,it.sZ);it.riseS=0.3+Math.random()*0.5;it.wobP=Math.random()*Math.PI*2;it.wobA=2+Math.random()*4;it.popT=0.6+Math.random()*0.35;});
upd=(s,el)=>{const t=el/dur;s.items.forEach(it=>{if(t>it.popT+0.05){this._setOp(it,0);return;}if(t>it.popT){const pt=(t-it.popT)/0.05;if(it.type==='3d')it.obj.scale.setScalar(1+pt*2);else{const sc=it.size*(1+pt*2);it.obj.scale.set(sc*2,sc,1);}this._setOp(it,1-pt);return;}it.obj.position.set(it.sX+Math.sin(el*0.002+it.wobP)*it.wobA,it.sY+t*it.riseS*50,it.sZ+Math.cos(el*0.0015+it.wobP)*2);this._setOp(it,t<0.1?t/0.1:0.8);if(it.type==='3d')it.obj.rotation.y+=0.01;});};
break;
}
case 18: { // ── 번개 ──
dur=4500;
const big18=this._mkItem('SAM',0xFFFF00,7,C.clone());big18.size=7;items.push(big18);
for(let i=0;i<10;i++){const it=this._mkItem(i%2===0?'SAM':'샘',0xE1F5FE,1+Math.random()*3,C.clone().add(new THREE.Vector3((Math.random()-0.5)*20,(Math.random()-0.5)*15,(Math.random()-0.5)*20)));items.push(it);}
const fl18=this._mkFlash(C);extras.push(fl18);
upd=(s,el)=>{const t=el/dur;let br=0;[0.1,0.15,0.4,0.45,0.7].forEach(st=>{const dt=Math.abs(t-st);if(dt<0.03)br=Math.max(br,1-dt/0.03);});fl18.scale.setScalar(30+br*50);fl18.material.opacity=br*0.7;const big=s.items[0];this._setOp(big,t>0.15?(t>0.85?(1-t)/0.15:Math.min((t-0.15)/0.1,1)):br);if(big.type==='3d')big.obj.material.emissiveIntensity=0.3+br*0.7;for(let i=1;i<s.items.length;i++){this._setOp(s.items[i],Math.max(0,t>0.2?(t>0.8?(1-t)/0.2:0.3+br*0.7):br*0.5));if(s.items[i].type==='3d')s.items[i].obj.material.emissiveIntensity=br;}};
break;
}
case 19: { // ── 벚꽃 ──
dur=8000; const pinks=[0xF8BBD0,0xF48FB1,0xFFCDD2,0xFCE4EC,0xF06292];
for(let i=0;i<25;i++){const sz=1.5+Math.random()*3,col=pinks[i%5],x=cx+(Math.random()-0.5)*50,z=cz+(Math.random()-0.5)*30;const it=this._mkItem(i%4===0?'SAM':'샘',col,sz,new THREE.Vector3(x,cy+30+Math.random()*15,z));it.sX=x;it.sY=cy+30+Math.random()*15;it.sZ=z;it.swP=Math.random()*Math.PI*2;it.swA=3+Math.random()*5;it.fallS=0.3+Math.random()*0.4;it.rotS=(Math.random()-0.5)*2;items.push(it);}
upd=(s,el)=>{const t=el/dur;s.items.forEach(it=>{it.obj.position.set(it.sX+Math.sin(el*0.001+it.swP)*it.swA,it.sY-t*it.fallS*50,it.sZ+Math.cos(el*0.0005+it.swP*0.7)*3);this._setOp(it,t<0.05?t/0.05:t>0.9?(1-t)/0.1:0.85);if(it.type==='3d')it.obj.rotation.z+=it.rotS*0.01;});};
break;
}
case 20: { // ── 피닉스 ──
dur=6000; const fc=[0xFF6F00,0xF44336,0xFFAB00,0xDD2C00,0xFFD600,0xFF3D00];
for(let i=0;i<24;i++){const sz=1.5+Math.random()*4,col=fc[i%6],x=cx+(Math.random()-0.5)*15,z=cz+(Math.random()-0.5)*10;const it=this._mkItem(i%3===0?'SAM':'샘',col,sz,new THREE.Vector3(x,cy-25,z));it.sX=x;it.sZ=z;it.rDelay=Math.random()*1500;it.rSpeed=0.5+Math.random()*0.5;it.sway=(Math.random()-0.5)*8;it.phase=Math.random()*Math.PI*2;items.push(it);}
const fl20=this._mkFlash(C);fl20.material.color.setHex(0xFF6F00);extras.push(fl20);
upd=(s,el)=>{const t=el/dur;fl20.position.y=cy-10;fl20.scale.setScalar(5+Math.sin(el*0.005)*2);fl20.material.opacity=t<0.8?0.15+Math.sin(el*0.01)*0.05:Math.max(0,(1-t)/0.2*0.15);s.items.forEach(it=>{const lt=Math.max(0,el-it.rDelay)/(dur-it.rDelay);if(lt<=0){this._setOp(it,0);return;}const rise=Math.pow(lt,0.7)*55,wob=Math.sin(el*0.003+it.phase)*it.sway*(1-lt);it.obj.position.set(it.sX+wob,cy-25+rise,it.sZ+Math.cos(el*0.002+it.phase)*2);if(it.type==='3d'){it.obj.material.emissiveIntensity=0.3+Math.min(lt*1.5,1)*0.5;it.obj.rotation.y+=0.03;}this._setOp(it,lt<0.1?lt/0.1:lt>0.8?(1-lt)/0.2:1);});};
break;
}
default: return;
}
this._show = { type:id, items, extras, startTime:performance.now(), duration:dur, update:upd };
}
_updateShow() {
if (!this._show) return;
const s = this._show, el = performance.now() - s.startTime;
if (el >= s.duration) { this._cleanupShow(); return; }
s.update(s, el);
}
_cleanupShow() {
const s = this._show;
s.items.forEach(it => {
this.scene.remove(it.obj);
if (it.type === '3d') { it.obj.geometry.dispose(); it.obj.material.dispose(); }
else { it.obj.material.map?.dispose(); it.obj.material.dispose(); }
});
(s.extras||[]).forEach(m => { this.scene.remove(m); m.geometry?.dispose(); m.material?.dispose(); });
this._show = null;
}
dispose() {
cancelAnimationFrame(this._animId);
if (this._show) this._cleanupShow();
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
════════════════════════════════════════════════ */
const PMIS_MENUS = [
{ icon: 'ri-building-2-line', label: 'BIM 관리', id: 'bim', children: [
{ label: 'BIM 뷰어', id: 'bim-viewer', url: '/juil/construction-pmis/bim-viewer' },
{ label: 'BIM 생성기', id: 'bim-generator', url: '/juil/construction-pmis/bim-generator' },
]},
{ icon: 'ri-line-chart-line', label: '시공관리', id: 'construction', children: [
{ label: '인원관리', id: 'workforce', url: '/juil/construction-pmis/workforce' },
{ label: '장비관리', id: 'equipment', url: '/juil/construction-pmis/equipment' },
{ label: '자재관리', id: 'materials', url: '/juil/construction-pmis/materials' },
{ label: '공사량관리', id: 'work-volume', url: '/juil/construction-pmis/work-volume' },
{ label: '출면일보', id: 'daily-attendance', url: '/juil/construction-pmis/daily-attendance' },
{ label: '작업일보', id: 'daily-report', url: '/juil/construction-pmis/daily-report' },
]},
{ icon: 'ri-file-list-3-line', label: '품질관리', id: 'quality', children: [
{ label: '시정조치', id: 'corrective-action', url: '/juil/construction-pmis/corrective-action' },
]},
{ icon: 'ri-shield-check-line', label: '안전관리', id: 'safety', children: [
{ label: '안전보건교육', id: 'safety-education', url: '/juil/construction-pmis/safety-education' },
{ label: 'TBM현장', id: 'tbm', url: '/juil/construction-pmis/tbm' },
{ label: '위험성 평가', id: 'risk-assessment', url: '/juil/construction-pmis/risk-assessment' },
{ label: '재해예방조치', id: 'disaster-prevention', url: '/juil/construction-pmis/disaster-prevention' },
]},
{ icon: 'ri-folder-line', label: '자료실', id: 'archive', children: [
{ label: '자료보관함', id: 'archive-files', url: '/juil/construction-pmis/archive-files' },
{ label: '매뉴얼', id: 'archive-manual', url: '/juil/construction-pmis/archive-manual' },
{ label: '공지사항', id: 'archive-notice', url: '/juil/construction-pmis/archive-notice' },
]},
];
function PmisSidebar({ activePage }) {
const [profile, setProfile] = useState(null);
const [expanded, setExpanded] = useState(() => {
for (const m of PMIS_MENUS) {
if (m.children?.some(c => c.id === activePage)) return m.id;
}
return null;
});
useEffect(() => {
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
.then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {});
}, []);
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">
{PMIS_MENUS.map(m => (
<div key={m.id}>
<div
onClick={() => setExpanded(expanded === m.id ? null : m.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${expanded === m.id ? '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 ${expanded === m.id ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>
</div>
{expanded === m.id && m.children?.map(c => (
<a key={c.id} href={c.url}
className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600' : 'text-gray-500 hover:text-blue-600 hover:bg-gray-50'}`}>
{c.label}
</a>
))}
</div>
))}
</div>
</div>
);
}
const SHOW_EFFECTS = [
{id:1,name:'별 모임'},{id:2,name:'불꽃놀이'},{id:3,name:'매트릭스'},{id:4,name:'토네이도'},
{id:5,name:'은하수'},{id:6,name:'DNA 나선'},{id:7,name:'파도'},{id:8,name:'폭포'},
{id:9,name:'오로라'},{id:10,name:'네온사인'},{id:11,name:'스타워즈'},{id:12,name:'빅뱅'},
{id:13,name:'반딧불이'},{id:14,name:'도미노'},{id:15,name:'하트비트'},{id:16,name:'블랙홀'},
{id:17,name:'비눗방울'},{id:18,name:'번개'},{id:19,name:'벚꽃'},{id:20,name:'피닉스'},
];
function BimToolbar({ onView, visibility, onToggle, wireframe, onWireframe, mode, onUpload, onSwitchDemo, onExportIFC, effectId, onEffectChange, onPlayEffect }) {
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', 'fireShutter'];
const handleFile = (e) => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; };
return (
<div className="shrink-0 bg-white/95 backdrop-blur border-t border-gray-200 px-3 py-1 flex items-center gap-1.5 text-xs z-10 flex-wrap overflow-hidden">
{/* 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-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 transition font-semibold">
<i className="ri-upload-2-line"></i> IFC 업로드
</button>
{mode === 'ifc' && (
<button onClick={onSwitchDemo} className="flex items-center gap-1 px-2 py-1 rounded bg-gray-200 text-gray-700 hover:bg-gray-300 transition">
<i className="ri-building-line"></i> 데모 모델
</button>
)}
</div>
<div className="w-px h-5 bg-gray-300"></div>
{/* 시점 */}
<div className="flex items-center gap-0.5">
<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-0.5 px-1.5 py-1 rounded hover:bg-blue-50 hover:text-blue-700 text-gray-600 transition" title={v.label}>
<i className={v.icon}></i><span className="hidden xl:inline">{v.label}</span>
</button>
))}
</div>
{mode === 'demo' && (<>
<div className="w-px h-5 bg-gray-300"></div>
<div className="flex items-center gap-0.5">
<span className="text-gray-500 font-semibold mr-1">요소</span>
{toggles.map(t => (
<button key={t} onClick={() => onToggle(t)}
className={`flex items-center gap-0.5 px-1.5 py-1 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'}></i><span className="hidden xl:inline">{TYPE_LABEL[t]}</span>
</button>
))}
</div>
</>)}
<div className="w-px h-5 bg-gray-300"></div>
<button onClick={onWireframe}
className={`flex items-center gap-0.5 px-1.5 py-1 rounded transition ${wireframe ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'}`}>
<i className="ri-pencil-ruler-2-line"></i><span className="hidden xl:inline">와이어프레임</span>
</button>
<div className="w-px h-5 bg-gray-300"></div>
<button onClick={onExportIFC}
className="flex items-center gap-1 px-2 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition font-semibold">
<i className="ri-download-2-line"></i> IFC 다운로드
</button>
<div className="w-px h-5 bg-gray-300"></div>
<div className="flex items-center gap-1">
<select value={effectId} onChange={e => onEffectChange(Number(e.target.value))}
className="px-1.5 py-1 rounded border border-gray-300 bg-white text-gray-700 text-xs focus:ring-1 focus:ring-blue-500 outline-none">
{SHOW_EFFECTS.map(e => <option key={e.id} value={e.id}>{e.id}. {e.name}</option>)}
</select>
<button onClick={onPlayEffect}
className="flex items-center gap-1 px-2 py-1 rounded bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:from-blue-700 hover:to-indigo-700 transition font-semibold shadow-sm">
<i className="ri-play-line"></i> 실행
</button>
</div>
</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.part],
['재질', selected.material], ['층', selected.floor], ['치수', selected.dimensions],
['구역', selected.zone], ['면', 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(); }, []);
const [effectId, setEffectId] = useState(1);
const handlePlayEffect = useCallback(() => { sceneRef.current?.playShow(effectId); }, [effectId]);
// 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)' }}>
<PmisSidebar activePage="bim-viewer" />
<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}
effectId={effectId} onEffectChange={setEffectId} onPlayEffect={handlePlayEffect} />
</div>
<BimInfoPanel selected={selected} counts={counts} mode={mode} ifcModelInfo={ifcModelInfo} />
</div>
);
}
ReactDOM.render(<BimViewerApp />, document.getElementById('root'));
@endverbatim
</script>
@endpush