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

1331 lines
79 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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; }
</style>
<div class="fs-wrap">
<main style="max-width:1800px; margin:0 auto; padding:1rem 1.5rem;">
<div class="flex" style="gap:1.25rem; height:calc(100vh - 96px);">
<!-- LEFT PANEL: 30% -->
<div class="custom-scrollbar" style="width:30%; flex-shrink:0; overflow-y:auto; padding-right:0.5rem;">
<!-- Tab Buttons (2x2 grid) -->
<div class="grid gap-1 mb-4 bg-slate-900/50 p-1.5 rounded-xl border border-slate-800" style="grid-template-columns:1fr 1fr;">
<button id="tabSettings" class="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" onclick="fsSwitch('Settings')">
<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="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
설정
</button>
<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>
<!-- ========== SETTINGS TAB CONTROLS ========== -->
<div id="ctrlSettings">
<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-blue-600">S</span>
기본 설정
</h2>
<div>
<label class="fs-label">제품 유형</label>
<select id="productType" class="fs-select" onchange="fsOnProductType()">
<option value="steel">강판형 (Steel Slat)</option>
<option value="screen">스크린형 (Screen/Fabric)</option>
</select>
</div>
<div>
<label class="fs-label">제품 모델</label>
<select id="productModel" class="fs-select" onchange="fsOnModelChange()">
<optgroup label="강판형">
<option value="KFS01">KFS01 - 강판 방화셔터 기본</option>
<option value="KFS02">KFS02 - 강판 방화셔터 대형</option>
</optgroup>
<optgroup label="스크린형">
<option value="KSS01">KSS01 - 실리카 스크린</option>
<option value="KSS02">KSS02 - 와이어 스크린</option>
</optgroup>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">개구부 W0 (mm)</label>
<input type="number" id="openWidth" class="fs-input" value="2000" onchange="fsCalc()">
</div>
<div>
<label class="fs-label">개구부 높이 H0 (mm)</label>
<input type="number" id="openHeight" class="fs-input" value="3000" onchange="fsCalc()">
</div>
</div>
<div>
<label class="fs-label">수량</label>
<input type="number" id="quantity" class="fs-input" value="1" min="1" onchange="fsCalc()">
</div>
</section>
<!-- Auto-Calculated -->
<section class="fs-section space-y-3 mt-4">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="fs-badge bg-emerald-600">C</span>
자동 계산
</h2>
<div class="space-y-1">
<div class="fs-calc-row"><span class="fs-calc-label">제작 (W1)</span><span id="calcW1" class="fs-calc-value">2110 mm</span></div>
<div class="fs-calc-row"><span class="fs-calc-label">제작 높이 (H1)</span><span id="calcH1" class="fs-calc-value">3350 mm</span></div>
<div class="fs-calc-row"><span class="fs-calc-label">면적 (M)</span><span id="calcArea" class="fs-calc-value">7.07 </span></div>
<div class="fs-calc-row"><span class="fs-calc-label">중량 (K)</span><span id="calcWeight" class="fs-calc-value">176.7 kg</span></div>
<div class="fs-calc-row"><span class="fs-calc-label">권장 모터</span><span id="calcMotor" class="fs-calc-value text-blue-400">300K (4")</span></div>
<div class="fs-calc-row" style="border:none;"><span class="fs-calc-label">가이드레일 조합</span><span id="calcRailCombo" class="fs-calc-value text-amber-400">3,305mm × 2</span></div>
</div>
</section>
<!-- Preset -->
<section class="fs-section space-y-3 mt-4">
<h2 class="text-sm font-black text-slate-400 flex items-center gap-2">
<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"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/></svg>
프리셋
</h2>
<div class="flex gap-2">
<select id="presetSelect" class="fs-select flex-1"><option value="">-- 선택 --</option></select>
<button class="fs-btn fs-btn-primary" onclick="fsLoadPreset()">불러오기</button>
</div>
<div class="flex gap-2">
<input type="text" id="presetName" class="fs-input flex-1" placeholder="프리셋 이름">
<button class="fs-btn fs-btn-primary" onclick="fsSavePreset()">저장</button>
<button class="fs-btn fs-btn-ghost" onclick="fsDeletePreset()">삭제</button>
</div>
</section>
</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="65" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">레일 깊이 (mm)</label>
<input type="number" id="grDepth" class="fs-input" value="50" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">강판 두께 (mm)</label>
<input type="number" id="grThickness" class="fs-input" value="2.3" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">립(입구) 높이 (mm)</label>
<input type="number" id="grLip" class="fs-input" value="15" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 두께 (mm)</label>
<input type="number" id="grSealThick" class="fs-input" value="5" 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="1.6" 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="1500" 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="380" 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="120" step="5" onchange="fsRender()">
</div>
<div>
<label class="fs-label">브래킷 폭 (mm)</label>
<input type="number" id="sbBracketW" class="fs-input" value="80" step="5" 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 active" 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>
</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: 'steel',
openWidth: 2000,
openHeight: 3000,
quantity: 1,
// Guide Rail
gr: { width:65, depth:50, thickness:2.3, lip:15, sealThick:5, sealDepth:40, slatThick:1.6, anchorSpacing:500, viewMode:'cross', showDim:true, showSeal:true },
// Shutter Box
sb: { width:1500, height:380, depth:380, thickness:1.6, shaftDia:120, bracketW:80, 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:true } },
// 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:65,depth:50,thickness:2.3,lip:15}, slatThick:1.6 },
screen: { marginW:140, marginH:350, weightFactor:2, gr:{width:30,depth:25,thickness:1.5,lip:11}, slatThick:0.8 },
};
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 = {};
// ============================
// TAB SWITCHING
// ============================
window.fsSwitch = function(tab) {
S.tab = tab;
const tabs = ['Settings','GuideRail','ShutterBox','3D'];
const tabBtnIds = ['tabSettings','tabGuideRail','tabShutterBox','tab3D'];
const ctrlIds = ['ctrlSettings','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() {
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 combo
let bestCombo = '';
const h = S.openHeight;
for (const r of RAIL_LENGTHS) {
if (r >= h) { bestCombo = `${r.toLocaleString()}mm × 2`; break; }
}
if (!bestCombo) bestCombo = `${RAIL_LENGTHS[RAIL_LENGTHS.length-1].toLocaleString()}mm × 2 (절단 조합)`;
$('calcW1').textContent = W1.toLocaleString() + ' mm';
$('calcH1').textContent = H1.toLocaleString() + ' mm';
$('calcArea').textContent = area.toFixed(2) + ' m²';
$('calcWeight').textContent = weight.toFixed(1) + ' kg';
$('calcMotor').textContent = `${motor.spec} (${motor.inch}")`;
$('calcRailCombo').textContent = bestCombo;
// Auto-update box width
$('sbWidth').value = W1;
S.sb.width = W1;
};
window.fsOnProductType = function() {
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;
// 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();
fsRender();
};
window.fsOnModelChange = function() { fsCalc(); };
// ============================
// 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() {
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">셔터박스 (HEAD BOX)</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;
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">가이드레일 횡단면도 (Cross-Section, Top 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) — ${S.productType === 'steel' ? '강판형' : '스크린형'}</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, svgH = sh + pad * 2 + 60;
const ox = pad, oy = pad;
const shaftR = (b.shaftDia / 2) * sc;
const shaftCx = ox + sw / 2;
const shaftCy = oy + sh * 0.45;
const bracketW = b.bracketW * sc;
const motorW = 80 * sc, motorH = 50 * sc;
const motorX = b.motorSide === 'right' ? ox + sw - bracketW - motorW - 10 : ox + bracketW + 10;
const motorY = shaftCy - motorH / 2;
const brakeW = 30 * sc, brakeH = 40 * sc;
const brakeX = b.motorSide === 'right' ? motorX - brakeW - 5 : motorX + motorW + 5;
const springW = 60 * sc, springH = 20 * sc;
const springX = b.motorSide === 'right' ? ox + bracketW + 20 : ox + sw - bracketW - springW - 20;
const springY = shaftCy + shaftR + 15;
// Slat roll (wrapped around shaft)
const rollR = shaftR + 25 * 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 Cross-Section)</text>
<!-- Case outer -->
<rect x="${ox}" y="${oy}" width="${sw}" height="${sh}" fill="none" stroke="#94a3b8" stroke-width="2.5" rx="4"/>
<!-- Case fill (semi-transparent) -->
<rect x="${ox}" y="${oy}" width="${sw}" height="${sh}" fill="#374151" opacity="0.3" rx="4"/>
<!-- Case thickness indication -->
<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="${ox}" y="${oy + 5}" width="${bracketW}" height="${sh - 10}" fill="#8b5cf6" opacity="0.6" stroke="#7c3aed" stroke-width="1.5" rx="2"/>
<rect x="${ox + sw - bracketW}" y="${oy + 5}" width="${bracketW}" height="${sh - 10}" fill="#8b5cf6" opacity="0.6" stroke="#7c3aed" stroke-width="1.5" rx="2"/>
<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 -->
${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"/>
<text x="${shaftCx}" y="${shaftCy - shaftR - 8}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">샤프트 ${b.shaftDia}</text>
` : ''}
<!-- Slat Roll -->
${b.showSlatRoll ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${rollR}" fill="none" stroke="#f59e0b" stroke-width="3" opacity="0.6" stroke-dasharray="6 3"/>
<text x="${shaftCx + rollR + 10}" y="${shaftCy + 4}" fill="#f59e0b" font-size="9" font-weight="700" 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 opening at bottom -->
<line x1="${ox + bracketW + 10}" y1="${oy + sh}" x2="${ox + sw - bracketW - 10}" 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">HEAD BOX / 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);
}
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;
// === SHUTTER BOX (CASE) ===
const boxGeo = new THREE.BoxGeometry(W1, b.height, b.depth);
const boxMat = new THREE.MeshStandardMaterial({ color: 0x374151, transparent: true, opacity: S.td.caseOpacity, side: THREE.DoubleSide });
meshes.case = new THREE.Mesh(boxGeo, boxMat);
meshes.case.position.set(0, H + b.height / 2, 0);
scene.add(meshes.case);
// Box wireframe (child of case — position (0,0,0) relative to parent)
const boxEdges = new THREE.EdgesGeometry(boxGeo);
const boxLine = new THREE.LineSegments(boxEdges, new THREE.LineBasicMaterial({ color: 0x94a3b8 }));
meshes.case.add(boxLine);
// === SHAFT ===
const shaftGeo = new THREE.CylinderGeometry(b.shaftDia / 2, b.shaftDia / 2, W1 - b.bracketW * 2, 32);
shaftGeo.rotateZ(Math.PI / 2);
const shaftMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.6, roughness: 0.3 });
meshes.shaft = new THREE.Mesh(shaftGeo, shaftMat);
meshes.shaft.position.set(0, H + b.height * 0.5, 0);
scene.add(meshes.shaft);
// === MOTOR ===
const motorGeo = new THREE.BoxGeometry(150, 100, 120);
const motorMat = new THREE.MeshStandardMaterial({ color: 0x2563eb, metalness: 0.4, roughness: 0.4 });
meshes.motor = new THREE.Mesh(motorGeo, motorMat);
const motorXPos = b.motorSide === 'right' ? hw - 100 : -hw + 100;
meshes.motor.position.set(motorXPos, H + b.height * 0.5, 0);
scene.add(meshes.motor);
// === GUIDE RAILS (ExtrudeGeometry) ===
const railShape = new THREE.Shape();
const rw = g.width, rd = g.depth, rt = g.thickness, rl = g.lip;
// C-channel profile (XY plane, will extrude along Z = height)
railShape.moveTo(0, 0);
railShape.lineTo(rw, 0);
railShape.lineTo(rw, rl);
railShape.lineTo(rw - rt, rl);
railShape.lineTo(rw - rt, rt);
railShape.lineTo(rt, rt);
railShape.lineTo(rt, rl);
railShape.lineTo(0, rl);
railShape.lineTo(0, 0);
// Bottom lip
const railShape2 = new THREE.Shape();
railShape2.moveTo(0, rd - rl);
railShape2.lineTo(rt, rd - rl);
railShape2.lineTo(rt, rd - rt);
railShape2.lineTo(rw - rt, rd - rt);
railShape2.lineTo(rw - rt, rd - rl);
railShape2.lineTo(rw, rd - rl);
railShape2.lineTo(rw, rd);
railShape2.lineTo(0, rd);
railShape2.lineTo(0, rd - rl);
// Side walls
const railShapeL = new THREE.Shape();
railShapeL.moveTo(0, rl);
railShapeL.lineTo(rt, rl);
railShapeL.lineTo(rt, rd - rl);
railShapeL.lineTo(0, rd - rl);
railShapeL.lineTo(0, rl);
const railShapeR = new THREE.Shape();
railShapeR.moveTo(rw - rt, rl);
railShapeR.lineTo(rw, rl);
railShapeR.lineTo(rw, rd - rl);
railShapeR.lineTo(rw - rt, rd - rl);
railShapeR.lineTo(rw - rt, rl);
const railExtrude = { depth: H + b.height, bevelEnabled: false };
const railMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.5, roughness: 0.4 });
// Left rail
const railGroupL = new THREE.Group();
[railShape, railShape2, railShapeL, railShapeR].forEach(s => {
const geo = new THREE.ExtrudeGeometry(s, railExtrude);
const mesh = new THREE.Mesh(geo, railMat);
railGroupL.add(mesh);
});
railGroupL.rotation.x = -Math.PI / 2;
railGroupL.position.set(-hw - rw / 2, 0, rd / 2);
meshes.rails = new THREE.Group();
meshes.rails.add(railGroupL);
// Right rail (mirror)
const railGroupR = railGroupL.clone();
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)
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 = H - i * slatPitch;
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);
}
// === 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 (semi-transparent) ===
const wallGeo = new THREE.BoxGeometry(W1 + 200, H + b.height + 100, 100);
const wallMat = new THREE.MeshStandardMaterial({ color: 0xa1887f, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
meshes.wall = new THREE.Mesh(wallGeo, wallMat);
meshes.wall.position.set(0, (H + b.height) / 2, -rd / 2 - 50);
scene.add(meshes.wall);
// Camera target
controls.target.set(0, H / 2, 0);
camera.position.set(W * 1.2, H * 0.8, W * 1.5);
controls.update();
}
// 3D Controls
window.fs3dShutterPos = function(v) {
S.td.shutterPos = Number(v);
$('shutterPosLabel').textContent = v + '%';
fs3dBuild();
};
window.fs3dOpacity = function(v) {
S.td.caseOpacity = Number(v) / 100;
$('opacityLabel').textContent = v + '%';
if (meshes.case) meshes.case.material.opacity = S.td.caseOpacity;
};
window.fsToggle3d = function(el, key) {
el.classList.toggle('active');
S.td.show[key] = el.classList.contains('active');
if (meshes[key]) meshes[key].visible = S.td.show[key];
};
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);
};
// ============================
// 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
// ============================
fsCalc();
refreshPresetList();
fsSwitch('Settings');
})();
</script>
@endpush
@endsection