- 방화셔터/방화문/방화댐퍼 파라메트릭 3D 설계 - 실시간 Three.js 프리뷰 + IfcProduct JSON 생성 - IFC STEP 파일 내보내기 - PMIS 사이드바에 BIM 생성기 메뉴 등록
1311 lines
78 KiB
PHP
1311 lines
78 KiB
PHP
@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
|