2026-03-08 19:30:05 +09:00
@ extends ( 'layouts.app' )
@ section ( 'title' , '방화셔터 도면생성' )
@ section ( 'content' )
< style >
body { font - family : 'Pretendard' , sans - serif ; }
. custom - scrollbar ::- webkit - scrollbar { width : 6 px ; }
. 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 ( 12 px ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); }
. neon - border { box - shadow : 0 0 15 px rgba ( 59 , 130 , 246 , 0.1 ); border : 1 px solid rgba ( 59 , 130 , 246 , 0.2 ); }
input ::- webkit - outer - spin - button , input ::- webkit - inner - spin - button { - webkit - appearance : none ; margin : 0 ; }
2026-03-08 20:17:05 +09:00
. fs - wrap { margin : - 24 px ; min - height : calc ( 100 vh - 64 px ); background : #020617; overflow: hidden; }
2026-03-08 19:30:05 +09:00
. fs - input { width : 100 % ; background : rgba ( 2 , 6 , 23 , 0.8 ); border : 1 px 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; }
2026-03-08 20:17:05 +09:00
. fs - section { background : rgba ( 15 , 23 , 42 , 0.5 ); border - radius : 1 rem ; padding : 1 rem ; border : 1 px solid #1e293b; }
2026-03-08 19:30:05 +09:00
. fs - badge { display : inline - flex ; align - items : center ; justify - content : center ; width : 1.75 rem ; height : 1.75 rem ; border - radius : 0.75 rem ; font - size : 0.625 rem ; font - weight : 900 ; color : white ; }
2026-03-08 19:47:47 +09:00
. fs - toggle { position : relative ; width : 2 rem ; height : 1 rem ; background : #334155; border-radius: 9999px; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
2026-03-08 19:30:05 +09:00
. fs - toggle . active { background : #3b82f6; }
2026-03-08 19:47:47 +09:00
. fs - toggle :: after { content : '' ; position : absolute ; top : 2 px ; left : 2 px ; width : 0.75 rem ; height : 0.75 rem ; background : white ; border - radius : 9999 px ; transition : transform 0.2 s ; }
. fs - toggle . active :: after { transform : translateX ( 1 rem ); }
2026-03-08 19:30:05 +09:00
. fs - select { width : 100 % ; background : rgba ( 2 , 6 , 23 , 0.8 ); border : 1 px 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.5 rem 1 rem ; border - radius : 0.75 rem ; font - size : 0.75 rem ; font - weight : 900 ; cursor : pointer ; transition : all 0.2 s ; 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.375 rem 0 ; border - bottom : 1 px 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; }
2026-03-09 09:38:27 +09:00
. fs - ctx - menu { position : fixed ; display : none ; z - index : 10000 ; background : rgba ( 15 , 23 , 42 , 0.95 ); border : 1 px 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.5 rem ; width : 100 % ; padding : 0.5 rem 0.75 rem ; 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 : 1 px ; background : #334155; margin: 0.25rem 0; }
. fs - iso - badge { position : absolute ; top : 12 px ; left : 12 px ; display : none ; padding : 6 px 14 px ; background : rgba ( 59 , 130 , 246 , 0.9 ); color : white ; font - size : 0.75 rem ; font - weight : 900 ; border - radius : 0.5 rem ; z - index : 100 ; cursor : pointer ; box - shadow : 0 4 px 12 px rgba ( 59 , 130 , 246 , 0.4 ); }
. fs - iso - badge : hover { background : rgba ( 37 , 99 , 235 , 1 ); }
2026-03-08 19:30:05 +09:00
</ style >
< div class = " fs-wrap " >
2026-03-08 20:17:05 +09:00
< main style = " max-width:1800px; margin:0 auto; padding:1rem 1.5rem; " >
2026-03-09 11:05:52 +09:00
<!-- ========== 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() " >
2026-03-15 18:21:46 +09:00
< option value = " steel " selected > 철재스라트 </ option >
< option value = " screen " > 스크린형 </ option >
2026-03-09 11:05:52 +09:00
</ 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() " >
2026-03-15 17:31:01 +09:00
< optgroup label = " 철재스라트 " >
2026-03-15 18:21:46 +09:00
< option value = " KFS01 " selected > KFS01 - 기본 </ option >
2026-03-09 11:05:52 +09:00
< option value = " KFS02 " > KFS02 - 대형 </ option >
</ optgroup >
< optgroup label = " 스크린형 " >
2026-03-15 18:21:46 +09:00
< option value = " KSS01 " > KSS01 - 실리카 </ option >
2026-03-09 11:05:52 +09:00
< option value = " KSS02 " > KSS02 - 와이어 </ option >
</ optgroup >
</ select >
</ div >
2026-03-09 11:38:53 +09:00
< div style = " flex:0 0 90px; " >
< label class = " fs-label " > 오픈폭 </ label >
2026-03-09 11:05:52 +09:00
< input type = " number " id = " openWidth " class = " fs-input " style = " font-size:0.75rem;padding:0.375rem 0.5rem; " value = " 2000 " onchange = " fsCalc() " >
</ div >
2026-03-09 11:38:53 +09:00
< div style = " flex:0 0 90px; " >
< label class = " fs-label " > 오픈H </ label >
2026-03-09 11:05:52 +09:00
< input type = " number " id = " openHeight " class = " fs-input " style = " font-size:0.75rem;padding:0.375rem 0.5rem; " value = " 3000 " onchange = " fsCalc() " >
</ div >
2026-03-09 11:38:53 +09:00
< div style = " flex:0 0 60px; " >
2026-03-09 11:05:52 +09:00
< label class = " fs-label " > 수량 </ label >
< input type = " number " id = " quantity " class = " fs-input " style = " font-size:0.75rem;padding:0.375rem 0.5rem; " value = " 1 " min = " 1 " onchange = " fsCalc() " >
</ div >
< div class = " flex-1 " ></ div >
< div class = " flex items-center gap-3 text-[11px] text-slate-400 shrink-0 " >
< span > W1 : < b id = " calcW1 " class = " text-white " > 2110 </ b ></ span >
< span > H1 : < b id = " calcH1 " class = " text-white " > 3350 </ b ></ span >
< span > 면적 : < b id = " calcArea " class = " text-white " > 7.07 </ b > m² </ span >
< span > 중량 : < b id = " calcWeight " class = " text-white " > 176.7 </ b > kg </ span >
< span > 모터 : < b id = " calcMotor " class = " text-blue-400 " > 300 K </ b ></ span >
2026-03-10 01:28:07 +09:00
< span > 레일 : < b id = " calcRailCombo " class = " text-amber-400 " > 3 , 150 mm × 2 </ b ></ span >
2026-03-09 11:05:52 +09:00
</ 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 >
2026-03-08 19:30:05 +09:00
</ div >
2026-03-09 11:05:52 +09:00
< 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 >
2026-03-08 19:30:05 +09:00
</ div >
2026-03-09 10:27:47 +09:00
</ div >
</ div >
2026-03-09 11:05:52 +09:00
</ div >
</ div >
2026-03-09 10:27:47 +09:00
2026-03-09 11:05:52 +09:00
<!-- ========== 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; " >
2026-03-09 10:27:47 +09:00
<!-- Tab Buttons ( 1 x3 ) -->
< 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 >
3 D 렌더링
</ button >
2026-03-08 19:30:05 +09:00
</ 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 >
2026-03-09 16:58:05 +09:00
< input type = " number " id = " grWidth " class = " fs-input " value = " 120 " step = " 0.1 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 레일 깊이 ( mm ) </ label >
2026-03-09 16:58:05 +09:00
< input type = " number " id = " grDepth " class = " fs-input " value = " 70 " step = " 0.1 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 강판 두께 ( mm ) </ label >
2026-03-08 20:40:35 +09:00
< input type = " number " id = " grThickness " class = " fs-input " value = " 1.55 " step = " 0.01 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 립 ( 입구 ) 높이 ( mm ) </ label >
2026-03-09 16:50:09 +09:00
< input type = " number " id = " grLip " class = " fs-input " value = " 10 " step = " 0.1 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 연기차단재 두께 ( mm ) </ label >
2026-03-08 20:40:35 +09:00
< input type = " number " id = " grSealThick " class = " fs-input " value = " 0.8 " step = " 0.1 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ 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 >
2026-03-09 16:50:09 +09:00
< input type = " number " id = " grSlatThick " class = " fs-input " value = " 0.8 " step = " 0.1 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
2026-03-15 18:48:04 +09:00
< div id = " grAnchorSpacingWrap " >
2026-03-08 19:30:05 +09:00
< label class = " fs-label " > 앵커볼트 간격 ( mm ) </ label >
< input type = " number " id = " grAnchorSpacing " class = " fs-input " value = " 500 " step = " 10 " onchange = " fsRender() " >
</ div >
</ div >
</ section >
2026-03-08 20:17:05 +09:00
< section class = " fs-section space-y-3 mt-4 " >
2026-03-08 19:30:05 +09:00
< h2 class = " text-sm font-black text-slate-400 " > 뷰 옵션 </ h2 >
< div class = " flex flex-wrap gap-2 " >
2026-03-08 20:40:35 +09:00
< button class = " fs-btn fs-btn-ghost active " data - grview = " cross " onclick = " fsGrView('cross') " > 평면도 </ button >
2026-03-08 19:30:05 +09:00
< 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 >
2026-03-08 20:48:12 +09:00
< input type = " number " id = " sbWidth " class = " fs-input " value = " 2210 " step = " 10 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 케이스 높이 ( mm ) </ label >
2026-03-09 16:50:09 +09:00
< input type = " number " id = " sbHeight " class = " fs-input " value = " 380 " step = " 10 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 케이스 깊이 ( mm ) </ label >
2026-03-09 16:50:09 +09:00
< input type = " number " id = " sbDepth " class = " fs-input " value = " 500 " step = " 10 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ 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 >
2026-03-09 16:50:09 +09:00
< input type = " number " id = " sbShaftDia " class = " fs-input " value = " 80 " step = " 5 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
< div >
< label class = " fs-label " > 브래킷 폭 ( mm ) </ label >
2026-03-08 20:48:12 +09:00
< input type = " number " id = " sbBracketW " class = " fs-input " value = " 10 " step = " 1 " onchange = " fsRender() " >
2026-03-08 19:30:05 +09:00
</ div >
</ div >
2026-03-15 16:53:44 +09:00
< div class = " grid grid-cols-2 gap-4 " >
< div >
< label class = " fs-label " > 레일폭 ( mm ) </ label >
< input type = " number " id = " sbRailWidth " class = " fs-input " value = " 70 " step = " 5 " onchange = " fsRender() " >
</ div >
< div >
< label class = " fs-label " > 전면 밑치수 ( mm ) </ label >
< input type = " number " id = " sbFrontBottom " class = " fs-input " value = " 50 " step = " 5 " onchange = " fsRender() " >
</ div >
</ div >
2026-03-08 19:30:05 +09:00
< div >
< label class = " fs-label " > 모터 위치 </ label >
< select id = " sbMotorSide " class = " fs-select " onchange = " fsRender() " >
< option value = " right " > 우측 </ option >
< option value = " left " > 좌측 </ option >
</ select >
</ div >
2026-03-15 14:47:43 +09:00
< div >
< label class = " fs-label " > 점검구 방향 </ label >
2026-03-15 16:36:41 +09:00
< select id = " sbDoorDir " class = " fs-select " onchange = " fsSetDoorDir(this.value) " >
2026-03-15 14:47:43 +09:00
< option value = " dual " selected > 양면 ( 밑면 + 후면 ) </ option >
< option value = " bottom " > 밑면 </ option >
< option value = " rear " > 후면 </ option >
</ select >
</ div >
2026-03-08 19:30:05 +09:00
</ section >
2026-03-08 20:17:05 +09:00
< section class = " fs-section space-y-3 mt-4 " >
2026-03-08 19:30:05 +09:00
< h2 class = " text-sm font-black text-slate-400 " > 뷰 옵션 </ h2 >
< div class = " flex flex-wrap gap-2 " >
2026-03-13 21:53:19 +09:00
< button class = " fs-btn fs-btn-ghost " data - sbview = " front " onclick = " fsSbView('front') " > 정면도 </ button >
< button class = " fs-btn fs-btn-ghost active " data - sbview = " side " onclick = " fsSbView('side') " > 측면도 </ button >
2026-03-08 19:30:05 +09:00
</ 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 >
<!-- ========== 3 D TAB CONTROLS ========== -->
< div id = " ctrl3D " class = " hidden " >
2026-03-08 20:02:42 +09:00
< section class = " fs-section " style = " padding:0.75rem 1rem; " >
2026-03-15 17:34:10 +09:00
<!-- 셔터박스 크기 -->
< div class = " grid gap-1 mb-1 " style = " grid-template-columns:1fr 1fr 1fr; " >
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 깊이 </ label >
< input type = " number " id = " td3dDepth " class = " fs-input " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " value = " 500 " step = " 10 " onchange = " fsSet3dParam('depth',this.value) " >
</ div >
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 높이 </ label >
< input type = " number " id = " td3dHeight " class = " fs-input " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " value = " 380 " step = " 10 " onchange = " fsSet3dParam('height',this.value) " >
</ div >
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 샤프트⌀ </ label >
< input type = " number " id = " td3dShaftDia " class = " fs-input " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " value = " 102 " step = " 1 " onchange = " fsSet3dParam('shaftDia',this.value) " >
</ div >
</ div >
<!-- 가변 파라미터 -->
< div class = " grid gap-1 mb-2 " style = " grid-template-columns:1fr 1fr 1fr 1fr; " >
2026-03-15 17:23:01 +09:00
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 점검구 </ label >
< select id = " td3dDoorDir " class = " fs-select " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " onchange = " fsSetDoorDir(this.value) " >
< option value = " dual " > 양면 </ option >
< option value = " bottom " > 밑면 </ option >
< option value = " rear " > 후면 </ option >
</ select >
</ div >
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 레일폭 </ label >
< input type = " number " id = " td3dRailWidth " class = " fs-input " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " value = " 70 " step = " 5 " onchange = " fsSet3dParam('railWidth',this.value) " >
</ div >
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 전면밑 </ label >
< input type = " number " id = " td3dFrontBottom " class = " fs-input " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " value = " 50 " step = " 5 " onchange = " fsSet3dParam('frontBottom',this.value) " >
</ div >
2026-03-15 17:34:10 +09:00
< div >
< label class = " text-[9px] text-slate-500 font-bold " > 두께 </ label >
< input type = " number " id = " td3dThickness " class = " fs-input " style = " font-size:0.6rem;padding:0.2rem 0.3rem; " value = " 1.6 " step = " 0.1 " onchange = " fsSet3dParam('thickness',this.value) " >
</ div >
2026-03-15 16:33:19 +09:00
</ div >
2026-03-08 20:02:42 +09:00
<!-- 개폐율 : 한 행 -->
< div class = " flex items-center gap-2 mb-1 " >
< label class = " text-[10px] text-slate-500 font-bold shrink-0 " style = " width:24px; " > 개폐 </ label >
2026-03-15 17:51:21 +09:00
< input type = " range " id = " shutterPos " min = " 0 " max = " 100 " value = " 50 " class = " flex-1 accent-blue-500 " style = " height:3px; " oninput = " fs3dShutterPos(this.value) " >
< span id = " shutterPosLabel " class = " text-[10px] text-blue-400 font-black shrink-0 " style = " width:28px;text-align:right; " > 50 %</ span >
2026-03-08 19:30:05 +09:00
</ div >
2026-03-08 20:02:42 +09:00
<!-- 투명도 : 한 행 -->
< 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 >
2026-03-08 19:30:05 +09:00
</ div >
2026-03-08 20:02:42 +09:00
<!-- 부품 : 4 열 compact grid -->
< div class = " grid gap-x-0.5 gap-y-0.5 mb-2 " style = " grid-template-columns:repeat(4,auto); " >
2026-03-14 18:17:49 +09:00
< 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 >
2026-03-08 20:02:42 +09:00
< 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 >
2026-03-09 16:37:37 +09:00
< 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 >
2026-03-08 19:30:05 +09:00
</ div >
2026-03-08 20:02:42 +09:00
<!-- 조명색 + 배경색 : 한 행 -->
< 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 >
2026-03-08 19:30:05 +09:00
</ div >
</ div >
</ section >
2026-03-08 21:33:46 +09:00
2026-03-09 16:39:00 +09:00
<!-- 벽체 설정 ( 벽 토글 OFF 시 숨김 ) -->
< section id = " wallSettings " class = " fs-section mt-2 hidden " style = " padding:0.75rem 1rem; " >
2026-03-08 21:33:46 +09:00
< 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 >
2026-03-09 11:33:12 +09:00
< 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 >
2026-03-08 21:33:46 +09:00
</ 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 >
2026-03-09 11:33:12 +09:00
< 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 >
2026-03-08 21:33:46 +09:00
</ 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 >
2026-03-08 19:30:05 +09:00
</ div >
</ div >
2026-03-08 20:17:05 +09:00
<!-- 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%; " >
2026-03-08 19:30:05 +09:00
<!-- 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 -->
2026-03-08 20:17:05 +09:00
< div id = " canvasViewport " class = " flex-1 bg-[#050507] relative overflow-hidden cursor-grab active:cursor-grabbing " >
2026-03-08 19:30:05 +09:00
< 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 >
2026-03-13 20:53:47 +09:00
< script src = " https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js " ></ script >
2026-03-08 19:30:05 +09:00
@ push ( 'scripts' )
< script >
( function (){
'use strict' ;
const $ = id => document . getElementById ( id );
// ============================
// STATE
// ============================
const S = {
2026-03-15 18:21:46 +09:00
tab : 'GuideRail' ,
productType : 'steel' ,
2026-03-08 19:30:05 +09:00
openWidth : 2000 ,
openHeight : 3000 ,
quantity : 1 ,
// Guide Rail
2026-03-11 19:58:41 +09:00
gr : { width : 70 , depth : 120 , thickness : 1.55 , lip : 10 , flange : 30 , sideWall : 80 , backWall : 67 , trimThick : 1.2 , sealThick : 0.8 , sealDepth : 40 , slatThick : 0.8 , anchorSpacing : 500 , viewMode : 'cross' , showDim : true , showSeal : true },
2026-03-08 19:30:05 +09:00
// Shutter Box
2026-03-15 16:53:44 +09:00
sb : { width : 2280 , height : 380 , depth : 500 , thickness : 1.6 , shaftDia : 102 , railWidth : 70 , frontBottom : 50 , bracketW : 10 , motorSide : 'right' , doorDir : 'dual' , viewMode : 'side' , showShaft : true , showSlatRoll : true , showMotor : true , showBrake : true , showSpring : true },
2026-03-08 19:30:05 +09:00
// 3D
2026-03-15 17:51:21 +09:00
td : { shutterPos : 50 , caseOpacity : 0.3 , lightPreset : 'default' , bgColor : '#ffffff' , show : { case : true , shaft : true , motor : true , rails : true , slats : true , bottomBar : true , wall : false , slatRoll : true } },
2026-03-08 21:33:46 +09:00
// Wall (벽체)
2026-03-09 11:33:12 +09:00
wall : { wing : 600 , thick : 600 , topMargin : 300 , color : '#a1887f' , opacity : 30 },
2026-03-08 19:30:05 +09:00
// 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 = {
2026-03-15 19:20:42 +09:00
steel : { marginW : 110 , marginH : 350 , weightFactor : 25 , gr : { width : 75 , depth : 130 , thickness : 1.55 , lip : 15 }, slatThick : 1.6 ,
2026-03-09 15:30:27 +09:00
sb : { height : 550 , depth : 650 , frontH : 410 , bottomOpen : 75 , shaftDia : 120 },
2026-03-09 15:33:04 +09:00
bk : { nmH : 320 , nmD : 320 , mtH : 320 , mtD : 530 , thick : 18 , sprocketR : 215 , motorSpR : 40 , motorOffset : 160 } },
2026-03-09 13:34:35 +09:00
screen : { marginW : 140 , marginH : 350 , weightFactor : 2 ,
2026-03-11 19:58:41 +09:00
gr : { width : 70 , depth : 120 , thickness : 1.55 , lip : 10 , flange : 30 , sideWall : 80 , backWall : 67 , trimThick : 1.2 },
2026-03-09 13:34:35 +09:00
slatThick : 0.8 ,
2026-03-15 15:59:01 +09:00
sb : { height : 380 , depth : 500 , frontH : 240 , rearDoorH : 240 , bottomDoorW : 240 , shaftDia : 102 , // 4인치 외경 (101.6mm)
2026-03-15 10:21:47 +09:00
frontPanel : [ 17 , 55 , 50 , 380 , 55 , 15 , 20 ]}, // 케이스 전면판 절곡 프로파일 (세그먼트 1~7, 합계588)
2026-03-09 15:30:27 +09:00
bk : { nmH : 180 , nmD : 180 , mtH : 180 , mtD : 380 , thick : 18 , sprocketR : 70 , motorSpR : 30 , motorOffset : 0 } },
2026-03-08 19:30:05 +09:00
};
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 = {};
2026-03-09 09:38:27 +09:00
let fs3dIsolated = null ; // 현재 단품 보기 중인 키
2026-03-13 20:53:47 +09:00
let transformCtrl = null ;
let selectedKey = null ;
let selectionBox = null ;
2026-03-13 21:05:24 +09:00
const hiddenKeys = new Set (); // H키로 감춘 요소 추적
2026-03-09 09:38:27 +09:00
const meshLabels = {
case : '셔터박스' , shaft : '샤프트 ASSY' , motor : '모터/체인' ,
rails : '가이드레일' , slats : '슬랫 커튼' , slatRoll : '감긴 슬랫' ,
bottomBar : '하부바' , wall : '벽체' ,
};
2026-03-08 19:30:05 +09:00
// ============================
// TAB SWITCHING
// ============================
window . fsSwitch = function ( tab ) {
S . tab = tab ;
2026-03-09 10:27:47 +09:00
const tabs = [ 'GuideRail' , 'ShutterBox' , '3D' ];
const tabBtnIds = [ 'tabGuideRail' , 'tabShutterBox' , 'tab3D' ];
const ctrlIds = [ 'ctrlGuideRail' , 'ctrlShutterBox' , 'ctrl3D' ];
2026-03-08 19:30:05 +09:00
tabs . forEach (( t , i ) => {
const btn = $ ( tabBtnIds [ i ]);
const ctrl = $ ( ctrlIds [ i ]);
if ( t === tab ) {
2026-03-08 20:17:05 +09:00
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' ;
2026-03-08 19:30:05 +09:00
if ( ctrl ) ctrl . classList . remove ( 'hidden' );
} else {
2026-03-08 20:17:05 +09:00
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' ;
2026-03-08 19:30:05 +09:00
if ( ctrl ) ctrl . classList . add ( 'hidden' );
}
});
const svgC = $ ( 'svgContainer' );
const tdC = $ ( 'threeDContainer' );
if ( tab === '3D' ) {
svgC . classList . add ( 'hidden' );
tdC . classList . remove ( 'hidden' );
fs3dInit ();
2026-03-15 16:33:19 +09:00
if ( $ ( 'td3dDoorDir' )) $ ( 'td3dDoorDir' ) . value = S . sb . doorDir ;
2026-03-15 17:34:10 +09:00
if ( $ ( 'td3dDepth' )) $ ( 'td3dDepth' ) . value = S . sb . depth ;
if ( $ ( 'td3dHeight' )) $ ( 'td3dHeight' ) . value = S . sb . height ;
if ( $ ( 'td3dShaftDia' )) $ ( 'td3dShaftDia' ) . value = S . sb . shaftDia ;
2026-03-15 17:23:01 +09:00
if ( $ ( 'td3dRailWidth' )) $ ( 'td3dRailWidth' ) . value = S . sb . railWidth ;
if ( $ ( 'td3dFrontBottom' )) $ ( 'td3dFrontBottom' ) . value = S . sb . frontBottom ;
2026-03-15 17:34:10 +09:00
if ( $ ( 'td3dThickness' )) $ ( 'td3dThickness' ) . value = S . sb . thickness ;
2026-03-08 19:30:05 +09:00
fs3dBuild ();
} else {
svgC . classList . remove ( 'hidden' );
tdC . classList . add ( 'hidden' );
fsRender ();
}
};
// ============================
// CALCULATIONS
// ============================
window . fsCalc = function () {
2026-03-09 16:33:11 +09:00
undoSaveState (); // Ctrl+Z용 스냅샷 저장
2026-03-08 19:30:05 +09:00
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 ];
2026-03-10 07:32:55 +09:00
// Guide rail height = 오픈H + 100mm (바닥~셔터박스 하단 결합부)
const guideRailH = S . openHeight + 100 ;
2026-03-10 01:28:07 +09:00
const bestCombo = `${guideRailH.toLocaleString()}mm × 2` ;
2026-03-08 19:30:05 +09:00
2026-03-09 11:05:52 +09:00
$ ( 'calcW1' ) . textContent = W1 . toLocaleString ();
$ ( 'calcH1' ) . textContent = H1 . toLocaleString ();
$ ( 'calcArea' ) . textContent = area . toFixed ( 2 );
$ ( 'calcWeight' ) . textContent = weight . toFixed ( 1 );
$ ( 'calcMotor' ) . textContent = motor . spec ;
2026-03-08 19:30:05 +09:00
$ ( 'calcRailCombo' ) . textContent = bestCombo ;
// Auto-update box width
$ ( 'sbWidth' ) . value = W1 ;
S . sb . width = W1 ;
2026-03-09 11:09:35 +09:00
// 3D 탭 활성 시 자동 갱신
if ( S . tab === '3D' ) fs3dBuild ();
2026-03-08 19:30:05 +09:00
};
window . fsOnProductType = function () {
2026-03-09 16:33:11 +09:00
undoSaveState (); // Ctrl+Z용 스냅샷 저장
2026-03-08 19:30:05 +09:00
const type = $ ( 'productType' ) . value ;
S . productType = type ;
const p = PRODUCTS [ type ];
2026-03-15 18:48:04 +09:00
// 철재스라트: 앵커볼트 간격 숨김 (스크린 전용)
if ( $ ( 'grAnchorSpacingWrap' )) $ ( 'grAnchorSpacingWrap' ) . style . display = type === 'steel' ? 'none' : '' ;
2026-03-08 19:30:05 +09:00
// 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 ;
2026-03-09 13:34:35 +09:00
// 스크린형 전용 속성
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 ;
2026-03-09 10:06:37 +09:00
// 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 ;
2026-03-15 17:31:01 +09:00
S . sb . railWidth = S . productType === 'screen' ? 70 : 75 ; $ ( 'sbRailWidth' ) . value = S . sb . railWidth ;
2026-03-15 16:53:44 +09:00
S . sb . frontBottom = 50 ; $ ( 'sbFrontBottom' ) . value = S . sb . frontBottom ;
2026-03-08 19:30:05 +09:00
// 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 ();
2026-03-15 18:22:33 +09:00
if ( S . tab === '3D' ) fs3dBuild ();
else fsRender ();
2026-03-08 19:30:05 +09:00
};
2026-03-11 15:32:45 +09:00
window . fsOnModelChange = function () { fsCalc (); fsRender (); };
2026-03-08 19:30:05 +09:00
2026-03-09 16:33:11 +09:00
// ============================
// 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 ;
2026-03-15 16:53:44 +09:00
$ ( 'sbRailWidth' ) . value = S . sb . railWidth ;
$ ( 'sbFrontBottom' ) . value = S . sb . frontBottom ;
2026-03-09 16:33:11 +09:00
$ ( 'sbMotorSide' ) . value = S . sb . motorSide ;
2026-03-15 14:47:43 +09:00
$ ( 'sbDoorDir' ) . value = S . sb . doorDir ;
2026-03-09 16:33:11 +09:00
// 재계산 + 렌더링
fsCalc ();
if ( S . tab === '3D' ) fs3dBuild ();
else fsRender ();
}
document . addEventListener ( 'keydown' , function ( e ) {
if ( e . ctrlKey && e . key === 'z' && ! e . shiftKey ) {
e . preventDefault ();
undoRestore ();
}
});
2026-03-08 19:30:05 +09:00
// ============================
// 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 );
2026-03-15 16:53:44 +09:00
S . sb . railWidth = Number ( $ ( 'sbRailWidth' ) . value );
S . sb . frontBottom = Number ( $ ( 'sbFrontBottom' ) . value );
2026-03-08 19:30:05 +09:00
S . sb . motorSide = $ ( 'sbMotorSide' ) . value ;
2026-03-15 14:47:43 +09:00
S . sb . doorDir = $ ( 'sbDoorDir' ) . value ;
2026-03-08 19:30:05 +09:00
}
2026-03-08 20:23:48 +09:00
// ============================
// 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)}%` ;
}
2026-03-08 19:30:05 +09:00
// ============================
// RENDER DISPATCHER
// ============================
window . fsRender = function () {
2026-03-09 16:33:11 +09:00
undoSaveState (); // Ctrl+Z용 스냅샷 저장
2026-03-08 19:30:05 +09:00
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 " />
2026-03-08 20:30:57 +09:00
< 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 >
2026-03-08 19:30:05 +09:00
<!-- 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 > ` ;
2026-03-08 20:23:48 +09:00
displaySvg ( svg );
2026-03-08 19:30:05 +09:00
}
// ============================
// GUIDE RAIL SVG RENDERER
// ============================
function renderGuideRail () {
if ( S . gr . viewMode === 'cross' ) renderGrCross ();
else renderGrFront ();
}
function renderGrCross () {
const g = S . gr ;
2026-03-09 17:27:50 +09:00
2026-03-11 10:01:51 +09:00
// ====== 스크린형: 실제 조립 구조 평면도 (4개 부재) ======
2026-03-11 15:53:08 +09:00
// ① 마감재 SUS 1.2T × 2장 (상하대칭, 빨간색) — ② 위를 덮씌우는 외피
// ② 가이드레일 EGI 1.55T × 1개 (ㄷ자, 파란색) — C채널 기초 구조
2026-03-15 18:58:14 +09:00
// ③ C형 — ④ D형/벽연형 (③ 내부 보강)
2026-03-15 18:34:15 +09:00
// 세로 = 채널 폭 (스크린:70mm, 철재:75mm)
2026-03-15 18:58:14 +09:00
if ( S . productType === 'screen' ) {
2026-03-11 17:58:41 +09:00
const sc = 3 ; // px per mm (두께 비율 개선)
2026-03-11 15:53:08 +09:00
const t2 = g . thickness * sc ; // ② 가이드레일 EGI 1.55T
const t1 = ( g . trimThick || 1.2 ) * sc ; // ① 마감재 SUS 1.2T
2026-03-09 17:27:50 +09:00
const sealT = g . sealThick * sc ;
const slatT = Math . max ( g . slatThick * sc , 2 );
2026-03-12 07:34:25 +09:00
// ── ② 가이드레일 EGI (ㄷ자 1개, 절곡: lip10-flange30-sw80-bw67-sw80-flange30-lip10) ──
2026-03-10 08:18:55 +09:00
const bLip = g . lip * sc ; // 10mm 립
2026-03-12 07:34:25 +09:00
const bFl = g . flange * sc ; // 30mm 플랜지
2026-03-10 08:18:55 +09:00
const bSw = g . sideWall * sc ; // 80mm 사이드월
const bBw = g . backWall * sc ; // 67mm 백월
2026-03-12 07:34:25 +09:00
const bOuterW = g . width * sc ; // 70mm 외폭 (명시된 폭 사용, 부동소수점 방지)
const bSlot = bOuterW - 2 * bFl ; // 슬롯 개구 = 70-2*30 = 10mm
2026-03-10 08:18:55 +09:00
// ── ③ 벽연형-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 ;
2026-03-13 08:59:34 +09:00
// ── ① 마감재 SUS 치수 (2장 대칭, ② 위 덮씌우기, 절곡: 10-11-110-30-15-15-15) ──
2026-03-11 17:58:41 +09:00
const m1a = 10 * sc ; // 코킹립 (벽쪽, 수평)
const m1b = 11 * sc ; // 측면탭 (수직)
2026-03-13 08:45:09 +09:00
const m1c = 110 * sc ; // 수평면 (메인 커버, 코킹립10 포함 시 전체 120mm)
2026-03-13 08:59:34 +09:00
const m1d = 30 * sc ; // 우측 수직벽 (표면→바닥, 중앙 방향)
const m1e = 15 * sc ; // 하단 수평탭 (개구부 방향, 우측)
const m1f = 15 * sc ; // 복귀벽 (하단탭→상단탭, 외측 방향)
const m1g = 15 * sc ; // 상단 수평탭 (개구부 방향, 우측)
2026-03-10 08:18:55 +09:00
// ── 레이아웃 (좌→우 배치) ──
const pad = 80 , wallW = 35 ;
2026-03-11 11:20:12 +09:00
// ③ 벽연형-C 몸체가 방화벽에 직접 맞닿음 → ③ 립(30mm)이 본체 백월까지 연장
const bkTotalD = c3Lip ; // ③ 립 = 벽→본체 백월 깊이
const bx = pad + wallW + bkTotalD ; // 본체 백월 외면 X (벽+③립, 갭 없음)
2026-03-10 08:18:55 +09:00
// 본체 사이드월 끝 (립 시작점)
const swEndX = bx + t2 + bSw ; // 백월두께 + 사이드월
// 립 끝
const lipEndX = swEndX + bLip ;
2026-03-11 19:36:09 +09:00
// ① 마감재 수평면 범위 (코킹립 끝 = 벽 내면, 측면탭 = 벽 내면 + 코킹립10mm)
const trimL1 = pad + wallW + m1a + 2 ; // 측면탭 X (코킹립이 벽 내면까지만 도달)
2026-03-11 18:01:50 +09:00
const trimR1 = lipEndX ; // 수평면 우측 끝 (C채널 립 끝에 맞춤)
2026-03-10 08:18:55 +09:00
const by = pad ; // 본체 상단 Y
2026-03-11 18:01:50 +09:00
const svgW = lipEndX + pad + 160 ;
2026-03-11 10:01:51 +09:00
const svgH = bOuterW + pad * 2 + 100 ;
// ── 색상 정의 (부재별 명확 구분) ──
2026-03-11 15:53:08 +09:00
const cTrim = '#ef4444' ; // ① 마감재 SUS — 빨간색
const cBody = '#3b82f6' ; // ② 가이드레일 EGI — 파란색
const cBk3 = '#22c55e' ; // ③ C형 — 초록색
const cBk4 = '#f97316' ; // ④ D형 — 주황색
2026-03-10 08:18:55 +09:00
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"/>` ;
2026-03-11 15:53:08 +09:00
// ── ② 가이드레일 C채널 EGI (ㄷ자 1개, 절곡: lip-flange-sideWall-backWall-sideWall-flange-lip) ──
2026-03-09 18:58:49 +09:00
const bodySvg = [
2026-03-10 08:18:55 +09:00
// 백월 (수직, 좌측)
`<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}"/>` ,
2026-03-10 01:40:54 +09:00
// 하단 사이드월
2026-03-10 08:18:55 +09:00
`<rect x="${bx}" y="${by+bOuterW-t2}" width="${t2+bSw}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-11 10:35:26 +09:00
// 상단 립 (사이드월 끝에서 바깥Y 유지, 개구부 방향 10mm 연장, 'ㄱ' 수평부)
`<rect x="${swEndX}" y="${by}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-11 10:23:10 +09:00
// 하단 립 (거울상)
2026-03-11 10:35:26 +09:00
`<rect x="${swEndX}" y="${by+bOuterW-t2}" width="${bLip}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-13 09:11:47 +09:00
// 상단 플랜지 (립 우측 끝에서 안쪽/중앙으로 절곡, 'ㄱ' 수직부)
2026-03-11 10:35:26 +09:00
`<rect x="${lipEndX-t2}" y="${by+t2}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-13 09:11:47 +09:00
// 상단 플랜지 끝 10mm 절곡 (플랜지 내측 끝에서 좌측으로, 채널 안쪽)
`<rect x="${lipEndX-t2-bLip}" y="${by+bFl-t2}" width="${bLip+t2}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-11 10:35:26 +09:00
// 하단 플랜지 (거울상)
`<rect x="${lipEndX-t2}" y="${by+bOuterW-bFl}" width="${t2}" height="${bFl-t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-13 09:11:47 +09:00
// 하단 플랜지 끝 10mm 절곡 (플랜지 내측 끝에서 좌측으로, 채널 안쪽)
`<rect x="${lipEndX-t2-bLip}" y="${by+bOuterW-bFl}" width="${bLip+t2}" height="${t2}" fill="${cBody}" stroke="${ms}" stroke-width="${mw}"/>` ,
2026-03-09 18:58:49 +09:00
] . join ( '\n' );
2026-03-09 17:58:47 +09:00
2026-03-11 15:53:08 +09:00
// ── ③ C형 (절곡: 30-45-30, 백월 뒤 C브라켓) ──
// C 개구부가 ② 가이드레일쪽(우측)을 향함: 몸체(벽쪽) → 립(가이드레일쪽)
2026-03-10 08:18:55 +09:00
const c3CenterY = by + bOuterW / 2 ;
2026-03-10 08:46:46 +09:00
const c3Y = c3CenterY - c3Body / 2 ;
const c3BodyX = bx - c3Lip ; // 몸체 X (벽쪽, 좌측 끝)
const c3LipEnd = bx ; // 립 끝 = ② 백월 외면
2026-03-10 08:18:55 +09:00
const bk3Svg = [
2026-03-10 08:46:46 +09:00
// 몸체 (세로 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}"/>` ,
2026-03-10 08:18:55 +09:00
] . join ( '\n' );
2026-03-09 17:58:47 +09:00
2026-03-11 15:53:08 +09:00
// ── ④ D형 (절곡: 11-23-40-23-11, ③ 내부에 중첩) ──
2026-03-11 16:11:10 +09:00
// ③과 마주보는 형태: ③은 우측(②쪽)으로 열리고, ④는 좌측(벽쪽)으로 열림
// 몸체(40) ②쪽(우측), 사이드(23) 벽 방향(좌측), 립(11) 안쪽 절곡
2026-03-10 08:18:55 +09:00
const c4CenterY = c3CenterY ;
2026-03-10 08:46:46 +09:00
const c4Y = c4CenterY - c4c / 2 ;
2026-03-11 16:11:10 +09:00
const c4BodyX = bx - t2 - 2 ; // 몸체: ② 백월 바로 좌측 (우측 배치)
const c4SideStartX = c4BodyX - c4b ; // 사이드 좌측 끝 (벽 방향)
2026-03-10 08:18:55 +09:00
const bk4Svg = [
2026-03-11 16:11:10 +09:00
// 몸체 (세로 40mm, 우측 = ② 백월쪽)
2026-03-10 08:46:46 +09:00
`<rect x="${c4BodyX}" y="${c4Y}" width="${t2}" height="${c4c}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>` ,
2026-03-11 16:11:10 +09:00
// 상단 사이드 (23mm, 벽 방향 = 좌측)
`<rect x="${c4SideStartX}" y="${c4Y}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>` ,
2026-03-10 08:46:46 +09:00
// 하단 사이드
2026-03-11 16:11:10 +09:00
`<rect x="${c4SideStartX}" y="${c4Y+c4c-t2}" width="${c4b}" height="${t2}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>` ,
// 상단 립 (11mm, 사이드 좌측 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideStartX}" y="${c4Y+t2}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>` ,
// 하단 립 (11mm, 사이드 좌측 끝에서 안쪽/중앙으로 절곡)
`<rect x="${c4SideStartX}" y="${c4Y+c4c-t2-c4a}" width="${t2}" height="${c4a}" fill="${cBk4}" stroke="${ms}" stroke-width="${mw}" opacity="0.8"/>` ,
2026-03-10 08:18:55 +09:00
] . join ( '\n' );
2026-03-09 17:58:47 +09:00
2026-03-13 08:59:34 +09:00
// ── ① 마감재 SUS 1.2T × 2장 (② 바깥을 감싸는 외피, 절곡: 10-11-110-30-15-15-15, 상하 대칭) ──
2026-03-11 17:58:41 +09:00
// 좌측: 코킹립(10, 벽쪽 수평) + 측면탭(11, 수직) → 방화벽과 'ㄴ'자 립 결합
2026-03-13 08:59:34 +09:00
// 우측: 30(수직,중앙↓) → 15(수평,우측→) → 15(수직,외측↑) → 15(수평,우측→) — J-hook 접힘 (총높이 30mm)
2026-03-11 18:01:50 +09:00
const trimX2 = lipEndX ; // 치수선용 우측 끝
2026-03-11 15:53:08 +09:00
2026-03-11 17:58:41 +09:00
// ①는 ② 바깥을 감싸는 구조
2026-03-11 16:21:45 +09:00
const tTop = by - t1 ; // 상단 ① 수평면 Y (② 상면 바깥)
const tBot = by + bOuterW ; // 하단 ① 수평면 Y (② 하면 바깥)
2026-03-11 15:53:08 +09:00
2026-03-11 17:58:41 +09:00
// 개구부쪽 접힘 좌표 (상단 기준)
const wrapX = trimR1 ; // 접힘 시작 X = 수평면 우측 끝
2026-03-11 15:53:08 +09:00
const trim1Svg = [
2026-03-11 16:21:45 +09:00
// ══════ 상단 ① 마감재 (② 상면 바깥을 감싸기) ══════
2026-03-11 19:36:09 +09:00
// 코킹립: 측면탭 하단에서 벽쪽(좌측) 방향 수평 10mm ('ㄴ'자 하단)
`<rect x="${trimL1-m1a}" y="${tTop+m1b}" width="${m1a}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
// 측면탭: 벽쪽 수직 11mm ('ㄴ'자 세로획)
2026-03-11 17:41:23 +09:00
`<rect x="${trimL1}" y="${tTop}" width="${t1}" height="${m1b+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-11 18:01:50 +09:00
// 수평면: 벽~립 (좌→우, 메인 커버)
`<rect x="${trimL1}" y="${tTop}" width="${trimR1-trimL1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-13 08:59:34 +09:00
// 개구부쪽 1단: 수직벽 30mm (수평면 우측 끝에서 아래로, 중앙 방향)
`<rect x="${wrapX-t1}" y="${tTop+t1}" width="${t1}" height="${m1d}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-13 09:06:48 +09:00
// 개구부쪽 2단: 하단 수평탭 15mm (수직벽 하단에서 좌측으로, 채널 안쪽)
`<rect x="${wrapX-t1-m1e}" y="${tTop+m1d}" width="${m1e+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
// 개구부쪽 3단: 복귀벽 15mm (하단탭 좌측 끝에서 위로, 외측 방향)
`<rect x="${wrapX-t1-m1e}" y="${tTop+m1d-m1f}" width="${t1}" height="${m1f+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-13 09:11:47 +09:00
// 개구부쪽 4단: 상단 수평탭 15mm (복귀벽 상단에서 좌측으로, 채널 더 안쪽 — 연기차단재 용접 공간)
`<rect x="${wrapX-t1-m1e-m1g}" y="${tTop+m1d-m1f}" width="${m1g+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-11 16:21:45 +09:00
// ══════ 하단 ① 마감재 (② 하면 바깥을 감싸기, 거울상) ══════
2026-03-11 19:36:09 +09:00
// 코킹립: 측면탭 하단에서 벽쪽(좌측) 방향 수평 10mm ('ㄴ'자 하단)
`<rect x="${trimL1-m1a}" y="${tBot-m1b-t1}" width="${m1a}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-11 17:58:41 +09:00
// 측면탭
2026-03-11 17:41:23 +09:00
`<rect x="${trimL1}" y="${tBot-m1b}" width="${t1}" height="${m1b+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-11 17:58:41 +09:00
// 수평면
2026-03-11 18:01:50 +09:00
`<rect x="${trimL1}" y="${tBot}" width="${trimR1-trimL1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-13 08:59:34 +09:00
// 개구부쪽 1단: 수직벽 30mm (수평면 우측 끝에서 위로, 중앙 방향)
`<rect x="${wrapX-t1}" y="${tBot-m1d}" width="${t1}" height="${m1d}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-13 09:06:48 +09:00
// 개구부쪽 2단: 상단 수평탭 15mm (수직벽 상단에서 좌측으로, 채널 안쪽)
`<rect x="${wrapX-t1-m1e}" y="${tBot-m1d}" width="${m1e+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
// 개구부쪽 3단: 복귀벽 15mm (상단탭 좌측 끝에서 아래로, 외측 방향)
`<rect x="${wrapX-t1-m1e}" y="${tBot-m1d}" width="${t1}" height="${m1f+t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-13 09:11:47 +09:00
// 개구부쪽 4단: 하단 수평탭 15mm (복귀벽 하단에서 좌측으로, 채널 더 안쪽 — 연기차단재 용접 공간)
`<rect x="${wrapX-t1-m1e-m1g}" y="${tBot-m1d+m1f}" width="${m1g+t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` ,
2026-03-11 15:53:08 +09:00
] . join ( '\n' );
2026-03-10 08:49:38 +09:00
2026-03-11 10:01:51 +09:00
// ── 연기차단재 (현재 비활성 — 위치 재조정 후 활성화 예정) ──
2026-03-09 17:27:50 +09:00
let sealSvg = '' ;
2026-03-10 08:18:55 +09:00
// ── 슬랫 ──
const slatY = by + bOuterW / 2 - slatT / 2 ;
const slatX1 = bx + t2 + 2 ;
const slatX2 = lipEndX - 2 ;
// ── 방화벽 ──
const wallX = pad ;
2026-03-09 17:27:50 +09:00
2026-03-16 14:56:43 +09:00
// ── 각 부재별 절곡 치수 라벨 ──
const sdf = 'font-size="7" font-weight="700" font-family="Pretendard"' ;
let segDims = '' ;
// ② 가이드레일 (파란색)
segDims += `<text x="${bx-4}" y="${by+bOuterW/2+2}" fill="${cBody}" ${sdf} text-anchor="end">${g.width}</text>` ;
segDims += `<text x="${bx+t2+bSw/2}" y="${by-4}" fill="${cBody}" ${sdf} text-anchor="middle">${g.sideWall}</text>` ;
segDims += `<text x="${swEndX+bLip/2}" y="${by+t2+10}" fill="${cBody}" ${sdf} text-anchor="middle">${g.lip}</text>` ;
segDims += `<text x="${lipEndX+4}" y="${by+bFl/2+2}" fill="${cBody}" ${sdf} text-anchor="start">${g.flange}</text>` ;
segDims += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW+t2+9}" fill="${cBody}" ${sdf} text-anchor="middle">${g.sideWall}</text>` ;
segDims += `<text x="${lipEndX+4}" y="${by+bOuterW-bFl/2+2}" fill="${cBody}" ${sdf} text-anchor="start">${g.flange}</text>` ;
// ③ C형 (초록색)
segDims += `<text x="${c3BodyX-4}" y="${c3CenterY+2}" fill="${cBk3}" ${sdf} text-anchor="end">${Math.round(c3Body/sc)}</text>` ;
segDims += `<text x="${c3BodyX+c3Lip/2}" y="${c3Y-4}" fill="${cBk3}" ${sdf} text-anchor="middle">${Math.round(c3Lip/sc)}</text>` ;
segDims += `<text x="${c3BodyX+c3Lip/2}" y="${c3Y+c3Body+9}" fill="${cBk3}" ${sdf} text-anchor="middle">${Math.round(c3Lip/sc)}</text>` ;
// ④ D형 (주황색)
segDims += `<text x="${c4BodyX+t2+4}" y="${c4CenterY+2}" fill="${cBk4}" ${sdf} text-anchor="start">${Math.round(c4c/sc)}</text>` ;
segDims += `<text x="${c4SideStartX+c4b/2}" y="${c4Y-4}" fill="${cBk4}" ${sdf} text-anchor="middle">${Math.round(c4b/sc)}</text>` ;
segDims += `<text x="${c4SideStartX-4}" y="${c4Y+t2+c4a/2+2}" fill="${cBk4}" ${sdf} text-anchor="end">${Math.round(c4a/sc)}</text>` ;
segDims += `<text x="${c4SideStartX-4}" y="${c4Y+c4c-t2-c4a/2+2}" fill="${cBk4}" ${sdf} text-anchor="end">${Math.round(c4a/sc)}</text>` ;
// ① 마감재 상단 (빨간색)
segDims += `<text x="${trimL1-m1a/2}" y="${tTop+m1b+t1+9}" fill="${cTrim}" ${sdf} text-anchor="middle">10</text>` ;
segDims += `<text x="${trimL1-6}" y="${tTop+m1b/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">11</text>` ;
segDims += `<text x="${(trimL1+trimR1)/2}" y="${tTop-3}" fill="${cTrim}" ${sdf} text-anchor="middle">110</text>` ;
segDims += `<text x="${wrapX+4}" y="${tTop+m1d/2+2}" fill="${cTrim}" ${sdf} text-anchor="start">30</text>` ;
segDims += `<text x="${wrapX-m1e/2}" y="${tTop+m1d+t1+9}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>` ;
segDims += `<text x="${wrapX-m1e-6}" y="${tTop+m1d-m1f/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">15</text>` ;
segDims += `<text x="${wrapX-m1e-m1g/2}" y="${tTop+m1d-m1f-4}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>` ;
// ① 마감재 하단 (빨간색, 거울상)
segDims += `<text x="${trimL1-m1a/2}" y="${tBot-m1b-4}" fill="${cTrim}" ${sdf} text-anchor="middle">10</text>` ;
segDims += `<text x="${trimL1-6}" y="${tBot-m1b/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">11</text>` ;
segDims += `<text x="${(trimL1+trimR1)/2}" y="${tBot+t1+9}" fill="${cTrim}" ${sdf} text-anchor="middle">110</text>` ;
segDims += `<text x="${wrapX+4}" y="${tBot-m1d/2+2}" fill="${cTrim}" ${sdf} text-anchor="start">30</text>` ;
segDims += `<text x="${wrapX-m1e/2}" y="${tBot-m1d-4}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>` ;
segDims += `<text x="${wrapX-m1e-6}" y="${tBot-m1d+m1f/2+2}" fill="${cTrim}" ${sdf} text-anchor="end">15</text>` ;
segDims += `<text x="${wrapX-m1e-m1g/2}" y="${tBot-m1d+m1f+t2+9}" fill="${cTrim}" ${sdf} text-anchor="middle">15</text>` ;
2026-03-10 08:18:55 +09:00
// ── 치수선 ──
2026-03-09 17:27:50 +09:00
let dimLines = '' ;
if ( g . showDim ) {
2026-03-10 08:18:55 +09:00
const totalLeft = bx - c3Lip ;
2026-03-10 08:28:43 +09:00
const totalRight = trimX2 ;
2026-03-09 17:58:47 +09:00
// 깊이 120mm (하단)
2026-03-10 08:18:55 +09:00
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>` ;
2026-03-11 10:01:51 +09:00
// 폭 70mm (우측, 충분히 떨어진 위치)
const dimRightX = totalRight + 40 ;
dimLines += `<line x1="${dimRightX}" y1="${by}" x2="${dimRightX}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="1"/>` ;
dimLines += `<line x1="${dimRightX-5}" y1="${by}" x2="${dimRightX+5}" y2="${by}" stroke="#3b82f6" stroke-width="0.5"/>` ;
dimLines += `<line x1="${dimRightX-5}" y1="${by+bOuterW}" x2="${dimRightX+5}" y2="${by+bOuterW}" stroke="#3b82f6" stroke-width="0.5"/>` ;
dimLines += `<text x="${dimRightX+8}" y="${by+bOuterW/2+4}" fill="#3b82f6" font-size="12" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>` ;
2026-03-11 15:53:08 +09:00
// ② 플랜지 26mm (②의 립 끝 기준 라벨)
dimLines += `<text x="${lipEndX+6}" y="${by+bFl/2+3}" fill="${cBody}" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">②FL${g.flange}</text>` ;
2026-03-11 19:36:09 +09:00
// ① 코킹립 10mm (벽쪽 라벨)
dimLines += `<text x="${trimL1-m1a-4}" y="${tTop+m1b+t1/2+3}" fill="${cTrim}" font-size="8" font-weight="700" text-anchor="end" font-family="Pretendard">①립${m1a/sc}</text>` ;
2026-03-11 10:15:27 +09:00
// 슬롯 개구 (우측 중앙)
2026-03-12 07:34:25 +09:00
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+16}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯${Math.round(bSlot/sc)}</text>` ;
2026-03-11 10:15:27 +09:00
// 립 깊이
2026-03-11 15:53:08 +09:00
dimLines += `<text x="${(swEndX+lipEndX)/2}" y="${by+bFl+14}" fill="${cBody}" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard">②립${g.lip}</text>` ;
2026-03-11 10:01:51 +09:00
// 두께 (하단, 120mm 치수선 아래)
2026-03-11 15:53:08 +09:00
dimLines += `<text x="${(totalLeft+totalRight)/2}" y="${by+bOuterW+68}" fill="#94a3b8" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard">①t=${g.trimThick||1.2}(SUS) ②t=${g.thickness}(EGI)</text>` ;
2026-03-11 16:04:14 +09:00
// 부재 번호 라벨 (참조 도면 기준 배치)
2026-03-11 16:33:00 +09:00
// ① 마감재: 수평면 중앙 (상단, 하단 각 1개)
2026-03-11 17:58:41 +09:00
dimLines += `<text x="${(trimL1+trimR1)/2}" y="${tTop+t1/2+3}" fill="${cTrim}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">①</text>` ;
dimLines += `<text x="${(trimL1+trimR1)/2}" y="${tBot+t1/2+3}" fill="${cTrim}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">①</text>` ;
2026-03-11 16:04:14 +09:00
// ② 가이드레일: 채널 내부 중앙
2026-03-11 17:58:41 +09:00
dimLines += `<text x="${bx+t2+bSw/2}" y="${by+bOuterW/2+4}" fill="${cBody}" font-size="24" font-weight="900" text-anchor="middle" font-family="Pretendard">②</text>` ;
2026-03-11 16:04:14 +09:00
// ③ C형: 몸체(45mm) 중심
2026-03-11 17:58:41 +09:00
dimLines += `<text x="${c3BodyX+c3Lip/2}" y="${c3CenterY+4}" fill="${cBk3}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">③</text>` ;
2026-03-11 16:11:10 +09:00
// ④ D형: 몸체(40mm) 중심 (좌측 열림, ③과 마주봄)
2026-03-11 17:58:41 +09:00
dimLines += `<text x="${c4BodyX-c4b/2}" y="${c4CenterY+4}" fill="${cBk4}" font-size="20" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>` ;
2026-03-09 17:27:50 +09:00
}
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 >
2026-03-15 18:44:14 +09:00
< text x = " ${ svgW/2 } " y = " 25 " fill = " #94a3b8 " font - size = " 14 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > 가이드레일 평면도 ( Plan View ) — $ { S . productType === 'screen' ? '스크린형' : '철재스라트' } </ text >
2026-03-10 08:18:55 +09:00
<!-- 방화벽 -->
< 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 }
2026-03-11 15:53:08 +09:00
<!-- ② 가이드레일 C채널 EGI ( 파란색 , ㄷ자 1 개 ) -->
2026-03-11 10:01:51 +09:00
$ { bodySvg }
2026-03-11 15:53:08 +09:00
<!-- ③ C형 -->
2026-03-10 08:18:55 +09:00
$ { bk3Svg }
2026-03-11 15:53:08 +09:00
<!-- ④ D형 -->
2026-03-10 08:18:55 +09:00
$ { bk4Svg }
2026-03-11 15:53:08 +09:00
<!-- ① 마감재 SUS ( 빨간색 , ② 위 덮씌우기 ) -->
2026-03-11 10:01:51 +09:00
$ { trim1Svg }
2026-03-09 17:27:50 +09:00
<!-- 연기차단재 -->
$ { sealSvg }
2026-03-10 08:18:55 +09:00
<!-- 슬랫 -->
2026-03-09 17:58:47 +09:00
< rect x = " ${ slatX1 } " y = " ${ slatY } " width = " ${ slatX2-slatX1 } " height = " ${ slatT } " fill = " #c084fc " opacity = " 0.8 " rx = " 1 " />
2026-03-11 10:01:51 +09:00
$ { g . showDim ? `<text x="${slatX1}" y="${slatY-4}" fill="#c084fc" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬랫 t=${g.slatThick}</text>` : '' }
2026-03-16 14:56:43 +09:00
<!-- 절곡 치수 -->
$ { segDims }
2026-03-09 17:58:47 +09:00
<!-- 개구부 방향 -->
2026-03-11 16:33:00 +09:00
< text x = " ${ lipEndX+10 } " y = " ${ by+bOuterW/2-2 } " fill = " #64748b " font - size = " 9 " font - weight = " 700 " text - anchor = " start " font - family = " Pretendard " > →개구부 </ text >
2026-03-09 17:27:50 +09:00
$ { dimLines }
2026-03-15 18:34:15 +09:00
< text x = " ${ svgW/2 } " y = " ${ by+bOuterW+82 } " fill = " #94a3b8 " font - size = " 10 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > GUIDE RAIL — $ { S . productType === 'screen' ? '스크린형' : '철재스라트' } ( ①마감재 ②가이드레일 ③C형 ④D형 ) </ text >
2026-03-09 17:27:50 +09:00
</ svg > ` ;
2026-03-15 18:58:14 +09:00
displaySvg ( svg );
return ;
}
2026-03-15 19:34:57 +09:00
// ====== 철재스라트: 가이드레일 평면도 (130mm × 75mm 결합도) ======
// LEFT = 방화벽(벽쪽), RIGHT = 개구부(실내쪽, 슬랫 통과)
2026-03-16 14:16:06 +09:00
// ① 마감재 SUS 1.2T (빨간색) - ② 바깥을 감싸는 외피, 상하 2장
2026-03-15 19:34:57 +09:00
// 상단: 10←lip + 13↓tab + 120→body + 25↓ + 15←
// 하단: 10←lip + 13↑tab + 120→body + 25↑ + 내부스텝(19← + 14↓ + 15←)
2026-03-16 14:16:06 +09:00
// ② 본체 EGI 1.55T (회색) - 메인 C채널, 개구부가 RIGHT
// 외형: 90mm(가로) × 72mm(세로), 좌측벽 72mm, 상/하단 플랜지 90mm
// 우측 상부 립 21mm, 내부 개구부가 y=60까지 확장, 하부 립 12mm
// ③ C형 EGI 1.55T (주황색) - ② 내부 좌측 상단에 위치, 30-45-30 ㄷ자(우측열림)
// ④ 벽연형 EGI 1.55T (갈색) - ② 좌측에 위치, 벽과 연결, 30-45-30 ㄷ자(우측열림)
2026-03-15 18:58:14 +09:00
{
2026-03-16 08:52:24 +09:00
const sc = 4 ; // px per mm (확대하여 철판 비율 현실적)
const t = g . thickness * sc ; // 1.55mm → 6.2px (EGI)
const t1 = ( g . trimThick || 1.2 ) * sc ; // 1.2mm → 4.8px (SUS)
2026-03-15 18:58:14 +09:00
const slatT = Math . max ( g . slatThick * sc , 2 );
2026-03-15 19:17:08 +09:00
// 색상
const cBody = '#4b5563' , cTrim = '#ef4444' , cC3 = '#f59e0b' , cWall = '#8b6c5c' ;
2026-03-15 19:34:57 +09:00
// ── 도면 치수 (mm 단위, sc 미적용) ──
// 전체: 130mm(가로) × 75mm(세로)
// ① 마감재: lip=10, tab=13, body=120, end25=25, end15=15, end19=19, end14=14
// ② 본체: 90× 72, lip=21, 내부 shelf78/shelf30/step43/s15/s20
// ③ C형: 30-45-30
// ④ 벽연형: 30-45-30
// ── 기준 좌표 설정 ──
// ① lip 좌측 끝을 x 기준 0으로 놓고 오프셋 적용
const wallW = 50 ; // 방화벽 해치 너비 (px)
2026-03-16 08:45:19 +09:00
const wallGap = 0 ; // 방화벽과 ④ 밀착 (벽에 부착)
2026-03-15 19:34:57 +09:00
const padL = 30 ; // SVG 좌측 여백 (px)
const padT = 55 ; // SVG 상단 여백 (타이틀 공간)
2026-03-16 09:18:05 +09:00
// ④ C형 보강 치수
2026-03-16 09:16:30 +09:00
const w4depth = 30 * sc ;
const w4height = 45 * sc ;
2026-03-15 19:34:57 +09:00
// ② 본체 치수
const b2w = 90 * sc ; // 90mm → 270px (가로)
const b2h = 72 * sc ; // 72mm → 216px (세로)
const b2lip = 21 * sc ; // 21mm → 63px (우측 상/하부 립)
// ① 마감재 치수
const m1lip = 10 * sc ; // 10mm → 30px (좌측 코킹립)
const m1tab = 13 * sc ; // 13mm → 39px (좌측 절곡탭)
const m1body = 120 * sc ; // 120mm → 360px (본체 수평부)
const m1e25 = 25 * sc ; // 25mm → 75px (우측 수직 절곡)
const m1e15 = 15 * sc ; // 15mm → 45px (우측 수평 리턴)
const m1e19 = 19 * sc ; // 19mm → 57px (하단 내부 수평)
const m1e14 = 14 * sc ; // 14mm → 42px (하단 내부 수직)
// ── 좌표 계산 ──
2026-03-16 09:08:51 +09:00
// 방화벽 우측면 = ① 립 좌측 끝 (밀착)
const trimLipX = padL + wallW ; // ① 립 좌측 = 벽 우측
const bx = trimLipX + m1lip ; // ② 좌측벽 = ① 립 끝
const by = padT + m1tab ; // ② 상단 y
const trimRightX = bx + m1body ; // ① body 우측 끝 (130mm 끝)
2026-03-16 09:18:05 +09:00
const w4x = trimLipX ; // ④ 좌측 = 벽 우측면 = ① 립 좌측
2026-03-15 19:34:57 +09:00
// SVG 캔버스 크기
const svgW = trimRightX + 80 ; // 우측 치수선/라벨 공간
const svgH = by + b2h + m1tab + 60 ; // 하단 치수선/범례 공간
const P = []; // SVG 부재 요소 배열
// ══════════════════════════════════════════
2026-03-16 14:16:06 +09:00
// ══════════════════════════════════════════
// ④ C형 보강 EGI 1.55T (갈색, ㄷ자 30-45-30 우측열림)
// 벽 우측면에 부착, 세로 중앙 정렬
// ══════════════════════════════════════════
const w4cy = by + ( b2h - w4height ) / 2 ;
P . push ( `<rect x="${w4x}" y="${w4cy}" width="${w4depth}" height="${t}" fill="${cWall}" stroke="${cWall}" stroke-width="0.5" opacity="0.85"/>` );
P . push ( `<rect x="${w4x}" y="${w4cy}" width="${t}" height="${w4height}" fill="${cWall}" stroke="${cWall}" stroke-width="0.5" opacity="0.85"/>` );
P . push ( `<rect x="${w4x}" y="${w4cy + w4height - t}" width="${w4depth}" height="${t}" fill="${cWall}" stroke="${cWall}" stroke-width="0.5" opacity="0.85"/>` );
2026-03-16 14:41:56 +09:00
// ④ 치수 라벨 (갈색)
const wdf = 'font-size="7" font-weight="700" font-family="Pretendard"' ;
P . push ( `<text x="${w4x + w4depth / 2}" y="${w4cy - 4}" fill="${cWall}" ${wdf} text-anchor="middle">30</text>` );
P . push ( `<text x="${w4x - 4}" y="${w4cy + w4height / 2 + 2}" fill="${cWall}" ${wdf} text-anchor="end">45</text>` );
2026-03-16 14:16:06 +09:00
// ══════════════════════════════════════════
// ② 본체 EGI 1.55T (절곡, 체이닝 좌표)
2026-03-16 10:24:12 +09:00
// 프로파일 좌표: (0,0)=좌상단, 90× 72mm
2026-03-16 14:16:06 +09:00
// 내부 개구부가 아래로 확장 (④ 진입 공간)
feat: [fire-shutter] ② 본체 15세그먼트 절곡 프로파일 구현
- 15세그먼트: 10,60,90,21,15,15,20,15,43,30,78,21,90,12,10
- 시작/끝: 좌측벽 y=60 (Seg1·Seg15 립 겹침)
- 좌측벽 72mm = Seg2(60↑) + Seg14(12↑)
- 상/하단 플랜지 90mm, 상/하부 립 21mm
- 내부 스텝: Seg5~Seg8 (15←15↓20←15↑ 직사각형 노치)
- 내부 선반: Seg9(43←) + Seg10(30↓) + Seg11(78→)
2026-03-16 10:20:23 +09:00
// ══════════════════════════════════════════
{
2026-03-16 14:53:37 +09:00
const ox2 = w4x + w4depth + ( 7 + 0.75 ) * sc ; // ② 좌측벽 = ④ 끝 + 7mm + 0.75(반두께)
2026-03-16 10:24:12 +09:00
const oy2 = by ; // ② 상단
const c = cBody , s = '#94a3b8' ;
// 프로파일 좌표 → SVG 좌표 변환
function r ( x , y , w , h , op ) {
P . push ( `<rect x="${ox2+x*sc}" y="${oy2+y*sc}" width="${w*sc}" height="${h*sc}" fill="${c}" stroke="${s}" stroke-width="${op?'0.3':'0.5'}" ${op?'opacity="0.7"':''}/>` );
}
2026-03-16 14:16:06 +09:00
// 벤드포인트 체이닝 (프로파일 좌표 mm 단위)
// B0=(0,60) → Seg1(10→) → (10,60): 립 우측(채널 안쪽)
2026-03-16 10:27:00 +09:00
r ( 0 , 60 - 0.75 , 10 , 1.55 );
2026-03-16 14:16:06 +09:00
// B0=(0,60) → Seg2(60↑) → (0,0): 좌측벽 상부
r ( 0 , 0 , 1.55 , 60 );
2026-03-16 10:24:12 +09:00
// (0,0) → Seg3(90→) → (90,0): 상단 플랜지
r ( 0 , 0 , 90 , 1.55 );
// (90,0) → Seg4(21↓) → (90,21): 상부 립
r ( 90 - 1.55 , 0 , 1.55 , 21 );
2026-03-16 14:22:33 +09:00
// (90,21) → Seg5(78←) → (12,21): 상부 내부 선반 (넓은)
r ( 12 , 21 , 78 , 1.55 , 1 );
// (12,21) → Seg6(30↓) → (12,51): 내부 벽
r ( 12 , 21 , 1.55 , 30 , 1 );
// (12,51) → Seg7(43→) → (55,51): 하부 내부 선반
r ( 12 , 51 , 43 , 1.55 , 1 );
// (55,51) → Seg8(15↓) → (55,66): 스텝 하강
r ( 55 - 1.55 , 51 , 1.55 , 15 , 1 );
// (55,66) → Seg9(20→) → (75,66): 스텝 우측
r ( 55 , 66 - 1.55 , 20 , 1.55 , 1 );
// (75,66) → Seg10(15↑) → (75,51): 스텝 상승
r ( 75 , 51 , 1.55 , 15 , 1 );
// (75,51) → Seg11(15→) → (90,51): 스텝 우측
r ( 75 , 51 , 15 , 1.55 , 1 );
// (90,51) → Seg12(21↓) → (90,72): 하부 립
r ( 90 - 1.55 , 51 , 1.55 , 21 );
2026-03-16 14:16:06 +09:00
// (90,72) → Seg13(90←) → (0,72): 하단 플랜지
r ( 0 , 72 - 1.55 , 90 , 1.55 );
// (0,72) → Seg14(12↑) → (0,60): 좌측벽 하부
r ( 0 , 60 , 1.55 , 12 );
// (0,60) → Seg15(10→) → (10,60): 끝 립 (Seg1과 겹침)
r ( 0 , 60 , 10 , 1.55 );
2026-03-16 14:37:27 +09:00
2026-03-16 14:41:56 +09:00
// ── ② 본체 절곡 치수 라벨 (검증용, 본체 색상) ──
const df = 'font-size="7" font-weight="700" font-family="Pretendard"' ;
function d ( x , y , txt , anc , clr ) {
P . push ( `<text x="${ox2+x*sc}" y="${oy2+y*sc}" fill="${clr||s}" ${df} text-anchor="${anc||'middle'}">${txt}</text>` );
2026-03-16 14:37:27 +09:00
}
2026-03-16 14:41:56 +09:00
d ( 5 , 57 , '10' ); // Seg1: 립 10mm
d ( - 4 , 30 , '60' , 'end' ); // Seg2: 좌측벽 60mm
d ( 45 , - 3 , '90' ); // Seg3: 상단 플랜지 90mm
d ( 94 , 11 , '21' , 'start' ); // Seg4: 상부 립 21mm
d ( 51 , 18 , '78' ); // Seg5: 상부 선반 78mm
d ( 9 , 36 , '30' , 'end' ); // Seg6: 내부 벽 30mm
d ( 33 , 49 , '43' ); // Seg7: 하부 선반 43mm
d ( 49 , 59 , '15' , 'end' ); // Seg8: 스텝↓ 15mm
d ( 65 , 70 , '20' ); // Seg9: 스텝→ 20mm
d ( 79 , 59 , '15' , 'start' ); // Seg10: 스텝↑ 15mm
d ( 82 , 49 , '15' ); // Seg11: 스텝→ 15mm
d ( 94 , 62 , '21' , 'start' ); // Seg12: 하부 립 21mm
d ( 45 , 76 , '90' ); // Seg13: 하단 플랜지 90mm
d ( - 4 , 67 , '12' , 'end' ); // Seg14: 좌측벽 하부 12mm
feat: [fire-shutter] ② 본체 15세그먼트 절곡 프로파일 구현
- 15세그먼트: 10,60,90,21,15,15,20,15,43,30,78,21,90,12,10
- 시작/끝: 좌측벽 y=60 (Seg1·Seg15 립 겹침)
- 좌측벽 72mm = Seg2(60↑) + Seg14(12↑)
- 상/하단 플랜지 90mm, 상/하부 립 21mm
- 내부 스텝: Seg5~Seg8 (15←15↓20←15↑ 직사각형 노치)
- 내부 선반: Seg9(43←) + Seg10(30↓) + Seg11(78→)
2026-03-16 10:20:23 +09:00
}
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
// ① 마감재 SUS 1.2T (빨간색) — 상단 장
// 경로: 10←lip + 13↓tab + 120→body + 25↓ + 15←
// ② 상단 플랜지 바로 위를 감싸는 형태
// ══════════════════════════════════════════
const tTop = by - t1 ; // ② 상단 플랜지 바로 위
2026-03-16 08:21:22 +09:00
// ── 상단 ① 경로: 120→body + 13↓tab + 10←lip ── (우측: 25↓ + 15←)
// (a) 본체 수평부 120mm
P . push ( `<rect x="${bx}" y="${tTop}" width="${m1body}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (b) 좌측 절곡탭 13mm: body 좌단에서 아래로
P . push ( `<rect x="${bx}" y="${tTop}" width="${t1}" height="${m1tab}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (c) 좌측 코킹립 10mm: tab 하단에서 좌측으로
P . push ( `<rect x="${bx - m1lip}" y="${tTop + m1tab - t1}" width="${m1lip + t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
2026-03-15 19:34:57 +09:00
// (d) 우측 수직 절곡 25mm: body 우측 끝에서 아래로
P . push ( `<rect x="${trimRightX - t1}" y="${tTop}" width="${t1}" height="${m1e25}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (e) 우측 수평 리턴 15mm: 25mm 끝에서 좌측으로
P . push ( `<rect x="${trimRightX - m1e15}" y="${tTop + m1e25 - t1}" width="${m1e15}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// ══════════════════════════════════════════
2026-03-16 08:21:22 +09:00
// ① 마감재 하단 — 경로: 120→body + 13↑tab + 10←lip (우측: 25↑+19←+14↓+15←)
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
2026-03-16 08:21:22 +09:00
const tBot = by + b2h ;
// (a) 본체 수평부 120mm
P . push ( `<rect x="${bx}" y="${tBot}" width="${m1body}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (b) 좌측 절곡탭 13mm: body 좌단에서 위로
P . push ( `<rect x="${bx}" y="${tBot - m1tab + t1}" width="${t1}" height="${m1tab}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (c) 좌측 코킹립 10mm: tab 상단에서 좌측으로
P . push ( `<rect x="${bx - m1lip}" y="${tBot - m1tab}" width="${m1lip + t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
2026-03-15 19:34:57 +09:00
// (c) 본체 수평부 120mm: bx에서 우측으로
P . push ( `<rect x="${bx}" y="${tBot}" width="${m1body}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (d) 우측 수직 절곡 25mm: body 우측 끝에서 위로
P . push ( `<rect x="${trimRightX - t1}" y="${tBot - m1e25 + t1}" width="${t1}" height="${m1e25}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (e) 내부스텝 19mm 수평: 25mm 벽 상단에서 좌측으로
P . push ( `<rect x="${trimRightX - m1e19}" y="${tBot - m1e25 + t1}" width="${m1e19}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (f) 내부스텝 14mm 수직: 19mm 좌측 끝에서 아래로
P . push ( `<rect x="${trimRightX - m1e19}" y="${tBot - m1e25 + t1}" width="${t1}" height="${m1e14}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
// (g) 내부스텝 15mm 수평: 14mm 끝에서 좌측으로
P . push ( `<rect x="${trimRightX - m1e19 - m1e15}" y="${tBot - m1e25 + m1e14 + t1}" width="${m1e15 + t1}" height="${t1}" fill="${cTrim}" stroke="${cTrim}" stroke-width="0.3"/>` );
2026-03-16 14:41:56 +09:00
// ── ① 마감재 치수 라벨 (빨간색) ──
const tdf = `font-size="7" font-weight="700" font-family="Pretendard"` ;
// 상단 ①
P . push ( `<text x="${bx + m1body / 2}" y="${tTop - 3}" fill="${cTrim}" ${tdf} text-anchor="middle">120</text>` );
P . push ( `<text x="${bx - 4}" y="${tTop + m1tab / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="end">13</text>` );
P . push ( `<text x="${bx - m1lip / 2}" y="${tTop + m1tab + 6}" fill="${cTrim}" ${tdf} text-anchor="middle">10</text>` );
P . push ( `<text x="${trimRightX + 4}" y="${tTop + m1e25 / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="start">25</text>` );
P . push ( `<text x="${trimRightX - m1e15 / 2}" y="${tTop + m1e25 + 6}" fill="${cTrim}" ${tdf} text-anchor="middle">15</text>` );
// 하단 ①
P . push ( `<text x="${bx + m1body / 2}" y="${tBot + t1 + 9}" fill="${cTrim}" ${tdf} text-anchor="middle">120</text>` );
P . push ( `<text x="${bx - 4}" y="${tBot - m1tab / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="end">13</text>` );
P . push ( `<text x="${bx - m1lip / 2}" y="${tBot - m1tab - 3}" fill="${cTrim}" ${tdf} text-anchor="middle">10</text>` );
P . push ( `<text x="${trimRightX + 4}" y="${tBot - m1e25 / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="start">25</text>` );
P . push ( `<text x="${trimRightX - m1e19 / 2}" y="${tBot - m1e25 - 2}" fill="${cTrim}" ${tdf} text-anchor="middle">19</text>` );
P . push ( `<text x="${trimRightX - m1e19 - 4}" y="${tBot - m1e25 + m1e14 / 2 + 2}" fill="${cTrim}" ${tdf} text-anchor="end">14</text>` );
P . push ( `<text x="${trimRightX - m1e19 - m1e15 / 2}" y="${tBot - m1e25 + m1e14 + 9}" fill="${cTrim}" ${tdf} text-anchor="middle">15</text>` );
2026-03-16 08:27:17 +09:00
// (슬랫 표시 생략 — 철재는 슬랫이 개구부 밖에서 통과)
2026-03-15 18:58:14 +09:00
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
2026-03-15 18:58:14 +09:00
// 치수선
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
2026-03-15 18:58:14 +09:00
let dims = '' ;
if ( g . showDim ) {
2026-03-15 19:34:57 +09:00
// ── 수평 치수: 전체 깊이 (130mm = g.depth) ──
// ① lip 좌측 ~ ① body 우측
const dimHy = tBot + m1tab + 18 ;
dims += `<line x1="${trimLipX}" y1="${dimHy}" x2="${trimRightX}" y2="${dimHy}" stroke="#3b82f6" stroke-width="1" marker-start="url(#dimArrow)" marker-end="url(#dimArrow)"/>` ;
dims += `<line x1="${trimLipX}" y1="${dimHy - 5}" x2="${trimLipX}" y2="${dimHy + 5}" stroke="#3b82f6" stroke-width="0.5"/>` ;
dims += `<line x1="${trimRightX}" y1="${dimHy - 5}" x2="${trimRightX}" y2="${dimHy + 5}" stroke="#3b82f6" stroke-width="0.5"/>` ;
dims += `<text x="${(trimLipX + trimRightX) / 2}" y="${dimHy + 14}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="middle" font-family="Pretendard">${g.depth} mm</text>` ;
// ── 수직 치수: 전체 폭 (75mm = g.width) ──
const dimVx = trimRightX + 12 ;
// ①상단 lip ~ ①하단 lip (전체 75mm)
const dimVtop = tTop ;
const dimVbot = tBot + t1 ;
dims += `<line x1="${dimVx}" y1="${dimVtop}" x2="${dimVx}" y2="${dimVbot}" stroke="#3b82f6" stroke-width="1"/>` ;
dims += `<line x1="${dimVx - 5}" y1="${dimVtop}" x2="${dimVx + 5}" y2="${dimVtop}" stroke="#3b82f6" stroke-width="0.5"/>` ;
dims += `<line x1="${dimVx - 5}" y1="${dimVbot}" x2="${dimVx + 5}" y2="${dimVbot}" stroke="#3b82f6" stroke-width="0.5"/>` ;
dims += `<text x="${dimVx + 8}" y="${(dimVtop + dimVbot) / 2 + 4}" fill="#3b82f6" font-size="11" font-weight="900" text-anchor="start" font-family="Pretendard">${g.width} mm</text>` ;
// ── 보조 치수: ② 본체 가로 90mm ──
const dimB2y = by - m1tab - 12 ;
dims += `<line x1="${bx}" y1="${dimB2y}" x2="${bx + b2w}" y2="${dimB2y}" stroke="#94a3b8" stroke-width="0.7" stroke-dasharray="3,2"/>` ;
dims += `<line x1="${bx}" y1="${dimB2y - 4}" x2="${bx}" y2="${dimB2y + 4}" stroke="#94a3b8" stroke-width="0.5"/>` ;
dims += `<line x1="${bx + b2w}" y1="${dimB2y - 4}" x2="${bx + b2w}" y2="${dimB2y + 4}" stroke="#94a3b8" stroke-width="0.5"/>` ;
dims += `<text x="${bx + b2w / 2}" y="${dimB2y - 3}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="middle" font-family="Pretendard">90</text>` ;
// ── 보조 치수: ② 본체 세로 72mm ──
const dimB2x = bx - m1lip - 12 ;
dims += `<line x1="${dimB2x}" y1="${by}" x2="${dimB2x}" y2="${by + b2h}" stroke="#94a3b8" stroke-width="0.7" stroke-dasharray="3,2"/>` ;
dims += `<line x1="${dimB2x - 4}" y1="${by}" x2="${dimB2x + 4}" y2="${by}" stroke="#94a3b8" stroke-width="0.5"/>` ;
dims += `<line x1="${dimB2x - 4}" y1="${by + b2h}" x2="${dimB2x + 4}" y2="${by + b2h}" stroke="#94a3b8" stroke-width="0.5"/>` ;
dims += `<text x="${dimB2x - 3}" y="${by + b2h / 2 + 4}" fill="#94a3b8" font-size="9" font-weight="700" text-anchor="end" font-family="Pretendard">72</text>` ;
2026-03-15 18:58:14 +09:00
}
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
// 번호 라벨 (원 + 번호)
// ══════════════════════════════════════════
let labels = '' ;
// ① 마감재 라벨 (상단 중앙 위)
const lb1x = bx + m1body / 2 , lb1y = tTop - 12 ;
labels += `<circle cx="${lb1x}" cy="${lb1y}" r="8" fill="none" stroke="${cTrim}" stroke-width="1.5"/>` ;
labels += `<text x="${lb1x}" y="${lb1y + 3}" fill="${cTrim}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">①</text>` ;
2026-03-16 14:16:06 +09:00
// (② 라벨 — 제거)
// (③ C형 라벨 — 생략)
// ④ C형 보강 라벨
2026-03-16 09:16:30 +09:00
const lb4x = w4x + w4depth / 2 , lb4y = w4cy + w4height / 2 ;
labels += `<circle cx="${lb4x}" cy="${lb4y}" r="8" fill="none" stroke="${cWall}" stroke-width="1.5"/>` ;
labels += `<text x="${lb4x}" y="${lb4y + 3}" fill="${cWall}" font-size="9" font-weight="900" text-anchor="middle" font-family="Pretendard">④</text>` ;
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
// 개구부 방향 표시
// ══════════════════════════════════════════
const arrowX = bx + b2w + 8 ;
const arrowY = by + b2h / 2 ;
const arrowTxt = `<text x="${arrowX}" y="${arrowY}" fill="#64748b" font-size="10" font-weight="700" text-anchor="start" font-family="Pretendard">→ 개구부</text>` ;
// ══════════════════════════════════════════
2026-03-16 09:08:51 +09:00
// 방화벽 (해치 패턴) — ① 립 좌측에 밀착
2026-03-15 19:34:57 +09:00
// ══════════════════════════════════════════
2026-03-16 09:08:51 +09:00
const wallX = trimLipX - wallW ; // 벽 우측 = ① 립 좌측
2026-03-15 19:34:57 +09:00
const wallYstart = by - 20 ;
const wallH = b2h + 40 ;
// ══════════════════════════════════════════
// SVG 조립
// ══════════════════════════════════════════
2026-03-15 18:58:14 +09:00
const svg = ` < svg xmlns = " http://www.w3.org/2000/svg " viewBox = " 0 0 ${ svgW } ${ svgH } " style = " max-width:100%;max-height:100%; " >
2026-03-15 19:34:57 +09:00
< defs >
< pattern id = " steelWH " width = " 6 " height = " 6 " patternUnits = " userSpaceOnUse " patternTransform = " rotate(45) " >
< line x1 = " 0 " y1 = " 0 " x2 = " 0 " y2 = " 6 " stroke = " #8b7355 " stroke - width = " 0.8 " />
</ pattern >
</ defs >
<!-- 타이틀 -->
< text x = " $ { svgW / 2} " y = " 22 " fill = " #94a3b8 " font - size = " 14 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > 가이드레일 평면도 ( Plan View ) — 철재스라트 </ text >
<!-- 방화벽 ( 해치 ) -->
< rect x = " ${ wallX } " y = " ${ wallYstart } " width = " ${ wallW } " height = " ${ wallH } " fill = " url(#steelWH) " stroke = " #8b7355 " stroke - width = " 1 " rx = " 2 " />
< text x = " $ { wallX + wallW / 2} " y = " $ { wallYstart + wallH + 14} " fill = " #a1887f " font - size = " 10 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " > 방화벽 </ text >
<!-- 부재들 -->
2026-03-15 19:17:08 +09:00
$ { P . join ( '\n ' )}
2026-03-15 19:34:57 +09:00
<!-- 개구부 방향 -->
$ { arrowTxt }
<!-- 치수선 -->
2026-03-15 18:58:14 +09:00
$ { dims }
2026-03-15 19:34:57 +09:00
<!-- 번호 라벨 -->
$ { labels }
<!-- 범례 -->
< text x = " $ { svgW / 2} " y = " $ { svgH - 10} " fill = " #94a3b8 " font - size = " 10 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > ①마감재 SUS t = $ { g . trimThick || 1.2 } ②본체 EGI t = $ { g . thickness } ③C형 ④벽연형 — GUIDE RAIL 철재스라트 </ text >
2026-03-15 18:58:14 +09:00
</ svg > ` ;
2026-03-09 17:27:50 +09:00
displaySvg ( svg );
}
2026-03-08 19:30:05 +09:00
}
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 > ` ;
2026-03-08 20:23:48 +09:00
displaySvg ( svg );
2026-03-08 19:30:05 +09:00
}
// ============================
// 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 ;
2026-03-08 20:48:12 +09:00
const svgW = sw + pad * 2 + 60 , svgH = sh + pad * 2 + 60 ;
2026-03-08 19:30:05 +09:00
const ox = pad , oy = pad ;
2026-03-08 20:48:12 +09:00
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
2026-03-08 19:30:05 +09:00
const motorW = 80 * sc , motorH = 50 * sc ;
2026-03-08 20:48:12 +09:00
const motorX = b . motorSide === 'right' ? bracketX2 - motorW - 5 : ox + bracketW + 5 ;
2026-03-08 19:30:05 +09:00
const motorY = shaftCy - motorH / 2 ;
2026-03-08 20:48:12 +09:00
// Brake: next to motor
2026-03-08 19:30:05 +09:00
const brakeW = 30 * sc , brakeH = 40 * sc ;
const brakeX = b . motorSide === 'right' ? motorX - brakeW - 5 : motorX + motorW + 5 ;
2026-03-08 20:48:12 +09:00
// Spring: opposite side of motor
2026-03-08 19:30:05 +09:00
const springW = 60 * sc , springH = 20 * sc ;
2026-03-08 20:48:12 +09:00
const springX = b . motorSide === 'right' ? ox + bracketW + 20 : bracketX2 - springW - 20 ;
const springY = shaftCy + shaftH / 2 + 10 ;
2026-03-08 19:30:05 +09:00
2026-03-08 20:48:12 +09:00
// Bracket label positioning (outside if too thin)
const brkThin = bracketW < 15 ;
2026-03-08 19:30:05 +09:00
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 -->
2026-03-08 20:48:12 +09:00
< 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 -->
2026-03-08 19:30:05 +09:00
$ { b . showShaft ? `
2026-03-08 20:48:12 +09:00
< 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 >
2026-03-08 19:30:05 +09:00
` : '' }
2026-03-08 20:48:12 +09:00
<!-- Slat Roll : horizontal bar wrapping shaft -->
2026-03-08 19:30:05 +09:00
$ { b . showSlatRoll ? `
2026-03-08 20:48:12 +09:00
< 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 >
2026-03-08 19:30:05 +09:00
` : '' }
<!-- 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 >
` : '' }
2026-03-08 20:48:12 +09:00
<!-- 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 " />
2026-03-08 19:30:05 +09:00
< 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 -->
2026-03-08 20:30:57 +09:00
< 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 >
2026-03-08 19:30:05 +09:00
</ svg > ` ;
2026-03-08 20:23:48 +09:00
displaySvg ( svg );
2026-03-08 19:30:05 +09:00
}
function renderSbSide () {
const b = S . sb ;
2026-03-13 21:41:14 +09:00
const p = PRODUCTS [ S . productType ];
const D = b . depth , H = b . height ;
2026-03-15 17:15:41 +09:00
const fb = b . frontBottom || 50 ; // 전면 밑치수
const rw = b . railWidth || 70 ; // 레일폭
// 점검구 치수 (고정치수 제외한 나머지 = 가변)
// 밑면: D - frontBottom - railWidth - 50(린텔선반) - 20(린텔훅) - 20(코너훅) - 50(코너선반)
const bDoorW = D - fb - rw - 50 - 20 - 20 - 50 ;
// 후면: H - 50(상부코너벽) - 20(상부립) - 20(하부립) - 50(하부코너벽)
const rDoorH = H - 50 - 20 - 20 - 50 ;
2026-03-14 16:42:45 +09:00
const tabSz = 13 ; // 점검구 스크류 탭
const flgSz = 17 ; // 리시빙 플랜지
2026-03-13 21:41:14 +09:00
const sc = Math . min ( 480 / D , 520 / H );
2026-03-08 19:30:05 +09:00
const pad = 80 ;
2026-03-14 16:42:45 +09:00
const sD = D * sc , sH = H * sc ;
2026-03-13 23:01:46 +09:00
const svgW = sD + pad * 2 + 140 , svgH = sH + pad * 2 + 70 ;
2026-03-13 21:41:14 +09:00
const ox = pad + 65 , oy = pad + 30 ;
2026-03-15 15:45:07 +09:00
const vt = Math . max ( 2 , ( b . thickness || 1.55 ) * sc * 1.5 );
2026-03-13 22:02:22 +09:00
const pf = '#4b5563' , ps = '#6b7280' , ff = '#374151' ;
2026-03-14 16:42:45 +09:00
const doorFill = 'rgba(139,92,52,0.35)' , doorStroke = '#d97706' ;
2026-03-13 22:02:22 +09:00
2026-03-14 16:42:45 +09:00
// 절곡 치수 (스케일 적용)
2026-03-15 16:53:44 +09:00
const f13 = tabSz * sc , f17 = flgSz * sc , f50 = ( b . frontBottom || 50 ) * sc , f55 = 55 * sc , f20 = 20 * sc , f15 = 15 * sc ;
2026-03-13 23:01:46 +09:00
2026-03-14 16:42:45 +09:00
// 후면 점검구 위치 (높이 방향 가운데 정렬)
const rTopH = Math . round (( H - rDoorH ) / 2 ); // 후면 상부 고정부 높이 (~70mm)
const rBotH = H - rDoorH - rTopH ; // 후면 하부 고정부 높이
const srTopH = rTopH * sc , srBotH = rBotH * sc , srDoorH = rDoorH * sc ;
// 밑면 점검구 위치 (깊이 방향 가운데 정렬)
const bFrontW = Math . round (( D - bDoorW ) / 2 ); // 밑면 전면측 고정부 (~130mm)
const bRearW = D - bDoorW - bFrontW ;
const sbFrontW = bFrontW * sc , sbRearW = bRearW * sc , sbDoorW = bDoorW * sc ;
// 레일 홀
2026-03-15 16:53:44 +09:00
const railHoleW = ( b . railWidth || ( S . productType === 'screen' ? 70 : 120 )) * sc ;
2026-03-13 21:41:14 +09:00
const railUp = 100 * sc ;
2026-03-13 22:02:22 +09:00
2026-03-14 16:42:45 +09:00
// 샤프트 위치
2026-03-13 21:41:14 +09:00
const shaftR = ( b . shaftDia / 2 ) * sc ;
2026-03-15 16:01:55 +09:00
const shaftCx = ox + sD * 0.40 ;
const shaftCy = oy + sH * 0.50 ;
2026-03-13 21:41:14 +09:00
const rollR = shaftR + 20 * sc ;
2026-03-08 19:30:05 +09:00
const svg = ` < svg xmlns = " http://www.w3.org/2000/svg " viewBox = " 0 0 ${ svgW } ${ svgH } " style = " max-width:100%;max-height:100%; " >
2026-03-14 16:42:45 +09:00
< text x = " ${ svgW/2 } " y = " 22 " fill = " #94a3b8 " font - size = " 14 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > 셔터박스 측면 단면도 — 양면점검구 </ text >
2026-03-13 21:41:14 +09:00
2026-03-14 16:42:45 +09:00
<!-- 내부 공간 배경 -->
< rect x = " ${ ox+vt } " y = " ${ oy+vt } " width = " ${ sD-vt*2 } " height = " ${ sH-vt*2 } " fill = " rgba(55,65,81,0.10) " rx = " 2 " />
2026-03-13 21:41:14 +09:00
2026-03-14 16:42:45 +09:00
<!-- ━━━━━ 고정 판재 ( 회색 ) ━━━━━ -->
2026-03-15 10:21:47 +09:00
<!-- ① 케이스 전면판 절곡 프로파일 ( 7 세그먼트 , 전개길이 $ {( p . sb . frontPanel || []) . reduce (( a , b ) => a + b , 0 )} mm ) -->
$ {(() => {
const seg = ( p . sb . frontPanel || [ 17 , 55 , 50 , 380 , 55 , 15 , 20 ]) . map ( v => v * sc );
2026-03-15 17:02:02 +09:00
seg [ 2 ] = ( b . frontBottom || 50 ) * sc ; // 전면 밑치수 가변 적용
2026-03-15 15:24:07 +09:00
// 상부: 55→(선반) 15↓(스텝) 20→(커버 받침 립) — 커버가 올라가는 파임 형태
2026-03-15 17:02:02 +09:00
// 하부: frontBottom→, 55↑, 17← (J-훅)
2026-03-15 10:34:43 +09:00
const O = [
2026-03-15 15:24:07 +09:00
[ ox + seg [ 4 ] + seg [ 6 ], oy + seg [ 5 ]], // 0: Seg7 끝 (커버 받침 립 끝)
[ ox + seg [ 4 ], oy + seg [ 5 ]], // 1: Seg6 하단 / Seg7 시작
[ ox + seg [ 4 ], oy ], // 2: Seg5 끝 / Seg6 상단 (55mm 선반)
[ ox , oy ], // 3: 본체 상단
[ ox , oy + sH ], // 4: 본체 하단
[ ox + seg [ 2 ], oy + sH ], // 5: Seg3 끝 (50mm→)
[ ox + seg [ 2 ], oy + sH - seg [ 1 ]], // 6: Seg2 상단 (55mm↑)
[ ox + seg [ 2 ] - seg [ 0 ], oy + sH - seg [ 1 ]], // 7: Seg1 끝 (17mm←)
2026-03-15 10:21:47 +09:00
];
2026-03-15 10:34:43 +09:00
const I = [
2026-03-15 15:24:07 +09:00
[ ox + seg [ 4 ] + seg [ 6 ], oy + seg [ 5 ] - vt ], // 0: Seg7 내곽
[ ox + seg [ 4 ] - vt , oy + seg [ 5 ] - vt ], // 1: Seg6/Seg7 내곽 코너
[ ox + seg [ 4 ] - vt , oy + vt ], // 2: Seg5/Seg6 내곽 코너
[ ox + vt , oy + vt ], // 3: 본체/Seg5 내곽 코너
[ ox + vt , oy + sH - vt ], // 4: 본체/Seg3 내곽 코너
[ ox + seg [ 2 ] - vt , oy + sH - vt ], // 5: Seg3/Seg2 내곽 코너
[ ox + seg [ 2 ] - vt , oy + sH - seg [ 1 ] + vt ], // 6: Seg2/Seg1 내곽 코너
[ ox + seg [ 2 ] - seg [ 0 ], oy + sH - seg [ 1 ] + vt ], // 7: Seg1 내곽 끝
2026-03-15 10:21:47 +09:00
];
2026-03-15 10:51:07 +09:00
const outer = O . map ( p => p . join ( ',' )) . join ( ' L' );
2026-03-15 15:24:07 +09:00
const inner = [ I [ 7 ], I [ 6 ], I [ 5 ], I [ 4 ], I [ 3 ], I [ 2 ], I [ 1 ], I [ 0 ]] . map ( p => p . join ( ',' )) . join ( ' L' );
2026-03-15 10:34:43 +09:00
const fpCol = '#60a5fa' ;
2026-03-15 10:21:47 +09:00
const lbls = [
2026-03-15 15:24:07 +09:00
{ x : ( O [ 3 ][ 0 ] + O [ 2 ][ 0 ]) / 2 , y : oy - 6 , t : '55→' }, // Seg5
{ x : O [ 1 ][ 0 ] + 10 , y : ( O [ 2 ][ 1 ] + O [ 1 ][ 1 ]) / 2 , t : '15↓' }, // Seg6
{ x : ( O [ 1 ][ 0 ] + O [ 0 ][ 0 ]) / 2 , y : O [ 0 ][ 1 ] + 10 , t : '20→' }, // Seg7
{ x : ( O [ 4 ][ 0 ] + O [ 5 ][ 0 ]) / 2 , y : O [ 4 ][ 1 ] + 12 , t : '50→' }, // Seg3
{ x : O [ 5 ][ 0 ] + 10 , y : ( O [ 5 ][ 1 ] + O [ 6 ][ 1 ]) / 2 , t : '55↑' }, // Seg2
{ x : ( O [ 6 ][ 0 ] + O [ 7 ][ 0 ]) / 2 , y : O [ 7 ][ 1 ] - 6 , t : '←17' }, // Seg1
2026-03-15 10:21:47 +09:00
];
return `
< path d = " M ${ outer } L ${ inner } Z " fill = " ${ pf } " stroke = " ${ ps } " stroke - width = " 1.2 " opacity = " 0.95 " />
2026-03-15 10:34:43 +09:00
$ { lbls . map ( l => `<text x="${l.x}" y="${l.y}" fill="${fpCol}" font-size="7" font-weight="700" text-anchor="middle" font-family="Pretendard" opacity="0.7">${l.t}</text>` ) . join ( '\n ' )}
2026-03-15 10:21:47 +09:00
< text x = " ${ ox-28 } " y = " ${ oy+sH/2 } " fill = " #60a5fa " font - size = " 8 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " transform = " rotate(-90, ${ ox-28},${oy+sH/2 } ) " opacity = " 0.6 " > 전면판 전개 $ {( p . sb . frontPanel || []) . reduce (( a , b ) => a + b , 0 )} mm </ text >
` ;
})()}
2026-03-14 16:42:45 +09:00
2026-03-15 15:33:56 +09:00
<!-- ② 상부 커버 ( 389 mm = D - 55 - 50 - 6 , 전면판 Seg5 뒤에서 시작 ) -->
$ {(() => {
const coverW = ( D - 55 - 50 - 6 ) * sc ; // 389mm
const coverX = ox + 55 * sc ; // Seg5(55mm) 뒤에서 시작
return `
2026-03-15 15:43:22 +09:00
< rect x = " ${ coverX } " y = " $ { oy + f15 - vt} " width = " ${ coverW } " height = " ${ vt } " fill = " #546e7a " stroke = " #78909c " stroke - width = " 1.2 " />
< text x = " $ { coverX + coverW/2} " y = " $ { oy + f15 - vt - 4} " fill = " #78909c " font - size = " 7 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " > 상부 커버 ( $ { D - 55 - 50 - 6 } mm ) </ text >
2026-03-15 15:33:56 +09:00
` ;
})()}
2026-03-14 16:42:45 +09:00
2026-03-15 16:08:43 +09:00
<!-- ━━━━━ 후면벽 구조 ( doorDir에 따라 분기 ) ━━━━━ -->
2026-03-15 15:33:56 +09:00
$ {(() => {
const c20 = 20 * sc , c15 = 15 * sc , c50 = 50 * sc ;
2026-03-15 16:08:43 +09:00
const cx = ox + sD - vt ;
const cCol = '#8b6c5c' , cStr = '#6d4c3d' ;
if ( b . doorDir === 'bottom' ) {
// ── 밑면 전용: 후면벽 연속 절곡판 (상부훅+380mm벽+하부훅) ──
return `
<!-- 후면벽 본체 ( 380 mm 전체 높이 ) -->
< rect x = " ${ cx } " y = " ${ oy } " width = " ${ vt } " height = " ${ sH } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 1 " opacity = " 0.9 " />
<!-- 상부 선반 ( 50 mm ← ) -->
< rect x = " ${ cx-c50 } " y = " ${ oy } " width = " ${ c50+vt } " height = " ${ vt } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
<!-- 상부 스텝 ( 15 mm ↓ ) -->
< rect x = " ${ cx-c50 } " y = " ${ oy+vt } " width = " ${ vt*0.7 } " height = " ${ c15 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
<!-- 상부 립 ( 20 mm ← ) -->
< rect x = " ${ cx-c50-c20 } " y = " ${ oy+c15 } " width = " ${ c20+vt } " height = " ${ vt*0.7 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
<!-- 하부 선반 ( 50 mm ← ) -->
< rect x = " ${ cx-c50 } " y = " ${ oy+sH-vt } " width = " ${ c50+vt } " height = " ${ vt } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
<!-- 하부 스텝 ( 15 mm ↑ ) -->
< rect x = " ${ cx-c50 } " y = " ${ oy+sH-vt-c15 } " width = " ${ vt*0.7 } " height = " ${ c15 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
<!-- 하부 립 ( 20 mm ← ) -->
< rect x = " ${ cx-c50-c20 } " y = " ${ oy+sH-c15-vt } " width = " ${ c20+vt } " height = " ${ vt*0.7 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< text x = " ${ cx-c50/2 } " y = " ${ oy+sH/2 } " fill = " ${ cCol } " font - size = " 7 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " opacity = " 0.6 " transform = " rotate(-90, ${ cx-c50/2},${oy+sH/2 } ) " > 후면벽 ( 연속 ) </ text >
` ;
}
// ── 양면(dual) / 후면(rear): 상부코너 + 후면점검구 + 밑면코너 ──
const ct = oy , cy = oy + sH ;
2026-03-15 15:33:56 +09:00
return `
2026-03-15 16:08:43 +09:00
<!-- ③ 상부 코너부 -->
2026-03-15 15:33:56 +09:00
< rect x = " ${ cx-c50 } " y = " ${ ct } " width = " ${ c50+vt } " height = " ${ vt } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx } " y = " ${ ct } " width = " ${ vt } " height = " ${ c50 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx-c50 } " y = " ${ ct+vt } " width = " ${ vt*0.7 } " height = " ${ c15 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx-c50-c20 } " y = " ${ ct+c15 } " width = " ${ c20+vt } " height = " ${ vt*0.7 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx-c15 } " y = " ${ ct+c50 } " width = " ${ c15 } " height = " ${ vt*0.7 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx-c15 } " y = " ${ ct+c50 } " width = " ${ vt*0.7 } " height = " ${ c20 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< text x = " ${ cx-c50/2 } " y = " ${ ct+c50/2 } " fill = " ${ cCol } " font - size = " 6 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " opacity = " 0.6 " > 상부코너 </ text >
2026-03-15 16:08:43 +09:00
<!-- ⑥ 밑면 코너부 -->
2026-03-15 14:04:36 +09:00
< rect x = " ${ cx } " y = " ${ cy-c50 } " width = " ${ vt } " height = " ${ c50 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx-c15 } " y = " ${ cy-c50 } " width = " ${ c15 } " height = " ${ vt*0.7 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
2026-03-15 14:28:23 +09:00
< rect x = " ${ cx-c15 } " y = " ${ cy-c50-c20 } " width = " ${ vt*0.7 } " height = " ${ c20 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
2026-03-15 15:06:10 +09:00
< rect x = " ${ cx-c50 } " y = " ${ cy-vt } " width = " ${ c50+vt } " height = " ${ vt } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
2026-03-15 16:26:13 +09:00
$ { b . doorDir !== 'rear' ? `
2026-03-15 15:06:10 +09:00
< rect x = " ${ cx-c50 } " y = " ${ cy-c15-vt } " width = " ${ vt*0.7 } " height = " ${ c15+vt } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ cx-c50-c20 } " y = " ${ cy-c15-vt } " width = " ${ c20+vt } " height = " ${ vt*0.7 } " fill = " ${ cCol } " stroke = " ${ cStr } " stroke - width = " 0.8 " opacity = " 0.85 " />
2026-03-15 16:26:13 +09:00
` : '' }
2026-03-15 14:04:36 +09:00
< text x = " ${ cx-c50/2 } " y = " ${ cy-c50/2 } " fill = " ${ cCol } " font - size = " 6 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " opacity = " 0.6 " > 코너부 </ text >
2026-03-15 16:08:43 +09:00
<!-- ⑦ 후면 점검구 절곡판 -->
< rect x = " ${ cx } " y = " ${ oy+srTopH } " width = " ${ vt } " height = " ${ srDoorH } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1.5 " stroke - dasharray = " 6 3 " />
< rect x = " ${ cx-f13 } " y = " ${ oy+srTopH } " width = " ${ f13+vt } " height = " ${ vt*0.7 } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< rect x = " ${ cx-f13 } " y = " ${ oy+srTopH-f17 } " width = " ${ vt*0.7 } " height = " ${ f17 } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< rect x = " ${ cx-f13 } " y = " ${ oy+srTopH+srDoorH-vt*0.7 } " width = " ${ f13+vt } " height = " ${ vt*0.7 } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< rect x = " ${ cx-f13 } " y = " ${ oy+srTopH+srDoorH } " width = " ${ vt*0.7 } " height = " ${ f17 } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< text x = " ${ cx+vt+15 } " y = " ${ oy+srTopH+srDoorH/2-4 } " fill = " ${ doorStroke } " font - size = " 9 " font - weight = " 700 " text - anchor = " start " font - family = " Pretendard " > 후면 </ text >
< text x = " ${ cx+vt+15 } " y = " ${ oy+srTopH+srDoorH/2+8 } " fill = " ${ doorStroke } " font - size = " 8 " text - anchor = " start " font - family = " Pretendard " opacity = " 0.7 " > 점검구 </ text >
2026-03-15 14:04:36 +09:00
` ;
})()}
2026-03-14 16:42:45 +09:00
2026-03-15 16:19:22 +09:00
<!-- ━━━━━ 밑면 구조 ( doorDir에 따라 분기 ) ━━━━━ -->
2026-03-14 16:42:45 +09:00
$ {(() => {
2026-03-15 16:19:22 +09:00
const brkCol = '#7c9c6b' , brkStroke = '#5a7a4a' ;
const dy = oy + sH ;
const rhX = ox + f50 ;
const rhW = railHoleW ;
if ( b . doorDir === 'rear' ) {
// ── 후면 전용: 밑면 연속판 (전면판 J-훅 + 레일개구 + 연속 밑면) ──
const c20 = 20 * sc , c15 = 15 * sc , c50 = 50 * sc ;
const bottomLen = sD - f50 - rhW ; // 린텔위치~후면벽
return `
<!-- 레일 홀 -->
< rect x = " ${ rhX } " y = " ${ dy-vt } " width = " ${ rhW } " height = " ${ vt } " fill = " rgba(100,116,139,0.2) " stroke = " #64748b " stroke - width = " 1 " stroke - dasharray = " 3 2 " rx = " 1 " />
< rect x = " ${ rhX } " y = " ${ dy-railUp } " width = " ${ rhW } " height = " ${ railUp-vt } " fill = " rgba(100,116,139,0.08) " stroke = " #64748b " stroke - width = " 0.8 " stroke - dasharray = " 4 2 " rx = " 1 " />
< text x = " ${ rhX+rhW/2 } " y = " ${ dy-railUp/2+4 } " fill = " #64748b " font - size = " 7 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " > 레일 </ text >
<!-- 밑면 연속판 ( 린텔벽 ~ 후면벽 , 점검구 없음 ) -->
< rect x = " ${ rhX+rhW } " y = " ${ dy-vt } " width = " ${ bottomLen } " height = " ${ vt } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 1 " opacity = " 0.9 " />
<!-- 린텔측 상향 훅 ( 55 벽 + 30 립 ) -->
< rect x = " ${ rhX+rhW } " y = " ${ dy-55*sc } " width = " ${ vt } " height = " ${ 55*sc } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ rhX+rhW } " y = " ${ dy-55*sc } " width = " ${ 30*sc } " height = " ${ vt } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
2026-03-15 16:22:10 +09:00
<!-- 후면 전용 : 중간 훅 없음 ( 하나의 연속 절곡물 ) -->
2026-03-15 16:19:22 +09:00
< text x = " ${ ox+sD/2 } " y = " ${ dy+12 } " fill = " ${ brkCol } " font - size = " 7 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " opacity = " 0.6 " > 밑면 ( 연속 ) </ text >
` ;
}
2026-03-14 16:42:45 +09:00
2026-03-15 16:19:22 +09:00
// ── 양면(dual) / 밑면(bottom): 린텔 + 밑면 점검구 + 코너부 밑면선반 ──
2026-03-15 12:33:48 +09:00
const b20 = 20 * sc , b15 = 15 * sc , b50b = 50 * sc , b55 = 55 * sc , b30 = 30 * sc ;
2026-03-15 16:19:22 +09:00
const lintelX = ox + f50 + rhW ;
const lintelShelfEnd = lintelX + b50b ;
const lintelHookEnd = lintelShelfEnd + b20 ;
2026-03-15 17:15:41 +09:00
const doorMainSpan = bDoorW ;
2026-03-15 16:19:22 +09:00
const dw = doorMainSpan * sc ;
const df17 = f17 , df13 = f13 ;
const fy = dy - f15 ;
2026-03-15 11:43:05 +09:00
return `
2026-03-15 16:19:22 +09:00
<!-- 레일 홀 -->
< rect x = " ${ rhX } " y = " ${ dy-vt } " width = " ${ rhW } " height = " ${ vt } " fill = " rgba(100,116,139,0.2) " stroke = " #64748b " stroke - width = " 1 " stroke - dasharray = " 3 2 " rx = " 1 " />
< rect x = " ${ rhX } " y = " ${ dy-railUp } " width = " ${ rhW } " height = " ${ railUp-vt } " fill = " rgba(100,116,139,0.08) " stroke = " #64748b " stroke - width = " 0.8 " stroke - dasharray = " 4 2 " rx = " 1 " />
< text x = " ${ rhX+rhW/2 } " y = " ${ dy-railUp/2+4 } " fill = " #64748b " font - size = " 7 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " > 레일 </ text >
<!-- 린텔 -->
< rect x = " ${ lintelX } " y = " ${ dy-b55 } " width = " ${ vt } " height = " ${ b55 } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ lintelX } " y = " ${ dy-b55 } " width = " ${ b30 } " height = " ${ vt } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ lintelX } " y = " ${ dy-vt } " width = " ${ b50b } " height = " ${ vt } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ lintelX+b50b } " y = " ${ dy-b15-vt } " width = " ${ vt } " height = " ${ b15+vt } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
< rect x = " ${ lintelX+b50b } " y = " ${ dy-b15-vt } " width = " ${ b20 } " height = " ${ vt } " fill = " ${ brkCol } " stroke = " ${ brkStroke } " stroke - width = " 0.8 " opacity = " 0.85 " />
< text x = " ${ lintelX+b30/2 } " y = " ${ dy-b55/2 } " fill = " ${ brkCol } " font - size = " 6 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " opacity = " 0.6 " > 린텔 </ text >
<!-- 밑면 점검구 -->
< rect x = " ${ lintelHookEnd } " y = " ${ fy+df13 } " width = " ${ dw } " height = " ${ vt } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1.5 " stroke - dasharray = " 6 3 " />
< rect x = " ${ lintelHookEnd } " y = " ${ fy } " width = " ${ vt*0.7 } " height = " ${ df13+vt } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< rect x = " ${ lintelHookEnd-df17 } " y = " ${ fy } " width = " ${ df17+vt*0.3 } " height = " ${ vt*0.7 } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< rect x = " ${ lintelHookEnd+dw-vt*0.7 } " y = " ${ fy } " width = " ${ vt*0.7 } " height = " ${ df13+vt } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< rect x = " ${ lintelHookEnd+dw-vt*0.3 } " y = " ${ fy } " width = " ${ df17+vt*0.3 } " height = " ${ vt*0.7 } " fill = " ${ doorFill } " stroke = " ${ doorStroke } " stroke - width = " 1 " stroke - dasharray = " 4 2 " />
< line x1 = " ${ lintelHookEnd } " y1 = " ${ fy+df13+vt+8 } " x2 = " ${ lintelHookEnd+dw } " y2 = " ${ fy+df13+vt+8 } " stroke = " ${ doorStroke } " stroke - width = " 0.8 " />
< line x1 = " ${ lintelHookEnd } " y1 = " ${ fy+df13+vt+3 } " x2 = " ${ lintelHookEnd } " y2 = " ${ fy+df13+vt+13 } " stroke = " ${ doorStroke } " stroke - width = " 0.5 " />
< line x1 = " ${ lintelHookEnd+dw } " y1 = " ${ fy+df13+vt+3 } " x2 = " ${ lintelHookEnd+dw } " y2 = " ${ fy+df13+vt+13 } " stroke = " ${ doorStroke } " stroke - width = " 0.5 " />
< text x = " ${ lintelHookEnd+dw/2 } " y = " ${ fy+df13+vt+22 } " fill = " ${ doorStroke } " font - size = " 10 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > 밑면 점검구 $ { doorMainSpan } </ text >
2026-03-15 11:43:05 +09:00
` ;
})()}
2026-03-15 11:25:31 +09:00
<!-- ━━━━━ 슬랫 경로 ( 레일홀 → 상부 → 샤프트 시계방향 감김 ) ━━━━━ -->
2026-03-13 22:16:47 +09:00
$ {(() => {
2026-03-15 12:49:57 +09:00
const railCx = ox + f50 + railHoleW / 2 ; // 레일 개구 중앙
2026-03-15 11:25:31 +09:00
const rollTopY = shaftCy - rollR ;
2026-03-13 22:16:47 +09:00
return `
2026-03-15 11:21:41 +09:00
<!-- 케이스 외부 : 슬랫 하강 -->
< line x1 = " ${ railCx } " y1 = " ${ oy+sH } " x2 = " ${ railCx } " y2 = " ${ oy+sH+20 } " stroke = " #f59e0b " stroke - width = " 2 " stroke - dasharray = " 5 3 " opacity = " 0.5 " />
2026-03-15 11:36:49 +09:00
<!-- 케이스 내부 : 레일홀에서 샤프트 롤 좌측까지 대각선 직선 ( 팽팽한 천 ) -->
< line x1 = " ${ railCx } " y1 = " ${ oy+sH-vt } " x2 = " ${ shaftCx-rollR } " y2 = " ${ shaftCy } " stroke = " #f59e0b " stroke - width = " 2.5 " stroke - dasharray = " 5 3 " opacity = " 0.5 " />
2026-03-14 16:42:45 +09:00
< text x = " ${ railCx+12 } " y = " ${ oy+sH+18 } " fill = " #94a3b8 " font - size = " 8 " font - weight = " 700 " font - family = " Pretendard " > ↓ 슬랫 </ text >
2026-03-13 22:16:47 +09:00
` ;
})()}
2026-03-13 21:41:14 +09:00
2026-03-14 16:42:45 +09:00
<!-- ━━━━━ 내부 구성품 ━━━━━ -->
2026-03-08 19:30:05 +09:00
$ { b . showShaft ? `
< circle cx = " ${ shaftCx } " cy = " ${ shaftCy } " r = " ${ shaftR } " fill = " #475569 " stroke = " #64748b " stroke - width = " 2 " />
< circle cx = " ${ shaftCx } " cy = " ${ shaftCy } " r = " 4 " fill = " #94a3b8 " />
` : '' }
$ { b . showSlatRoll ? `
< circle cx = " ${ shaftCx } " cy = " ${ shaftCy } " r = " ${ rollR } " fill = " none " stroke = " #f59e0b " stroke - width = " 3 " opacity = " 0.5 " stroke - dasharray = " 6 3 " />
$ { Array . from ({ length : 5 },( _ , i ) => {
2026-03-14 16:42:45 +09:00
const r = shaftR + ( i + 1 ) * 4 * sc ;
2026-03-08 19:30:05 +09:00
return r < rollR ? `<circle cx="${shaftCx}" cy="${shaftCy}" r="${r}" fill="none" stroke="#f59e0b" stroke-width="0.5" opacity="0.3"/>` : '' ;
}) . join ( '' )}
` : '' }
2026-03-15 15:43:22 +09:00
$ { b . showMotor ? (() => {
2026-03-15 15:47:20 +09:00
const mR = shaftR * 1.1 ;
2026-03-15 15:50:06 +09:00
const gap = rollR + mR + 25 * sc ;
2026-03-15 15:43:22 +09:00
const mCx = shaftCx + gap ;
2026-03-15 15:47:20 +09:00
const mCy = shaftCy ;
2026-03-15 15:50:06 +09:00
const sprS = shaftR * 0.35 ; // 샤프트 스프로켓
const sprM = mR * 0.4 ; // 모터 스프로켓
2026-03-15 15:43:22 +09:00
return `
2026-03-15 15:50:06 +09:00
<!-- 모터 본체 -->
2026-03-15 15:43:22 +09:00
< circle cx = " ${ mCx } " cy = " ${ mCy } " r = " ${ mR } " fill = " #2563eb " opacity = " 0.25 " stroke = " #3b82f6 " stroke - width = " 1.5 " stroke - dasharray = " 4 2 " />
< text x = " ${ mCx } " y = " ${ mCy+3 } " fill = " #3b82f6 " font - size = " 8 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " opacity = " 0.6 " > M </ text >
2026-03-15 15:50:06 +09:00
<!-- 스프로켓 ( 샤프트측 + 모터측 ) -->
< circle cx = " ${ shaftCx } " cy = " ${ shaftCy } " r = " ${ sprS } " fill = " none " stroke = " #60a5fa " stroke - width = " 1.5 " opacity = " 0.6 " />
< circle cx = " ${ mCx } " cy = " ${ mCy } " r = " ${ sprM } " fill = " none " stroke = " #60a5fa " stroke - width = " 1.5 " opacity = " 0.6 " />
<!-- 체인 루프 ( 스프로켓 감싸는 벨트 형태 ) -->
< path d = " M ${ shaftCx},${shaftCy-sprS } L ${ mCx},${mCy-sprM } A ${ sprM},${sprM } 0 0 1 ${ mCx},${mCy+sprM } L ${ shaftCx},${shaftCy+sprS } A ${ sprS},${sprS } 0 0 1 ${ shaftCx},${shaftCy-sprS } Z " fill = " rgba(96,165,250,0.08) " stroke = " #60a5fa " stroke - width = " 1.5 " stroke - dasharray = " 4 2 " opacity = " 0.5 " />
2026-03-15 15:43:22 +09:00
` ;
})() : '' }
2026-03-13 21:41:14 +09:00
2026-03-14 16:42:45 +09:00
<!-- ━━━━━ 치수선 ━━━━━ -->
2026-03-13 21:41:14 +09:00
<!-- 깊이 ( 상단 ) -->
< line x1 = " ${ ox } " y1 = " ${ oy-15 } " x2 = " ${ ox+sD } " y2 = " ${ oy-15 } " stroke = " #3b82f6 " stroke - width = " 1 " />
< line x1 = " ${ ox } " y1 = " ${ oy-20 } " x2 = " ${ ox } " y2 = " ${ oy-5 } " stroke = " #3b82f6 " stroke - width = " 0.5 " />
< line x1 = " ${ ox+sD } " y1 = " ${ oy-20 } " x2 = " ${ ox+sD } " y2 = " ${ oy-5 } " stroke = " #3b82f6 " stroke - width = " 0.5 " />
2026-03-14 16:42:45 +09:00
< text x = " ${ ox+sD/2 } " y = " ${ oy-22 } " fill = " #3b82f6 " font - size = " 11 " font - weight = " 900 " text - anchor = " middle " font - family = " Pretendard " > $ { D } × $ { H } </ text >
<!-- 전체 높이 ( 좌측 ) -->
< line x1 = " ${ ox-20 } " y1 = " ${ oy } " x2 = " ${ ox-20 } " y2 = " ${ oy+sH } " stroke = " #3b82f6 " stroke - width = " 1 " />
< line x1 = " ${ ox-25 } " y1 = " ${ oy } " x2 = " ${ ox-15 } " y2 = " ${ oy } " stroke = " #3b82f6 " stroke - width = " 0.5 " />
< line x1 = " ${ ox-25 } " y1 = " ${ oy+sH } " x2 = " ${ ox-15 } " y2 = " ${ oy+sH } " stroke = " #3b82f6 " stroke - width = " 0.5 " />
< text x = " ${ ox-28 } " y = " ${ oy+sH/2+4 } " fill = " #3b82f6 " font - size = " 10 " font - weight = " 700 " text - anchor = " end " font - family = " Pretendard " > $ { H } </ text >
2026-03-15 16:19:22 +09:00
<!-- 후면 점검구 높이 ( 우측 1 단 , 양면 / 후면 모드만 표시 ) -->
$ { b . doorDir !== 'bottom' ? `
2026-03-14 16:42:45 +09:00
< line x1 = " ${ ox+sD+15 } " y1 = " ${ oy+srTopH } " x2 = " ${ ox+sD+15 } " y2 = " ${ oy+srTopH+srDoorH } " stroke = " ${ doorStroke } " stroke - width = " 1 " />
< line x1 = " ${ ox+sD+10 } " y1 = " ${ oy+srTopH } " x2 = " ${ ox+sD+20 } " y2 = " ${ oy+srTopH } " stroke = " ${ doorStroke } " stroke - width = " 0.5 " />
< line x1 = " ${ ox+sD+10 } " y1 = " ${ oy+srTopH+srDoorH } " x2 = " ${ ox+sD+20 } " y2 = " ${ oy+srTopH+srDoorH } " stroke = " ${ doorStroke } " stroke - width = " 0.5 " />
< text x = " ${ ox+sD+25 } " y = " ${ oy+srTopH+srDoorH/2+4 } " fill = " ${ doorStroke } " font - size = " 10 " font - weight = " 700 " text - anchor = " start " font - family = " Pretendard " > $ { rDoorH } </ text >
2026-03-15 16:19:22 +09:00
` : '' }
2026-03-15 15:08:20 +09:00
<!-- 밑면 점검구 치수 : 절곡판 IIFE 내에서 표시 -->
2026-03-13 21:41:14 +09:00
<!-- 측면 라벨 -->
2026-03-14 16:42:45 +09:00
< text x = " ${ ox-8 } " y = " ${ oy+sH/2 } " fill = " #94a3b8 " font - size = " 9 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " transform = " rotate(-90, ${ ox-8},${oy+sH/2 } ) " > 전면 ( 실내 ) </ text >
< text x = " ${ ox+sD+8 } " y = " ${ oy+sH/2 } " fill = " #94a3b8 " font - size = " 9 " font - weight = " 700 " text - anchor = " middle " font - family = " Pretendard " transform = " rotate(90, ${ ox+sD+8},${oy+sH/2 } ) " > 후면 ( 벽측 ) </ text >
2026-03-08 19:30:05 +09:00
</ svg > ` ;
2026-03-08 20:23:48 +09:00
displaySvg ( svg );
2026-03-08 19:30:05 +09:00
}
// ============================
// 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 ();
};
2026-03-15 17:23:01 +09:00
window . fsSet3dParam = function ( key , v ) {
S . sb [ key ] = Number ( v );
// 셔터박스 탭 입력과 양방향 동기화
2026-03-15 17:34:10 +09:00
const syncMap = { railWidth : 'sbRailWidth' , frontBottom : 'sbFrontBottom' , depth : 'sbDepth' , height : 'sbHeight' , shaftDia : 'sbShaftDia' , thickness : 'sbThickness' };
if ( syncMap [ key ] && $ ( syncMap [ key ])) $ ( syncMap [ key ]) . value = v ;
2026-03-15 17:23:01 +09:00
fs3dBuild ();
};
2026-03-15 16:36:41 +09:00
window . fsSetDoorDir = function ( v ) {
S . sb . doorDir = v ;
if ( $ ( 'sbDoorDir' )) $ ( 'sbDoorDir' ) . value = v ;
if ( $ ( 'td3dDoorDir' )) $ ( 'td3dDoorDir' ) . value = v ;
if ( S . tab === '3D' ) fs3dBuild (); else fsRender ();
};
2026-03-08 19:30:05 +09:00
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 );
2026-03-13 21:20:54 +09:00
// Grid (B키로 토글)
2026-03-08 19:30:05 +09:00
const grid = new THREE . GridHelper ( 5000 , 50 , 0x334155 , 0x1e293b );
grid . position . y = 0 ;
scene . add ( grid );
2026-03-13 21:20:54 +09:00
let floorGridRef = grid ; // B키 토글용 참조
2026-03-08 19:30:05 +09:00
2026-03-13 20:53:47 +09:00
// === TransformControls (클릭 선택 + 이동) ===
transformCtrl = new THREE . TransformControls ( camera , renderer . domElement );
transformCtrl . setMode ( 'translate' );
transformCtrl . setSize ( 0.8 );
scene . add ( transformCtrl );
// 드래그 중 OrbitControls 비활성화
transformCtrl . addEventListener ( 'dragging-changed' , ( e ) => {
controls . enabled = ! e . value ;
});
// 선택 HUD
const selectHud = document . createElement ( 'div' );
selectHud . className = 'fs-select-hud' ;
selectHud . style . cssText = 'position:absolute;top:8px;left:8px;display:none;background:rgba(0,0,0,0.7);color:#fff;padding:6px 12px;border-radius:6px;font-size:12px;pointer-events:none;z-index:10;font-family:Pretendard,sans-serif;' ;
container . appendChild ( selectHud );
function updateSelectHud () {
2026-03-13 21:05:24 +09:00
const hiddenInfo = hiddenKeys . size > 0
? `<span style="color:#f59e0b;margin-left:8px;">${hiddenKeys.size}개 감춤</span> <span style="opacity:0.5">Alt+H:전체 표시</span>`
: '' ;
2026-03-13 20:53:47 +09:00
if ( ! selectedKey ) {
2026-03-13 21:05:24 +09:00
if ( hiddenKeys . size > 0 ) {
selectHud . innerHTML = hiddenInfo ;
selectHud . style . display = 'block' ;
} else {
selectHud . style . display = 'none' ;
}
2026-03-13 20:53:47 +09:00
return ;
}
const label = meshLabels [ selectedKey ] || selectedKey ;
const axisAll = transformCtrl . showX && transformCtrl . showY && transformCtrl . showZ ;
let axisText = '전체 축' ;
let axisColor = '#fff' ;
if ( ! axisAll ) {
if ( transformCtrl . showX ) { axisText = 'X축' ; axisColor = '#ef4444' ; }
if ( transformCtrl . showY ) { axisText = 'Y축' ; axisColor = '#22c55e' ; }
if ( transformCtrl . showZ ) { axisText = 'Z축' ; axisColor = '#3b82f6' ; }
}
2026-03-13 21:05:53 +09:00
selectHud . innerHTML = `<b>${label}</b> 선택 · <span style="color:${axisColor}">${axisText}</span> 이동 <span style="opacity:0.5">| 화살표 기즈모를 드래그하여 이동 · X Y Z:축 · H:감추기 · ESC:해제</span>${hiddenInfo}` ;
2026-03-13 20:53:47 +09:00
selectHud . style . display = 'block' ;
}
function selectMesh ( key ) {
deselectMesh ();
selectedKey = key ;
const obj = meshes [ key ];
transformCtrl . attach ( obj );
transformCtrl . showX = true ;
transformCtrl . showY = true ;
transformCtrl . showZ = true ;
// 선택 박스 (녹색 바운딩박스)
selectionBox = new THREE . BoxHelper ( obj , 0x22c55e );
scene . add ( selectionBox );
updateSelectHud ();
}
function deselectMesh () {
if ( selectionBox ) { scene . remove ( selectionBox ); selectionBox = null ; }
if ( selectedKey ) { transformCtrl . detach (); selectedKey = null ; }
updateSelectHud ();
}
// 클릭 감지 (드래그와 구분)
let clickStart = null ;
renderer . domElement . addEventListener ( 'pointerdown' , ( e ) => {
if ( e . button === 0 ) clickStart = { x : e . clientX , y : e . clientY };
});
renderer . domElement . addEventListener ( 'pointerup' , ( e ) => {
if ( ! clickStart || e . button !== 0 ) return ;
const dx = e . clientX - clickStart . x , dy = e . clientY - clickStart . y ;
clickStart = null ;
if ( Math . sqrt ( dx * dx + dy * dy ) > 4 ) return ; // 드래그면 무시
if ( transformCtrl . dragging ) return ; // 기즈모 조작 중이면 무시
const rect = renderer . domElement . getBoundingClientRect ();
mouse . x = (( e . clientX - rect . left ) / rect . width ) * 2 - 1 ;
mouse . y = - (( e . clientY - rect . top ) / rect . height ) * 2 + 1 ;
raycaster . setFromCamera ( mouse , camera );
2026-03-13 23:17:49 +09:00
const sceneObjects = Object . entries ( meshes ) . filter (([ k , m ]) => m && ! hiddenKeys . has ( k )) . map (([, m ]) => m );
2026-03-13 20:53:47 +09:00
const intersects = raycaster . intersectObjects ( sceneObjects , true );
if ( intersects . length > 0 ) {
const key = findMeshKey ( intersects [ 0 ] . object );
if ( key && meshes [ key ]) selectMesh ( key );
} else {
deselectMesh ();
}
});
2026-03-13 21:05:24 +09:00
// 키보드 단축키 (축 제한 + 감추기)
2026-03-13 20:53:47 +09:00
document . addEventListener ( 'keydown' , ( e ) => {
2026-03-13 21:05:24 +09:00
if ( S . tab !== '3D' ) return ;
2026-03-13 20:53:47 +09:00
const k = e . key . toLowerCase ();
2026-03-13 21:05:24 +09:00
// Alt+H: 감춘 요소 전체 표시
if ( k === 'h' && e . altKey ) {
e . preventDefault ();
if ( hiddenKeys . size === 0 ) return ;
hiddenKeys . forEach ( hk => {
if ( meshes [ hk ]) meshes [ hk ] . visible = true ;
2026-03-15 17:55:28 +09:00
// S.td.show + 토글 UI 복원
if ( hk in S . td . show ) {
S . td . show [ hk ] = true ;
document . querySelectorAll ( `[onclick*="fsToggle3d(this,'${hk}')"]` ) . forEach ( el => el . classList . add ( 'active' ));
}
2026-03-13 21:05:24 +09:00
});
hiddenKeys . clear ();
updateSelectHud ();
return ;
}
2026-03-13 21:20:54 +09:00
// B: 바닥 그리드 토글
if ( k === 'b' && ! e . altKey && ! e . ctrlKey ) {
if ( floorGridRef ) floorGridRef . visible = ! floorGridRef . visible ;
return ;
}
2026-03-15 17:55:28 +09:00
// H: 선택된 요소 감추기 (토글 UI 연동)
2026-03-13 21:05:24 +09:00
if ( k === 'h' && selectedKey ) {
const hideKey = selectedKey ;
deselectMesh ();
if ( meshes [ hideKey ]) {
meshes [ hideKey ] . visible = false ;
hiddenKeys . add ( hideKey );
2026-03-15 17:55:28 +09:00
// S.td.show 상태 + 토글 UI 동기화
if ( hideKey in S . td . show ) {
S . td . show [ hideKey ] = false ;
document . querySelectorAll ( `[onclick*="fsToggle3d(this,'${hideKey}')"]` ) . forEach ( el => el . classList . remove ( 'active' ));
}
// 슬랫 숨기면 감긴 슬랫도 연동
if ( hideKey === 'slats' ) {
S . td . show . slatRoll = false ;
if ( meshes . slatRoll ) meshes . slatRoll . visible = false ;
}
2026-03-13 21:05:24 +09:00
}
updateSelectHud ();
return ;
}
// 아래는 선택된 상태에서만 동작
if ( ! selectedKey ) return ;
2026-03-13 20:53:47 +09:00
if ( k === 'x' ) {
transformCtrl . showX = true ; transformCtrl . showY = false ; transformCtrl . showZ = false ;
updateSelectHud ();
} else if ( k === 'y' ) {
transformCtrl . showX = false ; transformCtrl . showY = true ; transformCtrl . showZ = false ;
updateSelectHud ();
} else if ( k === 'z' ) {
transformCtrl . showX = false ; transformCtrl . showY = false ; transformCtrl . showZ = true ;
updateSelectHud ();
} else if ( k === 'a' || k === ' ' ) {
transformCtrl . showX = true ; transformCtrl . showY = true ; transformCtrl . showZ = true ;
updateSelectHud ();
} else if ( k === 'escape' ) {
deselectMesh ();
}
});
2026-03-08 19:30:05 +09:00
function animate () {
animId = requestAnimationFrame ( animate );
controls . update ();
2026-03-13 20:53:47 +09:00
if ( selectionBox ) selectionBox . update ();
2026-03-08 19:30:05 +09:00
renderer . render ( scene , camera );
}
animate ();
2026-03-08 20:17:05 +09:00
// 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 );
}
2026-03-08 19:30:05 +09:00
});
2026-03-08 20:17:05 +09:00
ro . observe ( container );
2026-03-09 09:38:27 +09:00
// === 우클릭 컨텍스트 메뉴 (단품 보기 / 전체 보기) ===
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 );
2026-03-14 08:42:25 +09:00
// ── 뷰 전환 패널 (정면/평면/우측면/좌측면/투시) ──
const viewPanel = document . createElement ( 'div' );
viewPanel . style . cssText = 'position:absolute;top:8px;right:8px;display:flex;gap:4px;z-index:10;' ;
const viewPresets = [
{ label : '정면' , icon : '⬜' , key : 'front' },
{ label : '평면' , icon : '⬒' , key : 'top' },
{ label : '우측' , icon : '▷' , key : 'right' },
{ label : '좌측' , icon : '◁' , key : 'left' },
{ label : '배면' , icon : '⬛' , key : 'back' },
{ label : '투시' , icon : '◈' , key : 'persp' },
];
viewPresets . forEach ( vp => {
const btn = document . createElement ( 'button' );
btn . style . cssText = 'padding:5px 10px;background:rgba(30,41,59,0.85);border:1px solid rgba(100,116,139,0.4);color:#94a3b8;font-size:11px;font-weight:700;border-radius:6px;cursor:pointer;font-family:Pretendard,sans-serif;transition:all 0.15s;' ;
btn . textContent = vp . label ;
btn . onmouseenter = () => { btn . style . background = 'rgba(59,130,246,0.3)' ; btn . style . color = '#fff' ; btn . style . borderColor = '#3b82f6' ; };
btn . onmouseleave = () => { btn . style . background = 'rgba(30,41,59,0.85)' ; btn . style . color = '#94a3b8' ; btn . style . borderColor = 'rgba(100,116,139,0.4)' ; };
btn . onclick = () => fs3dSetView ( vp . key );
viewPanel . appendChild ( btn );
});
container . appendChild ( viewPanel );
2026-03-09 09:38:27 +09:00
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 );
2026-03-13 23:17:49 +09:00
const sceneObjects = Object . entries ( meshes ) . filter (([ k , m ]) => m && ! hiddenKeys . has ( k )) . map (([, m ]) => m );
2026-03-09 09:38:27 +09:00
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' ; });
2026-03-08 19:30:05 +09:00
}
2026-03-08 21:11:52 +09:00
let fs3dCameraInit = false ;
2026-03-08 19:30:05 +09:00
function fs3dBuild () {
if ( ! scene ) return ;
2026-03-13 21:05:24 +09:00
// 선택/숨기기 해제 (리빌드 시 메시가 교체되므로)
2026-03-13 20:53:47 +09:00
if ( transformCtrl ) transformCtrl . detach ();
if ( selectionBox ) { scene . remove ( selectionBox ); selectionBox = null ; }
selectedKey = null ;
2026-03-13 21:05:24 +09:00
hiddenKeys . clear ();
2026-03-08 19:30:05 +09:00
// 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 ;
2026-03-09 16:05:37 +09:00
// 셔터박스 Z 배치 — 도면 기준
2026-03-09 17:58:47 +09:00
// 스크린: Z방향 = width(개구부 70mm), 철재: Z방향 = depth(75mm)
const railZSpan = S . productType === 'screen' ? p . gr . width : p . gr . depth ;
const railHalf = railZSpan / 2 ; // 스크린:35, 철재:37.5
2026-03-09 16:27:20 +09:00
const frontOffset = railHalf + 50 ; // 박스 전면판 Z (스크린:85, 철재:110)
2026-03-09 16:13:46 +09:00
const boxCenterZ = frontOffset - b . depth / 2 ;
2026-03-09 16:27:20 +09:00
// 샤프트 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 여유
}
2026-03-09 10:06:37 +09:00
2026-03-09 11:38:53 +09:00
// === SHUTTER BOX (CASE) — 조립식 철판 구조 ===
2026-03-13 23:01:46 +09:00
// 절곡도 기반: 후면판, 상판, 전면 상부판, 점검구(탈착), 바닥판, 좌우측판
2026-03-09 11:38:53 +09:00
const pt = b . thickness || 1.6 ; // 철판 두께
2026-03-13 23:01:46 +09:00
const frontH = p . sb . frontH ; // 전면 상부판 높이 (스크린:240, 철재:410)
const doorH = b . height - frontH ; // 점검구 높이 (스크린:140, 철재:140)
const junctionY = b . height - frontH ; // 상부판-점검구 접합부 Y위치
2026-03-08 19:30:05 +09:00
const boxMat = new THREE . MeshStandardMaterial ({ color : 0x374151 , transparent : true , opacity : S . td . caseOpacity , side : THREE . DoubleSide });
2026-03-13 23:01:46 +09:00
const doorMat = new THREE . MeshStandardMaterial ({ color : 0x5c4833 , transparent : true , opacity : Math . min ( S . td . caseOpacity * 0.85 , 1 ), side : THREE . DoubleSide });
2026-03-09 11:38:53 +09:00
const boxEdgeMat = new THREE . LineBasicMaterial ({ color : 0x94a3b8 });
2026-03-13 23:01:46 +09:00
const doorEdgeMat = new THREE . LineBasicMaterial ({ color : 0xd97706 });
2026-03-09 11:38:53 +09:00
meshes . case = new THREE . Group ();
2026-03-09 15:43:02 +09:00
meshes . case . position . set ( 0 , H , boxCenterZ );
2026-03-09 11:38:53 +09:00
2026-03-13 23:01:46 +09:00
// 철판 생성 헬퍼 (mat, eMat: 선택적 재질 오버라이드)
function addPlate ( w , h , d , x , y , z , mat , eMat ) {
2026-03-09 11:38:53 +09:00
const geo = new THREE . BoxGeometry ( w , h , d );
2026-03-13 23:01:46 +09:00
const m = new THREE . Mesh ( geo , mat || boxMat );
2026-03-09 11:38:53 +09:00
m . position . set ( x , y , z );
meshes . case . add ( m );
const edges = new THREE . EdgesGeometry ( geo );
2026-03-13 23:01:46 +09:00
m . add ( new THREE . LineSegments ( edges , eMat || boxEdgeMat ));
2026-03-09 11:38:53 +09:00
}
2026-03-08 19:30:05 +09:00
2026-03-14 16:42:45 +09:00
const isDualDoor = !! p . sb . rearDoorH ; // 양면점검구 여부 (screen/wire)
if ( isDualDoor ) {
// ━━━━━ 양면점검구 구조 (screen, wire slat) ━━━━━
2026-03-15 17:15:41 +09:00
const fbVal = b . frontBottom || 50 ;
const rwVal = b . railWidth || 70 ;
const bDoorW = b . depth - fbVal - rwVal - 50 - 20 - 20 - 50 ; // 밑면 점검구 (가변)
const rDoorH = b . height - 50 - 20 - 20 - 50 ; // 후면 점검구 (가변)
2026-03-14 16:42:45 +09:00
const flgSz = 17 ; // 리시빙 플랜지 (고정부)
const tabSz = 13 ; // 스크류 탭 (점검구)
const retCorner = 50 ; // 상판 후면측 코너 리턴
// 후면: 높이 방향 가운데 정렬
const rTopH = Math . round (( b . height - rDoorH ) / 2 );
const rBotH = b . height - rDoorH - rTopH ;
// 밑면: 깊이 방향 가운데 정렬
const bFrontW = Math . round (( b . depth - bDoorW ) / 2 );
const bRearW = b . depth - bDoorW - bFrontW ;
2026-03-15 10:51:07 +09:00
// ── ① 케이스 전면판 절곡 프로파일 ──
2026-03-15 17:02:02 +09:00
const fpSeg = [ ... ( p . sb . frontPanel || [ 17 , 55 , 50 , 380 , 55 , 15 , 20 ])];
fpSeg [ 2 ] = b . frontBottom || 50 ; // 전면 밑치수 가변 적용
2026-03-15 10:21:47 +09:00
const frontZ = b . depth / 2 ;
// Seg4: 본체 (전체 높이 수직면)
addPlate ( W1 , b . height , pt , 0 , b . height / 2 , frontZ - pt / 2 );
2026-03-15 15:24:07 +09:00
// ── 상부: 55→(선반) 15↓(스텝) 20→(커버 받침 립) ──
// Seg5: 55mm → (케이스 상단에서 내향, 커버 선반)
addPlate ( W1 , pt , fpSeg [ 4 ], 0 , b . height - pt / 2 , frontZ - fpSeg [ 4 ] / 2 );
// Seg6: 15mm ↓ (Seg5 내측 끝에서 하향, 커버 두께 스텝)
addPlate ( W1 , fpSeg [ 5 ], pt , 0 , b . height - fpSeg [ 5 ] / 2 , frontZ - fpSeg [ 4 ] + pt / 2 );
// Seg7: 20mm → (Seg6 하단에서 더 내향, 커버 받침 립)
addPlate ( W1 , pt , fpSeg [ 6 ], 0 , b . height - fpSeg [ 5 ] + pt / 2 , frontZ - fpSeg [ 4 ] - fpSeg [ 6 ] / 2 );
2026-03-15 10:51:07 +09:00
// ── 하부: J-훅 (50→ 내향, 55↑ 상향, 17← 전면복귀) ──
2026-03-15 10:34:43 +09:00
// Seg3: 50mm → (케이스 바닥에서 내향)
2026-03-15 10:21:47 +09:00
addPlate ( W1 , pt , fpSeg [ 2 ], 0 , pt / 2 , frontZ - fpSeg [ 2 ] / 2 );
2026-03-15 10:42:51 +09:00
// Seg2: 55mm ↑ (Seg3 끝에서 케이스 내부 위로)
addPlate ( W1 , fpSeg [ 1 ], pt , 0 , fpSeg [ 1 ] / 2 , frontZ - fpSeg [ 2 ] + pt / 2 );
// Seg1: 17mm ← (Seg2 상단에서 전면 방향으로 복귀)
addPlate ( W1 , pt , fpSeg [ 0 ], 0 , fpSeg [ 1 ] - pt / 2 , frontZ - fpSeg [ 2 ] + fpSeg [ 0 ] / 2 );
2026-03-14 16:42:45 +09:00
2026-03-15 12:42:03 +09:00
// ── 밑면레일연결절곡물 (린텔, 전면에서 70mm 레일 띄우고 위치) ──
// 전면판 J-훅과 Y축 대칭, CCW: Seg1(20→)─Seg2(15↑)─Seg3(50←)─Seg4(55↑)─Seg5(30→)
2026-03-15 11:43:05 +09:00
const brkMat = new THREE . MeshStandardMaterial ({ color : 0x7c9c6b , transparent : true , opacity : S . td . caseOpacity , side : THREE . DoubleSide });
2026-03-15 16:53:44 +09:00
const railW = b . railWidth || ( S . productType === 'screen' ? 70 : 120 );
2026-03-15 12:42:03 +09:00
const brkZ = frontZ - fpSeg [ 2 ] - railW ; // 전면판 Seg3(50) + 레일(70) 뒤
// Seg4: 좌측벽 (55mm ↑, 밑면에서 위로, 린텔 메인벽)
addPlate ( W1 , 55 , pt , 0 , 55 / 2 , brkZ + pt / 2 , brkMat );
// Seg5: 상부립 (30mm → 내향, 벽 상단)
addPlate ( W1 , pt , 30 , 0 , 55 - pt / 2 , brkZ - 30 / 2 , brkMat );
// Seg3: 하부선반 (50mm → 내향, 밑면 레벨)
addPlate ( W1 , pt , 50 , 0 , pt / 2 , brkZ - 50 / 2 , brkMat );
2026-03-15 12:46:07 +09:00
// Seg2: 후크벽 (15mm ↑, 선반 내측 끝에서 상향, 케이스 내부로)
addPlate ( W1 , 15 , pt , 0 , 15 / 2 , brkZ - 50 - pt / 2 , brkMat );
// Seg1: 하부립 (20mm → 내향, 후크 상단에서)
addPlate ( W1 , pt , 20 , 0 , 15 - pt / 2 , brkZ - 50 - 20 / 2 , brkMat );
2026-03-15 11:43:05 +09:00
2026-03-15 14:04:36 +09:00
// ── 밑면 코너부 (6세그먼트: 20,15,50,50,15,20, 후면벽↔밑면 L-코너) ──
const cornerMat = new THREE . MeshStandardMaterial ({ color : 0x8b6c5c , transparent : true , opacity : S . td . caseOpacity , side : THREE . DoubleSide });
const cornerZ = - b . depth / 2 + pt ; // 후면벽 내측 Z
// Seg3: 수직부 (50mm, 후면벽 내측 하부)
addPlate ( W1 , 50 , pt , 0 , 50 / 2 , cornerZ + pt / 2 , cornerMat );
// Seg1: 상부 립 (20mm ↑, 수직부 상단에서 상향)
addPlate ( W1 , 20 , pt * 0.7 , 0 , 50 + 20 / 2 , cornerZ + pt / 2 , cornerMat );
// Seg2: 상부 스텝 (15mm → 내향, 수직부 상단)
addPlate ( W1 , pt * 0.7 , 15 , 0 , 50 , cornerZ + pt + 15 / 2 , cornerMat );
// Seg4: 수평부 (50mm, 밑면 선반)
addPlate ( W1 , pt , 50 , 0 , pt / 2 , cornerZ + 50 / 2 , cornerMat );
// Seg5: 하부 스텝 (15mm ↑, 선반 내측 끝에서 상향)
addPlate ( W1 , 15 , pt * 0.7 , 0 , 15 / 2 , cornerZ + 50 + pt / 2 , cornerMat );
// Seg6: 하부 립 (20mm → 내향, 점검구 우측 결합)
addPlate ( W1 , pt * 0.7 , 20 , 0 , 15 , cornerZ + 50 + 20 / 2 , cornerMat );
2026-03-15 11:03:14 +09:00
// ── ② 상부 커버는 케이스 공통으로 아래에서 렌더링 ──
2026-03-14 16:42:45 +09:00
2026-03-15 16:33:19 +09:00
const doorDir = b . doorDir || 'dual' ;
const rearZ = - b . depth / 2 + pt / 2 ; // 후면벽 Z
2026-03-14 16:42:45 +09:00
2026-03-15 16:33:19 +09:00
// ── 후면벽 구조 (doorDir 분기) ──
if ( doorDir === 'bottom' ) {
// 밑면 전용: 후면벽 연속판 (상부훅+벽+하부훅)
addPlate ( W1 , b . height , pt , 0 , b . height / 2 , rearZ ); // 전체 높이 벽
addPlate ( W1 , pt , 50 , 0 , b . height - pt / 2 , rearZ + pt / 2 + 25 ); // 상부 선반
addPlate ( W1 , pt , 50 , 0 , pt / 2 , rearZ + pt / 2 + 25 ); // 하부 선반
} else {
// 양면/후면: 상부고정부 + 점검구 + 하부고정부
addPlate ( W1 , rTopH , pt , 0 , b . height - rTopH / 2 , rearZ );
addPlate ( W1 , rBotH , pt , 0 , rBotH / 2 , rearZ );
// 후면 점검구 (탈착)
addPlate ( W1 , rDoorH , pt , 0 , rBotH + rDoorH / 2 , rearZ , doorMat , doorEdgeMat );
}
2026-03-14 16:42:45 +09:00
// ── 좌우측판 ──
addPlate ( pt , b . height , b . depth , - W1 / 2 + pt / 2 , b . height / 2 , 0 );
addPlate ( pt , b . height , b . depth , W1 / 2 - pt / 2 , b . height / 2 , 0 );
2026-03-15 16:33:19 +09:00
// ── 밑면 구조 (doorDir 분기) ──
2026-03-14 16:42:45 +09:00
const openW = railZSpan ;
2026-03-15 16:33:19 +09:00
if ( doorDir === 'rear' ) {
// 후면 전용: 밑면 연속판 (레일홀 후~후면벽)
const contStart = frontZ - fpSeg [ 2 ] - railW ;
const contLen = b . depth / 2 + contStart ;
addPlate ( W1 , pt , contLen , 0 , pt / 2 , contStart - contLen / 2 );
} else {
// 양면/밑면: 린텔~점검구~코너부 구조
const solidFront = bFrontW - openW ;
if ( solidFront > 0 ) addPlate ( W1 , pt , solidFront , 0 , pt / 2 , b . depth / 2 - solidFront / 2 );
addPlate ( W1 , pt , bRearW , 0 , pt / 2 , - b . depth / 2 + bRearW / 2 );
// 밑면 점검구 (탈착)
addPlate ( W1 , pt , bDoorW , 0 , pt / 2 , b . depth / 2 - bFrontW - bDoorW / 2 , doorMat , doorEdgeMat );
2026-03-14 16:51:16 +09:00
}
2026-03-14 16:42:45 +09:00
} else {
// ━━━━━ 기존 전면 점검구 구조 (steel) ━━━━━
const flangeBack = 55 , flangeFront = 50 , returnW = 50 ;
// 후면판 (전체 높이)
addPlate ( W1 , b . height , pt , 0 , b . height / 2 , - b . depth / 2 + pt / 2 );
addPlate ( W1 , pt , flangeBack , 0 , b . height - pt / 2 , - b . depth / 2 + flangeBack / 2 );
// 전면 상부판 (frontH)
addPlate ( W1 , frontH , pt , 0 , b . height - frontH / 2 , b . depth / 2 - pt / 2 );
addPlate ( W1 , pt , flangeFront , 0 , b . height - pt / 2 , b . depth / 2 - flangeFront / 2 );
addPlate ( W1 , pt , returnW , 0 , junctionY - pt / 2 , b . depth / 2 - pt - returnW / 2 );
// 전면 점검구 (doorH, 탈착식)
if ( doorH > 0 ) {
addPlate ( W1 , doorH , pt , 0 , doorH / 2 , b . depth / 2 - pt / 2 , doorMat , doorEdgeMat );
addPlate ( W1 , pt , returnW , 0 , junctionY + pt / 2 , b . depth / 2 - pt - returnW / 2 , doorMat , doorEdgeMat );
}
2026-03-09 13:00:41 +09:00
2026-03-14 16:42:45 +09:00
// 좌우측판
addPlate ( pt , b . height , b . depth , - W1 / 2 + pt / 2 , b . height / 2 , 0 );
addPlate ( pt , b . height , b . depth , W1 / 2 - pt / 2 , b . height / 2 , 0 );
// 바닥판 (레일 개구부 제외)
const frontLipD = 50 , openW = railZSpan ;
const backLipD = b . depth - frontLipD - openW ;
if ( frontLipD > 0 ) addPlate ( W1 , pt , frontLipD , 0 , pt / 2 , b . depth / 2 - frontLipD / 2 );
if ( backLipD > 0 ) addPlate ( W1 , pt , backLipD , 0 , pt / 2 , - b . depth / 2 + backLipD / 2 );
}
2026-03-09 13:00:41 +09:00
2026-03-15 11:03:14 +09:00
// ── 상부 커버 (절곡 없는 평판, 전면판 립 안쪽 10mm에 올라감) ──
// 폭: W1 - 20 (양쪽 10mm 축소), 깊이: depth - 20 (양쪽 10mm 축소)
// 중앙 정렬 → 전면에서 10mm, 후면에서 10mm 들어간 위치
addPlate ( W1 - 20 , pt , b . depth - 20 , 0 , b . height + pt / 2 , 0 );
2026-03-09 11:38:53 +09:00
scene . add ( meshes . case );
2026-03-08 19:30:05 +09:00
2026-03-09 07:52:19 +09:00
// === SHAFT ASSEMBLY (양쪽 브라켓 관통, 슬랫 폭과 동일) ===
2026-03-08 20:58:57 +09:00
const shaftY = H + b . height * 0.5 ; // 샤프트 중심 Y
2026-03-08 19:30:05 +09:00
const shaftMat = new THREE . MeshStandardMaterial ({ color : 0x64748b , metalness : 0.6 , roughness : 0.3 });
2026-03-08 20:58:57 +09:00
const bracketMat = new THREE . MeshStandardMaterial ({ color : 0x4b5563 , metalness : 0.5 , roughness : 0.4 });
meshes . shaft = new THREE . Group ();
2026-03-09 07:52:19 +09:00
const motorDir = b . motorSide === 'right' ? 1 : - 1 ;
const nonMotorSide = - motorDir ;
2026-03-09 15:30:27 +09:00
// 브라켓 치수 (제품 유형별)
const bk = p . bk ;
const bkThick = bk . thick ; // 두께 (X방향, 철판)
const bkH = bk . nmH ; // 비모터측 높이 (Y방향)
const bkD = bk . nmD ; // 비모터측 깊이 (Z방향)
2026-03-09 08:12:08 +09:00
2026-03-09 15:30:27 +09:00
// 모터측 브라켓
const motorBkW = bk . mtD ; // 깊이 (Z방향, 샤프트~모터 거리 수용)
const motorBkH = bk . mtH ; // 높이 (Y방향)
const motorBkD = bk . thick ; // 두께 (X방향, 철판)
2026-03-09 07:52:19 +09:00
const shaftFromInner = 90 ; // 브라켓 내면에서 샤프트 중심까지 거리 (도면 기준)
2026-03-08 20:58:57 +09:00
2026-03-09 08:24:55 +09:00
// 환봉/플랜지 치수
const stubPinR = 15 ; // 환봉 반지름 (ø30)
const stubPinTotal = 300 ; // 환봉 전체 길이
const stubPinVisible = 200 ; // 밖에서 보이는 길이 (플랜지 밖)
const stubPinInside = stubPinTotal - stubPinVisible ; // 플랜지 안쪽 (100mm)
const shaftR = b . shaftDia / 2 ;
2026-03-09 08:30:08 +09:00
const shaftWallThick = 4 ; // 샤프트 관 두께 (4mm)
const flangeR = shaftR - shaftWallThick - 0.5 ; // 플랜지 외경 = 샤프트 내경 - 1mm 공차
2026-03-09 08:24:55 +09:00
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 ;
2026-03-08 20:58:57 +09:00
2026-03-09 13:17:29 +09:00
// --- Non-motor side Bracket (180× 180mm, 두께 18mm) — motor 그룹에 포함 (브라켓 한쌍) ---
2026-03-09 08:12:08 +09:00
const bkGeo = new THREE . BoxGeometry ( bkThick , bkH , bkD );
2026-03-08 22:06:15 +09:00
const bkMesh = new THREE . Mesh ( bkGeo , bracketMat );
2026-03-09 08:19:19 +09:00
const bkCenterX = nonMotorSide * ( W1 / 2 - bkThick / 2 );
bkMesh . position . set ( bkCenterX , 0 , 0 );
2026-03-09 13:17:29 +09:00
// meshes.motor에 추가 (아래에서 motor 그룹 생성 후 추가)
2026-03-08 20:58:57 +09:00
2026-03-09 08:24:55 +09:00
// 비모터측 환봉 (브라켓에서 안쪽으로 200mm 돌출)
const stubPinGeo = new THREE . CylinderGeometry ( stubPinR , stubPinR , stubPinVisible , 16 );
2026-03-09 08:19:19 +09:00
stubPinGeo . rotateZ ( Math . PI / 2 );
2026-03-09 08:24:55 +09:00
const stubPin = new THREE . Mesh ( stubPinGeo , stubPinMat );
2026-03-09 08:19:19 +09:00
const bkInnerFaceX = nonMotorSide * ( W1 / 2 - bkThick );
2026-03-09 08:24:55 +09:00
stubPin . position . set ( bkInnerFaceX - nonMotorSide * stubPinVisible / 2 , 0 , 0 );
2026-03-09 08:19:19 +09:00
meshes . shaft . add ( stubPin );
2026-03-09 08:53:41 +09:00
// 모터측: 3인치 연결관 + 복주머니 (브라켓 직접 체결)
2026-03-09 08:41:20 +09:00
const reducerR = 38 ; // 3인치 관 반지름 (ø76mm ≈ 3")
2026-03-09 08:53:41 +09:00
const bokjuR = reducerR + 8 ; // 복주머니 외경
2026-03-09 08:41:20 +09:00
const bokjuLen = 35 ; // 복주머니 길이
const bokjuMat = new THREE . MeshStandardMaterial ({ color : 0x6b7280 , metalness : 0.6 , roughness : 0.3 });
2026-03-09 08:24:55 +09:00
const mtBkInnerFaceX = motorDir * ( W1 / 2 - motorBkD );
2026-03-09 08:53:41 +09:00
const shaftEndX = motorDir * ( mainShaftLen / 2 ); // 샤프트 관 끝
2026-03-09 08:41:20 +09:00
// 복주머니 (브라켓 내면에서 바로 안쪽, 브라켓과 직접 결합)
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 );
2026-03-09 08:53:41 +09:00
// 3인치 연결관 (복주머니 ~ 샤프트 관 끝, 플랜지 없이 직접 용접)
const reducerStartX = mtBkInnerFaceX - motorDir * bokjuLen ;
const reducerEndX = shaftEndX ;
const reducerLen = Math . abs ( reducerStartX - reducerEndX );
2026-03-09 08:41:20 +09:00
const reducerGeo = new THREE . CylinderGeometry ( reducerR , reducerR , reducerLen , 24 );
reducerGeo . rotateZ ( Math . PI / 2 );
const reducer = new THREE . Mesh ( reducerGeo , stubPinMat );
2026-03-09 08:53:41 +09:00
reducer . position . set (( reducerStartX + reducerEndX ) / 2 , 0 , 0 );
2026-03-09 08:41:20 +09:00
meshes . shaft . add ( reducer );
2026-03-09 08:19:19 +09:00
2026-03-09 08:53:41 +09:00
// --- Main Shaft (원형관) ---
2026-03-09 08:19:19 +09:00
const msGeo = new THREE . CylinderGeometry ( shaftR , shaftR , mainShaftLen , 32 );
2026-03-08 20:58:57 +09:00
msGeo . rotateZ ( Math . PI / 2 );
const msMesh = new THREE . Mesh ( msGeo , shaftMat );
2026-03-09 08:41:20 +09:00
msMesh . position . set ( 0 , 0 , 0 );
2026-03-08 20:58:57 +09:00
meshes . shaft . add ( msMesh );
2026-03-09 08:53:41 +09:00
// 비모터측 플랜지만 (샤프트 내경에 삽입 용접)
2026-03-09 08:19:19 +09:00
const flangeGeo = new THREE . CylinderGeometry ( flangeR , flangeR , flangeThick , 32 );
flangeGeo . rotateZ ( Math . PI / 2 );
const flangeNM = new THREE . Mesh ( flangeGeo , flangeMat );
2026-03-09 08:24:55 +09:00
flangeNM . position . set ( nonMotorSide * ( mainShaftLen / 2 + flangeThick / 2 ), 0 , 0 );
2026-03-09 08:19:19 +09:00
meshes . shaft . add ( flangeNM );
2026-03-09 15:43:02 +09:00
meshes . shaft . position . set ( 0 , shaftY , shaftCenterZ );
2026-03-08 19:30:05 +09:00
scene . add ( meshes . shaft );
2026-03-09 08:06:34 +09:00
// === MOTOR (체인 구동: 모터는 샤프트와 수평(Z방향) 배치 — 슬랫 간섭 방지) ===
2026-03-08 21:09:23 +09:00
meshes . motor = new THREE . Group ();
2026-03-09 13:17:29 +09:00
meshes . motor . add ( bkMesh ); // 비모터측 브라켓도 motor 그룹에 포함 (브라켓 한쌍)
2026-03-08 22:03:23 +09:00
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 });
2026-03-09 07:52:19 +09:00
const chainMat = new THREE . MeshStandardMaterial ({ color : 0xdc2626 , metalness : 0.3 , roughness : 0.5 });
2026-03-09 08:06:34 +09:00
// --- 1) 모터측 브라켓 (380× 180mm, 벽면 부착 — YZ 평면 판) ---
// 380mm=Z방향(깊이, 샤프트~모터 수용), 180mm=Y방향(높이), 18mm=X방향(두께)
const motorBkThick = motorBkD ; // 18mm
2026-03-09 07:52:19 +09:00
const motorBkCX = motorDir * ( W1 / 2 - motorBkThick / 2 );
2026-03-09 08:06:34 +09:00
const motorR = b . shaftDia * 0.45 ;
2026-03-09 15:30:27 +09:00
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 ); // 스크린: 자동 계산
2026-03-09 08:06:34 +09:00
// 브라켓: Z 중심은 샤프트(0)와 모터(motorZ) 사이
const motorBkGeo = new THREE . BoxGeometry ( motorBkThick , motorBkH , motorBkW );
2026-03-09 07:52:19 +09:00
const motorBk = new THREE . Mesh ( motorBkGeo , bracketMat );
2026-03-09 08:06:34 +09:00
motorBk . position . set ( motorBkCX , 0 , motorZ / 2 );
2026-03-09 07:52:19 +09:00
meshes . motor . add ( motorBk );
2026-03-09 09:31:31 +09:00
// --- 2) 스프로켓 톱니바퀴 (샤프트측 + 모터측) ---
2026-03-09 07:52:19 +09:00
const sprocketThick = 12 ;
const sprocketFaceX = motorDir * ( W1 / 2 - motorBkThick );
const shaftSprocketX = sprocketFaceX - motorDir * sprocketThick / 2 ;
2026-03-09 09:31:31 +09:00
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 );
2026-03-09 07:52:19 +09:00
2026-03-09 08:06:34 +09:00
// --- 3) 모터 (샤프트와 수평방향 Z 오프셋 — 슬랫과 간섭 없음) ---
2026-03-09 07:52:19 +09:00
const motorBodyLen = motorR * 3 ;
2026-03-09 08:06:34 +09:00
// 모터 X: 브라켓 안쪽 (셔터박스 내부)
2026-03-09 07:52:19 +09:00
const motorCX = sprocketFaceX - motorDir * ( motorBodyLen / 2 + 20 );
2026-03-09 08:06:34 +09:00
// 모터 본체 (축=X, 샤프트와 평행, Y=0 수평, Z=motorZ)
2026-03-08 22:03:23 +09:00
const bodyGeo = new THREE . CylinderGeometry ( motorR , motorR , motorBodyLen , 24 );
bodyGeo . rotateZ ( Math . PI / 2 );
const body = new THREE . Mesh ( bodyGeo , metalMat );
2026-03-09 08:06:34 +09:00
body . position . set ( motorCX , 0 , motorZ );
2026-03-08 22:03:23 +09:00
meshes . motor . add ( body );
2026-03-09 08:06:34 +09:00
// 모터 후면 마감판
2026-03-09 07:52:19 +09:00
const ecGeo = new THREE . CylinderGeometry ( motorR + 3 , motorR + 3 , 4 , 24 );
2026-03-08 22:03:23 +09:00
ecGeo . rotateZ ( Math . PI / 2 );
const ec = new THREE . Mesh ( ecGeo , darkMat );
2026-03-09 08:06:34 +09:00
ec . position . set ( motorCX - motorDir * ( motorBodyLen / 2 + 2 ), 0 , motorZ );
2026-03-08 22:03:23 +09:00
meshes . motor . add ( ec );
2026-03-09 08:06:34 +09:00
// 상단 냉각 리브
2026-03-09 07:52:19 +09:00
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 );
2026-03-09 08:06:34 +09:00
rib . position . set ( motorCX , motorR + 1.5 , motorZ + ( i - 1 ) * motorR * 0.4 );
2026-03-09 07:52:19 +09:00
meshes . motor . add ( rib );
2026-03-08 22:03:23 +09:00
}
2026-03-09 08:06:34 +09:00
// 마운팅 플레이트
2026-03-09 07:52:19 +09:00
const mMountGeo = new THREE . BoxGeometry ( motorBodyLen * 0.6 , 5 , motorR * 1.4 );
const mMount = new THREE . Mesh ( mMountGeo , darkMat );
2026-03-09 08:06:34 +09:00
mMount . position . set ( motorCX , - motorR - 2.5 , motorZ );
2026-03-09 07:52:19 +09:00
meshes . motor . add ( mMount );
2026-03-09 09:31:31 +09:00
// 모터 출력 스프로켓 톱니바퀴 (샤프트 스프로켓과 같은 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 );
2026-03-09 07:52:19 +09:00
2026-03-09 08:06:34 +09:00
// --- 4) 체인 (RS #40, YZ 평면 — 수평으로 두 스프로켓 연결) ---
// 샤프트 스프로켓: (Y=0, Z=0), 모터 스프로켓: (Y=0, Z=motorZ)
const R1 = shaftSprocketR , R2 = motorSprocketR ;
2026-03-09 07:52:19 +09:00
const chainPts = [];
2026-03-09 08:06:34 +09:00
const seg = 20 ;
// 큰 스프로켓 호: 상단→하단 (모터 반대쪽 반원, +Z side)
2026-03-09 07:52:19 +09:00
for ( let i = 0 ; i <= seg ; i ++ ) {
2026-03-09 08:06:34 +09:00
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 )));
2026-03-09 07:52:19 +09:00
}
2026-03-09 08:06:34 +09:00
// 하단 직선: 큰 스프로켓 하단 → 작은 스프로켓 하단
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 )));
2026-03-09 07:52:19 +09:00
}
2026-03-09 08:06:34 +09:00
// 상단 직선: 작은 상단 → 큰 상단 (닫기)
2026-03-09 07:52:19 +09:00
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 );
2026-03-08 22:09:36 +09:00
2026-03-09 15:43:02 +09:00
meshes . motor . position . set ( 0 , shaftY , shaftCenterZ );
2026-03-08 19:30:05 +09:00
scene . add ( meshes . motor );
// === GUIDE RAILS (ExtrudeGeometry) ===
2026-03-09 13:34:35 +09:00
const rw = g . width , rd = g . depth , rt = g . thickness ;
2026-03-10 07:32:55 +09:00
const railHeight = H + 100 ; // 바닥(0)부터 셔터박스 하단 결합부 (+100mm)
2026-03-08 20:58:57 +09:00
const railExtrude = { depth : railHeight > 0 ? railHeight : H , bevelEnabled : false };
2026-03-08 19:30:05 +09:00
const railMat = new THREE . MeshStandardMaterial ({ color : 0x64748b , metalness : 0.5 , roughness : 0.4 });
2026-03-09 13:34:35 +09:00
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=개구부/실내측, +=벽쪽)
2026-03-11 19:58:41 +09:00
const fl = g . flange ; // 30mm 플랜지 (슬롯=outerW-2*fl=10mm)
2026-03-09 13:34:35 +09:00
const lp = g . lip ; // 10mm (립)
const sw = g . sideWall ; // 80mm (사이드월)
const bw = g . backWall ; // 67mm (백월)
2026-03-09 18:06:02 +09:00
// --- ② 본체 (EGI 1.55T): 슬롯10-플랜지30-사이드월80-백월t-사이드월80-플랜지30-슬롯 ---
// C채널: 사이드월 80mm, 플랜지 30mm, 슬롯 개구부 10mm (=width-2*fl)
2026-03-09 13:34:35 +09:00
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 ); // 상단 플랜지 시작
2026-03-10 01:40:54 +09:00
bodyShape . lineTo ( rw - fl , - lp ); // 상단 립 끝 (개구부쪽으로 10mm)
bodyShape . lineTo ( fl , - lp ); // 하단 립 끝 (슬롯 개구부)
2026-03-09 13:34:35 +09:00
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 ); // 상단 플랜지 내면
2026-03-10 01:40:54 +09:00
hole . lineTo ( rw - fl - rt , - lp + rt ); // 상단 립 내면 (개구부쪽)
hole . lineTo ( fl + rt , - lp + rt ); // 하단 립 내면 (개구부쪽)
2026-03-09 13:34:35 +09:00
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 ();
2026-03-13 20:12:22 +09:00
// 몸체(벽쪽) → 개구부(②쪽): SVG 도면과 일치하도록 벽쪽 body, 채널쪽 opening
wcShape . moveTo ( wcCenterX - wcBody / 2 , wcY + wcLip );
2026-03-09 13:34:35 +09:00
wcShape . lineTo ( wcCenterX - wcBody / 2 , wcY );
2026-03-13 20:12:22 +09:00
wcShape . lineTo ( wcCenterX - wcBody / 2 + rt , wcY );
wcShape . lineTo ( wcCenterX - wcBody / 2 + rt , wcY + wcLip - rt );
wcShape . lineTo ( wcCenterX + wcBody / 2 - rt , wcY + wcLip - rt );
wcShape . lineTo ( wcCenterX + wcBody / 2 - rt , wcY );
wcShape . lineTo ( wcCenterX + wcBody / 2 , wcY );
wcShape . lineTo ( wcCenterX + wcBody / 2 , wcY + wcLip );
wcShape . lineTo ( wcCenterX - wcBody / 2 , wcY + wcLip );
2026-03-09 13:34:35 +09:00
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 ();
2026-03-13 20:12:22 +09:00
// 립이 안쪽(중앙)으로 향하도록 수정: SVG 도면과 일치
2026-03-09 13:34:35 +09:00
wdShape . moveTo ( wcCenterX - wdSeg [ 2 ] / 2 , wdY );
wdShape . lineTo ( wcCenterX - wdSeg [ 2 ] / 2 , wdY + wdSeg [ 1 ]);
2026-03-13 20:12:22 +09:00
wdShape . lineTo ( wcCenterX - wdSeg [ 2 ] / 2 + wdSeg [ 0 ], wdY + wdSeg [ 1 ]);
wdShape . lineTo ( wcCenterX - wdSeg [ 2 ] / 2 + wdSeg [ 0 ], wdY + wdSeg [ 1 ] - rt );
2026-03-09 13:34:35 +09:00
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 );
2026-03-13 20:12:22 +09:00
wdShape . lineTo ( wcCenterX + wdSeg [ 2 ] / 2 - wdSeg [ 4 ], wdY + wdSeg [ 3 ] - rt );
wdShape . lineTo ( wcCenterX + wdSeg [ 2 ] / 2 - wdSeg [ 4 ], wdY + wdSeg [ 3 ]);
2026-03-09 13:34:35 +09:00
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 ));
2026-03-13 10:30:56 +09:00
// --- ② 플랜지 끝 10mm 절곡 (채널 안쪽으로, 각 플랜지 내측 끝) ---
[[ fl - rt , fl ], [ rw - fl , rw - fl + rt ]] . forEach (([ x1 , x2 ]) => {
const feLip = new THREE . Shape ();
feLip . moveTo ( x1 , 0 );
feLip . lineTo ( x1 , lp );
feLip . lineTo ( x2 , lp );
feLip . lineTo ( x2 , 0 );
feLip . lineTo ( x1 , 0 );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( feLip , railExtrude ), railMat ));
});
// --- ① 마감재 SUS 1.2T × 2장 (절곡: 10-11-110-30-15-15-15, J-hook 상하 대칭) ---
const tT = g . trimThick || 1.2 ;
const m1d = 30 , m1e = 15 , m1f = 15 , m1g = 15 , m1a = 10 , m1b = 11 ;
// 하단 트림 (X=0 외면) — cover + J-hook
2026-03-09 13:34:35 +09:00
const bt = new THREE . Shape ();
2026-03-13 10:30:56 +09:00
bt . moveTo ( - tT , sw + rt + tT );
bt . lineTo ( - tT , - lp );
bt . lineTo ( m1d , - lp );
bt . lineTo ( m1d , - lp + m1e );
bt . lineTo ( m1d - m1f , - lp + m1e );
bt . lineTo ( m1d - m1f , - lp + m1e + m1g );
bt . lineTo ( m1d - m1f + tT , - lp + m1e + m1g );
bt . lineTo ( m1d - m1f + tT , - lp + m1e - tT );
bt . lineTo ( m1d - tT , - lp + m1e - tT );
bt . lineTo ( m1d - tT , - lp + tT );
bt . lineTo ( 0 , - lp + tT );
bt . lineTo ( 0 , sw + rt + tT );
bt . lineTo ( - tT , sw + rt + tT );
2026-03-09 13:34:35 +09:00
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( bt , railExtrude ), railSusMat ));
2026-03-13 10:30:56 +09:00
// 하단 벽쪽 ㄴ자 (측면탭 11mm + 코킹립 10mm)
const btW = new THREE . Shape ();
btW . moveTo ( 0 , sw + rt );
btW . lineTo ( m1b + tT , sw + rt );
btW . lineTo ( m1b + tT , sw + rt + m1a );
btW . lineTo ( m1b , sw + rt + m1a );
btW . lineTo ( m1b , sw + rt + tT );
btW . lineTo ( 0 , sw + rt + tT );
btW . lineTo ( 0 , sw + rt );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( btW , railExtrude ), railSusMat ));
// 상단 트림 (X=rw 외면) — cover + J-hook (대칭)
2026-03-09 13:34:35 +09:00
const tt = new THREE . Shape ();
2026-03-13 10:30:56 +09:00
tt . moveTo ( rw + tT , sw + rt + tT );
tt . lineTo ( rw + tT , - lp );
tt . lineTo ( rw - m1d , - lp );
tt . lineTo ( rw - m1d , - lp + m1e );
tt . lineTo ( rw - m1d + m1f , - lp + m1e );
tt . lineTo ( rw - m1d + m1f , - lp + m1e + m1g );
tt . lineTo ( rw - m1d + m1f - tT , - lp + m1e + m1g );
tt . lineTo ( rw - m1d + m1f - tT , - lp + m1e - tT );
tt . lineTo ( rw - m1d + tT , - lp + m1e - tT );
tt . lineTo ( rw - m1d + tT , - lp + tT );
tt . lineTo ( rw , - lp + tT );
tt . lineTo ( rw , sw + rt + tT );
tt . lineTo ( rw + tT , sw + rt + tT );
2026-03-09 13:34:35 +09:00
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( tt , railExtrude ), railSusMat ));
2026-03-13 10:30:56 +09:00
// 상단 벽쪽 ㄴ자 (대칭)
const ttW = new THREE . Shape ();
ttW . moveTo ( rw , sw + rt );
ttW . lineTo ( rw - m1b - tT , sw + rt );
ttW . lineTo ( rw - m1b - tT , sw + rt + m1a );
ttW . lineTo ( rw - m1b , sw + rt + m1a );
ttW . lineTo ( rw - m1b , sw + rt + tT );
ttW . lineTo ( rw , sw + rt + tT );
ttW . lineTo ( rw , sw + rt );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( ttW , railExtrude ), railSusMat ));
2026-03-08 19:30:05 +09:00
2026-03-09 13:34:35 +09:00
} else {
2026-03-16 21:05:33 +09:00
// ====== 철재스라트 가이드레일 (상세 프로파일, 2D 평면도 기준) ======
// 3D 좌표: X=폭(0→rw=75), Y=깊이(0=개구부/실내→rd=벽)
// 2D 평면도 → 3D 매핑: plan_y→X, plan_x→Y(반전)
const bXO = ( rw - 72 ) / 2 ; // 본체 X 오프셋 (폭 중앙 정렬)
const bYO = rd - 90 - 15 ; // 본체 Y 오프셋 (벽쪽 15mm 여유)
const tT = g . trimThick || 1.2 ; // ① SUS 두께
// 본체 세그먼트 생성 헬퍼 (평면도 좌표 → 3D Shape)
function bSeg ( px , py , pw , ph ) {
const s = new THREE . Shape ();
const x1 = bXO + py , x2 = bXO + py + ph ;
const y1 = bYO + 90 - px - pw , y2 = bYO + 90 - px ;
s . moveTo ( x1 , y1 ); s . lineTo ( x2 , y1 ); s . lineTo ( x2 , y2 ); s . lineTo ( x1 , y2 ); s . lineTo ( x1 , y1 );
return s ;
}
// ── ② 본체 EGI 1.55T (15세그먼트 절곡) ──
const bodySegs = [
[ 0 , 59.25 , 10 , rt ], // Seg1/15: 립 10mm
[ 0 , 0 , rt , 60 ], // Seg2: 좌측벽 상부 60mm
[ 0 , 0 , 90 , rt ], // Seg3: 상단 플랜지 90mm
[ 90 - rt , 0 , rt , 21 ], // Seg4: 상부 립 21mm
[ 12 , 21 , 78 , rt ], // Seg5: 상부 선반 78mm
[ 12 , 21 , rt , 30 ], // Seg6: 내부 벽 30mm
[ 12 , 51 , 43 , rt ], // Seg7: 하부 선반 43mm
[ 55 - rt , 51 , rt , 15 ], // Seg8: 스텝↓ 15mm
[ 55 , 66 - rt , 20 , rt ], // Seg9: 스텝→ 20mm
[ 75 , 51 , rt , 15 ], // Seg10: 스텝↑ 15mm
[ 75 , 51 , 15 , rt ], // Seg11: 스텝→ 15mm
[ 90 - rt , 51 , rt , 21 ], // Seg12: 하부 립 21mm
[ 0 , 72 - rt , 90 , rt ], // Seg13: 하단 플랜지 90mm
[ 0 , 60 , rt , 12 ], // Seg14: 좌측벽 하부 12mm
];
bodySegs . forEach (([ px , py , pw , ph ]) => {
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( bSeg ( px , py , pw , ph ), railExtrude ), railMat ));
2026-03-09 13:34:35 +09:00
});
2026-03-16 21:05:33 +09:00
// ── ④ 벽연형 EGI 1.55T (ㄷ자 30-45-30) ──
const w4Y = bYO + 90 + 3 ; // 본체 뒤 3mm 간격
const w4X = ( rw - 45 ) / 2 ; // 폭 중앙 정렬
const w4S = new THREE . Shape ();
w4S . moveTo ( w4X , w4Y );
w4S . lineTo ( w4X + 45 , w4Y ); w4S . lineTo ( w4X + 45 , w4Y + 30 );
w4S . lineTo ( w4X + 45 - rt , w4Y + 30 ); w4S . lineTo ( w4X + 45 - rt , w4Y + rt );
w4S . lineTo ( w4X + rt , w4Y + rt ); w4S . lineTo ( w4X + rt , w4Y + 30 );
w4S . lineTo ( w4X , w4Y + 30 ); w4S . lineTo ( w4X , w4Y );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( w4S , railExtrude ), railMat ));
// ── ① 마감재 SUS 1.2T × 2장 (상/하 대칭) ──
// 상단: body(120)→tab(13↓)→lip(10←) + 우측: 25↓→15←
const m1Lip = 10 , m1Tab = 13 , m1Body = 120 , m1E25 = 25 , m1E15 = 15 ;
const trimYFront = bYO + 90 - 90 ; // 본체 개구부쪽 = bYO
const trimYBack = trimYFront + m1Body ; // 120mm 수평부
// 상단 ① (X=bXO-tT 위치)
const t1Top = new THREE . Shape ();
const tx = bXO - tT ;
// 수평부 120mm
t1Top . moveTo ( tx , trimYFront ); t1Top . lineTo ( tx + tT , trimYFront );
t1Top . lineTo ( tx + tT , trimYBack ); t1Top . lineTo ( tx , trimYBack ); t1Top . lineTo ( tx , trimYFront );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t1Top , railExtrude ), railSusMat ));
// 좌측탭 13mm + 립 10mm
const t1Tab = new THREE . Shape ();
t1Tab . moveTo ( tx , trimYBack ); t1Tab . lineTo ( tx + m1Tab , trimYBack );
t1Tab . lineTo ( tx + m1Tab , trimYBack + m1Lip ); t1Tab . lineTo ( tx + m1Tab - tT , trimYBack + m1Lip );
t1Tab . lineTo ( tx + m1Tab - tT , trimYBack + tT ); t1Tab . lineTo ( tx , trimYBack + tT ); t1Tab . lineTo ( tx , trimYBack );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t1Tab , railExtrude ), railSusMat ));
// 우측 25mm 수직 + 15mm 리턴
const t1R = new THREE . Shape ();
t1R . moveTo ( tx , trimYFront - m1E25 ); t1R . lineTo ( tx + tT , trimYFront - m1E25 );
t1R . lineTo ( tx + tT , trimYFront ); t1R . lineTo ( tx , trimYFront ); t1R . lineTo ( tx , trimYFront - m1E25 );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t1R , railExtrude ), railSusMat ));
const t1Ret = new THREE . Shape ();
t1Ret . moveTo ( tx , trimYFront - m1E25 ); t1Ret . lineTo ( tx + m1E15 , trimYFront - m1E25 );
t1Ret . lineTo ( tx + m1E15 , trimYFront - m1E25 + tT ); t1Ret . lineTo ( tx , trimYFront - m1E25 + tT ); t1Ret . lineTo ( tx , trimYFront - m1E25 );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t1Ret , railExtrude ), railSusMat ));
// 하단 ① (X=bXO+72 위치, 대칭)
const bx2 = bXO + 72 ;
const t2Top = new THREE . Shape ();
t2Top . moveTo ( bx2 , trimYFront ); t2Top . lineTo ( bx2 + tT , trimYFront );
t2Top . lineTo ( bx2 + tT , trimYBack ); t2Top . lineTo ( bx2 , trimYBack ); t2Top . lineTo ( bx2 , trimYFront );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t2Top , railExtrude ), railSusMat ));
const t2Tab = new THREE . Shape ();
t2Tab . moveTo ( bx2 , trimYBack ); t2Tab . lineTo ( bx2 , trimYBack + tT );
t2Tab . lineTo ( bx2 + m1Tab - tT , trimYBack + tT ); t2Tab . lineTo ( bx2 + m1Tab - tT , trimYBack + m1Lip );
t2Tab . lineTo ( bx2 + m1Tab , trimYBack + m1Lip ); t2Tab . lineTo ( bx2 + m1Tab , trimYBack ); t2Tab . lineTo ( bx2 , trimYBack );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t2Tab , railExtrude ), railSusMat ));
const t2R = new THREE . Shape ();
t2R . moveTo ( bx2 , trimYFront - m1E25 ); t2R . lineTo ( bx2 + tT , trimYFront - m1E25 );
t2R . lineTo ( bx2 + tT , trimYFront ); t2R . lineTo ( bx2 , trimYFront ); t2R . lineTo ( bx2 , trimYFront - m1E25 );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t2R , railExtrude ), railSusMat ));
const t2Ret = new THREE . Shape ();
t2Ret . moveTo ( bx2 , trimYFront - m1E25 ); t2Ret . lineTo ( bx2 + m1E15 , trimYFront - m1E25 );
t2Ret . lineTo ( bx2 + m1E15 , trimYFront - m1E25 + tT ); t2Ret . lineTo ( bx2 , trimYFront - m1E25 + tT ); t2Ret . lineTo ( bx2 , trimYFront - m1E25 );
grp . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( t2Ret , railExtrude ), railSusMat ));
2026-03-09 13:34:35 +09:00
}
// 회전: XY평면 → 수직 (Z extrude → Y height)
grp . rotation . x = - Math . PI / 2 ;
return grp ;
}
2026-03-08 19:30:05 +09:00
meshes . rails = new THREE . Group ();
2026-03-09 17:13:48 +09:00
const isScreenType = S . productType === 'screen' ;
if ( isScreenType ) {
2026-03-14 08:50:39 +09:00
// ====== 스크린형: 채널 개구부(립)가 중심(슬랫)을 향하도록 Y축 회전 ======
2026-03-14 13:57:48 +09:00
// createRailGroup() 단면: X=폭(0→70), Y=깊이(lip=-10 ~ backWall=sw+rt)
// Rx(-PI/2) 후: X=폭, Y=높이(extrude), Z=-Y(lip→z=+10, backWall→z=-81.55)
//
// 좌표계 정리 (Rx(-PI/2) 후):
// z>0 = 립/개구부(실내쪽), z<0 = 백월(벽쪽)
//
// Left: Ry(+PI/2) → x'=z → 립(z=10)→x'=+10(중심), 백월(z=-81.55)→x'=-81.55(벽)
// Right: Ry(-PI/2) → x'=-z → 립(z=10)→x'=-10(중심), 백월(z=-81.55)→x'=+81.55(벽)
2026-03-09 17:13:48 +09:00
2026-03-14 17:09:05 +09:00
// 레일 백월을 벽면에서 20mm 슬랫 방향으로 이격
2026-03-14 13:40:02 +09:00
const railDepth = g . sideWall + rt ; // 81.55mm
2026-03-14 17:09:05 +09:00
const railOffset = 20 ; // 벽면에서 슬랫 방향 이격 거리
const railPosX = W1 / 2 - railDepth - railOffset ; // wrapper X 위치
2026-03-14 13:40:02 +09:00
2026-03-14 13:57:48 +09:00
// Left rail — Ry(+PI/2): 립→+X(중심), 백월→-X(벽=-W1/2)
// Z방향: z'=-x(원본), 폭 0→70이 z' 0→-70으로 매핑
2026-03-14 14:07:47 +09:00
// wrapper Z=+rw/2 → 레일 Z범위 -rw/2 ~ +rw/2 (벽면 중심에 걸침)
2026-03-09 17:13:48 +09:00
const railGroupL = createRailGroup ();
const wrapperL = new THREE . Group ();
wrapperL . add ( railGroupL );
2026-03-14 13:57:48 +09:00
wrapperL . rotation . y = Math . PI / 2 ;
2026-03-14 14:07:47 +09:00
wrapperL . position . set ( - railPosX , 0 , rw / 2 );
2026-03-09 17:13:48 +09:00
meshes . rails . add ( wrapperL );
2026-03-14 13:57:48 +09:00
// Right rail — Ry(-PI/2): 립→-X(중심), 백월→+X(벽=+W1/2)
// Z방향: z'=x(원본), 폭 0→70이 z' 0→70으로 매핑
2026-03-14 14:07:47 +09:00
// wrapper Z=-rw/2 → 레일 Z범위 -rw/2 ~ +rw/2 (벽면 중심에 걸침)
2026-03-09 17:13:48 +09:00
const railGroupR = createRailGroup ();
const wrapperR = new THREE . Group ();
wrapperR . add ( railGroupR );
2026-03-14 13:57:48 +09:00
wrapperR . rotation . y = - Math . PI / 2 ;
2026-03-14 14:07:47 +09:00
wrapperR . position . set ( railPosX , 0 , - rw / 2 );
2026-03-09 17:13:48 +09:00
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 );
}
2026-03-08 19:30:05 +09:00
scene . add ( meshes . rails );
2026-03-14 18:33:51 +09:00
// === SLAT CURTAIN (샤프트 상단에서 바닥까지 이어지는 스크린) ===
2026-03-08 19:30:05 +09:00
const shutterH = H * ( S . td . shutterPos / 100 );
2026-03-14 18:33:51 +09:00
const slatTop = shaftY ; // 샤프트 중심에서 시작
const slatBottom = H - shutterH ; // 바닥 (100%일 때 Y=0)
const slatHeight = slatTop - slatBottom ; // 샤프트~바닥 전체 높이
const slatGeo = new THREE . PlaneGeometry ( W - 20 , slatHeight > 0 ? slatHeight : 1 );
2026-03-15 17:47:03 +09:00
const slatColor = S . productType === 'steel' ? 0x8b9aab : 0xc084fc ;
2026-03-08 19:30:05 +09:00
const slatMat = new THREE . MeshStandardMaterial ({
2026-03-15 17:47:03 +09:00
color : slatColor ,
2026-03-08 19:30:05 +09:00
side : THREE . DoubleSide ,
2026-03-15 17:47:03 +09:00
transparent : true ,
opacity : S . productType === 'screen' ? 0.6 : 0.85 ,
2026-03-08 19:30:05 +09:00
metalness : S . productType === 'steel' ? 0.4 : 0 ,
roughness : S . productType === 'steel' ? 0.5 : 0.8 ,
});
meshes . slats = new THREE . Mesh ( slatGeo , slatMat );
2026-03-14 18:33:51 +09:00
meshes . slats . position . set ( 0 , slatTop - slatHeight / 2 , 0 );
2026-03-08 19:30:05 +09:00
scene . add ( meshes . slats );
2026-03-08 20:30:57 +09:00
// Slat lines (horizontal grooves for steel type) — local coords relative to meshes.slats
2026-03-08 19:30:05 +09:00
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 ++ ) {
2026-03-08 20:30:57 +09:00
const y = shutterH / 2 - i * slatPitch ; // local Y: top of plane → down
if ( y < - shutterH / 2 ) break ;
2026-03-08 19:30:05 +09:00
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 );
}
2026-03-14 18:33:51 +09:00
// === SLAT ROLL (샤프트에 감긴 슬랫 — 감아올린 만큼만 표시) ===
2026-03-09 07:52:19 +09:00
const rolledH = H - shutterH ;
2026-03-14 18:33:51 +09:00
{
2026-03-08 21:23:59 +09:00
const wrapThick = S . productType === 'steel' ? 10 : 1 ;
2026-03-08 21:20:47 +09:00
const shaftR = b . shaftDia / 2 ;
2026-03-14 18:33:51 +09:00
// 감아올린 양에 비례 (100% 내림 시 최소 3mm 고정부만 표시)
const rollThick = rolledH > 0
? Math . max ( Math . sqrt ( rolledH * wrapThick / Math . PI ), 5 )
: 3 ;
2026-03-08 21:20:47 +09:00
const rollOuterR = shaftR + rollThick ;
2026-03-14 18:35:36 +09:00
const rollLen = W - 20 ; // 슬랫 커튼과 동일 너비 (같은 스크린)
2026-03-09 07:52:19 +09:00
2026-03-09 07:58:46 +09:00
// 하나의 Mesh로 표현 (접시 모양 방지)
const rollGeo = new THREE . CylinderGeometry ( rollOuterR , rollOuterR , rollLen , 48 , 1 , false );
2026-03-08 21:20:47 +09:00
rollGeo . rotateZ ( Math . PI / 2 );
const rollMat = new THREE . MeshStandardMaterial ({
2026-03-15 17:47:03 +09:00
color : slatColor , // 슬랫과 동일 색상
metalness : S . productType === 'steel' ? 0.3 : 0 ,
roughness : S . productType === 'steel' ? 0.4 : 0.8 ,
opacity : 0.9 ,
2026-03-08 21:20:47 +09:00
transparent : true ,
2026-03-09 07:52:19 +09:00
});
2026-03-09 07:58:46 +09:00
meshes . slatRoll = new THREE . Mesh ( rollGeo , rollMat );
2026-03-09 15:43:02 +09:00
meshes . slatRoll . position . set ( 0 , shaftY , shaftCenterZ );
2026-03-09 07:58:46 +09:00
scene . add ( meshes . slatRoll );
2026-03-09 07:52:19 +09:00
2026-03-09 07:58:46 +09:00
// 표면 나선 라인 (감긴 슬랫 질감)
2026-03-09 07:52:19 +09:00
if ( rollThick > 5 ) {
2026-03-15 17:47:03 +09:00
const spiralMat = new THREE . LineBasicMaterial ({ color : S . productType === 'steel' ? 0x6b7a8b : 0x7c4dff , opacity : 0.5 , transparent : true });
2026-03-09 07:58:46 +09:00
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 ;
2026-03-09 07:52:19 +09:00
for ( let i = 0 ; i <= turns * 32 ; i ++ ) {
const t = i / ( turns * 32 );
2026-03-09 07:58:46 +09:00
const angle = startA + t * turns * Math . PI * 2 ;
2026-03-09 07:52:19 +09:00
const x = - rollLen / 2 + t * rollLen ;
2026-03-09 07:58:46 +09:00
pts . push ( new THREE . Vector3 ( x , Math . sin ( angle ) * ( rollOuterR + 0.5 ), Math . cos ( angle ) * ( rollOuterR + 0.5 )));
2026-03-09 07:52:19 +09:00
}
2026-03-09 07:58:46 +09:00
const geo = new THREE . BufferGeometry () . setFromPoints ( pts );
meshes . slatRoll . add ( new THREE . Line ( geo , spiralMat ));
2026-03-09 07:52:19 +09:00
}
}
2026-03-08 21:20:47 +09:00
}
2026-03-14 17:48:14 +09:00
// === BOTTOM BAR (하장바 어셈블리: 하단마감재 ㄷ채널 + L바 + 평철) ===
meshes . bottomBar = new THREE . Group ();
{
const barW = W - 20 ;
const barH = 40 , barD = 60 ;
const halfH = barH / 2 , halfD = barD / 2 ;
2026-03-14 18:11:49 +09:00
const bt = 3 , lipW = 22 ;
2026-03-14 17:48:14 +09:00
const barExtrude = { depth : barW , bevelEnabled : false };
const barEdgeMat = new THREE . LineBasicMaterial ({ color : 0xb45309 , transparent : true , opacity : 0.5 });
// 회전 래퍼 (XY 단면 → Z extrude → Y축 회전으로 X방향 정렬)
const barInner = new THREE . Group ();
barInner . rotation . y = Math . PI / 2 ;
2026-03-14 17:52:17 +09:00
barInner . position . set ( - barW / 2 , 0 , 0 );
2026-03-14 17:48:14 +09:00
// --- 1) 하단마감재 (ㄷ채널, SUS 1.2T) ---
const cs = new THREE . Shape ();
cs . moveTo ( - halfD , - halfH );
cs . lineTo ( halfD , - halfH );
cs . lineTo ( halfD , halfH );
cs . lineTo ( halfD - lipW , halfH );
cs . lineTo ( halfD - lipW , halfH - bt );
cs . lineTo ( halfD - bt , halfH - bt );
cs . lineTo ( halfD - bt , - halfH + bt );
cs . lineTo ( - halfD + bt , - halfH + bt );
cs . lineTo ( - halfD + bt , halfH - bt );
cs . lineTo ( - halfD + lipW , halfH - bt );
cs . lineTo ( - halfD + lipW , halfH );
cs . lineTo ( - halfD , halfH );
cs . closePath ();
const csGeo = new THREE . ExtrudeGeometry ( cs , barExtrude );
barInner . add ( new THREE . Mesh ( csGeo , new THREE . MeshStandardMaterial ({ color : 0xf59e0b , metalness : 0.5 , roughness : 0.3 })));
barInner . add ( new THREE . LineSegments ( new THREE . EdgesGeometry ( csGeo ), barEdgeMat ));
2026-03-14 18:11:49 +09:00
// --- 2) L바 (180° 회전: 수평부 아래, 수직부 위로, EGI 1.55T) ---
// L바 2개가 수직부(긴 면)를 서로 마주보며 스크린+평철을 직결피스로 클램핑
// 수평부는 바깥으로 뻗어 하장바 내부에 위치
// 하장바를 아래에서 끼우면 날개(lipW=22)가 수평부 위를 덮어 고리처럼 잡아줌
const lbT = 2 ;
const cg = 3 ; // 중심~수직부 내면 간격 (스크린+평철)
const armY = 5 ; // 수평부 바닥 Y (채널 내부)
const vtop = 25 ; // 수직부 상단 Y (하장바 위로 돌출)
2026-03-14 17:48:14 +09:00
const lMat = new THREE . MeshStandardMaterial ({ color : 0xd97706 , metalness : 0.3 , roughness : 0.5 });
2026-03-14 18:11:49 +09:00
// 좌측 L바: 수평부→왼쪽(바깥), 수직부→위로(중심쪽)
2026-03-14 17:48:14 +09:00
const llS = new THREE . Shape ();
2026-03-14 18:11:49 +09:00
llS . moveTo ( cg , armY ); // 수평부 우하단 (중심쪽)
llS . lineTo ( halfD - bt - 1 , armY ); // 수평부 좌하단 (벽 근처)
llS . lineTo ( halfD - bt - 1 , armY + lbT ); // 수평부 좌상단
llS . lineTo ( cg + lbT , armY + lbT ); // 수직부-수평부 연결점
llS . lineTo ( cg + lbT , vtop ); // 수직부 좌면 상단
llS . lineTo ( cg , vtop ); // 수직부 우면 상단 (긴 면)
2026-03-14 17:48:14 +09:00
llS . closePath ();
barInner . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( llS , barExtrude ), lMat ));
2026-03-14 18:11:49 +09:00
// 우측 L바 (반전): 수평부→오른쪽(바깥), 수직부→위로(중심쪽)
2026-03-14 17:48:14 +09:00
const rlS = new THREE . Shape ();
2026-03-14 18:11:49 +09:00
rlS . moveTo ( - cg , armY );
rlS . lineTo ( - ( halfD - bt - 1 ), armY );
rlS . lineTo ( - ( halfD - bt - 1 ), armY + lbT );
rlS . lineTo ( - cg - lbT , armY + lbT );
rlS . lineTo ( - cg - lbT , vtop );
rlS . lineTo ( - cg , vtop );
2026-03-14 17:48:14 +09:00
rlS . closePath ();
barInner . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( rlS , barExtrude ), lMat ));
2026-03-14 18:11:49 +09:00
// --- 3) 평철 (L바 수직부 사이, EGI 1.15T) ---
// 스크린 시접 안의 보강 평철, 직결피스로 L바+평철+L바 체결
const fpT = 2 , fpH = 18 ;
2026-03-14 17:48:14 +09:00
const fpS = new THREE . Shape ();
2026-03-14 18:11:49 +09:00
fpS . moveTo ( - fpT / 2 , armY ); fpS . lineTo ( fpT / 2 , armY );
fpS . lineTo ( fpT / 2 , armY + fpH ); fpS . lineTo ( - fpT / 2 , armY + fpH );
2026-03-14 17:48:14 +09:00
fpS . closePath ();
barInner . add ( new THREE . Mesh ( new THREE . ExtrudeGeometry ( fpS , barExtrude ),
new THREE . MeshStandardMaterial ({ color : 0x92400e , metalness : 0.3 , roughness : 0.5 })));
meshes . bottomBar . add ( barInner );
}
2026-03-08 19:30:05 +09:00
meshes . bottomBar . position . set ( 0 , H - shutterH - 20 , 0 );
scene . add ( meshes . bottomBar );
2026-03-08 21:42:22 +09:00
// === WALL (좌/우 기둥 + 상부 인방) ===
2026-03-09 11:29:04 +09:00
// 벽체: 기둥+인방을 하나의 U자 형상으로 생성 (이음새 없음)
2026-03-13 20:53:47 +09:00
// 벽 기둥 내면을 가이드레일 브라켓 끝에 정렬
2026-03-08 21:33:46 +09:00
const wl = S . wall ;
2026-03-09 11:29:04 +09:00
const colH = H + b . height ;
2026-03-08 21:33:46 +09:00
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 ();
2026-03-14 13:40:02 +09:00
// 벽 기둥 내면 = 브라켓 벽면(W1/2)과 동일 선상
// 스크린: 레일 백월 = 브라켓 벽면 = W1/2 (③④ 벽연형은 벽 두께 안에 매립)
// 철재: 레일 절반 폭 기준
const whw = isScreenType
? ( W1 / 2 ) // 브라켓/레일 백월 정렬
: ( hw + rw / 2 ); // 철재형: 레일 절반 폭
2026-03-09 11:29:04 +09:00
const wg = wl . wing ;
const topH = wl . topMargin ;
const totalH = colH + topH ;
if ( wg > 0 && topH > 0 ) {
// U자 형상 (기둥+인방 일체형)
const shape = new THREE . Shape ();
2026-03-09 11:30:15 +09:00
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 );
2026-03-09 11:29:04 +09:00
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 );
2026-03-08 21:42:22 +09:00
const leftCol = new THREE . Mesh ( colGeo , wallMat );
2026-03-09 11:30:15 +09:00
leftCol . position . set ( - whw - wg / 2 , colH / 2 , 0 );
2026-03-08 21:42:22 +09:00
meshes . wall . add ( leftCol );
const rightCol = new THREE . Mesh ( colGeo . clone (), wallMat );
2026-03-09 11:30:15 +09:00
rightCol . position . set ( whw + wg / 2 , colH / 2 , 0 );
2026-03-08 21:42:22 +09:00
meshes . wall . add ( rightCol );
2026-03-09 11:29:04 +09:00
} else if ( topH > 0 ) {
// 인방만 (기둥 없음)
const lintelGeo = new THREE . BoxGeometry ( W1 , topH , wl . thick );
2026-03-08 21:37:10 +09:00
const lintel = new THREE . Mesh ( lintelGeo , wallMat );
2026-03-09 11:29:04 +09:00
lintel . position . set ( 0 , colH + topH / 2 , 0 );
2026-03-08 21:37:10 +09:00
meshes . wall . add ( lintel );
}
2026-03-08 21:33:46 +09:00
2026-03-08 21:42:22 +09:00
// 벽체 와이어프레임
2026-03-08 21:33:46 +09:00
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 );
});
2026-03-08 19:30:05 +09:00
scene . add ( meshes . wall );
2026-03-09 09:38:27 +09:00
// 재빌드 후 토글/단품 상태 동기화
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 ];
});
}
2026-03-09 07:52:19 +09:00
2026-03-08 21:11:52 +09:00
// 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 ;
}
2026-03-15 17:40:04 +09:00
// 빌드 후 부품 표시/숨김 상태 복원
Object . keys ( S . td . show ) . forEach ( k => {
if ( meshes [ k ]) meshes [ k ] . visible = S . td . show [ k ];
});
2026-03-08 19:30:05 +09:00
}
2026-03-09 09:38:27 +09:00
// 단품 보기 (Isolation)
function fs3dIsolate ( key ) {
fs3dIsolated = key ;
Object . entries ( meshes ) . forEach (([ k , obj ]) => {
if ( obj ) obj . visible = ( k === key );
});
// 배지 표시
const badge = document . querySelector ( '.fs-iso-badge' );
if ( badge ) {
const label = meshLabels [ key ] || key ;
badge . innerHTML = `${label} 단품 보기 중 ✕` ;
badge . style . display = 'block' ;
}
2026-03-09 13:12:13 +09:00
// 카메라 위치 유지 (현재 뷰 그대로, 단품만 표시)
2026-03-09 09:38:27 +09:00
}
// 전체 보기 (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' ;
}
2026-03-14 08:42:25 +09:00
// 뷰 프리셋 (정면/평면/우측면/좌측면/배면/투시)
function fs3dSetView ( preset ) {
if ( ! camera || ! controls ) return ;
const t = controls . target . clone (); // 현재 타겟 유지
const dist = camera . position . distanceTo ( t ); // 현재 거리 유지
const d = Math . max ( dist , 2000 ); // 최소 거리 보장
let pos ;
switch ( preset ) {
case 'front' : pos = new THREE . Vector3 ( t . x , t . y , t . z + d ); break ; // +Z: 정면 (전면에서 봄)
case 'back' : pos = new THREE . Vector3 ( t . x , t . y , t . z - d ); break ; // -Z: 배면 (후면에서 봄)
case 'top' : pos = new THREE . Vector3 ( t . x , t . y + d , t . z + 1 ); break ; // +Y: 평면 (위에서 봄)
case 'right' : pos = new THREE . Vector3 ( t . x + d , t . y , t . z ); break ; // +X: 우측면
case 'left' : pos = new THREE . Vector3 ( t . x - d , t . y , t . z ); break ; // -X: 좌측면
case 'persp' : pos = new THREE . Vector3 ( t . x + d * 0.7 , t . y + d * 0.5 , t . z + d * 0.7 ); break ;
default : return ;
}
// 부드러운 전환 (애니메이션)
const startPos = camera . position . clone ();
const startTime = performance . now ();
const duration = 400 ; // ms
function animateView ( now ) {
const elapsed = now - startTime ;
const progress = Math . min ( elapsed / duration , 1 );
const ease = 1 - Math . pow ( 1 - progress , 3 ); // easeOutCubic
camera . position . lerpVectors ( startPos , pos , ease );
camera . lookAt ( t );
controls . update ();
if ( progress < 1 ) requestAnimationFrame ( animateView );
}
requestAnimationFrame ( animateView );
}
2026-03-08 19:30:05 +09:00
// 3D Controls
window . fs3dShutterPos = function ( v ) {
2026-03-09 16:33:11 +09:00
undoSaveState ();
2026-03-08 19:30:05 +09:00
S . td . shutterPos = Number ( v );
2026-03-08 19:47:47 +09:00
$ ( 'shutterPosLabel' ) . textContent = v + '%' ;
2026-03-08 19:30:05 +09:00
fs3dBuild ();
2026-03-15 17:40:04 +09:00
// 빌드 후 숨겨진 부품 상태 복원
Object . keys ( S . td . show ) . forEach ( k => {
if ( meshes [ k ]) meshes [ k ] . visible = S . td . show [ k ];
});
2026-03-08 19:30:05 +09:00
};
window . fs3dOpacity = function ( v ) {
2026-03-09 16:33:11 +09:00
undoSaveState ();
2026-03-08 19:30:05 +09:00
S . td . caseOpacity = Number ( v ) / 100 ;
2026-03-08 19:47:47 +09:00
$ ( 'opacityLabel' ) . textContent = v + '%' ;
2026-03-09 11:38:53 +09:00
if ( meshes . case ) meshes . case . traverse ( c => { if ( c . isMesh ) c . material . opacity = S . td . caseOpacity ; });
2026-03-08 19:30:05 +09:00
};
window . fsToggle3d = function ( el , key ) {
2026-03-09 16:33:11 +09:00
undoSaveState ();
2026-03-08 19:30:05 +09:00
el . classList . toggle ( 'active' );
S . td . show [ key ] = el . classList . contains ( 'active' );
if ( meshes [ key ]) meshes [ key ] . visible = S . td . show [ key ];
2026-03-15 17:40:04 +09:00
// 슬랫 토글 시 감긴 슬랫(slatRoll)도 연동
if ( key === 'slats' ) {
S . td . show . slatRoll = S . td . show [ key ];
if ( meshes . slatRoll ) meshes . slatRoll . visible = S . td . show [ key ];
}
2026-03-09 16:39:00 +09:00
// 벽 토글 시 벽체 설정 패널 연동
if ( key === 'wall' ) {
const ws = $ ( 'wallSettings' );
if ( ws ) ws . classList . toggle ( 'hidden' , ! S . td . show . wall );
}
2026-03-08 19:30:05 +09:00
};
2026-03-08 20:02:42 +09:00
window . fs3dLightColor = function ( color ) {
2026-03-08 19:30:05 +09:00
if ( ! scene ) return ;
2026-03-08 20:02:42 +09:00
const c = new THREE . Color ( color );
scene . children . forEach ( l => {
if ( l . isDirectionalLight ) l . color . copy ( c );
2026-03-08 19:30:05 +09:00
});
};
window . fs3dBg = function ( color ) {
S . td . bgColor = color ;
if ( scene ) scene . background = new THREE . Color ( color );
};
2026-03-08 21:33:46 +09:00
window . fs3dWall = function ( key , v ) {
2026-03-09 16:33:11 +09:00
undoSaveState ();
2026-03-08 21:33:46 +09:00
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 ) {
2026-03-09 16:33:11 +09:00
undoSaveState ();
2026-03-08 21:33:46 +09:00
S . wall . color = color ;
$ ( 'wallColorPicker' ) . value = color ;
fs3dBuild ();
};
2026-03-08 19:30:05 +09:00
// ============================
// 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
// ============================
2026-03-09 16:50:09 +09:00
fsOnProductType (); // 스크린형 기본값 적용 + 모델 필터링
2026-03-08 19:30:05 +09:00
refreshPresetList ();
2026-03-15 18:19:36 +09:00
fsSwitch ( S . productType === 'steel' ? 'GuideRail' : '3D' );
2026-03-08 19:30:05 +09:00
})();
</ script >
@ endpush
@ endsection