- 모터 숨기면 복주머니+플랜지도 함께 숨김 - 샤프트는 복주머니 없이 자기 구간까지만 표시 - 모터 ON: 브라켓 ← 모터 ← 출력축 ← 복주머니 ← 샤프트 - 모터 OFF: 샤프트만 남음 (비모터측 브라켓+환봉까지)
1639 lines
97 KiB
PHP
1639 lines
97 KiB
PHP
@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 m²</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
|