Files
sam-manage/resources/views/juil/bim-generator.blade.php
김보곤 240b6e0e25 feat: [bim] 셔터 감아올림 물리 기반 롤 모델링 구현
- 이차방정식으로 감긴 회전수(n) 계산: t/2·n² + (R0-t/2)·n = L/2π
- 셔터 위치에 따라 샤프트 외경이 실시간 변화
- 강판형: 감긴 층 표현 (외곽 링 라인)
- 스크린형: 공기층 감안 두께 계수 2.5x, 반투명 롤
2026-03-13 17:41:30 +09:00

875 lines
50 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, useMemo } = React;
/* ════════════════════════════════════════════════
슬랫 유형 정의 (핵심 기술 사양)
════════════════════════════════════════════════ */
const SLAT_TYPES = {
'EGI': {
label: 'EGI 강판 (철재슬라트)',
thickness: 1.6,
pitch: 80,
profile: 'C',
color: 0x78909C,
panelColor: 0x90A4AE,
opacity: 1.0,
weightFactor: 25,
marginW: 110,
guideRailWidth: 65,
guideRailDepth: 50,
description: 'EGI 1.6T, C형 인터록킹',
},
'STS304': {
label: 'STS304 스테인리스',
thickness: 1.5,
pitch: 80,
profile: 'C',
color: 0xB0BEC5,
panelColor: 0xCFD8DC,
opacity: 1.0,
weightFactor: 26,
marginW: 110,
guideRailWidth: 65,
guideRailDepth: 50,
description: 'STS304 1.5T, C형 인터록킹',
},
'SILICA': {
label: '실리카 스크린 (KSS01)',
thickness: 0.8,
pitch: 100,
profile: 'flat',
color: 0xBCAAA4,
panelColor: 0xD7CCC8,
opacity: 0.6,
weightFactor: 2,
marginW: 140,
guideRailWidth: 70,
guideRailDepth: 50,
description: '실리카 섬유 원단 0.8T',
},
'WIRE': {
label: '와이어 스크린 (KSS02)',
thickness: 0.8,
pitch: 100,
profile: 'flat',
color: 0xA1887F,
panelColor: 0xBCAAA4,
opacity: 0.5,
weightFactor: 2.5,
marginW: 140,
guideRailWidth: 70,
guideRailDepth: 50,
description: '와이어 메쉬 원단 0.8T',
},
};
/* ════════════════════════════════════════════════
객체 라이브러리 — 방화셔터 (상세 사양 기반)
════════════════════════════════════════════════ */
const OBJECT_LIBRARY = [
{
id: 'fire-shutter',
name: '방화셔터',
icon: 'ri-fire-line',
category: 'fire-protection',
categoryName: '방화설비',
description: '방화구획 경계에 설치하는 자동 강하 셔터',
ifcType: 'IfcDoor',
ifcPredefinedType: 'GATE',
defaultParams: {
openWidth: 3000,
openHeight: 3000,
slatType: 'EGI',
fireRating: '2시간',
shutterType: 'single',
motorSide: 'right',
boxHeight: 380,
boxDepth: 380,
shaftDiameter: 120,
guideRailThickness: 2.3,
bottomBarHeight: 40,
sealHeight: 15,
shutterPosition: 100,
},
paramDefs: [
{ key: 'openWidth', label: '개구부 폭 (W0)', unit: 'mm', type: 'number', min: 500, max: 15000, step: 100 },
{ key: 'openHeight', label: '개구부 높이 (H0)', unit: 'mm', type: 'number', min: 500, max: 8000, step: 100 },
{ key: 'slatType', label: '슬랫 유형', type: 'select', options: [
{ value: 'EGI', label: 'EGI 강판 (철재 1.6T)' },
{ value: 'STS304', label: 'STS304 (스테인리스 1.5T)' },
{ value: 'SILICA', label: '실리카 스크린 (0.8T)' },
{ value: 'WIRE', label: '와이어 스크린 (0.8T)' },
]},
{ key: 'fireRating', label: '내화등급', type: 'select', options: ['30분', '1시간', '2시간', '3시간'] },
{ key: 'shutterType', label: '셔터 유형', type: 'select', options: [
{ value: 'single', label: '단일 강하' },
{ value: 'double', label: '이중 강하 (양방향)' },
]},
{ key: 'motorSide', label: '모터 위치', type: 'select', options: [
{ value: 'left', label: '좌측' },
{ value: 'right', label: '우측' },
]},
{ key: 'boxHeight', label: '셔터박스 높이', unit: 'mm', type: 'number', min: 250, max: 600, step: 10 },
{ key: 'boxDepth', label: '셔터박스 깊이', unit: 'mm', type: 'number', min: 250, max: 600, step: 10 },
{ key: 'shaftDiameter', label: '샤프트 직경', unit: 'mm', type: 'number', min: 60, max: 250, step: 10 },
{ key: 'guideRailThickness', label: '가이드레일 두께', unit: 'mm', type: 'number', min: 1.5, max: 3.2, step: 0.1 },
{ key: 'bottomBarHeight', label: '하장바 높이', unit: 'mm', type: 'number', min: 20, max: 80, step: 5 },
{ key: 'sealHeight', label: '고무실링 높이', unit: 'mm', type: 'number', min: 5, max: 30, step: 5 },
{ key: 'shutterPosition', label: '셔터 위치 (%)', unit: '%', type: 'number', min: 0, max: 100, step: 5 },
],
},
{
id: 'fire-door',
name: '방화문',
icon: 'ri-door-lock-line',
category: 'fire-protection',
categoryName: '방화설비',
description: '방화구획에 설치하는 갑종/을종 방화문',
ifcType: 'IfcDoor',
ifcPredefinedType: 'DOOR',
defaultParams: { width: 900, height: 2100, depth: 60, fireRating: '1시간', material: 'Steel', doorType: 'single-swing', glazing: 'none' },
paramDefs: [
{ key: 'width', label: '폭', unit: 'mm', type: 'number', min: 600, max: 3000, step: 50 },
{ key: 'height', label: '높이', unit: 'mm', type: 'number', min: 1800, max: 3000, step: 50 },
{ key: 'depth', label: '두께', unit: 'mm', type: 'number', min: 40, max: 120, step: 5 },
{ key: 'fireRating', label: '내화등급', type: 'select', options: ['30분', '1시간', '2시간'] },
{ key: 'material', label: '재질', type: 'select', options: ['Steel', 'STS304', 'Wood Core'] },
{ key: 'doorType', label: '개폐 방식', type: 'select', options: [
{ value: 'single-swing', label: '외여닫이' },
{ value: 'double-swing', label: '양여닫이' },
]},
{ key: 'glazing', label: '유리', type: 'select', options: [
{ value: 'none', label: '없음' },
{ value: 'small', label: '소형 확인창' },
{ value: 'half', label: '반유리' },
]},
],
},
];
const COLORS = {
housing: 0x546E7A, jamb: 0x607D8B, panel: 0xE53935,
frame: 0x455A64, door: 0x8D6E63, doorFrame: 0x5D4037,
glazing: 0x80DEEA, dimension: 0x1565C0,
guideRail: 0x64748b, shaft: 0x546E7A, bracket: 0x4B5563,
motor: 0x94a3b8, bottomBar: 0x37474F, rubber: 0x212121,
wall: 0xBDBDBD, sealPacking: 0xE65100,
};
/* ════════════════════════════════════════════════
PMIS 사이드바 메뉴
════════════════════════════════════════════════ */
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' },
]},
];
/* ════════════════════════════════════════════════
IfcProduct JSON 생성기
════════════════════════════════════════════════ */
function generateGlobalId() {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$';
let s = '';
for (let i = 0; i < 22; i++) s += chars[Math.random() * 64 | 0];
return s;
}
function buildIfcProductJSON(objectDef, params) {
const isShutter = objectDef.id === 'fire-shutter';
const slatInfo = isShutter ? SLAT_TYPES[params.slatType] : null;
const w = isShutter ? (params.openWidth + (slatInfo?.marginW || 110)) / 1000 : params.width / 1000;
const h = isShutter ? (params.openHeight + 350) / 1000 : params.height / 1000;
const d = isShutter ? (params.boxDepth || 380) / 1000 : params.depth / 1000;
const psetProps = {};
for (const [key, val] of Object.entries(params)) {
if (['width', 'height', 'depth'].includes(key) && !isShutter) continue;
const def = objectDef.paramDefs.find(p => p.key === key);
if (!def) continue;
let displayVal = val;
if (def.type === 'select' && Array.isArray(def.options) && typeof def.options[0] === 'object') {
const opt = def.options.find(o => o.value === val);
if (opt) displayVal = opt.label;
}
psetProps[def.label] = displayVal;
}
if (isShutter && slatInfo) {
psetProps['슬랫 두께'] = slatInfo.thickness + 'mm';
psetProps['슬랫 피치'] = slatInfo.pitch + 'mm';
psetProps['슬랫 프로파일'] = slatInfo.profile;
psetProps['제작 폭 (W1)'] = (params.openWidth + slatInfo.marginW) + 'mm';
psetProps['제작 높이 (H1)'] = (params.openHeight + 350) + 'mm';
const area = w * h;
psetProps['면적'] = area.toFixed(2) + 'm²';
psetProps['중량'] = (area * slatInfo.weightFactor).toFixed(1) + 'kg';
}
return {
type: objectDef.ifcType,
GlobalId: generateGlobalId(),
OwnerHistory: {
OwningUser: { ThePerson: { FamilyName: 'SAM', GivenName: 'User' }, TheOrganization: { Name: 'CodeBridgeX' } },
OwningApplication: { ApplicationDeveloper: { Name: 'CodeBridgeX' }, Version: '1.0', ApplicationFullName: 'SAM BIM Generator', ApplicationIdentifier: 'SAM-BIM-GEN' },
ChangeAction: 'ADDED', CreationDate: Math.floor(Date.now() / 1000),
},
Name: objectDef.name,
Description: objectDef.description,
ObjectType: objectDef.name,
PredefinedType: objectDef.ifcPredefinedType,
ObjectPlacement: {
type: 'IfcLocalPlacement',
RelativePlacement: { type: 'IfcAxis2Placement3D', Location: { type: 'IfcCartesianPoint', Coordinates: [0, 0, 0] }, Axis: { type: 'IfcDirection', DirectionRatios: [0, 0, 1] }, RefDirection: { type: 'IfcDirection', DirectionRatios: [1, 0, 0] } },
},
Representation: {
type: 'IfcProductDefinitionShape',
Representations: [{
type: 'IfcShapeRepresentation', ContextOfItems: 'Model', RepresentationIdentifier: 'Body', RepresentationType: 'SweptSolid',
Items: [{ type: 'IfcExtrudedAreaSolid', SweptArea: { type: 'IfcRectangleProfileDef', ProfileType: 'AREA', XDim: w, YDim: d }, Depth: h, ExtrudedDirection: { type: 'IfcDirection', DirectionRatios: [0, 0, 1] } }],
}],
},
OverallWidth: w, OverallHeight: h,
IsDefinedBy: [{
type: 'IfcRelDefinesByProperties',
RelatingPropertyDefinition: {
type: 'IfcPropertySet', Name: 'Pset_FireShutterCommon',
HasProperties: Object.entries(psetProps).map(([name, val]) => ({
type: 'IfcPropertySingleValue', Name: name,
NominalValue: { type: typeof val === 'number' ? 'IfcReal' : 'IfcLabel', value: val },
})),
},
}],
};
}
/* ════════════════════════════════════════════════
Three.js 파라메트릭 프리뷰 엔진
════════════════════════════════════════════════ */
class GeneratorScene {
constructor(el) {
this.el = el; this.scene = null; this.camera = null; this.renderer = null; this.controls = null;
this._animId = null; this._onResize = this.onResize.bind(this);
this.objectGroup = null; this.dimGroup = 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.008);
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 200);
this.camera.position.set(6, 3, 6);
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(0, 1.5, 0);
this.controls.enableDamping = true; this.controls.dampingFactor = 0.08;
this.controls.update();
this.scene.add(new THREE.HemisphereLight(0xb1e1ff, 0xb97a20, 0.55));
const sun = new THREE.DirectionalLight(0xffffff, 0.85);
sun.position.set(5, 8, 5); sun.castShadow = true;
sun.shadow.mapSize.width = 2048; sun.shadow.mapSize.height = 2048;
const sc = sun.shadow.camera; sc.left = -10; sc.right = 10; sc.top = 10; sc.bottom = -2;
this.scene.add(sun);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.25));
const grid = new THREE.GridHelper(20, 20, 0xc0c0c0, 0xe0e0e0);
grid.position.y = -0.01; this.scene.add(grid);
const axes = new THREE.AxesHelper(1); axes.position.set(-0.5, 0, -0.5); this.scene.add(axes);
const ground = new THREE.Mesh(new THREE.PlaneGeometry(20, 20), new THREE.MeshPhongMaterial({ color: 0xE8E0D0 }));
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; ground.receiveShadow = true;
this.scene.add(ground);
this.objectGroup = new THREE.Group(); this.scene.add(this.objectGroup);
this.dimGroup = new THREE.Group(); this.scene.add(this.dimGroup);
window.addEventListener('resize', this._onResize);
this.animate();
}
mat(color, opts) {
const o = opts || {};
return new THREE.MeshPhongMaterial({
color, transparent: !!o.transparent, opacity: o.opacity ?? 1,
side: o.transparent ? THREE.DoubleSide : THREE.FrontSide,
metalness: o.metalness, roughness: o.roughness,
});
}
addBox(gx, gy, gz, pos, color, opts) {
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gx, gy, gz), this.mat(color, opts));
mesh.position.set(pos[0], pos[1], pos[2]);
mesh.castShadow = true; mesh.receiveShadow = true;
this.objectGroup.add(mesh); return mesh;
}
addCylinder(radius, height, pos, color, axis, opts) {
const geo = new THREE.CylinderGeometry(radius, radius, height, 32);
if (axis === 'x') geo.rotateZ(Math.PI / 2);
if (axis === 'z') geo.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(geo, this.mat(color, opts));
mesh.position.set(pos[0], pos[1], pos[2]);
mesh.castShadow = true; this.objectGroup.add(mesh); return mesh;
}
addExtrude(shape, depth, pos, color, rotation, opts) {
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
const mesh = new THREE.Mesh(geo, this.mat(color, opts));
mesh.position.set(pos[0], pos[1], pos[2]);
if (rotation) { mesh.rotation.set(rotation[0], rotation[1], rotation[2]); }
mesh.castShadow = true; this.objectGroup.add(mesh); return mesh;
}
addDimLabel(start, end, label, offset) {
const mid = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5);
if (offset) mid.add(offset);
const p0 = start.clone(); const p1 = end.clone();
if (offset) { p0.add(offset); p1.add(offset); }
this.dimGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([p0, p1]), new THREE.LineBasicMaterial({ color: COLORS.dimension, linewidth: 2 })));
if (offset) {
const ext = new THREE.LineBasicMaterial({ color: COLORS.dimension, linewidth: 1, transparent: true, opacity: 0.4 });
this.dimGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([start.clone(), p0]), ext));
this.dimGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints([end.clone(), p1]), ext));
}
const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1565C0'; ctx.font = 'bold 28px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, 128, 32);
const tex = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true }));
sprite.position.copy(mid); if (offset) sprite.position.add(new THREE.Vector3(0, 0.12, 0));
const len = start.distanceTo(end); sprite.scale.set(Math.max(0.4, len * 0.4), Math.max(0.1, len * 0.1), 1);
this.dimGroup.add(sprite);
}
clearObject() {
const dispose = (g) => { while (g.children.length > 0) { const c = g.children[0]; if (c.geometry) c.geometry.dispose(); if (c.material) { if (c.material.map) c.material.map.dispose(); c.material.dispose(); } g.remove(c); } };
dispose(this.objectGroup); dispose(this.dimGroup);
}
/* ── 방화셔터 (실제 구조 기반) ── */
buildFireShutter(params) {
const slatInfo = SLAT_TYPES[params.slatType] || SLAT_TYPES.EGI;
const W0 = params.openWidth / 1000; // 개구부 폭 (m)
const H0 = params.openHeight / 1000; // 개구부 높이 (m)
const W1 = (params.openWidth + slatInfo.marginW) / 1000; // 제작 폭
const BH = params.boxHeight / 1000; // 셔터박스 높이
const BD = params.boxDepth / 1000; // 셔터박스 깊이
const SD = params.shaftDiameter / 1000;
const GRW = slatInfo.guideRailWidth / 1000;
const GRD = slatInfo.guideRailDepth / 1000;
const GRT = params.guideRailThickness / 1000;
const BBH = params.bottomBarHeight / 1000;
const SLH = params.sealHeight / 1000;
const shutPos = params.shutterPosition / 100; // 0=open, 1=closed
const isScreen = slatInfo.profile === 'flat';
// ── 셔터박스 (상부 하우징) ──
const boxY = H0 + BH / 2;
this.addBox(W1, BH, BD, [0, boxY, 0], COLORS.housing, { transparent: true, opacity: 0.55 });
// 셔터박스 엣지
const boxGeo = new THREE.BoxGeometry(W1, BH, BD);
const edges = new THREE.EdgesGeometry(boxGeo);
const edgeMesh = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x94a3b8 }));
edgeMesh.position.set(0, boxY, 0); this.objectGroup.add(edgeMesh);
// ── 브래킷 (좌우 베어링 지지판) ──
const bkW = 0.08;
const bkX = W1 / 2 - bkW / 2;
this.addBox(bkW, BH * 0.7, BD * 0.5, [bkX, boxY, 0], COLORS.bracket);
this.addBox(bkW, BH * 0.7, BD * 0.5, [-bkX, boxY, 0], COLORS.bracket);
// ── 샤프트 (양쪽 브래킷을 관통 — 브래킷 중심간 거리) ──
const shaftLen = W1 - bkW;
this.addCylinder(SD / 2, shaftLen, [0, boxY, 0], COLORS.shaft, 'x');
// ── 감아올림 롤 (샤프트에 감긴 슬랫 — 물리 기반 계산) ──
const slatT_roll = slatInfo.thickness / 1000;
const woundUpH = H0 * (1 - shutPos);
if (woundUpH > 0.01) {
const R0 = SD / 2;
// 스크린형은 원단이 겹치면서 공기층으로 실제 두께의 약 2.5배
const t = isScreen ? slatT_roll * 2.5 : slatT_roll;
// 이차방정식: t/2·n² + (R0 - t/2)·n - woundUpH/(2π) = 0
const a = t / 2;
const b = R0 - t / 2;
const c = -woundUpH / (2 * Math.PI);
const disc = b * b - 4 * a * c;
const wraps = disc > 0 ? Math.max(0, (-b + Math.sqrt(disc)) / (2 * a)) : 0;
const rollR = R0 + wraps * t;
if (rollR > R0 + 0.001) {
const rollLen = W0 * 0.96;
const rollColor = isScreen ? slatInfo.panelColor : slatInfo.color;
this.addCylinder(rollR, rollLen, [0, boxY, 0], rollColor, 'x', {
transparent: isScreen, opacity: isScreen ? 0.65 : 0.95,
});
// 강판형: 감긴 층 표현 (외곽 링 라인)
if (!isScreen && wraps >= 1) {
const ringGeo = new THREE.RingGeometry(rollR - 0.001, rollR + 0.001, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0x546E7A, side: THREE.DoubleSide });
const ringCount = Math.min(Math.floor(rollLen / 0.06), 30);
for (let ri = 0; ri <= ringCount; ri++) {
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.position.set(-rollLen / 2 + ri * (rollLen / ringCount), boxY, 0);
ring.rotation.y = Math.PI / 2;
this.objectGroup.add(ring);
}
}
}
}
// ── 모터 (모터측 브래킷 내측에 밀착 배치) ──
const motorDir = params.motorSide === 'right' ? 1 : -1;
const motorR = Math.min(SD * 0.55, BH * 0.28);
const motorLen = Math.min(W1 * 0.12, 0.25);
const motorX = motorDir * (bkX - bkW / 2 - motorLen / 2 - 0.01);
this.addCylinder(motorR, motorLen, [motorX, boxY, 0], COLORS.motor, 'x');
// ── 가이드레일 (C-채널, ExtrudeGeometry) ──
const railH = H0 + 0.1; // 바닥~셔터박스 하단 결합부
const lip = isScreen ? 0.010 : 0.015;
const createRailShape = () => {
const s = new THREE.Shape();
s.moveTo(0, 0);
s.lineTo(GRW, 0);
s.lineTo(GRW, lip);
s.lineTo(GRW - GRT, lip);
s.lineTo(GRW - GRT, GRT);
s.lineTo(GRT, GRT);
s.lineTo(GRT, lip);
s.lineTo(0, lip);
s.lineTo(0, 0);
// 내부 채널 (hole)
const hole = new THREE.Path();
const inner = GRT * 1.5;
hole.moveTo(inner, inner);
hole.lineTo(inner, GRD - GRT);
hole.lineTo(GRW - inner, GRD - GRT);
hole.lineTo(GRW - inner, inner);
hole.lineTo(inner, inner);
s.holes.push(hole);
return s;
};
// 좌측 가이드레일
this.addExtrude(createRailShape(), railH, [-W0 / 2 - GRW, 0, -GRD / 2], COLORS.guideRail, [-Math.PI / 2, 0, 0]);
// 우측 가이드레일 (미러)
this.addExtrude(createRailShape(), railH, [W0 / 2 + GRW, 0, GRD / 2], COLORS.guideRail, [Math.PI / 2, Math.PI, 0]);
// ── 연기차단재 (Smoke Seal Packing) ──
const sealT = 0.005;
const sealD = GRD * 0.7;
this.addBox(sealT, railH, sealD, [-W0 / 2 - GRW / 2, railH / 2, 0], COLORS.sealPacking, { transparent: true, opacity: 0.7 });
this.addBox(sealT, railH, sealD, [W0 / 2 + GRW / 2, railH / 2, 0], COLORS.sealPacking, { transparent: true, opacity: 0.7 });
// ── 슬랫 커튼 ──
const curtainH = H0 * shutPos;
const curtainTop = H0;
const curtainBot = H0 - curtainH;
const slatT = slatInfo.thickness / 1000;
const slatPitch = slatInfo.pitch / 1000;
if (curtainH > 0.01) {
if (isScreen) {
// 스크린형 (실리카/와이어): 연속된 천/원단 — 개별 슬랫·연결부 없음
const fabricThick = 0.003;
// 메인 원단 면 (반투명 연속 평면)
this.addBox(W0, curtainH, fabricThick, [0, curtainBot + curtainH / 2, 0], slatInfo.panelColor, { transparent: true, opacity: slatInfo.opacity });
// 원단 뒷면 (약간 어두운 이중면으로 천 두께감)
this.addBox(W0 - 0.005, curtainH - 0.005, fabricThick, [0, curtainBot + curtainH / 2, fabricThick * 1.5], slatInfo.color, { transparent: true, opacity: slatInfo.opacity * 0.4 });
// 미세한 직조 라인 (넓은 간격, 매우 얇게 — 원단 텍스처)
const weaveGap = 0.15;
const weaveCount = Math.floor(curtainH / weaveGap);
for (let i = 1; i <= Math.min(weaveCount, 80); i++) {
const y = curtainBot + i * weaveGap;
if (y > curtainTop) break;
this.addBox(W0 - 0.01, 0.001, fabricThick * 2, [0, y, 0], slatInfo.color, { transparent: true, opacity: 0.25 });
}
// 상단 원단 고정바 (셔터박스 하단에 원단이 감기는 부분)
this.addBox(W0, 0.015, 0.012, [0, curtainTop - 0.008, 0], slatInfo.color);
} else {
// 강판형 (EGI/STS304): 인터록킹 슬랫 개별 표현
const slatCount = Math.floor(curtainH / slatPitch);
for (let i = 0; i < Math.min(slatCount, 120); i++) {
const y = curtainBot + (i + 0.5) * slatPitch;
if (y > curtainTop) break;
const slatH = slatPitch * 0.85;
this.addBox(W0, slatH, slatT, [0, y, 0], slatInfo.color);
// 인터록킹 연결부 (작은 립)
if (i > 0) {
this.addBox(W0 - 0.01, slatPitch * 0.1, slatT * 1.5, [0, y - slatH / 2 - slatPitch * 0.05, slatT * 0.3], slatInfo.panelColor);
}
}
}
}
// ── 하장바 (Bottom Bar) ──
if (shutPos > 0.9) {
this.addBox(W0, BBH, GRD * 0.6, [0, BBH / 2, 0], COLORS.bottomBar);
// 고무 실링
this.addBox(W0 + 0.01, SLH, GRD * 0.4, [0, -SLH / 2, 0], COLORS.rubber);
}
// ── 방화벽 (반투명 배경) ──
this.addBox(W0 + GRW * 4, H0 + BH + 0.5, 0.15, [0, (H0 + BH) / 2, -GRD / 2 - 0.15], COLORS.wall, { transparent: true, opacity: 0.15 });
// ── 치수선 ──
const totalW = W0 + GRW * 2;
this.addDimLabel(
new THREE.Vector3(-totalW / 2, 0, 0), new THREE.Vector3(totalW / 2, 0, 0),
`${params.openWidth}mm`, new THREE.Vector3(0, -0.25, GRD / 2 + 0.3)
);
this.addDimLabel(
new THREE.Vector3(totalW / 2, 0, 0), new THREE.Vector3(totalW / 2, H0, 0),
`${params.openHeight}mm`, new THREE.Vector3(0.4, 0, 0)
);
this.addDimLabel(
new THREE.Vector3(totalW / 2, H0, 0), new THREE.Vector3(totalW / 2, H0 + BH, 0),
`${params.boxHeight}mm`, new THREE.Vector3(0.6, 0, 0)
);
this.fitCamera(totalW, H0 + BH);
}
/* ── 방화문 ── */
buildFireDoor(params) {
const W = params.width / 1000, H = params.height / 1000, D = params.depth / 1000;
const fw = 0.06, isDouble = params.doorType === 'double-swing';
this.addBox(fw, H, D + 0.02, [-(W / 2 + fw / 2), H / 2, 0], COLORS.doorFrame);
this.addBox(fw, H, D + 0.02, [(W / 2 + fw / 2), H / 2, 0], COLORS.doorFrame);
this.addBox(W + fw * 2, fw, D + 0.02, [0, H + fw / 2, 0], COLORS.doorFrame);
if (isDouble) {
this.addBox(W / 2 - 0.01, H - 0.02, D, [-W / 4, H / 2, 0], COLORS.door);
this.addBox(W / 2 - 0.01, H - 0.02, D, [W / 4, H / 2, 0], COLORS.door);
} else {
this.addBox(W - 0.02, H - 0.02, D, [0, H / 2, 0], COLORS.door);
}
if (params.glazing === 'small') {
const gw = Math.min(W * 0.3, 0.3);
if (isDouble) { this.addBox(gw, 0.3, D + 0.01, [-W / 4, H * 0.65, 0], COLORS.glazing, { transparent: true, opacity: 0.4 }); this.addBox(gw, 0.3, D + 0.01, [W / 4, H * 0.65, 0], COLORS.glazing, { transparent: true, opacity: 0.4 }); }
else { this.addBox(gw, 0.3, D + 0.01, [0, H * 0.65, 0], COLORS.glazing, { transparent: true, opacity: 0.4 }); }
} else if (params.glazing === 'half') {
const gw = W * 0.7, gh = H * 0.4;
if (isDouble) { this.addBox(gw / 2 - 0.03, gh, D + 0.01, [-W / 4, H * 0.55, 0], COLORS.glazing, { transparent: true, opacity: 0.4 }); this.addBox(gw / 2 - 0.03, gh, D + 0.01, [W / 4, H * 0.55, 0], COLORS.glazing, { transparent: true, opacity: 0.4 }); }
else { this.addBox(gw, gh, D + 0.01, [0, H * 0.55, 0], COLORS.glazing, { transparent: true, opacity: 0.4 }); }
}
this.addDimLabel(new THREE.Vector3(-W / 2 - fw, 0, 0), new THREE.Vector3(W / 2 + fw, 0, 0), `${params.width}mm`, new THREE.Vector3(0, -0.2, D / 2 + 0.2));
this.addDimLabel(new THREE.Vector3(W / 2 + fw + 0.05, 0, 0), new THREE.Vector3(W / 2 + fw + 0.05, H, 0), `${params.height}mm`, new THREE.Vector3(0.3, 0, 0));
this.fitCamera(W + fw * 2, H);
}
buildObject(objectId, params) {
this.clearObject();
if (objectId === 'fire-shutter') this.buildFireShutter(params);
else if (objectId === 'fire-door') this.buildFireDoor(params);
}
fitCamera(w, h) {
const maxDim = Math.max(w, h);
const dist = maxDim * 1.6 + 2;
this.camera.position.set(dist * 0.7, h * 0.5 + 1, dist * 0.7);
this.controls.target.set(0, h / 2, 0); this.controls.update();
}
onResize() {
const w = this.el.clientWidth, h = this.el.clientHeight;
if (!w || !h) return;
this.camera.aspect = w / h; this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
}
animate() {
this._animId = requestAnimationFrame(() => this.animate());
this.controls.update(); this.renderer.render(this.scene, this.camera);
}
dispose() {
cancelAnimationFrame(this._animId); this.clearObject();
window.removeEventListener('resize', this._onResize);
this.renderer.dispose();
if (this.el.contains(this.renderer.domElement)) this.el.removeChild(this.renderer.domElement);
}
}
/* ════════════════════════════════════════════════
IFC STEP 파일 생성 + 다운로드
════════════════════════════════════════════════ */
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);
}
/* ════════════════════════════════════════════════
React Components
════════════════════════════════════════════════ */
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>
);
}
/* ── 객체 라이브러리 ── */
function ObjectLibrary({ selectedId, onSelect }) {
const categories = useMemo(() => {
const map = {};
for (const obj of OBJECT_LIBRARY) { if (!map[obj.category]) map[obj.category] = { name: obj.categoryName, items: [] }; map[obj.category].items.push(obj); }
return Object.entries(map);
}, []);
return (
<div className="p-3 border-b border-gray-200">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-1"><i className="ri-shapes-line"></i> 객체 라이브러리</h3>
{categories.map(([catId, cat]) => (
<div key={catId} className="mb-2">
<div className="text-xs text-gray-400 font-medium mb-1">{cat.name}</div>
<div className="space-y-1">
{cat.items.map(obj => (
<button key={obj.id} onClick={() => onSelect(obj.id)}
className={`w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left transition text-sm ${selectedId === obj.id ? 'bg-blue-100 text-blue-800 ring-1 ring-blue-300 font-semibold' : 'bg-gray-50 text-gray-700 hover:bg-gray-100'}`}>
<i className={`${obj.icon} text-base ${selectedId === obj.id ? 'text-blue-600' : 'text-gray-400'}`}></i>
<div><div className="leading-tight">{obj.name}</div><div className="text-xs text-gray-400 leading-tight">{obj.description}</div></div>
</button>
))}
</div>
</div>
))}
</div>
);
}
/* ── 파라미터 에디터 ── */
function ParamEditor({ objectDef, params, onChange }) {
if (!objectDef) return null;
const handleChange = (key, value) => {
const def = objectDef.paramDefs.find(p => p.key === key);
if (def?.type === 'number') value = Number(value);
onChange({ ...params, [key]: value });
};
// 계산 표시 (방화셔터)
const calcInfo = useMemo(() => {
if (objectDef.id !== 'fire-shutter') return null;
const slatInfo = SLAT_TYPES[params.slatType];
if (!slatInfo) return null;
const W1 = params.openWidth + slatInfo.marginW;
const H1 = params.openHeight + 350;
const area = (W1 * H1) / 1000000;
const weight = area * slatInfo.weightFactor;
return { W1, H1, area: area.toFixed(2), weight: weight.toFixed(1), slatInfo };
}, [objectDef, params]);
return (
<div className="p-3 flex-1 overflow-auto">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-1"><i className="ri-settings-3-line"></i> 속성 설정</h3>
{calcInfo && (
<div className="mb-3 p-2 bg-blue-50 rounded-lg border border-blue-200 text-xs space-y-1">
<div className="font-bold text-blue-700 mb-1">자동 계산</div>
<div className="flex justify-between"><span className="text-gray-500">제작 (W1)</span><span className="font-mono font-bold">{calcInfo.W1}mm</span></div>
<div className="flex justify-between"><span className="text-gray-500">제작 높이 (H1)</span><span className="font-mono font-bold">{calcInfo.H1}mm</span></div>
<div className="flex justify-between"><span className="text-gray-500">면적</span><span className="font-mono font-bold">{calcInfo.area}</span></div>
<div className="flex justify-between"><span className="text-gray-500">중량</span><span className="font-mono font-bold">{calcInfo.weight}kg</span></div>
<div className="flex justify-between"><span className="text-gray-500">슬랫</span><span className="font-mono text-blue-600 font-bold">{calcInfo.slatInfo.description}</span></div>
</div>
)}
<div className="space-y-2.5">
{objectDef.paramDefs.map(def => (
<div key={def.key}>
<label className="text-xs font-medium text-gray-600 mb-0.5 block">{def.label} {def.unit && <span className="text-gray-400">({def.unit})</span>}</label>
{def.type === 'number' ? (
<div className="flex items-center gap-2">
<input type="range" min={def.min} max={def.max} step={def.step} value={params[def.key] || 0} onChange={e => handleChange(def.key, e.target.value)} className="flex-1 accent-blue-600 h-1.5" />
<input type="number" min={def.min} max={def.max} step={def.step} value={params[def.key] || 0} onChange={e => handleChange(def.key, e.target.value)}
className="w-20 px-2 py-1 text-xs border border-gray-300 rounded bg-white text-right font-mono focus:ring-1 focus:ring-blue-500 outline-none" />
</div>
) : def.type === 'select' ? (
<select value={params[def.key] || ''} onChange={e => handleChange(def.key, e.target.value)}
className="w-full px-2 py-1.5 text-xs border border-gray-300 rounded bg-white text-gray-700 focus:ring-1 focus:ring-blue-500 outline-none">
{def.options.map(opt => { const val = typeof opt === 'object' ? opt.value : opt; const label = typeof opt === 'object' ? opt.label : opt; return <option key={val} value={val}>{label}</option>; })}
</select>
) : null}
</div>
))}
</div>
</div>
);
}
/* ── JSON 패널 (접기/펼치기) ── */
function JsonPanel({ json, onDownload, visible, onToggle }) {
const [copied, setCopied] = useState(false);
const jsonStr = useMemo(() => JSON.stringify(json, null, 2), [json]);
const handleCopy = () => { navigator.clipboard.writeText(jsonStr).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); };
return (
<div className="flex shrink-0" style={{ width: visible ? 340 : 0, transition: 'width 0.3s ease' }}>
{visible && (
<div className="bg-white border-l border-gray-200 shadow-sm flex flex-col" style={{ width: 340 }}>
<div className="p-3 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-800 flex items-center gap-1"><i className="ri-braces-line text-green-600"></i> IfcProduct JSON</h3>
<div className="flex items-center gap-1">
<button onClick={handleCopy} className={`px-2 py-1 rounded text-xs transition ${copied ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
<i className={copied ? 'ri-check-line' : 'ri-clipboard-line'}></i> {copied ? '복사됨' : '복사'}
</button>
<button onClick={onDownload} className="px-2 py-1 rounded text-xs bg-blue-100 text-blue-700 hover:bg-blue-200 transition"><i className="ri-download-2-line"></i> 저장</button>
<button onClick={onToggle} className="px-1.5 py-1 rounded text-xs text-gray-400 hover:bg-gray-100 transition" title="패널 접기"><i className="ri-arrow-right-double-line"></i></button>
</div>
</div>
<div className="flex-1 overflow-auto p-2">
<pre className="text-xs font-mono text-gray-700 whitespace-pre leading-relaxed bg-gray-50 rounded-lg p-3 border border-gray-100" style={{ fontSize: '11px', tabSize: 2 }}>{jsonStr}</pre>
</div>
<div className="p-3 border-t border-gray-200 bg-gray-50">
<div className="flex items-center gap-2 text-xs text-gray-500"><i className="ri-information-line text-blue-500"></i><span>IFC4 IfcProduct 스키마 호환</span></div>
</div>
</div>
)}
</div>
);
}
/* ── 하단 툴바 ── */
function GeneratorToolbar({ onResetView, onExportJson, objectName, calcInfo, jsonVisible, onToggleJson }) {
return (
<div className="shrink-0 bg-white/95 backdrop-blur border-t border-gray-200 px-3 py-1.5 flex items-center gap-2 text-xs z-10">
<span className="text-gray-500 font-semibold flex items-center gap-1"><i className="ri-cube-line text-blue-500"></i> {objectName || '객체 미선택'}</span>
{calcInfo && (<>
<div className="w-px h-5 bg-gray-300"></div>
<span className="text-gray-400">W1:<b className="text-gray-700 ml-0.5">{calcInfo.W1}</b></span>
<span className="text-gray-400">H1:<b className="text-gray-700 ml-0.5">{calcInfo.H1}</b></span>
<span className="text-gray-400">{calcInfo.area}</span>
<span className="text-gray-400">{calcInfo.weight}kg</span>
</>)}
<div className="w-px h-5 bg-gray-300"></div>
<button onClick={onResetView} className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-100 text-gray-600 transition"><i className="ri-refresh-line"></i> 시점 초기화</button>
<div className="flex-1"></div>
{!jsonVisible && (
<button onClick={onToggleJson} className="flex items-center gap-1 px-2 py-1 rounded bg-gray-100 text-gray-600 hover:bg-gray-200 transition"><i className="ri-braces-line"></i> JSON</button>
)}
<button onClick={onExportJson} className="flex items-center gap-1 px-3 py-1 rounded bg-green-600 text-white hover:bg-green-700 transition font-semibold"><i className="ri-download-2-line"></i> JSON 다운로드</button>
</div>
);
}
/* ════════════════════════════════════════════════
Root Component
════════════════════════════════════════════════ */
function BimGeneratorApp() {
const vpRef = useRef(null);
const sceneRef = useRef(null);
const [selectedObjectId, setSelectedObjectId] = useState('fire-shutter');
const [params, setParams] = useState(() => OBJECT_LIBRARY[0].defaultParams);
const [jsonVisible, setJsonVisible] = useState(true);
const objectDef = useMemo(() => OBJECT_LIBRARY.find(o => o.id === selectedObjectId), [selectedObjectId]);
const ifcJson = useMemo(() => objectDef ? buildIfcProductJSON(objectDef, params) : null, [objectDef, params]);
const calcInfo = useMemo(() => {
if (selectedObjectId !== 'fire-shutter') return null;
const slatInfo = SLAT_TYPES[params.slatType];
if (!slatInfo) return null;
const W1 = params.openWidth + slatInfo.marginW;
const H1 = params.openHeight + 350;
const area = (W1 * H1) / 1000000;
return { W1, H1, area: area.toFixed(2), weight: (area * slatInfo.weightFactor).toFixed(1) };
}, [selectedObjectId, params]);
useEffect(() => { if (!vpRef.current) return; const s = new GeneratorScene(vpRef.current); s.init(); sceneRef.current = s; return () => s.dispose(); }, []);
useEffect(() => { if (sceneRef.current && selectedObjectId) sceneRef.current.buildObject(selectedObjectId, params); }, [selectedObjectId, params]);
// JSON 토글 시 3D 캔버스 리사이즈
useEffect(() => { setTimeout(() => sceneRef.current?.onResize(), 350); }, [jsonVisible]);
const handleSelectObject = useCallback((id) => { const def = OBJECT_LIBRARY.find(o => o.id === id); if (def) { setSelectedObjectId(id); setParams({ ...def.defaultParams }); } }, []);
const handleResetView = useCallback(() => { if (sceneRef.current && objectDef) { const w = selectedObjectId === 'fire-shutter' ? params.openWidth / 1000 : params.width / 1000; const h = selectedObjectId === 'fire-shutter' ? (params.openHeight + params.boxHeight) / 1000 : params.height / 1000; sceneRef.current.fitCamera(w, h); } }, [objectDef, params, selectedObjectId]);
const handleDownloadJson = useCallback(() => { if (!ifcJson) return; downloadFile(`SAM_${objectDef.name}_IfcProduct.json`, JSON.stringify(ifcJson, null, 2), 'application/json'); }, [ifcJson, objectDef]);
return (
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
<PmisSidebar activePage="bim-generator" />
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0 overflow-hidden" style={{ width: 280 }}>
<div className="px-3 py-2.5 border-b border-gray-200 bg-gradient-to-r from-blue-600 to-indigo-600">
<h2 className="text-sm font-bold text-white flex items-center gap-1.5"><i className="ri-tools-line"></i> BIM 생성기</h2>
<p className="text-xs text-blue-200 mt-0.5">파라메트릭 BIM 객체 설계</p>
</div>
<div className="flex-1 overflow-auto">
<ObjectLibrary selectedId={selectedObjectId} onSelect={handleSelectObject} />
<ParamEditor objectDef={objectDef} params={params} onChange={setParams} />
</div>
</div>
<div className="flex-1 flex flex-col relative overflow-hidden">
<div ref={vpRef} className="flex-1" style={{ minHeight: 0 }} />
<GeneratorToolbar onResetView={handleResetView} onExportJson={handleDownloadJson} objectName={objectDef?.name} calcInfo={calcInfo} jsonVisible={jsonVisible} onToggleJson={() => setJsonVisible(v => !v)} />
</div>
<JsonPanel json={ifcJson} onDownload={handleDownloadJson} visible={jsonVisible} onToggle={() => setJsonVisible(false)} />
</div>
);
}
ReactDOM.render(<BimGeneratorApp />, document.getElementById('root'));
@endverbatim
</script>
@endpush