- 방화셔터/방화문/방화댐퍼 파라메트릭 3D 설계 - 실시간 Three.js 프리뷰 + IfcProduct JSON 생성 - IFC STEP 파일 내보내기 - PMIS 사이드바에 BIM 생성기 메뉴 등록
1065 lines
48 KiB
PHP
1065 lines
48 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 OBJECT_LIBRARY = [
|
|
{
|
|
id: 'fire-shutter',
|
|
name: '방화셔터',
|
|
icon: 'ri-fire-line',
|
|
category: 'fire-protection',
|
|
categoryName: '방화설비',
|
|
description: '방화구획 경계에 설치하는 자동 강하 셔터',
|
|
ifcType: 'IfcDoor',
|
|
ifcPredefinedType: 'GATE',
|
|
defaultParams: {
|
|
width: 3000,
|
|
height: 3500,
|
|
depth: 300,
|
|
fireRating: '2시간',
|
|
material: 'STS304',
|
|
shutterType: 'single',
|
|
housingHeight: 350,
|
|
jambWidth: 200,
|
|
slat: 'flat',
|
|
},
|
|
paramDefs: [
|
|
{ key: 'width', label: '폭', unit: 'mm', type: 'number', min: 500, max: 15000, step: 100 },
|
|
{ key: 'height', label: '높이', unit: 'mm', type: 'number', min: 500, max: 8000, step: 100 },
|
|
{ key: 'depth', label: '깊이', unit: 'mm', type: 'number', min: 100, max: 600, step: 50 },
|
|
{ key: 'fireRating', label: '내화등급', type: 'select', options: ['30분', '1시간', '2시간', '3시간'] },
|
|
{ key: 'material', label: '재질', type: 'select', options: ['STS304', 'STS430', 'GI (용융아연도금)', 'AL (알루미늄)'] },
|
|
{ key: 'shutterType', label: '셔터 유형', type: 'select', options: [
|
|
{ value: 'single', label: '단일 강하' },
|
|
{ value: 'double', label: '이중 강하 (양방향)' },
|
|
{ value: 'compact', label: '컴팩트 (소형 하우징)' },
|
|
]},
|
|
{ key: 'housingHeight', label: '하우징 높이', unit: 'mm', type: 'number', min: 200, max: 600, step: 50 },
|
|
{ key: 'jambWidth', label: '가이드레일 폭', unit: 'mm', type: 'number', min: 100, max: 400, step: 50 },
|
|
{ key: 'slat', label: '슬랫 형태', type: 'select', options: [
|
|
{ value: 'flat', label: '평판형' },
|
|
{ value: 'interlocking', label: '인터로킹형' },
|
|
{ value: 'insulated', label: '단열형' },
|
|
]},
|
|
],
|
|
},
|
|
{
|
|
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: '양여닫이' },
|
|
{ value: 'sliding', label: '미닫이' },
|
|
]},
|
|
{ key: 'glazing', label: '유리', type: 'select', options: [
|
|
{ value: 'none', label: '없음' },
|
|
{ value: 'small', label: '소형 확인창' },
|
|
{ value: 'half', label: '반유리' },
|
|
]},
|
|
],
|
|
},
|
|
{
|
|
id: 'fire-damper',
|
|
name: '방화댐퍼',
|
|
icon: 'ri-windy-line',
|
|
category: 'fire-protection',
|
|
categoryName: '방화설비',
|
|
description: '덕트 관통부에 설치하는 방화댐퍼',
|
|
ifcType: 'IfcFlowController',
|
|
ifcPredefinedType: 'USERDEFINED',
|
|
defaultParams: {
|
|
width: 400,
|
|
height: 300,
|
|
depth: 200,
|
|
fireRating: '1시간',
|
|
material: 'GI',
|
|
bladeType: 'multi',
|
|
actuator: 'fusible-link',
|
|
},
|
|
paramDefs: [
|
|
{ key: 'width', label: '폭', unit: 'mm', type: 'number', min: 100, max: 2000, step: 50 },
|
|
{ key: 'height', label: '높이', unit: 'mm', type: 'number', min: 100, max: 2000, step: 50 },
|
|
{ key: 'depth', label: '깊이', unit: 'mm', type: 'number', min: 100, max: 500, step: 50 },
|
|
{ key: 'fireRating', label: '내화등급', type: 'select', options: ['1시간', '1.5시간', '2시간'] },
|
|
{ key: 'material', label: '재질', type: 'select', options: ['GI (용융아연도금)', 'STS304'] },
|
|
{ key: 'bladeType', label: '블레이드 유형', type: 'select', options: [
|
|
{ value: 'multi', label: '다날개형' },
|
|
{ value: 'single', label: '단일 날개형' },
|
|
{ value: 'curtain', label: '커튼형' },
|
|
]},
|
|
{ key: 'actuator', label: '작동 방식', type: 'select', options: [
|
|
{ value: 'fusible-link', label: '퓨즈 링크 (72°C)' },
|
|
{ value: 'motorized', label: '전동식' },
|
|
{ value: 'spring-return', label: '스프링 리턴' },
|
|
]},
|
|
],
|
|
},
|
|
];
|
|
|
|
const COLORS = {
|
|
housing: 0x78909C,
|
|
jamb: 0x607D8B,
|
|
panel: 0xE53935,
|
|
panelTransparent: 0xEF5350,
|
|
frame: 0x455A64,
|
|
door: 0x8D6E63,
|
|
doorFrame: 0x5D4037,
|
|
glazing: 0x80DEEA,
|
|
damperBody: 0x90A4AE,
|
|
damperBlade: 0xB0BEC5,
|
|
dimension: 0x1565C0,
|
|
};
|
|
|
|
/* ════════════════════════════════════════════════
|
|
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 w = params.width / 1000;
|
|
const h = params.height / 1000;
|
|
const d = params.depth / 1000;
|
|
|
|
const psetProps = {};
|
|
for (const [key, val] of Object.entries(params)) {
|
|
if (['width', 'height', 'depth'].includes(key)) 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;
|
|
}
|
|
|
|
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.0, 0.0] },
|
|
Axis: { type: 'IfcDirection', DirectionRatios: [0.0, 0.0, 1.0] },
|
|
RefDirection: { type: 'IfcDirection', DirectionRatios: [1.0, 0.0, 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, 0.0, 1.0] },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
OverallWidth: w,
|
|
OverallHeight: h,
|
|
IsDefinedBy: [
|
|
{
|
|
type: 'IfcRelDefinesByProperties',
|
|
RelatingPropertyDefinition: {
|
|
type: 'IfcPropertySet',
|
|
Name: 'Pset_' + objectDef.id.replace(/-/g, '') + 'Common',
|
|
HasProperties: Object.entries(psetProps).map(([name, val]) => ({
|
|
type: typeof val === 'number' ? 'IfcPropertySingleValue' : 'IfcPropertySingleValue',
|
|
Name: name,
|
|
NominalValue: {
|
|
type: typeof val === 'number' ? 'IfcReal' : 'IfcLabel',
|
|
value: val,
|
|
},
|
|
})),
|
|
},
|
|
},
|
|
{
|
|
type: 'IfcRelDefinesByProperties',
|
|
RelatingPropertyDefinition: {
|
|
type: 'IfcPropertySet',
|
|
Name: 'Pset_Dimensions',
|
|
HasProperties: [
|
|
{ type: 'IfcPropertySingleValue', Name: 'Width', NominalValue: { type: 'IfcLengthMeasure', value: w } },
|
|
{ type: 'IfcPropertySingleValue', Name: 'Height', NominalValue: { type: 'IfcLengthMeasure', value: h } },
|
|
{ type: 'IfcPropertySingleValue', Name: 'Depth', NominalValue: { type: 'IfcLengthMeasure', value: d } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
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.dimensionGroup = 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.015);
|
|
|
|
this.camera = new THREE.PerspectiveCamera(45, w / h, 0.01, 100);
|
|
this.camera.position.set(5, 3, 5);
|
|
|
|
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();
|
|
|
|
// Lights
|
|
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; sc.near = 1; sc.far = 30;
|
|
this.scene.add(sun);
|
|
this.scene.add(new THREE.AmbientLight(0xffffff, 0.25));
|
|
|
|
// Grid + Axes
|
|
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);
|
|
|
|
// Ground
|
|
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.dimensionGroup = new THREE.Group();
|
|
this.scene.add(this.dimensionGroup);
|
|
|
|
window.addEventListener('resize', this._onResize);
|
|
this.animate();
|
|
}
|
|
|
|
mat(color, transparent) {
|
|
return new THREE.MeshPhongMaterial({
|
|
color,
|
|
transparent: !!transparent,
|
|
opacity: transparent ? 0.4 : 1,
|
|
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
|
|
});
|
|
}
|
|
|
|
addBox(gx, gy, gz, pos, color, transparent) {
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(gx, gy, gz), this.mat(color, transparent));
|
|
mesh.position.set(pos[0], pos[1], pos[2]);
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
this.objectGroup.add(mesh);
|
|
return mesh;
|
|
}
|
|
|
|
addDimensionLine(start, end, label, offset) {
|
|
const dir = new THREE.Vector3().subVectors(end, start);
|
|
const len = dir.length();
|
|
const mid = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5);
|
|
if (offset) mid.add(offset);
|
|
|
|
// Line
|
|
const points = [start.clone(), end.clone()];
|
|
if (offset) { points[0].add(offset); points[1].add(offset); }
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const lineMat = new THREE.LineBasicMaterial({ color: COLORS.dimension, linewidth: 2 });
|
|
this.dimensionGroup.add(new THREE.Line(lineGeo, lineMat));
|
|
|
|
// Extension lines
|
|
if (offset) {
|
|
const ext1 = new THREE.BufferGeometry().setFromPoints([start.clone(), points[0].clone()]);
|
|
const ext2 = new THREE.BufferGeometry().setFromPoints([end.clone(), points[1].clone()]);
|
|
const extMat = new THREE.LineBasicMaterial({ color: COLORS.dimension, linewidth: 1, transparent: true, opacity: 0.4 });
|
|
this.dimensionGroup.add(new THREE.Line(ext1, extMat));
|
|
this.dimensionGroup.add(new THREE.Line(ext2, extMat));
|
|
}
|
|
|
|
// Text label (sprite)
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 256;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#1565C0';
|
|
ctx.font = 'bold 32px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(label, 128, 32);
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
|
|
const sprite = new THREE.Sprite(spriteMat);
|
|
sprite.position.copy(mid);
|
|
if (offset) sprite.position.add(new THREE.Vector3(0, 0.15, 0));
|
|
sprite.scale.set(len * 0.5, len * 0.125, 1);
|
|
sprite.scale.clampScalar(0.3, 2);
|
|
this.dimensionGroup.add(sprite);
|
|
}
|
|
|
|
clearObject() {
|
|
while (this.objectGroup.children.length > 0) {
|
|
const child = this.objectGroup.children[0];
|
|
if (child.geometry) child.geometry.dispose();
|
|
if (child.material) child.material.dispose();
|
|
this.objectGroup.remove(child);
|
|
}
|
|
while (this.dimensionGroup.children.length > 0) {
|
|
const child = this.dimensionGroup.children[0];
|
|
if (child.geometry) child.geometry.dispose();
|
|
if (child.material) {
|
|
if (child.material.map) child.material.map.dispose();
|
|
child.material.dispose();
|
|
}
|
|
this.dimensionGroup.remove(child);
|
|
}
|
|
}
|
|
|
|
buildFireShutter(params) {
|
|
const W = params.width / 1000;
|
|
const H = params.height / 1000;
|
|
const D = params.depth / 1000;
|
|
const HH = params.housingHeight / 1000;
|
|
const JW = params.jambWidth / 1000;
|
|
const panelH = H - HH;
|
|
const isDouble = params.shutterType === 'double';
|
|
|
|
// Housing (top box)
|
|
this.addBox(W + JW * 2 + 0.05, HH, D + 0.05, [0, H - HH / 2, 0], COLORS.housing, false);
|
|
|
|
// Left Jamb
|
|
this.addBox(JW, H, D, [-(W / 2 + JW / 2), H / 2, 0], COLORS.jamb, false);
|
|
|
|
// Right Jamb
|
|
this.addBox(JW, H, D, [(W / 2 + JW / 2), H / 2, 0], COLORS.jamb, false);
|
|
|
|
// Shutter Panel (closed state, semi-transparent)
|
|
if (isDouble) {
|
|
this.addBox(W * 0.48, panelH, 0.02, [-W * 0.01, panelH / 2, D * 0.15], COLORS.panelTransparent, true);
|
|
this.addBox(W * 0.48, panelH, 0.02, [W * 0.01, panelH / 2, -D * 0.15], COLORS.panelTransparent, true);
|
|
} else {
|
|
this.addBox(W, panelH, 0.02, [0, panelH / 2, 0], COLORS.panelTransparent, true);
|
|
}
|
|
|
|
// Slat lines (visual detail)
|
|
const slatCount = Math.floor(panelH / 0.1);
|
|
for (let i = 1; i < Math.min(slatCount, 30); i++) {
|
|
const y = i * (panelH / slatCount);
|
|
this.addBox(W - 0.02, 0.005, 0.025, [0, y, 0], COLORS.panel, false);
|
|
}
|
|
|
|
// Dimension lines
|
|
const totalW = W + JW * 2;
|
|
this.addDimensionLine(
|
|
new THREE.Vector3(-totalW / 2, 0, 0),
|
|
new THREE.Vector3(totalW / 2, 0, 0),
|
|
`${params.width + params.jambWidth * 2}mm`,
|
|
new THREE.Vector3(0, -0.3, D / 2 + 0.3)
|
|
);
|
|
this.addDimensionLine(
|
|
new THREE.Vector3(totalW / 2 + 0.1, 0, 0),
|
|
new THREE.Vector3(totalW / 2 + 0.1, H, 0),
|
|
`${params.height}mm`,
|
|
new THREE.Vector3(0.5, 0, 0)
|
|
);
|
|
|
|
// Fit camera
|
|
this.fitCamera(totalW, H);
|
|
}
|
|
|
|
buildFireDoor(params) {
|
|
const W = params.width / 1000;
|
|
const H = params.height / 1000;
|
|
const D = params.depth / 1000;
|
|
const frameW = 0.06;
|
|
const isDouble = params.doorType === 'double-swing';
|
|
|
|
// Frame
|
|
this.addBox(frameW, H, D + 0.02, [-(W / 2 + frameW / 2), H / 2, 0], COLORS.doorFrame, false);
|
|
this.addBox(frameW, H, D + 0.02, [(W / 2 + frameW / 2), H / 2, 0], COLORS.doorFrame, false);
|
|
this.addBox(W + frameW * 2, frameW, D + 0.02, [0, H + frameW / 2, 0], COLORS.doorFrame, false);
|
|
|
|
// Door panel(s)
|
|
if (isDouble) {
|
|
this.addBox(W / 2 - 0.01, H - 0.02, D, [-W / 4, H / 2, 0], COLORS.door, false);
|
|
this.addBox(W / 2 - 0.01, H - 0.02, D, [W / 4, H / 2, 0], COLORS.door, false);
|
|
} else {
|
|
this.addBox(W - 0.02, H - 0.02, D, [0, H / 2, 0], COLORS.door, false);
|
|
}
|
|
|
|
// Glazing
|
|
if (params.glazing === 'small') {
|
|
const gw = Math.min(W * 0.3, 0.3), gh = 0.3;
|
|
if (isDouble) {
|
|
this.addBox(gw, gh, D + 0.01, [-W / 4, H * 0.65, 0], COLORS.glazing, true);
|
|
this.addBox(gw, gh, D + 0.01, [W / 4, H * 0.65, 0], COLORS.glazing, true);
|
|
} else {
|
|
this.addBox(gw, gh, D + 0.01, [0, H * 0.65, 0], COLORS.glazing, true);
|
|
}
|
|
} 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, true);
|
|
this.addBox(gw / 2 - 0.03, gh, D + 0.01, [W / 4, H * 0.55, 0], COLORS.glazing, true);
|
|
} else {
|
|
this.addBox(gw, gh, D + 0.01, [0, H * 0.55, 0], COLORS.glazing, true);
|
|
}
|
|
}
|
|
|
|
// Handle
|
|
const handleH = 0.15;
|
|
if (isDouble) {
|
|
this.addBox(0.02, handleH, 0.03, [-0.04, H * 0.47, D / 2 + 0.02], 0x333333, false);
|
|
this.addBox(0.02, handleH, 0.03, [0.04, H * 0.47, D / 2 + 0.02], 0x333333, false);
|
|
} else {
|
|
this.addBox(0.02, handleH, 0.03, [W * 0.3, H * 0.47, D / 2 + 0.02], 0x333333, false);
|
|
}
|
|
|
|
const totalW = W + frameW * 2;
|
|
this.addDimensionLine(
|
|
new THREE.Vector3(-totalW / 2, 0, 0),
|
|
new THREE.Vector3(totalW / 2, 0, 0),
|
|
`${params.width}mm`,
|
|
new THREE.Vector3(0, -0.2, D / 2 + 0.2)
|
|
);
|
|
this.addDimensionLine(
|
|
new THREE.Vector3(totalW / 2 + 0.05, 0, 0),
|
|
new THREE.Vector3(totalW / 2 + 0.05, H, 0),
|
|
`${params.height}mm`,
|
|
new THREE.Vector3(0.3, 0, 0)
|
|
);
|
|
|
|
this.fitCamera(totalW, H);
|
|
}
|
|
|
|
buildFireDamper(params) {
|
|
const W = params.width / 1000;
|
|
const H = params.height / 1000;
|
|
const D = params.depth / 1000;
|
|
const flangeT = 0.02;
|
|
|
|
// Body
|
|
this.addBox(W, H, D, [0, H / 2, 0], COLORS.damperBody, false);
|
|
|
|
// Flanges (front and back)
|
|
this.addBox(W + 0.06, H + 0.06, flangeT, [0, H / 2, D / 2 + flangeT / 2], COLORS.frame, false);
|
|
this.addBox(W + 0.06, H + 0.06, flangeT, [0, H / 2, -D / 2 - flangeT / 2], COLORS.frame, false);
|
|
|
|
// Blades
|
|
const bladeCount = params.bladeType === 'single' ? 1 : Math.max(2, Math.floor(H / 0.08));
|
|
for (let i = 0; i < bladeCount; i++) {
|
|
const y = (i + 0.5) * (H / bladeCount);
|
|
this.addBox(W - 0.02, 0.003, D * 0.85, [0, y, 0], COLORS.damperBlade, false);
|
|
}
|
|
|
|
// Actuator box
|
|
this.addBox(0.08, 0.08, 0.06, [W / 2 + 0.04, H / 2, 0], 0xFFC107, false);
|
|
|
|
this.addDimensionLine(
|
|
new THREE.Vector3(-W / 2 - 0.03, 0, 0),
|
|
new THREE.Vector3(W / 2 + 0.03, 0, 0),
|
|
`${params.width}mm`,
|
|
new THREE.Vector3(0, -0.15, D / 2 + 0.15)
|
|
);
|
|
this.addDimensionLine(
|
|
new THREE.Vector3(W / 2 + 0.06, 0, 0),
|
|
new THREE.Vector3(W / 2 + 0.06, H, 0),
|
|
`${params.height}mm`,
|
|
new THREE.Vector3(0.2, 0, 0)
|
|
);
|
|
|
|
this.fitCamera(W + 0.1, H);
|
|
}
|
|
|
|
buildObject(objectId, params) {
|
|
this.clearObject();
|
|
switch (objectId) {
|
|
case 'fire-shutter': this.buildFireShutter(params); break;
|
|
case 'fire-door': this.buildFireDoor(params); break;
|
|
case 'fire-damper': this.buildFireDamper(params); break;
|
|
}
|
|
}
|
|
|
|
fitCamera(w, h) {
|
|
const maxDim = Math.max(w, h);
|
|
const dist = maxDim * 1.8 + 2;
|
|
this.camera.position.set(dist * 0.7, h * 0.6 + 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 === 0 || h === 0) 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);
|
|
}
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
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 });
|
|
};
|
|
|
|
return (
|
|
<div className="p-3 flex-1 overflow-auto">
|
|
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-1">
|
|
<i className="ri-settings-3-line"></i> 속성 설정
|
|
</h3>
|
|
<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, onCopy, onDownload }) {
|
|
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="bg-white border-l border-gray-200 shadow-sm flex flex-col shrink-0" 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>
|
|
</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 스키마 호환 JSON 구조</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── 하단 툴바 ── */
|
|
function GeneratorToolbar({ onResetView, onExportIFC, objectName }) {
|
|
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>
|
|
<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>
|
|
<button onClick={onExportIFC}
|
|
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> IFC STEP 다운로드
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
IFC STEP 파일 생성기
|
|
════════════════════════════════════════════════ */
|
|
function generateIFCStep(objectDef, params) {
|
|
const w = params.width / 1000;
|
|
const h = params.height / 1000;
|
|
const d = params.depth / 1000;
|
|
|
|
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);
|
|
|
|
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 Generator','SAM-GEN')`);
|
|
const oh = W(N(), `IFCOWNERHISTORY(${po},${app},$,.NOCHANGE.,$,${po},${app},${Date.now()/1000|0})`);
|
|
|
|
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}))`);
|
|
|
|
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})`);
|
|
|
|
const ctx = W(N(), `IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-05,${ax0},$)`);
|
|
const bctx = W(N(), `IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,${ctx},$,.MODEL_VIEW.,$)`);
|
|
|
|
const proj = W(N(), `IFCPROJECT(${G()},${oh},'SAM Generated',$,$,'SAM BIM Generator',$,(${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},'Generated Building',$,$,${bPl},$,$,.ELEMENT.,$,$,$)`);
|
|
|
|
const stPt = W(N(), `IFCCARTESIANPOINT((0.,0.,0.))`);
|
|
const stAx = W(N(), `IFCAXIS2PLACEMENT3D(${stPt},${dZ},${dX})`);
|
|
const stPl = W(N(), `IFCLOCALPLACEMENT(${bPl},${stAx})`);
|
|
const storey = W(N(), `IFCBUILDINGSTOREY(${G()},${oh},'1F',$,$,${stPl},$,$,.ELEMENT.,0.)`);
|
|
|
|
W(N(), `IFCRELAGGREGATES(${G()},${oh},$,$,${proj},(${site}))`);
|
|
W(N(), `IFCRELAGGREGATES(${G()},${oh},$,$,${site},(${bldg}))`);
|
|
W(N(), `IFCRELAGGREGATES(${G()},${oh},$,$,${bldg},(${storey}))`);
|
|
|
|
const elPl = W(N(), `IFCLOCALPLACEMENT(${stPl},${ax0})`);
|
|
const pf = W(N(), `IFCRECTANGLEPROFILEDEF(.AREA.,$,$,${F(w)},${F(d)})`);
|
|
const sol = W(N(), `IFCEXTRUDEDAREASOLID(${pf},$,${dZ},${F(h)})`);
|
|
const sr = W(N(), `IFCSHAPEREPRESENTATION(${bctx},'Body','SweptSolid',(${sol}))`);
|
|
const ps = W(N(), `IFCPRODUCTDEFINITIONSHAPE($,$,(${sr}))`);
|
|
|
|
const nm = objectDef.name.replace(/'/g, '');
|
|
let elem;
|
|
if (objectDef.ifcType === 'IfcDoor') {
|
|
elem = W(N(), `IFCDOOR(${G()},${oh},'${nm}','${nm}',$,${elPl},${ps},$,${F(h)},${F(w)})`);
|
|
} else {
|
|
elem = W(N(), `IFCBUILDINGELEMENTPROXY(${G()},${oh},'${nm}','${nm}',$,${elPl},${ps},$,.USERDEFINED.)`);
|
|
}
|
|
|
|
// Property Set
|
|
const props = [];
|
|
for (const [key, val] of Object.entries(params)) {
|
|
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;
|
|
}
|
|
if (typeof displayVal === 'number') {
|
|
props.push(W(N(), `IFCPROPERTYSINGLEVALUE('${def.label}',$,IFCREAL(${F(displayVal)}),$)`));
|
|
} else {
|
|
props.push(W(N(), `IFCPROPERTYSINGLEVALUE('${def.label}',$,IFCLABEL('${String(displayVal).replace(/'/g,'')}'),$)`));
|
|
}
|
|
}
|
|
if (props.length > 0) {
|
|
const pset = W(N(), `IFCPROPERTYSET(${G()},${oh},'Pset_${nm.replace(/\s/g,'')}Common',$,(${props.join(',')}))`);
|
|
W(N(), `IFCRELDEFINESBYPROPERTIES(${G()},${oh},$,$,(${elem}),${pset})`);
|
|
}
|
|
|
|
W(N(), `IFCRELCONTAINEDINSPATIALSTRUCTURE(${G()},${oh},$,$,(${elem}),${storey})`);
|
|
|
|
const hdr = [
|
|
"ISO-10303-21;", "HEADER;",
|
|
"FILE_DESCRIPTION(('ViewDefinition [CoordinationView_V2.0]'),'2;1');",
|
|
`FILE_NAME('SAM_Generated_${nm}.ifc','${new Date().toISOString().slice(0,19)}',('SAM BIM Generator'),('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);
|
|
}
|
|
|
|
/* ════════════════════════════════════════════════
|
|
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 objectDef = useMemo(() => OBJECT_LIBRARY.find(o => o.id === selectedObjectId), [selectedObjectId]);
|
|
const ifcJson = useMemo(() => objectDef ? buildIfcProductJSON(objectDef, params) : null, [objectDef, params]);
|
|
|
|
// Init Three.js
|
|
useEffect(() => {
|
|
if (!vpRef.current) return;
|
|
const scene = new GeneratorScene(vpRef.current);
|
|
scene.init();
|
|
sceneRef.current = scene;
|
|
return () => scene.dispose();
|
|
}, []);
|
|
|
|
// Update 3D preview when params change
|
|
useEffect(() => {
|
|
if (sceneRef.current && selectedObjectId) {
|
|
sceneRef.current.buildObject(selectedObjectId, params);
|
|
}
|
|
}, [selectedObjectId, params]);
|
|
|
|
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 = params.width / 1000;
|
|
const h = params.height / 1000;
|
|
sceneRef.current.fitCamera(w, h);
|
|
}
|
|
}, [objectDef, params]);
|
|
|
|
const handleExportIFC = useCallback(() => {
|
|
if (!objectDef) return;
|
|
const step = generateIFCStep(objectDef, params);
|
|
downloadFile(`SAM_${objectDef.name}.ifc`, step, 'application/x-step');
|
|
}, [objectDef, params]);
|
|
|
|
const handleDownloadJson = useCallback(() => {
|
|
if (!ifcJson) return;
|
|
const str = JSON.stringify(ifcJson, null, 2);
|
|
downloadFile(`SAM_${objectDef.name}_IfcProduct.json`, str, 'application/json');
|
|
}, [ifcJson, objectDef]);
|
|
|
|
return (
|
|
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
|
|
<PmisSidebar activePage="bim-generator" />
|
|
|
|
{/* Left Panel */}
|
|
<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>
|
|
|
|
{/* Center: 3D Canvas */}
|
|
<div className="flex-1 flex flex-col relative overflow-hidden">
|
|
<div ref={vpRef} className="flex-1" style={{ minHeight: 0 }} />
|
|
<GeneratorToolbar
|
|
onResetView={handleResetView}
|
|
onExportIFC={handleExportIFC}
|
|
objectName={objectDef?.name}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right Panel: JSON */}
|
|
<JsonPanel json={ifcJson} onDownload={handleDownloadJson} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.render(<BimGeneratorApp />, document.getElementById('root'));
|
|
@endverbatim
|
|
</script>
|
|
@endpush
|