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

2345 lines
138 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

@extends('layouts.app')
@section('title', '방화셔터 도면생성')
@section('content')
<style>
body { font-family: 'Pretendard', sans-serif; }
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #475569; }
.glass-panel { background: rgba(15, 23, 42, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); }
.neon-border { box-shadow: 0 0 15px rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); }
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.fs-wrap { margin: -24px; min-height: calc(100vh - 64px); background: #020617; overflow: hidden; }
.fs-input { width: 100%; background: rgba(2,6,23,0.8); border: 1px solid #334155; border-radius: 0.75rem; padding: 0.5rem 0.75rem; color: #f8fafc; font-size: 0.875rem; font-weight: 700; outline: none; transition: border-color 0.2s; }
.fs-input:focus { border-color: #3b82f6; }
.fs-label { display: block; color: #94a3b8; font-size: 0.75rem; font-weight: 700; margin-bottom: 0.25rem; }
.fs-section { background: rgba(15,23,42,0.5); border-radius: 1rem; padding: 1rem; border: 1px solid #1e293b; }
.fs-badge { display: inline-flex; align-items: center; justify-content: center; width: 1.75rem; height: 1.75rem; border-radius: 0.75rem; font-size: 0.625rem; font-weight: 900; color: white; }
.fs-toggle { position: relative; width: 2rem; height: 1rem; background: #334155; border-radius: 9999px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
.fs-toggle.active { background: #3b82f6; }
.fs-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 0.75rem; height: 0.75rem; background: white; border-radius: 9999px; transition: transform 0.2s; }
.fs-toggle.active::after { transform: translateX(1rem); }
.fs-select { width: 100%; background: rgba(2,6,23,0.8); border: 1px solid #334155; border-radius: 0.75rem; padding: 0.5rem 0.75rem; color: #f8fafc; font-size: 0.875rem; font-weight: 700; outline: none; }
.fs-btn { padding: 0.5rem 1rem; border-radius: 0.75rem; font-size: 0.75rem; font-weight: 900; cursor: pointer; transition: all 0.2s; border: none; }
.fs-btn-primary { background: #3b82f6; color: white; }
.fs-btn-primary:hover { background: #2563eb; }
.fs-btn-ghost { background: transparent; color: #94a3b8; border: 1px solid #334155; }
.fs-btn-ghost:hover { background: #1e293b; color: white; }
.fs-btn-ghost.active { background: #1e293b; color: #3b82f6; border-color: #3b82f6; }
.fs-calc-row { display: flex; justify-content: space-between; align-items: center; padding: 0.375rem 0; border-bottom: 1px solid rgba(51,65,85,0.3); }
.fs-calc-label { color: #64748b; font-size: 0.75rem; }
.fs-calc-value { color: #f8fafc; font-size: 0.875rem; font-weight: 900; }
.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">철재슬라트</option>
<option value="screen" selected>스크린형</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">KFS01 - 기본</option>
<option value="KFS02">KFS02 - 대형</option>
</optgroup>
<optgroup label="스크린형">
<option value="KSS01" selected>KSS01 - 실리카</option>
<option value="KSS02">KSS02 - 와이어</option>
</optgroup>
</select>
</div>
<div style="flex:0 0 90px;">
<label class="fs-label">오픈폭</label>
<input type="number" id="openWidth" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="2000" onchange="fsCalc()">
</div>
<div style="flex:0 0 90px;">
<label class="fs-label">오픈H</label>
<input type="number" id="openHeight" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="3000" onchange="fsCalc()">
</div>
<div style="flex:0 0 60px;">
<label class="fs-label">수량</label>
<input type="number" id="quantity" class="fs-input" style="font-size:0.75rem;padding:0.375rem 0.5rem;" value="1" min="1" onchange="fsCalc()">
</div>
<div class="flex-1"></div>
<div class="flex items-center gap-3 text-[11px] text-slate-400 shrink-0">
<span>W1: <b id="calcW1" class="text-white">2110</b></span>
<span>H1: <b id="calcH1" class="text-white">3350</b></span>
<span>면적: <b id="calcArea" class="text-white">7.07</b></span>
<span>중량: <b id="calcWeight" class="text-white">176.7</b>kg</span>
<span>모터: <b id="calcMotor" class="text-blue-400">300K</b></span>
<span>레일: <b id="calcRailCombo" class="text-amber-400">3,150mm × 2</b></span>
</div>
<button onclick="var d=document.getElementById('settingsDetail');d.classList.toggle('hidden');this.querySelector('span').textContent=d.classList.contains('hidden')?'▼':'▲';" class="text-[11px] text-slate-500 hover:text-slate-300 font-bold px-2 py-1 rounded hover:bg-slate-800 transition-colors shrink-0">
<span></span>
</button>
</div>
<!-- 접기/펼치기 상세 영역 -->
<div id="settingsDetail" class="hidden mt-3 pt-3 border-t border-slate-700/50">
<div class="flex gap-6">
<div class="flex-1 space-y-2">
<span class="text-[10px] text-slate-500 font-bold">프리셋</span>
<div class="flex gap-2">
<select id="presetSelect" class="fs-select flex-1" style="font-size:0.75rem;"><option value="">-- 선택 --</option></select>
<button class="fs-btn fs-btn-primary" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsLoadPreset()">불러오기</button>
</div>
<div class="flex gap-2">
<input type="text" id="presetName" class="fs-input flex-1" style="font-size:0.75rem;" placeholder="프리셋 이름">
<button class="fs-btn fs-btn-primary" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsSavePreset()">저장</button>
<button class="fs-btn fs-btn-ghost" style="font-size:0.65rem;padding:0.375rem 0.5rem;" onclick="fsDeletePreset()">삭제</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========== BOTTOM: 좌측 컨트롤 + 우측 ========== -->
<div class="flex" style="gap:1.25rem; height:calc(100vh - 170px);">
<!-- LEFT PANEL -->
<div class="custom-scrollbar" style="width:28%; flex-shrink:0; overflow-y:auto; padding-right:0.5rem;">
<!-- Tab Buttons (1x3) -->
<div class="grid gap-1 mb-3 bg-slate-900/50 p-1.5 rounded-xl border border-slate-800" style="grid-template-columns:1fr 1fr 1fr;">
<button id="tabGuideRail" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('GuideRail')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>
가이드레일
</button>
<button id="tabShutterBox" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('ShutterBox')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="8" rx="2"/><path d="M6 12v4"/><path d="M18 12v4"/></svg>
셔터박스
</button>
<button id="tab3D" class="px-3 py-2 rounded-lg font-black text-xs transition-all flex items-center gap-1.5 justify-center text-slate-400 hover:text-white hover:bg-slate-800" onclick="fsSwitch('3D')">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
3D 렌더링
</button>
</div>
<!-- ========== GUIDE RAIL TAB CONTROLS ========== -->
<div id="ctrlGuideRail" class="hidden">
<section class="fs-section space-y-5">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="fs-badge bg-purple-600">GR</span>
가이드레일 파라미터
</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="fs-label">레일 전체 (mm)</label>
<input type="number" id="grWidth" class="fs-input" value="120" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">레일 깊이 (mm)</label>
<input type="number" id="grDepth" class="fs-input" value="70" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">강판 두께 (mm)</label>
<input type="number" id="grThickness" class="fs-input" value="1.55" step="0.01" onchange="fsRender()">
</div>
<div>
<label class="fs-label">(입구) 높이 (mm)</label>
<input type="number" id="grLip" class="fs-input" value="10" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 두께 (mm)</label>
<input type="number" id="grSealThick" class="fs-input" value="0.8" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">연기차단재 깊이 (mm)</label>
<input type="number" id="grSealDepth" class="fs-input" value="40" step="0.1" onchange="fsRender()">
</div>
<div>
<label class="fs-label">슬랫 두께 (mm)</label>
<input type="number" id="grSlatThick" class="fs-input" value="0.8" step="0.1" onchange="fsRender()">
</div>
<div>
<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>
<label class="fs-label">모터 위치</label>
<select id="sbMotorSide" class="fs-select" onchange="fsRender()">
<option value="right">우측</option>
<option value="left">좌측</option>
</select>
</div>
</section>
<section class="fs-section space-y-3 mt-4">
<h2 class="text-sm font-black text-slate-400"> 옵션</h2>
<div class="flex flex-wrap gap-2">
<button class="fs-btn fs-btn-ghost active" data-sbview="front" onclick="fsSbView('front')">정면도</button>
<button class="fs-btn fs-btn-ghost" data-sbview="side" onclick="fsSbView('side')">측면도</button>
</div>
<h2 class="text-sm font-black text-slate-400 mt-3">내부 부품 표시</h2>
<div class="space-y-2">
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">샤프트</span><div class="fs-toggle active" onclick="fsToggle(this,'showShaft')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">감긴 슬랫</span><div class="fs-toggle active" onclick="fsToggle(this,'showSlatRoll')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">모터/감속기</span><div class="fs-toggle active" onclick="fsToggle(this,'showMotor')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">브레이크</span><div class="fs-toggle active" onclick="fsToggle(this,'showBrake')"></div></div>
<div class="flex items-center justify-between"><span class="text-xs text-slate-400">밸런스 스프링</span><div class="fs-toggle active" onclick="fsToggle(this,'showSpring')"></div></div>
</div>
</section>
</div>
<!-- ========== 3D TAB CONTROLS ========== -->
<div id="ctrl3D" class="hidden">
<section class="fs-section" style="padding:0.75rem 1rem;">
<!-- 개폐율: -->
<div class="flex items-center gap-2 mb-1">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:24px;">개폐</label>
<input type="range" id="shutterPos" min="0" max="100" value="100" class="flex-1 accent-blue-500" style="height:3px;" oninput="fs3dShutterPos(this.value)">
<span id="shutterPosLabel" class="text-[10px] text-blue-400 font-black shrink-0" style="width:28px;text-align:right;">100%</span>
</div>
<!-- 투명도: -->
<div class="flex items-center gap-2 mb-2">
<label class="text-[10px] text-slate-500 font-bold shrink-0" style="width:24px;">투명</label>
<input type="range" id="caseOpacity" min="0" max="100" value="30" class="flex-1 accent-blue-500" style="height:3px;" oninput="fs3dOpacity(this.value)">
<span id="opacityLabel" class="text-[10px] text-blue-400 font-black shrink-0" style="width:28px;text-align:right;">30%</span>
</div>
<!-- 부품: 4 compact grid -->
<div class="grid gap-x-0.5 gap-y-0.5 mb-2" style="grid-template-columns:repeat(4,auto);">
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'case')"></div><span class="text-[10px] text-slate-500">박스</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'shaft')"></div><span class="text-[10px] text-slate-500">샤프트</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'motor')"></div><span class="text-[10px] text-slate-500">모터</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'rails')"></div><span class="text-[10px] text-slate-500">레일</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'slats')"></div><span class="text-[10px] text-slate-500">슬랫</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle active" onclick="fsToggle3d(this,'bottomBar')"></div><span class="text-[10px] text-slate-500">하장바</span></div>
<div class="flex items-center gap-0.5"><div class="fs-toggle" 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>
@push('scripts')
<script>
(function(){
'use strict';
const $ = id => document.getElementById(id);
// ============================
// STATE
// ============================
const S = {
tab: 'Settings',
productType: 'screen',
openWidth: 2000,
openHeight: 3000,
quantity: 1,
// Guide Rail
gr: { width:70, depth:120, thickness:1.55, lip:10, flange:26, 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:80, bracketW:10, motorSide:'right', viewMode:'front', showShaft:true, showSlatRoll:true, showMotor:true, showBrake:true, showSpring:true },
// 3D
td: { shutterPos:100, caseOpacity:0.3, lightPreset:'default', bgColor:'#ffffff', show:{ case:true, shaft:true, motor:true, rails:true, slats:true, bottomBar:true, wall: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:120,depth:75,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:26, sideWall:80, backWall:67, trimThick:1.2},
slatThick:0.8,
sb:{height:380, depth:500, frontH:240, bottomOpen:75, shaftDia:80},
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; // 현재 단품 보기 중인 키
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();
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];
// 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;
// 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') fsRender();
};
window.fsOnModelChange = function() { fsCalc(); };
// ============================
// 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;
$('sbMotorSide').value = S.sb.motorSide;
// 재계산 + 렌더링
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.motorSide = $('sbMotorSide').value;
}
// ============================
// SVG DISPLAY HELPER
// ============================
function displaySvg(svg) {
const c = $('svgContainer');
c.innerHTML = '';
const div = document.createElement('div');
div.style.cssText = 'width:100%;height:100%;display:flex;align-items:center;justify-content:center;';
div.style.transform = `scale(${S.view.scale}) translate(${S.view.offset.x / S.view.scale}px, ${S.view.offset.y / S.view.scale}px)`;
div.style.transformOrigin = 'center center';
div.innerHTML = svg;
c.appendChild(div);
$('scaleDisplay').textContent = `SCALE: ${Math.round(S.view.scale * 100)}%`;
}
// ============================
// RENDER DISPATCHER
// ============================
window.fsRender = function() {
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;
// ====== 스크린형: 실제 조립 구조 평면도 (5개 부재) ======
// 좌→우: 방화벽 → ③벽연형C → ④벽연형D → ②본체(C채널) → ⑤마감재(SUS커버) → 개구부
// 세로 = 채널 폭 70mm
if (S.productType === 'screen') {
const sc = 4; // px per mm
const t2 = g.thickness * sc; // 본체 EGI 1.55T
const t5 = (g.trimThick || 1.2) * sc; // 마감재 SUS 1.2T
const sealT = g.sealThick * sc;
const slatT = Math.max(g.slatThick * sc, 2);
// ── ② 본체 치수 (절곡: 10-26-80-67-80-26-10) ──
const bLip = g.lip * sc; // 10mm 립
const bFl = g.flange * sc; // 26mm 플랜지
const bSw = g.sideWall * sc; // 80mm 사이드월
const bBw = g.backWall * sc; // 67mm 백월
const bOuterW = bBw + 2 * t2; // 70mm 외폭 (세로)
const bSlot = bBw - 2 * bFl; // 15mm 슬롯 개구
// ── ③ 벽연형-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;
// ── ⑤ 마감재 치수 (절곡: 10-11-110-30-15-15-15) ──
const m5a = 10 * sc; // 벽측 립
const m5b = 11 * sc; // 벽측 절곡면
const m5c = 110 * sc; // 사이드월 커버 (긴 면)
const m5d = 30 * sc; // A각 절곡 후 면
const m5e = 15 * sc; // 끝단 1
const m5f = 15 * sc; // 끝단 2
const m5g = 15 * sc; // 끝단 3
// ── 레이아웃 (좌→우 배치) ──
const pad = 80, wallW = 35;
// ③ 벽연형-C의 가장 왼쪽: 벽면에서 립 30mm 돌출
const bkTotalD = c3Lip; // 벽→본체 백월 사이 깊이
const bx = pad + wallW + 10 + bkTotalD; // 본체 백월 외면 X
// 본체 사이드월 끝 (립 시작점)
const swEndX = bx + t2 + bSw; // 백월두께 + 사이드월
// 립 끝
const lipEndX = swEndX + bLip;
const by = pad; // 본체 상단 Y
const svgW = lipEndX + pad + 120;
const svgH = bOuterW + pad * 2 + 70;
// 색상 정의
const cBody = '#64748b'; // ② 본체 (EGI)
const cBk3 = '#78716c'; // ③ 벽연형-C
const cBk4 = '#92400e'; // ④ 벽연형-D
const cTrim = '#a1a1aa'; // ⑤ 마감재 (SUS)
const cClip = '#8b5cf6'; // ① 클립
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채널 (절곡: 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}"/>`,
// 상단 플랜지 (사이드월 끝에서 안쪽/중앙으로 26mm 절곡)
`<rect x="${swEndX-t2}" y="${by+t2}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 플랜지
`<rect x="${swEndX-t2}" y="${by+bOuterW-bFl}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 상단 립 (플랜지 끝에서 개구부 방향으로 10mm)
`<rect x="${swEndX}" y="${by+bFl-t2}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>`,
// 하단 립
`<rect x="${swEndX}" y="${by+bOuterW-bFl}" width="${bLip}" 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, ③ 내부에 중첩) ──
// ③ 내부에 배치, C 개구부가 ② 본체쪽(우측)을 향함
// 몸체(40) 벽쪽, 사이드(23) 본체 방향, 립(11) 안쪽 절곡
const c4CenterY = c3CenterY;
const c4Y = c4CenterY - c4c / 2;
const c4BodyX = c3BodyX + t2 + 2; // ③ 몸체 바로 안쪽
const c4SideEnd = c4BodyX + t2 + 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="${c4BodyX+t2}" y="${c4Y}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 하단 사이드
`<rect x="${c4BodyX+t2}" y="${c4Y+c4c-t2}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 상단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideEnd-t2}" y="${c4Y+t2}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>`,
// 하단 립 (11mm, 사이드 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideEnd-t2}" 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) ──
// 평면도(위에서 봄): 접힌 후 외곽 프레임 형태로 보임
// 좌측: ③ 좌단과 정렬, 우측: 본체 립 끝 바깥으로 감싸는 형태
const trimL = bx - c3Lip; // 좌측 끝 (③ 좌단 정렬)
const trimR = lipEndX + t5; // 우측 끝 (립 바깥)
const trimX2 = trimR; // 치수선용
// 상단 마감재: 수평(좌→우) + 우측 리턴(안쪽으로 플랜지 높이만큼)
const trim5Svg = `
<polyline points="${trimL},${by-t5} ${trimR},${by-t5} ${trimR},${by+bFl}"
fill="none" stroke="${cTrim}" stroke-width="${t5}" stroke-linejoin="miter" opacity="0.7"/>
<polyline points="${trimL},${by+bOuterW+t5} ${trimR},${by+bOuterW+t5} ${trimR},${by+bOuterW-bFl}"
fill="none" stroke="${cTrim}" stroke-width="${t5}" stroke-linejoin="miter" opacity="0.7"/>`;
// ── ① 클립 (채널 내부, 개구부 근처 L형) ──
const clipArmLen = 10 * sc;
const clipLegLen = 15 * sc;
const clipT = Math.max(t2, 2);
const clipX1 = swEndX - t2 - 2; // 플랜지 내면 근처
const clipY1 = by + t2 + 2;
const clipY2 = by + bOuterW - t2 - 2;
const clipSvg = `
<path d="M${clipX1},${clipY1} L${clipX1},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen} L${clipX1-clipArmLen},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1+clipLegLen-clipT} L${clipX1-clipT},${clipY1} Z" fill="${cClip}" opacity="0.4"/>
<path d="M${clipX1},${clipY2} L${clipX1},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen} L${clipX1-clipArmLen},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2-clipLegLen+clipT} L${clipX1-clipT},${clipY2} Z" fill="${cClip}" opacity="0.4"/>`;
// ── 연기차단재 ──
let sealSvg = '';
if (g.showSeal) {
const slatCenterY = by + bOuterW / 2;
const sealLen = bLip * 0.8;
const sealPosX = swEndX + 2;
const sealH = Math.max(sealT * 1.5, 6);
sealSvg += `<rect x="${sealPosX}" y="${slatCenterY - slatT/2 - sealH}" width="${sealLen}" height="${sealH}" fill="#f97316" opacity="0.7" rx="1"/>`;
sealSvg += `<rect x="${sealPosX}" y="${slatCenterY + slatT/2}" width="${sealLen}" height="${sealH}" fill="#f97316" opacity="0.7" rx="1"/>`;
if (g.showDim) {
sealSvg += `<text x="${sealPosX + sealLen/2}" y="${slatCenterY - slatT/2 - sealH - 4}" fill="#f97316" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard">차단재</text>`;
}
}
// ── 슬랫 ──
const slatY = by + bOuterW / 2 - slatT / 2;
const slatX1 = bx + t2 + 2;
const slatX2 = lipEndX - 2;
// ── 방화벽 ──
const wallX = pad;
// ── 치수선 ──
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 (우측)
dimLines += `<line x1="${totalRight+12}" y1="${by}" x2="${totalRight+12}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${totalRight+7}" y1="${by}" x2="${totalRight+17}" y2="${by}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${totalRight+7}" y1="${by+bOuterW}" x2="${totalRight+17}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${totalRight+22}" 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 += `<line x1="${lipEndX+3}" y1="${by}" x2="${lipEndX+3}" y2="${by+bFl}" stroke="#94a3b8" stroke-width="0.8"/>`;
dimLines += `<text x="${lipEndX+8}" y="${by+bFl/2+3}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">FL ${g.flange}</text>`;
// 슬롯 개구
dimLines += `<line x1="${lipEndX+1}" y1="${by+bFl+3}" x2="${lipEndX+1}" y2="${by+bOuterW-bFl-3}" stroke="#22c55e" stroke-width="0.8"/>`;
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+3}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯 ${bSlot/sc}</text>`;
// 립 깊이
dimLines += `<line x1="${swEndX}" y1="${by+bFl+3}" x2="${lipEndX}" y2="${by+bFl+3}" stroke="#94a3b8" stroke-width="0.8"/>`;
dimLines += `<text x="${(swEndX+lipEndX)/2}" y="${by+bFl+14}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">립 ${g.lip}</text>`;
// 두께
dimLines += `<text x="${bx+t2}" y="${by+bOuterW+65}" fill="#94a3b8" font-size="9" font-weight="700" font-family="Pretendard">②t=${g.thickness} ⑤t=${g.trimThick||1.2}</text>`;
// 부재 번호 라벨
dimLines += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW/2+4}" fill="#475569" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">②</text>`;
dimLines += `<text x="${c3BodyX+c3Lip/2}" y="${c3CenterY+4}" fill="${cBk3}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">③</text>`;
dimLines += `<text x="${c4BodyX+c4b/2}" y="${c4CenterY+4}" fill="${cBk4}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>`;
dimLines += `<text x="${(trimX1+trimX2)/2}" y="${by-t5-m5b-5}" fill="${cTrim}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">⑤</text>`;
dimLines += `<text x="${clipX1-clipArmLen-2}" y="${clipY1+clipLegLen/2+3}" fill="${cClip}" font-size="8" font-weight="900" text-anchor="end" 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) — 스크린형</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}
<!-- ⑤ 마감재 SUS 커버 -->
${trim5Svg}
<!-- ③ 벽연형-C -->
${bk3Svg}
<!-- ④ 벽연형-D -->
${bk4Svg}
<!-- ② 본체 C채널 -->
${bodySvg}
<!-- ① 클립 -->
${clipSvg}
<!-- 연기차단재 -->
${sealSvg}
<!-- 슬랫 -->
<rect x="${slatX1}" y="${slatY}" width="${slatX2-slatX1}" height="${slatT}" fill="#c084fc" opacity="0.8" rx="1"/>
${g.showDim ? `<text x="${(slatX1+slatX2)/2}" y="${slatY-4}" fill="#c084fc" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : ''}
<!-- 개구부 방향 -->
<text x="${lipEndX+15}" y="${by+bOuterW/2+4}" fill="#22c55e" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">→ 개구부</text>
${dimLines}
<text x="${(bx+lipEndX)/2}" y="${by+bOuterW+65}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL — 스크린형 (5부재 조립)</text>
</svg>`;
displaySvg(svg);
return;
}
// ====== 철재형: 기존 C채널 단면도 ======
const sc = 6; // px per mm
const w = g.width * sc, d = g.depth * sc, t = g.thickness * sc, lip = g.lip * sc;
const sealT = g.sealThick * sc, sealD = g.sealDepth * sc, slatT = g.slatThick * sc;
const pad = 120;
const svgW = w + pad * 2 + 200, svgH = d + pad * 2 + 100;
const ox = pad + 100, oy = pad;
// C-channel path (looking from top)
const outer = `M${ox},${oy} L${ox+w},${oy} L${ox+w},${oy+lip} L${ox+w-t},${oy+lip} L${ox+w-t},${oy+t} L${ox+t},${oy+t} L${ox+t},${oy+lip} L${ox},${oy+lip} Z`;
const bottom = `M${ox},${oy+d-lip} L${ox+t},${oy+d-lip} L${ox+t},${oy+d-t} L${ox+w-t},${oy+d-t} L${ox+w-t},${oy+d-lip} L${ox+w},${oy+d-lip} L${ox+w},${oy+d} L${ox},${oy+d} Z`;
const leftWall = `M${ox},${oy+lip} L${ox+t},${oy+lip} L${ox+t},${oy+d-lip} L${ox},${oy+d-lip} Z`;
// Seal rectangles
const sealY1 = oy + lip;
const sealY2 = oy + d - lip - sealD;
const sealX = ox + t;
// Slat position (center of channel opening)
const slatX = ox + w/2 - slatT/2;
const slatY1 = oy + lip + 5;
const slatY2 = oy + d - lip - 5;
// Wall (hatched area to the right)
const wallX = ox + w + 10;
let dimLines = '';
if (g.showDim) {
// Width dimension (top)
dimLines += `<line x1="${ox}" y1="${oy-30}" x2="${ox+w}" y2="${oy-30}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${ox}" y1="${oy-35}" x2="${ox}" y2="${oy-5}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${ox+w}" y1="${oy-35}" x2="${ox+w}" y2="${oy-5}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${ox+w/2}" y="${oy-40}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.width} mm</text>`;
// Depth dimension (left)
dimLines += `<line x1="${ox-30}" y1="${oy}" x2="${ox-30}" y2="${oy+d}" stroke="#3b82f6" stroke-width="1"/>`;
dimLines += `<line x1="${ox-35}" y1="${oy}" x2="${ox-5}" y2="${oy}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<line x1="${ox-35}" y1="${oy+d}" x2="${ox-5}" y2="${oy+d}" stroke="#3b82f6" stroke-width="0.5"/>`;
dimLines += `<text x="${ox-40}" y="${oy+d/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="end" font-family="Pretendard">${g.depth} mm</text>`;
// Thickness annotation
dimLines += `<text x="${ox+t/2}" y="${oy+d+25}" fill="#94a3b8" font-size="10" font-weight="700" text-anchor="middle" font-family="Pretendard">t=${g.thickness}</text>`;
// Lip dimension
dimLines += `<text x="${ox+w+15}" y="${oy+lip/2+4}" fill="#94a3b8" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">립 ${g.lip}</text>`;
}
let sealSvg = '';
if (g.showSeal) {
// Upper seal
sealSvg += `<rect x="${sealX}" y="${sealY1}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
// Lower seal
sealSvg += `<rect x="${sealX}" y="${sealY2}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
// Seal on right side
sealSvg += `<rect x="${ox+w-t-sealT}" y="${sealY1}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
sealSvg += `<rect x="${ox+w-t-sealT}" y="${sealY2}" width="${sealT}" height="${sealD}" fill="#f97316" opacity="0.7" rx="1"/>`;
if (g.showDim) {
sealSvg += `<text x="${sealX + sealT + 5}" y="${sealY1 + sealD/2 + 3}" fill="#f97316" font-size="9" font-weight="700" font-family="Pretendard">연기차단재</text>`;
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<defs>
<pattern id="wallHatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="6" stroke="#8b7355" stroke-width="0.8"/>
</pattern>
</defs>
<!-- Title -->
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 평면도 (Plan View)</text>
<!-- Wall -->
<rect x="${wallX}" y="${oy - 20}" width="60" height="${d + 40}" fill="url(#wallHatch)" stroke="#8b7355" stroke-width="1" rx="2"/>
<text x="${wallX + 30}" y="${oy + d + 35}" fill="#a1887f" font-size="10" font-weight="700" text-anchor="middle" font-family="Pretendard">방화벽</text>
<!-- C-Channel Body -->
<path d="${outer}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<path d="${bottom}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<path d="${leftWall}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<!-- Right wall of channel -->
<rect x="${ox+w-t}" y="${oy+lip}" width="${t}" height="${d - lip*2}" fill="#64748b" stroke="#94a3b8" stroke-width="1.5"/>
<!-- Channel interior (empty space) -->
<rect x="${ox+t}" y="${oy+t}" width="${w-t*2}" height="${d-t*2}" fill="#0f172a" stroke="none"/>
<!-- Re-draw lips over interior -->
<rect x="${ox+t}" y="${oy+t}" width="${w-t*2}" height="${lip - t}" fill="#0f172a"/>
<rect x="${ox+t}" y="${oy+d-lip}" width="${w-t*2}" height="${lip - t}" fill="#0f172a"/>
<!-- Seals -->
${sealSvg}
<!-- Slat Edge -->
<rect x="${slatX}" y="${slatY1}" width="${slatT}" height="${slatY2 - slatY1}" fill="#60a5fa" opacity="0.8" rx="1"/>
${g.showDim ? `<text x="${slatX + slatT + 8}" y="${(slatY1+slatY2)/2 + 3}" fill="#60a5fa" font-size="9" font-weight="700" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : ''}
<!-- Dimension Lines -->
${dimLines}
<!-- Labels -->
<text x="${ox + w/2}" y="${oy + d + 45}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL BODY (C-CHANNEL) — 철재슬라트</text>
</svg>`;
displaySvg(svg);
}
function renderGrFront() {
const g = S.gr;
const H = S.openHeight || 3000;
const sc = Math.min(0.2, 700 / H);
const rw = g.width * 3, rh = H * sc;
const pad = 80;
const svgW = rw + pad * 2 + 100, svgH = rh + pad * 2 + 60;
const ox = pad + 50, oy = pad;
// Anchor bolt positions
const anchorCount = Math.floor(H / g.anchorSpacing);
const anchors = Array.from({length: anchorCount}, (_, i) => oy + (i + 1) * g.anchorSpacing * sc);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">가이드레일 정면도 (Front View)</text>
<!-- Rail body -->
<rect x="${ox}" y="${oy}" width="${rw}" height="${rh}" fill="#64748b" stroke="#94a3b8" stroke-width="2" rx="2"/>
<!-- Channel groove (center line) -->
<line x1="${ox + rw/2}" y1="${oy}" x2="${ox + rw/2}" y2="${oy + rh}" stroke="#334155" stroke-width="2" stroke-dasharray="8 4"/>
<!-- Anchor bolts -->
${anchors.map(ay => `
<circle cx="${ox + rw/2}" cy="${ay}" r="5" fill="#ef4444" stroke="#dc2626" stroke-width="1.5"/>
<line x1="${ox + rw + 10}" y1="${ay}" x2="${ox + rw + 30}" y2="${ay}" stroke="#ef4444" stroke-width="0.8"/>
<text x="${ox + rw + 35}" y="${ay + 3}" fill="#ef4444" font-size="9" font-weight="700" font-family="Pretendard">앵커볼트</text>
`).join('')}
<!-- Height dimension -->
<line x1="${ox - 25}" y1="${oy}" x2="${ox - 25}" y2="${oy + rh}" stroke="#3b82f6" stroke-width="1"/>
<line x1="${ox - 30}" y1="${oy}" x2="${ox - 5}" y2="${oy}" stroke="#3b82f6" stroke-width="0.5"/>
<line x1="${ox - 30}" y1="${oy + rh}" x2="${ox - 5}" y2="${oy + rh}" stroke="#3b82f6" stroke-width="0.5"/>
<text x="${ox - 35}" y="${oy + rh/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="end" font-family="Pretendard">${H.toLocaleString()} mm</text>
<!-- Width dimension -->
<text x="${ox + rw/2}" y="${oy + rh + 30}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">폭 ${g.width} mm | 앵커 간격 ${g.anchorSpacing} mm</text>
<!-- Label -->
<text x="${ox + rw/2}" y="${oy + rh + 50}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">GUIDE RAIL — FRONT VIEW</text>
</svg>`;
displaySvg(svg);
}
// ============================
// SHUTTER BOX SVG RENDERER
// ============================
function renderShutterBox() {
if (S.sb.viewMode === 'front') renderSbFront();
else renderSbSide();
}
function renderSbFront() {
const b = S.sb;
const sc = Math.min(700 / b.width, 600 / b.height);
const sw = b.width * sc, sh = b.height * sc;
const pad = 80;
const svgW = sw + pad * 2 + 60, svgH = sh + pad * 2 + 60;
const ox = pad, oy = pad;
const bracketW = Math.max(b.bracketW * sc, 3); // min 3px visible
const bracketX1 = ox; // left bracket
const bracketX2 = ox + sw - bracketW; // right bracket
const shaftCy = oy + sh / 2; // vertical center of box = bracket center
// Shaft: horizontal bar connecting both brackets
const shaftH = b.shaftDia * sc;
const shaftX = ox + bracketW;
const shaftW = sw - bracketW * 2;
const shaftY = shaftCy - shaftH / 2;
// Slat roll: horizontal bar wrapping shaft (larger)
const rollH = shaftH + 50 * sc;
const rollY = shaftCy - rollH / 2;
// Motor: positioned near specified side
const motorW = 80 * sc, motorH = 50 * sc;
const motorX = b.motorSide === 'right' ? bracketX2 - motorW - 5 : ox + bracketW + 5;
const motorY = shaftCy - motorH / 2;
// Brake: next to motor
const brakeW = 30 * sc, brakeH = 40 * sc;
const brakeX = b.motorSide === 'right' ? motorX - brakeW - 5 : motorX + motorW + 5;
// Spring: opposite side of motor
const springW = 60 * sc, springH = 20 * sc;
const springX = b.motorSide === 'right' ? ox + bracketW + 20 : bracketX2 - springW - 20;
const springY = shaftCy + shaftH / 2 + 10;
// Bracket label positioning (outside if too thin)
const brkThin = bracketW < 15;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 정면 단면도 (Front Cross-Section)</text>
<!-- Case outer -->
<rect x="${ox}" y="${oy}" width="${sw}" height="${sh}" fill="none" stroke="#94a3b8" stroke-width="2.5" rx="4"/>
<rect x="${ox}" y="${oy}" width="${sw}" height="${sh}" fill="#374151" opacity="0.3" rx="4"/>
<rect x="${ox+2}" y="${oy+2}" width="${sw-4}" height="${sh-4}" fill="none" stroke="#475569" stroke-width="1" stroke-dasharray="4 4" rx="3"/>
<!-- Brackets -->
<rect x="${bracketX1}" y="${oy + 3}" width="${bracketW}" height="${sh - 6}" fill="#8b5cf6" opacity="0.7" stroke="#7c3aed" stroke-width="1.5" rx="1"/>
<rect x="${bracketX2}" y="${oy + 3}" width="${bracketW}" height="${sh - 6}" fill="#8b5cf6" opacity="0.7" stroke="#7c3aed" stroke-width="1.5" rx="1"/>
${brkThin ? `
<text x="${bracketX1 - 5}" y="${oy + sh/2 + 3}" fill="#a78bfa" font-size="9" font-weight="700" text-anchor="end" font-family="Pretendard">브래킷</text>
<text x="${bracketX2 + bracketW + 5}" y="${oy + sh/2 + 3}" fill="#a78bfa" font-size="9" font-weight="700" text-anchor="start" font-family="Pretendard">브래킷</text>
` : `
<text x="${ox + bracketW/2}" y="${oy + sh/2}" fill="white" font-size="${Math.max(8, bracketW * 0.12)}" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + bracketW/2}, ${oy + sh/2})" font-family="Pretendard">브래킷</text>
<text x="${ox + sw - bracketW/2}" y="${oy + sh/2}" fill="white" font-size="${Math.max(8, bracketW * 0.12)}" font-weight="900" text-anchor="middle" transform="rotate(-90, ${ox + sw - bracketW/2}, ${oy + sh/2})" font-family="Pretendard">브래킷</text>
`}
<!-- Shaft: horizontal bar connecting brackets -->
${b.showShaft ? `
<rect x="${shaftX}" y="${shaftY}" width="${shaftW}" height="${shaftH}" fill="#475569" stroke="#64748b" stroke-width="1.5" rx="${shaftH/2}"/>
<text x="${ox + sw/2}" y="${shaftY - 6}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="middle" font-family="Pretendard">샤프트 ${b.shaftDia}</text>
` : ''}
<!-- Slat Roll: horizontal bar wrapping shaft -->
${b.showSlatRoll ? `
<rect x="${shaftX}" y="${rollY}" width="${shaftW}" height="${rollH}" fill="none" stroke="#f59e0b" stroke-width="2.5" opacity="0.6" stroke-dasharray="6 3" rx="${rollH/2}"/>
<text x="${ox + sw/2}" y="${rollY - 6}" fill="#f59e0b" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">감긴 슬랫</text>
` : ''}
<!-- Motor -->
${b.showMotor ? `
<rect x="${motorX}" y="${motorY}" width="${motorW}" height="${motorH}" fill="#2563eb" opacity="0.7" stroke="#3b82f6" stroke-width="1.5" rx="3"/>
<text x="${motorX + motorW/2}" y="${motorY + motorH/2 + 4}" fill="white" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">모터+감속기</text>
` : ''}
<!-- Brake -->
${b.showBrake ? `
<rect x="${brakeX}" y="${motorY + 5}" width="${brakeW}" height="${brakeH}" fill="#dc2626" opacity="0.7" stroke="#ef4444" stroke-width="1.5" rx="2"/>
<text x="${brakeX + brakeW/2}" y="${motorY + 5 + brakeH/2 + 3}" fill="white" font-size="7" font-weight="900" text-anchor="middle" font-family="Pretendard">브레이크</text>
` : ''}
<!-- Spring -->
${b.showSpring ? `
<rect x="${springX}" y="${springY}" width="${springW}" height="${springH}" fill="#16a34a" opacity="0.6" stroke="#22c55e" stroke-width="1.5" rx="2"/>
<text x="${springX + springW/2}" y="${springY + springH/2 + 3}" fill="white" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard">밸런스 스프링</text>
` : ''}
<!-- Slat exit -->
<line x1="${ox + bracketW + 5}" y1="${oy + sh}" x2="${ox + sw - bracketW - 5}" y2="${oy + sh}" stroke="#f59e0b" stroke-width="3" stroke-dasharray="8 4"/>
<text x="${ox + sw/2}" y="${oy + sh + 18}" fill="#f59e0b" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">↓ 슬랫 출구 ↓</text>
<!-- Dimensions -->
<line x1="${ox}" y1="${oy + sh + 35}" x2="${ox + sw}" y2="${oy + sh + 35}" stroke="#3b82f6" stroke-width="1"/>
<text x="${ox + sw/2}" y="${oy + sh + 50}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">${b.width.toLocaleString()} mm</text>
<line x1="${ox + sw + 20}" y1="${oy}" x2="${ox + sw + 20}" y2="${oy + sh}" stroke="#3b82f6" stroke-width="1"/>
<text x="${ox + sw + 35}" y="${oy + sh/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">${b.height} mm</text>
<!-- Case label -->
<text x="${ox + sw/2}" y="${oy - 10}" fill="#94a3b8" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 (CASE) — t=${b.thickness}mm</text>
</svg>`;
displaySvg(svg);
}
function renderSbSide() {
const b = S.sb;
const sc = Math.min(500 / b.depth, 600 / b.height);
const sd = b.depth * sc, sh = b.height * sc;
const pad = 80;
const svgW = sd + pad * 2 + 100, svgH = sh + pad * 2 + 60;
const ox = pad + 50, oy = pad;
const shaftR = (b.shaftDia / 2) * sc;
const shaftCx = ox + sd / 2;
const shaftCy = oy + sh * 0.45;
const rollR = shaftR + 20 * sc;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgW} ${svgH}" style="max-width:100%;max-height:100%;">
<text x="${svgW/2}" y="25" fill="#94a3b8" font-size="14" font-weight="900" text-anchor="middle" font-family="Pretendard">셔터박스 측면 단면도 (Side Cross-Section)</text>
<!-- Case outer -->
<rect x="${ox}" y="${oy}" width="${sd}" height="${sh}" fill="#374151" opacity="0.3" stroke="#94a3b8" stroke-width="2.5" rx="4"/>
<!-- Shaft -->
${b.showShaft ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${shaftR}" fill="#475569" stroke="#64748b" stroke-width="2"/>
<circle cx="${shaftCx}" cy="${shaftCy}" r="4" fill="#94a3b8"/>
` : ''}
<!-- Slat roll -->
${b.showSlatRoll ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${rollR}" fill="none" stroke="#f59e0b" stroke-width="3" opacity="0.5" stroke-dasharray="6 3"/>
${Array.from({length:5},(_,i) => {
const r = shaftR + (i+1) * 4 * sc;
return r < rollR ? `<circle cx="${shaftCx}" cy="${shaftCy}" r="${r}" fill="none" stroke="#f59e0b" stroke-width="0.5" opacity="0.3"/>` : '';
}).join('')}
` : ''}
<!-- Motor (side view = circle) -->
${b.showMotor ? `
<circle cx="${shaftCx}" cy="${shaftCy}" r="${shaftR * 0.6}" fill="#2563eb" opacity="0.5" stroke="#3b82f6" stroke-width="1.5"/>
<text x="${shaftCx}" y="${shaftCy + 3}" fill="white" font-size="8" font-weight="900" text-anchor="middle" font-family="Pretendard">M</text>
` : ''}
<!-- Slat exit -->
<rect x="${ox + sd * 0.2}" y="${oy + sh - 5}" width="${sd * 0.6}" height="5" fill="#f59e0b" opacity="0.6" rx="1"/>
<!-- Slat curtain going down -->
<line x1="${ox + sd * 0.5}" y1="${oy + sh}" x2="${ox + sd * 0.5}" y2="${oy + sh + 30}" stroke="#9ca3af" stroke-width="2" stroke-dasharray="4 2"/>
<text x="${ox + sd * 0.5 + 10}" y="${oy + sh + 20}" fill="#94a3b8" font-size="9" font-weight="700" font-family="Pretendard">↓ 슬랫</text>
<!-- Dimensions -->
<text x="${ox + sd/2}" y="${oy + sh + 50}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">깊이 ${b.depth} mm</text>
<text x="${ox + sd + 25}" y="${oy + sh/2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">${b.height} mm</text>
</svg>`;
displaySvg(svg);
}
// ============================
// VIEW MODE SWITCHES
// ============================
window.fsGrView = function(mode) {
S.gr.viewMode = mode;
document.querySelectorAll('[data-grview]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.grview === mode);
btn.style.color = btn.dataset.grview === mode ? '#3b82f6' : '';
btn.style.borderColor = btn.dataset.grview === mode ? '#3b82f6' : '';
});
fsRender();
};
window.fsSbView = function(mode) {
S.sb.viewMode = mode;
document.querySelectorAll('[data-sbview]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.sbview === mode);
btn.style.color = btn.dataset.sbview === mode ? '#3b82f6' : '';
btn.style.borderColor = btn.dataset.sbview === mode ? '#3b82f6' : '';
});
fsRender();
};
window.fsToggle = function(el, key) {
el.classList.toggle('active');
const on = el.classList.contains('active');
if (key in S.gr) S.gr[key] = on;
if (key in S.sb) S.sb[key] = on;
fsRender();
};
// ============================
// THREE.JS 3D RENDERING
// ============================
function fs3dInit() {
const container = $('threeDContainer');
if (renderer) return; // Already initialized
scene = new THREE.Scene();
scene.background = new THREE.Color(S.td.bgColor);
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 50000);
camera.position.set(2000, 1500, 3000);
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.target.set(0, 0, 0);
// Lights
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
const dir1 = new THREE.DirectionalLight(0xffffff, 0.8);
dir1.position.set(2000, 3000, 2000);
dir1.castShadow = true;
const dir2 = new THREE.DirectionalLight(0xffffff, 0.3);
dir2.position.set(-2000, 1000, -1000);
const hemi = new THREE.HemisphereLight(0x87ceeb, 0x8b4513, 0.3);
scene.add(ambient, dir1, dir2, hemi);
// Grid
const grid = new THREE.GridHelper(5000, 50, 0x334155, 0x1e293b);
grid.position.y = 0;
scene.add(grid);
function animate() {
animId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
// Resize (use ResizeObserver for flex layout)
const ro = new ResizeObserver(() => {
if (container.clientWidth && container.clientHeight) {
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
});
ro.observe(container);
// === 우클릭 컨텍스트 메뉴 (단품 보기 / 전체 보기) ===
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 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.values(meshes).filter(Boolean);
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;
// 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 boxMat = new THREE.MeshStandardMaterial({ color: 0x374151, transparent: true, opacity: S.td.caseOpacity, side: THREE.DoubleSide });
const boxEdgeMat = new THREE.LineBasicMaterial({ color: 0x94a3b8 });
meshes.case = new THREE.Group();
meshes.case.position.set(0, H, boxCenterZ);
// 철판 생성 헬퍼
function addPlate(w, h, d, x, y, z) {
const geo = new THREE.BoxGeometry(w, h, d);
const m = new THREE.Mesh(geo, boxMat);
m.position.set(x, y, z);
meshes.case.add(m);
const edges = new THREE.EdgesGeometry(geo);
m.add(new THREE.LineSegments(edges, boxEdgeMat));
}
// ── 박스 본체 ──
const flangeBack = 55; // 후면판 상단 안쪽 절곡 깊이
const flangeFront = 50; // 전면판 상단 안쪽 절곡 깊이
// 후면판 (Back plate — 전체 높이)
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);
// 전면판 (Front plate — 짧음, 상단에서 걸림)
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);
// 전면 하부판 (전면판 아래 빈 공간을 막는 수직판)
const frontGap = b.height - frontH;
if (frontGap > 0) addPlate(W1, frontGap, pt, 0, frontGap / 2, b.depth / 2 - pt / 2);
// 좌측판 (Left side plate — 전체 높이)
addPlate(pt, b.height, b.depth, -W1 / 2 + pt / 2, b.height / 2, 0);
// 우측판 (Right side plate — 전체 높이)
addPlate(pt, b.height, b.depth, W1 / 2 - pt / 2, b.height / 2, 0);
// ── 밑면 결합체 (슬랫 통과 개구부: 레일 너비만큼만 개방) ──
// 구조: [전면 립 50mm] → [개구부 = 레일 너비] → [후면 밑판]
const frontLipD = 50; // 전면판 ~ 레일 전면 (50mm)
const openingW = railZSpan; // 개구부 Z폭 (스크린:width=70, 철재:depth=75)
const backLipD = b.depth - frontLipD - openingW; // 레일 후면 ~ 후면판
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);
// ── 덮개 (Cover) — 절곡 선반 위에 올라가는 평판 (용접 결합, 양쪽 10mm 축소) ──
const coverY = b.height + pt; // 절곡 선반(pt) 위에 올라감
addPlate(W1 - 20, pt, b.depth - 20, 0, coverY + 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; // 26mm 플랜지 (슬롯=backWall-2*fl=15mm)
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();
wcShape.moveTo(wcCenterX - wcBody / 2, wcY);
wcShape.lineTo(wcCenterX - wcBody / 2, wcY + wcLip);
wcShape.lineTo(wcCenterX - wcBody / 2 + rt, wcY + wcLip);
wcShape.lineTo(wcCenterX - wcBody / 2 + rt, wcY + rt);
wcShape.lineTo(wcCenterX + wcBody / 2 - rt, wcY + rt);
wcShape.lineTo(wcCenterX + wcBody / 2 - rt, wcY + wcLip);
wcShape.lineTo(wcCenterX + wcBody / 2, wcY + wcLip);
wcShape.lineTo(wcCenterX + wcBody / 2, wcY);
wcShape.lineTo(wcCenterX - wcBody / 2, wcY);
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();
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));
// --- ⑤ 벽도마감 (SUS 1.2T): 9-114-34-10 × 2개 ---
// 본체 양쪽 사이드를 감싸는 SUS 커버 (L자 프로파일)
const trimT = g.trimThick || 1.2;
// 하단 트림 (X=0 외면)
const bt = new THREE.Shape();
bt.moveTo(-trimT, -9); // 전면 립 (9mm 돌출)
bt.lineTo(0, -9);
bt.lineTo(0, sw); // 사이드 커버 (본체 사이드월까지)
bt.lineTo(34, sw); // 벽쪽 플랜지 (34mm 안쪽으로)
bt.lineTo(34, sw + trimT);
bt.lineTo(-trimT, sw + trimT);
bt.lineTo(-trimT, -9);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(bt, railExtrude), railSusMat));
// 상단 트림 (X=rw 외면)
const tt = new THREE.Shape();
tt.moveTo(rw + trimT, -9); // 전면 립
tt.lineTo(rw, -9);
tt.lineTo(rw, sw); // 사이드 커버
tt.lineTo(rw - 34, sw); // 벽쪽 플랜지
tt.lineTo(rw - 34, sw + trimT);
tt.lineTo(rw + trimT, sw + trimT);
tt.lineTo(rw + trimT, -9);
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(tt, railExtrude), railSusMat));
// --- ① 마감재 (EGI 1.55T) × 2개 ---
// 슬랫 가이드 클립: 사이드월 내면에 부착된 L형 브라켓
// 참조 도면 기준: 채널 내부 사이드월에서 안쪽으로 돌출
[{ x: rt, dir: 1 }, { x: rw - rt, dir: -1 }].forEach(({ x, dir }) => {
const clipShape = new THREE.Shape();
const cy = lp + rt + 3; // 립 내면에서 약간 안쪽
const armLen = 12; // 사이드월에서 안쪽으로 돌출 길이
const legLen = 20; // 사이드월 따라 길이
// L형 브라켓 (사이드월 내면 → 채널 안쪽으로)
clipShape.moveTo(x, cy);
clipShape.lineTo(x + dir * armLen, cy);
clipShape.lineTo(x + dir * armLen, cy + rt);
clipShape.lineTo(x + dir * rt, cy + rt);
clipShape.lineTo(x + dir * rt, cy + legLen);
clipShape.lineTo(x, cy + legLen);
clipShape.lineTo(x, cy);
const clipGeo = new THREE.ExtrudeGeometry(clipShape, railExtrude);
grp.add(new THREE.Mesh(clipGeo, railMat));
});
} else {
// ====== 철재슬라트 가이드레일 (기존 C채널) ======
const rl = g.lip;
// Top lip + wall
const s1 = new THREE.Shape();
s1.moveTo(0, 0); s1.lineTo(rw, 0); s1.lineTo(rw, rl);
s1.lineTo(rw - rt, rl); s1.lineTo(rw - rt, rt);
s1.lineTo(rt, rt); s1.lineTo(rt, rl); s1.lineTo(0, rl); s1.lineTo(0, 0);
// Bottom lip + wall
const s2 = new THREE.Shape();
s2.moveTo(0, rd - rl); s2.lineTo(rt, rd - rl); s2.lineTo(rt, rd - rt);
s2.lineTo(rw - rt, rd - rt); s2.lineTo(rw - rt, rd - rl);
s2.lineTo(rw, rd - rl); s2.lineTo(rw, rd); s2.lineTo(0, rd); s2.lineTo(0, rd - rl);
// Side walls
const s3 = new THREE.Shape();
s3.moveTo(0, rl); s3.lineTo(rt, rl); s3.lineTo(rt, rd - rl); s3.lineTo(0, rd - rl); s3.lineTo(0, rl);
const s4 = new THREE.Shape();
s4.moveTo(rw - rt, rl); s4.lineTo(rw, rl); s4.lineTo(rw, rd - rl); s4.lineTo(rw - rt, rd - rl); s4.lineTo(rw - rt, rl);
[s1, s2, s3, s4].forEach(s => {
grp.add(new THREE.Mesh(new THREE.ExtrudeGeometry(s, railExtrude), railMat));
});
}
// 회전: 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()의 Rx(-PI/2) 후: 개구부가 +Z 방향
// Ry(±PI/2) 래퍼로 개구부를 ±X 방향(중심 향함)으로 회전
// Left rail — Ry(+PI/2): 개구부 → +X (중심 방향)
const railGroupL = createRailGroup();
const wrapperL = new THREE.Group();
wrapperL.add(railGroupL);
wrapperL.rotation.y = Math.PI / 2;
wrapperL.position.set(-hw, 0, rw / 2);
meshes.rails.add(wrapperL);
// Right rail — Ry(-PI/2): 개구부 → -X (중심 방향)
const railGroupR = createRailGroup();
const wrapperR = new THREE.Group();
wrapperR.add(railGroupR);
wrapperR.rotation.y = -Math.PI / 2;
wrapperR.position.set(hw, 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 slatGeo = new THREE.PlaneGeometry(W - 20, shutterH);
const slatMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0x9ca3af : 0xc084fc,
side: THREE.DoubleSide,
transparent: S.productType === 'screen',
opacity: S.productType === 'screen' ? 0.6 : 0.9,
metalness: S.productType === 'steel' ? 0.4 : 0,
roughness: S.productType === 'steel' ? 0.5 : 0.8,
});
meshes.slats = new THREE.Mesh(slatGeo, slatMat);
meshes.slats.position.set(0, H - shutterH / 2, 0);
scene.add(meshes.slats);
// Slat lines (horizontal grooves for steel type) — local coords relative to meshes.slats
if (S.productType === 'steel') {
const lineGroup = new THREE.Group();
const slatPitch = 80; // mm
const lineCount = Math.floor(shutterH / slatPitch);
for (let i = 1; i < lineCount; i++) {
const y = shutterH / 2 - i * slatPitch; // local Y: top of plane → down
if (y < -shutterH / 2) break;
const lineGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-W/2 + 10, y, 1),
new THREE.Vector3(W/2 - 10, y, 1)
]);
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: 0x6b7280 }));
lineGroup.add(line);
}
meshes.slats.add(lineGroup);
}
// === SLAT ROLL (샤프트에 감긴 슬랫 — 샤프트 전체에 두껍게 감기는 실린더) ===
const rolledH = H - shutterH;
if (rolledH > 0) {
const wrapThick = S.productType === 'steel' ? 10 : 1;
const shaftR = b.shaftDia / 2;
const rollThick = Math.max(Math.sqrt(rolledH * wrapThick / Math.PI), 8);
const rollOuterR = shaftR + rollThick;
const rollLen = W - 30 - stubPinVisible * 2; // 샤프트 관 길이에 맞춤
// 하나의 Mesh로 표현 (접시 모양 방지)
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 48, 1, false);
rollGeo.rotateZ(Math.PI / 2);
const rollMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0xC9B89A : 0xc084fc,
metalness: S.productType === 'steel' ? 0.2 : 0,
roughness: S.productType === 'steel' ? 0.5 : 0.8,
opacity: 0.93,
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' ? 0x8B7355 : 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 ===
const barGeo = new THREE.BoxGeometry(W - 20, 40, 60);
const barMat = new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.3, roughness: 0.5 });
meshes.bottomBar = new THREE.Mesh(barGeo, barMat);
meshes.bottomBar.position.set(0, H - shutterH - 20, 0);
scene.add(meshes.bottomBar);
// === WALL (좌/우 기둥 + 상부 인방) ===
// 벽체: 기둥+인방을 하나의 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();
const whw = W1 / 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;
}
}
// 단품 보기 (Isolation)
function fs3dIsolate(key) {
fs3dIsolated = key;
Object.entries(meshes).forEach(([k, obj]) => {
if (obj) obj.visible = (k === key);
});
// 배지 표시
const badge = document.querySelector('.fs-iso-badge');
if (badge) {
const label = meshLabels[key] || key;
badge.innerHTML = `${label} 단품 보기 중 &nbsp;✕`;
badge.style.display = 'block';
}
// 카메라 위치 유지 (현재 뷰 그대로, 단품만 표시)
}
// 전체 보기 (Show All)
function fs3dShowAll() {
fs3dIsolated = null;
Object.entries(meshes).forEach(([k, obj]) => {
if (!obj) return;
obj.visible = S.td.show[k] !== undefined ? S.td.show[k] : true;
});
// 배지 숨기기
const badge = document.querySelector('.fs-iso-badge');
if (badge) badge.style.display = 'none';
}
// 3D Controls
window.fs3dShutterPos = function(v) {
undoSaveState();
S.td.shutterPos = Number(v);
$('shutterPosLabel').textContent = v + '%';
fs3dBuild();
};
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];
// 벽 토글 시 벽체 설정 패널 연동
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('3D');
})();
</script>
@endpush
@endsection