Files
sam-manage/resources/views/rd/fire-shutter-bim-viewer/index.blade.php
김보곤 9a43a9187f fix: [fire-shutter] L바 180° 회전 + 하장바 날개 연장
- L바 수평부를 아래, 수직부를 위로 (180° 회전)
- 하장바 날개(lipW) 10→22mm 확장 (간격 ~16mm, 고리 구조)
- 날개가 L바 수평부 위를 덮어 빠짐 방지하는 실제 구조 반영
2026-03-14 18:11:49 +09:00

1103 lines
49 KiB
PHP

@extends('layouts.app')
@section('title', '방화셔터 BIM 뷰어')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
@include('partials.react-cdn')
<script type="text/babel">
@verbatim
const { useState, useEffect, useRef, useCallback } = React;
/* ════════════════════════════════════════════════
상수 & 기본값
════════════════════════════════════════════════ */
const PRODUCT_DEFAULTS = {
steel: {
label: '철재슬라트', marginW: 110, marginH: 350, weightFactor: 25,
guideRail: { width: 120, depth: 70, thickness: 2.3, lip: 15, sealThickness: 5, sealDepth: 40 },
slat: { pitch: 80, thickness: 1.6, profile: 'C' },
color: 0x9ca3af,
},
screen: {
label: '스크린형', marginW: 140, marginH: 350, weightFactor: 2,
guideRail: { width: 67, depth: 80, thickness: 1.55, lip: 10, sealThickness: 0.8, sealDepth: 40 },
slat: { pitch: 100, thickness: 0.8, profile: 'flat' },
color: 0xc084fc,
},
};
const MOTOR_TABLE = [
{ max: 150, spec: '150K' }, { max: 300, spec: '300K' }, { max: 500, spec: '500K' },
{ max: 750, spec: '750K' }, { max: 1000, spec: '1000K' }, { max: 1500, spec: '1500K' },
];
const PART_COLORS = {
case: 0x374151, shaft: 0x64748b, motor: 0x3b82f6, brake: 0xef4444,
spring: 0x22c55e, rails: 0x94a3b8, slats: 0x9ca3af, bottomBar: 0xf59e0b,
slatRoll: 0xC9B89A, seal: 0xf97316, wall: 0xa1887f,
};
const PART_LABELS = {
case: '셔터박스', shaft: '샤프트', motor: '모터', brake: '브레이크',
spring: '스프링', rails: '가이드레일', slats: '슬랫 커튼', bottomBar: '하장바',
slatRoll: '감긴 슬랫', wall: '벽체',
};
/* ════════════════════════════════════════════════
FireShutterScene — Three.js 3D 엔진
════════════════════════════════════════════════ */
class FireShutterScene {
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.onSelect = null;
this._animId = null;
this._onClick = this.onClick.bind(this);
this._onResize = this.onResize.bind(this);
this.targetPos = null; this.targetLook = null;
this.selectedOutline = null;
// 파라미터
this.params = {
productType: 'screen',
openWidth: 2000, openHeight: 3000,
shutterPos: 100, caseOpacity: 0.3,
show: { case: true, shaft: true, motor: true, brake: true, spring: true, rails: true, slats: true, bottomBar: true, slatRoll: true, wall: false },
bgColor: '#1a1a2e',
guideRail: { ...PRODUCT_DEFAULTS.screen.guideRail },
shutterBox: { width: 2140, height: 380, depth: 500, thickness: 1.6, shaftDia: 80, bracketW: 10, motorSide: 'right' },
};
}
init() {
const w = this.el.clientWidth, h = this.el.clientHeight;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(this.params.bgColor);
this.camera = new THREE.PerspectiveCamera(45, w / h, 1, 50000);
this.camera.position.set(2000, 1500, 3000);
this.renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
this.renderer.setSize(w, h);
this.renderer.setPixelRatio(window.devicePixelRatio);
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.enableDamping = true;
this.controls.dampingFactor = 0.1;
this.controls.target.set(0, 1500, 0);
// 조명
this.scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const dir1 = new THREE.DirectionalLight(0xffffff, 0.8);
dir1.position.set(2000, 3000, 2000); dir1.castShadow = true;
dir1.shadow.mapSize.set(2048, 2048);
const sc = dir1.shadow.camera; sc.left = -3000; sc.right = 3000; sc.top = 3000; sc.bottom = -1000;
this.scene.add(dir1);
this.scene.add(new THREE.DirectionalLight(0xffffff, 0.3).position.set(-2000, 1000, -1000) && new THREE.DirectionalLight(0xffffff, 0.3));
this.scene.add(new THREE.HemisphereLight(0x87ceeb, 0x8b4513, 0.3));
// 그리드
const grid = new THREE.GridHelper(6000, 30, 0x444444, 0x333333);
grid.position.y = -1;
this.scene.add(grid);
this.el.addEventListener('click', this._onClick);
window.addEventListener('resize', this._onResize);
this.build();
this.animate();
}
/* ── 전체 모델 빌드 ── */
build() {
// 기존 메시 제거
Object.values(this.meshes).forEach(m => { if (m) { this.scene.remove(m); this._dispose(m); } });
this.meshes = {};
const p = this.params;
const type = p.productType;
const def = PRODUCT_DEFAULTS[type];
const W1 = p.openWidth + def.marginW;
const H = p.openHeight;
const b = p.shutterBox;
b.width = W1;
const hw = W1 / 2; // half width
const shutterH = H * (p.shutterPos / 100);
// ── 1. 셔터박스 (CASE) ──
this.meshes.case = this._buildCase(b, W1, H);
this.scene.add(this.meshes.case);
// ── 2. 샤프트 ──
this.meshes.shaft = this._buildShaft(b, W1, H);
this.scene.add(this.meshes.shaft);
// ── 3. 모터 ──
this.meshes.motor = this._buildMotor(b, W1, H);
this.scene.add(this.meshes.motor);
// ── 4. 브레이크 ──
this.meshes.brake = this._buildBrake(b, W1, H);
this.scene.add(this.meshes.brake);
// ── 5. 스프링 ──
this.meshes.spring = this._buildSpring(b, W1, H);
this.scene.add(this.meshes.spring);
// ── 6. 가이드레일 ──
this.meshes.rails = this._buildRails(p.guideRail, W1, H, type);
this.scene.add(this.meshes.rails);
// ── 7. 슬랫 커튼 ──
if (shutterH > 0) {
this.meshes.slats = this._buildSlats(W1, H, shutterH, type, def);
this.scene.add(this.meshes.slats);
}
// ── 8. 감긴 슬랫 롤 ──
const rolledH = H - shutterH;
if (rolledH > 0) {
this.meshes.slatRoll = this._buildSlatRoll(b, W1, H, rolledH, type);
this.scene.add(this.meshes.slatRoll);
}
// ── 9. 하장바 ──
if (shutterH > 0) {
this.meshes.bottomBar = this._buildBottomBar(W1, H, shutterH);
this.scene.add(this.meshes.bottomBar);
}
// ── 10. 벽체 ──
this.meshes.wall = this._buildWall(W1, H, b);
this.scene.add(this.meshes.wall);
// 가시성 적용
Object.entries(this.meshes).forEach(([k, m]) => {
if (m) m.visible = p.show[k] !== undefined ? p.show[k] : true;
});
}
/* ── 셔터박스 ── */
_buildCase(b, W1, H) {
const grp = new THREE.Group();
const pt = b.thickness;
const mat = new THREE.MeshStandardMaterial({ color: PART_COLORS.case, transparent: true, opacity: this.params.caseOpacity, side: THREE.DoubleSide, metalness: 0.3, roughness: 0.6 });
const edgeMat = new THREE.LineBasicMaterial({ color: 0x1f2937, transparent: true, opacity: 0.5 });
const frontH = b.height * 0.6;
const addPlate = (w, h, d, x, y, z) => {
const geo = new THREE.BoxGeometry(w, h, d);
const m = new THREE.Mesh(geo, mat);
m.position.set(x, y, z);
m.userData = { part: 'case', label: '셔터박스' };
grp.add(m);
grp.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo), edgeMat).translateX(x).translateY(y).translateZ(z));
};
// 상판
addPlate(W1, pt, b.depth, 0, b.height, 0);
// 후면판
addPlate(W1, b.height, pt, 0, b.height / 2, -b.depth / 2 + pt / 2);
// 전면판 (짧음)
addPlate(W1, frontH, pt, 0, b.height - frontH / 2, b.depth / 2 - pt / 2);
// 좌측판
addPlate(pt, b.height, b.depth, -W1 / 2 + pt / 2, b.height / 2, 0);
// 우측판
addPlate(pt, b.height, b.depth, W1 / 2 - pt / 2, b.height / 2, 0);
grp.position.set(0, H, 0);
return grp;
}
/* ── 샤프트 ── */
_buildShaft(b, W1, H) {
const grp = new THREE.Group();
const shaftR = b.shaftDia / 2;
const shaftLen = W1 - 60;
const mat = new THREE.MeshStandardMaterial({ color: PART_COLORS.shaft, metalness: 0.5, roughness: 0.4 });
const geo = new THREE.CylinderGeometry(shaftR, shaftR, shaftLen, 32);
geo.rotateZ(Math.PI / 2);
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { part: 'shaft', label: '샤프트', diameter: b.shaftDia + 'mm', length: shaftLen + 'mm' };
grp.add(mesh);
// 양쪽 스텁핀
const pinR = 15, pinLen = 50;
const pinGeo = new THREE.CylinderGeometry(pinR, pinR, pinLen, 16);
pinGeo.rotateZ(Math.PI / 2);
const pinMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.6, roughness: 0.3 });
const pinL = new THREE.Mesh(pinGeo, pinMat);
pinL.position.set(-shaftLen / 2 - pinLen / 2, 0, 0);
grp.add(pinL);
const pinR2 = new THREE.Mesh(pinGeo.clone(), pinMat);
pinR2.position.set(shaftLen / 2 + pinLen / 2, 0, 0);
grp.add(pinR2);
const shaftY = H + b.height / 2;
grp.position.set(0, shaftY, 0);
return grp;
}
/* ── 모터 ── */
_buildMotor(b, W1, H) {
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: PART_COLORS.motor, metalness: 0.4, roughness: 0.5 });
// 모터 본체 (원통)
const motorR = 60, motorLen = 200;
const geo = new THREE.CylinderGeometry(motorR, motorR, motorLen, 24);
geo.rotateZ(Math.PI / 2);
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { part: 'motor', label: '모터/감속기' };
grp.add(mesh);
// 감속기 (작은 박스)
const gearGeo = new THREE.BoxGeometry(100, 80, 80);
const gear = new THREE.Mesh(gearGeo, mat.clone());
gear.material.color.setHex(0x2563eb);
gear.position.set(b.motorSide === 'right' ? 60 : -60, -30, 0);
grp.add(gear);
const side = b.motorSide === 'right' ? 1 : -1;
const shaftY = H + b.height / 2;
grp.position.set(side * (W1 / 2 - 150), shaftY, 0);
return grp;
}
/* ── 브레이크 ── */
_buildBrake(b, W1, H) {
const mat = new THREE.MeshStandardMaterial({ color: PART_COLORS.brake, metalness: 0.3, roughness: 0.5 });
const geo = new THREE.CylinderGeometry(45, 45, 40, 24);
geo.rotateZ(Math.PI / 2);
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { part: 'brake', label: '브레이크' };
const side = b.motorSide === 'right' ? 1 : -1;
mesh.position.set(side * (W1 / 2 - 350), H + b.height / 2, 0);
return mesh;
}
/* ── 밸런스 스프링 ── */
_buildSpring(b, W1, H) {
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: PART_COLORS.spring, metalness: 0.2, roughness: 0.6 });
// 나선형 스프링 (토러스로 단순화)
const springR = 35, tubeR = 8;
const geo = new THREE.TorusGeometry(springR, tubeR, 8, 32);
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.y = Math.PI / 2;
mesh.userData = { part: 'spring', label: '밸런스 스프링' };
grp.add(mesh);
const side = b.motorSide === 'right' ? -1 : 1;
grp.position.set(side * (W1 / 2 - 150), H + b.height / 2, 0);
return grp;
}
/* ── 가이드레일 (C채널 ExtrudeGeometry) ── */
_buildRails(gr, W1, H, type) {
const grp = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: PART_COLORS.rails, metalness: 0.4, roughness: 0.5, side: THREE.DoubleSide });
const sealMat = new THREE.MeshStandardMaterial({ color: PART_COLORS.seal, metalness: 0, roughness: 0.8 });
const createRail = () => {
const railGrp = new THREE.Group();
const rw = gr.width, rd = gr.depth, rt = gr.thickness, lp = gr.lip;
// C채널 프로파일 (ExtrudeGeometry)
const shape = new THREE.Shape();
// 외곽 (시계방향)
shape.moveTo(0, 0);
shape.lineTo(0, rd);
shape.lineTo(rw, rd);
shape.lineTo(rw, 0);
shape.lineTo(rw - lp, 0);
shape.lineTo(rw - lp, rt);
shape.lineTo(rt, rt);
shape.lineTo(rt, 0);
shape.lineTo(0, 0);
// 내부 채널 (hole - 반시계방향)
const hole = new THREE.Path();
const innerW = rw - rt * 2 - lp;
if (innerW > 0 && rd - rt * 2 > 0) {
hole.moveTo(rt + lp, rt);
hole.lineTo(rw - rt, rt);
hole.lineTo(rw - rt, rd - rt);
hole.lineTo(rt, rd - rt);
hole.lineTo(rt, rt);
hole.lineTo(rt + lp, rt);
shape.holes.push(hole);
}
const extrudeSettings = { depth: H + 200, bevelEnabled: false };
const geo = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const mesh = new THREE.Mesh(geo, mat);
mesh.userData = { part: 'rails', label: '가이드레일', width: rw + 'mm', depth: rd + 'mm', thickness: rt + 'mm' };
railGrp.add(mesh);
// 연기차단재
if (gr.sealThickness > 0) {
const sealGeo = new THREE.BoxGeometry(gr.sealThickness, gr.sealDepth, H + 100);
const seal1 = new THREE.Mesh(sealGeo, sealMat);
seal1.position.set(rw / 2, rd / 2 + 5, (H + 100) / 2);
railGrp.add(seal1);
const seal2 = new THREE.Mesh(sealGeo.clone(), sealMat);
seal2.position.set(rw / 2, rd / 2 - 5, (H + 100) / 2);
railGrp.add(seal2);
}
// XY평면 → 수직 회전
railGrp.rotation.x = -Math.PI / 2;
return railGrp;
};
const hw = W1 / 2;
// 좌측 레일
const railL = createRail();
const wrapL = new THREE.Group();
wrapL.add(railL);
wrapL.rotation.y = Math.PI / 2;
wrapL.position.set(-hw - gr.width / 2, 0, gr.depth / 2);
grp.add(wrapL);
// 우측 레일
const railR = createRail();
const wrapR = new THREE.Group();
wrapR.add(railR);
wrapR.rotation.y = -Math.PI / 2;
wrapR.position.set(hw + gr.width / 2, 0, gr.depth / 2);
grp.add(wrapR);
return grp;
}
/* ── 슬랫 커튼 ── */
_buildSlats(W1, H, shutterH, type, def) {
const grp = new THREE.Group();
const isSteel = type === 'steel';
const slatMat = new THREE.MeshStandardMaterial({
color: isSteel ? 0x9ca3af : 0xc084fc,
side: THREE.DoubleSide,
transparent: !isSteel, opacity: isSteel ? 0.95 : 0.6,
metalness: isSteel ? 0.4 : 0, roughness: isSteel ? 0.5 : 0.8,
});
const slatGeo = new THREE.PlaneGeometry(W1 - 20, shutterH);
const slatMesh = new THREE.Mesh(slatGeo, slatMat);
slatMesh.userData = { part: 'slats', label: '슬랫 커튼', type: isSteel ? '철재슬라트' : '스크린', height: shutterH + 'mm' };
grp.add(slatMesh);
// 슬랫 라인 (철재형)
if (isSteel) {
const pitch = def.slat.pitch;
const count = Math.floor(shutterH / pitch);
const lineMat = new THREE.LineBasicMaterial({ color: 0x6b7280 });
for (let i = 1; i < count; i++) {
const y = shutterH / 2 - i * pitch;
const pts = [new THREE.Vector3(-W1 / 2 + 15, y, 1), new THREE.Vector3(W1 / 2 - 15, y, 1)];
grp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat));
}
}
grp.position.set(0, H - shutterH / 2, 0);
return grp;
}
/* ── 감긴 슬랫 롤 ── */
_buildSlatRoll(b, W1, H, rolledH, type) {
const grp = new THREE.Group();
const isSteel = type === 'steel';
const wrapThick = isSteel ? 10 : 1;
const shaftR = b.shaftDia / 2;
const rollThick = Math.max(Math.sqrt(rolledH * wrapThick / Math.PI), 8);
const rollOuterR = shaftR + rollThick;
const rollLen = W1 - 80;
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 48);
rollGeo.rotateZ(Math.PI / 2);
const rollMat = new THREE.MeshStandardMaterial({
color: isSteel ? PART_COLORS.slatRoll : 0xc084fc,
metalness: isSteel ? 0.2 : 0, roughness: isSteel ? 0.5 : 0.8,
transparent: true, opacity: 0.9,
});
const rollMesh = new THREE.Mesh(rollGeo, rollMat);
rollMesh.userData = { part: 'slatRoll', label: '감긴 슬랫', outerRadius: rollOuterR.toFixed(1) + 'mm' };
grp.add(rollMesh);
// 나선 라인 (감긴 질감)
if (rollThick > 5) {
const spiralMat = new THREE.LineBasicMaterial({ color: isSteel ? 0x8B7355 : 0x7c4dff, transparent: true, opacity: 0.5 });
for (let s = 0; s < 4; s++) {
const pts = [];
const turns = Math.max(3, Math.min(rollLen / 60, 25));
const startA = (s / 4) * Math.PI * 2;
for (let i = 0; i <= turns * 32; i++) {
const t = i / (turns * 32);
const angle = startA + t * turns * Math.PI * 2;
pts.push(new THREE.Vector3(
-rollLen / 2 + t * rollLen,
Math.sin(angle) * (rollOuterR + 0.5),
Math.cos(angle) * (rollOuterR + 0.5)
));
}
grp.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), spiralMat));
}
}
grp.position.set(0, H + b.height / 2, 0);
return grp;
}
/* ── 하장바 (하단마감재 ㄷ채널 + L바 + 평철 어셈블리) ── */
_buildBottomBar(W1, H, shutterH) {
const grp = new THREE.Group();
grp.userData = { part: 'bottomBar', label: '하장바' };
const barW = W1 - 20;
const barH = 40, barD = 60;
const halfH = barH / 2, halfD = barD / 2;
const bt = 3, lipW = 22;
const barExtrude = { depth: barW, bevelEnabled: false };
const edgeMat = new THREE.LineBasicMaterial({ color: 0xb45309, transparent: true, opacity: 0.5 });
const inner = new THREE.Group();
inner.rotation.y = Math.PI / 2;
inner.position.set(-barW / 2, 0, 0);
// 1) 하단마감재 (ㄷ채널, SUS 1.2T)
const cs = new THREE.Shape();
cs.moveTo(-halfD, -halfH); cs.lineTo(halfD, -halfH);
cs.lineTo(halfD, halfH); cs.lineTo(halfD - lipW, halfH);
cs.lineTo(halfD - lipW, halfH - bt); cs.lineTo(halfD - bt, halfH - bt);
cs.lineTo(halfD - bt, -halfH + bt); cs.lineTo(-halfD + bt, -halfH + bt);
cs.lineTo(-halfD + bt, halfH - bt); cs.lineTo(-halfD + lipW, halfH - bt);
cs.lineTo(-halfD + lipW, halfH); cs.lineTo(-halfD, halfH);
cs.closePath();
const csGeo = new THREE.ExtrudeGeometry(cs, barExtrude);
inner.add(new THREE.Mesh(csGeo, new THREE.MeshStandardMaterial({ color: PART_COLORS.bottomBar, metalness: 0.5, roughness: 0.3 })));
inner.add(new THREE.LineSegments(new THREE.EdgesGeometry(csGeo), edgeMat));
// 2) L바 (180° 회전: 수평부 아래, 수직부 위로, EGI 1.55T)
const lbT = 2, cg = 3, armY = 5, vtop = 25;
const lMat = new THREE.MeshStandardMaterial({ color: 0xd97706, metalness: 0.3, roughness: 0.5 });
const llS = new THREE.Shape();
llS.moveTo(cg, armY); llS.lineTo(halfD - bt - 1, armY);
llS.lineTo(halfD - bt - 1, armY + lbT); llS.lineTo(cg + lbT, armY + lbT);
llS.lineTo(cg + lbT, vtop); llS.lineTo(cg, vtop);
llS.closePath();
inner.add(new THREE.Mesh(new THREE.ExtrudeGeometry(llS, barExtrude), lMat));
const rlS = new THREE.Shape();
rlS.moveTo(-cg, armY); rlS.lineTo(-(halfD - bt - 1), armY);
rlS.lineTo(-(halfD - bt - 1), armY + lbT); rlS.lineTo(-cg - lbT, armY + lbT);
rlS.lineTo(-cg - lbT, vtop); rlS.lineTo(-cg, vtop);
rlS.closePath();
inner.add(new THREE.Mesh(new THREE.ExtrudeGeometry(rlS, barExtrude), lMat));
// 3) 평철 (L바 수직부 사이, EGI 1.15T)
const fpT = 2, fpH = 18;
const fpS = new THREE.Shape();
fpS.moveTo(-fpT / 2, armY); fpS.lineTo(fpT / 2, armY);
fpS.lineTo(fpT / 2, armY + fpH); fpS.lineTo(-fpT / 2, armY + fpH);
fpS.closePath();
inner.add(new THREE.Mesh(new THREE.ExtrudeGeometry(fpS, barExtrude),
new THREE.MeshStandardMaterial({ color: 0x92400e, metalness: 0.3, roughness: 0.5 })));
grp.add(inner);
grp.position.set(0, H - shutterH - 20, 0);
return grp;
}
/* ── 벽체 ── */
_buildWall(W1, H, b) {
const grp = new THREE.Group();
const wallMat = new THREE.MeshStandardMaterial({ color: PART_COLORS.wall, transparent: true, opacity: 0.25, side: THREE.DoubleSide });
const wallThick = 200;
const totalH = H + b.height + 100;
// 좌측 벽
const wallGeoL = new THREE.BoxGeometry(wallThick, totalH, b.depth + 200);
const wallL = new THREE.Mesh(wallGeoL, wallMat);
wallL.position.set(-W1 / 2 - wallThick / 2 - 50, totalH / 2, 0);
grp.add(wallL);
// 우측 벽
const wallR = new THREE.Mesh(wallGeoL.clone(), wallMat);
wallR.position.set(W1 / 2 + wallThick / 2 + 50, totalH / 2, 0);
grp.add(wallR);
// 천장 (케이스 위)
const ceilGeo = new THREE.BoxGeometry(W1 + wallThick * 2 + 100, 100, b.depth + 400);
const ceil = new THREE.Mesh(ceilGeo, wallMat);
ceil.position.set(0, totalH + 50, 0);
grp.add(ceil);
// 바닥
const floorGeo = new THREE.BoxGeometry(W1 + wallThick * 2 + 100, 20, b.depth + 400);
const floorMat = new THREE.MeshStandardMaterial({ color: 0x64748b, transparent: true, opacity: 0.3 });
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.position.set(0, -10, 0);
grp.add(floor);
return grp;
}
/* ── 파라미터 업데이트 ── */
updateParams(newParams) {
Object.assign(this.params, newParams);
// 가이드레일 기본값 동기화
if (newParams.productType) {
const def = PRODUCT_DEFAULTS[newParams.productType];
this.params.guideRail = { ...def.guideRail };
}
this.build();
}
setShutterPos(v) {
this.params.shutterPos = v;
this.build();
}
setCaseOpacity(v) {
this.params.caseOpacity = v;
if (this.meshes.case) {
this.meshes.case.traverse(c => { if (c.isMesh && c.material) c.material.opacity = v; });
}
}
togglePart(key, visible) {
this.params.show[key] = visible;
if (this.meshes[key]) this.meshes[key].visible = visible;
}
setBgColor(color) {
this.params.bgColor = color;
this.scene.background = new THREE.Color(color);
}
/* ── 카메라 뷰 ── */
setView(preset) {
const b = new THREE.Box3();
Object.values(this.meshes).forEach(m => { if (m && m.visible) b.expandByObject(m); });
if (b.isEmpty()) return;
const center = b.getCenter(new THREE.Vector3());
const size = b.getSize(new THREE.Vector3());
const d = Math.max(size.x, size.y, size.z) * 0.8;
const views = {
perspective: [center.x + d, center.y + d * 0.5, center.z + d],
front: [center.x, center.y, center.z + d * 1.3],
back: [center.x, center.y, center.z - d * 1.3],
right: [center.x + d * 1.3, center.y, center.z],
left: [center.x - d * 1.3, center.y, center.z],
top: [center.x, center.y + d * 1.5, center.z + 0.01],
};
this.targetPos = new THREE.Vector3(...(views[preset] || views.perspective));
this.targetLook = center.clone();
}
fitToModel() {
const b = new THREE.Box3();
Object.values(this.meshes).forEach(m => { if (m && m.visible) b.expandByObject(m); });
if (b.isEmpty()) return;
const center = b.getCenter(new THREE.Vector3());
const size = b.getSize(new THREE.Vector3());
const d = Math.max(size.x, size.y, size.z) * 1.2;
this.targetPos = new THREE.Vector3(center.x + d * 0.6, center.y + d * 0.4, center.z + d * 0.6);
this.targetLook = center.clone();
}
/* ── 클릭 선택 (Raycaster) ── */
onClick(e) {
const rect = this.el.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 targets = [];
Object.values(this.meshes).forEach(m => { if (m && m.visible) { if (m.isMesh) targets.push(m); else m.traverse(c => { if (c.isMesh) targets.push(c); }); } });
const hits = this.raycaster.intersectObjects(targets, false);
// 이전 선택 해제
if (this.selectedOutline) { this.scene.remove(this.selectedOutline); this.selectedOutline = null; }
if (hits.length > 0) {
const hit = hits[0].object;
const ud = hit.userData || {};
// 외곽선 하이라이트
if (hit.geometry) {
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(hit.geometry),
new THREE.LineBasicMaterial({ color: 0x3b82f6, linewidth: 2 })
);
outline.position.copy(hit.getWorldPosition(new THREE.Vector3()));
outline.quaternion.copy(hit.getWorldQuaternion(new THREE.Quaternion()));
outline.scale.copy(hit.getWorldScale(new THREE.Vector3()));
this.scene.add(outline);
this.selectedOutline = outline;
}
if (this.onSelect) this.onSelect(ud);
} else {
if (this.onSelect) this.onSelect(null);
}
}
/* ── 스크린샷 ── */
screenshot() {
this.renderer.render(this.scene, this.camera);
const url = this.renderer.domElement.toDataURL('image/png');
const a = document.createElement('a');
a.href = url; a.download = 'fire-shutter-bim.png';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
/* ── 리사이즈 ── */
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) < 1) this.targetPos = null;
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
/* ── 정리 ── */
_dispose(obj) {
obj.traverse(c => {
if (c.geometry) c.geometry.dispose();
if (c.material) {
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
});
}
dispose() {
if (this._animId) cancelAnimationFrame(this._animId);
this.el.removeEventListener('click', this._onClick);
window.removeEventListener('resize', this._onResize);
Object.values(this.meshes).forEach(m => { if (m) this._dispose(m); });
if (this.renderer) this.renderer.dispose();
}
}
/* ════════════════════════════════════════════════
React Components
════════════════════════════════════════════════ */
/* ── 좌측 파라미터 패널 ── */
function ParamPanel({ params, onParamChange, onRebuild }) {
const p = params;
const def = PRODUCT_DEFAULTS[p.productType];
const W1 = p.openWidth + def.marginW;
const H1 = p.openHeight + def.marginH;
const area = (W1 * H1 / 1e6).toFixed(2);
const weight = (area * def.weightFactor).toFixed(1);
const motor = MOTOR_TABLE.find(m => weight <= m.max) || MOTOR_TABLE[MOTOR_TABLE.length - 1];
const inputCls = "w-full bg-slate-950/80 border border-slate-700 rounded-lg px-3 py-1.5 text-white text-sm font-bold outline-none focus:border-blue-500 transition-colors";
const labelCls = "text-slate-400 text-[11px] font-bold mb-1 block";
const sectionCls = "bg-slate-900/50 rounded-xl p-4 border border-slate-800 mb-3";
return (
<div className="space-y-3" style={{fontSize:'13px'}}>
{/* 제품 유형 */}
<div className={sectionCls}>
<h3 className="text-white text-sm font-black mb-3 flex items-center gap-2">
<i className="ri-fire-line text-red-400"></i> 기본 설정
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>제품 유형</label>
<select className={inputCls} value={p.productType}
onChange={e => onParamChange({ productType: e.target.value })}>
<option value="steel">철재슬라트</option>
<option value="screen">스크린형</option>
</select>
</div>
<div>
<label className={labelCls}>개구부 (mm)</label>
<input type="number" className={inputCls} value={p.openWidth}
onChange={e => onParamChange({ openWidth: Number(e.target.value) })} />
</div>
<div>
<label className={labelCls}>개구부 높이 (mm)</label>
<input type="number" className={inputCls} value={p.openHeight}
onChange={e => onParamChange({ openHeight: Number(e.target.value) })} />
</div>
</div>
{/* 자동 계산 */}
<div className="mt-3 pt-3 border-t border-slate-700/50 grid grid-cols-3 gap-2 text-[11px]">
<div><span className="text-slate-500">제작폭</span><br/><b className="text-white">{W1}mm</b></div>
<div><span className="text-slate-500">면적</span><br/><b className="text-white">{area}m&sup2;</b></div>
<div><span className="text-slate-500">중량</span><br/><b className="text-white">{weight}kg</b></div>
<div><span className="text-slate-500">모터</span><br/><b className="text-blue-400">{motor.spec}</b></div>
<div><span className="text-slate-500">유형</span><br/><b className="text-amber-400">{def.label}</b></div>
</div>
</div>
{/* 셔터박스 */}
<div className={sectionCls}>
<h3 className="text-white text-sm font-black mb-3 flex items-center gap-2">
<i className="ri-inbox-line text-amber-400"></i> 셔터박스
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>높이 (mm)</label>
<input type="number" className={inputCls} value={p.shutterBox.height}
onChange={e => { p.shutterBox.height = Number(e.target.value); onRebuild(); }} />
</div>
<div>
<label className={labelCls}>깊이 (mm)</label>
<input type="number" className={inputCls} value={p.shutterBox.depth}
onChange={e => { p.shutterBox.depth = Number(e.target.value); onRebuild(); }} />
</div>
<div>
<label className={labelCls}>샤프트 직경 (mm)</label>
<input type="number" className={inputCls} value={p.shutterBox.shaftDia}
onChange={e => { p.shutterBox.shaftDia = Number(e.target.value); onRebuild(); }} />
</div>
<div>
<label className={labelCls}>모터 위치</label>
<select className={inputCls} value={p.shutterBox.motorSide}
onChange={e => { p.shutterBox.motorSide = e.target.value; onRebuild(); }}>
<option value="right">우측</option>
<option value="left">좌측</option>
</select>
</div>
</div>
</div>
{/* 가이드레일 */}
<div className={sectionCls}>
<h3 className="text-white text-sm font-black mb-3 flex items-center gap-2">
<i className="ri-layout-column-line text-purple-400"></i> 가이드레일
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}> (mm)</label>
<input type="number" className={inputCls} value={p.guideRail.width} step="0.1"
onChange={e => { p.guideRail.width = Number(e.target.value); onRebuild(); }} />
</div>
<div>
<label className={labelCls}>깊이 (mm)</label>
<input type="number" className={inputCls} value={p.guideRail.depth} step="0.1"
onChange={e => { p.guideRail.depth = Number(e.target.value); onRebuild(); }} />
</div>
<div>
<label className={labelCls}>두께 (mm)</label>
<input type="number" className={inputCls} value={p.guideRail.thickness} step="0.01"
onChange={e => { p.guideRail.thickness = Number(e.target.value); onRebuild(); }} />
</div>
<div>
<label className={labelCls}> 높이 (mm)</label>
<input type="number" className={inputCls} value={p.guideRail.lip} step="0.1"
onChange={e => { p.guideRail.lip = Number(e.target.value); onRebuild(); }} />
</div>
</div>
</div>
</div>
);
}
/* ── 하단 툴바 ── */
function Toolbar({ onView, visibility, onToggle, shutterPos, onShutterPos, caseOpacity, onOpacity, onScreenshot, onBgColor }) {
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: 'left', icon: 'ri-layout-left-2-line', label: '좌측' },
{ id: 'top', icon: 'ri-layout-top-2-line', label: '상부' },
{ id: 'back', icon: 'ri-arrow-go-back-line', label: '배면' },
];
const partKeys = Object.keys(PART_LABELS);
const bgColors = ['#1a1a2e', '#000000', '#0f172a', '#ffffff', '#f0f0f0', '#303030'];
return (
<div style={{background:'rgba(15,23,42,0.9)',backdropFilter:'blur(12px)',borderTop:'1px solid rgba(255,255,255,0.1)'}}
className="px-4 py-2 flex items-center gap-4 flex-wrap">
{/* 시점 */}
<div className="flex gap-1">
{views.map(v => (
<button key={v.id} onClick={() => onView(v.id)} title={v.label}
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-white hover:bg-slate-700 transition-colors">
<i className={v.icon + ' text-base'}></i>
</button>
))}
</div>
<div className="w-px h-6 bg-slate-700"></div>
{/* 개폐율 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 font-bold">개폐</span>
<input type="range" min="0" max="100" value={shutterPos}
onChange={e => onShutterPos(Number(e.target.value))}
className="w-20 accent-blue-500" style={{height:'3px'}} />
<span className="text-[10px] text-blue-400 font-black w-7 text-right">{shutterPos}%</span>
</div>
{/* 투명도 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 font-bold">투명</span>
<input type="range" min="0" max="100" value={Math.round(caseOpacity * 100)}
onChange={e => onOpacity(Number(e.target.value) / 100)}
className="w-16 accent-blue-500" style={{height:'3px'}} />
</div>
<div className="w-px h-6 bg-slate-700"></div>
{/* 부품 토글 */}
<div className="flex gap-1 flex-wrap">
{partKeys.map(k => (
<button key={k} onClick={() => onToggle(k)}
className={'px-2 py-1 rounded text-[10px] font-bold transition-colors ' +
(visibility[k] !== false ? 'bg-slate-700 text-white' : 'bg-transparent text-slate-600 line-through')}
title={PART_LABELS[k]}>
{PART_LABELS[k]}
</button>
))}
</div>
<div className="w-px h-6 bg-slate-700"></div>
{/* 배경색 */}
<div className="flex gap-1">
{bgColors.map(c => (
<button key={c} onClick={() => onBgColor(c)}
className="w-5 h-5 rounded border border-slate-600 hover:border-blue-400 transition-colors"
style={{background: c}}></button>
))}
</div>
{/* 스크린샷 */}
<button onClick={onScreenshot}
className="ml-auto px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-bold transition-colors flex items-center gap-1">
<i className="ri-camera-line"></i> 스크린샷
</button>
</div>
);
}
/* ── 우측 정보 패널 ── */
function InfoPanel({ selected, params }) {
const def = PRODUCT_DEFAULTS[params.productType];
const W1 = params.openWidth + def.marginW;
const H1 = params.openHeight + def.marginH;
const area = (W1 * H1 / 1e6).toFixed(2);
const weight = (area * def.weightFactor).toFixed(1);
return (
<div style={{width:'260px',background:'rgba(15,23,42,0.85)',backdropFilter:'blur(12px)',borderLeft:'1px solid rgba(255,255,255,0.1)'}}
className="p-4 overflow-y-auto custom-scrollbar flex-shrink-0">
<h3 className="text-white text-sm font-black mb-3 flex items-center gap-2">
<i className="ri-information-line text-blue-400"></i> 모델 정보
</h3>
<div className="space-y-2 text-[11px]">
<div className="flex justify-between"><span className="text-slate-500">제품 유형</span><span className="text-white font-bold">{def.label}</span></div>
<div className="flex justify-between"><span className="text-slate-500">개구부</span><span className="text-white font-bold">{params.openWidth} x {params.openHeight}mm</span></div>
<div className="flex justify-between"><span className="text-slate-500">제작 크기</span><span className="text-white font-bold">{W1} x {H1}mm</span></div>
<div className="flex justify-between"><span className="text-slate-500">면적</span><span className="text-white font-bold">{area}m&sup2;</span></div>
<div className="flex justify-between"><span className="text-slate-500">추정 중량</span><span className="text-white font-bold">{weight}kg</span></div>
<div className="flex justify-between"><span className="text-slate-500">셔터박스</span><span className="text-white font-bold">{params.shutterBox.height}mm</span></div>
<div className="flex justify-between"><span className="text-slate-500">샤프트</span><span className="text-white font-bold">ø{params.shutterBox.shaftDia}mm</span></div>
</div>
{selected && (
<div className="mt-4 pt-4 border-t border-slate-700">
<h3 className="text-white text-sm font-black mb-3 flex items-center gap-2">
<i className="ri-focus-3-line text-amber-400"></i> 선택된 부품
</h3>
<div className="space-y-2 text-[11px]">
{Object.entries(selected).map(([k, v]) => (
<div key={k} className="flex justify-between">
<span className="text-slate-500">{k}</span>
<span className="text-white font-bold">{String(v)}</span>
</div>
))}
</div>
</div>
)}
{/* 부품 색상 범례 */}
<div className="mt-4 pt-4 border-t border-slate-700">
<h3 className="text-slate-400 text-[11px] font-black mb-2">부품 범례</h3>
<div className="space-y-1">
{Object.entries(PART_LABELS).map(([k, label]) => (
<div key={k} className="flex items-center gap-2 text-[10px]">
<span className="w-3 h-3 rounded-sm flex-shrink-0"
style={{background: '#' + PART_COLORS[k].toString(16).padStart(6, '0')}}></span>
<span className="text-slate-400">{label}</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ── 메인 앱 ── */
function FireShutterBimApp() {
const vpRef = useRef(null);
const sceneRef = useRef(null);
const [selected, setSelected] = useState(null);
const [params, setParams] = useState(null);
const [shutterPos, setShutterPos] = useState(100);
const [caseOpacity, setCaseOpacity] = useState(0.3);
const [visibility, setVisibility] = useState({
case: true, shaft: true, motor: true, brake: true, spring: true,
rails: true, slats: true, bottomBar: true, slatRoll: true, wall: false,
});
useEffect(() => {
if (!vpRef.current) return;
const s = new FireShutterScene(vpRef.current);
s.init();
s.onSelect = setSelected;
sceneRef.current = s;
setParams({...s.params});
// 초기 카메라 위치
setTimeout(() => s.setView('perspective'), 100);
return () => s.dispose();
}, []);
const handleParamChange = useCallback((changes) => {
const s = sceneRef.current;
if (!s) return;
s.updateParams(changes);
setParams({...s.params});
}, []);
const handleRebuild = useCallback(() => {
const s = sceneRef.current;
if (!s) return;
s.build();
setParams({...s.params});
}, []);
const handleShutterPos = useCallback((v) => {
setShutterPos(v);
sceneRef.current?.setShutterPos(v);
}, []);
const handleOpacity = useCallback((v) => {
setCaseOpacity(v);
sceneRef.current?.setCaseOpacity(v);
}, []);
const handleToggle = useCallback((key) => {
setVisibility(prev => {
const next = {...prev, [key]: prev[key] === false ? true : false};
sceneRef.current?.togglePart(key, next[key]);
return next;
});
}, []);
const handleView = useCallback((preset) => {
sceneRef.current?.setView(preset);
}, []);
const handleBgColor = useCallback((color) => {
sceneRef.current?.setBgColor(color);
}, []);
const handleScreenshot = useCallback(() => {
sceneRef.current?.screenshot();
}, []);
return (
<div style={{margin:'-24px',height:'calc(100vh - 64px)',background:'#020617',display:'flex',flexDirection:'column',overflow:'hidden'}}>
{/* 상단 헤더 */}
<div style={{background:'rgba(15,23,42,0.9)',borderBottom:'1px solid rgba(255,255,255,0.1)',backdropFilter:'blur(12px)'}}
className="px-5 py-2.5 flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-2">
<i className="ri-fire-line text-red-400 text-lg"></i>
<h1 className="text-white text-base font-black">방화셔터 BIM 뷰어</h1>
</div>
{params && (
<>
<span className="text-slate-500 text-[11px]">|</span>
<span className="text-slate-400 text-[11px] font-bold">
{PRODUCT_DEFAULTS[params.productType].label} &middot; {params.openWidth} x {params.openHeight}mm
</span>
</>
)}
</div>
{/* 메인 영역 */}
<div className="flex flex-1" style={{minHeight:0}}>
{/* 좌측 파라미터 패널 */}
{params && (
<div style={{width:'280px',background:'rgba(15,23,42,0.85)',borderRight:'1px solid rgba(255,255,255,0.1)',backdropFilter:'blur(12px)'}}
className="p-3 overflow-y-auto custom-scrollbar flex-shrink-0">
<ParamPanel params={params} onParamChange={handleParamChange} onRebuild={handleRebuild} />
</div>
)}
{/* 3D 뷰포트 — 항상 렌더링 (vpRef 필수) */}
<div className="flex-1 relative" style={{minWidth:0}}>
<div ref={vpRef} style={{width:'100%',height:'100%'}}></div>
{/* 개폐율 표시 */}
<div className="absolute top-3 right-3 px-3 py-1.5 rounded-lg text-[11px] font-black"
style={{background:'rgba(15,23,42,0.8)',backdropFilter:'blur(8px)',border:'1px solid rgba(255,255,255,0.1)'}}>
<span className="text-slate-400">개폐율 </span>
<span className="text-blue-400">{shutterPos}%</span>
</div>
</div>
{/* 우측 정보 패널 */}
{params && <InfoPanel selected={selected} params={params} />}
</div>
{/* 하단 툴바 */}
<Toolbar
onView={handleView}
visibility={visibility}
onToggle={handleToggle}
shutterPos={shutterPos}
onShutterPos={handleShutterPos}
caseOpacity={caseOpacity}
onOpacity={handleOpacity}
onScreenshot={handleScreenshot}
onBgColor={handleBgColor}
/>
</div>
);
}
/* ── 렌더 ── */
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<FireShutterBimApp />);
@endverbatim
</script>
<style>
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #475569; }
</style>
@endpush