- ② 본체: 15세그먼트 절곡 프로파일 (78 선반, 30 내부벽, 스텝 등) - ④ 벽연형: 30-45-30 ㄷ자 브라켓 - ① 마감재: SUS 상/하 대칭 (120 수평부, 13탭, 10립, 25+15 우측)
3513 lines
222 KiB
PHP
3513 lines
222 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', '방화셔터 도면생성')
|
||
|
||
@section('content')
|
||
<style>
|
||
body { font-family: 'Pretendard', sans-serif; }
|
||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||
.glass-panel { background: rgba(15, 23, 42, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); }
|
||
.neon-border { box-shadow: 0 0 15px rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); }
|
||
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||
.fs-wrap { margin: -24px; min-height: calc(100vh - 64px); background: #020617; overflow: hidden; }
|
||
.fs-input { width: 100%; background: rgba(2,6,23,0.8); border: 1px solid #334155; border-radius: 0.75rem; padding: 0.5rem 0.75rem; color: #f8fafc; font-size: 0.875rem; font-weight: 700; outline: none; transition: border-color 0.2s; }
|
||
.fs-input:focus { border-color: #3b82f6; }
|
||
.fs-label { display: block; color: #94a3b8; font-size: 0.75rem; font-weight: 700; margin-bottom: 0.25rem; }
|
||
.fs-section { background: rgba(15,23,42,0.5); border-radius: 1rem; padding: 1rem; border: 1px solid #1e293b; }
|
||
.fs-badge { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 0.75rem; font-size: 0.625rem; font-weight: 900; color: white; }
|
||
.fs-toggle { position: relative; width: 2rem; height: 1rem; background: #334155; border-radius: 9999px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
|
||
.fs-toggle.active { background: #3b82f6; }
|
||
.fs-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 0.75rem; height: 0.75rem; background: white; border-radius: 9999px; transition: transform 0.2s; }
|
||
.fs-toggle.active::after { transform: translateX(1rem); }
|
||
.fs-select { width: 100%; background: rgba(2,6,23,0.8); border: 1px solid #334155; border-radius: 0.75rem; padding: 0.5rem 0.75rem; color: #f8fafc; font-size: 0.875rem; font-weight: 700; outline: none; }
|
||
.fs-btn { padding: 0.5rem 1rem; border-radius: 0.75rem; font-size: 0.75rem; font-weight: 900; cursor: pointer; transition: all 0.2s; border: none; }
|
||
.fs-btn-primary { background: #3b82f6; color: white; }
|
||
.fs-btn-primary:hover { background: #2563eb; }
|
||
.fs-btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #334155; }
|
||
.fs-btn-ghost:hover { background: #1e293b; color: white; }
|
||
.fs-btn-ghost.active { background: #1e293b; color: #3b82f6; border-color: #3b82f6; }
|
||
.fs-calc-row { display: flex; justify-content: space-between; align-items: center; padding: 0.375rem 0; border-bottom: 1px solid rgba(51,65,85,0.3); }
|
||
.fs-calc-label { color: #64748b; font-size: 0.75rem; }
|
||
.fs-calc-value { color: #f8fafc; font-size: 0.875rem; font-weight: 900; }
|
||
.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>m²</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} 단품 보기 중 ✕`;
|
||
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
|