Files
김보곤 c2ab0f120d feat: [fire-shutter] 3D 철재스라트 가이드레일 상세 프로파일 적용
- ② 본체: 15세그먼트 절곡 프로파일 (78 선반, 30 내부벽, 스텝 등)
- ④ 벽연형: 30-45-30 ㄷ자 브라켓
- ① 마감재: SUS 상/하 대칭 (120 수평부, 13탭, 10립, 25+15 우측)
2026-03-16 21:05:33 +09:00

3513 lines
222 KiB
PHP
Raw Permalink 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; }
.fs-ctx-menu { position: fixed; display: none; z-index: 10000; background: rgba(15,23,42,0.95); border: 1px solid #334155; border-radius: 0.5rem; padding: 0.25rem; backdrop-filter: blur(12px); box-shadow: 0 10px 25px rgba(0,0,0,0.5); min-width: 160px; }
.fs-ctx-btn { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.75rem; border: none; background: transparent; color: #e2e8f0; font-size: 0.8125rem; font-weight: 700; cursor: pointer; border-radius: 0.375rem; white-space: nowrap; text-align: left; }
.fs-ctx-btn:hover { background: rgba(59,130,246,0.2); color: #60a5fa; }
.fs-ctx-sep { height: 1px; background: #334155; margin: 0.25rem 0; }
.fs-iso-badge { position: absolute; top: 12px; left: 12px; display: none; padding: 6px 14px; background: rgba(59,130,246,0.9); color: white; font-size: 0.75rem; font-weight: 900; border-radius: 0.5rem; z-index: 100; cursor: pointer; box-shadow: 0 4px 12px rgba(59,130,246,0.4); }
.fs-iso-badge:hover { background: rgba(37,99,235,1); }
</style>
<div class="fs-wrap">
<main style="max-width:1800px; margin:0 auto; padding:1rem 1.5rem;">
<!-- ========== TOP: 기본 설정 (전체 ) ========== -->
<div class="fs-section mb-3" style="padding:0.75rem 1rem;">
<div class="flex gap-3 items-end">
<div style="flex:0 0 120px;">
<label class="fs-label">유형</label>
<select id="productType" class="fs-select" style="font-size:0.75rem;padding:0.375rem 0.5rem;" onchange="fsOnProductType()">
<option value="steel" selected>철재스라트</option>
<option value="screen">스크린형</option>
</select>
</div>
<div style="flex:0 0 130px;">
<label class="fs-label">제품 모델</label>
<select id="productModel" class="fs-select" style="font-size:0.75rem;padding:0.375rem 0.5rem;" onchange="fsOnModelChange()">
<optgroup label="철재스라트">
<option value="KFS01" selected>KFS01 - 기본</option>
<option value="KFS02">KFS02 - 대형</option>
</optgroup>
<optgroup label="스크린형">
<option value="KSS01">KSS01 - 실리카</option>
<option value="KSS02">KSS02 - 와이어</option>
</optgroup>
</select>
</div>
<div style="flex:0 0 90px;">
<label class="fs-label">오픈폭</label>
<input type="number" id="openWidth" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="2000" onchange="fsCalc()">
</div>
<div style="flex:0 0 90px;">
<label class="fs-label">오픈H</label>
<input type="number" id="openHeight" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="3000" onchange="fsCalc()">
</div>
<div style="flex:0 0 60px;">
<label class="fs-label">수량</label>
<input type="number" id="quantity" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="1" min="1" onchange="fsCalc()">
</div>
<div class="flex-1"></div>
<div class="flex items-center gap-3 text-[11px] text-slate-400 shrink-0">
<span>W1: <b id="calcW1" class="text-white">2110</b></span>
<span>H1: <b id="calcH1" class="text-white">3350</b></span>
<span>면적: <b id="calcArea" class="text-white">7.07</b></span>
<span>중량: <b id="calcWeight" class="text-white">176.7</b>kg</span>
<span>모터: <b id="calcMotor" class="text-blue-400">300K</b></span>
<span>레일: <b id="calcRailCombo" class="text-amber-400">3,150mm × 2</b></span>
</div>
<button onclick="var d=document.getElementById('settingsDetail');d.classList.toggle('hidden');this.querySelector('span').textContent=d.classList.contains('hidden')?'▼':'▲';" class="text-[11px] text-slate-500 hover:text-slate-300 font-bold px-2 py-1 rounded hover:bg-slate-800 transition-colors shrink-0">
<span></span>
</button>
</div>
<!-- 접기/펼치기 상세 영역 -->
<div id="settingsDetail" class="hidden mt-3 pt-3 border-t border-slate-700/50">
<div class="flex gap-6">
<div class="flex-1 space-y-2">
<span class="text-[10px] text-slate-500 font-bold">프리셋</span>
<div class="flex gap-2">
<select id="presetSelect" class="fs-select flex-1" style="font-size:0.75rem;"><option value="">-- 선택 --</option></select>
<button class="fs-btn fs-btn-primary" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsLoadPreset()">불러오기</button>
</div>
<div class="flex gap-2">
<input type="text" id="presetName" class="fs-input flex-1" style="font-size:0.75rem;" placeholder="프리셋 이름">
<button class="fs-btn fs-btn-primary" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsSavePreset()">저장</button>
<button class="fs-btn fs-btn-ghost" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsDeletePreset()">삭제</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========== BOTTOM: 좌측 컨트롤 + 우측 ========== -->
<div class="flex" style="gap:1.25rem; height:calc(100vh - 170px);">
<!-- LEFT PANEL -->
<div class="custom-scrollbar" style="width:28%; flex-shrink:0; overflow-y:auto; padding-right:0.5rem;">
<!-- Tab Buttons (1x3) -->
<div class="grid gap-1 mb-3 bg-slate-900/50 p-1.5 rounded-xl border border-slate-800" style="grid-template-columns:1fr 1fr 1fr;">
<button id="tabGuideRail" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('GuideRail')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
가이드레일
</button>
<button id="tabShutterBox" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('ShutterBox')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="8" rx="2"/><path d="M6 12v4"/><path d="M18 12v4"/></svg>
셔터박스
</button>
<button id="tab3D" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('3D')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
3D 렌더링
</button>
</div>
<!-- ========== GUIDE RAIL TAB CONTROLS ========== -->
<div id="ctrlGuideRail" class="hidden">
<section class="fs-section space-y-5">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="fs-badge bg-purple-600">GR</span>
가이드레일 파라미터
</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">레일 전체 (mm)</label>
<input type="number" id="grWidth" class="fs-input" value="120" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">레일 깊이 (mm)</label>
<input type="number" id="grDepth" class="fs-input" value="70" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">강판 두께 (mm)</label>
<input type="number" id="grThickness" class="fs-input" value="1.55" step="0.01" onchange="fsRender()">
</div>
<div>
<label class="fs-label">(입구) 높이 (mm)</label>
<input type="number" id="grLip" class="fs-input" value="10" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 두께 (mm)</label>
<input type="number" id="grSealThick" class="fs-input" value="0.8" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 깊이 (mm)</label>
<input type="number" id="grSealDepth" class="fs-input" value="40" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">슬랫 두께 (mm)</label>
<input type="number" id="grSlatThick" class="fs-input" value="0.8" step="0.1" onchange="fsRender()">
</div>
<div id="grAnchorSpacingWrap">
<label class="fs-label">앵커볼트 간격 (mm)</label>
<input type="number" id="grAnchorSpacing" class="fs-input" value="500" step="10" onchange="fsRender()">
</div>
</div>
</section>
<section class="fs-section space-y-3 mt-4">
<h2 class="text-sm font-black text-slate-400"> 옵션</h2>
<div class="flex flex-wrap gap-2">
<button class="fs-btn fs-btn-ghost active" data-grview="cross" onclick="fsGrView('cross')">평면도</button>
<button class="fs-btn fs-btn-ghost" data-grview="front" onclick="fsGrView('front')">정면도</button>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400 font-bold">치수 표시</span>
<div id="toggleDim" class="fs-toggle active" onclick="fsToggle(this,'showDim')"></div>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-slate-400 font-bold">연기차단재 표시</span>
<div id="toggleSeal" class="fs-toggle active" onclick="fsToggle(this,'showSeal')"></div>
</div>
</section>
</div>
<!-- ========== SHUTTER BOX TAB CONTROLS ========== -->
<div id="ctrlShutterBox" class="hidden">
<section class="fs-section space-y-5">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="fs-badge bg-amber-600">SB</span>
셔터박스 파라미터
</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">케이스 (mm)</label>
<input type="number" id="sbWidth" class="fs-input" value="2210" step="10" onchange="fsRender()">
</div>
<div>
<label class="fs-label">케이스 높이 (mm)</label>
<input type="number" id="sbHeight" class="fs-input" value="380" step="10" onchange="fsRender()">
</div>
<div>
<label class="fs-label">케이스 깊이 (mm)</label>
<input type="number" id="sbDepth" class="fs-input" value="500" step="10" onchange="fsRender()">
</div>
<div>
<label class="fs-label">강판 두께 (mm)</label>
<input type="number" id="sbThickness" class="fs-input" value="1.6" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">샤프트 직경 (mm)</label>
<input type="number" id="sbShaftDia" class="fs-input" value="80" step="5" onchange="fsRender()">
</div>
<div>
<label class="fs-label">브래킷 (mm)</label>
<input type="number" id="sbBracketW" class="fs-input" value="10" step="1" onchange="fsRender()">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">레일폭 (mm)</label>
<input type="number" id="sbRailWidth" class="fs-input" value="70" step="5" onchange="fsRender()">
</div>
<div>
<label class="fs-label">전면 밑치수 (mm)</label>
<input type="number" id="sbFrontBottom" class="fs-input" value="50" step="5" onchange="fsRender()">
</div>
</div>
<div>
<label class="fs-label">모터 위치</label>
<select id="sbMotorSide" class="fs-select" onchange="fsRender()">
<option value="right">우측</option>
<option value="left">좌측</option>
</select>
</div>
<div>
<label class="fs-label">점검구 방향</label>
<select id="sbDoorDir" class="fs-select" onchange="fsSetDoorDir(this.value)">
<option value="dual" selected>양면 (밑면+후면)</option>
<option value="bottom">밑면</option>
<option value="rear">후면</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" data-sbview="front" onclick="fsSbView('front')">정면도</button>
<button class="fs-btn fs-btn-ghost active" 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="grid gap-1 mb-1" style="grid-template-columns:1fr 1fr 1fr;">
<div>
<label class="text-[9px] text-slate-500 font-bold">깊이</label>
<input type="number" id="td3dDepth" class="fs-input" style="font-size:0.6rem;padding:0.2rem 0.3rem;" value="500" step="10" onchange="fsSet3dParam('depth',this.value)">
</div>
<div>
<label class="text-[9px] text-slate-500 font-bold">높이</label>
<input type="number" id="td3dHeight" class="fs-input" style="font-size:0.6rem;padding:0.2rem 0.3rem;" value="380" step="10" onchange="fsSet3dParam('height',this.value)">
</div>
<div>
<label class="text-[9px] text-slate-500 font-bold">샤프트⌀</label>
<input type="number" id="td3dShaftDia" class="fs-input" style="font-size:0.6rem;padding:0.2rem 0.3rem;" value="102" step="1" onchange="fsSet3dParam('shaftDia',this.value)">
</div>
</div>
<!-- 가변 파라미터 -->
<div class="grid gap-1 mb-2" style="grid-template-columns:1fr 1fr 1fr 1fr;">
<div>
<label class="text-[9px] text-slate-500 font-bold">점검구</label>
<select id="td3dDoorDir" class="fs-select" style="font-size:0.6rem;padding:0.2rem 0.3rem;" onchange="fsSetDoorDir(this.value)">
<option value="dual">양면</option>
<option value="bottom">밑면</option>
<option value="rear">후면</option>
</select>
</div>
<div>
<label class="text-[9px] text-slate-500 font-bold">레일폭</label>
<input type="number" id="td3dRailWidth" class="fs-input" style="font-size:0.6rem;padding:0.2rem 0.3rem;" value="70" step="5" onchange="fsSet3dParam('railWidth',this.value)">
</div>
<div>
<label class="text-[9px] text-slate-500 font-bold">전면밑</label>
<input type="number" id="td3dFrontBottom" class="fs-input" style="font-size:0.6rem;padding:0.2rem 0.3rem;" value="50" step="5" onchange="fsSet3dParam('frontBottom',this.value)">
</div>
<div>
<label class="text-[9px] text-slate-500 font-bold">두께</label>
<input type="number" id="td3dThickness" class="fs-input" style="font-size:0.6rem;padding:0.2rem 0.3rem;" value="1.6" step="0.1" onchange="fsSet3dParam('thickness',this.value)">
</div>
</div>
<!-- 개폐율: -->
<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="50" 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;">50%</span>
</div>
<!-- 투명도: -->
<div class="flex items-center gap-2 mb-2">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:24px;">투명</label>
<input type="range" id="caseOpacity" min="0" max="100" value="30" class="flex-1 accent-blue-500" style="height:3px;" oninput="fs3dOpacity(this.value)">
<span id="opacityLabel" class="text-[10px] text-blue-400 font-black shrink-0" style="width:28px;text-align:right;">30%</span>
</div>
<!-- 부품: 4 compact grid -->
<div class="grid gap-x-0.5 gap-y-0.5 mb-2" style="grid-template-columns:repeat(4,auto);">
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'case')"></div><span class="text-[10px] text-slate-500">박스</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'shaft')"></div><span class="text-[10px] text-slate-500">샤프트</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'motor')"></div><span class="text-[10px] text-slate-500">모터</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'rails')"></div><span class="text-[10px] text-slate-500">레일</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'slats')"></div><span class="text-[10px] text-slate-500">슬랫</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'bottomBar')"></div><span class="text-[10px] text-slate-500">하장바</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle" onclick="fsToggle3d(this,'wall')"></div><span class="text-[10px] text-slate-500"></span></div>
</div>
<!-- 조명색 + 배경색: -->
<div class="flex items-center gap-3 border-t border-slate-800 pt-1.5">
<div class="flex items-center gap-1"><label class="text-[10px] text-slate-500 font-bold">조명</label><input type="color" id="lightColor" value="#ffffff" class="w-4 h-4 rounded cursor-pointer border-0 p-0" style="background:none;" onchange="fs3dLightColor(this.value)"></div>
<div class="flex items-center gap-1">
<label class="text-[10px] text-slate-500 font-bold">배경</label>
<button onclick="fs3dBg('#ffffff')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#fff"></button>
<button onclick="fs3dBg('#f0f0f0')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#f0f0f0"></button>
<button onclick="fs3dBg('#808080')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#808080"></button>
<button onclick="fs3dBg('#303030')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#303030"></button>
<button onclick="fs3dBg('#1a1a2e')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#1a1a2e"></button>
<button onclick="fs3dBg('#000')" class="w-3.5 h-3.5 rounded border border-slate-600 hover:border-blue-400" style="background:#000"></button>
</div>
</div>
</section>
<!-- 벽체 설정 ( 토글 OFF 숨김) -->
<section id="wallSettings" class="fs-section mt-2 hidden" style="padding:0.75rem 1rem;">
<h3 class="text-[11px] font-black text-slate-300 mb-2 flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="6" width="22" height="15" rx="2"/><path d="M1 10h22"/></svg>
벽체 설정
</h3>
<!-- 날개벽 -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">날개벽</label>
<input type="range" id="wallWing" min="0" max="2000" value="600" step="50" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('wing',this.value)">
<span id="wallWingLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">600</span>
</div>
<!-- 두께 -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">두께</label>
<input type="range" id="wallThick" min="100" max="1000" value="600" step="10" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('thick',this.value)">
<span id="wallThickLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">600</span>
</div>
<!-- 상단 여유 -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">상단</label>
<input type="range" id="wallTopMargin" min="0" max="1000" value="300" step="50" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('topMargin',this.value)">
<span id="wallTopMarginLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">300</span>
</div>
<!-- 투명도 -->
<div class="flex items-center gap-2 mb-2">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:42px;">투명도</label>
<input type="range" id="wallOpacity" min="0" max="100" value="30" class="flex-1 accent-amber-500" style="height:3px;" oninput="fs3dWall('opacity',this.value)">
<span id="wallOpacityLabel" class="text-[10px] text-amber-400 font-black shrink-0" style="width:40px;text-align:right;">30%</span>
</div>
<!-- 색상 프리셋 -->
<div class="flex items-center gap-1.5">
<label class="text-[10px] text-slate-500 font-bold shrink-0">색상</label>
<button onclick="fs3dWallColor('#a1887f')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#a1887f" title="콘크리트"></button>
<button onclick="fs3dWallColor('#c0785c')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#c0785c" title="벽돌"></button>
<button onclick="fs3dWallColor('#e8e8e8')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#e8e8e8" title="흰색 도장"></button>
<button onclick="fs3dWallColor('#78909c')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#78909c" title="커튼월"></button>
<button onclick="fs3dWallColor('#8d6e63')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#8d6e63" title="갈색 벽돌"></button>
<button onclick="fs3dWallColor('#b0bec5')" class="w-5 h-5 rounded border border-slate-600 hover:border-amber-400 hover:scale-110 transition-all" style="background:#b0bec5" title="ALC"></button>
<input type="color" id="wallColorPicker" value="#a1887f" class="w-5 h-5 rounded cursor-pointer border border-slate-600 p-0" style="background:none;" onchange="fs3dWallColor(this.value)" title="직접 선택">
</div>
</section>
</div>
</div>
<!-- RIGHT PANEL: 70% -->
<div style="flex:1; min-width:0;">
<section class="bg-slate-900 rounded-2xl border border-slate-800 shadow-2xl flex flex-col overflow-hidden relative" style="height:100%;">
<!-- Preview Header -->
<div class="px-8 py-5 border-b border-slate-800 flex items-center justify-between bg-slate-900/50 backdrop-blur">
<div class="flex items-center gap-4">
<h3 id="previewTitle" class="text-sm font-black text-blue-500 uppercase tracking-[0.2em] flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/><path d="M21 12c-1.889 2.991-4.67 5-9 5s-7.111-2.009-9-5c1.889-2.991 4.67-5 9-5s7.111 2.009 9 5Z"/></svg>
방화셔터 도면 미리보기
</h3>
<div class="h-4 w-px bg-slate-700"></div>
<span id="scaleDisplay" class="text-[10px] font-mono text-slate-500">SCALE: 100%</span>
</div>
<div class="flex items-center gap-2">
<button class="fs-btn fs-btn-ghost" onclick="fsExportPng()">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
PNG
</button>
<button class="fs-btn fs-btn-ghost" onclick="fsExportDxf()">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
DXF
</button>
<button class="fs-btn fs-btn-ghost" onclick="fsExportJson()">JSON</button>
</div>
</div>
<!-- Canvas -->
<div id="canvasViewport" class="flex-1 bg-[#050507] relative overflow-hidden cursor-grab active:cursor-grabbing">
<div id="svgContainer" class="absolute inset-0 flex items-center justify-center select-none"></div>
<div id="threeDContainer" class="absolute inset-0 hidden"></div>
<!-- Zoom Controls -->
<div class="absolute bottom-6 right-6 flex items-center gap-2 z-10">
<button id="zoomInBtn" class="w-10 h-10 rounded-xl bg-slate-800/80 border border-slate-700 text-white font-black text-lg hover:bg-slate-700 transition-all">+</button>
<button id="zoomOutBtn" class="w-10 h-10 rounded-xl bg-slate-800/80 border border-slate-700 text-white font-black text-lg hover:bg-slate-700 transition-all"></button>
<button id="zoomResetBtn" class="px-4 h-10 rounded-xl bg-slate-800/80 border border-slate-700 text-slate-400 font-black text-xs hover:bg-slate-700 transition-all">리셋</button>
</div>
</div>
</section>
</div>
</div>
</main>
</div>
<!-- Three.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js"></script>
@push('scripts')
<script>
(function(){
'use strict';
const $ = id => document.getElementById(id);
// ============================
// STATE
// ============================
const S = {
tab: 'GuideRail',
productType: 'steel',
openWidth: 2000,
openHeight: 3000,
quantity: 1,
// Guide Rail
gr: { width:70, depth:120, thickness:1.55, lip:10, flange:30, sideWall:80, backWall:67, trimThick:1.2, sealThick:0.8, sealDepth:40, slatThick:0.8, anchorSpacing:500, viewMode:'cross', showDim:true, showSeal:true },
// Shutter Box
sb: { width:2280, height:380, depth:500, thickness:1.6, shaftDia:102, railWidth:70, frontBottom:50, bracketW:10, motorSide:'right', doorDir:'dual', viewMode:'side', showShaft:true, showSlatRoll:true, showMotor:true, showBrake:true, showSpring:true },
// 3D
td: { shutterPos:50, caseOpacity:0.3, lightPreset:'default', bgColor:'#ffffff', show:{ case:true, shaft:true, motor:true, rails:true, slats:true, bottomBar:true, wall:false, slatRoll:true } },
// Wall (벽체)
wall: { wing:600, thick:600, topMargin:300, color:'#a1887f', opacity:30 },
// View
view: { scale:1, offset:{x:0,y:0}, dragging:false, lastMouse:{x:0,y:0} },
// Presets
presets: JSON.parse(localStorage.getItem('fs_presets')||'[]'),
};
// Product defaults
const PRODUCTS = {
steel: { marginW:110, marginH:350, weightFactor:25, gr:{width:75,depth:130,thickness:1.55,lip:15}, slatThick:1.6,
sb:{height:550, depth:650, frontH:410, bottomOpen:75, shaftDia:120},
bk:{nmH:320, nmD:320, mtH:320, mtD:530, thick:18, sprocketR:215, motorSpR:40, motorOffset:160} },
screen: { marginW:140, marginH:350, weightFactor:2,
gr:{width:70, depth:120, thickness:1.55, lip:10, flange:30, sideWall:80, backWall:67, trimThick:1.2},
slatThick:0.8,
sb:{height:380, depth:500, frontH:240, rearDoorH:240, bottomDoorW:240, shaftDia:102, // 4인치 외경 (101.6mm)
frontPanel:[17, 55, 50, 380, 55, 15, 20]}, // 케이스 전면판 절곡 프로파일 (세그먼트 1~7, 합계588)
bk:{nmH:180, nmD:180, mtH:180, mtD:380, thick:18, sprocketR:70, motorSpR:30, motorOffset:0} },
};
const MOTORS = [{max:150,spec:'150K',inch:4},{max:300,spec:'300K',inch:4},{max:500,spec:'500K',inch:5},{max:750,spec:'750K',inch:5},{max:1000,spec:'1000K',inch:6},{max:1500,spec:'1500K',inch:6}];
const RAIL_LENGTHS = [2438, 3305, 4430];
// 3D objects
let scene, camera, renderer, controls, animId;
let meshes = {};
let fs3dIsolated = null; // 현재 단품 보기 중인 키
let transformCtrl = null;
let selectedKey = null;
let selectionBox = null;
const hiddenKeys = new Set(); // H키로 감춘 요소 추적
const meshLabels = {
case: '셔터박스', shaft: '샤프트 ASSY', motor: '모터/체인',
rails: '가이드레일', slats: '슬랫 커튼', slatRoll: '감긴 슬랫',
bottomBar: '하부바', wall: '벽체',
};
// ============================
// TAB SWITCHING
// ============================
window.fsSwitch = function(tab) {
S.tab = tab;
const tabs = ['GuideRail','ShutterBox','3D'];
const tabBtnIds = ['tabGuideRail','tabShutterBox','tab3D'];
const ctrlIds = ['ctrlGuideRail','ctrlShutterBox','ctrl3D'];
tabs.forEach((t,i) => {
const btn = $(tabBtnIds[i]);
const ctrl = $(ctrlIds[i]);
if (t === tab) {
btn.className = 'px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center bg-blue-600 text-white';
if (ctrl) ctrl.classList.remove('hidden');
} else {
btn.className = 'px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800';
if (ctrl) ctrl.classList.add('hidden');
}
});
const svgC = $('svgContainer');
const tdC = $('threeDContainer');
if (tab === '3D') {
svgC.classList.add('hidden');
tdC.classList.remove('hidden');
fs3dInit();
if ($('td3dDoorDir')) $('td3dDoorDir').value = S.sb.doorDir;
if ($('td3dDepth')) $('td3dDepth').value = S.sb.depth;
if ($('td3dHeight')) $('td3dHeight').value = S.sb.height;
if ($('td3dShaftDia')) $('td3dShaftDia').value = S.sb.shaftDia;
if ($('td3dRailWidth')) $('td3dRailWidth').value = S.sb.railWidth;
if ($('td3dFrontBottom')) $('td3dFrontBottom').value = S.sb.frontBottom;
if ($('td3dThickness')) $('td3dThickness').value = S.sb.thickness;
fs3dBuild();
} else {
svgC.classList.remove('hidden');
tdC.classList.add('hidden');
fsRender();
}
};
// ============================
// CALCULATIONS
// ============================
window.fsCalc = function() {
undoSaveState(); // Ctrl+Z용 스냅샷 저장
S.productType = $('productType').value;
S.openWidth = Number($('openWidth').value) || 2000;
S.openHeight = Number($('openHeight').value) || 3000;
S.quantity = Number($('quantity').value) || 1;
const p = PRODUCTS[S.productType];
const W1 = S.openWidth + p.marginW;
const H1 = S.openHeight + p.marginH;
const area = (W1 * H1) / 1000000;
const weight = area * p.weightFactor;
const motor = MOTORS.find(m => weight <= m.max) || MOTORS[MOTORS.length-1];
// Guide rail height = 오픈H + 100mm (바닥~셔터박스 하단 결합부)
const guideRailH = S.openHeight + 100;
const bestCombo = `${guideRailH.toLocaleString()}mm × 2`;
$('calcW1').textContent = W1.toLocaleString();
$('calcH1').textContent = H1.toLocaleString();
$('calcArea').textContent = area.toFixed(2);
$('calcWeight').textContent = weight.toFixed(1);
$('calcMotor').textContent = motor.spec;
$('calcRailCombo').textContent = bestCombo;
// Auto-update box width
$('sbWidth').value = W1;
S.sb.width = W1;
// 3D 탭 활성 시 자동 갱신
if (S.tab === '3D') fs3dBuild();
};
window.fsOnProductType = function() {
undoSaveState(); // Ctrl+Z용 스냅샷 저장
const type = $('productType').value;
S.productType = type;
const p = PRODUCTS[type];
// 철재스라트: 앵커볼트 간격 숨김 (스크린 전용)
if ($('grAnchorSpacingWrap')) $('grAnchorSpacingWrap').style.display = type === 'steel' ? 'none' : '';
// Update guide rail defaults
$('grWidth').value = p.gr.width; S.gr.width = p.gr.width;
$('grDepth').value = p.gr.depth; S.gr.depth = p.gr.depth;
$('grThickness').value = p.gr.thickness; S.gr.thickness = p.gr.thickness;
$('grLip').value = p.gr.lip; S.gr.lip = p.gr.lip;
$('grSlatThick').value = p.slatThick; S.gr.slatThick = p.slatThick;
// 스크린형 전용 속성
S.gr.flange = p.gr.flange || 0;
S.gr.sideWall = p.gr.sideWall || 0;
S.gr.backWall = p.gr.backWall || 0;
S.gr.trimThick = p.gr.trimThick || 0;
// Update shutter box defaults
$('sbHeight').value = p.sb.height; S.sb.height = p.sb.height;
$('sbDepth').value = p.sb.depth; S.sb.depth = p.sb.depth;
$('sbShaftDia').value = p.sb.shaftDia; S.sb.shaftDia = p.sb.shaftDia;
S.sb.railWidth = S.productType === 'screen' ? 70 : 75; $('sbRailWidth').value = S.sb.railWidth;
S.sb.frontBottom = 50; $('sbFrontBottom').value = S.sb.frontBottom;
// Filter models
const sel = $('productModel');
for (const opt of sel.options) {
const isSteel = opt.value.startsWith('KF');
const isScreen = opt.value.startsWith('KS');
opt.hidden = (type === 'steel' && isScreen) || (type === 'screen' && isSteel);
}
sel.value = type === 'steel' ? 'KFS01' : 'KSS01';
fsCalc();
if (S.tab === '3D') fs3dBuild();
else fsRender();
};
window.fsOnModelChange = function() { fsCalc(); fsRender(); };
// ============================
// UNDO (Ctrl+Z) — 상태 스냅샷 기반
// ============================
const undoStack = [];
const UNDO_MAX = 50;
let undoLastTime = 0;
function undoSaveState() {
// 100ms 이내 연속 호출 무시 (슬라이더 드래그 등)
const now = Date.now();
if (now - undoLastTime < 100) return;
undoLastTime = now;
// S에서 undo에 필요한 입력값만 스냅샷
undoStack.push(JSON.stringify({
productType: S.productType,
openWidth: S.openWidth, openHeight: S.openHeight, quantity: S.quantity,
gr: { ...S.gr }, sb: { ...S.sb },
td: { shutterPos: S.td.shutterPos, caseOpacity: S.td.caseOpacity,
show: { ...S.td.show } },
wall: { ...S.wall },
}));
if (undoStack.length > UNDO_MAX) undoStack.shift();
}
function undoRestore() {
if (undoStack.length === 0) return;
const snap = JSON.parse(undoStack.pop());
// 상태 복원
S.productType = snap.productType;
S.openWidth = snap.openWidth; S.openHeight = snap.openHeight; S.quantity = snap.quantity;
Object.assign(S.gr, snap.gr);
Object.assign(S.sb, snap.sb);
S.td.shutterPos = snap.td.shutterPos;
S.td.caseOpacity = snap.td.caseOpacity;
Object.assign(S.td.show, snap.td.show);
Object.assign(S.wall, snap.wall);
// UI 입력 필드 동기화
$('productType').value = S.productType;
$('openWidth').value = S.openWidth;
$('openHeight').value = S.openHeight;
$('quantity').value = S.quantity;
$('grWidth').value = S.gr.width;
$('grDepth').value = S.gr.depth;
$('grThickness').value = S.gr.thickness;
$('grLip').value = S.gr.lip;
$('grSealThick').value = S.gr.sealThick;
$('grSealDepth').value = S.gr.sealDepth;
$('grSlatThick').value = S.gr.slatThick;
$('grAnchorSpacing').value = S.gr.anchorSpacing;
$('sbWidth').value = S.sb.width;
$('sbHeight').value = S.sb.height;
$('sbDepth').value = S.sb.depth;
$('sbThickness').value = S.sb.thickness;
$('sbShaftDia').value = S.sb.shaftDia;
$('sbBracketW').value = S.sb.bracketW;
$('sbRailWidth').value = S.sb.railWidth;
$('sbFrontBottom').value = S.sb.frontBottom;
$('sbMotorSide').value = S.sb.motorSide;
$('sbDoorDir').value = S.sb.doorDir;
// 재계산 + 렌더링
fsCalc();
if (S.tab === '3D') fs3dBuild();
else fsRender();
}
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undoRestore();
}
});
// ============================
// READ STATE FROM INPUTS
// ============================
function readGr() {
S.gr.width = Number($('grWidth').value);
S.gr.depth = Number($('grDepth').value);
S.gr.thickness = Number($('grThickness').value);
S.gr.lip = Number($('grLip').value);
S.gr.sealThick = Number($('grSealThick').value);
S.gr.sealDepth = Number($('grSealDepth').value);
S.gr.slatThick = Number($('grSlatThick').value);
S.gr.anchorSpacing = Number($('grAnchorSpacing').value);
}
function readSb() {
S.sb.width = Number($('sbWidth').value);
S.sb.height = Number($('sbHeight').value);
S.sb.depth = Number($('sbDepth').value);
S.sb.thickness = Number($('sbThickness').value);
S.sb.shaftDia = Number($('sbShaftDia').value);
S.sb.bracketW = Number($('sbBracketW').value);
S.sb.railWidth = Number($('sbRailWidth').value);
S.sb.frontBottom = Number($('sbFrontBottom').value);
S.sb.motorSide = $('sbMotorSide').value;
S.sb.doorDir = $('sbDoorDir').value;
}
// ============================
// SVG DISPLAY HELPER
// ============================
function displaySvg(svg) {
const c = $('svgContainer');
c.innerHTML = '';
const div = document.createElement('div');
div.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;';
div.style.transform = `scale(${S.view.scale}) translate(${S.view.offset.x / S.view.scale}px, ${S.view.offset.y / S.view.scale}px)`;
div.style.transformOrigin = 'center center';
div.innerHTML = svg;
c.appendChild(div);
$('scaleDisplay').textContent = `SCALE: ${Math.round(S.view.scale * 100)}%`;
}
// ============================
// RENDER DISPATCHER
// ============================
window.fsRender = function() {
undoSaveState(); // Ctrl+Z용 스냅샷 저장
if (S.tab === 'Settings') renderSettingsPreview();
else if (S.tab === 'GuideRail') { readGr(); renderGuideRail(); }
else if (S.tab === 'ShutterBox') { readSb(); renderShutterBox(); }
};
// ============================
// SETTINGS PREVIEW (assembly overview SVG)
// ============================
function renderSettingsPreview() {
const W = S.openWidth, H = S.openHeight;
const p = PRODUCTS[S.productType];
const W1 = W + p.marginW;
const H1 = H + p.marginH;
const sc = Math.min(700 / W1, 750 / H1) * 0.7;
const sw = W1 * sc, sh = H1 * sc;
const ox = 80, oy = 60;
const boxH = 60, railW = 12;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${sw + 160} ${sh + 160}" style="max-width:100%;max-height:100%;">
<defs>
<pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="8" stroke="#6b5b4f" stroke-width="1"/>
</pattern>
</defs>
<!-- Wall (hatched) -->
<rect x="${ox - 20}" y="${oy}" width="20" height="${sh}" fill="url(#hatch)" stroke="#8b7355" stroke-width="1"/>
<rect x="${ox + sw}" y="${oy}" width="20" height="${sh}" fill="url(#hatch)" stroke="#8b7355" stroke-width="1"/>
<rect x="${ox - 20}" y="${oy - 20}" width="${sw + 40}" height="20" fill="url(#hatch)" stroke="#8b7355" stroke-width="1"/>
<!-- Shutter Box -->
<rect x="${ox}" y="${oy}" width="${sw}" height="${boxH}" fill="#374151" stroke="#94a3b8" stroke-width="2" rx="3"/>
<text x="${ox + sw/2}" y="${oy + boxH/2 + 4}" fill="#60a5fa" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 (CASE)</text>
<!-- Shaft circle -->
<circle cx="${ox + sw/2}" cy="${oy + boxH/2}" r="18" fill="none" stroke="#64748b" stroke-width="2" stroke-dasharray="4 2"/>
<!-- Guide Rails -->
<rect x="${ox}" y="${oy + boxH}" width="${railW}" height="${sh - boxH}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<rect x="${ox + sw - railW}" y="${oy + boxH}" width="${railW}" height="${sh - boxH}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<text x="${ox + railW/2}" y="${oy + boxH + (sh-boxH)/2}" fill="#a78bfa" font-size="9" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + railW/2}, ${oy + boxH + (sh-boxH)/2})" font-family="Pretendard">가이드레일</text>
<text x="${ox + sw - railW/2}" y="${oy + boxH + (sh-boxH)/2}" fill="#a78bfa" font-size="9" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + sw - railW/2}, ${oy + boxH + (sh-boxH)/2})" font-family="Pretendard">가이드레일</text>
<!-- Slat Curtain -->
${Array.from({length: Math.floor((sh - boxH - 20) / 12)}, (_, i) => {
const y = oy + boxH + 10 + i * 12;
return `<line x1="${ox + railW + 2}" y1="${y}" x2="${ox + sw - railW - 2}" y2="${y}" stroke="${S.productType === 'steel' ? '#9ca3af' : '#c084fc'}" stroke-width="1.5" opacity="0.5"/>`;
}).join('')}
<!-- Bottom Bar -->
<rect x="${ox + railW}" y="${oy + sh - 14}" width="${sw - railW*2}" height="14" fill="#f59e0b" stroke="#d97706" stroke-width="1.5" rx="2"/>
<text x="${ox + sw/2}" y="${oy + sh - 4}" fill="#1e293b" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard">하장바</text>
<!-- Floor -->
<line x1="${ox - 20}" y1="${oy + sh + 2}" x2="${ox + sw + 20}" y2="${oy + sh + 2}" stroke="#475569" stroke-width="2"/>
<!-- Dimension lines -->
<!-- Width -->
<line x1="${ox}" y1="${oy + sh + 25}" x2="${ox + sw}" y2="${oy + sh + 25}" stroke="#3b82f6" stroke-width="1" marker-start="url(#arrowL)" marker-end="url(#arrowR)"/>
<text x="${ox + sw/2}" y="${oy + sh + 42}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">W0 = ${W.toLocaleString()} mm</text>
<!-- Height -->
<line x1="${ox + sw + 35}" y1="${oy + boxH}" x2="${ox + sw + 35}" y2="${oy + sh}" stroke="#3b82f6" stroke-width="1"/>
<text x="${ox + sw + 50}" y="${oy + (sh + boxH)/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">H0 = ${H.toLocaleString()} mm</text>
<!-- Type label -->
<text x="${ox + sw/2}" y="${oy + boxH + (sh - boxH)/2 + 4}" fill="${S.productType === 'steel' ? '#94a3b8' : '#c084fc'}" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard" opacity="0.4">${S.productType === 'steel' ? '강판 슬랫' : '스크린 원단'}</text>
</svg>`;
displaySvg(svg);
}
// ============================
// GUIDE RAIL SVG RENDERER
// ============================
function renderGuideRail() {
if (S.gr.viewMode === 'cross') renderGrCross();
else renderGrFront();
}
function renderGrCross() {
const g = S.gr;
// ====== 스크린형: 실제 조립 구조 평면도 (4개 부재) ======
// ① 마감재 SUS 1.2T × 2장 (상하대칭, 빨간색) — ② 위를 덮씌우는 외피
// ② 가이드레일 EGI 1.55T × 1개 (ㄷ자, 파란색) — C채널 기초 구조
// ③ C형 — ④ D형/벽연형 (③ 내부 보강)
// 세로 = 채널 폭 (스크린:70mm, 철재:75mm)
if (S.productType === 'screen') {
const sc = 3; // px per mm (두께 비율 개선)
const t2 = g.thickness * sc; // ② 가이드레일 EGI 1.55T
const t1 = (g.trimThick || 1.2) * sc; // ① 마감재 SUS 1.2T
const sealT = g.sealThick * sc;
const slatT = Math.max(g.slatThick * sc, 2);
// ── ② 가이드레일 EGI (ㄷ자 1개, 절곡: lip10-flange30-sw80-bw67-sw80-flange30-lip10) ──
const bLip = g.lip * sc; // 10mm 립
const bFl = g.flange * sc; // 30mm 플랜지
const bSw = g.sideWall * sc; // 80mm 사이드월
const bBw = g.backWall * sc; // 67mm 백월
const bOuterW = g.width * sc; // 70mm 외폭 (명시된 폭 사용, 부동소수점 방지)
const bSlot = bOuterW - 2 * bFl; // 슬롯 개구 = 70-2*30 = 10mm
// ── ③ 벽연형-C 치수 (절곡: 30-45-30) ──
const c3Lip = 30 * sc; // 립 30mm
const c3Body = 45 * sc; // 몸체 45mm (백월에 밀착)
// ── ④ 벽연형-D 치수 (절곡: 11-23-40-23-11) ──
const c4a = 11 * sc, c4b = 23 * sc, c4c = 40 * sc;
// ── ① 마감재 SUS 치수 (2장 대칭, ② 위 덮씌우기, 절곡: 10-11-110-30-15-15-15) ──
const m1a = 10 * sc; // 코킹립 (벽쪽, 수평)
const m1b = 11 * sc; // 측면탭 (수직)
const m1c = 110 * sc; // 수평면 (메인 커버, 코킹립10 포함 시 전체 120mm)
const m1d = 30 * sc; // 우측 수직벽 (표면→바닥, 중앙 방향)
const m1e = 15 * sc; // 하단 수평탭 (개구부 방향, 우측)
const m1f = 15 * sc; // 복귀벽 (하단탭→상단탭, 외측 방향)
const m1g = 15 * sc; // 상단 수평탭 (개구부 방향, 우측)
// ── 레이아웃 (좌→우 배치) ──
const pad = 80, wallW = 35;
// ③ 벽연형-C 몸체가 방화벽에 직접 맞닿음 → ③ 립(30mm)이 본체 백월까지 연장
const bkTotalD = c3Lip; // ③ 립 = 벽→본체 백월 깊이
const bx = pad + wallW + bkTotalD; // 본체 백월 외면 X (벽+③립, 갭 없음)
// 본체 사이드월 끝 (립 시작점)
const swEndX = bx + t2 + bSw; // 백월두께 + 사이드월
// 립 끝
const lipEndX = swEndX + bLip;
// ① 마감재 수평면 범위 (코킹립 끝 = 벽 내면, 측면탭 = 벽 내면 + 코킹립10mm)
const trimL1 = pad + wallW + m1a + 2; // 측면탭 X (코킹립이 벽 내면까지만 도달)
const trimR1 = lipEndX; // 수평면 우측 끝 (C채널 립 끝에 맞춤)
const by = pad; // 본체 상단 Y
const svgW = lipEndX + pad + 160;
const svgH = bOuterW + pad * 2 + 100;
// ── 색상 정의 (부재별 명확 구분) ──
const cTrim = '#ef4444'; // ① 마감재 SUS — 빨간색
const cBody = '#3b82f6'; // ② 가이드레일 EGI — 파란색
const cBk3 = '#22c55e'; // ③ C형 — 초록색
const cBk4 = '#f97316'; // ④ D형 — 주황색
const ms = '#94a3b8'; // 스트로크
const mw = 0.8;
// ── 채널 내부 배경 ──
const interiorSvg = `<rect x="${bx+t2}" y="${by+t2}" width="${bSw-t2}" height="${bOuterW-2*t2}" fill="#0f172a"/>`;
// ── ② 가이드레일 C채널 EGI (ㄷ자 1개, 절곡: lip-flange-sideWall-backWall-sideWall-flange-lip) ──
const bodySvg = [
// 백월 (수직, 좌측)
`<rect x="${bx}" y="${by}" width="${t2}" height="${bOuterW}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 사이드월 (백월→개구부 방향, 80mm)
`<rect x="${bx}" y="${by}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 사이드월
`<rect x="${bx}" y="${by+bOuterW-t2}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 립 (사이드월 끝에서 바깥Y 유지, 개구부 방향 10mm 연장, 'ㄱ' 수평부)
`<rect x="${swEndX}" y="${by}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 립 (거울상)
`<rect x="${swEndX}" y="${by+bOuterW-t2}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 플랜지 (립 우측 끝에서 안쪽/중앙으로 절곡, 'ㄱ' 수직부)
`<rect x="${lipEndX-t2}" y="${by+t2}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 플랜지 끝 10mm 절곡 (플랜지 내측 끝에서 좌측으로, 채널 안쪽)
`<rect x="${lipEndX-t2-bLip}" y="${by+bFl-t2}" width="${bLip+t2}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 플랜지 (거울상)
`<rect x="${lipEndX-t2}" y="${by+bOuterW-bFl}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 플랜지 끝 10mm 절곡 (플랜지 내측 끝에서 좌측으로, 채널 안쪽)
`<rect x="${lipEndX-t2-bLip}" y="${by+bOuterW-bFl}" width="${bLip+t2}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
].join('\n');
// ── ③ C형 (절곡: 30-45-30, 백월 뒤 C브라켓) ──
// C 개구부가 ② 가이드레일쪽(우측)을 향함: 몸체(벽쪽) → 립(가이드레일쪽)
const c3CenterY = by + bOuterW / 2;
const c3Y = c3CenterY - c3Body / 2;
const c3BodyX = bx - c3Lip; // 몸체 X (벽쪽, 좌측 끝)
const c3LipEnd = bx; // 립 끝 = ② 백월 외면
const bk3Svg = [
// 몸체 (세로 45mm, 벽쪽)
`<rect x="${c3BodyX}" y="${c3Y}" width="${t2}" height="${c3Body}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 립 (몸체→본체 방향 30mm)
`<rect x="${c3BodyX}" y="${c3Y}" width="${c3Lip}" height="${t2}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 립 (몸체→본체 방향 30mm)
`<rect x="${c3BodyX}" y="${c3Y+c3Body-t2}" width="${c3Lip}" height="${t2}" fill="${cBk3}" stroke="${ms}" stroke-width="${mw}"/>`,
].join('\n');
// ── ④ D형 (절곡: 11-23-40-23-11, ③ 내부에 중첩) ──
// ③과 마주보는 형태: ③은 우측(②쪽)으로 열리고, ④는 좌측(벽쪽)으로 열림
// 몸체(40) ②쪽(우측), 사이드(23) 벽 방향(좌측), 립(11) 안쪽 절곡
const c4CenterY = c3CenterY;
const c4Y = c4CenterY - c4c / 2;
const c4BodyX = bx - t2 - 2; // 몸체: ② 백월 바로 좌측 (우측 배치)
const c4SideStartX = c4BodyX - c4b; // 사이드 좌측 끝 (벽 방향)
const bk4Svg = [
// 몸체 (세로 40mm, 우측 = ② 백월쪽)
`<rect x="${c4BodyX}" y="${c4Y}" width="${t2}" height="${c4c}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 상단 사이드 (23mm, 벽 방향 = 좌측)
`<rect x="${c4SideStartX}" y="${c4Y}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 하단 사이드
`<rect x="${c4SideStartX}" y="${c4Y+c4c-t2}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 상단 립 (11mm, 사이드 좌측 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideStartX}" y="${c4Y+t2}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 하단 립 (11mm, 사이드 좌측 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideStartX}" y="${c4Y+c4c-t2-c4a}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
].join('\n');
// ── ① 마감재 SUS 1.2T × 2장 (② 바깥을 감싸는 외피, 절곡: 10-11-110-30-15-15-15, 상하 대칭) ──
// 좌측: 코킹립(10, 벽쪽 수평) + 측면탭(11, 수직) → 방화벽과 'ㄴ'자 립 결합
// 우측: 30(수직,중앙↓) → 15(수평,우측→) → 15(수직,외측↑) → 15(수평,우측→) — J-hook 접힘 (총높이 30mm)
const trimX2 = lipEndX; // 치수선용 우측 끝
// ①는 ② 바깥을 감싸는 구조
const tTop = by - t1; // 상단 ① 수평면 Y (② 상면 바깥)
const tBot = by + bOuterW; // 하단 ① 수평면 Y (② 하면 바깥)
// 개구부쪽 접힘 좌표 (상단 기준)
const wrapX = trimR1; // 접힘 시작 X = 수평면 우측 끝
const trim1Svg = [
// ══════ 상단 ① 마감재 (② 상면 바깥을 감싸기) ══════
// 코킹립: 측면탭 하단에서 벽쪽(좌측) 방향 수평 10mm ('ㄴ'자 하단)
`<rect x="${trimL1-m1a}" y="${tTop+m1b}" width="${m1a}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 측면탭: 벽쪽 수직 11mm ('ㄴ'자 세로획)
`<rect x="${trimL1}" y="${tTop}" width="${t1}" height="${m1b+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 수평면: 벽~립 (좌→우, 메인 커버)
`<rect x="${trimL1}" y="${tTop}" width="${trimR1-trimL1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 1단: 수직벽 30mm (수평면 우측 끝에서 아래로, 중앙 방향)
`<rect x="${wrapX-t1}" y="${tTop+t1}" width="${t1}" height="${m1d}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 2단: 하단 수평탭 15mm (수직벽 하단에서 좌측으로, 채널 안쪽)
`<rect x="${wrapX-t1-m1e}" y="${tTop+m1d}" width="${m1e+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 3단: 복귀벽 15mm (하단탭 좌측 끝에서 위로, 외측 방향)
`<rect x="${wrapX-t1-m1e}" y="${tTop+m1d-m1f}" width="${t1}" height="${m1f+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 4단: 상단 수평탭 15mm (복귀벽 상단에서 좌측으로, 채널 더 안쪽 — 연기차단재 용접 공간)
`<rect x="${wrapX-t1-m1e-m1g}" y="${tTop+m1d-m1f}" width="${m1g+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// ══════ 하단 ① 마감재 (② 하면 바깥을 감싸기, 거울상) ══════
// 코킹립: 측면탭 하단에서 벽쪽(좌측) 방향 수평 10mm ('ㄴ'자 하단)
`<rect x="${trimL1-m1a}" y="${tBot-m1b-t1}" width="${m1a}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 측면탭
`<rect x="${trimL1}" y="${tBot-m1b}" width="${t1}" height="${m1b+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 수평면
`<rect x="${trimL1}" y="${tBot}" width="${trimR1-trimL1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 1단: 수직벽 30mm (수평면 우측 끝에서 위로, 중앙 방향)
`<rect x="${wrapX-t1}" y="${tBot-m1d}" width="${t1}" height="${m1d}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 2단: 상단 수평탭 15mm (수직벽 상단에서 좌측으로, 채널 안쪽)
`<rect x="${wrapX-t1-m1e}" y="${tBot-m1d}" width="${m1e+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 3단: 복귀벽 15mm (상단탭 좌측 끝에서 아래로, 외측 방향)
`<rect x="${wrapX-t1-m1e}" y="${tBot-m1d}" width="${t1}" height="${m1f+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
// 개구부쪽 4단: 하단 수평탭 15mm (복귀벽 하단에서 좌측으로, 채널 더 안쪽 — 연기차단재 용접 공간)
`<rect x="${wrapX-t1-m1e-m1g}" y="${tBot-m1d+m1f}" width="${m1g+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`,
].join('\n');
// ── 연기차단재 (현재 비활성 — 위치 재조정 후 활성화 예정) ──
let sealSvg = '';
// ── 슬랫 ──
const slatY = by + bOuterW / 2 - slatT / 2;
const slatX1 = bx + t2 + 2;
const slatX2 = lipEndX - 2;
// ── 방화벽 ──
const wallX = pad;
// ── 각 부재별 절곡 치수 라벨 ──
const sdf = 'font-size="7" font-weight="700" font-family="Pretendard"';
let segDims = '';
// ② 가이드레일 (파란색)
segDims += `<text x="${bx-4}" y="${by+bOuterW/2+2}" fill="${cBody}" ${sdf} text-anchor="end">${g.width}</text>`;
segDims += `<text x="${bx+t2+bSw/2}" y="${by-4}" fill="${cBody}" ${sdf} text-anchor="middle">${g.sideWall}</text>`;
segDims += `<text x="${swEndX+bLip/2}" y="${by+t2+10}" fill="${cBody}" ${sdf} text-anchor="middle">${g.lip}</text>`;
segDims += `<text x="${lipEndX+4}" y="${by+bFl/2+2}" fill="${cBody}" ${sdf} text-anchor="start">${g.flange}</text>`;
segDims += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW+t2+9}" fill="${cBody}" ${sdf} text-anchor="middle">${g.sideWall}</text>`;
segDims += `<text x="${lipEndX+4}" y="${by+bOuterW-bFl/2+2}" fill="${cBody}" ${sdf} text-anchor="start">${g.flange}</text>`;
// ③ C형 (초록색)
segDims += `<text x="${c3BodyX-4}" y="${c3CenterY+2}" fill="${cBk3}" ${sdf} text-anchor="end">${Math.round(c3Body/sc)}</text>`;
segDims += `<text x="${c3BodyX+c3Lip/2}" y="${c3Y-4}" fill="${cBk3}" ${sdf} text-anchor="middle">${Math.round(c3Lip/sc)}</text>`;
segDims += `<text x="${c3BodyX+c3Lip/2}" y="${c3Y+c3Body+9}" fill="${cBk3}" ${sdf} text-anchor="middle">${Math.round(c3Lip/sc)}</text>`;
// ④ D형 (주황색)
segDims += `<text x="${c4BodyX+t2+4}" y="${c4CenterY+2}" fill="${cBk4}" ${sdf} text-anchor="start">${Math.round(c4c/sc)}</text>`;
segDims += `<text x="${c4SideStartX+c4b/2}" y="${c4Y-4}" fill="${cBk4}" ${sdf} text-anchor="middle">${Math.round(c4b/sc)}</text>`;
segDims += `<text x="${c4SideStartX-4}" y="${c4Y+t2+c4a/2+2}" fill="${cBk4}" ${sdf} text-anchor="end">${Math.round(c4a/sc)}</text>`;
segDims += `<text x="${c4SideStartX-4}" y="${c4Y+c4c-t2-c4a/2+2}" fill="${cBk4}" ${sdf} text-anchor="end">${Math.round(c4a/sc)}</text>`;
// ① 마감재 상단 (빨간색)
segDims += `<text x="${trimL1-m1a/2}" y="${tTop+m1b+t1+9}" fill="${cTrim}" ${sdf} text-anchor="middle">10</text>`;
segDims += `<text x="${trimL1-6}" y="${tTop+m1b/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">11</text>`;
segDims += `<text x="${(trimL1+trimR1)/2}" y="${tTop-3}" fill="${cTrim}" ${sdf} text-anchor="middle">110</text>`;
segDims += `<text x="${wrapX+4}" y="${tTop+m1d/2+2}" fill="${cTrim}" ${sdf} text-anchor="start">30</text>`;
segDims += `<text x="${wrapX-m1e/2}" y="${tTop+m1d+t1+9}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>`;
segDims += `<text x="${wrapX-m1e-6}" y="${tTop+m1d-m1f/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">15</text>`;
segDims += `<text x="${wrapX-m1e-m1g/2}" y="${tTop+m1d-m1f-4}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>`;
// ① 마감재 하단 (빨간색, 거울상)
segDims += `<text x="${trimL1-m1a/2}" y="${tBot-m1b-4}" fill="${cTrim}" ${sdf} text-anchor="middle">10</text>`;
segDims += `<text x="${trimL1-6}" y="${tBot-m1b/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">11</text>`;
segDims += `<text x="${(trimL1+trimR1)/2}" y="${tBot+t1+9}" fill="${cTrim}" ${sdf} text-anchor="middle">110</text>`;
segDims += `<text x="${wrapX+4}" y="${tBot-m1d/2+2}" fill="${cTrim}" ${sdf} text-anchor="start">30</text>`;
segDims += `<text x="${wrapX-m1e/2}" y="${tBot-m1d-4}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>`;
segDims += `<text x="${wrapX-m1e-6}" y="${tBot-m1d+m1f/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">15</text>`;
segDims += `<text x="${wrapX-m1e-m1g/2}" y="${tBot-m1d+m1f+t2+9}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>`;
// ── 치수선 ──
let dimLines = '';
if (g.showDim) {
const totalLeft = bx - c3Lip;
const totalRight = trimX2;
// 깊이 120mm (하단)
dimLines += `<line x1="${totalLeft}" y1="${by+bOuterW+35}" x2="${totalRight}" y2="${by+bOuterW+35}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${totalLeft}" y1="${by+bOuterW+30}" x2="${totalLeft}" y2="${by+bOuterW+40}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${totalRight}" y1="${by+bOuterW+30}" x2="${totalRight}" y2="${by+bOuterW+40}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${(totalLeft+totalRight)/2}" y="${by+bOuterW+52}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.depth} mm</text>`;
// 폭 70mm (우측, 충분히 떨어진 위치)
const dimRightX = totalRight + 40;
dimLines += `<line x1="${dimRightX}" y1="${by}" x2="${dimRightX}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${dimRightX-5}" y1="${by}" x2="${dimRightX+5}" y2="${by}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${dimRightX-5}" y1="${by+bOuterW}" x2="${dimRightX+5}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${dimRightX+8}" y="${by+bOuterW/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>`;
// ② 플랜지 26mm (②의 립 끝 기준 라벨)
dimLines += `<text x="${lipEndX+6}" y="${by+bFl/2+3}" fill="${cBody}" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">②FL${g.flange}</text>`;
// ① 코킹립 10mm (벽쪽 라벨)
dimLines += `<text x="${trimL1-m1a-4}" y="${tTop+m1b+t1/2+3}" fill="${cTrim}" font-size="8" font-weight="700" text-anchor="end" font-family="Pretendard">①립${m1a/sc}</text>`;
// 슬롯 개구 (우측 중앙)
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+16}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯${Math.round(bSlot/sc)}</text>`;
// 립 깊이
dimLines += `<text x="${(swEndX+lipEndX)/2}" y="${by+bFl+14}" fill="${cBody}" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard">②립${g.lip}</text>`;
// 두께 (하단, 120mm 치수선 아래)
dimLines += `<text x="${(totalLeft+totalRight)/2}" y="${by+bOuterW+68}" fill="#94a3b8" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard">①t=${g.trimThick||1.2}(SUS) ②t=${g.thickness}(EGI)</text>`;
// 부재 번호 라벨 (참조 도면 기준 배치)
// ① 마감재: 수평면 중앙 (상단, 하단 각 1개)
dimLines += `<text x="${(trimL1+trimR1)/2}" y="${tTop+t1/2+3}" fill="${cTrim}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">①</text>`;
dimLines += `<text x="${(trimL1+trimR1)/2}" y="${tBot+t1/2+3}" fill="${cTrim}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">①</text>`;
// ② 가이드레일: 채널 내부 중앙
dimLines += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW/2+4}" fill="${cBody}" font-size="24" font-weight="900" text-anchor="middle" font-family="Pretendard">②</text>`;
// ③ C형: 몸체(45mm) 중심
dimLines += `<text x="${c3BodyX+c3Lip/2}" y="${c3CenterY+4}" fill="${cBk3}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">③</text>`;
// ④ D형: 몸체(40mm) 중심 (좌측 열림, ③과 마주봄)
dimLines += `<text x="${c4BodyX-c4b/2}" y="${c4CenterY+4}" fill="${cBk4}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>`;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<defs>
<pattern id="wallHatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="#8b7355" stroke-width="0.8"/>
</pattern>
</defs>
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 평면도 (Plan View) — ${S.productType==='screen'?'스크린형':'철재스라트'}</text>
<!-- 방화벽 -->
<rect x="${wallX}" y="${by-20}" width="${wallW}" height="${bOuterW+40}" fill="url(#wallHatch)" stroke="#8b7355" stroke-width="1" rx="2"/>
<text x="${wallX+wallW/2}" y="${by+bOuterW+35}" fill="#a1887f" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
<!-- 채널 내부 -->
${interiorSvg}
<!-- ② 가이드레일 C채널 EGI (파란색, ㄷ자 1개) -->
${bodySvg}
<!-- ③ C형 -->
${bk3Svg}
<!-- ④ D형 -->
${bk4Svg}
<!-- ① 마감재 SUS (빨간색, ② 위 덮씌우기) -->
${trim1Svg}
<!-- 연기차단재 -->
${sealSvg}
<!-- 슬랫 -->
<rect x="${slatX1}" y="${slatY}" width="${slatX2-slatX1}" height="${slatT}" fill="#c084fc" opacity="0.8" rx="1"/>
${g.showDim ? `<text x="${slatX1}" y="${slatY-4}" fill="#c084fc" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : ''}
<!-- 절곡 치수 -->
${segDims}
<!-- 개구부 방향 -->
<text x="${lipEndX+10}" y="${by+bOuterW/2-2}" fill="#64748b" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">→개구부</text>
${dimLines}
<text x="${svgW/2}" y="${by+bOuterW+82}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL — ${S.productType==='screen'?'스크린형':'철재스라트'} (①마감재 ②가이드레일 ③C형 ④D형)</text>
</svg>`;
displaySvg(svg);
return;
}
// ====== 철재스라트: 가이드레일 평면도 (130mm × 75mm 결합도) ======
// LEFT = 방화벽(벽쪽), RIGHT = 개구부(실내쪽, 슬랫 통과)
// ① 마감재 SUS 1.2T (빨간색) - ② 바깥을 감싸는 외피, 상하 2장
// 상단: 10←lip + 13↓tab + 120→body + 25↓ + 15←
// 하단: 10←lip + 13↑tab + 120→body + 25↑ + 내부스텝(19← + 14↓ + 15←)
// ② 본체 EGI 1.55T (회색) - 메인 C채널, 개구부가 RIGHT
// 외형: 90mm(가로) × 72mm(세로), 좌측벽 72mm, 상/하단 플랜지 90mm
// 우측 상부 립 21mm, 내부 개구부가 y=60까지 확장, 하부 립 12mm
// ③ C형 EGI 1.55T (주황색) - ② 내부 좌측 상단에 위치, 30-45-30 ㄷ자(우측열림)
// ④ 벽연형 EGI 1.55T (갈색) - ② 좌측에 위치, 벽과 연결, 30-45-30 ㄷ자(우측열림)
{
const sc = 4; // px per mm (확대하여 철판 비율 현실적)
const t = g.thickness * sc; // 1.55mm → 6.2px (EGI)
const t1 = (g.trimThick || 1.2) * sc; // 1.2mm → 4.8px (SUS)
const slatT = Math.max(g.slatThick * sc, 2);
// 색상
const cBody = '#4b5563', cTrim = '#ef4444', cC3 = '#f59e0b', cWall = '#8b6c5c';
// ── 도면 치수 (mm 단위, sc 미적용) ──
// 전체: 130mm(가로) × 75mm(세로)
// ① 마감재: lip=10, tab=13, body=120, end25=25, end15=15, end19=19, end14=14
// ② 본체: 90×72, lip=21, 내부 shelf78/shelf30/step43/s15/s20
// ③ C형: 30-45-30
// ④ 벽연형: 30-45-30
// ── 기준 좌표 설정 ──
// ① lip 좌측 끝을 x 기준 0으로 놓고 오프셋 적용
const wallW = 50; // 방화벽 해치 너비 (px)
const wallGap = 0; // 방화벽과 ④ 밀착 (벽에 부착)
const padL = 30; // SVG 좌측 여백 (px)
const padT = 55; // SVG 상단 여백 (타이틀 공간)
// ④ C형 보강 치수
const w4depth = 30 * sc;
const w4height = 45 * sc;
// ② 본체 치수
const b2w = 90 * sc; // 90mm → 270px (가로)
const b2h = 72 * sc; // 72mm → 216px (세로)
const b2lip = 21 * sc; // 21mm → 63px (우측 상/하부 립)
// ① 마감재 치수
const m1lip = 10 * sc; // 10mm → 30px (좌측 코킹립)
const m1tab = 13 * sc; // 13mm → 39px (좌측 절곡탭)
const m1body = 120 * sc; // 120mm → 360px (본체 수평부)
const m1e25 = 25 * sc; // 25mm → 75px (우측 수직 절곡)
const m1e15 = 15 * sc; // 15mm → 45px (우측 수평 리턴)
const m1e19 = 19 * sc; // 19mm → 57px (하단 내부 수평)
const m1e14 = 14 * sc; // 14mm → 42px (하단 내부 수직)
// ── 좌표 계산 ──
// 방화벽 우측면 = ① 립 좌측 끝 (밀착)
const trimLipX = padL + wallW; // ① 립 좌측 = 벽 우측
const bx = trimLipX + m1lip; // ② 좌측벽 = ① 립 끝
const by = padT + m1tab; // ② 상단 y
const trimRightX = bx + m1body; // ① body 우측 끝 (130mm 끝)
const w4x = trimLipX; // ④ 좌측 = 벽 우측면 = ① 립 좌측
// SVG 캔버스 크기
const svgW = trimRightX + 80; // 우측 치수선/라벨 공간
const svgH = by + b2h + m1tab + 60; // 하단 치수선/범례 공간
const P = []; // SVG 부재 요소 배열
// ══════════════════════════════════════════
// ══════════════════════════════════════════
// ④ C형 보강 EGI 1.55T (갈색, ㄷ자 30-45-30 우측열림)
// 벽 우측면에 부착, 세로 중앙 정렬
// ══════════════════════════════════════════
const w4cy = by + (b2h - w4height) / 2;
P.push(`<rect x="${w4x}" y="${w4cy}" width="${w4depth}" height="${t}" fill="${cWall}" stroke="${cWall}" stroke-width="0.5" opacity="0.85"/>`);
P.push(`<rect x="${w4x}" y="${w4cy}" width="${t}" height="${w4height}" fill="${cWall}" stroke="${cWall}" stroke-width="0.5" opacity="0.85"/>`);
P.push(`<rect x="${w4x}" y="${w4cy + w4height - t}" width="${w4depth}" height="${t}" fill="${cWall}" stroke="${cWall}" stroke-width="0.5" opacity="0.85"/>`);
// ④ 치수 라벨 (갈색)
const wdf = 'font-size="7" font-weight="700" font-family="Pretendard"';
P.push(`<text x="${w4x + w4depth / 2}" y="${w4cy - 4}" fill="${cWall}" ${wdf} text-anchor="middle">30</text>`);
P.push(`<text x="${w4x - 4}" y="${w4cy + w4height / 2 + 2}" fill="${cWall}" ${wdf} text-anchor="end">45</text>`);
// ══════════════════════════════════════════
// ② 본체 EGI 1.55T (절곡, 체이닝 좌표)
// 프로파일 좌표: (0,0)=좌상단, 90×72mm
// 내부 개구부가 아래로 확장 (④ 진입 공간)
// ══════════════════════════════════════════
{
const ox2 = w4x + w4depth + (7 + 0.75) * sc; // ② 좌측벽 = ④ 끝 + 7mm + 0.75(반두께)
const oy2 = by; // ② 상단
const c = cBody, s = '#94a3b8';
// 프로파일 좌표 → SVG 좌표 변환
function r(x,y,w,h,op) {
P.push(`<rect x="${ox2+x*sc}" y="${oy2+y*sc}" width="${w*sc}" height="${h*sc}" fill="${c}" stroke="${s}" stroke-width="${op?'0.3':'0.5'}" ${op?'opacity="0.7"':''}/>`);
}
// 벤드포인트 체이닝 (프로파일 좌표 mm 단위)
// B0=(0,60) → Seg1(10→) → (10,60): 립 우측(채널 안쪽)
r(0, 60-0.75, 10, 1.55);
// B0=(0,60) → Seg2(60↑) → (0,0): 좌측벽 상부
r(0, 0, 1.55, 60);
// (0,0) → Seg3(90→) → (90,0): 상단 플랜지
r(0, 0, 90, 1.55);
// (90,0) → Seg4(21↓) → (90,21): 상부 립
r(90-1.55, 0, 1.55, 21);
// (90,21) → Seg5(78←) → (12,21): 상부 내부 선반 (넓은)
r(12, 21, 78, 1.55, 1);
// (12,21) → Seg6(30↓) → (12,51): 내부 벽
r(12, 21, 1.55, 30, 1);
// (12,51) → Seg7(43→) → (55,51): 하부 내부 선반
r(12, 51, 43, 1.55, 1);
// (55,51) → Seg8(15↓) → (55,66): 스텝 하강
r(55-1.55, 51, 1.55, 15, 1);
// (55,66) → Seg9(20→) → (75,66): 스텝 우측
r(55, 66-1.55, 20, 1.55, 1);
// (75,66) → Seg10(15↑) → (75,51): 스텝 상승
r(75, 51, 1.55, 15, 1);
// (75,51) → Seg11(15→) → (90,51): 스텝 우측
r(75, 51, 15, 1.55, 1);
// (90,51) → Seg12(21↓) → (90,72): 하부 립
r(90-1.55, 51, 1.55, 21);
// (90,72) → Seg13(90←) → (0,72): 하단 플랜지
r(0, 72-1.55, 90, 1.55);
// (0,72) → Seg14(12↑) → (0,60): 좌측벽 하부
r(0, 60, 1.55, 12);
// (0,60) → Seg15(10→) → (10,60): 끝 립 (Seg1과 겹침)
r(0, 60, 10, 1.55);
// ── ② 본체 절곡 치수 라벨 (검증용, 본체 색상) ──
const df = 'font-size="7" font-weight="700" font-family="Pretendard"';
function d(x, y, txt, anc, clr) {
P.push(`<text x="${ox2+x*sc}" y="${oy2+y*sc}" fill="${clr||s}" ${df} text-anchor="${anc||'middle'}">${txt}</text>`);
}
d(5, 57, '10'); // Seg1: 립 10mm
d(-4, 30, '60', 'end'); // Seg2: 좌측벽 60mm
d(45, -3, '90'); // Seg3: 상단 플랜지 90mm
d(94, 11, '21', 'start'); // Seg4: 상부 립 21mm
d(51, 18, '78'); // Seg5: 상부 선반 78mm
d(9, 36, '30', 'end'); // Seg6: 내부 벽 30mm
d(33, 49, '43'); // Seg7: 하부 선반 43mm
d(49, 59, '15', 'end'); // Seg8: 스텝↓ 15mm
d(65, 70, '20'); // Seg9: 스텝→ 20mm
d(79, 59, '15', 'start'); // Seg10: 스텝↑ 15mm
d(82, 49, '15'); // Seg11: 스텝→ 15mm
d(94, 62, '21', 'start'); // Seg12: 하부 립 21mm
d(45, 76, '90'); // Seg13: 하단 플랜지 90mm
d(-4, 67, '12', 'end'); // Seg14: 좌측벽 하부 12mm
}
// ══════════════════════════════════════════
// ① 마감재 SUS 1.2T (빨간색) — 상단 장
// 경로: 10←lip + 13↓tab + 120→body + 25↓ + 15←
// ② 상단 플랜지 바로 위를 감싸는 형태
// ══════════════════════════════════════════
const tTop = by - t1; // ② 상단 플랜지 바로 위
// ── 상단 ① 경로: 120→body + 13↓tab + 10←lip ── (우측: 25↓ + 15←)
// (a) 본체 수평부 120mm
P.push(`<rect x="${bx}" y="${tTop}" width="${m1body}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (b) 좌측 절곡탭 13mm: body 좌단에서 아래로
P.push(`<rect x="${bx}" y="${tTop}" width="${t1}" height="${m1tab}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (c) 좌측 코킹립 10mm: tab 하단에서 좌측으로
P.push(`<rect x="${bx - m1lip}" y="${tTop + m1tab - t1}" width="${m1lip + t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (d) 우측 수직 절곡 25mm: body 우측 끝에서 아래로
P.push(`<rect x="${trimRightX - t1}" y="${tTop}" width="${t1}" height="${m1e25}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (e) 우측 수평 리턴 15mm: 25mm 끝에서 좌측으로
P.push(`<rect x="${trimRightX - m1e15}" y="${tTop + m1e25 - t1}" width="${m1e15}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// ══════════════════════════════════════════
// ① 마감재 하단 — 경로: 120→body + 13↑tab + 10←lip (우측: 25↑+19←+14↓+15←)
// ══════════════════════════════════════════
const tBot = by + b2h;
// (a) 본체 수평부 120mm
P.push(`<rect x="${bx}" y="${tBot}" width="${m1body}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (b) 좌측 절곡탭 13mm: body 좌단에서 위로
P.push(`<rect x="${bx}" y="${tBot - m1tab + t1}" width="${t1}" height="${m1tab}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (c) 좌측 코킹립 10mm: tab 상단에서 좌측으로
P.push(`<rect x="${bx - m1lip}" y="${tBot - m1tab}" width="${m1lip + t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (c) 본체 수평부 120mm: bx에서 우측으로
P.push(`<rect x="${bx}" y="${tBot}" width="${m1body}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (d) 우측 수직 절곡 25mm: body 우측 끝에서 위로
P.push(`<rect x="${trimRightX - t1}" y="${tBot - m1e25 + t1}" width="${t1}" height="${m1e25}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (e) 내부스텝 19mm 수평: 25mm 벽 상단에서 좌측으로
P.push(`<rect x="${trimRightX - m1e19}" y="${tBot - m1e25 + t1}" width="${m1e19}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (f) 내부스텝 14mm 수직: 19mm 좌측 끝에서 아래로
P.push(`<rect x="${trimRightX - m1e19}" y="${tBot - m1e25 + t1}" width="${t1}" height="${m1e14}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// (g) 내부스텝 15mm 수평: 14mm 끝에서 좌측으로
P.push(`<rect x="${trimRightX - m1e19 - m1e15}" y="${tBot - m1e25 + m1e14 + t1}" width="${m1e15 + t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>`);
// ── ① 마감재 치수 라벨 (빨간색) ──
const tdf = `font-size="7" font-weight="700" font-family="Pretendard"`;
// 상단 ①
P.push(`<text x="${bx + m1body / 2}" y="${tTop - 3}" fill="${cTrim}" ${tdf} text-anchor="middle">120</text>`);
P.push(`<text x="${bx - 4}" y="${tTop + m1tab / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="end">13</text>`);
P.push(`<text x="${bx - m1lip / 2}" y="${tTop + m1tab + 6}" fill="${cTrim}" ${tdf} text-anchor="middle">10</text>`);
P.push(`<text x="${trimRightX + 4}" y="${tTop + m1e25 / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="start">25</text>`);
P.push(`<text x="${trimRightX - m1e15 / 2}" y="${tTop + m1e25 + 6}" fill="${cTrim}" ${tdf} text-anchor="middle">15</text>`);
// 하단 ①
P.push(`<text x="${bx + m1body / 2}" y="${tBot + t1 + 9}" fill="${cTrim}" ${tdf} text-anchor="middle">120</text>`);
P.push(`<text x="${bx - 4}" y="${tBot - m1tab / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="end">13</text>`);
P.push(`<text x="${bx - m1lip / 2}" y="${tBot - m1tab - 3}" fill="${cTrim}" ${tdf} text-anchor="middle">10</text>`);
P.push(`<text x="${trimRightX + 4}" y="${tBot - m1e25 / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="start">25</text>`);
P.push(`<text x="${trimRightX - m1e19 / 2}" y="${tBot - m1e25 - 2}" fill="${cTrim}" ${tdf} text-anchor="middle">19</text>`);
P.push(`<text x="${trimRightX - m1e19 - 4}" y="${tBot - m1e25 + m1e14 / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="end">14</text>`);
P.push(`<text x="${trimRightX - m1e19 - m1e15 / 2}" y="${tBot - m1e25 + m1e14 + 9}" fill="${cTrim}" ${tdf} text-anchor="middle">15</text>`);
// (슬랫 표시 생략 — 철재는 슬랫이 개구부 밖에서 통과)
// ══════════════════════════════════════════
// 치수선
// ══════════════════════════════════════════
let dims = '';
if (g.showDim) {
// ── 수평 치수: 전체 깊이 (130mm = g.depth) ──
// ① lip 좌측 ~ ① body 우측
const dimHy = tBot + m1tab + 18;
dims += `<line x1="${trimLipX}" y1="${dimHy}" x2="${trimRightX}" y2="${dimHy}" stroke="#3b82f6" stroke-width="1" marker-start="url(#dimArrow)" marker-end="url(#dimArrow)"/>`;
dims += `<line x1="${trimLipX}" y1="${dimHy - 5}" x2="${trimLipX}" y2="${dimHy + 5}" stroke="#3b82f6" stroke-width="0.5"/>`;
dims += `<line x1="${trimRightX}" y1="${dimHy - 5}" x2="${trimRightX}" y2="${dimHy + 5}" stroke="#3b82f6" stroke-width="0.5"/>`;
dims += `<text x="${(trimLipX + trimRightX) / 2}" y="${dimHy + 14}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.depth} mm</text>`;
// ── 수직 치수: 전체 폭 (75mm = g.width) ──
const dimVx = trimRightX + 12;
// ①상단 lip ~ ①하단 lip (전체 75mm)
const dimVtop = tTop;
const dimVbot = tBot + t1;
dims += `<line x1="${dimVx}" y1="${dimVtop}" x2="${dimVx}" y2="${dimVbot}" stroke="#3b82f6" stroke-width="1"/>`;
dims += `<line x1="${dimVx - 5}" y1="${dimVtop}" x2="${dimVx + 5}" y2="${dimVtop}" stroke="#3b82f6" stroke-width="0.5"/>`;
dims += `<line x1="${dimVx - 5}" y1="${dimVbot}" x2="${dimVx + 5}" y2="${dimVbot}" stroke="#3b82f6" stroke-width="0.5"/>`;
dims += `<text x="${dimVx + 8}" y="${(dimVtop + dimVbot) / 2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>`;
// ── 보조 치수: ② 본체 가로 90mm ──
const dimB2y = by - m1tab - 12;
dims += `<line x1="${bx}" y1="${dimB2y}" x2="${bx + b2w}" y2="${dimB2y}" stroke="#94a3b8" stroke-width="0.7" stroke-dasharray="3,2"/>`;
dims += `<line x1="${bx}" y1="${dimB2y - 4}" x2="${bx}" y2="${dimB2y + 4}" stroke="#94a3b8" stroke-width="0.5"/>`;
dims += `<line x1="${bx + b2w}" y1="${dimB2y - 4}" x2="${bx + b2w}" y2="${dimB2y + 4}" stroke="#94a3b8" stroke-width="0.5"/>`;
dims += `<text x="${bx + b2w / 2}" y="${dimB2y - 3}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">90</text>`;
// ── 보조 치수: ② 본체 세로 72mm ──
const dimB2x = bx - m1lip - 12;
dims += `<line x1="${dimB2x}" y1="${by}" x2="${dimB2x}" y2="${by + b2h}" stroke="#94a3b8" stroke-width="0.7" stroke-dasharray="3,2"/>`;
dims += `<line x1="${dimB2x - 4}" y1="${by}" x2="${dimB2x + 4}" y2="${by}" stroke="#94a3b8" stroke-width="0.5"/>`;
dims += `<line x1="${dimB2x - 4}" y1="${by + b2h}" x2="${dimB2x + 4}" y2="${by + b2h}" stroke="#94a3b8" stroke-width="0.5"/>`;
dims += `<text x="${dimB2x - 3}" y="${by + b2h / 2 + 4}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="end" font-family="Pretendard">72</text>`;
}
// ══════════════════════════════════════════
// 번호 라벨 (원 + 번호)
// ══════════════════════════════════════════
let labels = '';
// ① 마감재 라벨 (상단 중앙 위)
const lb1x = bx + m1body / 2, lb1y = tTop - 12;
labels += `<circle cx="${lb1x}" cy="${lb1y}" r="8" fill="none" stroke="${cTrim}" stroke-width="1.5"/>`;
labels += `<text x="${lb1x}" y="${lb1y + 3}" fill="${cTrim}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">①</text>`;
// (② 라벨 — 제거)
// (③ C형 라벨 — 생략)
// ④ C형 보강 라벨
const lb4x = w4x + w4depth / 2, lb4y = w4cy + w4height / 2;
labels += `<circle cx="${lb4x}" cy="${lb4y}" r="8" fill="none" stroke="${cWall}" stroke-width="1.5"/>`;
labels += `<text x="${lb4x}" y="${lb4y + 3}" fill="${cWall}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>`;
// ══════════════════════════════════════════
// 개구부 방향 표시
// ══════════════════════════════════════════
const arrowX = bx + b2w + 8;
const arrowY = by + b2h / 2;
const arrowTxt = `<text x="${arrowX}" y="${arrowY}" fill="#64748b" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">→ 개구부</text>`;
// ══════════════════════════════════════════
// 방화벽 (해치 패턴) — ① 립 좌측에 밀착
// ══════════════════════════════════════════
const wallX = trimLipX - wallW; // 벽 우측 = ① 립 좌측
const wallYstart = by - 20;
const wallH = b2h + 40;
// ══════════════════════════════════════════
// SVG 조립
// ══════════════════════════════════════════
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="steelWH" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="#8b7355" stroke-width="0.8"/>
</pattern>
</defs>
<!-- 타이틀 -->
<text x="${svgW / 2}" y="22" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 평면도 (Plan View) — 철재스라트</text>
<!-- 방화벽 (해치) -->
<rect x="${wallX}" y="${wallYstart}" width="${wallW}" height="${wallH}" fill="url(#steelWH)" stroke="#8b7355" stroke-width="1" rx="2"/>
<text x="${wallX + wallW / 2}" y="${wallYstart + wallH + 14}" fill="#a1887f" font-size="10" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
<!-- 부재들 -->
${P.join('\n ')}
<!-- 개구부 방향 -->
${arrowTxt}
<!-- 치수선 -->
${dims}
<!-- 번호 라벨 -->
${labels}
<!-- 범례 -->
<text x="${svgW / 2}" y="${svgH - 10}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">①마감재 SUS t=${g.trimThick||1.2} ②본체 EGI t=${g.thickness} ③C형 ④벽연형 — GUIDE RAIL 철재스라트</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 p = PRODUCTS[S.productType];
const D = b.depth, H = b.height;
const fb = b.frontBottom || 50; // 전면 밑치수
const rw = b.railWidth || 70; // 레일폭
// 점검구 치수 (고정치수 제외한 나머지 = 가변)
// 밑면: D - frontBottom - railWidth - 50(린텔선반) - 20(린텔훅) - 20(코너훅) - 50(코너선반)
const bDoorW = D - fb - rw - 50 - 20 - 20 - 50;
// 후면: H - 50(상부코너벽) - 20(상부립) - 20(하부립) - 50(하부코너벽)
const rDoorH = H - 50 - 20 - 20 - 50;
const tabSz = 13; // 점검구 스크류 탭
const flgSz = 17; // 리시빙 플랜지
const sc = Math.min(480 / D, 520 / H);
const pad = 80;
const sD = D*sc, sH = H*sc;
const svgW = sD + pad*2 + 140, svgH = sH + pad*2 + 70;
const ox = pad + 65, oy = pad + 30;
const vt = Math.max(2, (b.thickness||1.55)*sc*1.5);
const pf='#4b5563', ps='#6b7280', ff='#374151';
const doorFill='rgba(139,92,52,0.35)', doorStroke='#d97706';
// 절곡 치수 (스케일 적용)
const f13=tabSz*sc, f17=flgSz*sc, f50=(b.frontBottom||50)*sc, f55=55*sc, f20=20*sc, f15=15*sc;
// 후면 점검구 위치 (높이 방향 가운데 정렬)
const rTopH = Math.round((H - rDoorH) / 2); // 후면 상부 고정부 높이 (~70mm)
const rBotH = H - rDoorH - rTopH; // 후면 하부 고정부 높이
const srTopH = rTopH*sc, srBotH = rBotH*sc, srDoorH = rDoorH*sc;
// 밑면 점검구 위치 (깊이 방향 가운데 정렬)
const bFrontW = Math.round((D - bDoorW) / 2); // 밑면 전면측 고정부 (~130mm)
const bRearW = D - bDoorW - bFrontW;
const sbFrontW = bFrontW*sc, sbRearW = bRearW*sc, sbDoorW = bDoorW*sc;
// 레일 홀
const railHoleW = (b.railWidth || (S.productType==='screen' ? 70 : 120))*sc;
const railUp = 100*sc;
// 샤프트 위치
const shaftR = (b.shaftDia/2)*sc;
const shaftCx = ox + sD*0.40;
const shaftCy = oy + sH*0.50;
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="22" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 측면 단면도 — 양면점검구</text>
<!-- 내부 공간 배경 -->
<rect x="${ox+vt}" y="${oy+vt}" width="${sD-vt*2}" height="${sH-vt*2}" fill="rgba(55,65,81,0.10)" rx="2"/>
<!-- ━━━━━ 고정 판재 (회색) ━━━━━ -->
<!-- ① 케이스 전면판 절곡 프로파일 (7세그먼트, 전개길이 ${(p.sb.frontPanel||[]).reduce((a,b)=>a+b,0)}mm) -->
${(() => {
const seg = (p.sb.frontPanel || [17,55,50,380,55,15,20]).map(v => v*sc);
seg[2] = (b.frontBottom || 50) * sc; // 전면 밑치수 가변 적용
// 상부: 55→(선반) 15↓(스텝) 20→(커버 받침 립) — 커버가 올라가는 파임 형태
// 하부: frontBottom→, 55↑, 17← (J-훅)
const O = [
[ox+seg[4]+seg[6], oy+seg[5]], // 0: Seg7 끝 (커버 받침 립 끝)
[ox+seg[4], oy+seg[5]], // 1: Seg6 하단 / Seg7 시작
[ox+seg[4], oy], // 2: Seg5 끝 / Seg6 상단 (55mm 선반)
[ox, oy], // 3: 본체 상단
[ox, oy+sH], // 4: 본체 하단
[ox+seg[2], oy+sH], // 5: Seg3 끝 (50mm→)
[ox+seg[2], oy+sH-seg[1]], // 6: Seg2 상단 (55mm↑)
[ox+seg[2]-seg[0], oy+sH-seg[1]], // 7: Seg1 끝 (17mm←)
];
const I = [
[ox+seg[4]+seg[6], oy+seg[5]-vt], // 0: Seg7 내곽
[ox+seg[4]-vt, oy+seg[5]-vt], // 1: Seg6/Seg7 내곽 코너
[ox+seg[4]-vt, oy+vt], // 2: Seg5/Seg6 내곽 코너
[ox+vt, oy+vt], // 3: 본체/Seg5 내곽 코너
[ox+vt, oy+sH-vt], // 4: 본체/Seg3 내곽 코너
[ox+seg[2]-vt, oy+sH-vt], // 5: Seg3/Seg2 내곽 코너
[ox+seg[2]-vt, oy+sH-seg[1]+vt], // 6: Seg2/Seg1 내곽 코너
[ox+seg[2]-seg[0], oy+sH-seg[1]+vt], // 7: Seg1 내곽 끝
];
const outer = O.map(p=>p.join(',')).join(' L');
const inner = [I[7],I[6],I[5],I[4],I[3],I[2],I[1],I[0]].map(p=>p.join(',')).join(' L');
const fpCol = '#60a5fa';
const lbls = [
{ x:(O[3][0]+O[2][0])/2, y:oy-6, t:'55→' }, // Seg5
{ x:O[1][0]+10, y:(O[2][1]+O[1][1])/2, t:'15↓' }, // Seg6
{ x:(O[1][0]+O[0][0])/2, y:O[0][1]+10, t:'20→' }, // Seg7
{ x:(O[4][0]+O[5][0])/2, y:O[4][1]+12, t:'50→' }, // Seg3
{ x:O[5][0]+10, y:(O[5][1]+O[6][1])/2, t:'55↑' }, // Seg2
{ x:(O[6][0]+O[7][0])/2, y:O[7][1]-6, t:'←17' }, // Seg1
];
return `
<path d="M${outer} L${inner} Z" fill="${pf}" stroke="${ps}" stroke-width="1.2" opacity="0.95"/>
${lbls.map(l => `<text x="${l.x}" y="${l.y}" fill="${fpCol}" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.7">${l.t}</text>`).join('\n ')}
<text x="${ox-28}" y="${oy+sH/2}" fill="#60a5fa" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard" transform="rotate(-90,${ox-28},${oy+sH/2})" opacity="0.6">전면판 전개 ${(p.sb.frontPanel||[]).reduce((a,b)=>a+b,0)}mm</text>
`;
})()}
<!-- ② 상부 커버 (389mm = D-55-50-6, 전면판 Seg5 뒤에서 시작) -->
${(() => {
const coverW = (D - 55 - 50 - 6) * sc; // 389mm
const coverX = ox + 55*sc; // Seg5(55mm) 뒤에서 시작
return `
<rect x="${coverX}" y="${oy + f15 - vt}" width="${coverW}" height="${vt}" fill="#546e7a" stroke="#78909c" stroke-width="1.2"/>
<text x="${coverX + coverW/2}" y="${oy + f15 - vt - 4}" fill="#78909c" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard">상부 커버 (${D-55-50-6}mm)</text>
`;
})()}
<!-- ━━━━━ 후면벽 구조 (doorDir에 따라 분기) ━━━━━ -->
${(() => {
const c20=20*sc, c15=15*sc, c50=50*sc;
const cx = ox + sD - vt;
const cCol = '#8b6c5c', cStr = '#6d4c3d';
if (b.doorDir === 'bottom') {
// ── 밑면 전용: 후면벽 연속 절곡판 (상부훅+380mm벽+하부훅) ──
return `
<!-- 후면벽 본체 (380mm 전체 높이) -->
<rect x="${cx}" y="${oy}" width="${vt}" height="${sH}" fill="${cCol}" stroke="${cStr}" stroke-width="1" opacity="0.9"/>
<!-- 상부 선반 (50mm ) -->
<rect x="${cx-c50}" y="${oy}" width="${c50+vt}" height="${vt}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<!-- 상부 스텝 (15mm ) -->
<rect x="${cx-c50}" y="${oy+vt}" width="${vt*0.7}" height="${c15}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<!-- 상부 (20mm ) -->
<rect x="${cx-c50-c20}" y="${oy+c15}" width="${c20+vt}" height="${vt*0.7}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<!-- 하부 선반 (50mm ) -->
<rect x="${cx-c50}" y="${oy+sH-vt}" width="${c50+vt}" height="${vt}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<!-- 하부 스텝 (15mm ) -->
<rect x="${cx-c50}" y="${oy+sH-vt-c15}" width="${vt*0.7}" height="${c15}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<!-- 하부 (20mm ) -->
<rect x="${cx-c50-c20}" y="${oy+sH-c15-vt}" width="${c20+vt}" height="${vt*0.7}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<text x="${cx-c50/2}" y="${oy+sH/2}" fill="${cCol}" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.6" transform="rotate(-90,${cx-c50/2},${oy+sH/2})">후면벽 (연속)</text>
`;
}
// ── 양면(dual) / 후면(rear): 상부코너 + 후면점검구 + 밑면코너 ──
const ct = oy, cy = oy + sH;
return `
<!-- 상부 코너부 -->
<rect x="${cx-c50}" y="${ct}" width="${c50+vt}" height="${vt}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx}" y="${ct}" width="${vt}" height="${c50}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c50}" y="${ct+vt}" width="${vt*0.7}" height="${c15}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c50-c20}" y="${ct+c15}" width="${c20+vt}" height="${vt*0.7}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c15}" y="${ct+c50}" width="${c15}" height="${vt*0.7}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c15}" y="${ct+c50}" width="${vt*0.7}" height="${c20}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<text x="${cx-c50/2}" y="${ct+c50/2}" fill="${cCol}" font-size="6" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.6">상부코너</text>
<!-- 밑면 코너부 -->
<rect x="${cx}" y="${cy-c50}" width="${vt}" height="${c50}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c15}" y="${cy-c50}" width="${c15}" height="${vt*0.7}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c15}" y="${cy-c50-c20}" width="${vt*0.7}" height="${c20}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c50}" y="${cy-vt}" width="${c50+vt}" height="${vt}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
${b.doorDir !== 'rear' ? `
<rect x="${cx-c50}" y="${cy-c15-vt}" width="${vt*0.7}" height="${c15+vt}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
<rect x="${cx-c50-c20}" y="${cy-c15-vt}" width="${c20+vt}" height="${vt*0.7}" fill="${cCol}" stroke="${cStr}" stroke-width="0.8" opacity="0.85"/>
` : ''}
<text x="${cx-c50/2}" y="${cy-c50/2}" fill="${cCol}" font-size="6" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.6">코너부</text>
<!-- 후면 점검구 절곡판 -->
<rect x="${cx}" y="${oy+srTopH}" width="${vt}" height="${srDoorH}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1.5" stroke-dasharray="6 3"/>
<rect x="${cx-f13}" y="${oy+srTopH}" width="${f13+vt}" height="${vt*0.7}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<rect x="${cx-f13}" y="${oy+srTopH-f17}" width="${vt*0.7}" height="${f17}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<rect x="${cx-f13}" y="${oy+srTopH+srDoorH-vt*0.7}" width="${f13+vt}" height="${vt*0.7}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<rect x="${cx-f13}" y="${oy+srTopH+srDoorH}" width="${vt*0.7}" height="${f17}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<text x="${cx+vt+15}" y="${oy+srTopH+srDoorH/2-4}" fill="${doorStroke}" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">후면</text>
<text x="${cx+vt+15}" y="${oy+srTopH+srDoorH/2+8}" fill="${doorStroke}" font-size="8" text-anchor="start" font-family="Pretendard" opacity="0.7">점검구</text>
`;
})()}
<!-- ━━━━━ 밑면 구조 (doorDir에 따라 분기) ━━━━━ -->
${(() => {
const brkCol = '#7c9c6b', brkStroke = '#5a7a4a';
const dy = oy + sH;
const rhX = ox + f50;
const rhW = railHoleW;
if (b.doorDir === 'rear') {
// ── 후면 전용: 밑면 연속판 (전면판 J-훅 + 레일개구 + 연속 밑면) ──
const c20=20*sc, c15=15*sc, c50=50*sc;
const bottomLen = sD - f50 - rhW; // 린텔위치~후면벽
return `
<!-- 레일 -->
<rect x="${rhX}" y="${dy-vt}" width="${rhW}" height="${vt}" fill="rgba(100,116,139,0.2)" stroke="#64748b" stroke-width="1" stroke-dasharray="3 2" rx="1"/>
<rect x="${rhX}" y="${dy-railUp}" width="${rhW}" height="${railUp-vt}" fill="rgba(100,116,139,0.08)" stroke="#64748b" stroke-width="0.8" stroke-dasharray="4 2" rx="1"/>
<text x="${rhX+rhW/2}" y="${dy-railUp/2+4}" fill="#64748b" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard">레일</text>
<!-- 밑면 연속판 (린텔벽~후면벽, 점검구 없음) -->
<rect x="${rhX+rhW}" y="${dy-vt}" width="${bottomLen}" height="${vt}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="1" opacity="0.9"/>
<!-- 린텔측 상향 (55+30) -->
<rect x="${rhX+rhW}" y="${dy-55*sc}" width="${vt}" height="${55*sc}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<rect x="${rhX+rhW}" y="${dy-55*sc}" width="${30*sc}" height="${vt}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<!-- 후면 전용: 중간 없음 (하나의 연속 절곡물) -->
<text x="${ox+sD/2}" y="${dy+12}" fill="${brkCol}" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.6">밑면 (연속)</text>
`;
}
// ── 양면(dual) / 밑면(bottom): 린텔 + 밑면 점검구 + 코너부 밑면선반 ──
const b20=20*sc, b15=15*sc, b50b=50*sc, b55=55*sc, b30=30*sc;
const lintelX = ox + f50 + rhW;
const lintelShelfEnd = lintelX + b50b;
const lintelHookEnd = lintelShelfEnd + b20;
const doorMainSpan = bDoorW;
const dw = doorMainSpan * sc;
const df17 = f17, df13 = f13;
const fy = dy - f15;
return `
<!-- 레일 -->
<rect x="${rhX}" y="${dy-vt}" width="${rhW}" height="${vt}" fill="rgba(100,116,139,0.2)" stroke="#64748b" stroke-width="1" stroke-dasharray="3 2" rx="1"/>
<rect x="${rhX}" y="${dy-railUp}" width="${rhW}" height="${railUp-vt}" fill="rgba(100,116,139,0.08)" stroke="#64748b" stroke-width="0.8" stroke-dasharray="4 2" rx="1"/>
<text x="${rhX+rhW/2}" y="${dy-railUp/2+4}" fill="#64748b" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard">레일</text>
<!-- 린텔 -->
<rect x="${lintelX}" y="${dy-b55}" width="${vt}" height="${b55}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<rect x="${lintelX}" y="${dy-b55}" width="${b30}" height="${vt}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<rect x="${lintelX}" y="${dy-vt}" width="${b50b}" height="${vt}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<rect x="${lintelX+b50b}" y="${dy-b15-vt}" width="${vt}" height="${b15+vt}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<rect x="${lintelX+b50b}" y="${dy-b15-vt}" width="${b20}" height="${vt}" fill="${brkCol}" stroke="${brkStroke}" stroke-width="0.8" opacity="0.85"/>
<text x="${lintelX+b30/2}" y="${dy-b55/2}" fill="${brkCol}" font-size="6" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.6">린텔</text>
<!-- 밑면 점검구 -->
<rect x="${lintelHookEnd}" y="${fy+df13}" width="${dw}" height="${vt}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1.5" stroke-dasharray="6 3"/>
<rect x="${lintelHookEnd}" y="${fy}" width="${vt*0.7}" height="${df13+vt}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<rect x="${lintelHookEnd-df17}" y="${fy}" width="${df17+vt*0.3}" height="${vt*0.7}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<rect x="${lintelHookEnd+dw-vt*0.7}" y="${fy}" width="${vt*0.7}" height="${df13+vt}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<rect x="${lintelHookEnd+dw-vt*0.3}" y="${fy}" width="${df17+vt*0.3}" height="${vt*0.7}" fill="${doorFill}" stroke="${doorStroke}" stroke-width="1" stroke-dasharray="4 2"/>
<line x1="${lintelHookEnd}" y1="${fy+df13+vt+8}" x2="${lintelHookEnd+dw}" y2="${fy+df13+vt+8}" stroke="${doorStroke}" stroke-width="0.8"/>
<line x1="${lintelHookEnd}" y1="${fy+df13+vt+3}" x2="${lintelHookEnd}" y2="${fy+df13+vt+13}" stroke="${doorStroke}" stroke-width="0.5"/>
<line x1="${lintelHookEnd+dw}" y1="${fy+df13+vt+3}" x2="${lintelHookEnd+dw}" y2="${fy+df13+vt+13}" stroke="${doorStroke}" stroke-width="0.5"/>
<text x="${lintelHookEnd+dw/2}" y="${fy+df13+vt+22}" fill="${doorStroke}" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">밑면 점검구 ${doorMainSpan}</text>
`;
})()}
<!-- ━━━━━ 슬랫 경로 (레일홀 → 상부 → 샤프트 시계방향 감김) ━━━━━ -->
${(() => {
const railCx = ox + f50 + railHoleW / 2; // 레일 개구 중앙
const rollTopY = shaftCy - rollR;
return `
<!-- 케이스 외부: 슬랫 하강 -->
<line x1="${railCx}" y1="${oy+sH}" x2="${railCx}" y2="${oy+sH+20}" stroke="#f59e0b" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
<!-- 케이스 내부: 레일홀에서 샤프트 좌측까지 대각선 직선 (팽팽한 ) -->
<line x1="${railCx}" y1="${oy+sH-vt}" x2="${shaftCx-rollR}" y2="${shaftCy}" stroke="#f59e0b" stroke-width="2.5" stroke-dasharray="5 3" opacity="0.5"/>
<text x="${railCx+12}" y="${oy+sH+18}" fill="#94a3b8" font-size="8" font-weight="700" font-family="Pretendard"> 슬랫</text>
`;
})()}
<!-- ━━━━━ 내부 구성품 ━━━━━ -->
${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"/>
` : ''}
${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('')}
` : ''}
${b.showMotor ? (() => {
const mR = shaftR * 1.1;
const gap = rollR + mR + 25*sc;
const mCx = shaftCx + gap;
const mCy = shaftCy;
const sprS = shaftR * 0.35; // 샤프트 스프로켓
const sprM = mR * 0.4; // 모터 스프로켓
return `
<!-- 모터 본체 -->
<circle cx="${mCx}" cy="${mCy}" r="${mR}" fill="#2563eb" opacity="0.25" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="${mCx}" y="${mCy+3}" fill="#3b82f6" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard" opacity="0.6">M</text>
<!-- 스프로켓 (샤프트측 + 모터측) -->
<circle cx="${shaftCx}" cy="${shaftCy}" r="${sprS}" fill="none" stroke="#60a5fa" stroke-width="1.5" opacity="0.6"/>
<circle cx="${mCx}" cy="${mCy}" r="${sprM}" fill="none" stroke="#60a5fa" stroke-width="1.5" opacity="0.6"/>
<!-- 체인 루프 (스프로켓 감싸는 벨트 형태) -->
<path d="M${shaftCx},${shaftCy-sprS} L${mCx},${mCy-sprM} A${sprM},${sprM} 0 0 1 ${mCx},${mCy+sprM} L${shaftCx},${shaftCy+sprS} A${sprS},${sprS} 0 0 1 ${shaftCx},${shaftCy-sprS} Z" fill="rgba(96,165,250,0.08)" stroke="#60a5fa" stroke-width="1.5" stroke-dasharray="4 2" opacity="0.5"/>
`;
})() : ''}
<!-- ━━━━━ 치수선 ━━━━━ -->
<!-- 깊이 (상단) -->
<line x1="${ox}" y1="${oy-15}" x2="${ox+sD}" y2="${oy-15}" stroke="#3b82f6" stroke-width="1"/>
<line x1="${ox}" y1="${oy-20}" x2="${ox}" y2="${oy-5}" stroke="#3b82f6" stroke-width="0.5"/>
<line x1="${ox+sD}" y1="${oy-20}" x2="${ox+sD}" y2="${oy-5}" stroke="#3b82f6" stroke-width="0.5"/>
<text x="${ox+sD/2}" y="${oy-22}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">${D} × ${H}</text>
<!-- 전체 높이 (좌측) -->
<line x1="${ox-20}" y1="${oy}" x2="${ox-20}" y2="${oy+sH}" stroke="#3b82f6" stroke-width="1"/>
<line x1="${ox-25}" y1="${oy}" x2="${ox-15}" y2="${oy}" stroke="#3b82f6" stroke-width="0.5"/>
<line x1="${ox-25}" y1="${oy+sH}" x2="${ox-15}" y2="${oy+sH}" stroke="#3b82f6" stroke-width="0.5"/>
<text x="${ox-28}" y="${oy+sH/2+4}" fill="#3b82f6" font-size="10" font-weight="700" text-anchor="end" font-family="Pretendard">${H}</text>
<!-- 후면 점검구 높이 (우측 1단, 양면/후면 모드만 표시) -->
${b.doorDir !== 'bottom' ? `
<line x1="${ox+sD+15}" y1="${oy+srTopH}" x2="${ox+sD+15}" y2="${oy+srTopH+srDoorH}" stroke="${doorStroke}" stroke-width="1"/>
<line x1="${ox+sD+10}" y1="${oy+srTopH}" x2="${ox+sD+20}" y2="${oy+srTopH}" stroke="${doorStroke}" stroke-width="0.5"/>
<line x1="${ox+sD+10}" y1="${oy+srTopH+srDoorH}" x2="${ox+sD+20}" y2="${oy+srTopH+srDoorH}" stroke="${doorStroke}" stroke-width="0.5"/>
<text x="${ox+sD+25}" y="${oy+srTopH+srDoorH/2+4}" fill="${doorStroke}" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">${rDoorH}</text>
` : ''}
<!-- 밑면 점검구 치수: 절곡판 IIFE 내에서 표시 -->
<!-- 측면 라벨 -->
<text x="${ox-8}" y="${oy+sH/2}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard" transform="rotate(-90,${ox-8},${oy+sH/2})">전면 (실내)</text>
<text x="${ox+sD+8}" y="${oy+sH/2}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard" transform="rotate(90,${ox+sD+8},${oy+sH/2})">후면 (벽측)</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.fsSet3dParam = function(key, v) {
S.sb[key] = Number(v);
// 셔터박스 탭 입력과 양방향 동기화
const syncMap = { railWidth:'sbRailWidth', frontBottom:'sbFrontBottom', depth:'sbDepth', height:'sbHeight', shaftDia:'sbShaftDia', thickness:'sbThickness' };
if (syncMap[key] && $(syncMap[key])) $(syncMap[key]).value = v;
fs3dBuild();
};
window.fsSetDoorDir = function(v) {
S.sb.doorDir = v;
if ($('sbDoorDir')) $('sbDoorDir').value = v;
if ($('td3dDoorDir')) $('td3dDoorDir').value = v;
if (S.tab === '3D') fs3dBuild(); else 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 (B키로 토글)
const grid = new THREE.GridHelper(5000, 50, 0x334155, 0x1e293b);
grid.position.y = 0;
scene.add(grid);
let floorGridRef = grid; // B키 토글용 참조
// === TransformControls (클릭 선택 + 이동) ===
transformCtrl = new THREE.TransformControls(camera, renderer.domElement);
transformCtrl.setMode('translate');
transformCtrl.setSize(0.8);
scene.add(transformCtrl);
// 드래그 중 OrbitControls 비활성화
transformCtrl.addEventListener('dragging-changed', (e) => {
controls.enabled = !e.value;
});
// 선택 HUD
const selectHud = document.createElement('div');
selectHud.className = 'fs-select-hud';
selectHud.style.cssText = 'position:absolute;top:8px;left:8px;display:none;background:rgba(0,0,0,0.7);color:#fff;padding:6px 12px;border-radius:6px;font-size:12px;pointer-events:none;z-index:10;font-family:Pretendard,sans-serif;';
container.appendChild(selectHud);
function updateSelectHud() {
const hiddenInfo = hiddenKeys.size > 0
? `<span style="color:#f59e0b;margin-left:8px;">${hiddenKeys.size}개 감춤</span> <span style="opacity:0.5">Alt+H:전체 표시</span>`
: '';
if (!selectedKey) {
if (hiddenKeys.size > 0) {
selectHud.innerHTML = hiddenInfo;
selectHud.style.display = 'block';
} else {
selectHud.style.display = 'none';
}
return;
}
const label = meshLabels[selectedKey] || selectedKey;
const axisAll = transformCtrl.showX && transformCtrl.showY && transformCtrl.showZ;
let axisText = '전체 축';
let axisColor = '#fff';
if (!axisAll) {
if (transformCtrl.showX) { axisText = 'X축'; axisColor = '#ef4444'; }
if (transformCtrl.showY) { axisText = 'Y축'; axisColor = '#22c55e'; }
if (transformCtrl.showZ) { axisText = 'Z축'; axisColor = '#3b82f6'; }
}
selectHud.innerHTML = `<b>${label}</b> 선택 · <span style="color:${axisColor}">${axisText}</span> 이동 <span style="opacity:0.5">| 화살표 기즈모를 드래그하여 이동 · X Y Z:축 · H:감추기 · ESC:해제</span>${hiddenInfo}`;
selectHud.style.display = 'block';
}
function selectMesh(key) {
deselectMesh();
selectedKey = key;
const obj = meshes[key];
transformCtrl.attach(obj);
transformCtrl.showX = true;
transformCtrl.showY = true;
transformCtrl.showZ = true;
// 선택 박스 (녹색 바운딩박스)
selectionBox = new THREE.BoxHelper(obj, 0x22c55e);
scene.add(selectionBox);
updateSelectHud();
}
function deselectMesh() {
if (selectionBox) { scene.remove(selectionBox); selectionBox = null; }
if (selectedKey) { transformCtrl.detach(); selectedKey = null; }
updateSelectHud();
}
// 클릭 감지 (드래그와 구분)
let clickStart = null;
renderer.domElement.addEventListener('pointerdown', (e) => {
if (e.button === 0) clickStart = { x: e.clientX, y: e.clientY };
});
renderer.domElement.addEventListener('pointerup', (e) => {
if (!clickStart || e.button !== 0) return;
const dx = e.clientX - clickStart.x, dy = e.clientY - clickStart.y;
clickStart = null;
if (Math.sqrt(dx * dx + dy * dy) > 4) return; // 드래그면 무시
if (transformCtrl.dragging) return; // 기즈모 조작 중이면 무시
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const sceneObjects = Object.entries(meshes).filter(([k, m]) => m && !hiddenKeys.has(k)).map(([, m]) => m);
const intersects = raycaster.intersectObjects(sceneObjects, true);
if (intersects.length > 0) {
const key = findMeshKey(intersects[0].object);
if (key && meshes[key]) selectMesh(key);
} else {
deselectMesh();
}
});
// 키보드 단축키 (축 제한 + 감추기)
document.addEventListener('keydown', (e) => {
if (S.tab !== '3D') return;
const k = e.key.toLowerCase();
// Alt+H: 감춘 요소 전체 표시
if (k === 'h' && e.altKey) {
e.preventDefault();
if (hiddenKeys.size === 0) return;
hiddenKeys.forEach(hk => {
if (meshes[hk]) meshes[hk].visible = true;
// S.td.show + 토글 UI 복원
if (hk in S.td.show) {
S.td.show[hk] = true;
document.querySelectorAll(`[onclick*="fsToggle3d(this,'${hk}')"]`).forEach(el => el.classList.add('active'));
}
});
hiddenKeys.clear();
updateSelectHud();
return;
}
// B: 바닥 그리드 토글
if (k === 'b' && !e.altKey && !e.ctrlKey) {
if (floorGridRef) floorGridRef.visible = !floorGridRef.visible;
return;
}
// H: 선택된 요소 감추기 (토글 UI 연동)
if (k === 'h' && selectedKey) {
const hideKey = selectedKey;
deselectMesh();
if (meshes[hideKey]) {
meshes[hideKey].visible = false;
hiddenKeys.add(hideKey);
// S.td.show 상태 + 토글 UI 동기화
if (hideKey in S.td.show) {
S.td.show[hideKey] = false;
document.querySelectorAll(`[onclick*="fsToggle3d(this,'${hideKey}')"]`).forEach(el => el.classList.remove('active'));
}
// 슬랫 숨기면 감긴 슬랫도 연동
if (hideKey === 'slats') {
S.td.show.slatRoll = false;
if (meshes.slatRoll) meshes.slatRoll.visible = false;
}
}
updateSelectHud();
return;
}
// 아래는 선택된 상태에서만 동작
if (!selectedKey) return;
if (k === 'x') {
transformCtrl.showX = true; transformCtrl.showY = false; transformCtrl.showZ = false;
updateSelectHud();
} else if (k === 'y') {
transformCtrl.showX = false; transformCtrl.showY = true; transformCtrl.showZ = false;
updateSelectHud();
} else if (k === 'z') {
transformCtrl.showX = false; transformCtrl.showY = false; transformCtrl.showZ = true;
updateSelectHud();
} else if (k === 'a' || k === ' ') {
transformCtrl.showX = true; transformCtrl.showY = true; transformCtrl.showZ = true;
updateSelectHud();
} else if (k === 'escape') {
deselectMesh();
}
});
function animate() {
animId = requestAnimationFrame(animate);
controls.update();
if (selectionBox) selectionBox.update();
renderer.render(scene, camera);
}
animate();
// Resize (use ResizeObserver for flex layout)
const ro = new ResizeObserver(() => {
if (container.clientWidth && container.clientHeight) {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
});
ro.observe(container);
// === 우클릭 컨텍스트 메뉴 (단품 보기 / 전체 보기) ===
const ctxMenu = document.createElement('div');
ctxMenu.className = 'fs-ctx-menu';
document.body.appendChild(ctxMenu);
// 단품 보기 상태 배지
container.style.position = 'relative';
const isoBadge = document.createElement('div');
isoBadge.className = 'fs-iso-badge';
isoBadge.onclick = () => { fs3dShowAll(); };
container.appendChild(isoBadge);
// ── 뷰 전환 패널 (정면/평면/우측면/좌측면/투시) ──
const viewPanel = document.createElement('div');
viewPanel.style.cssText = 'position:absolute;top:8px;right:8px;display:flex;gap:4px;z-index:10;';
const viewPresets = [
{ label: '정면', icon: '⬜', key: 'front' },
{ label: '평면', icon: '⬒', key: 'top' },
{ label: '우측', icon: '▷', key: 'right' },
{ label: '좌측', icon: '◁', key: 'left' },
{ label: '배면', icon: '⬛', key: 'back' },
{ label: '투시', icon: '◈', key: 'persp' },
];
viewPresets.forEach(vp => {
const btn = document.createElement('button');
btn.style.cssText = 'padding:5px 10px;background:rgba(30,41,59,0.85);border:1px solid rgba(100,116,139,0.4);color:#94a3b8;font-size:11px;font-weight:700;border-radius:6px;cursor:pointer;font-family:Pretendard,sans-serif;transition:all 0.15s;';
btn.textContent = vp.label;
btn.onmouseenter = () => { btn.style.background = 'rgba(59,130,246,0.3)'; btn.style.color = '#fff'; btn.style.borderColor = '#3b82f6'; };
btn.onmouseleave = () => { btn.style.background = 'rgba(30,41,59,0.85)'; btn.style.color = '#94a3b8'; btn.style.borderColor = 'rgba(100,116,139,0.4)'; };
btn.onclick = () => fs3dSetView(vp.key);
viewPanel.appendChild(btn);
});
container.appendChild(viewPanel);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 클릭된 메시에서 meshes 키 찾기
function findMeshKey(hitObj) {
let target = hitObj;
while (target.parent && target.parent !== scene) target = target.parent;
for (const [key, obj] of Object.entries(meshes)) {
if (obj === target) return key;
}
return null;
}
// 우클릭 이벤트
renderer.domElement.addEventListener('contextmenu', (e) => {
e.preventDefault();
ctxMenu.style.display = 'none';
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const sceneObjects = Object.entries(meshes).filter(([k, m]) => m && !hiddenKeys.has(k)).map(([, m]) => m);
const intersects = raycaster.intersectObjects(sceneObjects, true);
let hitKey = null;
if (intersects.length > 0) hitKey = findMeshKey(intersects[0].object);
// 메뉴 항목 구성
ctxMenu.innerHTML = '';
let hasItem = false;
if (hitKey) {
const label = meshLabels[hitKey] || hitKey;
const btn = document.createElement('button');
btn.className = 'fs-ctx-btn';
btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M11 8v6M8 11h6"/></svg> '${label}' 단품 보기`;
btn.onclick = () => { fs3dIsolate(hitKey); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(btn);
hasItem = true;
}
if (fs3dIsolated) {
if (hasItem) {
const sep = document.createElement('div');
sep.className = 'fs-ctx-sep';
ctxMenu.appendChild(sep);
}
const btn = document.createElement('button');
btn.className = 'fs-ctx-btn';
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg> 전체 보기';
btn.onclick = () => { fs3dShowAll(); ctxMenu.style.display = 'none'; };
ctxMenu.appendChild(btn);
hasItem = true;
}
if (!hasItem) return;
// 화면 밖으로 넘어가지 않도록 위치 조정
ctxMenu.style.display = 'block';
const mw = ctxMenu.offsetWidth, mh = ctxMenu.offsetHeight;
const mx = Math.min(e.clientX, window.innerWidth - mw - 8);
const my = Math.min(e.clientY, window.innerHeight - mh - 8);
ctxMenu.style.left = mx + 'px';
ctxMenu.style.top = my + 'px';
});
// 좌클릭/스크롤 시 메뉴 닫기
document.addEventListener('click', () => { ctxMenu.style.display = 'none'; });
renderer.domElement.addEventListener('wheel', () => { ctxMenu.style.display = 'none'; });
}
let fs3dCameraInit = false;
function fs3dBuild() {
if (!scene) return;
// 선택/숨기기 해제 (리빌드 시 메시가 교체되므로)
if (transformCtrl) transformCtrl.detach();
if (selectionBox) { scene.remove(selectionBox); selectionBox = null; }
selectedKey = null;
hiddenKeys.clear();
// Clear existing meshes
Object.values(meshes).forEach(m => { if (m) scene.remove(m); });
meshes = {};
const W = S.openWidth || 2000;
const H = S.openHeight || 3000;
const p = PRODUCTS[S.productType];
const W1 = W + p.marginW;
const b = S.sb;
const g = S.gr;
// Center: opening center at 0,H/2,0
const hw = W / 2;
// 셔터박스 Z 배치 — 도면 기준
// 스크린: Z방향 = width(개구부 70mm), 철재: Z방향 = depth(75mm)
const railZSpan = S.productType === 'screen' ? p.gr.width : p.gr.depth;
const railHalf = railZSpan / 2; // 스크린:35, 철재:37.5
const frontOffset = railHalf + 50; // 박스 전면판 Z (스크린:85, 철재:110)
const boxCenterZ = frontOffset - b.depth / 2;
// 샤프트 Z: 기본 위치에서 시작, 박스 전면판을 벗어나면 자동 보정
const maxFwd = Math.max(p.bk.nmD / 2, p.bk.sprocketR); // 전면 최대 돌출
let shaftCenterZ = -(b.shaftDia / 2 + 5);
if (shaftCenterZ + maxFwd > frontOffset - 5) {
shaftCenterZ = frontOffset - maxFwd - 5; // 전면판 안쪽 5mm 여유
}
// === SHUTTER BOX (CASE) — 조립식 철판 구조 ===
// 절곡도 기반: 후면판, 상판, 전면 상부판, 점검구(탈착), 바닥판, 좌우측판
const pt = b.thickness || 1.6; // 철판 두께
const frontH = p.sb.frontH; // 전면 상부판 높이 (스크린:240, 철재:410)
const doorH = b.height - frontH; // 점검구 높이 (스크린:140, 철재:140)
const junctionY = b.height - frontH; // 상부판-점검구 접합부 Y위치
const boxMat = new THREE.MeshStandardMaterial({ color: 0x374151, transparent: true, opacity: S.td.caseOpacity, side: THREE.DoubleSide });
const doorMat = new THREE.MeshStandardMaterial({ color: 0x5c4833, transparent: true, opacity: Math.min(S.td.caseOpacity * 0.85, 1), side: THREE.DoubleSide });
const boxEdgeMat = new THREE.LineBasicMaterial({ color: 0x94a3b8 });
const doorEdgeMat = new THREE.LineBasicMaterial({ color: 0xd97706 });
meshes.case = new THREE.Group();
meshes.case.position.set(0, H, boxCenterZ);
// 철판 생성 헬퍼 (mat, eMat: 선택적 재질 오버라이드)
function addPlate(w, h, d, x, y, z, mat, eMat) {
const geo = new THREE.BoxGeometry(w, h, d);
const m = new THREE.Mesh(geo, mat || boxMat);
m.position.set(x, y, z);
meshes.case.add(m);
const edges = new THREE.EdgesGeometry(geo);
m.add(new THREE.LineSegments(edges, eMat || boxEdgeMat));
}
const isDualDoor = !!p.sb.rearDoorH; // 양면점검구 여부 (screen/wire)
if (isDualDoor) {
// ━━━━━ 양면점검구 구조 (screen, wire slat) ━━━━━
const fbVal = b.frontBottom || 50;
const rwVal = b.railWidth || 70;
const bDoorW = b.depth - fbVal - rwVal - 50 - 20 - 20 - 50; // 밑면 점검구 (가변)
const rDoorH = b.height - 50 - 20 - 20 - 50; // 후면 점검구 (가변)
const flgSz = 17; // 리시빙 플랜지 (고정부)
const tabSz = 13; // 스크류 탭 (점검구)
const retCorner = 50; // 상판 후면측 코너 리턴
// 후면: 높이 방향 가운데 정렬
const rTopH = Math.round((b.height - rDoorH) / 2);
const rBotH = b.height - rDoorH - rTopH;
// 밑면: 깊이 방향 가운데 정렬
const bFrontW = Math.round((b.depth - bDoorW) / 2);
const bRearW = b.depth - bDoorW - bFrontW;
// ── ① 케이스 전면판 절곡 프로파일 ──
const fpSeg = [...(p.sb.frontPanel || [17, 55, 50, 380, 55, 15, 20])];
fpSeg[2] = b.frontBottom || 50; // 전면 밑치수 가변 적용
const frontZ = b.depth / 2;
// Seg4: 본체 (전체 높이 수직면)
addPlate(W1, b.height, pt, 0, b.height / 2, frontZ - pt / 2);
// ── 상부: 55→(선반) 15↓(스텝) 20→(커버 받침 립) ──
// Seg5: 55mm → (케이스 상단에서 내향, 커버 선반)
addPlate(W1, pt, fpSeg[4], 0, b.height - pt / 2, frontZ - fpSeg[4] / 2);
// Seg6: 15mm ↓ (Seg5 내측 끝에서 하향, 커버 두께 스텝)
addPlate(W1, fpSeg[5], pt, 0, b.height - fpSeg[5] / 2, frontZ - fpSeg[4] + pt / 2);
// Seg7: 20mm → (Seg6 하단에서 더 내향, 커버 받침 립)
addPlate(W1, pt, fpSeg[6], 0, b.height - fpSeg[5] + pt / 2, frontZ - fpSeg[4] - fpSeg[6] / 2);
// ── 하부: J-훅 (50→ 내향, 55↑ 상향, 17← 전면복귀) ──
// Seg3: 50mm → (케이스 바닥에서 내향)
addPlate(W1, pt, fpSeg[2], 0, pt / 2, frontZ - fpSeg[2] / 2);
// Seg2: 55mm ↑ (Seg3 끝에서 케이스 내부 위로)
addPlate(W1, fpSeg[1], pt, 0, fpSeg[1] / 2, frontZ - fpSeg[2] + pt / 2);
// Seg1: 17mm ← (Seg2 상단에서 전면 방향으로 복귀)
addPlate(W1, pt, fpSeg[0], 0, fpSeg[1] - pt / 2, frontZ - fpSeg[2] + fpSeg[0] / 2);
// ── 밑면레일연결절곡물 (린텔, 전면에서 70mm 레일 띄우고 위치) ──
// 전면판 J-훅과 Y축 대칭, CCW: Seg1(20→)─Seg2(15↑)─Seg3(50←)─Seg4(55↑)─Seg5(30→)
const brkMat = new THREE.MeshStandardMaterial({ color: 0x7c9c6b, transparent: true, opacity: S.td.caseOpacity, side: THREE.DoubleSide });
const railW = b.railWidth || (S.productType === 'screen' ? 70 : 120);
const brkZ = frontZ - fpSeg[2] - railW; // 전면판 Seg3(50) + 레일(70) 뒤
// Seg4: 좌측벽 (55mm ↑, 밑면에서 위로, 린텔 메인벽)
addPlate(W1, 55, pt, 0, 55 / 2, brkZ + pt / 2, brkMat);
// Seg5: 상부립 (30mm → 내향, 벽 상단)
addPlate(W1, pt, 30, 0, 55 - pt / 2, brkZ - 30 / 2, brkMat);
// Seg3: 하부선반 (50mm → 내향, 밑면 레벨)
addPlate(W1, pt, 50, 0, pt / 2, brkZ - 50 / 2, brkMat);
// Seg2: 후크벽 (15mm ↑, 선반 내측 끝에서 상향, 케이스 내부로)
addPlate(W1, 15, pt, 0, 15 / 2, brkZ - 50 - pt / 2, brkMat);
// Seg1: 하부립 (20mm → 내향, 후크 상단에서)
addPlate(W1, pt, 20, 0, 15 - pt / 2, brkZ - 50 - 20 / 2, brkMat);
// ── 밑면 코너부 (6세그먼트: 20,15,50,50,15,20, 후면벽↔밑면 L-코너) ──
const cornerMat = new THREE.MeshStandardMaterial({ color: 0x8b6c5c, transparent: true, opacity: S.td.caseOpacity, side: THREE.DoubleSide });
const cornerZ = -b.depth / 2 + pt; // 후면벽 내측 Z
// Seg3: 수직부 (50mm, 후면벽 내측 하부)
addPlate(W1, 50, pt, 0, 50 / 2, cornerZ + pt / 2, cornerMat);
// Seg1: 상부 립 (20mm ↑, 수직부 상단에서 상향)
addPlate(W1, 20, pt * 0.7, 0, 50 + 20 / 2, cornerZ + pt / 2, cornerMat);
// Seg2: 상부 스텝 (15mm → 내향, 수직부 상단)
addPlate(W1, pt * 0.7, 15, 0, 50, cornerZ + pt + 15 / 2, cornerMat);
// Seg4: 수평부 (50mm, 밑면 선반)
addPlate(W1, pt, 50, 0, pt / 2, cornerZ + 50 / 2, cornerMat);
// Seg5: 하부 스텝 (15mm ↑, 선반 내측 끝에서 상향)
addPlate(W1, 15, pt * 0.7, 0, 15 / 2, cornerZ + 50 + pt / 2, cornerMat);
// Seg6: 하부 립 (20mm → 내향, 점검구 우측 결합)
addPlate(W1, pt * 0.7, 20, 0, 15, cornerZ + 50 + 20 / 2, cornerMat);
// ── ② 상부 커버는 케이스 공통으로 아래에서 렌더링 ──
const doorDir = b.doorDir || 'dual';
const rearZ = -b.depth / 2 + pt / 2; // 후면벽 Z
// ── 후면벽 구조 (doorDir 분기) ──
if (doorDir === 'bottom') {
// 밑면 전용: 후면벽 연속판 (상부훅+벽+하부훅)
addPlate(W1, b.height, pt, 0, b.height / 2, rearZ); // 전체 높이 벽
addPlate(W1, pt, 50, 0, b.height - pt / 2, rearZ + pt / 2 + 25); // 상부 선반
addPlate(W1, pt, 50, 0, pt / 2, rearZ + pt / 2 + 25); // 하부 선반
} else {
// 양면/후면: 상부고정부 + 점검구 + 하부고정부
addPlate(W1, rTopH, pt, 0, b.height - rTopH / 2, rearZ);
addPlate(W1, rBotH, pt, 0, rBotH / 2, rearZ);
// 후면 점검구 (탈착)
addPlate(W1, rDoorH, pt, 0, rBotH + rDoorH / 2, rearZ, doorMat, doorEdgeMat);
}
// ── 좌우측판 ──
addPlate(pt, b.height, b.depth, -W1 / 2 + pt / 2, b.height / 2, 0);
addPlate(pt, b.height, b.depth, W1 / 2 - pt / 2, b.height / 2, 0);
// ── 밑면 구조 (doorDir 분기) ──
const openW = railZSpan;
if (doorDir === 'rear') {
// 후면 전용: 밑면 연속판 (레일홀 후~후면벽)
const contStart = frontZ - fpSeg[2] - railW;
const contLen = b.depth / 2 + contStart;
addPlate(W1, pt, contLen, 0, pt / 2, contStart - contLen / 2);
} else {
// 양면/밑면: 린텔~점검구~코너부 구조
const solidFront = bFrontW - openW;
if (solidFront > 0) addPlate(W1, pt, solidFront, 0, pt / 2, b.depth / 2 - solidFront / 2);
addPlate(W1, pt, bRearW, 0, pt / 2, -b.depth / 2 + bRearW / 2);
// 밑면 점검구 (탈착)
addPlate(W1, pt, bDoorW, 0, pt / 2, b.depth / 2 - bFrontW - bDoorW / 2, doorMat, doorEdgeMat);
}
} else {
// ━━━━━ 기존 전면 점검구 구조 (steel) ━━━━━
const flangeBack = 55, flangeFront = 50, returnW = 50;
// 후면판 (전체 높이)
addPlate(W1, b.height, pt, 0, b.height / 2, -b.depth / 2 + pt / 2);
addPlate(W1, pt, flangeBack, 0, b.height - pt / 2, -b.depth / 2 + flangeBack / 2);
// 전면 상부판 (frontH)
addPlate(W1, frontH, pt, 0, b.height - frontH / 2, b.depth / 2 - pt / 2);
addPlate(W1, pt, flangeFront, 0, b.height - pt / 2, b.depth / 2 - flangeFront / 2);
addPlate(W1, pt, returnW, 0, junctionY - pt / 2, b.depth / 2 - pt - returnW / 2);
// 전면 점검구 (doorH, 탈착식)
if (doorH > 0) {
addPlate(W1, doorH, pt, 0, doorH / 2, b.depth / 2 - pt / 2, doorMat, doorEdgeMat);
addPlate(W1, pt, returnW, 0, junctionY + pt / 2, b.depth / 2 - pt - returnW / 2, doorMat, doorEdgeMat);
}
// 좌우측판
addPlate(pt, b.height, b.depth, -W1 / 2 + pt / 2, b.height / 2, 0);
addPlate(pt, b.height, b.depth, W1 / 2 - pt / 2, b.height / 2, 0);
// 바닥판 (레일 개구부 제외)
const frontLipD = 50, openW = railZSpan;
const backLipD = b.depth - frontLipD - openW;
if (frontLipD > 0) addPlate(W1, pt, frontLipD, 0, pt / 2, b.depth / 2 - frontLipD / 2);
if (backLipD > 0) addPlate(W1, pt, backLipD, 0, pt / 2, -b.depth / 2 + backLipD / 2);
}
// ── 상부 커버 (절곡 없는 평판, 전면판 립 안쪽 10mm에 올라감) ──
// 폭: W1 - 20 (양쪽 10mm 축소), 깊이: depth - 20 (양쪽 10mm 축소)
// 중앙 정렬 → 전면에서 10mm, 후면에서 10mm 들어간 위치
addPlate(W1 - 20, pt, b.depth - 20, 0, b.height + pt / 2, 0);
scene.add(meshes.case);
// === SHAFT ASSEMBLY (양쪽 브라켓 관통, 슬랫 폭과 동일) ===
const shaftY = H + b.height * 0.5; // 샤프트 중심 Y
const shaftMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.6, roughness: 0.3 });
const bracketMat = new THREE.MeshStandardMaterial({ color: 0x4b5563, metalness: 0.5, roughness: 0.4 });
meshes.shaft = new THREE.Group();
const motorDir = b.motorSide === 'right' ? 1 : -1;
const nonMotorSide = -motorDir;
// 브라켓 치수 (제품 유형별)
const bk = p.bk;
const bkThick = bk.thick; // 두께 (X방향, 철판)
const bkH = bk.nmH; // 비모터측 높이 (Y방향)
const bkD = bk.nmD; // 비모터측 깊이 (Z방향)
// 모터측 브라켓
const motorBkW = bk.mtD; // 깊이 (Z방향, 샤프트~모터 거리 수용)
const motorBkH = bk.mtH; // 높이 (Y방향)
const motorBkD = bk.thick; // 두께 (X방향, 철판)
const shaftFromInner = 90; // 브라켓 내면에서 샤프트 중심까지 거리 (도면 기준)
// 환봉/플랜지 치수
const stubPinR = 15; // 환봉 반지름 (ø30)
const stubPinTotal = 300; // 환봉 전체 길이
const stubPinVisible = 200; // 밖에서 보이는 길이 (플랜지 밖)
const stubPinInside = stubPinTotal - stubPinVisible; // 플랜지 안쪽 (100mm)
const shaftR = b.shaftDia / 2;
const shaftWallThick = 4; // 샤프트 관 두께 (4mm)
const flangeR = shaftR - shaftWallThick - 0.5; // 플랜지 외경 = 샤프트 내경 - 1mm 공차
const flangeThick = 10; // 플랜지 두께
const flangeMat = new THREE.MeshStandardMaterial({ color: 0x78716c, metalness: 0.5, roughness: 0.35 });
const stubPinMat = new THREE.MeshStandardMaterial({ color: 0x9ca3af, metalness: 0.7, roughness: 0.25 });
// 샤프트 관 길이: 양쪽 환봉 노출분만큼 축소
const mainShaftLen = W1 - stubPinVisible * 2;
// --- Non-motor side Bracket (180×180mm, 두께 18mm) — motor 그룹에 포함 (브라켓 한쌍) ---
const bkGeo = new THREE.BoxGeometry(bkThick, bkH, bkD);
const bkMesh = new THREE.Mesh(bkGeo, bracketMat);
const bkCenterX = nonMotorSide * (W1 / 2 - bkThick / 2);
bkMesh.position.set(bkCenterX, 0, 0);
// meshes.motor에 추가 (아래에서 motor 그룹 생성 후 추가)
// 비모터측 환봉 (브라켓에서 안쪽으로 200mm 돌출)
const stubPinGeo = new THREE.CylinderGeometry(stubPinR, stubPinR, stubPinVisible, 16);
stubPinGeo.rotateZ(Math.PI / 2);
const stubPin = new THREE.Mesh(stubPinGeo, stubPinMat);
const bkInnerFaceX = nonMotorSide * (W1 / 2 - bkThick);
stubPin.position.set(bkInnerFaceX - nonMotorSide * stubPinVisible / 2, 0, 0);
meshes.shaft.add(stubPin);
// 모터측: 3인치 연결관 + 복주머니 (브라켓 직접 체결)
const reducerR = 38; // 3인치 관 반지름 (ø76mm ≈ 3")
const bokjuR = reducerR + 8; // 복주머니 외경
const bokjuLen = 35; // 복주머니 길이
const bokjuMat = new THREE.MeshStandardMaterial({ color: 0x6b7280, metalness: 0.6, roughness: 0.3 });
const mtBkInnerFaceX = motorDir * (W1 / 2 - motorBkD);
const shaftEndX = motorDir * (mainShaftLen / 2); // 샤프트 관 끝
// 복주머니 (브라켓 내면에서 바로 안쪽, 브라켓과 직접 결합)
const bokjuGeo = new THREE.CylinderGeometry(bokjuR, bokjuR, bokjuLen, 24);
bokjuGeo.rotateZ(Math.PI / 2);
const bokju = new THREE.Mesh(bokjuGeo, bokjuMat);
bokju.position.set(mtBkInnerFaceX - motorDir * bokjuLen / 2, 0, 0);
meshes.shaft.add(bokju);
// 3인치 연결관 (복주머니 ~ 샤프트 관 끝, 플랜지 없이 직접 용접)
const reducerStartX = mtBkInnerFaceX - motorDir * bokjuLen;
const reducerEndX = shaftEndX;
const reducerLen = Math.abs(reducerStartX - reducerEndX);
const reducerGeo = new THREE.CylinderGeometry(reducerR, reducerR, reducerLen, 24);
reducerGeo.rotateZ(Math.PI / 2);
const reducer = new THREE.Mesh(reducerGeo, stubPinMat);
reducer.position.set((reducerStartX + reducerEndX) / 2, 0, 0);
meshes.shaft.add(reducer);
// --- Main Shaft (원형관) ---
const msGeo = new THREE.CylinderGeometry(shaftR, shaftR, mainShaftLen, 32);
msGeo.rotateZ(Math.PI / 2);
const msMesh = new THREE.Mesh(msGeo, shaftMat);
msMesh.position.set(0, 0, 0);
meshes.shaft.add(msMesh);
// 비모터측 플랜지만 (샤프트 내경에 삽입 용접)
const flangeGeo = new THREE.CylinderGeometry(flangeR, flangeR, flangeThick, 32);
flangeGeo.rotateZ(Math.PI / 2);
const flangeNM = new THREE.Mesh(flangeGeo, flangeMat);
flangeNM.position.set(nonMotorSide * (mainShaftLen / 2 + flangeThick / 2), 0, 0);
meshes.shaft.add(flangeNM);
meshes.shaft.position.set(0, shaftY, shaftCenterZ);
scene.add(meshes.shaft);
// === MOTOR (체인 구동: 모터는 샤프트와 수평(Z방향) 배치 — 슬랫 간섭 방지) ===
meshes.motor = new THREE.Group();
meshes.motor.add(bkMesh); // 비모터측 브라켓도 motor 그룹에 포함 (브라켓 한쌍)
const metalMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, metalness: 0.6, roughness: 0.3 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x374151, metalness: 0.5, roughness: 0.4 });
const chainMat = new THREE.MeshStandardMaterial({ color: 0xdc2626, metalness: 0.3, roughness: 0.5 });
// --- 1) 모터측 브라켓 (380×180mm, 벽면 부착 — YZ 평면 판) ---
// 380mm=Z방향(깊이, 샤프트~모터 수용), 180mm=Y방향(높이), 18mm=X방향(두께)
const motorBkThick = motorBkD; // 18mm
const motorBkCX = motorDir * (W1 / 2 - motorBkThick / 2);
const motorR = b.shaftDia * 0.45;
const shaftSprocketR = bk.sprocketR; // 철재:ø430(R215), 스크린:ø140(R70)
const motorSprocketR = bk.motorSpR; // 철재:ø80(R40), 스크린:ø60(R30)
const motorZ = bk.motorOffset > 0
? -bk.motorOffset // 철재: 도면 기준 160mm
: -(shaftSprocketR + motorSprocketR + 95); // 스크린: 자동 계산
// 브라켓: Z 중심은 샤프트(0)와 모터(motorZ) 사이
const motorBkGeo = new THREE.BoxGeometry(motorBkThick, motorBkH, motorBkW);
const motorBk = new THREE.Mesh(motorBkGeo, bracketMat);
motorBk.position.set(motorBkCX, 0, motorZ / 2);
meshes.motor.add(motorBk);
// --- 2) 스프로켓 톱니바퀴 (샤프트측 + 모터측) ---
const sprocketThick = 12;
const sprocketFaceX = motorDir * (W1 / 2 - motorBkThick);
const shaftSprocketX = sprocketFaceX - motorDir * sprocketThick / 2;
const sprocketMat = new THREE.MeshStandardMaterial({ color: 0x374151, metalness: 0.6, roughness: 0.35 });
// 샤프트 스프로켓 톱니바퀴
const shaftSpGroup = new THREE.Group();
const shaftSpBodyGeo = new THREE.CylinderGeometry(shaftSprocketR * 0.85, shaftSprocketR * 0.85, sprocketThick, 32);
shaftSpBodyGeo.rotateZ(Math.PI / 2);
shaftSpGroup.add(new THREE.Mesh(shaftSpBodyGeo, sprocketMat));
const shaftTeethCount = 18;
const toothH = 8, toothTW = 6;
for (let i = 0; i < shaftTeethCount; i++) {
const a = (i / shaftTeethCount) * Math.PI * 2;
const tGeo = new THREE.BoxGeometry(sprocketThick * 0.8, toothTW, toothH);
const t = new THREE.Mesh(tGeo, sprocketMat);
t.position.set(0, (shaftSprocketR - toothH / 2) * Math.sin(a), (shaftSprocketR - toothH / 2) * Math.cos(a));
t.rotation.x = a;
shaftSpGroup.add(t);
}
shaftSpGroup.position.set(shaftSprocketX, 0, 0);
meshes.motor.add(shaftSpGroup);
// --- 3) 모터 (샤프트와 수평방향 Z 오프셋 — 슬랫과 간섭 없음) ---
const motorBodyLen = motorR * 3;
// 모터 X: 브라켓 안쪽 (셔터박스 내부)
const motorCX = sprocketFaceX - motorDir * (motorBodyLen / 2 + 20);
// 모터 본체 (축=X, 샤프트와 평행, Y=0 수평, Z=motorZ)
const bodyGeo = new THREE.CylinderGeometry(motorR, motorR, motorBodyLen, 24);
bodyGeo.rotateZ(Math.PI / 2);
const body = new THREE.Mesh(bodyGeo, metalMat);
body.position.set(motorCX, 0, motorZ);
meshes.motor.add(body);
// 모터 후면 마감판
const ecGeo = new THREE.CylinderGeometry(motorR + 3, motorR + 3, 4, 24);
ecGeo.rotateZ(Math.PI / 2);
const ec = new THREE.Mesh(ecGeo, darkMat);
ec.position.set(motorCX - motorDir * (motorBodyLen / 2 + 2), 0, motorZ);
meshes.motor.add(ec);
// 상단 냉각 리브
for (let i = 0; i < 3; i++) {
const ribGeo = new THREE.BoxGeometry(motorBodyLen * 0.6, 3, motorR * 0.12);
const rib = new THREE.Mesh(ribGeo, darkMat);
rib.position.set(motorCX, motorR + 1.5, motorZ + (i - 1) * motorR * 0.4);
meshes.motor.add(rib);
}
// 마운팅 플레이트
const mMountGeo = new THREE.BoxGeometry(motorBodyLen * 0.6, 5, motorR * 1.4);
const mMount = new THREE.Mesh(mMountGeo, darkMat);
mMount.position.set(motorCX, -motorR - 2.5, motorZ);
meshes.motor.add(mMount);
// 모터 출력 스프로켓 톱니바퀴 (샤프트 스프로켓과 같은 X면, Z=motorZ)
const mSpGroup = new THREE.Group();
const mSpBodyGeo = new THREE.CylinderGeometry(motorSprocketR * 0.8, motorSprocketR * 0.8, sprocketThick, 20);
mSpBodyGeo.rotateZ(Math.PI / 2);
mSpGroup.add(new THREE.Mesh(mSpBodyGeo, sprocketMat));
const motorTeethCount = 10;
const mToothH = 6, mToothTW = 5;
for (let i = 0; i < motorTeethCount; i++) {
const a = (i / motorTeethCount) * Math.PI * 2;
const tGeo = new THREE.BoxGeometry(sprocketThick * 0.8, mToothTW, mToothH);
const t = new THREE.Mesh(tGeo, sprocketMat);
t.position.set(0, (motorSprocketR - mToothH / 2) * Math.sin(a), (motorSprocketR - mToothH / 2) * Math.cos(a));
t.rotation.x = a;
mSpGroup.add(t);
}
mSpGroup.position.set(shaftSprocketX, 0, motorZ);
meshes.motor.add(mSpGroup);
// --- 4) 체인 (RS #40, YZ 평면 — 수평으로 두 스프로켓 연결) ---
// 샤프트 스프로켓: (Y=0, Z=0), 모터 스프로켓: (Y=0, Z=motorZ)
const R1 = shaftSprocketR, R2 = motorSprocketR;
const chainPts = [];
const seg = 20;
// 큰 스프로켓 호: 상단→하단 (모터 반대쪽 반원, +Z side)
for (let i = 0; i <= seg; i++) {
const a = Math.PI / 2 - (i / seg) * Math.PI; // π/2 → -π/2
chainPts.push(new THREE.Vector3(0, R1 * Math.sin(a), R1 * Math.cos(a)));
}
// 하단 직선: 큰 스프로켓 하단 → 작은 스프로켓 하단
chainPts.push(new THREE.Vector3(0, -R2, motorZ));
// 작은 스프로켓 호: 하단→상단 (샤프트 반대쪽, -Z side)
for (let i = 0; i <= seg; i++) {
const a = -Math.PI / 2 - (i / seg) * Math.PI; // -π/2 → -3π/2
chainPts.push(new THREE.Vector3(0, R2 * Math.sin(a), motorZ + R2 * Math.cos(a)));
}
// 상단 직선: 작은 상단 → 큰 상단 (닫기)
chainPts.push(chainPts[0].clone());
const chainGeo = new THREE.BufferGeometry().setFromPoints(chainPts);
const chainLine = new THREE.Line(chainGeo, new THREE.LineBasicMaterial({ color: 0xdc2626, linewidth: 2 }));
chainLine.position.x = shaftSprocketX;
meshes.motor.add(chainLine);
meshes.motor.position.set(0, shaftY, shaftCenterZ);
scene.add(meshes.motor);
// === GUIDE RAILS (ExtrudeGeometry) ===
const rw = g.width, rd = g.depth, rt = g.thickness;
const railHeight = H + 100; // 바닥(0)부터 셔터박스 하단 결합부 (+100mm)
const railExtrude = { depth: railHeight > 0 ? railHeight : H, bevelEnabled: false };
const railMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.5, roughness: 0.4 });
const railSusMat = new THREE.MeshStandardMaterial({ color: 0x9ca3af, metalness: 0.7, roughness: 0.25 });
const railEdgeMat = new THREE.LineBasicMaterial({ color: 0x94a3b8, transparent: true, opacity: 0.4 });
function createRailGroup() {
const grp = new THREE.Group();
const isScreen = S.productType === 'screen';
if (isScreen) {
// ====== 스크린형 가이드레일 (실제 조립 구조) ======
// 단면 좌표계: X=폭(0→70), Y=깊이(0=개구부/실내측, +=벽쪽)
const fl = g.flange; // 30mm 플랜지 (슬롯=outerW-2*fl=10mm)
const lp = g.lip; // 10mm (립)
const sw = g.sideWall; // 80mm (사이드월)
const bw = g.backWall; // 67mm (백월)
// --- ② 본체 (EGI 1.55T): 슬롯10-플랜지30-사이드월80-백월t-사이드월80-플랜지30-슬롯 ---
// C채널: 사이드월 80mm, 플랜지 30mm, 슬롯 개구부 10mm (=width-2*fl)
const bodyShape = new THREE.Shape();
// 외곽 (시계방향)
bodyShape.moveTo(0, 0); // 하단-전면 모서리
bodyShape.lineTo(0, sw + rt); // 하단 사이드월 → 백월
bodyShape.lineTo(rw, sw + rt); // 백월 상단
bodyShape.lineTo(rw, 0); // 상단 사이드월 → 전면
bodyShape.lineTo(rw - fl, 0); // 상단 플랜지 시작
bodyShape.lineTo(rw - fl, -lp); // 상단 립 끝 (개구부쪽으로 10mm)
bodyShape.lineTo(fl, -lp); // 하단 립 끝 (슬롯 개구부)
bodyShape.lineTo(fl, 0); // 하단 플랜지 시작
bodyShape.lineTo(0, 0); // 닫기
// 내부 채널 (hole) — 반시계방향
const hole = new THREE.Path();
hole.moveTo(rt, rt); // 하단 내면
hole.lineTo(rt, sw); // 백월 내면
hole.lineTo(rw - rt, sw); // 백월 내면 상단
hole.lineTo(rw - rt, rt); // 상단 내면
hole.lineTo(rw - fl - rt, rt); // 상단 플랜지 내면
hole.lineTo(rw - fl - rt, -lp + rt); // 상단 립 내면 (개구부쪽)
hole.lineTo(fl + rt, -lp + rt); // 하단 립 내면 (개구부쪽)
hole.lineTo(fl + rt, rt); // 하단 플랜지 내면
hole.lineTo(rt, rt); // 닫기
bodyShape.holes.push(hole);
const bodyGeo = new THREE.ExtrudeGeometry(bodyShape, railExtrude);
const bodyMesh = new THREE.Mesh(bodyGeo, railMat);
grp.add(bodyMesh);
// 본체 엣지라인
const bodyEdge = new THREE.LineSegments(new THREE.EdgesGeometry(bodyGeo), railEdgeMat);
grp.add(bodyEdge);
// --- ③ 벽연형-C (EGI 1.55T): 30-45-30 ---
// C브라켓: 백월 뒤에 배치, 본체를 벽에 고정
const wcLip = 30, wcBody = 45;
const wcY = sw + rt; // 본체 백월 뒤
const wcCenterX = rw / 2;
const wcShape = new THREE.Shape();
// 몸체(벽쪽) → 개구부(②쪽): SVG 도면과 일치하도록 벽쪽 body, 채널쪽 opening
wcShape.moveTo(wcCenterX - wcBody / 2, wcY + wcLip);
wcShape.lineTo(wcCenterX - wcBody / 2, wcY);
wcShape.lineTo(wcCenterX - wcBody / 2 + rt, wcY);
wcShape.lineTo(wcCenterX - wcBody / 2 + rt, wcY + wcLip - rt);
wcShape.lineTo(wcCenterX + wcBody / 2 - rt, wcY + wcLip - rt);
wcShape.lineTo(wcCenterX + wcBody / 2 - rt, wcY);
wcShape.lineTo(wcCenterX + wcBody / 2, wcY);
wcShape.lineTo(wcCenterX + wcBody / 2, wcY + wcLip);
wcShape.lineTo(wcCenterX - wcBody / 2, wcY + wcLip);
const wcGeo = new THREE.ExtrudeGeometry(wcShape, railExtrude);
grp.add(new THREE.Mesh(wcGeo, railMat));
// --- ④ 벽연형-D (EGI 1.55T): 11-23-40-23-11 ---
// ③ 안쪽에 배치되는 보강 브라켓
const wdSeg = [11, 23, 40, 23, 11]; // 절곡 치수
const wdY = wcY + rt; // ③ 안쪽
const wdShape = new THREE.Shape();
// 립이 안쪽(중앙)으로 향하도록 수정: SVG 도면과 일치
wdShape.moveTo(wcCenterX - wdSeg[2] / 2, wdY);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2, wdY + wdSeg[1]);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 + wdSeg[0], wdY + wdSeg[1]);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 + wdSeg[0], wdY + wdSeg[1] - rt);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 + rt, wdY + wdSeg[1] - rt);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2 + rt, wdY + rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 - rt, wdY + rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 - rt, wdY + wdSeg[3] - rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 - wdSeg[4], wdY + wdSeg[3] - rt);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2 - wdSeg[4], wdY + wdSeg[3]);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2, wdY + wdSeg[3]);
wdShape.lineTo(wcCenterX + wdSeg[2] / 2, wdY);
wdShape.lineTo(wcCenterX - wdSeg[2] / 2, wdY);
const wdGeo = new THREE.ExtrudeGeometry(wdShape, railExtrude);
grp.add(new THREE.Mesh(wdGeo, railMat));
// --- ② 플랜지 끝 10mm 절곡 (채널 안쪽으로, 각 플랜지 내측 끝) ---
[[fl - rt, fl], [rw - fl, rw - fl + rt]].forEach(([x1, x2]) => {
const feLip = new THREE.Shape();
feLip.moveTo(x1, 0);
feLip.lineTo(x1, lp);
feLip.lineTo(x2, lp);
feLip.lineTo(x2, 0);
feLip.lineTo(x1, 0);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(feLip, railExtrude), railMat));
});
// --- ① 마감재 SUS 1.2T × 2장 (절곡: 10-11-110-30-15-15-15, J-hook 상하 대칭) ---
const tT = g.trimThick || 1.2;
const m1d = 30, m1e = 15, m1f = 15, m1g = 15, m1a = 10, m1b = 11;
// 하단 트림 (X=0 외면) — cover + J-hook
const bt = new THREE.Shape();
bt.moveTo(-tT, sw + rt + tT);
bt.lineTo(-tT, -lp);
bt.lineTo(m1d, -lp);
bt.lineTo(m1d, -lp + m1e);
bt.lineTo(m1d - m1f, -lp + m1e);
bt.lineTo(m1d - m1f, -lp + m1e + m1g);
bt.lineTo(m1d - m1f + tT, -lp + m1e + m1g);
bt.lineTo(m1d - m1f + tT, -lp + m1e - tT);
bt.lineTo(m1d - tT, -lp + m1e - tT);
bt.lineTo(m1d - tT, -lp + tT);
bt.lineTo(0, -lp + tT);
bt.lineTo(0, sw + rt + tT);
bt.lineTo(-tT, sw + rt + tT);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(bt, railExtrude), railSusMat));
// 하단 벽쪽 ㄴ자 (측면탭 11mm + 코킹립 10mm)
const btW = new THREE.Shape();
btW.moveTo(0, sw + rt);
btW.lineTo(m1b + tT, sw + rt);
btW.lineTo(m1b + tT, sw + rt + m1a);
btW.lineTo(m1b, sw + rt + m1a);
btW.lineTo(m1b, sw + rt + tT);
btW.lineTo(0, sw + rt + tT);
btW.lineTo(0, sw + rt);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(btW, railExtrude), railSusMat));
// 상단 트림 (X=rw 외면) — cover + J-hook (대칭)
const tt = new THREE.Shape();
tt.moveTo(rw + tT, sw + rt + tT);
tt.lineTo(rw + tT, -lp);
tt.lineTo(rw - m1d, -lp);
tt.lineTo(rw - m1d, -lp + m1e);
tt.lineTo(rw - m1d + m1f, -lp + m1e);
tt.lineTo(rw - m1d + m1f, -lp + m1e + m1g);
tt.lineTo(rw - m1d + m1f - tT, -lp + m1e + m1g);
tt.lineTo(rw - m1d + m1f - tT, -lp + m1e - tT);
tt.lineTo(rw - m1d + tT, -lp + m1e - tT);
tt.lineTo(rw - m1d + tT, -lp + tT);
tt.lineTo(rw, -lp + tT);
tt.lineTo(rw, sw + rt + tT);
tt.lineTo(rw + tT, sw + rt + tT);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(tt, railExtrude), railSusMat));
// 상단 벽쪽 ㄴ자 (대칭)
const ttW = new THREE.Shape();
ttW.moveTo(rw, sw + rt);
ttW.lineTo(rw - m1b - tT, sw + rt);
ttW.lineTo(rw - m1b - tT, sw + rt + m1a);
ttW.lineTo(rw - m1b, sw + rt + m1a);
ttW.lineTo(rw - m1b, sw + rt + tT);
ttW.lineTo(rw, sw + rt + tT);
ttW.lineTo(rw, sw + rt);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(ttW, railExtrude), railSusMat));
} else {
// ====== 철재스라트 가이드레일 (상세 프로파일, 2D 평면도 기준) ======
// 3D 좌표: X=폭(0→rw=75), Y=깊이(0=개구부/실내→rd=벽)
// 2D 평면도 → 3D 매핑: plan_y→X, plan_x→Y(반전)
const bXO = (rw - 72) / 2; // 본체 X 오프셋 (폭 중앙 정렬)
const bYO = rd - 90 - 15; // 본체 Y 오프셋 (벽쪽 15mm 여유)
const tT = g.trimThick || 1.2; // ① SUS 두께
// 본체 세그먼트 생성 헬퍼 (평면도 좌표 → 3D Shape)
function bSeg(px, py, pw, ph) {
const s = new THREE.Shape();
const x1 = bXO + py, x2 = bXO + py + ph;
const y1 = bYO + 90 - px - pw, y2 = bYO + 90 - px;
s.moveTo(x1,y1); s.lineTo(x2,y1); s.lineTo(x2,y2); s.lineTo(x1,y2); s.lineTo(x1,y1);
return s;
}
// ── ② 본체 EGI 1.55T (15세그먼트 절곡) ──
const bodySegs = [
[0, 59.25, 10, rt], // Seg1/15: 립 10mm
[0, 0, rt, 60], // Seg2: 좌측벽 상부 60mm
[0, 0, 90, rt], // Seg3: 상단 플랜지 90mm
[90-rt, 0, rt, 21], // Seg4: 상부 립 21mm
[12, 21, 78, rt], // Seg5: 상부 선반 78mm
[12, 21, rt, 30], // Seg6: 내부 벽 30mm
[12, 51, 43, rt], // Seg7: 하부 선반 43mm
[55-rt, 51, rt, 15], // Seg8: 스텝↓ 15mm
[55, 66-rt, 20, rt], // Seg9: 스텝→ 20mm
[75, 51, rt, 15], // Seg10: 스텝↑ 15mm
[75, 51, 15, rt], // Seg11: 스텝→ 15mm
[90-rt, 51, rt, 21], // Seg12: 하부 립 21mm
[0, 72-rt, 90, rt], // Seg13: 하단 플랜지 90mm
[0, 60, rt, 12], // Seg14: 좌측벽 하부 12mm
];
bodySegs.forEach(([px,py,pw,ph]) => {
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(bSeg(px,py,pw,ph), railExtrude), railMat));
});
// ── ④ 벽연형 EGI 1.55T (ㄷ자 30-45-30) ──
const w4Y = bYO + 90 + 3; // 본체 뒤 3mm 간격
const w4X = (rw - 45) / 2; // 폭 중앙 정렬
const w4S = new THREE.Shape();
w4S.moveTo(w4X, w4Y);
w4S.lineTo(w4X+45, w4Y); w4S.lineTo(w4X+45, w4Y+30);
w4S.lineTo(w4X+45-rt, w4Y+30); w4S.lineTo(w4X+45-rt, w4Y+rt);
w4S.lineTo(w4X+rt, w4Y+rt); w4S.lineTo(w4X+rt, w4Y+30);
w4S.lineTo(w4X, w4Y+30); w4S.lineTo(w4X, w4Y);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(w4S, railExtrude), railMat));
// ── ① 마감재 SUS 1.2T × 2장 (상/하 대칭) ──
// 상단: body(120)→tab(13↓)→lip(10←) + 우측: 25↓→15←
const m1Lip=10, m1Tab=13, m1Body=120, m1E25=25, m1E15=15;
const trimYFront = bYO + 90 - 90; // 본체 개구부쪽 = bYO
const trimYBack = trimYFront + m1Body; // 120mm 수평부
// 상단 ① (X=bXO-tT 위치)
const t1Top = new THREE.Shape();
const tx = bXO - tT;
// 수평부 120mm
t1Top.moveTo(tx, trimYFront); t1Top.lineTo(tx+tT, trimYFront);
t1Top.lineTo(tx+tT, trimYBack); t1Top.lineTo(tx, trimYBack); t1Top.lineTo(tx, trimYFront);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t1Top, railExtrude), railSusMat));
// 좌측탭 13mm + 립 10mm
const t1Tab = new THREE.Shape();
t1Tab.moveTo(tx, trimYBack); t1Tab.lineTo(tx+m1Tab, trimYBack);
t1Tab.lineTo(tx+m1Tab, trimYBack+m1Lip); t1Tab.lineTo(tx+m1Tab-tT, trimYBack+m1Lip);
t1Tab.lineTo(tx+m1Tab-tT, trimYBack+tT); t1Tab.lineTo(tx, trimYBack+tT); t1Tab.lineTo(tx, trimYBack);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t1Tab, railExtrude), railSusMat));
// 우측 25mm 수직 + 15mm 리턴
const t1R = new THREE.Shape();
t1R.moveTo(tx, trimYFront-m1E25); t1R.lineTo(tx+tT, trimYFront-m1E25);
t1R.lineTo(tx+tT, trimYFront); t1R.lineTo(tx, trimYFront); t1R.lineTo(tx, trimYFront-m1E25);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t1R, railExtrude), railSusMat));
const t1Ret = new THREE.Shape();
t1Ret.moveTo(tx, trimYFront-m1E25); t1Ret.lineTo(tx+m1E15, trimYFront-m1E25);
t1Ret.lineTo(tx+m1E15, trimYFront-m1E25+tT); t1Ret.lineTo(tx, trimYFront-m1E25+tT); t1Ret.lineTo(tx, trimYFront-m1E25);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t1Ret, railExtrude), railSusMat));
// 하단 ① (X=bXO+72 위치, 대칭)
const bx2 = bXO + 72;
const t2Top = new THREE.Shape();
t2Top.moveTo(bx2, trimYFront); t2Top.lineTo(bx2+tT, trimYFront);
t2Top.lineTo(bx2+tT, trimYBack); t2Top.lineTo(bx2, trimYBack); t2Top.lineTo(bx2, trimYFront);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t2Top, railExtrude), railSusMat));
const t2Tab = new THREE.Shape();
t2Tab.moveTo(bx2, trimYBack); t2Tab.lineTo(bx2, trimYBack+tT);
t2Tab.lineTo(bx2+m1Tab-tT, trimYBack+tT); t2Tab.lineTo(bx2+m1Tab-tT, trimYBack+m1Lip);
t2Tab.lineTo(bx2+m1Tab, trimYBack+m1Lip); t2Tab.lineTo(bx2+m1Tab, trimYBack); t2Tab.lineTo(bx2, trimYBack);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t2Tab, railExtrude), railSusMat));
const t2R = new THREE.Shape();
t2R.moveTo(bx2, trimYFront-m1E25); t2R.lineTo(bx2+tT, trimYFront-m1E25);
t2R.lineTo(bx2+tT, trimYFront); t2R.lineTo(bx2, trimYFront); t2R.lineTo(bx2, trimYFront-m1E25);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t2R, railExtrude), railSusMat));
const t2Ret = new THREE.Shape();
t2Ret.moveTo(bx2, trimYFront-m1E25); t2Ret.lineTo(bx2+m1E15, trimYFront-m1E25);
t2Ret.lineTo(bx2+m1E15, trimYFront-m1E25+tT); t2Ret.lineTo(bx2, trimYFront-m1E25+tT); t2Ret.lineTo(bx2, trimYFront-m1E25);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(t2Ret, railExtrude), railSusMat));
}
// 회전: XY평면 → 수직 (Z extrude → Y height)
grp.rotation.x = -Math.PI / 2;
return grp;
}
meshes.rails = new THREE.Group();
const isScreenType = S.productType === 'screen';
if (isScreenType) {
// ====== 스크린형: 채널 개구부(립)가 중심(슬랫)을 향하도록 Y축 회전 ======
// createRailGroup() 단면: X=폭(0→70), Y=깊이(lip=-10 ~ backWall=sw+rt)
// Rx(-PI/2) 후: X=폭, Y=높이(extrude), Z=-Y(lip→z=+10, backWall→z=-81.55)
//
// 좌표계 정리 (Rx(-PI/2) 후):
// z>0 = 립/개구부(실내쪽), z<0 = 백월(벽쪽)
//
// Left: Ry(+PI/2) → x'=z → 립(z=10)→x'=+10(중심), 백월(z=-81.55)→x'=-81.55(벽)
// Right: Ry(-PI/2) → x'=-z → 립(z=10)→x'=-10(중심), 백월(z=-81.55)→x'=+81.55(벽)
// 레일 백월을 벽면에서 20mm 슬랫 방향으로 이격
const railDepth = g.sideWall + rt; // 81.55mm
const railOffset = 20; // 벽면에서 슬랫 방향 이격 거리
const railPosX = W1 / 2 - railDepth - railOffset; // wrapper X 위치
// Left rail — Ry(+PI/2): 립→+X(중심), 백월→-X(벽=-W1/2)
// Z방향: z'=-x(원본), 폭 0→70이 z' 0→-70으로 매핑
// wrapper Z=+rw/2 → 레일 Z범위 -rw/2 ~ +rw/2 (벽면 중심에 걸침)
const railGroupL = createRailGroup();
const wrapperL = new THREE.Group();
wrapperL.add(railGroupL);
wrapperL.rotation.y = Math.PI / 2;
wrapperL.position.set(-railPosX, 0, rw / 2);
meshes.rails.add(wrapperL);
// Right rail — Ry(-PI/2): 립→-X(중심), 백월→+X(벽=+W1/2)
// Z방향: z'=x(원본), 폭 0→70이 z' 0→70으로 매핑
// wrapper Z=-rw/2 → 레일 Z범위 -rw/2 ~ +rw/2 (벽면 중심에 걸침)
const railGroupR = createRailGroup();
const wrapperR = new THREE.Group();
wrapperR.add(railGroupR);
wrapperR.rotation.y = -Math.PI / 2;
wrapperR.position.set(railPosX, 0, -rw / 2);
meshes.rails.add(wrapperR);
} else {
// ====== 철재형: 기존 C채널 방식 (슬랫이 Z방향 슬롯 통과) ======
const railGroupL = createRailGroup();
railGroupL.position.set(-hw - rw / 2, 0, rd / 2);
meshes.rails.add(railGroupL);
const railGroupR = createRailGroup();
railGroupR.scale.x = -1;
railGroupR.position.set(hw + rw / 2, 0, rd / 2);
meshes.rails.add(railGroupR);
}
scene.add(meshes.rails);
// === SLAT CURTAIN (샤프트 상단에서 바닥까지 이어지는 스크린) ===
const shutterH = H * (S.td.shutterPos / 100);
const slatTop = shaftY; // 샤프트 중심에서 시작
const slatBottom = H - shutterH; // 바닥 (100%일 때 Y=0)
const slatHeight = slatTop - slatBottom; // 샤프트~바닥 전체 높이
const slatGeo = new THREE.PlaneGeometry(W - 20, slatHeight > 0 ? slatHeight : 1);
const slatColor = S.productType === 'steel' ? 0x8b9aab : 0xc084fc;
const slatMat = new THREE.MeshStandardMaterial({
color: slatColor,
side: THREE.DoubleSide,
transparent: true,
opacity: S.productType === 'screen' ? 0.6 : 0.85,
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, slatTop - slatHeight / 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;
{
const wrapThick = S.productType === 'steel' ? 10 : 1;
const shaftR = b.shaftDia / 2;
// 감아올린 양에 비례 (100% 내림 시 최소 3mm 고정부만 표시)
const rollThick = rolledH > 0
? Math.max(Math.sqrt(rolledH * wrapThick / Math.PI), 5)
: 3;
const rollOuterR = shaftR + rollThick;
const rollLen = W - 20; // 슬랫 커튼과 동일 너비 (같은 스크린)
// 하나의 Mesh로 표현 (접시 모양 방지)
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 48, 1, false);
rollGeo.rotateZ(Math.PI / 2);
const rollMat = new THREE.MeshStandardMaterial({
color: slatColor, // 슬랫과 동일 색상
metalness: S.productType === 'steel' ? 0.3 : 0,
roughness: S.productType === 'steel' ? 0.4 : 0.8,
opacity: 0.9,
transparent: true,
});
meshes.slatRoll = new THREE.Mesh(rollGeo, rollMat);
meshes.slatRoll.position.set(0, shaftY, shaftCenterZ);
scene.add(meshes.slatRoll);
// 표면 나선 라인 (감긴 슬랫 질감)
if (rollThick > 5) {
const spiralMat = new THREE.LineBasicMaterial({ color: S.productType === 'steel' ? 0x6b7a8b : 0x7c4dff, opacity: 0.5, transparent: true });
for (let s = 0; s < 4; s++) {
const pts = [];
const turns = Math.max(3, Math.min(rollLen / 60, 25));
const startA = (s / 4) * Math.PI * 2;
for (let i = 0; i <= turns * 32; i++) {
const t = i / (turns * 32);
const angle = startA + t * turns * Math.PI * 2;
const x = -rollLen / 2 + t * rollLen;
pts.push(new THREE.Vector3(x, Math.sin(angle) * (rollOuterR + 0.5), Math.cos(angle) * (rollOuterR + 0.5)));
}
const geo = new THREE.BufferGeometry().setFromPoints(pts);
meshes.slatRoll.add(new THREE.Line(geo, spiralMat));
}
}
}
// === BOTTOM BAR (하장바 어셈블리: 하단마감재 ㄷ채널 + L바 + 평철) ===
meshes.bottomBar = new THREE.Group();
{
const barW = W - 20;
const barH = 40, barD = 60;
const halfH = barH / 2, halfD = barD / 2;
const bt = 3, lipW = 22;
const barExtrude = { depth: barW, bevelEnabled: false };
const barEdgeMat = new THREE.LineBasicMaterial({ color: 0xb45309, transparent: true, opacity: 0.5 });
// 회전 래퍼 (XY 단면 → Z extrude → Y축 회전으로 X방향 정렬)
const barInner = new THREE.Group();
barInner.rotation.y = Math.PI / 2;
barInner.position.set(-barW / 2, 0, 0);
// --- 1) 하단마감재 (ㄷ채널, SUS 1.2T) ---
const cs = new THREE.Shape();
cs.moveTo(-halfD, -halfH);
cs.lineTo(halfD, -halfH);
cs.lineTo(halfD, halfH);
cs.lineTo(halfD - lipW, halfH);
cs.lineTo(halfD - lipW, halfH - bt);
cs.lineTo(halfD - bt, halfH - bt);
cs.lineTo(halfD - bt, -halfH + bt);
cs.lineTo(-halfD + bt, -halfH + bt);
cs.lineTo(-halfD + bt, halfH - bt);
cs.lineTo(-halfD + lipW, halfH - bt);
cs.lineTo(-halfD + lipW, halfH);
cs.lineTo(-halfD, halfH);
cs.closePath();
const csGeo = new THREE.ExtrudeGeometry(cs, barExtrude);
barInner.add(new THREE.Mesh(csGeo, new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.5, roughness: 0.3 })));
barInner.add(new THREE.LineSegments(new THREE.EdgesGeometry(csGeo), barEdgeMat));
// --- 2) L바 (180° 회전: 수평부 아래, 수직부 위로, EGI 1.55T) ---
// L바 2개가 수직부(긴 면)를 서로 마주보며 스크린+평철을 직결피스로 클램핑
// 수평부는 바깥으로 뻗어 하장바 내부에 위치
// 하장바를 아래에서 끼우면 날개(lipW=22)가 수평부 위를 덮어 고리처럼 잡아줌
const lbT = 2;
const cg = 3; // 중심~수직부 내면 간격 (스크린+평철)
const armY = 5; // 수평부 바닥 Y (채널 내부)
const vtop = 25; // 수직부 상단 Y (하장바 위로 돌출)
const lMat = new THREE.MeshStandardMaterial({ color: 0xd97706, metalness: 0.3, roughness: 0.5 });
// 좌측 L바: 수평부→왼쪽(바깥), 수직부→위로(중심쪽)
const llS = new THREE.Shape();
llS.moveTo(cg, armY); // 수평부 우하단 (중심쪽)
llS.lineTo(halfD - bt - 1, armY); // 수평부 좌하단 (벽 근처)
llS.lineTo(halfD - bt - 1, armY + lbT); // 수평부 좌상단
llS.lineTo(cg + lbT, armY + lbT); // 수직부-수평부 연결점
llS.lineTo(cg + lbT, vtop); // 수직부 좌면 상단
llS.lineTo(cg, vtop); // 수직부 우면 상단 (긴 면)
llS.closePath();
barInner.add(new THREE.Mesh(new THREE.ExtrudeGeometry(llS, barExtrude), lMat));
// 우측 L바 (반전): 수평부→오른쪽(바깥), 수직부→위로(중심쪽)
const rlS = new THREE.Shape();
rlS.moveTo(-cg, armY);
rlS.lineTo(-(halfD - bt - 1), armY);
rlS.lineTo(-(halfD - bt - 1), armY + lbT);
rlS.lineTo(-cg - lbT, armY + lbT);
rlS.lineTo(-cg - lbT, vtop);
rlS.lineTo(-cg, vtop);
rlS.closePath();
barInner.add(new THREE.Mesh(new THREE.ExtrudeGeometry(rlS, barExtrude), lMat));
// --- 3) 평철 (L바 수직부 사이, EGI 1.15T) ---
// 스크린 시접 안의 보강 평철, 직결피스로 L바+평철+L바 체결
const fpT = 2, fpH = 18;
const fpS = new THREE.Shape();
fpS.moveTo(-fpT / 2, armY); fpS.lineTo(fpT / 2, armY);
fpS.lineTo(fpT / 2, armY + fpH); fpS.lineTo(-fpT / 2, armY + fpH);
fpS.closePath();
barInner.add(new THREE.Mesh(new THREE.ExtrudeGeometry(fpS, barExtrude),
new THREE.MeshStandardMaterial({ color: 0x92400e, metalness: 0.3, roughness: 0.5 })));
meshes.bottomBar.add(barInner);
}
meshes.bottomBar.position.set(0, H - shutterH - 20, 0);
scene.add(meshes.bottomBar);
// === WALL (좌/우 기둥 + 상부 인방) ===
// 벽체: 기둥+인방을 하나의 U자 형상으로 생성 (이음새 없음)
// 벽 기둥 내면을 가이드레일 브라켓 끝에 정렬
const wl = S.wall;
const colH = H + b.height;
const wallColor = new THREE.Color(wl.color);
const wallMat = new THREE.MeshStandardMaterial({ color: wallColor, transparent: true, opacity: wl.opacity / 100, side: THREE.DoubleSide });
meshes.wall = new THREE.Group();
// 벽 기둥 내면 = 브라켓 벽면(W1/2)과 동일 선상
// 스크린: 레일 백월 = 브라켓 벽면 = W1/2 (③④ 벽연형은 벽 두께 안에 매립)
// 철재: 레일 절반 폭 기준
const whw = isScreenType
? (W1 / 2) // 브라켓/레일 백월 정렬
: (hw + rw / 2); // 철재형: 레일 절반 폭
const wg = wl.wing;
const topH = wl.topMargin;
const totalH = colH + topH;
if (wg > 0 && topH > 0) {
// U자 형상 (기둥+인방 일체형)
const shape = new THREE.Shape();
shape.moveTo(-whw - wg, 0);
shape.lineTo(-whw - wg, totalH);
shape.lineTo(whw + wg, totalH);
shape.lineTo(whw + wg, 0);
shape.lineTo(whw, 0);
shape.lineTo(whw, colH);
shape.lineTo(-whw, colH);
shape.lineTo(-whw, 0);
shape.lineTo(-whw - wg, 0);
const wallGeo = new THREE.ExtrudeGeometry(shape, { depth: wl.thick, bevelEnabled: false });
wallGeo.translate(0, 0, -wl.thick / 2);
meshes.wall.add(new THREE.Mesh(wallGeo, wallMat));
} else if (wg > 0) {
// 기둥만 (인방 없음)
const colGeo = new THREE.BoxGeometry(wg, colH, wl.thick);
const leftCol = new THREE.Mesh(colGeo, wallMat);
leftCol.position.set(-whw - wg / 2, colH / 2, 0);
meshes.wall.add(leftCol);
const rightCol = new THREE.Mesh(colGeo.clone(), wallMat);
rightCol.position.set(whw + wg / 2, colH / 2, 0);
meshes.wall.add(rightCol);
} else if (topH > 0) {
// 인방만 (기둥 없음)
const lintelGeo = new THREE.BoxGeometry(W1, topH, wl.thick);
const lintel = new THREE.Mesh(lintelGeo, wallMat);
lintel.position.set(0, colH + topH / 2, 0);
meshes.wall.add(lintel);
}
// 벽체 와이어프레임
meshes.wall.children.forEach(m => {
const edges = new THREE.EdgesGeometry(m.geometry);
const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x8d6e63, opacity: 0.4, transparent: true }));
m.add(line);
});
scene.add(meshes.wall);
// 재빌드 후 토글/단품 상태 동기화
if (fs3dIsolated) {
// 단품 보기 모드 유지
Object.entries(meshes).forEach(([k, obj]) => { if (obj) obj.visible = (k === fs3dIsolated); });
} else {
Object.keys(S.td.show).forEach(key => {
if (meshes[key]) meshes[key].visible = S.td.show[key];
});
}
// Camera: 최초 빌드 시에만 위치 설정, 이후 재빌드 시 현재 시점 유지
if (!fs3dCameraInit) {
controls.target.set(0, H / 2, 0);
camera.position.set(W * 1.2, H * 0.8, W * 1.5);
controls.update();
fs3dCameraInit = true;
}
// 빌드 후 부품 표시/숨김 상태 복원
Object.keys(S.td.show).forEach(k => {
if (meshes[k]) meshes[k].visible = S.td.show[k];
});
}
// 단품 보기 (Isolation)
function fs3dIsolate(key) {
fs3dIsolated = key;
Object.entries(meshes).forEach(([k, obj]) => {
if (obj) obj.visible = (k === key);
});
// 배지 표시
const badge = document.querySelector('.fs-iso-badge');
if (badge) {
const label = meshLabels[key] || key;
badge.innerHTML = `${label} 단품 보기 중 &nbsp;✕`;
badge.style.display = 'block';
}
// 카메라 위치 유지 (현재 뷰 그대로, 단품만 표시)
}
// 전체 보기 (Show All)
function fs3dShowAll() {
fs3dIsolated = null;
Object.entries(meshes).forEach(([k, obj]) => {
if (!obj) return;
obj.visible = S.td.show[k] !== undefined ? S.td.show[k] : true;
});
// 배지 숨기기
const badge = document.querySelector('.fs-iso-badge');
if (badge) badge.style.display = 'none';
}
// 뷰 프리셋 (정면/평면/우측면/좌측면/배면/투시)
function fs3dSetView(preset) {
if (!camera || !controls) return;
const t = controls.target.clone(); // 현재 타겟 유지
const dist = camera.position.distanceTo(t); // 현재 거리 유지
const d = Math.max(dist, 2000); // 최소 거리 보장
let pos;
switch (preset) {
case 'front': pos = new THREE.Vector3(t.x, t.y, t.z + d); break; // +Z: 정면 (전면에서 봄)
case 'back': pos = new THREE.Vector3(t.x, t.y, t.z - d); break; // -Z: 배면 (후면에서 봄)
case 'top': pos = new THREE.Vector3(t.x, t.y + d, t.z + 1); break; // +Y: 평면 (위에서 봄)
case 'right': pos = new THREE.Vector3(t.x + d, t.y, t.z); break; // +X: 우측면
case 'left': pos = new THREE.Vector3(t.x - d, t.y, t.z); break; // -X: 좌측면
case 'persp': pos = new THREE.Vector3(t.x + d * 0.7, t.y + d * 0.5, t.z + d * 0.7); break;
default: return;
}
// 부드러운 전환 (애니메이션)
const startPos = camera.position.clone();
const startTime = performance.now();
const duration = 400; // ms
function animateView(now) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const ease = 1 - Math.pow(1 - progress, 3); // easeOutCubic
camera.position.lerpVectors(startPos, pos, ease);
camera.lookAt(t);
controls.update();
if (progress < 1) requestAnimationFrame(animateView);
}
requestAnimationFrame(animateView);
}
// 3D Controls
window.fs3dShutterPos = function(v) {
undoSaveState();
S.td.shutterPos = Number(v);
$('shutterPosLabel').textContent = v + '%';
fs3dBuild();
// 빌드 후 숨겨진 부품 상태 복원
Object.keys(S.td.show).forEach(k => {
if (meshes[k]) meshes[k].visible = S.td.show[k];
});
};
window.fs3dOpacity = function(v) {
undoSaveState();
S.td.caseOpacity = Number(v) / 100;
$('opacityLabel').textContent = v + '%';
if (meshes.case) meshes.case.traverse(c => { if (c.isMesh) c.material.opacity = S.td.caseOpacity; });
};
window.fsToggle3d = function(el, key) {
undoSaveState();
el.classList.toggle('active');
S.td.show[key] = el.classList.contains('active');
if (meshes[key]) meshes[key].visible = S.td.show[key];
// 슬랫 토글 시 감긴 슬랫(slatRoll)도 연동
if (key === 'slats') {
S.td.show.slatRoll = S.td.show[key];
if (meshes.slatRoll) meshes.slatRoll.visible = S.td.show[key];
}
// 벽 토글 시 벽체 설정 패널 연동
if (key === 'wall') {
const ws = $('wallSettings');
if (ws) ws.classList.toggle('hidden', !S.td.show.wall);
}
};
window.fs3dLightColor = function(color) {
if (!scene) return;
const c = new THREE.Color(color);
scene.children.forEach(l => {
if (l.isDirectionalLight) l.color.copy(c);
});
};
window.fs3dBg = function(color) {
S.td.bgColor = color;
if (scene) scene.background = new THREE.Color(color);
};
window.fs3dWall = function(key, v) {
undoSaveState();
const num = Number(v);
S.wall[key] = num;
const labelId = 'wall' + key.charAt(0).toUpperCase() + key.slice(1) + 'Label';
const label = $(labelId);
if (label) label.textContent = key === 'opacity' ? num + '%' : num;
fs3dBuild();
};
window.fs3dWallColor = function(color) {
undoSaveState();
S.wall.color = color;
$('wallColorPicker').value = color;
fs3dBuild();
};
// ============================
// ZOOM / PAN (SVG tabs)
// ============================
const viewport = $('canvasViewport');
viewport.addEventListener('mousedown', e => {
if (S.tab === '3D') return;
S.view.dragging = true;
S.view.lastMouse = { x: e.clientX, y: e.clientY };
});
viewport.addEventListener('wheel', e => {
if (S.tab === '3D') return;
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
S.view.scale = Math.max(0.1, Math.min(S.view.scale + delta, 10));
fsRender();
}, { passive: false });
window.addEventListener('mousemove', e => {
if (!S.view.dragging || S.tab === '3D') return;
S.view.offset.x += e.clientX - S.view.lastMouse.x;
S.view.offset.y += e.clientY - S.view.lastMouse.y;
S.view.lastMouse = { x: e.clientX, y: e.clientY };
fsRender();
});
window.addEventListener('mouseup', () => { S.view.dragging = false; });
$('zoomInBtn').onclick = () => { if (S.tab === '3D') return; S.view.scale = Math.min(S.view.scale + 0.2, 10); fsRender(); };
$('zoomOutBtn').onclick = () => { if (S.tab === '3D') return; S.view.scale = Math.max(S.view.scale - 0.2, 0.1); fsRender(); };
$('zoomResetBtn').onclick = () => { if (S.tab === '3D') return; S.view.scale = 1; S.view.offset = {x:0,y:0}; fsRender(); };
// ============================
// EXPORT: PNG
// ============================
window.fsExportPng = function() {
if (S.tab === '3D') {
// 3D screenshot
if (!renderer) return;
renderer.render(scene, camera);
const link = document.createElement('a');
link.download = `fire-shutter-3d-${Date.now()}.png`;
link.href = renderer.domElement.toDataURL('image/png');
link.click();
return;
}
// SVG to PNG
const svgEl = $('svgContainer').querySelector('svg');
if (!svgEl) return;
const svgData = new XMLSerializer().serializeToString(svgEl);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
img.onload = function() {
canvas.width = img.width * 2;
canvas.height = img.height * 2;
ctx.fillStyle = '#050507';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const link = document.createElement('a');
link.download = `fire-shutter-${S.tab.toLowerCase()}-${Date.now()}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
URL.revokeObjectURL(url);
};
img.src = url;
};
// ============================
// EXPORT: DXF
// ============================
window.fsExportDxf = function() {
let entities = '';
if (S.tab === 'GuideRail') {
const g = S.gr;
const w = g.width, d = g.depth, t = g.thickness, lip = g.lip;
// C-channel outline as DXF LWPOLYLINE
const pts = [
[0,0],[w,0],[w,lip],[w-t,lip],[w-t,t],[t,t],[t,lip],[0,lip],[0,0],
[0,d-lip],[t,d-lip],[t,d-t],[w-t,d-t],[w-t,d-lip],[w,d-lip],[w,d],[0,d],[0,d-lip],
[0,lip],[t,lip],[t,d-lip],[0,d-lip],
[w,lip],[w-t,lip],[w-t,d-lip],[w,d-lip]
];
pts.forEach((p, i) => {
if (i > 0) {
const prev = pts[i-1];
entities += `0\nLINE\n8\nGUIDE_RAIL\n10\n${prev[0]}\n20\n${prev[1]}\n30\n0\n11\n${p[0]}\n21\n${p[1]}\n31\n0\n`;
}
});
} else if (S.tab === 'ShutterBox') {
const b = S.sb;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n0\n20\n0\n30\n0\n11\n${b.width}\n21\n0\n31\n0\n`;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n${b.width}\n20\n0\n30\n0\n11\n${b.width}\n21\n${b.height}\n31\n0\n`;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n${b.width}\n20\n${b.height}\n30\n0\n11\n0\n21\n${b.height}\n31\n0\n`;
entities += `0\nLINE\n8\nSHUTTER_BOX\n10\n0\n20\n${b.height}\n30\n0\n11\n0\n21\n0\n31\n0\n`;
// Shaft circle
entities += `0\nCIRCLE\n8\nSHAFT\n10\n${b.width/2}\n20\n${b.height*0.45}\n30\n0\n40\n${b.shaftDia/2}\n`;
}
const dxf = `0\nSECTION\n2\nENTITIES\n${entities}0\nENDSEC\n0\nEOF\n`;
const blob = new Blob([dxf], { type: 'application/dxf' });
const link = document.createElement('a');
link.download = `fire-shutter-${S.tab.toLowerCase()}-${Date.now()}.dxf`;
link.href = URL.createObjectURL(blob);
link.click();
};
// ============================
// EXPORT: JSON (Params)
// ============================
window.fsExportJson = function() {
const data = {
productType: S.productType,
openWidth: S.openWidth,
openHeight: S.openHeight,
guideRail: { ...S.gr },
shutterBox: { ...S.sb },
exportDate: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.download = `fire-shutter-params-${Date.now()}.json`;
link.href = URL.createObjectURL(blob);
link.click();
};
// ============================
// PRESETS (localStorage)
// ============================
function refreshPresetList() {
const sel = $('presetSelect');
sel.innerHTML = '<option value="">-- 선택 --</option>';
S.presets.forEach((p, i) => {
sel.innerHTML += `<option value="${i}">${p.name} (${p.productType === 'steel' ? '강판' : '스크린'} ${p.openWidth}×${p.openHeight})</option>`;
});
}
window.fsSavePreset = function() {
const name = $('presetName').value.trim();
if (!name) { alert('프리셋 이름을 입력하세요.'); return; }
S.presets.push({
name,
productType: S.productType,
openWidth: S.openWidth,
openHeight: S.openHeight,
gr: { ...S.gr },
sb: { ...S.sb },
});
localStorage.setItem('fs_presets', JSON.stringify(S.presets));
$('presetName').value = '';
refreshPresetList();
};
window.fsLoadPreset = function() {
const idx = Number($('presetSelect').value);
if (isNaN(idx) || !S.presets[idx]) return;
const p = S.presets[idx];
S.productType = p.productType;
S.openWidth = p.openWidth;
S.openHeight = p.openHeight;
Object.assign(S.gr, p.gr);
Object.assign(S.sb, p.sb);
// Sync inputs
$('productType').value = p.productType;
$('openWidth').value = p.openWidth;
$('openHeight').value = p.openHeight;
$('grWidth').value = S.gr.width; $('grDepth').value = S.gr.depth;
$('grThickness').value = S.gr.thickness; $('grLip').value = S.gr.lip;
$('grSealThick').value = S.gr.sealThick; $('grSealDepth').value = S.gr.sealDepth;
$('grSlatThick').value = S.gr.slatThick; $('grAnchorSpacing').value = S.gr.anchorSpacing;
$('sbWidth').value = S.sb.width; $('sbHeight').value = S.sb.height;
$('sbDepth').value = S.sb.depth; $('sbThickness').value = S.sb.thickness;
$('sbShaftDia').value = S.sb.shaftDia; $('sbBracketW').value = S.sb.bracketW;
$('sbMotorSide').value = S.sb.motorSide;
fsCalc();
fsRender();
};
window.fsDeletePreset = function() {
const idx = Number($('presetSelect').value);
if (isNaN(idx) || !S.presets[idx]) return;
if (!confirm(`"${S.presets[idx].name}" 프리셋을 삭제하시겠습니까?`)) return;
S.presets.splice(idx, 1);
localStorage.setItem('fs_presets', JSON.stringify(S.presets));
refreshPresetList();
};
// ============================
// INIT
// ============================
fsOnProductType(); // 스크린형 기본값 적용 + 모델 필터링
refreshPresetList();
fsSwitch(S.productType === 'steel' ? 'GuideRail' : '3D');
})();
</script>
@endpush
@endsection