Files
sam-manage/resources/views/rd/fire-shutter-drawing/index.blade.php
김보곤 b3cd1ffebc fix: [rd] 복주머니+플랜지를 모터 그룹으로 이동
- 모터 숨기면 복주머니+플랜지도 함께 숨김
- 샤프트는 복주머니 없이 자기 구간까지만 표시
- 모터 ON: 브라켓 ← 모터 ← 출력축 ← 복주머니 ← 샤프트
- 모터 OFF: 샤프트만 남음 (비모터측 브라켓+환봉까지)
2026-03-08 22:12:33 +09:00

1639 lines
97 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="120" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">레일 깊이 (mm)</label>
<input type="number" id="grDepth" class="fs-input" value="75" 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="15" 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="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="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="520" 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="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 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>
<!-- 벽체 설정 -->
<section class="fs-section mt-2" 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="500" 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;">500</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="600" value="200" 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;">200</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: 'steel',
openWidth: 2000,
openHeight: 3000,
quantity: 1,
// Guide Rail
gr: { width:120, depth:75, thickness:1.55, lip:15, sealThick:0.8, sealDepth:40, slatThick:1.6, anchorSpacing:500, viewMode:'cross', showDim:true, showSeal:true },
// Shutter Box
sb: { width:2210, height:380, depth:520, thickness:1.6, shaftDia:120, 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:true, slatRoll:true } },
// Wall (벽체)
wall: { wing:500, thick:200, 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 },
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">셔터박스 (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;
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) — ${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 + 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);
}
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;
// === 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 ASSEMBLY (brackets + 환봉 + shaft + 복주머니 + motor) ===
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();
// Bracket dimensions
const bkW = b.bracketW; // 두께 (X방향)
const bkH = b.height * 0.7; // 높이 (Y방향)
const bkD = b.depth * 0.6; // 깊이 (Z방향)
const motorDir = b.motorSide === 'right' ? 1 : -1;
// Inner edge of brackets (where 환봉/복주머니 connects)
const bracketInnerX = W1 / 2 - bkW; // bracket inner face X
const roundBarDia = 40; // 환봉 직경
const couplingDia = 80; // 복주머니 직경
const couplingLen = 80; // 복주머니 길이
// 환봉 길이 = bracket inner → shaft end gap
const roundBarLen = 60;
const nonMotorSide = -motorDir; // 모터 반대쪽
// 주축 길이 = 비모터측(bracket+환봉) + 모터측(복주머니) 제외
const mainShaftLen = W1 - bkW - roundBarLen - couplingLen;
// --- Non-motor side Bracket (모터 반대쪽만 브라켓 존재) ---
const bkGeo = new THREE.BoxGeometry(bkW, bkH, bkD);
const bkMesh = new THREE.Mesh(bkGeo, bracketMat);
bkMesh.position.set(nonMotorSide * (W1 / 2 - bkW / 2), 0, 0);
meshes.shaft.add(bkMesh);
// --- Non-motor side: 환봉 (round bar, thin cylinder) ---
const rbGeo = new THREE.CylinderGeometry(roundBarDia / 2, roundBarDia / 2, roundBarLen, 16);
rbGeo.rotateZ(Math.PI / 2);
const rbMesh = new THREE.Mesh(rbGeo, shaftMat);
const rbCenterX = nonMotorSide * (W1 / 2 - bkW - roundBarLen / 2);
rbMesh.position.set(rbCenterX, 0, 0);
meshes.shaft.add(rbMesh);
// --- Main Shaft (center cylinder) ---
const msGeo = new THREE.CylinderGeometry(b.shaftDia / 2, b.shaftDia / 2, mainShaftLen, 32);
msGeo.rotateZ(Math.PI / 2);
const msMesh = new THREE.Mesh(msGeo, shaftMat);
// 주축 중심: 환봉쪽 끝 + mainShaftLen/2 쪽으로 이동
const msStartX = nonMotorSide * (W1 / 2 - bkW - roundBarLen);
const msCenterX = msStartX - nonMotorSide * mainShaftLen / 2;
msMesh.position.set(msCenterX, 0, 0);
meshes.shaft.add(msMesh);
// --- Motor side: 복주머니+플랜지는 모터 그룹에 추가 (아래 모터 섹션에서 생성) ---
// cpCenterX, couplingDia 등은 모터 섹션에서 사용
const cpCenterX = motorDir * (W1 / 2 - couplingLen / 2);
meshes.shaft.position.set(0, shaftY, 0);
scene.add(meshes.shaft);
// === MOTOR (DH-150K/300K 스타일: 복주머니 + 본체 + 기어박스 + 베이스 + 다리) ===
meshes.motor = new THREE.Group();
const motorR = b.shaftDia * 0.6; // 모터 반지름 (샤프트 1.2배)
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 blueMat = new THREE.MeshStandardMaterial({ color: 0x2563eb, metalness: 0.4, roughness: 0.4 });
// 0) 복주머니 + 플랜지 (모터 그룹 소속 — 모터 숨기면 함께 숨김)
const cpGeo = new THREE.CylinderGeometry(couplingDia / 2, couplingDia / 2, couplingLen, 24);
cpGeo.rotateZ(Math.PI / 2);
const cpMat = new THREE.MeshStandardMaterial({ color: 0x78716c, metalness: 0.7, roughness: 0.2 });
const cpMesh = new THREE.Mesh(cpGeo, cpMat);
cpMesh.position.set(cpCenterX, 0, 0);
meshes.motor.add(cpMesh);
const flangeGeo = new THREE.CylinderGeometry(couplingDia / 2 + 10, couplingDia / 2 + 10, 6, 24);
flangeGeo.rotateZ(Math.PI / 2);
const flangeMat = new THREE.MeshStandardMaterial({ color: 0x57534e, metalness: 0.8, roughness: 0.2 });
const flange = new THREE.Mesh(flangeGeo, flangeMat);
flange.position.set(motorDir * (W1 / 2 - 3), 0, 0);
meshes.motor.add(flange);
// 모터 위치: 브라켓 안쪽, 복주머니 옆
const motorBodyLen = motorR * 2.5; // 본체 길이
const gearBoxLen = motorR * 1.2; // 기어박스 길이
const totalMotorLen = motorBodyLen + gearBoxLen;
const motorStartX = motorDir * (W1 / 2 - couplingLen); // 복주머니 끝 (모터측 브라켓 없음)
const motorMidX = motorStartX - motorDir * totalMotorLen / 2; // 모터 전체 중심
// 1) 모터 본체 (은색 원통 — 메인 실린더)
const bodyGeo = new THREE.CylinderGeometry(motorR, motorR, motorBodyLen, 24);
bodyGeo.rotateZ(Math.PI / 2);
const body = new THREE.Mesh(bodyGeo, metalMat);
const bodyCX = motorStartX - motorDir * (gearBoxLen + motorBodyLen / 2);
body.position.set(bodyCX, 0, 0);
meshes.motor.add(body);
// 2) 상단 리브 (본체 위의 냉각 핀 3줄)
for (let i = 0; i < 3; i++) {
const ribGeo = new THREE.BoxGeometry(motorBodyLen * 0.7, 4, motorR * 0.15);
const rib = new THREE.Mesh(ribGeo, darkMat);
const ribZ = (i - 1) * motorR * 0.5;
rib.position.set(bodyCX, motorR + 2, ribZ);
meshes.motor.add(rib);
}
// 3) 기어박스 (파란색, 본체보다 살짝 넓은 박스 — 복주머니 쪽)
const gearR = motorR * 1.15;
const gearGeo = new THREE.CylinderGeometry(gearR, gearR, gearBoxLen, 24);
gearGeo.rotateZ(Math.PI / 2);
const gear = new THREE.Mesh(gearGeo, blueMat);
const gearCX = motorStartX - motorDir * (gearBoxLen / 2);
gear.position.set(gearCX, 0, 0);
meshes.motor.add(gear);
// 4) 기어박스 전면 플랜지 (복주머니 결합면)
const gfGeo = new THREE.CylinderGeometry(gearR + 8, gearR + 8, 5, 24);
gfGeo.rotateZ(Math.PI / 2);
const gf = new THREE.Mesh(gfGeo, darkMat);
gf.position.set(motorStartX - motorDir * 2.5, 0, 0);
meshes.motor.add(gf);
// 5) 후면 마감판 (본체 뒤쪽)
const ecGeo = new THREE.CylinderGeometry(motorR + 3, motorR + 3, 6, 24);
ecGeo.rotateZ(Math.PI / 2);
const ec = new THREE.Mesh(ecGeo, darkMat);
ec.position.set(bodyCX - motorDir * (motorBodyLen / 2 + 3), 0, 0);
meshes.motor.add(ec);
// 6) 마운팅 베이스 (브라켓 위에 안착하는 플레이트)
const baseW = totalMotorLen * 0.85;
const baseH = 6;
const baseD = motorR * 1.6;
const baseGeo = new THREE.BoxGeometry(baseW, baseH, baseD);
const basePlate = new THREE.Mesh(baseGeo, darkMat);
const baseY = -motorR - baseH / 2;
basePlate.position.set(motorMidX, baseY, 0);
meshes.motor.add(basePlate);
// 7) 마운팅 다리 (베이스 → 브라켓까지 연결, 3개)
const legH = bkH / 2 - motorR - baseH; // 베이스 하단 ~ 브라켓 상면
if (legH > 0) {
const legGeo = new THREE.BoxGeometry(10, legH, 20);
const legOffsets = [-baseW * 0.35, 0, baseW * 0.35];
legOffsets.forEach(dx => {
const leg = new THREE.Mesh(legGeo, darkMat);
leg.position.set(motorMidX + motorDir * dx, baseY - baseH / 2 - legH / 2, 0);
meshes.motor.add(leg);
});
}
// 8) 출력축 (기어박스 → 복주머니 방향)
const outR = b.shaftDia * 0.2;
const outLen = 30;
const outGeo = new THREE.CylinderGeometry(outR, outR, outLen, 12);
outGeo.rotateZ(Math.PI / 2);
const outShaft = new THREE.Mesh(outGeo, metalMat);
outShaft.position.set(motorStartX + motorDir * (outLen / 2), 0, 0);
meshes.motor.add(outShaft);
// 9) 모터측 브라켓 (모터가 안착되는 브라켓 — 모터 그룹에 포함)
const motorBkGeo = new THREE.BoxGeometry(bkW, bkH, bkD);
const motorBk = new THREE.Mesh(motorBkGeo, bracketMat);
motorBk.position.set(motorDir * (W1 / 2 - bkW / 2), 0, 0);
meshes.motor.add(motorBk);
meshes.motor.position.set(0, shaftY, 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);
// 가이드레일: 바닥(0)부터 샤프트 아래 100mm까지 (셔터박스 상단까지 올라가지 않음)
const railHeight = H - 100; // 개구부 높이 - 100mm (샤프트와 간격)
const railExtrude = { depth: railHeight > 0 ? railHeight : H, 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) — 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) {
// 철재: ㄷ자 슬랫(72mm 폭) 감길 때 두께 ~10mm / 스크린: ~1mm
const wrapThick = S.productType === 'steel' ? 10 : 1;
const shaftR = b.shaftDia / 2;
// 감긴 바퀴 수 = 감긴 높이 / (샤프트 둘레 + 누적 두께 보정)
// 간략 계산: 감긴 총 두께 = sqrt(rolledH * wrapThick / π)
const rollThick = Math.sqrt(rolledH * wrapThick / Math.PI);
const rollOuterR = shaftR + rollThick;
// 롤 길이 = 주축 길이 (브라켓 사이)
const rollLen = mainShaftLen;
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 32);
rollGeo.rotateZ(Math.PI / 2);
const rollMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0x78716c : 0xa78bfa,
metalness: S.productType === 'steel' ? 0.3 : 0,
roughness: S.productType === 'steel' ? 0.6 : 0.8,
transparent: true,
opacity: 0.85,
});
meshes.slatRoll = new THREE.Mesh(rollGeo, rollMat);
meshes.slatRoll.position.set(msCenterX, shaftY, 0);
scene.add(meshes.slatRoll);
// 철재: 감긴 ㄷ자 슬랫 골 표현 (나선형 링 라인)
if (S.productType === 'steel' && rollThick > 10) {
const ringGroup = new THREE.Group();
const ringCount = Math.min(Math.floor(rollThick / (wrapThick * 0.15)), 12);
for (let i = 1; i <= ringCount; i++) {
const r = shaftR + (rollThick * i / (ringCount + 1));
const pts = [];
for (let a = 0; a <= 64; a++) {
const angle = (a / 64) * Math.PI * 2;
pts.push(new THREE.Vector3(0, Math.sin(angle) * r, Math.cos(angle) * r));
}
const ringGeo = new THREE.BufferGeometry().setFromPoints(pts);
const ring = new THREE.Line(ringGeo, new THREE.LineBasicMaterial({ color: 0x57534e, opacity: 0.5, transparent: true }));
ring.position.x = msCenterX;
ringGroup.add(ring);
}
meshes.slatRoll.add(ringGroup);
}
}
// === 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 (좌/우 기둥 + 상부 인방) ===
// 기둥: 하나의 통 박스 (바닥~전체높이, 개구부 양옆)
// 인방: 셔터박스 윗면부터 위로만 (셔터박스 내부 침범 안 함)
const wl = S.wall;
const wallH = H + b.height + wl.topMargin;
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();
// 좌/우 기둥 (하나의 통 박스)
if (wl.wing > 0) {
const colGeo = new THREE.BoxGeometry(wl.wing, wallH, wl.thick);
const leftCol = new THREE.Mesh(colGeo, wallMat);
leftCol.position.set(-W1 / 2 - wl.wing / 2, wallH / 2, 0);
meshes.wall.add(leftCol);
const rightCol = new THREE.Mesh(colGeo.clone(), wallMat);
rightCol.position.set(W1 / 2 + wl.wing / 2, wallH / 2, 0);
meshes.wall.add(rightCol);
}
// 상부 인방 (셔터박스 윗면부터 위로만)
if (wl.topMargin > 0) {
const totalW = W1 + wl.wing * 2;
const lintelGeo = new THREE.BoxGeometry(totalW, wl.topMargin, wl.thick);
const lintel = new THREE.Mesh(lintelGeo, wallMat);
lintel.position.set(0, H + b.height + wl.topMargin / 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);
// 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;
}
}
// 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);
};
window.fs3dWall = function(key, v) {
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) {
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
// ============================
fsCalc();
refreshPresetList();
fsSwitch('Settings');
})();
</script>
@endpush
@endsection