Files
sam-manage/resources/views/rd/fire-shutter-drawing/index.blade.php

2345 lines
138 KiB
PHP
Raw Normal View History

@extends('layouts.app')
@section('title', '방화셔터 도면생성')
@section('content')
<style>
body { font-family: 'Pretendard', sans-serif; }
.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; }
.glass-panel { background: rgba(15, 23, 42, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); }
.neon-border { box-shadow: 0 0 15px rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); }
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.fs-wrap { margin: -24px; min-height: calc(100vh - 64px); background: #020617; overflow: hidden; }
.fs-input { width: 100%; background: rgba(2,6,23,0.8); border: 1px solid #334155; border-radius: 0.75rem; padding: 0.5rem 0.75rem; color: #f8fafc; font-size: 0.875rem; font-weight: 700; outline: none; transition: border-color 0.2s; }
.fs-input:focus { border-color: #3b82f6; }
.fs-label { display: block; color: #94a3b8; font-size: 0.75rem; font-weight: 700; margin-bottom: 0.25rem; }
.fs-section { background: rgba(15,23,42,0.5); border-radius: 1rem; padding: 1rem; border: 1px solid #1e293b; }
.fs-badge { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 0.75rem; font-size: 0.625rem; font-weight: 900; color: white; }
.fs-toggle { position: relative; width: 2rem; height: 1rem; background: #334155; border-radius: 9999px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
.fs-toggle.active { background: #3b82f6; }
.fs-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 0.75rem; height: 0.75rem; background: white; border-radius: 9999px; transition: transform 0.2s; }
.fs-toggle.active::after { transform: translateX(1rem); }
.fs-select { width: 100%; background: rgba(2,6,23,0.8); border: 1px solid #334155; border-radius: 0.75rem; padding: 0.5rem 0.75rem; color: #f8fafc; font-size: 0.875rem; font-weight: 700; outline: none; }
.fs-btn { padding: 0.5rem 1rem; border-radius: 0.75rem; font-size: 0.75rem; font-weight: 900; cursor: pointer; transition: all 0.2s; border: none; }
.fs-btn-primary { background: #3b82f6; color: white; }
.fs-btn-primary:hover { background: #2563eb; }
.fs-btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.fs-btn-ghost:hover { background: #1e293b; color: white; }
.fs-btn-ghost.active { background: #1e293b; color: #3b82f6; border-color: #3b82f6; }
.fs-calc-row { display: flex; justify-content: space-between; align-items: center; padding: 0.375rem 0; border-bottom: 1px solid rgba(51,65,85,0.3); }
.fs-calc-label { color: #64748b; font-size: 0.75rem; }
.fs-calc-value { color: #f8fafc; font-size: 0.875rem; font-weight: 900; }
.fs-ctx-menu { position: fixed; display: none; z-index: 10000; background: rgba(15,23,42,0.95); border: 1px solid #334155; border-radius: 0.5rem; padding: 0.25rem; backdrop-filter: blur(12px); box-shadow: 0 10px 25px rgba(0,0,0,0.5); min-width: 160px; }
.fs-ctx-btn { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.75rem; border: none; background: transparent; color: #e2e8f0; font-size: 0.8125rem; font-weight: 700; cursor: pointer; border-radius: 0.375rem; white-space: nowrap; text-align: left; }
.fs-ctx-btn:hover { background: rgba(59,130,246,0.2); color: #60a5fa; }
.fs-ctx-sep { height: 1px; background: #334155; margin: 0.25rem 0; }
.fs-iso-badge { position: absolute; top: 12px; left: 12px; display: none; padding: 6px 14px; background: rgba(59,130,246,0.9); color: white; font-size: 0.75rem; font-weight: 900; border-radius: 0.5rem; z-index: 100; cursor: pointer; box-shadow: 0 4px 12px rgba(59,130,246,0.4); }
.fs-iso-badge:hover { background: rgba(37,99,235,1); }
</style>
<div class="fs-wrap">
<main style="max-width:1800px; margin:0 auto; padding:1rem 1.5rem;">
<!-- ========== TOP: 기본 설정 (전체 ) ========== -->
<div class="fs-section mb-3" style="padding:0.75rem 1rem;">
<div class="flex gap-3 items-end">
<div style="flex:0 0 120px;">
<label class="fs-label">유형</label>
<select id="productType" class="fs-select" style="font-size:0.75rem;padding:0.375rem 0.5rem;" onchange="fsOnProductType()">
<option value="steel">철재슬라트</option>
<option value="screen" selected>스크린형</option>
</select>
</div>
<div style="flex:0 0 130px;">
<label class="fs-label">제품 모델</label>
<select id="productModel" class="fs-select" style="font-size:0.75rem;padding:0.375rem 0.5rem;" onchange="fsOnModelChange()">
<optgroup label="철재슬라트">
<option value="KFS01">KFS01 - 기본</option>
<option value="KFS02">KFS02 - 대형</option>
</optgroup>
<optgroup label="스크린형">
<option value="KSS01" selected>KSS01 - 실리카</option>
<option value="KSS02">KSS02 - 와이어</option>
</optgroup>
</select>
</div>
<div style="flex:0 0 90px;">
<label class="fs-label">오픈폭</label>
<input type="number" id="openWidth" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="2000" onchange="fsCalc()">
</div>
<div style="flex:0 0 90px;">
<label class="fs-label">오픈H</label>
<input type="number" id="openHeight" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="3000" onchange="fsCalc()">
</div>
<div style="flex:0 0 60px;">
<label class="fs-label">수량</label>
<input type="number" id="quantity" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="1" min="1" onchange="fsCalc()">
</div>
<div class="flex-1"></div>
<div class="flex items-center gap-3 text-[11px] text-slate-400 shrink-0">
<span>W1: <b id="calcW1" class="text-white">2110</b></span>
<span>H1: <b id="calcH1" class="text-white">3350</b></span>
<span>면적: <b id="calcArea" class="text-white">7.07</b></span>
<span>중량: <b id="calcWeight" class="text-white">176.7</b>kg</span>
<span>모터: <b id="calcMotor" class="text-blue-400">300K</b></span>
<span>레일: <b id="calcRailCombo" class="text-amber-400">3,150mm × 2</b></span>
</div>
<button onclick="var d=document.getElementById('settingsDetail');d.classList.toggle('hidden');this.querySelector('span').textContent=d.classList.contains('hidden')?'▼':'▲';" class="text-[11px] text-slate-500 hover:text-slate-300 font-bold px-2 py-1 rounded hover:bg-slate-800 transition-colors shrink-0">
<span></span>
</button>
</div>
<!-- 접기/펼치기 상세 영역 -->
<div id="settingsDetail" class="hidden mt-3 pt-3 border-t border-slate-700/50">
<div class="flex gap-6">
<div class="flex-1 space-y-2">
<span class="text-[10px] text-slate-500 font-bold">프리셋</span>
<div class="flex gap-2">
<select id="presetSelect" class="fs-select flex-1" style="font-size:0.75rem;"><option value="">-- 선택 --</option></select>
<button class="fs-btn fs-btn-primary" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsLoadPreset()">불러오기</button>
</div>
<div class="flex gap-2">
<input type="text" id="presetName" class="fs-input flex-1" style="font-size:0.75rem;" placeholder="프리셋 이름">
<button class="fs-btn fs-btn-primary" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsSavePreset()">저장</button>
<button class="fs-btn fs-btn-ghost" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsDeletePreset()">삭제</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========== BOTTOM: 좌측 컨트롤 + 우측 ========== -->
<div class="flex" style="gap:1.25rem; height:calc(100vh - 170px);">
<!-- LEFT PANEL -->
<div class="custom-scrollbar" style="width:28%; flex-shrink:0; overflow-y:auto; padding-right:0.5rem;">
<!-- Tab Buttons (1x3) -->
<div class="grid gap-1 mb-3 bg-slate-900/50 p-1.5 rounded-xl border border-slate-800" style="grid-template-columns:1fr 1fr 1fr;">
<button id="tabGuideRail" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('GuideRail')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
가이드레일
</button>
<button id="tabShutterBox" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('ShutterBox')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="8" rx="2"/><path d="M6 12v4"/><path d="M18 12v4"/></svg>
셔터박스
</button>
<button id="tab3D" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('3D')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
3D 렌더링
</button>
</div>
<!-- ========== GUIDE RAIL TAB CONTROLS ========== -->
<div id="ctrlGuideRail" class="hidden">
<section class="fs-section space-y-5">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="fs-badge bg-purple-600">GR</span>
가이드레일 파라미터
</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">레일 전체 (mm)</label>
<input type="number" id="grWidth" class="fs-input" value="120" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">레일 깊이 (mm)</label>
<input type="number" id="grDepth" class="fs-input" value="70" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">강판 두께 (mm)</label>
<input type="number" id="grThickness" class="fs-input" value="1.55" step="0.01" onchange="fsRender()">
</div>
<div>
<label class="fs-label">(입구) 높이 (mm)</label>
<input type="number" id="grLip" class="fs-input" value="10" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 두께 (mm)</label>
<input type="number" id="grSealThick" class="fs-input" value="0.8" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 깊이 (mm)</label>
<input type="number" id="grSealDepth" class="fs-input" value="40" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">슬랫 두께 (mm)</label>
<input type="number" id="grSlatThick" class="fs-input" value="0.8" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">앵커볼트 간격 (mm)</label>
<input type="number" id="grAnchorSpacing" class="fs-input" value="500" step="10" onchange="fsRender()">
</div>
</div>
</section>
<section class="fs-section space-y-3 mt-4">
<h2 class="text-sm font-black text-slate-400"> 옵션</h2>
<div class="flex flex-wrap gap-2">
<button class="fs-btn fs-btn-ghost active" data-grview="cross" onclick="fsGrView('cross')">평면도</button>
<button class="fs-btn fs-btn-ghost" data-grview="front" onclick="fsGrView('front')">정면도</button>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400 font-bold">치수 표시</span>
<div id="toggleDim" class="fs-toggle active" onclick="fsToggle(this,'showDim')"></div>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400 font-bold">연기차단재 표시</span>
<div id="toggleSeal" class="fs-toggle active" onclick="fsToggle(this,'showSeal')"></div>
</div>
</section>
</div>
<!-- ========== SHUTTER BOX TAB CONTROLS ========== -->
<div id="ctrlShutterBox" class="hidden">
<section class="fs-section space-y-5">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="fs-badge bg-amber-600">SB</span>
셔터박스 파라미터
</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">케이스 (mm)</label>
<input type="number" id="sbWidth" class="fs-input" value="2210" step="10" onchange="fsRender()">
</div>
<div>
<label class="fs-label">케이스 높이 (mm)</label>
<input type="number" id="sbHeight" class="fs-input" value="380" step="10" onchange="fsRender()">
</div>
<div>
<label class="fs-label">케이스 깊이 (mm)</label>
<input type="number" id="sbDepth" class="fs-input" value="500" step="10" onchange="fsRender()">
</div>
<div>
<label class="fs-label">강판 두께 (mm)</label>
<input type="number" id="sbThickness" class="fs-input" value="1.6" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">샤프트 직경 (mm)</label>
<input type="number" id="sbShaftDia" class="fs-input" value="80" step="5" onchange="fsRender()">
</div>
<div>
<label class="fs-label">브래킷 (mm)</label>
<input type="number" id="sbBracketW" class="fs-input" value="10" step="1" onchange="fsRender()">
</div>
</div>
<div>
<label class="fs-label">모터 위치</label>
<select id="sbMotorSide" class="fs-select" onchange="fsRender()">
<option value="right">우측</option>
<option value="left">좌측</option>
</select>
</div>
</section>
<section class="fs-section space-y-3 mt-4">
<h2 class="text-sm font-black text-slate-400"> 옵션</h2>
<div class="flex flex-wrap gap-2">
<button class="fs-btn fs-btn-ghost active" data-sbview="front" onclick="fsSbView('front')">정면도</button>
<button class="fs-btn fs-btn-ghost" data-sbview="side" onclick="fsSbView('side')">측면도</button>
</div>
<h2 class="text-sm font-black text-slate-400 mt-3">내부 부품 표시</h2>
<div class="space-y-2">
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">샤프트</span><div class="fs-toggle active" onclick="fsToggle(this,'showShaft')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">감긴 슬랫</span><div class="fs-toggle active" onclick="fsToggle(this,'showSlatRoll')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">모터/감속기</span><div class="fs-toggle active" onclick="fsToggle(this,'showMotor')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">브레이크</span><div class="fs-toggle active" onclick="fsToggle(this,'showBrake')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">밸런스 스프링</span><div class="fs-toggle active" onclick="fsToggle(this,'showSpring')"></div></div>
</div>
</section>
</div>
<!-- ========== 3D TAB CONTROLS ========== -->
<div id="ctrl3D" class="hidden">
<section class="fs-section" style="padding:0.75rem 1rem;">
<!-- 개폐율: -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:24px;">개폐</label>
<input type="range" id="shutterPos" min="0" max="100" value="100" class="flex-1 accent-blue-500" style="height:3px;" oninput="fs3dShutterPos(this.value)">
<span id="shutterPosLabel" class="text-[10px] text-blue-400 font-black shrink-0" style="width:28px;text-align:right;">100%</span>
</div>
<!-- 투명도: -->
<div class="flex items-center gap-2 mb-2">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:24px;">투명</label>
<input type="range" id="caseOpacity" min="0" max="100" value="30" class="flex-1 accent-blue-500" style="height:3px;" oninput="fs3dOpacity(this.value)">
<span id="opacityLabel" class="text-[10px] text-blue-400 font-black shrink-0" style="width:28px;text-align:right;">30%</span>
</div>
<!-- 부품: 4 compact grid -->
<div class="grid gap-x-0.5 gap-y-0.5 mb-2" style="grid-template-columns:repeat(4,auto);">
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'case')"></div><span class="text-[10px] text-slate-500">박스</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'shaft')"></div><span class="text-[10px] text-slate-500">샤프트</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'motor')"></div><span class="text-[10px] text-slate-500">모터</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'rails')"></div><span class="text-[10px] text-slate-500">레일</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'slats')"></div><span class="text-[10px] text-slate-500">슬랫</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'bottomBar')"></div><span class="text-[10px] text-slate-500">하장바</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle" onclick="fsToggle3d(this,'wall')"></div><span class="text-[10px] text-slate-500"></span></div>
</div>
<!-- 조명색 + 배경색: -->
<div class="flex items-center gap-3 border-t border-slate-800 pt-1.5">
<div class="flex items-center gap-1"><label class="text-[10px] text-slate-500 font-bold">조명</label><input type="color" id="lightColor" value="#ffffff" class="w-4 h-4 rounded cursor-pointer border-0 p-0" style="background:none;" onchange="fs3dLightColor(this.value)"></div>
<div class="flex items-center gap-1">
<label class="text-[10px] text-slate-500 font-bold">배경</label>
<button onclick="fs3dBg('#ffffff')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#fff"></button>
<button onclick="fs3dBg('#f0f0f0')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#f0f0f0"></button>
<button onclick="fs3dBg('#808080')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#808080"></button>
<button onclick="fs3dBg('#303030')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#303030"></button>
<button onclick="fs3dBg('#1a1a2e')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#1a1a2e"></button>
<button onclick="fs3dBg('#000')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#000"></button>
</div>
</div>
</section>
<!-- 벽체 설정 ( 토글 OFF 숨김) -->
<section id="wallSettings" class="fs-section mt-2 hidden" style="padding:0.75rem 1rem;">
<h3 class="text-[11px] font-black text-slate-300 mb-2 flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="6" width="22" height="15" rx="2"/><path d="M1 10h22"/></svg>
벽체 설정
</h3>
<!-- 날개벽 -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">날개벽</label>
<input type="range" id="wallWing" min="0" max="2000" value="600" step="50" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('wing',this.value)">
<span id="wallWingLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">600</span>
</div>
<!-- 두께 -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">두께</label>
<input type="range" id="wallThick" min="100" max="1000" value="600" step="10" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('thick',this.value)">
<span id="wallThickLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">600</span>
</div>
<!-- 상단 여유 -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">상단</label>
<input type="range" id="wallTopMargin" min="0" max="1000" value="300" step="50" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('topMargin',this.value)">
<span id="wallTopMarginLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">300</span>
</div>
<!-- 투명도 -->
<div class="flex items-center gap-2 mb-2">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">투명도</label>
<input type="range" id="wallOpacity" min="0" max="100" value="30" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('opacity',this.value)">
<span id="wallOpacityLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">30%</span>
</div>
<!-- 색상 프리셋 -->
<div class="flex items-center gap-1.5">
<label class="text-[10px] text-slate-500 font-bold shrink-0">색상</label>
<button onclick="fs3dWallColor('#a1887f')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#a1887f" title="콘크리트"></button>
<button onclick="fs3dWallColor('#c0785c')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#c0785c" title="벽돌"></button>
<button onclick="fs3dWallColor('#e8e8e8')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#e8e8e8" title="흰색 도장"></button>
<button onclick="fs3dWallColor('#78909c')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#78909c" title="커튼월"></button>
<button onclick="fs3dWallColor('#8d6e63')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#8d6e63" title="갈색 벽돌"></button>
<button onclick="fs3dWallColor('#b0bec5')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#b0bec5" title="ALC"></button>
<input type="color" id="wallColorPicker" value="#a1887f" class="w-5 h-5 rounded cursor-pointer border border-slate-600 p-0" style="background:none;" onchange="fs3dWallColor(this.value)" title="직접 선택">
</div>
</section>
</div>
</div>
<!-- RIGHT PANEL: 70% -->
<div style="flex:1; min-width:0;">
<section class="bg-slate-900 rounded-2xl border border-slate-800 shadow-2xl flex flex-col overflow-hidden relative" style="height:100%;">
<!-- Preview Header -->
<div class="px-8 py-5 border-b border-slate-800 flex items-center justify-between bg-slate-900/50 backdrop-blur">
<div class="flex items-center gap-4">
<h3 id="previewTitle" class="text-sm font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/><path d="M21 12c-1.889 2.991-4.67 5-9 5s-7.111-2.009-9-5c1.889-2.991 4.67-5 9-5s7.111 2.009 9 5Z"/></svg>
방화셔터 도면 미리보기
</h3>
<div class="h-4 w-px bg-slate-700"></div>
<span id="scaleDisplay" class="text-[10px] font-mono text-slate-500">SCALE: 100%</span>
</div>
<div class="flex items-center gap-2">
<button class="fs-btn fs-btn-ghost" onclick="fsExportPng()">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
PNG
</button>
<button class="fs-btn fs-btn-ghost" onclick="fsExportDxf()">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
DXF
</button>
<button class="fs-btn fs-btn-ghost" onclick="fsExportJson()">JSON</button>
</div>
</div>
<!-- Canvas -->
<div id="canvasViewport" class="flex-1 bg-[#050507] relative overflow-hidden cursor-grab active:cursor-grabbing">
<div id="svgContainer" class="absolute inset-0 flex items-center justify-center select-none"></div>
<div id="threeDContainer" class="absolute inset-0 hidden"></div>
<!-- Zoom Controls -->
<div class="absolute bottom-6 right-6 flex items-center gap-2 z-10">
<button id="zoomInBtn" class="w-10 h-10 rounded-xl bg-slate-800/80 border border-slate-700 text-white font-black text-lg hover:bg-slate-700 transition-all">+</button>
<button id="zoomOutBtn" class="w-10 h-10 rounded-xl bg-slate-800/80 border border-slate-700 text-white font-black text-lg hover:bg-slate-700 transition-all"></button>
<button id="zoomResetBtn" class="px-4 h-10 rounded-xl bg-slate-800/80 border border-slate-700 text-slate-400 font-black text-xs hover:bg-slate-700 transition-all">리셋</button>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
<!-- Three.js -->
<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>
@push('scripts')
<script>
(function(){
'use strict';
const $ = id => document.getElementById(id);
// ============================
// STATE
// ============================
const S = {
tab: 'Settings',
productType: 'screen',
openWidth: 2000,
openHeight: 3000,
quantity: 1,
// Guide Rail
gr: { width:70, depth:120, thickness:1.55, lip:10, flange:26, sideWall:80, backWall:67, trimThick:1.2, sealThick:0.8, sealDepth:40, slatThick:0.8, anchorSpacing:500, viewMode:'cross', showDim:true, showSeal:true },
// Shutter Box
sb: { width:2280, height:380, depth:500, thickness:1.6, shaftDia:80, bracketW:10, motorSide:'right', viewMode:'front', showShaft:true, showSlatRoll:true, showMotor:true, showBrake:true, showSpring:true },
// 3D
td: { shutterPos:100, caseOpacity:0.3, lightPreset:'default', bgColor:'#ffffff', show:{ case:true, shaft:true, motor:true, rails:true, slats:true, bottomBar:true, wall:false, slatRoll:true } },
// Wall (벽체)
wall: { wing:600, thick:600, topMargin:300, color:'#a1887f', opacity:30 },
// View
view: { scale:1, offset:{x:0,y:0}, dragging:false, lastMouse:{x:0,y:0} },
// Presets
presets: JSON.parse(localStorage.getItem('fs_presets')||'[]'),
};
// Product defaults
const PRODUCTS = {
steel: { marginW:110, marginH:350, weightFactor:25, gr:{width:120,depth:75,thickness:1.55,lip:15}, slatThick:1.6,
sb:{height:550, depth:650, frontH:410, bottomOpen:75, shaftDia:120},
bk:{nmH:320, nmD:320, mtH:320, mtD:530, thick:18, sprocketR:215, motorSpR:40, motorOffset:160} },
screen: { marginW:140, marginH:350, weightFactor:2,
gr:{width:70, depth:120, thickness:1.55, lip:10, flange:26, sideWall:80, backWall:67, trimThick:1.2},
slatThick:0.8,
sb:{height:380, depth:500, frontH:240, bottomOpen:75, shaftDia:80},
bk:{nmH:180, nmD:180, mtH:180, mtD:380, thick:18, sprocketR:70, motorSpR:30, motorOffset:0} },
};
const MOTORS = [{max:150,spec:'150K',inch:4},{max:300,spec:'300K',inch:4},{max:500,spec:'500K',inch:5},{max:750,spec:'750K',inch:5},{max:1000,spec:'1000K',inch:6},{max:1500,spec:'1500K',inch:6}];
const RAIL_LENGTHS = [2438, 3305, 4430];
// 3D objects
let scene, camera, renderer, controls, animId;
let meshes = {};
let fs3dIsolated = null; // 현재 단품 보기 중인 키
const meshLabels = {
case: '셔터박스', shaft: '샤프트 ASSY', motor: '모터/체인',
rails: '가이드레일', slats: '슬랫 커튼', slatRoll: '감긴 슬랫',
bottomBar: '하부바', wall: '벽체',
};
// ============================
// TAB SWITCHING
// ============================
window.fsSwitch = function(tab) {
S.tab = tab;
const tabs = ['GuideRail','ShutterBox','3D'];
const tabBtnIds = ['tabGuideRail','tabShutterBox','tab3D'];
const ctrlIds = ['ctrlGuideRail','ctrlShutterBox','ctrl3D'];
tabs.forEach((t,i) => {
const btn = $(tabBtnIds[i]);
const ctrl = $(ctrlIds[i]);
if (t === tab) {
btn.className = 'px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center bg-blue-600 text-white';
if (ctrl) ctrl.classList.remove('hidden');
} else {
btn.className = 'px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800';
if (ctrl) ctrl.classList.add('hidden');
}
});
const svgC = $('svgContainer');
const tdC = $('threeDContainer');
if (tab === '3D') {
svgC.classList.add('hidden');
tdC.classList.remove('hidden');
fs3dInit();
fs3dBuild();
} else {
svgC.classList.remove('hidden');
tdC.classList.add('hidden');
fsRender();
}
};
// ============================
// CALCULATIONS
// ============================
window.fsCalc = function() {
undoSaveState(); // Ctrl+Z용 스냅샷 저장
S.productType = $('productType').value;
S.openWidth = Number($('openWidth').value) || 2000;
S.openHeight = Number($('openHeight').value) || 3000;
S.quantity = Number($('quantity').value) || 1;
const p = PRODUCTS[S.productType];
const W1 = S.openWidth + p.marginW;
const H1 = S.openHeight + p.marginH;
const area = (W1 * H1) / 1000000;
const weight = area * p.weightFactor;
const motor = MOTORS.find(m => weight <= m.max) || MOTORS[MOTORS.length-1];
// Guide rail height = 오픈H + 100mm (바닥~셔터박스 하단 결합부)
const guideRailH = S.openHeight + 100;
const bestCombo = `${guideRailH.toLocaleString()}mm × 2`;
$('calcW1').textContent = W1.toLocaleString();
$('calcH1').textContent = H1.toLocaleString();
$('calcArea').textContent = area.toFixed(2);
$('calcWeight').textContent = weight.toFixed(1);
$('calcMotor').textContent = motor.spec;
$('calcRailCombo').textContent = bestCombo;
// Auto-update box width
$('sbWidth').value = W1;
S.sb.width = W1;
// 3D 탭 활성 시 자동 갱신
if (S.tab === '3D') fs3dBuild();
};
window.fsOnProductType = function() {
undoSaveState(); // Ctrl+Z용 스냅샷 저장
const type = $('productType').value;
S.productType = type;
const p = PRODUCTS[type];
// Update guide rail defaults
$('grWidth').value = p.gr.width; S.gr.width = p.gr.width;
$('grDepth').value = p.gr.depth; S.gr.depth = p.gr.depth;
$('grThickness').value = p.gr.thickness; S.gr.thickness = p.gr.thickness;
$('grLip').value = p.gr.lip; S.gr.lip = p.gr.lip;
$('grSlatThick').value = p.slatThick; S.gr.slatThick = p.slatThick;
// 스크린형 전용 속성
S.gr.flange = p.gr.flange || 0;
S.gr.sideWall = p.gr.sideWall || 0;
S.gr.backWall = p.gr.backWall || 0;
S.gr.trimThick = p.gr.trimThick || 0;
// Update shutter box defaults
$('sbHeight').value = p.sb.height; S.sb.height = p.sb.height;
$('sbDepth').value = p.sb.depth; S.sb.depth = p.sb.depth;
$('sbShaftDia').value = p.sb.shaftDia; S.sb.shaftDia = p.sb.shaftDia;
// Filter models
const sel = $('productModel');
for (const opt of sel.options) {
const isSteel = opt.value.startsWith('KF');
const isScreen = opt.value.startsWith('KS');
opt.hidden = (type === 'steel' && isScreen) || (type === 'screen' && isSteel);
}
sel.value = type === 'steel' ? 'KFS01' : 'KSS01';
fsCalc();
if (S.tab !== '3D') fsRender();
};
window.fsOnModelChange = function() { fsCalc(); };
// ============================
// UNDO (Ctrl+Z) — 상태 스냅샷 기반
// ============================
const undoStack = [];
const UNDO_MAX = 50;
let undoLastTime = 0;
function undoSaveState() {
// 100ms 이내 연속 호출 무시 (슬라이더 드래그 등)
const now = Date.now();
if (now - undoLastTime < 100) return;
undoLastTime = now;
// S에서 undo에 필요한 입력값만 스냅샷
undoStack.push(JSON.stringify({
productType: S.productType,
openWidth: S.openWidth, openHeight: S.openHeight, quantity: S.quantity,
gr: { ...S.gr }, sb: { ...S.sb },
td: { shutterPos: S.td.shutterPos, caseOpacity: S.td.caseOpacity,
show: { ...S.td.show } },
wall: { ...S.wall },
}));
if (undoStack.length > UNDO_MAX) undoStack.shift();
}
function undoRestore() {
if (undoStack.length === 0) return;
const snap = JSON.parse(undoStack.pop());
// 상태 복원
S.productType = snap.productType;
S.openWidth = snap.openWidth; S.openHeight = snap.openHeight; S.quantity = snap.quantity;
Object.assign(S.gr, snap.gr);
Object.assign(S.sb, snap.sb);
S.td.shutterPos = snap.td.shutterPos;
S.td.caseOpacity = snap.td.caseOpacity;
Object.assign(S.td.show, snap.td.show);
Object.assign(S.wall, snap.wall);
// UI 입력 필드 동기화
$('productType').value = S.productType;
$('openWidth').value = S.openWidth;
$('openHeight').value = S.openHeight;
$('quantity').value = S.quantity;
$('grWidth').value = S.gr.width;
$('grDepth').value = S.gr.depth;
$('grThickness').value = S.gr.thickness;
$('grLip').value = S.gr.lip;
$('grSealThick').value = S.gr.sealThick;
$('grSealDepth').value = S.gr.sealDepth;
$('grSlatThick').value = S.gr.slatThick;
$('grAnchorSpacing').value = S.gr.anchorSpacing;
$('sbWidth').value = S.sb.width;
$('sbHeight').value = S.sb.height;
$('sbDepth').value = S.sb.depth;
$('sbThickness').value = S.sb.thickness;
$('sbShaftDia').value = S.sb.shaftDia;
$('sbBracketW').value = S.sb.bracketW;
$('sbMotorSide').value = S.sb.motorSide;
// 재계산 + 렌더링
fsCalc();
if (S.tab === '3D') fs3dBuild();
else fsRender();
}
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undoRestore();
}
});
// ============================
// READ STATE FROM INPUTS
// ============================
function readGr() {
S.gr.width = Number($('grWidth').value);
S.gr.depth = Number($('grDepth').value);
S.gr.thickness = Number($('grThickness').value);
S.gr.lip = Number($('grLip').value);
S.gr.sealThick = Number($('grSealThick').value);
S.gr.sealDepth = Number($('grSealDepth').value);
S.gr.slatThick = Number($('grSlatThick').value);
S.gr.anchorSpacing = Number($('grAnchorSpacing').value);
}
function readSb() {
S.sb.width = Number($('sbWidth').value);
S.sb.height = Number($('sbHeight').value);
S.sb.depth = Number($('sbDepth').value);
S.sb.thickness = Number($('sbThickness').value);
S.sb.shaftDia = Number($('sbShaftDia').value);
S.sb.bracketW = Number($('sbBracketW').value);
S.sb.motorSide = $('sbMotorSide').value;
}
// ============================
// SVG DISPLAY HELPER
// ============================
function displaySvg(svg) {
const c = $('svgContainer');
c.innerHTML = '';
const div = document.createElement('div');
div.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;';
div.style.transform = `scale(${S.view.scale}) translate(${S.view.offset.x / S.view.scale}px, ${S.view.offset.y / S.view.scale}px)`;
div.style.transformOrigin = 'center center';
div.innerHTML = svg;
c.appendChild(div);
$('scaleDisplay').textContent = `SCALE: ${Math.round(S.view.scale * 100)}%`;
}
// ============================
// RENDER DISPATCHER
// ============================
window.fsRender = function() {
undoSaveState(); // Ctrl+Z용 스냅샷 저장
if (S.tab === 'Settings') renderSettingsPreview();
else if (S.tab === 'GuideRail') { readGr(); renderGuideRail(); }
else if (S.tab === 'ShutterBox') { readSb(); renderShutterBox(); }
};
// ============================
// SETTINGS PREVIEW (assembly overview SVG)
// ============================
function renderSettingsPreview() {
const W = S.openWidth, H = S.openHeight;
const p = PRODUCTS[S.productType];
const W1 = W + p.marginW;
const H1 = H + p.marginH;
const sc = Math.min(700 / W1, 750 / H1) * 0.7;
const sw = W1 * sc, sh = H1 * sc;
const ox = 80, oy = 60;
const boxH = 60, railW = 12;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sw + 160} ${sh + 160}" style="max-width:100%;max-height:100%;">
<defs>
<pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="8" stroke="#6b5b4f" stroke-width="1"/>
</pattern>
</defs>
<!-- Wall (hatched) -->
<rect x="${ox - 20}" y="${oy}" width="20" height="${sh}" fill="url(#hatch)" stroke="#8b7355" stroke-width="1"/>
<rect x="${ox + sw}" y="${oy}" width="20" height="${sh}" fill="url(#hatch)" stroke="#8b7355" stroke-width="1"/>
<rect x="${ox - 20}" y="${oy - 20}" width="${sw + 40}" height="20" fill="url(#hatch)" stroke="#8b7355" stroke-width="1"/>
<!-- Shutter Box -->
<rect x="${ox}" y="${oy}" width="${sw}" height="${boxH}" fill="#374151" stroke="#94a3b8" stroke-width="2" rx="3"/>
<text x="${ox + sw/2}" y="${oy + boxH/2 + 4}" fill="#60a5fa" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 (CASE)</text>
<!-- Shaft circle -->
<circle cx="${ox + sw/2}" cy="${oy + boxH/2}" r="18" fill="none" stroke="#64748b" stroke-width="2" stroke-dasharray="4 2"/>
<!-- Guide Rails -->
<rect x="${ox}" y="${oy + boxH}" width="${railW}" height="${sh - boxH}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<rect x="${ox + sw - railW}" y="${oy + boxH}" width="${railW}" height="${sh - boxH}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<text x="${ox + railW/2}" y="${oy + boxH + (sh-boxH)/2}" fill="#a78bfa" font-size="9" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + railW/2}, ${oy + boxH + (sh-boxH)/2})" font-family="Pretendard">가이드레일</text>
<text x="${ox + sw - railW/2}" y="${oy + boxH + (sh-boxH)/2}" fill="#a78bfa" font-size="9" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + sw - railW/2}, ${oy + boxH + (sh-boxH)/2})" font-family="Pretendard">가이드레일</text>
<!-- Slat Curtain -->
${Array.from({length: Math.floor((sh - boxH - 20) / 12)}, (_, i) => {
const y = oy + boxH + 10 + i * 12;
return `<line x1="${ox + railW + 2}" y1="${y}" x2="${ox + sw - railW - 2}" y2="${y}" stroke="${S.productType === 'steel' ? '#9ca3af' : '#c084fc'}" stroke-width="1.5" opacity="0.5"/>`;
}).join('')}
<!-- Bottom Bar -->
<rect x="${ox + railW}" y="${oy + sh - 14}" width="${sw - railW*2}" height="14" fill="#f59e0b" stroke="#d97706" stroke-width="1.5" rx="2"/>
<text x="${ox + sw/2}" y="${oy + sh - 4}" fill="#1e293b" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard">하장바</text>
<!-- Floor -->
<line x1="${ox - 20}" y1="${oy + sh + 2}" x2="${ox + sw + 20}" y2="${oy + sh + 2}" stroke="#475569" stroke-width="2"/>
<!-- Dimension lines -->
<!-- Width -->
<line x1="${ox}" y1="${oy + sh + 25}" x2="${ox + sw}" y2="${oy + sh + 25}" stroke="#3b82f6" stroke-width="1" marker-start="url(#arrowL)" marker-end="url(#arrowR)"/>
<text x="${ox + sw/2}" y="${oy + sh + 42}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">W0 = ${W.toLocaleString()} mm</text>
<!-- Height -->
<line x1="${ox + sw + 35}" y1="${oy + boxH}" x2="${ox + sw + 35}" y2="${oy + sh}" stroke="#3b82f6" stroke-width="1"/>
<text x="${ox + sw + 50}" y="${oy + (sh + boxH)/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">H0 = ${H.toLocaleString()} mm</text>
<!-- Type label -->
<text x="${ox + sw/2}" y="${oy + boxH + (sh - boxH)/2 + 4}" fill="${S.productType === 'steel' ? '#94a3b8' : '#c084fc'}" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard" opacity="0.4">${S.productType === 'steel' ? '강판 슬랫' : '스크린 원단'}</text>
</svg>`;
displaySvg(svg);
}
// ============================
// GUIDE RAIL SVG RENDERER
// ============================
function renderGuideRail() {
if (S.gr.viewMode === 'cross') renderGrCross();
else renderGrFront();
}
function renderGrCross() {
const g = S.gr;
// ====== 스크린형: 실제 조립 구조 평면도 (5개 부재) ======
// 좌→우: 방화벽 → ③벽연형C → ④벽연형D → ②본체(C채널) → ⑤마감재(SUS커버) → 개구부
// 세로 = 채널 폭 70mm
if (S.productType === 'screen') {
const sc = 4; // px per mm
const t2 = g.thickness * sc; // 본체 EGI 1.55T
const t5 = (g.trimThick || 1.2) * sc; // 마감재 SUS 1.2T
const sealT = g.sealThick * sc;
const slatT = Math.max(g.slatThick * sc, 2);
// ── ② 본체 치수 (절곡: 10-26-80-67-80-26-10) ──
const bLip = g.lip * sc; // 10mm 립
const bFl = g.flange * sc; // 26mm 플랜지
const bSw = g.sideWall * sc; // 80mm 사이드월
const bBw = g.backWall * sc; // 67mm 백월
const bOuterW = bBw + 2 * t2; // 70mm 외폭 (세로)
const bSlot = bBw - 2 * bFl; // 15mm 슬롯 개구
// ── ③ 벽연형-C 치수 (절곡: 30-45-30) ──
const c3Lip = 30 * sc; // 립 30mm
const c3Body = 45 * sc; // 몸체 45mm (백월에 밀착)
// ── ④ 벽연형-D 치수 (절곡: 11-23-40-23-11) ──
const c4a = 11 * sc, c4b = 23 * sc, c4c = 40 * sc;
// ── ⑤ 마감재 치수 (절곡: 10-11-110-30-15-15-15) ──
const m5a = 10 * sc; // 벽측 립
const m5b = 11 * sc; // 벽측 절곡면
const m5c = 110 * sc; // 사이드월 커버 (긴 면)
const m5d = 30 * sc; // A각 절곡 후 면
const m5e = 15 * sc; // 끝단 1
const m5f = 15 * sc; // 끝단 2
const m5g = 15 * sc; // 끝단 3
// ── 레이아웃 (좌→우 배치) ──
const pad = 80, wallW = 35;
// ③ 벽연형-C의 가장 왼쪽: 벽면에서 립 30mm 돌출
const bkTotalD = c3Lip; // 벽→본체 백월 사이 깊이
const bx = pad + wallW + 10 + bkTotalD; // 본체 백월 외면 X
// 본체 사이드월 끝 (립 시작점)
const swEndX = bx + t2 + bSw; // 백월두께 + 사이드월
// 립 끝
const lipEndX = swEndX + bLip;
const by = pad; // 본체 상단 Y
const svgW = lipEndX + pad + 120;
const svgH = bOuterW + pad * 2 + 70;
// 색상 정의
const cBody = '#64748b'; // ② 본체 (EGI)
const cBk3 = '#78716c'; // ③ 벽연형-C
const cBk4 = '#92400e'; // ④ 벽연형-D
const cTrim = '#a1a1aa'; // ⑤ 마감재 (SUS)
const cClip = '#8b5cf6'; // ① 클립
const ms = '#94a3b8'; // 스트로크
const mw = 0.8;
// ── 채널 내부 배경 ──
const interiorSvg = `<rect x="${bx+t2}" y="${by+t2}" width="${bSw-t2}" height="${bOuterW-2*t2}" fill="#0f172a"/>`;
// ── ② 본체 C채널 (절곡: lip-flange-sideWall-backWall-sideWall-flange-lip) ──
const bodySvg = [
// 백월 (수직, 좌측)
`<rect x="${bx}" y="${by}" width="${t2}" height="${bOuterW}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 사이드월 (백월→개구부 방향, 80mm)
`<rect x="${bx}" y="${by}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 사이드월
`<rect x="${bx}" y="${by+bOuterW-t2}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 플랜지 (사이드월 끝에서 안쪽/중앙으로 26mm 절곡)
`<rect x="${swEndX-t2}" y="${by+t2}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 플랜지
`<rect x="${swEndX-t2}" y="${by+bOuterW-bFl}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 립 (플랜지 끝에서 개구부 방향으로 10mm)
`<rect x="${swEndX}" y="${by+bFl-t2}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 립
`<rect x="${swEndX}" y="${by+bOuterW-bFl}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
].join('\n');
// ── ③ 벽연형-C (절곡: 30-45-30, 백월 뒤 C브라켓) ──
// C 개구부가 ② 본체쪽(우측)을 향함: 몸체(벽쪽) → 립(본체쪽)
const c3CenterY = by + bOuterW / 2;
const c3Y = c3CenterY - c3Body / 2;
const c3BodyX = bx - c3Lip; // 몸체 X (벽쪽, 좌측 끝)
const c3LipEnd = bx; // 립 끝 = ② 백월 외면
const bk3Svg = [
// 몸체 (세로 45mm, 벽쪽)
`<rect x="${c3BodyX}" y="${c3Y}" width="${t2}" height="${c3Body}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 립 (몸체→본체 방향 30mm)
`<rect x="${c3BodyX}" y="${c3Y}" width="${c3Lip}" height="${t2}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 립 (몸체→본체 방향 30mm)
`<rect x="${c3BodyX}" y="${c3Y+c3Body-t2}" width="${c3Lip}" height="${t2}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
].join('\n');
// ── ④ 벽연형-D (절곡: 11-23-40-23-11, ③ 내부에 중첩) ──
// ③ 내부에 배치, C 개구부가 ② 본체쪽(우측)을 향함
// 몸체(40) 벽쪽, 사이드(23) 본체 방향, 립(11) 안쪽 절곡
const c4CenterY = c3CenterY;
const c4Y = c4CenterY - c4c / 2;
const c4BodyX = c3BodyX + t2 + 2; // ③ 몸체 바로 안쪽
const c4SideEnd = c4BodyX + t2 + c4b; // 사이드 끝 (본체쪽)
const bk4Svg = [
// 몸체 (세로 40mm, ③ 안쪽 벽쪽)
`<rect x="${c4BodyX}" y="${c4Y}" width="${t2}" height="${c4c}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 상단 사이드 (23mm, 본체 방향)
`<rect x="${c4BodyX+t2}" y="${c4Y}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 하단 사이드
`<rect x="${c4BodyX+t2}" y="${c4Y+c4c-t2}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 상단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideEnd-t2}" y="${c4Y+t2}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 하단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideEnd-t2}" y="${c4Y+c4c-t2-c4a}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
].join('\n');
// ── ⑤ 마감재 SUS 1.2T × 2 (절곡: 10-11-110-30-15-15-15) ──
// 평면도(위에서 봄): 접힌 후 외곽 프레임 형태로 보임
// 좌측: ③ 좌단과 정렬, 우측: 본체 립 끝 바깥으로 감싸는 형태
const trimL = bx - c3Lip; // 좌측 끝 (③ 좌단 정렬)
const trimR = lipEndX + t5; // 우측 끝 (립 바깥)
const trimX2 = trimR; // 치수선용
// 상단 마감재: 수평(좌→우) + 우측 리턴(안쪽으로 플랜지 높이만큼)
const trim5Svg = `
<polyline points="${trimL},${by-t5} ${trimR},${by-t5} ${trimR},${by+bFl}"
fill="none" stroke="${cTrim}" stroke-width="${t5}" stroke-linejoin="miter" opacity="0.7"/>
<polyline points="${trimL},${by+bOuterW+t5} ${trimR},${by+bOuterW+t5} ${trimR},${by+bOuterW-bFl}"
fill="none" stroke="${cTrim}" stroke-width="${t5}" stroke-linejoin="miter" opacity="0.7"/>`;
// ── ① 클립 (채널 내부, 개구부 근처 L형) ──
const clipArmLen = 10 * sc;
const clipLegLen = 15 * sc;
const clipT = Math.max(t2, 2);
const clipX1 = swEndX - t2 - 2; // 플랜지 내면 근처
const clipY1 = by + t2 + 2;
const clipY2 = by + bOuterW - t2 - 2;
const clipSvg = `
<path d="M${clipX1},${clipY1} L${clipX1},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1} Z" fill="${cClip}" opacity="0.4"/>
<path d="M${clipX1},${clipY2} L${clipX1},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2} Z" fill="${cClip}" opacity="0.4"/>`;
// ── 연기차단재 ──
let sealSvg = '';
if (g.showSeal) {
const slatCenterY = by + bOuterW / 2;
const sealLen = bLip * 0.8;
const sealPosX = swEndX + 2;
const sealH = Math.max(sealT * 1.5, 6);
sealSvg += `<rect x="${sealPosX}" y="${slatCenterY - slatT/2 - sealH}" width="${sealLen}" height="${sealH}" fill="#f97316" opacity="0.7" rx="1"/>`;
sealSvg += `<rect x="${sealPosX}" y="${slatCenterY + slatT/2}" width="${sealLen}" height="${sealH}" fill="#f97316" opacity="0.7" rx="1"/>`;
if (g.showDim) {
sealSvg += `<text x="${sealPosX + sealLen/2}" y="${slatCenterY - slatT/2 - sealH - 4}" fill="#f97316" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard">차단재</text>`;
}
}
// ── 슬랫 ──
const slatY = by + bOuterW / 2 - slatT / 2;
const slatX1 = bx + t2 + 2;
const slatX2 = lipEndX - 2;
// ── 방화벽 ──
const wallX = pad;
// ── 치수선 ──
let dimLines = '';
if (g.showDim) {
const totalLeft = bx - c3Lip;
const totalRight = trimX2;
// 깊이 120mm (하단)
dimLines += `<line x1="${totalLeft}" y1="${by+bOuterW+35}" x2="${totalRight}" y2="${by+bOuterW+35}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${totalLeft}" y1="${by+bOuterW+30}" x2="${totalLeft}" y2="${by+bOuterW+40}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${totalRight}" y1="${by+bOuterW+30}" x2="${totalRight}" y2="${by+bOuterW+40}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${(totalLeft+totalRight)/2}" y="${by+bOuterW+52}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.depth} mm</text>`;
// 폭 70mm (우측)
dimLines += `<line x1="${totalRight+12}" y1="${by}" x2="${totalRight+12}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${totalRight+7}" y1="${by}" x2="${totalRight+17}" y2="${by}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${totalRight+7}" y1="${by+bOuterW}" x2="${totalRight+17}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${totalRight+22}" y="${by+bOuterW/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>`;
// 플랜지 26mm
dimLines += `<line x1="${lipEndX+3}" y1="${by}" x2="${lipEndX+3}" y2="${by+bFl}" stroke="#94a3b8" stroke-width="0.8"/>`;
dimLines += `<text x="${lipEndX+8}" y="${by+bFl/2+3}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">FL ${g.flange}</text>`;
// 슬롯 개구
dimLines += `<line x1="${lipEndX+1}" y1="${by+bFl+3}" x2="${lipEndX+1}" y2="${by+bOuterW-bFl-3}" stroke="#22c55e" stroke-width="0.8"/>`;
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+3}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯 ${bSlot/sc}</text>`;
// 립 깊이
dimLines += `<line x1="${swEndX}" y1="${by+bFl+3}" x2="${lipEndX}" y2="${by+bFl+3}" stroke="#94a3b8" stroke-width="0.8"/>`;
dimLines += `<text x="${(swEndX+lipEndX)/2}" y="${by+bFl+14}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">립 ${g.lip}</text>`;
// 두께
dimLines += `<text x="${bx+t2}" y="${by+bOuterW+65}" fill="#94a3b8" font-size="9" font-weight="700" font-family="Pretendard">②t=${g.thickness} ⑤t=${g.trimThick||1.2}</text>`;
// 부재 번호 라벨
dimLines += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW/2+4}" fill="#475569" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">②</text>`;
dimLines += `<text x="${c3BodyX+c3Lip/2}" y="${c3CenterY+4}" fill="${cBk3}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">③</text>`;
dimLines += `<text x="${c4BodyX+c4b/2}" y="${c4CenterY+4}" fill="${cBk4}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>`;
dimLines += `<text x="${(trimX1+trimX2)/2}" y="${by-t5-m5b-5}" fill="${cTrim}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">⑤</text>`;
dimLines += `<text x="${clipX1-clipArmLen-2}" y="${clipY1+clipLegLen/2+3}" fill="${cClip}" font-size="8" font-weight="900" text-anchor="end" font-family="Pretendard">①</text>`;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<defs>
<pattern id="wallHatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="#8b7355" stroke-width="0.8"/>
</pattern>
</defs>
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 평면도 (Plan View) 스크린형</text>
<!-- 방화벽 -->
<rect x="${wallX}" y="${by-20}" width="${wallW}" height="${bOuterW+40}" fill="url(#wallHatch)" stroke="#8b7355" stroke-width="1" rx="2"/>
<text x="${wallX+wallW/2}" y="${by+bOuterW+35}" fill="#a1887f" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
<!-- 채널 내부 -->
${interiorSvg}
<!-- 마감재 SUS 커버 -->
${trim5Svg}
<!-- 벽연형-C -->
${bk3Svg}
<!-- 벽연형-D -->
${bk4Svg}
<!-- 본체 C채널 -->
${bodySvg}
<!-- 클립 -->
${clipSvg}
<!-- 연기차단재 -->
${sealSvg}
<!-- 슬랫 -->
<rect x="${slatX1}" y="${slatY}" width="${slatX2-slatX1}" height="${slatT}" fill="#c084fc" opacity="0.8" rx="1"/>
${g.showDim ? `<text x="${(slatX1+slatX2)/2}" y="${slatY-4}" fill="#c084fc" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : ''}
<!-- 개구부 방향 -->
<text x="${lipEndX+15}" y="${by+bOuterW/2+4}" fill="#22c55e" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard"> 개구부</text>
${dimLines}
<text x="${(bx+lipEndX)/2}" y="${by+bOuterW+65}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL 스크린형 (5부재 조립)</text>
</svg>`;
displaySvg(svg);
return;
}
// ====== 철재형: 기존 C채널 단면도 ======
const sc = 6; // px per mm
const w = g.width * sc, d = g.depth * sc, t = g.thickness * sc, lip = g.lip * sc;
const sealT = g.sealThick * sc, sealD = g.sealDepth * sc, slatT = g.slatThick * sc;
const pad = 120;
const svgW = w + pad * 2 + 200, svgH = d + pad * 2 + 100;
const ox = pad + 100, oy = pad;
// C-channel path (looking from top)
const outer = `M${ox},${oy} L${ox+w},${oy} L${ox+w},${oy+lip} L${ox+w-t},${oy+lip} L${ox+w-t},${oy+t} L${ox+t},${oy+t} L${ox+t},${oy+lip} L${ox},${oy+lip} Z`;
const bottom = `M${ox},${oy+d-lip} L${ox+t},${oy+d-lip} L${ox+t},${oy+d-t} L${ox+w-t},${oy+d-t} L${ox+w-t},${oy+d-lip} L${ox+w},${oy+d-lip} L${ox+w},${oy+d} L${ox},${oy+d} Z`;
const leftWall = `M${ox},${oy+lip} L${ox+t},${oy+lip} L${ox+t},${oy+d-lip} L${ox},${oy+d-lip} Z`;
// Seal rectangles
const sealY1 = oy + lip;
const sealY2 = oy + d - lip - sealD;
const sealX = ox + t;
// Slat position (center of channel opening)
const slatX = ox + w/2 - slatT/2;
const slatY1 = oy + lip + 5;
const slatY2 = oy + d - lip - 5;
// Wall (hatched area to the right)
const wallX = ox + w + 10;
let dimLines = '';
if (g.showDim) {
// Width dimension (top)
dimLines += `<line x1="${ox}" y1="${oy-30}" x2="${ox+w}" y2="${oy-30}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${ox}" y1="${oy-35}" x2="${ox}" y2="${oy-5}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${ox+w}" y1="${oy-35}" x2="${ox+w}" y2="${oy-5}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${ox+w/2}" y="${oy-40}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.width} mm</text>`;
// Depth dimension (left)
dimLines += `<line x1="${ox-30}" y1="${oy}" x2="${ox-30}" y2="${oy+d}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${ox-35}" y1="${oy}" x2="${ox-5}" y2="${oy}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${ox-35}" y1="${oy+d}" x2="${ox-5}" y2="${oy+d}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${ox-40}" y="${oy+d/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="end" font-family="Pretendard">${g.depth} mm</text>`;
// Thickness annotation
dimLines += `<text x="${ox+t/2}" y="${oy+d+25}" fill="#94a3b8" font-size="10" font-weight="700" text-anchor="middle" font-family="Pretendard">t=${g.thickness}</text>`;
// Lip dimension
dimLines += `<text x="${ox+w+15}" y="${oy+lip/2+4}" fill="#94a3b8" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">립 ${g.lip}</text>`;
}
let sealSvg = '';
if (g.showSeal) {
// Upper seal
sealSvg += `<rect x="${sealX}" y="${sealY1}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
// Lower seal
sealSvg += `<rect x="${sealX}" y="${sealY2}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
// Seal on right side
sealSvg += `<rect x="${ox+w-t-sealT}" y="${sealY1}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
sealSvg += `<rect x="${ox+w-t-sealT}" y="${sealY2}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
if (g.showDim) {
sealSvg += `<text x="${sealX + sealT + 5}" y="${sealY1 + sealD/2 + 3}" fill="#f97316" font-size="9" font-weight="700" font-family="Pretendard">연기차단재</text>`;
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<defs>
<pattern id="wallHatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="#8b7355" stroke-width="0.8"/>
</pattern>
</defs>
<!-- Title -->
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 평면도 (Plan View)</text>
<!-- Wall -->
<rect x="${wallX}" y="${oy - 20}" width="60" height="${d + 40}" fill="url(#wallHatch)" stroke="#8b7355" stroke-width="1" rx="2"/>
<text x="${wallX + 30}" y="${oy + d + 35}" fill="#a1887f" font-size="10" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
<!-- C-Channel Body -->
<path d="${outer}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<path d="${bottom}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<path d="${leftWall}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<!-- Right wall of channel -->
<rect x="${ox+w-t}" y="${oy+lip}" width="${t}" height="${d - lip*2}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<!-- Channel interior (empty space) -->
<rect x="${ox+t}" y="${oy+t}" width="${w-t*2}" height="${d-t*2}" fill="#0f172a" stroke="none"/>
<!-- Re-draw lips over interior -->
<rect x="${ox+t}" y="${oy+t}" width="${w-t*2}" height="${lip - t}" fill="#0f172a"/>
<rect x="${ox+t}" y="${oy+d-lip}" width="${w-t*2}" height="${lip - t}" fill="#0f172a"/>
<!-- Seals -->
${sealSvg}
<!-- Slat Edge -->
<rect x="${slatX}" y="${slatY1}" width="${slatT}" height="${slatY2 - slatY1}" fill="#60a5fa" opacity="0.8" rx="1"/>
${g.showDim ? `<text x="${slatX + slatT + 8}" y="${(slatY1+slatY2)/2 + 3}" fill="#60a5fa" font-size="9" font-weight="700" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : ''}
<!-- Dimension Lines -->
${dimLines}
<!-- Labels -->
<text x="${ox + w/2}" y="${oy + d + 45}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL BODY (C-CHANNEL) 철재슬라트</text>
</svg>`;
displaySvg(svg);
}
function renderGrFront() {
const g = S.gr;
const H = S.openHeight || 3000;
const sc = Math.min(0.2, 700 / H);
const rw = g.width * 3, rh = H * sc;
const pad = 80;
const svgW = rw + pad * 2 + 100, svgH = rh + pad * 2 + 60;
const ox = pad + 50, oy = pad;
// Anchor bolt positions
const anchorCount = Math.floor(H / g.anchorSpacing);
const anchors = Array.from({length: anchorCount}, (_, i) => oy + (i + 1) * g.anchorSpacing * sc);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 정면도 (Front View)</text>
<!-- Rail body -->
<rect x="${ox}" y="${oy}" width="${rw}" height="${rh}" fill="#64748b" stroke="#94a3b8" stroke-width="2" rx="2"/>
<!-- Channel groove (center line) -->
<line x1="${ox + rw/2}" y1="${oy}" x2="${ox + rw/2}" y2="${oy + rh}" stroke="#334155" stroke-width="2" stroke-dasharray="8 4"/>
<!-- Anchor bolts -->
${anchors.map(ay => `
<circle cx="${ox + rw/2}" cy="${ay}" r="5" fill="#ef4444" stroke="#dc2626" stroke-width="1.5"/>
<line x1="${ox + rw + 10}" y1="${ay}" x2="${ox + rw + 30}" y2="${ay}" stroke="#ef4444" stroke-width="0.8"/>
<text x="${ox + rw + 35}" y="${ay + 3}" fill="#ef4444" font-size="9" font-weight="700" font-family="Pretendard">앵커볼트</text>
`).join('')}
<!-- Height dimension -->
<line x1="${ox - 25}" y1="${oy}" x2="${ox - 25}" y2="${oy + rh}" stroke="#3b82f6" stroke-width="1"/>
<line x1="${ox - 30}" y1="${oy}" x2="${ox - 5}" y2="${oy}" stroke="#3b82f6" stroke-width="0.5"/>
<line x1="${ox - 30}" y1="${oy + rh}" x2="${ox - 5}" y2="${oy + rh}" stroke="#3b82f6" stroke-width="0.5"/>
<text x="${ox - 35}" y="${oy + rh/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="end" font-family="Pretendard">${H.toLocaleString()} mm</text>
<!-- Width dimension -->
<text x="${ox + rw/2}" y="${oy + rh + 30}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard"> ${g.width} mm | 앵커 간격 ${g.anchorSpacing} mm</text>
<!-- Label -->
<text x="${ox + rw/2}" y="${oy + rh + 50}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL FRONT VIEW</text>
</svg>`;
displaySvg(svg);
}
// ============================
// SHUTTER BOX SVG RENDERER
// ============================
function renderShutterBox() {
if (S.sb.viewMode === 'front') renderSbFront();
else renderSbSide();
}
function renderSbFront() {
const b = S.sb;
const sc = Math.min(700 / b.width, 600 / b.height);
const sw = b.width * sc, sh = b.height * sc;
const pad = 80;
const svgW = sw + pad * 2 + 60, svgH = sh + pad * 2 + 60;
const ox = pad, oy = pad;
const bracketW = Math.max(b.bracketW * sc, 3); // min 3px visible
const bracketX1 = ox; // left bracket
const bracketX2 = ox + sw - bracketW; // right bracket
const shaftCy = oy + sh / 2; // vertical center of box = bracket center
// Shaft: horizontal bar connecting both brackets
const shaftH = b.shaftDia * sc;
const shaftX = ox + bracketW;
const shaftW = sw - bracketW * 2;
const shaftY = shaftCy - shaftH / 2;
// Slat roll: horizontal bar wrapping shaft (larger)
const rollH = shaftH + 50 * sc;
const rollY = shaftCy - rollH / 2;
// Motor: positioned near specified side
const motorW = 80 * sc, motorH = 50 * sc;
const motorX = b.motorSide === 'right' ? bracketX2 - motorW - 5 : ox + bracketW + 5;
const motorY = shaftCy - motorH / 2;
// Brake: next to motor
const brakeW = 30 * sc, brakeH = 40 * sc;
const brakeX = b.motorSide === 'right' ? motorX - brakeW - 5 : motorX + motorW + 5;
// Spring: opposite side of motor
const springW = 60 * sc, springH = 20 * sc;
const springX = b.motorSide === 'right' ? ox + bracketW + 20 : bracketX2 - springW - 20;
const springY = shaftCy + shaftH / 2 + 10;
// Bracket label positioning (outside if too thin)
const brkThin = bracketW < 15;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 정면 단면도 (Front Cross-Section)</text>
<!-- Case outer -->
<rect x="${ox}" y="${oy}" width="${sw}" height="${sh}" fill="none" stroke="#94a3b8" stroke-width="2.5" rx="4"/>
<rect x="${ox}" y="${oy}" width="${sw}" height="${sh}" fill="#374151" opacity="0.3" rx="4"/>
<rect x="${ox+2}" y="${oy+2}" width="${sw-4}" height="${sh-4}" fill="none" stroke="#475569" stroke-width="1" stroke-dasharray="4 4" rx="3"/>
<!-- Brackets -->
<rect x="${bracketX1}" y="${oy + 3}" width="${bracketW}" height="${sh - 6}" fill="#8b5cf6" opacity="0.7" stroke="#7c3aed" stroke-width="1.5" rx="1"/>
<rect x="${bracketX2}" y="${oy + 3}" width="${bracketW}" height="${sh - 6}" fill="#8b5cf6" opacity="0.7" stroke="#7c3aed" stroke-width="1.5" rx="1"/>
${brkThin ? `
<text x="${bracketX1 - 5}" y="${oy + sh/2 + 3}" fill="#a78bfa" font-size="9" font-weight="700" text-anchor="end" font-family="Pretendard">브래킷</text>
<text x="${bracketX2 + bracketW + 5}" y="${oy + sh/2 + 3}" fill="#a78bfa" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">브래킷</text>
` : `
<text x="${ox + bracketW/2}" y="${oy + sh/2}" fill="white" font-size="${Math.max(8, bracketW * 0.12)}" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + bracketW/2}, ${oy + sh/2})" font-family="Pretendard">브래킷</text>
<text x="${ox + sw - bracketW/2}" y="${oy + sh/2}" fill="white" font-size="${Math.max(8, bracketW * 0.12)}" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + sw - bracketW/2}, ${oy + sh/2})" font-family="Pretendard">브래킷</text>
`}
<!-- Shaft: horizontal bar connecting brackets -->
${b.showShaft ? `
<rect x="${shaftX}" y="${shaftY}" width="${shaftW}" height="${shaftH}" fill="#475569" stroke="#64748b" stroke-width="1.5" rx="${shaftH/2}"/>
<text x="${ox + sw/2}" y="${shaftY - 6}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">샤프트 ${b.shaftDia}</text>
` : ''}
<!-- Slat Roll: horizontal bar wrapping shaft -->
${b.showSlatRoll ? `
<rect x="${shaftX}" y="${rollY}" width="${shaftW}" height="${rollH}" fill="none" stroke="#f59e0b" stroke-width="2.5" opacity="0.6" stroke-dasharray="6 3" rx="${rollH/2}"/>
<text x="${ox + sw/2}" y="${rollY - 6}" fill="#f59e0b" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">감긴 슬랫</text>
` : ''}
<!-- Motor -->
${b.showMotor ? `
<rect x="${motorX}" y="${motorY}" width="${motorW}" height="${motorH}" fill="#2563eb" opacity="0.7" stroke="#3b82f6" stroke-width="1.5" rx="3"/>
<text x="${motorX + motorW/2}" y="${motorY + motorH/2 + 4}" fill="white" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">모터+감속기</text>
` : ''}
<!-- Brake -->
${b.showBrake ? `
<rect x="${brakeX}" y="${motorY + 5}" width="${brakeW}" height="${brakeH}" fill="#dc2626" opacity="0.7" stroke="#ef4444" stroke-width="1.5" rx="2"/>
<text x="${brakeX + brakeW/2}" y="${motorY + 5 + brakeH/2 + 3}" fill="white" font-size="7" font-weight="900" text-anchor="middle" font-family="Pretendard">브레이크</text>
` : ''}
<!-- Spring -->
${b.showSpring ? `
<rect x="${springX}" y="${springY}" width="${springW}" height="${springH}" fill="#16a34a" opacity="0.6" stroke="#22c55e" stroke-width="1.5" rx="2"/>
<text x="${springX + springW/2}" y="${springY + springH/2 + 3}" fill="white" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard">밸런스 스프링</text>
` : ''}
<!-- Slat exit -->
<line x1="${ox + bracketW + 5}" y1="${oy + sh}" x2="${ox + sw - bracketW - 5}" y2="${oy + sh}" stroke="#f59e0b" stroke-width="3" stroke-dasharray="8 4"/>
<text x="${ox + sw/2}" y="${oy + sh + 18}" fill="#f59e0b" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard"> 슬랫 출구 </text>
<!-- Dimensions -->
<line x1="${ox}" y1="${oy + sh + 35}" x2="${ox + sw}" y2="${oy + sh + 35}" stroke="#3b82f6" stroke-width="1"/>
<text x="${ox + sw/2}" y="${oy + sh + 50}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">${b.width.toLocaleString()} mm</text>
<line x1="${ox + sw + 20}" y1="${oy}" x2="${ox + sw + 20}" y2="${oy + sh}" stroke="#3b82f6" stroke-width="1"/>
<text x="${ox + sw + 35}" y="${oy + sh/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">${b.height} mm</text>
<!-- Case label -->
<text x="${ox + sw/2}" y="${oy - 10}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 (CASE) t=${b.thickness}mm</text>
</svg>`;
displaySvg(svg);
}
function renderSbSide() {
const b = S.sb;
const sc = Math.min(500 / b.depth, 600 / b.height);
const sd = b.depth * sc, sh = b.height * sc;
const pad = 80;
const svgW = sd + pad * 2 + 100, svgH = sh + pad * 2 + 60;
const ox = pad + 50, oy = pad;
const shaftR = (b.shaftDia / 2) * sc;
const shaftCx = ox + sd / 2;
const shaftCy = oy + sh * 0.45;
const rollR = shaftR + 20 * sc;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 측면 단면도 (Side Cross-Section)</text>
<!-- Case outer -->
<rect x="${ox}" y="${oy}" width="${sd}" height="${sh}" fill="#374151" opacity="0.3" stroke="#94a3b8" stroke-width="2.5" rx="4"/>
<!-- Shaft -->
${b.showShaft ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${shaftR}" fill="#475569" stroke="#64748b" stroke-width="2"/>
<circle cx="${shaftCx}" cy="${shaftCy}" r="4" fill="#94a3b8"/>
` : ''}
<!-- Slat roll -->
${b.showSlatRoll ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${rollR}" fill="none" stroke="#f59e0b" stroke-width="3" opacity="0.5" stroke-dasharray="6 3"/>
${Array.from({length:5},(_,i) => {
const r = shaftR + (i+1) * 4 * sc;
return r < rollR ? `<circle cx="${shaftCx}" cy="${shaftCy}" r="${r}" fill="none" stroke="#f59e0b" stroke-width="0.5" opacity="0.3"/>` : '';
}).join('')}
` : ''}
<!-- Motor (side view = circle) -->
${b.showMotor ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${shaftR * 0.6}" fill="#2563eb" opacity="0.5" stroke="#3b82f6" stroke-width="1.5"/>
<text x="${shaftCx}" y="${shaftCy + 3}" fill="white" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard">M</text>
` : ''}
<!-- Slat exit -->
<rect x="${ox + sd * 0.2}" y="${oy + sh - 5}" width="${sd * 0.6}" height="5" fill="#f59e0b" opacity="0.6" rx="1"/>
<!-- Slat curtain going down -->
<line x1="${ox + sd * 0.5}" y1="${oy + sh}" x2="${ox + sd * 0.5}" y2="${oy + sh + 30}" stroke="#9ca3af" stroke-width="2" stroke-dasharray="4 2"/>
<text x="${ox + sd * 0.5 + 10}" y="${oy + sh + 20}" fill="#94a3b8" font-size="9" font-weight="700" font-family="Pretendard"> 슬랫</text>
<!-- Dimensions -->
<text x="${ox + sd/2}" y="${oy + sh + 50}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">깊이 ${b.depth} mm</text>
<text x="${ox + sd + 25}" y="${oy + sh/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">${b.height} mm</text>
</svg>`;
displaySvg(svg);
}
// ============================
// VIEW MODE SWITCHES
// ============================
window.fsGrView = function(mode) {
S.gr.viewMode = mode;
document.querySelectorAll('[data-grview]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.grview === mode);
btn.style.color = btn.dataset.grview === mode ? '#3b82f6' : '';
btn.style.borderColor = btn.dataset.grview === mode ? '#3b82f6' : '';
});
fsRender();
};
window.fsSbView = function(mode) {
S.sb.viewMode = mode;
document.querySelectorAll('[data-sbview]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.sbview === mode);
btn.style.color = btn.dataset.sbview === mode ? '#3b82f6' : '';
btn.style.borderColor = btn.dataset.sbview === mode ? '#3b82f6' : '';
});
fsRender();
};
window.fsToggle = function(el, key) {
el.classList.toggle('active');
const on = el.classList.contains('active');
if (key in S.gr) S.gr[key] = on;
if (key in S.sb) S.sb[key] = on;
fsRender();
};
// ============================
// THREE.JS 3D RENDERING
// ============================
function fs3dInit() {
const container = $('threeDContainer');
if (renderer) return; // Already initialized
scene = new THREE.Scene();
scene.background = new THREE.Color(S.td.bgColor);
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 50000);
camera.position.set(2000, 1500, 3000);
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.target.set(0, 0, 0);
// Lights
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
const dir1 = new THREE.DirectionalLight(0xffffff, 0.8);
dir1.position.set(2000, 3000, 2000);
dir1.castShadow = true;
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-2000, 1000, -1000);
const hemi = new THREE.HemisphereLight(0x87ceeb, 0x8b4513, 0.3);
scene.add(ambient, dir1, dir2, hemi);
// Grid
const grid = new THREE.GridHelper(5000, 50, 0x334155, 0x1e293b);
grid.position.y = 0;
scene.add(grid);
function animate() {
animId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize (use ResizeObserver for flex layout)
const ro = new ResizeObserver(() => {
if (container.clientWidth && container.clientHeight) {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
});
ro.observe(container);
// === 우클릭 컨텍스트 메뉴 (단품 보기 / 전체 보기) ===
const ctxMenu = document.createElement('div');
ctxMenu.className = 'fs-ctx-menu';
document.body.appendChild(ctxMenu);
// 단품 보기 상태 배지
container.style.position = 'relative';
const isoBadge = document.createElement('div');
isoBadge.className = 'fs-iso-badge';
isoBadge.onclick = () => { fs3dShowAll(); };
container.appendChild(isoBadge);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 클릭된 메시에서 meshes 키 찾기
function findMeshKey(hitObj) {
let target = hitObj;
while (target.parent && target.parent !== scene) target = target.parent;
for (const [key, obj] of Object.entries(meshes)) {
if (obj === target) return key;
}
return null;
}
// 우클릭 이벤트
renderer.domElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
ctxMenu.style.display = 'none';
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const sceneObjects = Object.values(meshes).filter(Boolean);
const intersects = raycaster.intersectObjects(sceneObjects, true);
let hitKey = null;
if (intersects.length > 0) hitKey = findMeshKey(intersects[0].object);
// 메뉴 항목 구성
ctxMenu.innerHTML = '';
let hasItem = false;
if (hitKey) {
const label = meshLabels[hitKey] || hitKey;
const btn = document.createElement('button');
btn.className = 'fs-ctx-btn';
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg> '${label}' 단품 보기`;
btn.onclick = () => { fs3dIsolate(hitKey); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(btn);
hasItem = true;
}
if (fs3dIsolated) {
if (hasItem) {
const sep = document.createElement('div');
sep.className = 'fs-ctx-sep';
ctxMenu.appendChild(sep);
}
const btn = document.createElement('button');
btn.className = 'fs-ctx-btn';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg> 전체 보기';
btn.onclick = () => { fs3dShowAll(); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(btn);
hasItem = true;
}
if (!hasItem) return;
// 화면 밖으로 넘어가지 않도록 위치 조정
ctxMenu.style.display = 'block';
const mw = ctxMenu.offsetWidth, mh = ctxMenu.offsetHeight;
const mx = Math.min(e.clientX, window.innerWidth - mw - 8);
const my = Math.min(e.clientY, window.innerHeight - mh - 8);
ctxMenu.style.left = mx + 'px';
ctxMenu.style.top = my + 'px';
});
// 좌클릭/스크롤 시 메뉴 닫기
document.addEventListener('click', () => { ctxMenu.style.display = 'none'; });
renderer.domElement.addEventListener('wheel', () => { ctxMenu.style.display = 'none'; });
}
let fs3dCameraInit = false;
function fs3dBuild() {
if (!scene) return;
// Clear existing meshes
Object.values(meshes).forEach(m => { if (m) scene.remove(m); });
meshes = {};
const W = S.openWidth || 2000;
const H = S.openHeight || 3000;
const p = PRODUCTS[S.productType];
const W1 = W + p.marginW;
const b = S.sb;
const g = S.gr;
// Center: opening center at 0,H/2,0
const hw = W / 2;
// 셔터박스 Z 배치 — 도면 기준
// 스크린: Z방향 = width(개구부 70mm), 철재: Z방향 = depth(75mm)
const railZSpan = S.productType === 'screen' ? p.gr.width : p.gr.depth;
const railHalf = railZSpan / 2; // 스크린:35, 철재:37.5
const frontOffset = railHalf + 50; // 박스 전면판 Z (스크린:85, 철재:110)
const boxCenterZ = frontOffset - b.depth / 2;
// 샤프트 Z: 기본 위치에서 시작, 박스 전면판을 벗어나면 자동 보정
const maxFwd = Math.max(p.bk.nmD / 2, p.bk.sprocketR); // 전면 최대 돌출
let shaftCenterZ = -(b.shaftDia / 2 + 5);
if (shaftCenterZ + maxFwd > frontOffset - 5) {
shaftCenterZ = frontOffset - maxFwd - 5; // 전면판 안쪽 5mm 여유
}
// === SHUTTER BOX (CASE) — 조립식 철판 구조 ===
const pt = b.thickness || 1.6; // 철판 두께
const frontH = p.sb.frontH; // 전면판 높이 (스크린:240, 철재:410)
const boxMat = new THREE.MeshStandardMaterial({ color: 0x374151, transparent: true, opacity: S.td.caseOpacity, side: THREE.DoubleSide });
const boxEdgeMat = new THREE.LineBasicMaterial({ color: 0x94a3b8 });
meshes.case = new THREE.Group();
meshes.case.position.set(0, H, boxCenterZ);
// 철판 생성 헬퍼
function addPlate(w, h, d, x, y, z) {
const geo = new THREE.BoxGeometry(w, h, d);
const m = new THREE.Mesh(geo, boxMat);
m.position.set(x, y, z);
meshes.case.add(m);
const edges = new THREE.EdgesGeometry(geo);
m.add(new THREE.LineSegments(edges, boxEdgeMat));
}
// ── 박스 본체 ──
const flangeBack = 55; // 후면판 상단 안쪽 절곡 깊이
const flangeFront = 50; // 전면판 상단 안쪽 절곡 깊이
// 후면판 (Back plate — 전체 높이)
addPlate(W1, b.height, pt, 0, b.height / 2, -b.depth / 2 + pt / 2);
// 후면판 상단 'ㄱ' 절곡 (안쪽 수평 선반)
addPlate(W1, pt, flangeBack, 0, b.height - pt / 2, -b.depth / 2 + flangeBack / 2);
// 전면판 (Front plate — 짧음, 상단에서 걸림)
addPlate(W1, frontH, pt, 0, b.height - frontH / 2, b.depth / 2 - pt / 2);
// 전면판 상단 'ㄱ' 절곡 (안쪽 수평 선반)
addPlate(W1, pt, flangeFront, 0, b.height - pt / 2, b.depth / 2 - flangeFront / 2);
// 전면 하부판 (전면판 아래 빈 공간을 막는 수직판)
const frontGap = b.height - frontH;
if (frontGap > 0) addPlate(W1, frontGap, pt, 0, frontGap / 2, b.depth / 2 - pt / 2);
// 좌측판 (Left side plate — 전체 높이)
addPlate(pt, b.height, b.depth, -W1 / 2 + pt / 2, b.height / 2, 0);
// 우측판 (Right side plate — 전체 높이)
addPlate(pt, b.height, b.depth, W1 / 2 - pt / 2, b.height / 2, 0);
// ── 밑면 결합체 (슬랫 통과 개구부: 레일 너비만큼만 개방) ──
// 구조: [전면 립 50mm] → [개구부 = 레일 너비] → [후면 밑판]
const frontLipD = 50; // 전면판 ~ 레일 전면 (50mm)
const openingW = railZSpan; // 개구부 Z폭 (스크린:width=70, 철재:depth=75)
const backLipD = b.depth - frontLipD - openingW; // 레일 후면 ~ 후면판
if (frontLipD > 0) addPlate(W1, pt, frontLipD, 0, pt / 2, b.depth / 2 - frontLipD / 2);
if (backLipD > 0) addPlate(W1, pt, backLipD, 0, pt / 2, -b.depth / 2 + backLipD / 2);
// ── 덮개 (Cover) — 절곡 선반 위에 올라가는 평판 (용접 결합, 양쪽 10mm 축소) ──
const coverY = b.height + pt; // 절곡 선반(pt) 위에 올라감
addPlate(W1 - 20, pt, b.depth - 20, 0, coverY + pt / 2, 0);
scene.add(meshes.case);
// === SHAFT ASSEMBLY (양쪽 브라켓 관통, 슬랫 폭과 동일) ===
const shaftY = H + b.height * 0.5; // 샤프트 중심 Y
const shaftMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.6, roughness: 0.3 });
const bracketMat = new THREE.MeshStandardMaterial({ color: 0x4b5563, metalness: 0.5, roughness: 0.4 });
meshes.shaft = new THREE.Group();
const motorDir = b.motorSide === 'right' ? 1 : -1;
const nonMotorSide = -motorDir;
// 브라켓 치수 (제품 유형별)
const bk = p.bk;
const bkThick = bk.thick; // 두께 (X방향, 철판)
const bkH = bk.nmH; // 비모터측 높이 (Y방향)
const bkD = bk.nmD; // 비모터측 깊이 (Z방향)
// 모터측 브라켓
const motorBkW = bk.mtD; // 깊이 (Z방향, 샤프트~모터 거리 수용)
const motorBkH = bk.mtH; // 높이 (Y방향)
const motorBkD = bk.thick; // 두께 (X방향, 철판)
const shaftFromInner = 90; // 브라켓 내면에서 샤프트 중심까지 거리 (도면 기준)
// 환봉/플랜지 치수
const stubPinR = 15; // 환봉 반지름 (ø30)
const stubPinTotal = 300; // 환봉 전체 길이
const stubPinVisible = 200; // 밖에서 보이는 길이 (플랜지 밖)
const stubPinInside = stubPinTotal - stubPinVisible; // 플랜지 안쪽 (100mm)
const shaftR = b.shaftDia / 2;
const shaftWallThick = 4; // 샤프트 관 두께 (4mm)
const flangeR = shaftR - shaftWallThick - 0.5; // 플랜지 외경 = 샤프트 내경 - 1mm 공차
const flangeThick = 10; // 플랜지 두께
const flangeMat = new THREE.MeshStandardMaterial({ color: 0x78716c, metalness: 0.5, roughness: 0.35 });
const stubPinMat = new THREE.MeshStandardMaterial({ color: 0x9ca3af, metalness: 0.7, roughness: 0.25 });
// 샤프트 관 길이: 양쪽 환봉 노출분만큼 축소
const mainShaftLen = W1 - stubPinVisible * 2;
// --- Non-motor side Bracket (180×180mm, 두께 18mm) — motor 그룹에 포함 (브라켓 한쌍) ---
const bkGeo = new THREE.BoxGeometry(bkThick, bkH, bkD);
const bkMesh = new THREE.Mesh(bkGeo, bracketMat);
const bkCenterX = nonMotorSide * (W1 / 2 - bkThick / 2);
bkMesh.position.set(bkCenterX, 0, 0);
// meshes.motor에 추가 (아래에서 motor 그룹 생성 후 추가)
// 비모터측 환봉 (브라켓에서 안쪽으로 200mm 돌출)
const stubPinGeo = new THREE.CylinderGeometry(stubPinR, stubPinR, stubPinVisible, 16);
stubPinGeo.rotateZ(Math.PI / 2);
const stubPin = new THREE.Mesh(stubPinGeo, stubPinMat);
const bkInnerFaceX = nonMotorSide * (W1 / 2 - bkThick);
stubPin.position.set(bkInnerFaceX - nonMotorSide * stubPinVisible / 2, 0, 0);
meshes.shaft.add(stubPin);
// 모터측: 3인치 연결관 + 복주머니 (브라켓 직접 체결)
const reducerR = 38; // 3인치 관 반지름 (ø76mm ≈ 3")
const bokjuR = reducerR + 8; // 복주머니 외경
const bokjuLen = 35; // 복주머니 길이
const bokjuMat = new THREE.MeshStandardMaterial({ color: 0x6b7280, metalness: 0.6, roughness: 0.3 });
const mtBkInnerFaceX = motorDir * (W1 / 2 - motorBkD);
const shaftEndX = motorDir * (mainShaftLen / 2); // 샤프트 관 끝
// 복주머니 (브라켓 내면에서 바로 안쪽, 브라켓과 직접 결합)
const bokjuGeo = new THREE.CylinderGeometry(bokjuR, bokjuR, bokjuLen, 24);
bokjuGeo.rotateZ(Math.PI / 2);
const bokju = new THREE.Mesh(bokjuGeo, bokjuMat);
bokju.position.set(mtBkInnerFaceX - motorDir * bokjuLen / 2, 0, 0);
meshes.shaft.add(bokju);
// 3인치 연결관 (복주머니 ~ 샤프트 관 끝, 플랜지 없이 직접 용접)
const reducerStartX = mtBkInnerFaceX - motorDir * bokjuLen;
const reducerEndX = shaftEndX;
const reducerLen = Math.abs(reducerStartX - reducerEndX);
const reducerGeo = new THREE.CylinderGeometry(reducerR, reducerR, reducerLen, 24);
reducerGeo.rotateZ(Math.PI / 2);
const reducer = new THREE.Mesh(reducerGeo, stubPinMat);
reducer.position.set((reducerStartX + reducerEndX) / 2, 0, 0);
meshes.shaft.add(reducer);
// --- Main Shaft (원형관) ---
const msGeo = new THREE.CylinderGeometry(shaftR, shaftR, mainShaftLen, 32);
msGeo.rotateZ(Math.PI / 2);
const msMesh = new THREE.Mesh(msGeo, shaftMat);
msMesh.position.set(0, 0, 0);
meshes.shaft.add(msMesh);
// 비모터측 플랜지만 (샤프트 내경에 삽입 용접)
const flangeGeo = new THREE.CylinderGeometry(flangeR, flangeR, flangeThick, 32);
flangeGeo.rotateZ(Math.PI / 2);
const flangeNM = new THREE.Mesh(flangeGeo, flangeMat);
flangeNM.position.set(nonMotorSide * (mainShaftLen / 2 + flangeThick / 2), 0, 0);
meshes.shaft.add(flangeNM);
meshes.shaft.position.set(0, shaftY, shaftCenterZ);
scene.add(meshes.shaft);
// === MOTOR (체인 구동: 모터는 샤프트와 수평(Z방향) 배치 — 슬랫 간섭 방지) ===
meshes.motor = new THREE.Group();
meshes.motor.add(bkMesh); // 비모터측 브라켓도 motor 그룹에 포함 (브라켓 한쌍)
const metalMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, metalness: 0.6, roughness: 0.3 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x374151, metalness: 0.5, roughness: 0.4 });
const chainMat = new THREE.MeshStandardMaterial({ color: 0xdc2626, metalness: 0.3, roughness: 0.5 });
// --- 1) 모터측 브라켓 (380×180mm, 벽면 부착 — YZ 평면 판) ---
// 380mm=Z방향(깊이, 샤프트~모터 수용), 180mm=Y방향(높이), 18mm=X방향(두께)
const motorBkThick = motorBkD; // 18mm
const motorBkCX = motorDir * (W1 / 2 - motorBkThick / 2);
const motorR = b.shaftDia * 0.45;
const shaftSprocketR = bk.sprocketR; // 철재:ø430(R215), 스크린:ø140(R70)
const motorSprocketR = bk.motorSpR; // 철재:ø80(R40), 스크린:ø60(R30)
const motorZ = bk.motorOffset > 0
? -bk.motorOffset // 철재: 도면 기준 160mm
: -(shaftSprocketR + motorSprocketR + 95); // 스크린: 자동 계산
// 브라켓: Z 중심은 샤프트(0)와 모터(motorZ) 사이
const motorBkGeo = new THREE.BoxGeometry(motorBkThick, motorBkH, motorBkW);
const motorBk = new THREE.Mesh(motorBkGeo, bracketMat);
motorBk.position.set(motorBkCX, 0, motorZ / 2);
meshes.motor.add(motorBk);
// --- 2) 스프로켓 톱니바퀴 (샤프트측 + 모터측) ---
const sprocketThick = 12;
const sprocketFaceX = motorDir * (W1 / 2 - motorBkThick);
const shaftSprocketX = sprocketFaceX - motorDir * sprocketThick / 2;
const sprocketMat = new THREE.MeshStandardMaterial({ color: 0x374151, metalness: 0.6, roughness: 0.35 });
// 샤프트 스프로켓 톱니바퀴
const shaftSpGroup = new THREE.Group();
const shaftSpBodyGeo = new THREE.CylinderGeometry(shaftSprocketR * 0.85, shaftSprocketR * 0.85, sprocketThick, 32);
shaftSpBodyGeo.rotateZ(Math.PI / 2);
shaftSpGroup.add(new THREE.Mesh(shaftSpBodyGeo, sprocketMat));
const shaftTeethCount = 18;
const toothH = 8, toothTW = 6;
for (let i = 0; i < shaftTeethCount; i++) {
const a = (i / shaftTeethCount) * Math.PI * 2;
const tGeo = new THREE.BoxGeometry(sprocketThick * 0.8, toothTW, toothH);
const t = new THREE.Mesh(tGeo, sprocketMat);
t.position.set(0, (shaftSprocketR - toothH / 2) * Math.sin(a), (shaftSprocketR - toothH / 2) * Math.cos(a));
t.rotation.x = a;
shaftSpGroup.add(t);
}
shaftSpGroup.position.set(shaftSprocketX, 0, 0);
meshes.motor.add(shaftSpGroup);
// --- 3) 모터 (샤프트와 수평방향 Z 오프셋 — 슬랫과 간섭 없음) ---
const motorBodyLen = motorR * 3;
// 모터 X: 브라켓 안쪽 (셔터박스 내부)
const motorCX = sprocketFaceX - motorDir * (motorBodyLen / 2 + 20);
// 모터 본체 (축=X, 샤프트와 평행, Y=0 수평, Z=motorZ)
const bodyGeo = new THREE.CylinderGeometry(motorR, motorR, motorBodyLen, 24);
bodyGeo.rotateZ(Math.PI / 2);
const body = new THREE.Mesh(bodyGeo, metalMat);
body.position.set(motorCX, 0, motorZ);
meshes.motor.add(body);
// 모터 후면 마감판
const ecGeo = new THREE.CylinderGeometry(motorR + 3, motorR + 3, 4, 24);
ecGeo.rotateZ(Math.PI / 2);
const ec = new THREE.Mesh(ecGeo, darkMat);
ec.position.set(motorCX - motorDir * (motorBodyLen / 2 + 2), 0, motorZ);
meshes.motor.add(ec);
// 상단 냉각 리브
for (let i = 0; i < 3; i++) {
const ribGeo = new THREE.BoxGeometry(motorBodyLen * 0.6, 3, motorR * 0.12);
const rib = new THREE.Mesh(ribGeo, darkMat);
rib.position.set(motorCX, motorR + 1.5, motorZ + (i - 1) * motorR * 0.4);
meshes.motor.add(rib);
}
// 마운팅 플레이트
const mMountGeo = new THREE.BoxGeometry(motorBodyLen * 0.6, 5, motorR * 1.4);
const mMount = new THREE.Mesh(mMountGeo, darkMat);
mMount.position.set(motorCX, -motorR - 2.5, motorZ);
meshes.motor.add(mMount);
// 모터 출력 스프로켓 톱니바퀴 (샤프트 스프로켓과 같은 X면, Z=motorZ)
const mSpGroup = new THREE.Group();
const mSpBodyGeo = new THREE.CylinderGeometry(motorSprocketR * 0.8, motorSprocketR * 0.8, sprocketThick, 20);
mSpBodyGeo.rotateZ(Math.PI / 2);
mSpGroup.add(new THREE.Mesh(mSpBodyGeo, sprocketMat));
const motorTeethCount = 10;
const mToothH = 6, mToothTW = 5;
for (let i = 0; i < motorTeethCount; i++) {
const a = (i / motorTeethCount) * Math.PI * 2;
const tGeo = new THREE.BoxGeometry(sprocketThick * 0.8, mToothTW, mToothH);
const t = new THREE.Mesh(tGeo, sprocketMat);
t.position.set(0, (motorSprocketR - mToothH / 2) * Math.sin(a), (motorSprocketR - mToothH / 2) * Math.cos(a));
t.rotation.x = a;
mSpGroup.add(t);
}
mSpGroup.position.set(shaftSprocketX, 0, motorZ);
meshes.motor.add(mSpGroup);
// --- 4) 체인 (RS #40, YZ 평면 — 수평으로 두 스프로켓 연결) ---
// 샤프트 스프로켓: (Y=0, Z=0), 모터 스프로켓: (Y=0, Z=motorZ)
const R1 = shaftSprocketR, R2 = motorSprocketR;
const chainPts = [];
const seg = 20;
// 큰 스프로켓 호: 상단→하단 (모터 반대쪽 반원, +Z side)
for (let i = 0; i <= seg; i++) {
const a = Math.PI / 2 - (i / seg) * Math.PI; // π/2 → -π/2
chainPts.push(new THREE.Vector3(0, R1 * Math.sin(a), R1 * Math.cos(a)));
}
// 하단 직선: 큰 스프로켓 하단 → 작은 스프로켓 하단
chainPts.push(new THREE.Vector3(0, -R2, motorZ));
// 작은 스프로켓 호: 하단→상단 (샤프트 반대쪽, -Z side)
for (let i = 0; i <= seg; i++) {
const a = -Math.PI / 2 - (i / seg) * Math.PI; // -π/2 → -3π/2
chainPts.push(new THREE.Vector3(0, R2 * Math.sin(a), motorZ + R2 * Math.cos(a)));
}
// 상단 직선: 작은 상단 → 큰 상단 (닫기)
chainPts.push(chainPts[0].clone());
const chainGeo = new THREE.BufferGeometry().setFromPoints(chainPts);
const chainLine = new THREE.Line(chainGeo, new THREE.LineBasicMaterial({ color: 0xdc2626, linewidth: 2 }));
chainLine.position.x = shaftSprocketX;
meshes.motor.add(chainLine);
meshes.motor.position.set(0, shaftY, shaftCenterZ);
scene.add(meshes.motor);
// === GUIDE RAILS (ExtrudeGeometry) ===
const rw = g.width, rd = g.depth, rt = g.thickness;
const railHeight = H + 100; // 바닥(0)부터 셔터박스 하단 결합부 (+100mm)
const railExtrude = { depth: railHeight > 0 ? railHeight : H, bevelEnabled: false };
const railMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.5, roughness: 0.4 });
const railSusMat = new THREE.MeshStandardMaterial({ color: 0x9ca3af, metalness: 0.7, roughness: 0.25 });
const railEdgeMat = new THREE.LineBasicMaterial({ color: 0x94a3b8, transparent: true, opacity: 0.4 });
function createRailGroup() {
const grp = new THREE.Group();
const isScreen = S.productType === 'screen';
if (isScreen) {
// ====== 스크린형 가이드레일 (실제 조립 구조) ======
// 단면 좌표계: X=폭(0→70), Y=깊이(0=개구부/실내측, +=벽쪽)
const fl = g.flange; // 26mm 플랜지 (슬롯=backWall-2*fl=15mm)
const lp = g.lip; // 10mm (립)
const sw = g.sideWall; // 80mm (사이드월)
const bw = g.backWall; // 67mm (백월)
// --- ② 본체 (EGI 1.55T): 슬롯10-플랜지30-사이드월80-백월t-사이드월80-플랜지30-슬롯 ---
// C채널: 사이드월 80mm, 플랜지 30mm, 슬롯 개구부 10mm (=width-2*fl)
const bodyShape = new THREE.Shape();
// 외곽 (시계방향)
bodyShape.moveTo(0, 0); // 하단-전면 모서리
bodyShape.lineTo(0, sw + rt); // 하단 사이드월 → 백월
bodyShape.lineTo(rw, sw + rt); // 백월 상단
bodyShape.lineTo(rw, 0); // 상단 사이드월 → 전면
bodyShape.lineTo(rw - fl, 0); // 상단 플랜지 시작
bodyShape.lineTo(rw - fl, -lp); // 상단 립 끝 (개구부쪽으로 10mm)
bodyShape.lineTo(fl, -lp); // 하단 립 끝 (슬롯 개구부)
bodyShape.lineTo(fl, 0); // 하단 플랜지 시작
bodyShape.lineTo(0, 0); // 닫기
// 내부 채널 (hole) — 반시계방향
const hole = new THREE.Path();
hole.moveTo(rt, rt); // 하단 내면
hole.lineTo(rt, sw); // 백월 내면
hole.lineTo(rw - rt, sw); // 백월 내면 상단
hole.lineTo(rw - rt, rt); // 상단 내면
hole.lineTo(rw - fl - rt, rt); // 상단 플랜지 내면
hole.lineTo(rw - fl - rt, -lp + rt); // 상단 립 내면 (개구부쪽)
hole.lineTo(fl + rt, -lp + rt); // 하단 립 내면 (개구부쪽)
hole.lineTo(fl + rt, rt); // 하단 플랜지 내면
hole.lineTo(rt, rt); // 닫기
bodyShape.holes.push(hole);
const bodyGeo = new THREE.ExtrudeGeometry(bodyShape, railExtrude);
const bodyMesh = new THREE.Mesh(bodyGeo, railMat);
grp.add(bodyMesh);
// 본체 엣지라인
const bodyEdge = new THREE.LineSegments(new THREE.EdgesGeometry(bodyGeo), railEdgeMat);
grp.add(bodyEdge);
// --- ③ 벽연형-C (EGI 1.55T): 30-45-30 ---
// C브라켓: 백월 뒤에 배치, 본체를 벽에 고정
const wcLip = 30, wcBody = 45;
const wcY = sw + rt; // 본체 백월 뒤
const wcCenterX = rw / 2;
const wcShape = new THREE.Shape();
wcShape.moveTo(wcCenterX - wcBody / 2, wcY);
wcShape.lineTo(wcCenterX - wcBody / 2, wcY + wcLip);
wcShape.lineTo(wcCenterX - wcBody / 2 + rt, wcY + wcLip);
wcShape.lineTo(wcCenterX - wcBody / 2 + rt, wcY + rt);
wcShape.lineTo(wcCenterX + wcBody / 2 - rt, wcY + rt);
wcShape.lineTo(wcCenterX + wcBody / 2 - rt, wcY + wcLip);
wcShape.lineTo(wcCenterX + wcBody / 2, wcY + wcLip);
wcShape.lineTo(wcCenterX + wcBody / 2, wcY);
wcShape.lineTo(wcCenterX - wcBody / 2, wcY);
const wcGeo = new THREE.ExtrudeGeometry(wcShape, railExtrude);
grp.add(new THREE.Mesh(wcGeo, railMat));
// --- ④ 벽연형-D (EGI 1.55T): 11-23-40-23-11 ---
// ③ 안쪽에 배치되는 보강 브라켓
const wdSeg = [11, 23, 40, 23, 11]; // 절곡 치수
const wdY = wcY + rt; // ③ 안쪽
const wdShape = new THREE.Shape();
wdShape.moveTo(wcCenterX - wdSeg[2] / 2, wdY);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2, wdY + wdSeg[1]);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 - wdSeg[0], wdY + wdSeg[1]);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 - wdSeg[0], wdY + wdSeg[1] - rt);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 + rt, wdY + wdSeg[1] - rt);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 + rt, wdY + rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 - rt, wdY + rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 - rt, wdY + wdSeg[3] - rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 + wdSeg[4], wdY + wdSeg[3] - rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 + wdSeg[4], wdY + wdSeg[3]);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2, wdY + wdSeg[3]);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2, wdY);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2, wdY);
const wdGeo = new THREE.ExtrudeGeometry(wdShape, railExtrude);
grp.add(new THREE.Mesh(wdGeo, railMat));
// --- ⑤ 벽도마감 (SUS 1.2T): 9-114-34-10 × 2개 ---
// 본체 양쪽 사이드를 감싸는 SUS 커버 (L자 프로파일)
const trimT = g.trimThick || 1.2;
// 하단 트림 (X=0 외면)
const bt = new THREE.Shape();
bt.moveTo(-trimT, -9); // 전면 립 (9mm 돌출)
bt.lineTo(0, -9);
bt.lineTo(0, sw); // 사이드 커버 (본체 사이드월까지)
bt.lineTo(34, sw); // 벽쪽 플랜지 (34mm 안쪽으로)
bt.lineTo(34, sw + trimT);
bt.lineTo(-trimT, sw + trimT);
bt.lineTo(-trimT, -9);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(bt, railExtrude), railSusMat));
// 상단 트림 (X=rw 외면)
const tt = new THREE.Shape();
tt.moveTo(rw + trimT, -9); // 전면 립
tt.lineTo(rw, -9);
tt.lineTo(rw, sw); // 사이드 커버
tt.lineTo(rw - 34, sw); // 벽쪽 플랜지
tt.lineTo(rw - 34, sw + trimT);
tt.lineTo(rw + trimT, sw + trimT);
tt.lineTo(rw + trimT, -9);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(tt, railExtrude), railSusMat));
// --- ① 마감재 (EGI 1.55T) × 2개 ---
// 슬랫 가이드 클립: 사이드월 내면에 부착된 L형 브라켓
// 참조 도면 기준: 채널 내부 사이드월에서 안쪽으로 돌출
[{ x: rt, dir: 1 }, { x: rw - rt, dir: -1 }].forEach(({ x, dir }) => {
const clipShape = new THREE.Shape();
const cy = lp + rt + 3; // 립 내면에서 약간 안쪽
const armLen = 12; // 사이드월에서 안쪽으로 돌출 길이
const legLen = 20; // 사이드월 따라 길이
// L형 브라켓 (사이드월 내면 → 채널 안쪽으로)
clipShape.moveTo(x, cy);
clipShape.lineTo(x + dir * armLen, cy);
clipShape.lineTo(x + dir * armLen, cy + rt);
clipShape.lineTo(x + dir * rt, cy + rt);
clipShape.lineTo(x + dir * rt, cy + legLen);
clipShape.lineTo(x, cy + legLen);
clipShape.lineTo(x, cy);
const clipGeo = new THREE.ExtrudeGeometry(clipShape, railExtrude);
grp.add(new THREE.Mesh(clipGeo, railMat));
});
} else {
// ====== 철재슬라트 가이드레일 (기존 C채널) ======
const rl = g.lip;
// Top lip + wall
const s1 = new THREE.Shape();
s1.moveTo(0, 0); s1.lineTo(rw, 0); s1.lineTo(rw, rl);
s1.lineTo(rw - rt, rl); s1.lineTo(rw - rt, rt);
s1.lineTo(rt, rt); s1.lineTo(rt, rl); s1.lineTo(0, rl); s1.lineTo(0, 0);
// Bottom lip + wall
const s2 = new THREE.Shape();
s2.moveTo(0, rd - rl); s2.lineTo(rt, rd - rl); s2.lineTo(rt, rd - rt);
s2.lineTo(rw - rt, rd - rt); s2.lineTo(rw - rt, rd - rl);
s2.lineTo(rw, rd - rl); s2.lineTo(rw, rd); s2.lineTo(0, rd); s2.lineTo(0, rd - rl);
// Side walls
const s3 = new THREE.Shape();
s3.moveTo(0, rl); s3.lineTo(rt, rl); s3.lineTo(rt, rd - rl); s3.lineTo(0, rd - rl); s3.lineTo(0, rl);
const s4 = new THREE.Shape();
s4.moveTo(rw - rt, rl); s4.lineTo(rw, rl); s4.lineTo(rw, rd - rl); s4.lineTo(rw - rt, rd - rl); s4.lineTo(rw - rt, rl);
[s1, s2, s3, s4].forEach(s => {
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(s, railExtrude), railMat));
});
}
// 회전: XY평면 → 수직 (Z extrude → Y height)
grp.rotation.x = -Math.PI / 2;
return grp;
}
meshes.rails = new THREE.Group();
const isScreenType = S.productType === 'screen';
if (isScreenType) {
// ====== 스크린형: 채널 개구부가 중심(슬랫)을 향하도록 Y축 회전 ======
// createRailGroup()의 Rx(-PI/2) 후: 개구부가 +Z 방향
// Ry(±PI/2) 래퍼로 개구부를 ±X 방향(중심 향함)으로 회전
// Left rail — Ry(+PI/2): 개구부 → +X (중심 방향)
const railGroupL = createRailGroup();
const wrapperL = new THREE.Group();
wrapperL.add(railGroupL);
wrapperL.rotation.y = Math.PI / 2;
wrapperL.position.set(-hw, 0, rw / 2);
meshes.rails.add(wrapperL);
// Right rail — Ry(-PI/2): 개구부 → -X (중심 방향)
const railGroupR = createRailGroup();
const wrapperR = new THREE.Group();
wrapperR.add(railGroupR);
wrapperR.rotation.y = -Math.PI / 2;
wrapperR.position.set(hw, 0, -rw / 2);
meshes.rails.add(wrapperR);
} else {
// ====== 철재형: 기존 C채널 방식 (슬랫이 Z방향 슬롯 통과) ======
const railGroupL = createRailGroup();
railGroupL.position.set(-hw - rw / 2, 0, rd / 2);
meshes.rails.add(railGroupL);
const railGroupR = createRailGroup();
railGroupR.scale.x = -1;
railGroupR.position.set(hw + rw / 2, 0, rd / 2);
meshes.rails.add(railGroupR);
}
scene.add(meshes.rails);
// === SLAT CURTAIN ===
const shutterH = H * (S.td.shutterPos / 100);
const slatGeo = new THREE.PlaneGeometry(W - 20, shutterH);
const slatMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0x9ca3af : 0xc084fc,
side: THREE.DoubleSide,
transparent: S.productType === 'screen',
opacity: S.productType === 'screen' ? 0.6 : 0.9,
metalness: S.productType === 'steel' ? 0.4 : 0,
roughness: S.productType === 'steel' ? 0.5 : 0.8,
});
meshes.slats = new THREE.Mesh(slatGeo, slatMat);
meshes.slats.position.set(0, H - shutterH / 2, 0);
scene.add(meshes.slats);
// Slat lines (horizontal grooves for steel type) — local coords relative to meshes.slats
if (S.productType === 'steel') {
const lineGroup = new THREE.Group();
const slatPitch = 80; // mm
const lineCount = Math.floor(shutterH / slatPitch);
for (let i = 1; i < lineCount; i++) {
const y = shutterH / 2 - i * slatPitch; // local Y: top of plane → down
if (y < -shutterH / 2) break;
const lineGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-W/2 + 10, y, 1),
new THREE.Vector3(W/2 - 10, y, 1)
]);
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x6b7280 }));
lineGroup.add(line);
}
meshes.slats.add(lineGroup);
}
// === SLAT ROLL (샤프트에 감긴 슬랫 — 샤프트 전체에 두껍게 감기는 실린더) ===
const rolledH = H - shutterH;
if (rolledH > 0) {
const wrapThick = S.productType === 'steel' ? 10 : 1;
const shaftR = b.shaftDia / 2;
const rollThick = Math.max(Math.sqrt(rolledH * wrapThick / Math.PI), 8);
const rollOuterR = shaftR + rollThick;
const rollLen = W - 30 - stubPinVisible * 2; // 샤프트 관 길이에 맞춤
// 하나의 Mesh로 표현 (접시 모양 방지)
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 48, 1, false);
rollGeo.rotateZ(Math.PI / 2);
const rollMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0xC9B89A : 0xc084fc,
metalness: S.productType === 'steel' ? 0.2 : 0,
roughness: S.productType === 'steel' ? 0.5 : 0.8,
opacity: 0.93,
transparent: true,
});
meshes.slatRoll = new THREE.Mesh(rollGeo, rollMat);
meshes.slatRoll.position.set(0, shaftY, shaftCenterZ);
scene.add(meshes.slatRoll);
// 표면 나선 라인 (감긴 슬랫 질감)
if (rollThick > 5) {
const spiralMat = new THREE.LineBasicMaterial({ color: S.productType === 'steel' ? 0x8B7355 : 0x7c4dff, opacity: 0.5, transparent: true });
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;
const x = -rollLen / 2 + t * rollLen;
pts.push(new THREE.Vector3(x, Math.sin(angle) * (rollOuterR + 0.5), Math.cos(angle) * (rollOuterR + 0.5)));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
meshes.slatRoll.add(new THREE.Line(geo, spiralMat));
}
}
}
// === BOTTOM BAR ===
const barGeo = new THREE.BoxGeometry(W - 20, 40, 60);
const barMat = new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.3, roughness: 0.5 });
meshes.bottomBar = new THREE.Mesh(barGeo, barMat);
meshes.bottomBar.position.set(0, H - shutterH - 20, 0);
scene.add(meshes.bottomBar);
// === WALL (좌/우 기둥 + 상부 인방) ===
// 벽체: 기둥+인방을 하나의 U자 형상으로 생성 (이음새 없음)
const wl = S.wall;
const colH = H + b.height;
const wallColor = new THREE.Color(wl.color);
const wallMat = new THREE.MeshStandardMaterial({ color: wallColor, transparent: true, opacity: wl.opacity / 100, side: THREE.DoubleSide });
meshes.wall = new THREE.Group();
const whw = W1 / 2;
const wg = wl.wing;
const topH = wl.topMargin;
const totalH = colH + topH;
if (wg > 0 && topH > 0) {
// U자 형상 (기둥+인방 일체형)
const shape = new THREE.Shape();
shape.moveTo(-whw - wg, 0);
shape.lineTo(-whw - wg, totalH);
shape.lineTo(whw + wg, totalH);
shape.lineTo(whw + wg, 0);
shape.lineTo(whw, 0);
shape.lineTo(whw, colH);
shape.lineTo(-whw, colH);
shape.lineTo(-whw, 0);
shape.lineTo(-whw - wg, 0);
const wallGeo = new THREE.ExtrudeGeometry(shape, { depth: wl.thick, bevelEnabled: false });
wallGeo.translate(0, 0, -wl.thick / 2);
meshes.wall.add(new THREE.Mesh(wallGeo, wallMat));
} else if (wg > 0) {
// 기둥만 (인방 없음)
const colGeo = new THREE.BoxGeometry(wg, colH, wl.thick);
const leftCol = new THREE.Mesh(colGeo, wallMat);
leftCol.position.set(-whw - wg / 2, colH / 2, 0);
meshes.wall.add(leftCol);
const rightCol = new THREE.Mesh(colGeo.clone(), wallMat);
rightCol.position.set(whw + wg / 2, colH / 2, 0);
meshes.wall.add(rightCol);
} else if (topH > 0) {
// 인방만 (기둥 없음)
const lintelGeo = new THREE.BoxGeometry(W1, topH, wl.thick);
const lintel = new THREE.Mesh(lintelGeo, wallMat);
lintel.position.set(0, colH + topH / 2, 0);
meshes.wall.add(lintel);
}
// 벽체 와이어프레임
meshes.wall.children.forEach(m => {
const edges = new THREE.EdgesGeometry(m.geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x8d6e63, opacity: 0.4, transparent: true }));
m.add(line);
});
scene.add(meshes.wall);
// 재빌드 후 토글/단품 상태 동기화
if (fs3dIsolated) {
// 단품 보기 모드 유지
Object.entries(meshes).forEach(([k, obj]) => { if (obj) obj.visible = (k === fs3dIsolated); });
} else {
Object.keys(S.td.show).forEach(key => {
if (meshes[key]) meshes[key].visible = S.td.show[key];
});
}
// Camera: 최초 빌드 시에만 위치 설정, 이후 재빌드 시 현재 시점 유지
if (!fs3dCameraInit) {
controls.target.set(0, H / 2, 0);
camera.position.set(W * 1.2, H * 0.8, W * 1.5);
controls.update();
fs3dCameraInit = true;
}
}
// 단품 보기 (Isolation)
function fs3dIsolate(key) {
fs3dIsolated = key;
Object.entries(meshes).forEach(([k, obj]) => {
if (obj) obj.visible = (k === key);
});
// 배지 표시
const badge = document.querySelector('.fs-iso-badge');
if (badge) {
const label = meshLabels[key] || key;
badge.innerHTML = `${label} 단품 보기 중 &nbsp;✕`;
badge.style.display = 'block';
}
// 카메라 위치 유지 (현재 뷰 그대로, 단품만 표시)
}
// 전체 보기 (Show All)
function fs3dShowAll() {
fs3dIsolated = null;
Object.entries(meshes).forEach(([k, obj]) => {
if (!obj) return;
obj.visible = S.td.show[k] !== undefined ? S.td.show[k] : true;
});
// 배지 숨기기
const badge = document.querySelector('.fs-iso-badge');
if (badge) badge.style.display = 'none';
}
// 3D Controls
window.fs3dShutterPos = function(v) {
undoSaveState();
S.td.shutterPos = Number(v);
$('shutterPosLabel').textContent = v + '%';
fs3dBuild();
};
window.fs3dOpacity = function(v) {
undoSaveState();
S.td.caseOpacity = Number(v) / 100;
$('opacityLabel').textContent = v + '%';
if (meshes.case) meshes.case.traverse(c => { if (c.isMesh) c.material.opacity = S.td.caseOpacity; });
};
window.fsToggle3d = function(el, key) {
undoSaveState();
el.classList.toggle('active');
S.td.show[key] = el.classList.contains('active');
if (meshes[key]) meshes[key].visible = S.td.show[key];
// 벽 토글 시 벽체 설정 패널 연동
if (key === 'wall') {
const ws = $('wallSettings');
if (ws) ws.classList.toggle('hidden', !S.td.show.wall);
}
};
window.fs3dLightColor = function(color) {
if (!scene) return;
const c = new THREE.Color(color);
scene.children.forEach(l => {
if (l.isDirectionalLight) l.color.copy(c);
});
};
window.fs3dBg = function(color) {
S.td.bgColor = color;
if (scene) scene.background = new THREE.Color(color);
};
window.fs3dWall = function(key, v) {
undoSaveState();
const num = Number(v);
S.wall[key] = num;
const labelId = 'wall' + key.charAt(0).toUpperCase() + key.slice(1) + 'Label';
const label = $(labelId);
if (label) label.textContent = key === 'opacity' ? num + '%' : num;
fs3dBuild();
};
window.fs3dWallColor = function(color) {
undoSaveState();
S.wall.color = color;
$('wallColorPicker').value = color;
fs3dBuild();
};
// ============================
// ZOOM / PAN (SVG tabs)
// ============================
const viewport = $('canvasViewport');
viewport.addEventListener('mousedown', e => {
if (S.tab === '3D') return;
S.view.dragging = true;
S.view.lastMouse = { x: e.clientX, y: e.clientY };
});
viewport.addEventListener('wheel', e => {
if (S.tab === '3D') return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
S.view.scale = Math.max(0.1, Math.min(S.view.scale + delta, 10));
fsRender();
}, { passive: false });
window.addEventListener('mousemove', e => {
if (!S.view.dragging || S.tab === '3D') return;
S.view.offset.x += e.clientX - S.view.lastMouse.x;
S.view.offset.y += e.clientY - S.view.lastMouse.y;
S.view.lastMouse = { x: e.clientX, y: e.clientY };
fsRender();
});
window.addEventListener('mouseup', () => { S.view.dragging = false; });
$('zoomInBtn').onclick = () => { if (S.tab === '3D') return; S.view.scale = Math.min(S.view.scale + 0.2, 10); fsRender(); };
$('zoomOutBtn').onclick = () => { if (S.tab === '3D') return; S.view.scale = Math.max(S.view.scale - 0.2, 0.1); fsRender(); };
$('zoomResetBtn').onclick = () => { if (S.tab === '3D') return; S.view.scale = 1; S.view.offset = {x:0,y:0}; fsRender(); };
// ============================
// EXPORT: PNG
// ============================
window.fsExportPng = function() {
if (S.tab === '3D') {
// 3D screenshot
if (!renderer) return;
renderer.render(scene, camera);
const link = document.createElement('a');
link.download = `fire-shutter-3d-${Date.now()}.png`;
link.href = renderer.domElement.toDataURL('image/png');
link.click();
return;
}
// SVG to PNG
const svgEl = $('svgContainer').querySelector('svg');
if (!svgEl) return;
const svgData = new XMLSerializer().serializeToString(svgEl);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
img.onload = function() {
canvas.width = img.width * 2;
canvas.height = img.height * 2;
ctx.fillStyle = '#050507';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const link = document.createElement('a');
link.download = `fire-shutter-${S.tab.toLowerCase()}-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
URL.revokeObjectURL(url);
};
img.src = url;
};
// ============================
// EXPORT: DXF
// ============================
window.fsExportDxf = function() {
let entities = '';
if (S.tab === 'GuideRail') {
const g = S.gr;
const w = g.width, d = g.depth, t = g.thickness, lip = g.lip;
// C-channel outline as DXF LWPOLYLINE
const pts = [
[0,0],[w,0],[w,lip],[w-t,lip],[w-t,t],[t,t],[t,lip],[0,lip],[0,0],
[0,d-lip],[t,d-lip],[t,d-t],[w-t,d-t],[w-t,d-lip],[w,d-lip],[w,d],[0,d],[0,d-lip],
[0,lip],[t,lip],[t,d-lip],[0,d-lip],
[w,lip],[w-t,lip],[w-t,d-lip],[w,d-lip]
];
pts.forEach((p, i) => {
if (i > 0) {
const prev = pts[i-1];
entities += `0\nLINE\n8\nGUIDE_RAIL\n10\n${prev[0]}\n20\n${prev[1]}\n30\n0\n11\n${p[0]}\n21\n${p[1]}\n31\n0\n`;
}
});
} else if (S.tab === 'ShutterBox') {
const b = S.sb;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n0\n20\n0\n30\n0\n11\n${b.width}\n21\n0\n31\n0\n`;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n${b.width}\n20\n0\n30\n0\n11\n${b.width}\n21\n${b.height}\n31\n0\n`;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n${b.width}\n20\n${b.height}\n30\n0\n11\n0\n21\n${b.height}\n31\n0\n`;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n0\n20\n${b.height}\n30\n0\n11\n0\n21\n0\n31\n0\n`;
// Shaft circle
entities += `0\nCIRCLE\n8\nSHAFT\n10\n${b.width/2}\n20\n${b.height*0.45}\n30\n0\n40\n${b.shaftDia/2}\n`;
}
const dxf = `0\nSECTION\n2\nENTITIES\n${entities}0\nENDSEC\n0\nEOF\n`;
const blob = new Blob([dxf], { type: 'application/dxf' });
const link = document.createElement('a');
link.download = `fire-shutter-${S.tab.toLowerCase()}-${Date.now()}.dxf`;
link.href = URL.createObjectURL(blob);
link.click();
};
// ============================
// EXPORT: JSON (Params)
// ============================
window.fsExportJson = function() {
const data = {
productType: S.productType,
openWidth: S.openWidth,
openHeight: S.openHeight,
guideRail: { ...S.gr },
shutterBox: { ...S.sb },
exportDate: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.download = `fire-shutter-params-${Date.now()}.json`;
link.href = URL.createObjectURL(blob);
link.click();
};
// ============================
// PRESETS (localStorage)
// ============================
function refreshPresetList() {
const sel = $('presetSelect');
sel.innerHTML = '<option value="">-- 선택 --</option>';
S.presets.forEach((p, i) => {
sel.innerHTML += `<option value="${i}">${p.name} (${p.productType === 'steel' ? '강판' : '스크린'} ${p.openWidth}×${p.openHeight})</option>`;
});
}
window.fsSavePreset = function() {
const name = $('presetName').value.trim();
if (!name) { alert('프리셋 이름을 입력하세요.'); return; }
S.presets.push({
name,
productType: S.productType,
openWidth: S.openWidth,
openHeight: S.openHeight,
gr: { ...S.gr },
sb: { ...S.sb },
});
localStorage.setItem('fs_presets', JSON.stringify(S.presets));
$('presetName').value = '';
refreshPresetList();
};
window.fsLoadPreset = function() {
const idx = Number($('presetSelect').value);
if (isNaN(idx) || !S.presets[idx]) return;
const p = S.presets[idx];
S.productType = p.productType;
S.openWidth = p.openWidth;
S.openHeight = p.openHeight;
Object.assign(S.gr, p.gr);
Object.assign(S.sb, p.sb);
// Sync inputs
$('productType').value = p.productType;
$('openWidth').value = p.openWidth;
$('openHeight').value = p.openHeight;
$('grWidth').value = S.gr.width; $('grDepth').value = S.gr.depth;
$('grThickness').value = S.gr.thickness; $('grLip').value = S.gr.lip;
$('grSealThick').value = S.gr.sealThick; $('grSealDepth').value = S.gr.sealDepth;
$('grSlatThick').value = S.gr.slatThick; $('grAnchorSpacing').value = S.gr.anchorSpacing;
$('sbWidth').value = S.sb.width; $('sbHeight').value = S.sb.height;
$('sbDepth').value = S.sb.depth; $('sbThickness').value = S.sb.thickness;
$('sbShaftDia').value = S.sb.shaftDia; $('sbBracketW').value = S.sb.bracketW;
$('sbMotorSide').value = S.sb.motorSide;
fsCalc();
fsRender();
};
window.fsDeletePreset = function() {
const idx = Number($('presetSelect').value);
if (isNaN(idx) || !S.presets[idx]) return;
if (!confirm(`"${S.presets[idx].name}" 프리셋을 삭제하시겠습니까?`)) return;
S.presets.splice(idx, 1);
localStorage.setItem('fs_presets', JSON.stringify(S.presets));
refreshPresetList();
};
// ============================
// INIT
// ============================
fsOnProductType(); // 스크린형 기본값 적용 + 모델 필터링
refreshPresetList();
fsSwitch('3D');
})();
</script>
@endpush
@endsection