Files
sam-manage/resources/views/juil/bim-viewer.blade.php

553 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

@extends('layouts.app')
@section('title', 'BIM 뷰어')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
@include('partials.react-cdn')
<script type="text/babel">
@verbatim
const { useState, useEffect, useRef, useCallback } = React;
const 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