- L바 수평부를 아래, 수직부를 위로 (180° 회전) - 하장바 날개(lipW) 10→22mm 확장 (간격 ~16mm, 고리 구조) - 날개가 L바 수평부 위를 덮어 빠짐 방지하는 실제 구조 반영
1103 lines
49 KiB
PHP
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²</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²</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} · {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
|