553 lines
27 KiB
PHP
553 lines
27 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>
|
||
@include('partials.react-cdn')
|
||
<script type="text/babel">
|
||
@verbatim
|
||
const { useState, useEffect, useRef, useCallback } = React;
|
||
|
||
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||
|
||
/* ════════════════════════════════════════════════
|
||
상수
|
||
════════════════════════════════════════════════ */
|
||
const COLORS = {
|
||
floor: 0x81C784, roof: 0x4CAF50, column: 0xFF9800,
|
||
beam: 0xFFC107, wall: 0x42A5F5, window: 0x00BCD4,
|
||
door: 0x8B4513, stair: 0xFFAB91, ground: 0xE8E0D0,
|
||
};
|
||
const TYPE_LABEL = {
|
||
floor:'바닥 슬래브', roof:'지붕', column:'기둥', beam:'보',
|
||
wall:'벽체', window:'창호', door:'출입문', stair:'계단실',
|
||
};
|
||
const TYPE_ICONS = {
|
||
column:'ri-layout-grid-line', beam:'ri-ruler-line', floor:'ri-layout-bottom-line',
|
||
wall:'ri-layout-masonry-line', window:'ri-window-line', roof:'ri-home-4-line',
|
||
stair:'ri-stairs-line', door:'ri-door-open-line',
|
||
};
|
||
const MODEL_INFO = {
|
||
projectName: 'SAM 물류센터 (데모)',
|
||
modelName: '구조 + 건축 통합 모델',
|
||
buildingType: '물류창고 / 사무동',
|
||
floors: '지상 3층',
|
||
area: '60m × 30m (1,800㎡)',
|
||
height: '12.0m',
|
||
updatedAt: '2026-03-12 10:30',
|
||
};
|
||
|
||
/* ════════════════════════════════════════════════
|
||
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.selected = null;
|
||
this.groups = {};
|
||
this.onSelect = null;
|
||
this._animId = null;
|
||
this._onClick = this.onClick.bind(this);
|
||
this._onResize = this.onResize.bind(this);
|
||
this.targetPos = null; this.targetLook = null;
|
||
}
|
||
|
||
init() {
|
||
const w = this.el.clientWidth, h = this.el.clientHeight;
|
||
|
||
this.scene = new THREE.Scene();
|
||
this.scene.background = new THREE.Color(0xf0f4f8);
|
||
this.scene.fog = new THREE.FogExp2(0xf0f4f8, 0.004);
|
||
|
||
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 500);
|
||
this.camera.position.set(55, 35, 55);
|
||
|
||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
this.renderer.setSize(w, h);
|
||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
this.renderer.shadowMap.enabled = true;
|
||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
this.el.appendChild(this.renderer.domElement);
|
||
|
||
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
|
||
this.controls.target.set(30, 6, 15);
|
||
this.controls.enableDamping = true;
|
||
this.controls.dampingFactor = 0.08;
|
||
this.controls.update();
|
||
|
||
// Lights
|
||
this.scene.add(new THREE.HemisphereLight(0xb1e1ff, 0xb97a20, 0.55));
|
||
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
|
||
sun.position.set(45, 50, 35);
|
||
sun.castShadow = true;
|
||
sun.shadow.mapSize.width = 2048;
|
||
sun.shadow.mapSize.height = 2048;
|
||
const sc = sun.shadow.camera;
|
||
sc.left = -65; sc.right = 65; sc.top = 50; sc.bottom = -20; sc.near = 1; sc.far = 150;
|
||
this.scene.add(sun);
|
||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.25));
|
||
|
||
// Grid
|
||
const grid = new THREE.GridHelper(140, 28, 0xc0c0c0, 0xe0e0e0);
|
||
grid.position.set(30, -0.01, 15);
|
||
this.scene.add(grid);
|
||
|
||
// Axes
|
||
const axes = new THREE.AxesHelper(4);
|
||
axes.position.set(-3, 0, -3);
|
||
this.scene.add(axes);
|
||
|
||
this.createBuilding();
|
||
this.renderer.domElement.addEventListener('click', this._onClick);
|
||
window.addEventListener('resize', this._onResize);
|
||
this.animate();
|
||
}
|
||
|
||
mat(color, transparent) {
|
||
return new THREE.MeshPhongMaterial({
|
||
color, transparent: !!transparent, opacity: transparent ? 0.35 : 1,
|
||
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
|
||
});
|
||
}
|
||
|
||
box(gx, gy, gz, pos, userData, group) {
|
||
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gx, gy, gz), this.mat(COLORS[userData.type], userData.type === 'window'));
|
||
mesh.position.set(pos[0], pos[1], pos[2]);
|
||
mesh.castShadow = true;
|
||
mesh.receiveShadow = true;
|
||
mesh.userData = userData;
|
||
if (!this.groups[group]) { this.groups[group] = new THREE.Group(); this.groups[group].name = group; this.scene.add(this.groups[group]); }
|
||
this.groups[group].add(mesh);
|
||
this.meshes.push(mesh);
|
||
return mesh;
|
||
}
|
||
|
||
createBuilding() {
|
||
const W = 60, D = 30, FH = 4, NF = 3;
|
||
const CX = 7, CZ = 4, SX = 10, SZ = 10;
|
||
|
||
// Ground
|
||
const gnd = new THREE.Mesh(new THREE.PlaneGeometry(140, 100), new THREE.MeshPhongMaterial({ color: COLORS.ground }));
|
||
gnd.rotation.x = -Math.PI / 2; gnd.position.set(30, -0.02, 15); gnd.receiveShadow = true;
|
||
this.scene.add(gnd);
|
||
|
||
// ─── Slabs ───
|
||
for (let f = 0; f <= NF; f++) {
|
||
const isRoof = f === NF;
|
||
const label = f === 0 ? '기초 슬래브' : isRoof ? '지붕 슬래브' : `${f}F 바닥 슬래브`;
|
||
const th = isRoof ? 0.4 : 0.3;
|
||
this.box(W + 0.6, th, D + 0.6, [W / 2, f * FH, D / 2],
|
||
{ type: isRoof ? 'roof' : 'floor', name: label, material: '철근콘크리트 (24MPa)', floor: isRoof ? 'RF' : (f === 0 ? 'GL' : f + 'F'), dimensions: `${W}m × ${D}m × ${th * 1000}mm` },
|
||
isRoof ? 'roof' : 'floor');
|
||
}
|
||
|
||
// ─── Columns ───
|
||
let cn = 0;
|
||
for (let f = 0; f < NF; f++) {
|
||
for (let ix = 0; ix < CX; ix++) {
|
||
for (let iz = 0; iz < CZ; iz++) {
|
||
cn++;
|
||
const ch = FH - 0.3;
|
||
this.box(0.45, ch, 0.45, [ix * SX, f * FH + 0.15 + ch / 2, iz * SZ],
|
||
{ type: 'column', name: `C-${String(cn).padStart(3, '0')}`, material: 'H형강 (SS400)', floor: `${f + 1}F`, dimensions: '400×400×3,700mm', grid: `(${ix + 1},${iz + 1})` },
|
||
'column');
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─── Beams ───
|
||
for (let f = 1; f <= NF; f++) {
|
||
const by = f * FH - 0.25;
|
||
for (let iz = 0; iz < CZ; iz++) {
|
||
this.box(W, 0.5, 0.3, [W / 2, by, iz * SZ],
|
||
{ type: 'beam', name: `GB-X${iz + 1}-${f}F`, material: 'H형강 (SS400)', floor: `${f}F`, dimensions: `${W}m × 300×500mm`, direction: 'X방향 대보' },
|
||
'beam');
|
||
}
|
||
for (let ix = 0; ix < CX; ix++) {
|
||
this.box(0.3, 0.45, D, [ix * SX, by, D / 2],
|
||
{ type: 'beam', name: `GB-Z${ix + 1}-${f}F`, material: 'H형강 (SS400)', floor: `${f}F`, dimensions: `${D}m × 300×450mm`, direction: 'Z방향 대보' },
|
||
'beam');
|
||
}
|
||
}
|
||
|
||
// ─── Walls & Windows ───
|
||
for (let f = 0; f < NF; f++) {
|
||
const baseY = f * FH + 0.3;
|
||
const wh = FH - 0.6;
|
||
const segW = SX - 1;
|
||
|
||
// Front (z = 0) — windows
|
||
for (let s = 0; s < CX - 1; s++) {
|
||
const cx = s * SX + SX / 2;
|
||
const bh = 1.0, winH = 2.0, ah = wh - bh - winH;
|
||
this.box(segW, bh, 0.25, [cx, baseY + bh / 2, 0],
|
||
{ type: 'wall', name: `W-F-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F`, dimensions: `${segW}m × ${bh}m`, face: '전면(남)' }, 'wall');
|
||
this.box(segW - 0.6, winH, 0.06, [cx, baseY + bh + winH / 2, 0],
|
||
{ type: 'window', name: `WIN-F-${f + 1}F-${s + 1}`, material: '복층유리 (24mm)', floor: `${f + 1}F`, dimensions: `${(segW - 0.6).toFixed(1)}m × ${winH}m`, face: '전면(남)' }, 'window');
|
||
if (ah > 0.1)
|
||
this.box(segW, ah, 0.25, [cx, baseY + bh + winH + ah / 2, 0],
|
||
{ type: 'wall', name: `W-F-${f + 1}F-${s + 1}t`, material: 'ALC 패널', floor: `${f + 1}F`, face: '전면(남)' }, 'wall');
|
||
}
|
||
|
||
// Back (z = D)
|
||
for (let s = 0; s < CX - 1; s++) {
|
||
const cx = s * SX + SX / 2;
|
||
if (f === 0 && s % 2 === 0) {
|
||
// Loading dock door
|
||
const dh = 3.5, dw = 5;
|
||
this.box(dw, dh, 0.12, [cx, baseY + dh / 2, D],
|
||
{ type: 'door', name: `DOCK-${Math.floor(s / 2) + 1}`, material: '스틸 오버헤드도어', floor: '1F', dimensions: `${dw}m × ${dh}m`, face: '후면(북) 하역장' }, 'wall');
|
||
const aboveH = wh - dh;
|
||
if (aboveH > 0.1)
|
||
this.box(segW, aboveH, 0.25, [cx, baseY + dh + aboveH / 2, D],
|
||
{ type: 'wall', name: `W-B-1F-${s + 1}t`, material: 'ALC 패널', floor: '1F', face: '후면(북)' }, 'wall');
|
||
} else {
|
||
this.box(segW, wh, 0.25, [cx, baseY + wh / 2, D],
|
||
{ type: 'wall', name: `W-B-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F`, dimensions: `${segW}m × ${wh.toFixed(1)}m`, face: '후면(북)' }, 'wall');
|
||
}
|
||
}
|
||
|
||
// Sides (x = 0, x = W)
|
||
[0, W].forEach((x, si) => {
|
||
const face = si === 0 ? '좌측(서)' : '우측(동)';
|
||
for (let s = 0; s < CZ - 1; s++) {
|
||
const cz = s * SZ + SZ / 2;
|
||
this.box(0.25, wh, segW, [x, baseY + wh / 2, cz],
|
||
{ type: 'wall', name: `W-S${si}-${f + 1}F-${s + 1}`, material: 'ALC 패널 (150mm)', floor: `${f + 1}F`, dimensions: `${segW}m × ${wh.toFixed(1)}m`, face }, 'wall');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ─── Stair Core ───
|
||
const sx = 53, sz = 13, sw = 5, sd = 7;
|
||
for (let f = 0; f < NF; f++) {
|
||
const baseY = f * FH + 0.3, wh = FH - 0.6, my = baseY + wh / 2;
|
||
[[sw, wh, 0.2, [sx, my, sz - sd / 2]], [sw, wh, 0.2, [sx, my, sz + sd / 2]],
|
||
[0.2, wh, sd, [sx - sw / 2, my, sz]], [0.2, wh, sd, [sx + sw / 2, my, sz]]].forEach((p, i) => {
|
||
this.box(p[0], p[1], p[2], p[3],
|
||
{ type: 'stair', name: `STAIR-${f + 1}F-${i + 1}`, material: '철근콘크리트', floor: `${f + 1}F`, dimensions: '계단/EV 코어' }, 'stair');
|
||
});
|
||
// Stair landing slab
|
||
this.box(sw - 0.4, 0.15, sd - 0.4, [sx, baseY + wh / 2, sz],
|
||
{ type: 'stair', name: `STAIR-LAND-${f + 1}F`, material: '철근콘크리트', floor: `${f + 1}F`, dimensions: '계단 참 슬래브' }, 'stair');
|
||
}
|
||
}
|
||
|
||
onClick(e) {
|
||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||
const hits = this.raycaster.intersectObjects(this.meshes);
|
||
|
||
if (this.selected) { this.selected.material.emissive.setHex(0); this.selected = null; }
|
||
if (hits.length > 0) {
|
||
this.selected = hits[0].object;
|
||
this.selected.material.emissive.setHex(0x444400);
|
||
if (this.onSelect) this.onSelect(this.selected.userData);
|
||
} else {
|
||
if (this.onSelect) this.onSelect(null);
|
||
}
|
||
}
|
||
|
||
setView(p) {
|
||
const t = new THREE.Vector3(30, 6, 15);
|
||
const pos = { perspective: [55, 35, 55], top: [30, 55, 15.01], front: [30, 8, -35], right: [85, 8, 15], back: [30, 8, 55] };
|
||
this.targetPos = new THREE.Vector3(...(pos[p] || pos.perspective));
|
||
this.targetLook = t.clone();
|
||
}
|
||
|
||
toggleGroup(name, vis) { if (this.groups[name]) this.groups[name].visible = vis; }
|
||
toggleWireframe(on) { this.meshes.forEach(m => { m.material.wireframe = on; }); }
|
||
|
||
getCounts() {
|
||
const c = {};
|
||
this.meshes.forEach(m => { const t = m.userData.type; c[t] = (c[t] || 0) + 1; });
|
||
return c;
|
||
}
|
||
|
||
onResize() {
|
||
const w = this.el.clientWidth, h = this.el.clientHeight;
|
||
this.camera.aspect = w / h;
|
||
this.camera.updateProjectionMatrix();
|
||
this.renderer.setSize(w, h);
|
||
}
|
||
|
||
animate() {
|
||
this._animId = requestAnimationFrame(() => this.animate());
|
||
if (this.targetPos) {
|
||
this.camera.position.lerp(this.targetPos, 0.07);
|
||
this.controls.target.lerp(this.targetLook, 0.07);
|
||
if (this.camera.position.distanceTo(this.targetPos) < 0.2) this.targetPos = null;
|
||
}
|
||
this.controls.update();
|
||
this.renderer.render(this.scene, this.camera);
|
||
}
|
||
|
||
dispose() {
|
||
cancelAnimationFrame(this._animId);
|
||
this.renderer.domElement.removeEventListener('click', this._onClick);
|
||
window.removeEventListener('resize', this._onResize);
|
||
this.renderer.dispose();
|
||
if (this.el.contains(this.renderer.domElement)) this.el.removeChild(this.renderer.domElement);
|
||
}
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
React Components
|
||
════════════════════════════════════════════════ */
|
||
|
||
/* ─── 사이드바 ─── */
|
||
function BimSidebar() {
|
||
const [profile, setProfile] = useState(null);
|
||
useEffect(() => {
|
||
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
|
||
.then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {});
|
||
}, []);
|
||
|
||
const menus = [
|
||
{ icon: 'ri-building-2-line', label: 'BIM 관리', active: true, children: [{ label: 'BIM 뷰어', active: true }] },
|
||
{ icon: 'ri-line-chart-line', label: '시공관리' },
|
||
{ icon: 'ri-file-list-3-line', label: '품질관리' },
|
||
{ icon: 'ri-shield-check-line', label: '안전관리' },
|
||
{ icon: 'ri-folder-line', label: '자료실' },
|
||
];
|
||
|
||
return (
|
||
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0" style={{ width: 200 }}>
|
||
{/* Back */}
|
||
<a href="/juil/construction-pmis" className="flex items-center gap-2 px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 border-b border-gray-100 transition">
|
||
<i className="ri-arrow-left-s-line text-lg"></i> PMIS 대시보드
|
||
</a>
|
||
{/* Profile */}
|
||
<div className="p-3 border-b border-gray-100 text-center">
|
||
<div className="w-12 h-12 mx-auto mb-1 rounded-full bg-gray-100 border-2 border-gray-200 flex items-center justify-center">
|
||
{profile?.profile_photo_path
|
||
? <img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover" />
|
||
: <i className="ri-user-3-line text-xl text-gray-300"></i>}
|
||
</div>
|
||
<div className="text-sm font-bold text-gray-800">{profile?.name || '...'}</div>
|
||
<div className="text-xs text-gray-500 mt-0.5">{profile?.department || ''}</div>
|
||
</div>
|
||
{/* Menus */}
|
||
<div className="flex-1 overflow-auto py-1">
|
||
{menus.map(m => (
|
||
<div key={m.label}>
|
||
<div className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${m.active ? 'bg-blue-50 text-blue-700 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||
<i className={`${m.icon} text-base`}></i>
|
||
{m.label}
|
||
<i className={`ml-auto ${m.active ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>
|
||
</div>
|
||
{m.active && m.children && m.children.map(c => (
|
||
<div key={c.label} className={`pl-10 pr-4 py-2 text-sm ${c.active ? 'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600' : 'text-gray-500 hover:text-blue-600'}`}>
|
||
{c.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── 하단 툴바 ─── */
|
||
function BimToolbar({ onView, visibility, onToggle, wireframe, onWireframe }) {
|
||
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'];
|
||
|
||
return (
|
||
<div className="absolute bottom-0 left-0 right-0 bg-white/95 backdrop-blur border-t border-gray-200 px-4 py-2 flex items-center gap-4 text-xs z-10">
|
||
{/* View Presets */}
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-gray-500 font-semibold mr-1">시점</span>
|
||
{views.map(v => (
|
||
<button key={v.id} onClick={() => onView(v.id)}
|
||
className="flex items-center gap-1 px-2 py-1.5 rounded hover:bg-blue-50 hover:text-blue-700 text-gray-600 transition"
|
||
title={v.label}>
|
||
<i className={`${v.icon} text-sm`}></i>
|
||
<span className="hidden xl:inline">{v.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="w-px h-6 bg-gray-300"></div>
|
||
|
||
{/* Element Toggles */}
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-gray-500 font-semibold mr-1">요소</span>
|
||
{toggles.map(t => (
|
||
<button key={t} onClick={() => onToggle(t)}
|
||
className={`flex items-center gap-1 px-2 py-1.5 rounded transition ${visibility[t] !== false ? 'bg-gray-100 text-gray-700' : 'bg-gray-50 text-gray-300 line-through'}`}
|
||
title={TYPE_LABEL[t]}>
|
||
<i className={`${TYPE_ICONS[t] || 'ri-checkbox-blank-line'} text-sm`}></i>
|
||
<span className="hidden xl:inline">{TYPE_LABEL[t]}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="w-px h-6 bg-gray-300"></div>
|
||
|
||
{/* Wireframe */}
|
||
<button onClick={onWireframe}
|
||
className={`flex items-center gap-1 px-2 py-1.5 rounded transition ${wireframe ? 'bg-blue-100 text-blue-700' : 'text-gray-600 hover:bg-gray-100'}`}>
|
||
<i className="ri-pencil-ruler-2-line text-sm"></i>
|
||
<span className="hidden xl:inline">와이어프레임</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── 우측 정보 패널 ─── */
|
||
function BimInfoPanel({ selected, counts }) {
|
||
return (
|
||
<div className="bg-white border-l border-gray-200 shadow-sm flex flex-col shrink-0 overflow-auto" style={{ width: 280 }}>
|
||
{/* 모델 정보 */}
|
||
<div className="p-4 border-b border-gray-100">
|
||
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-1 mb-3">
|
||
<i className="ri-information-line text-blue-500"></i> 모델 정보
|
||
</h3>
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
{[['현장', MODEL_INFO.projectName], ['모델', MODEL_INFO.modelName], ['용도', MODEL_INFO.buildingType],
|
||
['규모', MODEL_INFO.floors], ['면적', MODEL_INFO.area], ['높이', MODEL_INFO.height],
|
||
['수정일', MODEL_INFO.updatedAt]].map(([k, v]) => (
|
||
<tr key={k} className="border-b border-gray-50">
|
||
<td className="py-1.5 pr-2 text-gray-500 font-medium whitespace-nowrap">{k}</td>
|
||
<td className="py-1.5 text-gray-800">{v}</td>
|
||
</tr>
|
||
))}
|
||
</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">
|
||
<span className="w-3 h-3 rounded-sm" style={{ backgroundColor: '#' + (COLORS[selected.type] || 0x999999).toString(16).padStart(6, '0') }}></span>
|
||
<span className="text-sm font-bold text-gray-800">{selected.name}</span>
|
||
</div>
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
{[['유형', TYPE_LABEL[selected.type] || selected.type],
|
||
['재질', selected.material],
|
||
['층', selected.floor],
|
||
['치수', selected.dimensions],
|
||
['면', selected.face],
|
||
['방향', selected.direction],
|
||
['격자', selected.grid],
|
||
].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">
|
||
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: '#' + (COLORS[type] || 0x999999).toString(16).padStart(6, '0') }}></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>
|
||
);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════
|
||
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);
|
||
|
||
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 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; });
|
||
}, []);
|
||
|
||
return (
|
||
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
|
||
<BimSidebar />
|
||
<div className="flex-1 flex flex-col relative overflow-hidden">
|
||
{/* 3D Viewport */}
|
||
<div ref={vpRef} className="flex-1" style={{ minHeight: 0 }} />
|
||
<BimToolbar onView={handleView} visibility={visibility} onToggle={handleToggle} wireframe={wireframe} onWireframe={handleWire} />
|
||
</div>
|
||
<BimInfoPanel selected={selected} counts={counts} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
ReactDOM.render(<BimViewerApp />, document.getElementById('root'));
|
||
@endverbatim
|
||
</script>
|
||
@endpush
|