Files
sam-manage/resources/views/rd/auto-drawing/index.blade.php
김보곤 ef3870f3a3 feat: [rd] 자동도면 생성 메뉴 추가
- 레거시 전개도 시뮬레이터를 MNG 환경으로 마이그레이션
- RdController에 autoDrawing 메서드 추가 (HX-Request 체크 포함)
- 라우트: GET /rd/auto-drawing
- R&D 대시보드에 자동도면 생성 카드 추가
- 레거시 PHP 코드 제거 (세션, API키, 서버기록 등)
- Three.js 3D 렌더링, SVG 미리보기, DXF 도면 생성 기능 유지
2026-03-08 17:56:09 +09:00

4885 lines
299 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

@extends('layouts.app')
@section('title', '자동도면 생성')
@section('content')
<style>
body { font-family: 'Pretendard', sans-serif; }
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #475569; }
.glass-panel {
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.neon-border {
box-shadow: 0 0 15px rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
}
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.animate-pulse-soft { animation: pulse-soft 3s infinite; }
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* 측정 모드 커서 스타일 - OrbitControls 오버라이드 */
#canvas3d.measure-mode,
#canvas3d.measure-mode * {
cursor: crosshair !important;
}
/* Auto Drawing specific overrides */
.ad-wrap {
margin: -24px;
min-height: calc(100vh - 64px);
background: #020617;
}
</style>
<div class="ad-wrap">
<main class="max-w-[1700px] mx-auto px-6 py-8">
<!-- Frame Type Tabs -->
<div class="flex items-center gap-1 mb-8 bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800 w-fit">
<button id="tabSettings" class="px-6 py-2.5 rounded-xl font-black text-sm transition-all flex items-center gap-2 bg-blue-600 text-white shadow-lg shadow-blue-500/20" onclick="switchTab('Settings')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
설정
</button>
<div class="h-4 w-px bg-slate-800 mx-1"></div>
<button id="tabLR" class="px-6 py-2.5 rounded-xl font-black text-sm transition-all flex items-center gap-2 text-slate-400 hover:text-white hover:bg-slate-800" onclick="switchTab('LR')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m18 8 4 4-4 4"/><path d="M2 12h20"/><path d="m6 8-4 4 4 4"/></svg>
좌우
</button>
<button id="tabFB" class="px-6 py-2.5 rounded-xl font-black text-sm transition-all flex items-center gap-2 text-slate-400 hover:text-white hover:bg-slate-800" onclick="switchTab('FB')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m8 18 4 4 4-4"/><path d="M12 2v20"/><path d="m8 6 4-4 4 4"/></svg>
앞뒤
</button>
<div class="h-4 w-px bg-slate-800 mx-1"></div>
<button id="tab3D" class="px-6 py-2.5 rounded-xl font-black text-sm transition-all flex items-center gap-2 text-slate-400 hover:text-white hover:bg-slate-800" onclick="switchTab('3D')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
3D 랜더링
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- CONTROLS COLUMN -->
<div class="lg:col-span-4 space-y-8">
<!-- Section 01: Bending Profile View -->
<section class="bg-slate-900/50 rounded-3xl p-6 border border-slate-800 shadow-2xl relative overflow-hidden group">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-xl bg-purple-600 text-xs font-black shadow-lg shadow-purple-500/20">01</span>
절곡 단면 시각화
</h2>
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse"></div>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">REAL-TIME PROFILE</span>
</div>
</div>
<div id="bendingProfileMini" class="bg-slate-950/80 rounded-2xl border border-slate-800 p-4 relative overflow-hidden group/mini">
<div id="profileSvgContainer" class="w-full h-96 flex items-center justify-center">
<!-- SVG will be injected here -->
</div>
</div>
</section>
<!-- Section 02: Dimensions & Segments -->
<section id="dimensionSection" class="hidden bg-slate-900/50 rounded-3xl p-6 border border-slate-800 shadow-2xl relative overflow-hidden group">
<div class="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1"><path d="M21 3.6V20.4a.6.6 0 0 1-.6.6H3.6a.6.6 0 0 1-.6-.6V3.6a.6.6 0 0 1 .6-.6h16.8a.6.6 0 0 1 .6.6Z"/></svg>
</div>
<div class="flex items-center justify-between mb-6 relative z-10">
<h2 class="text-lg font-black text-white flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-xl bg-blue-600 text-xs font-black shadow-lg shadow-blue-500/20">02</span>
도면 치수 입력
</h2>
<button id="addSegmentBtn" class="hidden p-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl transition-all shadow-lg active:scale-95 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<div id="segmentList" class="space-y-3">
<!-- Segments will be injected here -->
</div>
</section>
<!-- Section 03: Unrolling Details Table -->
<section id="detailsSection" class="hidden bg-slate-900/50 rounded-3xl p-6 border border-slate-800 shadow-2xl relative overflow-hidden group">
<h2 class="text-lg font-black text-white mb-6 flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-xl bg-orange-600 text-xs font-black shadow-lg shadow-orange-500/20">03</span>
전개 상세 내역
</h2>
<div class="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/50">
<table class="w-full text-left text-[11px] border-collapse">
<thead>
<tr class="bg-slate-900/80 border-b border-slate-800">
<th class="px-4 py-3 font-black text-slate-500 uppercase tracking-widest">구간</th>
<th class="px-4 py-3 font-black text-slate-500 uppercase tracking-widest text-right">원래길이</th>
<th class="px-4 py-3 font-black text-slate-500 uppercase tracking-widest text-center">각도</th>
<th class="px-4 py-3 font-black text-blue-500 uppercase tracking-widest text-right">전개길이</th>
<th class="px-4 py-3 font-black text-slate-500 uppercase tracking-widest text-center">V-CUT</th>
</tr>
</thead>
<tbody id="unfoldingDataTable" class="divide-y divide-slate-900">
<!-- Table rows will be injected here -->
</tbody>
</table>
</div>
</section>
</div>
<!-- PREVIEW COLUMN -->
<div class="lg:col-span-8 flex flex-col h-full space-y-6">
<!-- Section: Parameters (moved above preview) -->
<section id="parameterSection" class="hidden bg-slate-900/50 rounded-3xl p-5 border border-slate-800 shadow-2xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-black text-white flex items-center gap-3">
<span class="flex items-center justify-center w-7 h-7 rounded-xl bg-emerald-600 text-xs font-black shadow-lg shadow-emerald-500/20">P</span>
전개 파라미터
</h2>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">UNFOLDING PARAMS</span>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-1">
<label class="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">전체 (W)</label>
<div class="relative">
<input type="number" id="sheetWidthInput" value="830" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 font-black text-blue-400 outline-none focus:border-blue-500/50 transition-all text-sm" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-[9px] font-black text-slate-600">mm</span>
</div>
</div>
<div class="space-y-1">
<label class="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">날개 (N)</label>
<div class="relative">
<input type="number" id="wingWidthInput" value="98.2" class="w-full bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 font-black text-slate-300 outline-none focus:border-blue-500/50 transition-all text-sm" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-[9px] font-black text-slate-600">mm</span>
</div>
</div>
<div class="space-y-1">
<label class="text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1">노치 시작 구간</label>
<div class="relative">
<select id="wingStartIndexSelect" class="w-full appearance-none bg-slate-950 border border-slate-800 rounded-xl px-3 py-2.5 font-black text-slate-300 outline-none focus:border-blue-500/50 transition-all text-sm">
<option value="0">구간 1 이후</option>
</select>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
</div>
</div>
</div>
</section>
<section id="previewSection" class="bg-slate-900 rounded-[2.5rem] border border-slate-800 shadow-2xl flex-1 flex flex-col overflow-hidden min-h-[900px] relative transition-all duration-500">
<!-- Header of Preview -->
<div class="px-8 py-6 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-3">
<button id="downloadDxfBtn" class="hidden bg-blue-600 hover:bg-blue-500 text-white px-6 py-2.5 rounded-2xl font-black text-sm flex items-center gap-2 shadow-lg shadow-blue-500/20 transition-all active:scale-95 group">
<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" class="group-hover:translate-y-0.5 transition-transform"><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>
</div>
</div>
<!-- Canvas Area -->
<div id="canvasViewport" class="flex-1 bg-[#050507] relative overflow-hidden cursor-grab active:cursor-grabbing">
<div id="unfoldedDrawingContainer" class="absolute inset-0 hidden flex items-start justify-center p-20 select-none">
<!-- SVG will be injected here -->
</div>
<div id="threeDContainer" class="absolute inset-0 hidden">
<!-- 3D Canvas will be injected here -->
<!-- 조명 컨트롤 패널 -->
<div id="lightingPanel" class="absolute top-4 right-4 w-72 bg-slate-900/95 backdrop-blur-sm rounded-xl border border-slate-700/50 shadow-2xl z-20 overflow-hidden">
<!-- 헤더 -->
<div class="flex items-center justify-between px-4 py-3 bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-b border-slate-700/50 cursor-pointer" onclick="toggleLightingPanel()">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
<span class="text-white font-bold text-sm">조명 설정</span>
</div>
<svg id="lightingPanelArrow" class="w-4 h-4 text-slate-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<!-- 컨텐츠 -->
<div id="lightingPanelContent" class="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
<!-- 배경색 설정 -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-violet-400 text-xs font-bold flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-violet-400"></span>
배경색
</label>
<input type="color" id="bgColor" value="#ffffff" class="w-6 h-6 rounded cursor-pointer" onchange="updateBackgroundColor(this.value)">
</div>
<div class="grid grid-cols-6 gap-1.5 mt-2">
<button onclick="updateBackgroundColor('#ffffff')" class="w-full aspect-square rounded border-2 border-slate-600 hover:border-violet-400 transition-all" style="background-color: #ffffff" title="흰색"></button>
<button onclick="updateBackgroundColor('#f0f0f0')" class="w-full aspect-square rounded border-2 border-slate-600 hover:border-violet-400 transition-all" style="background-color: #f0f0f0" title="밝은 회색"></button>
<button onclick="updateBackgroundColor('#808080')" class="w-full aspect-square rounded border-2 border-slate-600 hover:border-violet-400 transition-all" style="background-color: #808080" title="중간 회색"></button>
<button onclick="updateBackgroundColor('#303030')" class="w-full aspect-square rounded border-2 border-slate-600 hover:border-violet-400 transition-all" style="background-color: #303030" title="어두운 회색"></button>
<button onclick="updateBackgroundColor('#1a1a2e')" class="w-full aspect-square rounded border-2 border-slate-600 hover:border-violet-400 transition-all" style="background-color: #1a1a2e" title="진한 남색"></button>
<button onclick="updateBackgroundColor('#000000')" class="w-full aspect-square rounded border-2 border-slate-600 hover:border-violet-400 transition-all" style="background-color: #000000" title="검정"></button>
</div>
</div>
<!-- 프리셋 선택 -->
<div class="space-y-2">
<label class="text-slate-400 text-xs font-medium flex items-center gap-2">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
프리셋
</label>
<div class="grid grid-cols-5 gap-1.5">
<button onclick="applyLightingPreset('default')" class="lighting-preset-btn px-2 py-1.5 rounded text-[10px] font-medium bg-slate-700 hover:bg-slate-600 text-slate-300 transition-all" data-preset="default">기본</button>
<button onclick="applyLightingPreset('studio')" class="lighting-preset-btn px-2 py-1.5 rounded text-[10px] font-medium bg-slate-700 hover:bg-slate-600 text-slate-300 transition-all" data-preset="studio">스튜디오</button>
<button onclick="applyLightingPreset('outdoor')" class="lighting-preset-btn px-2 py-1.5 rounded text-[10px] font-medium bg-slate-700 hover:bg-slate-600 text-slate-300 transition-all" data-preset="outdoor">야외</button>
<button onclick="applyLightingPreset('dramatic')" class="lighting-preset-btn px-2 py-1.5 rounded text-[10px] font-medium bg-slate-700 hover:bg-slate-600 text-slate-300 transition-all" data-preset="dramatic">드라마틱</button>
<button onclick="applyLightingPreset('soft')" class="lighting-preset-btn px-2 py-1.5 rounded text-[10px] font-medium bg-slate-700 hover:bg-slate-600 text-slate-300 transition-all" data-preset="soft">소프트</button>
</div>
</div>
<!-- 환경광 (Ambient) -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-amber-400 text-xs font-bold flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-amber-400"></span>
환경광 (Ambient)
</label>
<input type="color" id="lightAmbientColor" value="#ffffff" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[10px] w-12">강도</span>
<input type="range" id="lightAmbientIntensity" min="0" max="1" step="0.05" value="0.3" class="flex-1 accent-amber-400" oninput="updateLighting()">
<span id="lightAmbientIntensityVal" class="text-amber-400 text-[10px] font-mono w-8 text-right">0.30</span>
</div>
</div>
<!-- 조명 (Directional 1) -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-sky-400 text-xs font-bold flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-sky-400"></span>
조명 (태양광)
</label>
<input type="color" id="lightDir1Color" value="#ffffff" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[10px] w-12">강도</span>
<input type="range" id="lightDir1Intensity" min="0" max="2" step="0.1" value="0.8" class="flex-1 accent-sky-400" oninput="updateLighting()">
<span id="lightDir1IntensityVal" class="text-sky-400 text-[10px] font-mono w-8 text-right">0.80</span>
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div>
<label class="text-slate-500 text-[9px]">X</label>
<input type="number" id="lightDir1X" value="2000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-sky-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Y</label>
<input type="number" id="lightDir1Y" value="3000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-sky-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Z</label>
<input type="number" id="lightDir1Z" value="1000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-sky-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
</div>
</div>
<!-- 보조 조명 (Directional 2) -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-emerald-400 text-xs font-bold flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
보조 조명 (반대편)
</label>
<input type="color" id="lightDir2Color" value="#ffffff" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[10px] w-12">강도</span>
<input type="range" id="lightDir2Intensity" min="0" max="2" step="0.1" value="0.4" class="flex-1 accent-emerald-400" oninput="updateLighting()">
<span id="lightDir2IntensityVal" class="text-emerald-400 text-[10px] font-mono w-8 text-right">0.40</span>
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div>
<label class="text-slate-500 text-[9px]">X</label>
<input type="number" id="lightDir2X" value="-2000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-emerald-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Y</label>
<input type="number" id="lightDir2Y" value="2000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-emerald-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Z</label>
<input type="number" id="lightDir2Z" value="-1000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-emerald-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
</div>
</div>
<!-- 포인트 조명 -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-purple-400 text-xs font-bold flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-purple-400"></span>
포인트 조명 (전구)
</label>
<input type="color" id="lightPointColor" value="#ffffff" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[10px] w-12">강도</span>
<input type="range" id="lightPointIntensity" min="0" max="2" step="0.1" value="0.6" class="flex-1 accent-purple-400" oninput="updateLighting()">
<span id="lightPointIntensityVal" class="text-purple-400 text-[10px] font-mono w-8 text-right">0.60</span>
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div>
<label class="text-slate-500 text-[9px]">X</label>
<input type="number" id="lightPointX" value="0" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-purple-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Y</label>
<input type="number" id="lightPointY" value="1000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-purple-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Z</label>
<input type="number" id="lightPointZ" value="0" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-purple-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
</div>
</div>
<!-- 반구광 (Hemisphere) -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-rose-400 text-xs font-bold flex items-center gap-1.5">
<input type="checkbox" id="lightHemisphereEnabled" class="w-3.5 h-3.5 accent-rose-400" onchange="updateLighting()">
반구광 (하늘/)
</label>
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[10px] w-12">강도</span>
<input type="range" id="lightHemisphereIntensity" min="0" max="1" step="0.05" value="0.5" class="flex-1 accent-rose-400" oninput="updateLighting()">
<span id="lightHemisphereIntensityVal" class="text-rose-400 text-[10px] font-mono w-8 text-right">0.50</span>
</div>
<div class="flex items-center gap-4 mt-2">
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[9px]">하늘</span>
<input type="color" id="lightHemisphereSky" value="#87ceeb" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[9px]"></span>
<input type="color" id="lightHemisphereGround" value="#8b4513" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
</div>
</div>
<!-- 스포트라이트 -->
<div class="space-y-2 p-3 bg-slate-800/50 rounded-lg border border-slate-700/30">
<div class="flex items-center justify-between">
<label class="text-orange-400 text-xs font-bold flex items-center gap-1.5">
<input type="checkbox" id="lightSpotEnabled" class="w-3.5 h-3.5 accent-orange-400" onchange="updateLighting()">
스포트라이트
</label>
<input type="color" id="lightSpotColor" value="#ffffff" class="w-6 h-6 rounded cursor-pointer" onchange="updateLighting()">
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[10px] w-12">강도</span>
<input type="range" id="lightSpotIntensity" min="0" max="3" step="0.1" value="1.0" class="flex-1 accent-orange-400" oninput="updateLighting()">
<span id="lightSpotIntensityVal" class="text-orange-400 text-[10px] font-mono w-8 text-right">1.00</span>
</div>
<div class="grid grid-cols-3 gap-2 mt-2">
<div>
<label class="text-slate-500 text-[9px]">X</label>
<input type="number" id="lightSpotX" value="0" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-orange-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Y</label>
<input type="number" id="lightSpotY" value="2000" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-orange-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
<div>
<label class="text-slate-500 text-[9px]">Z</label>
<input type="number" id="lightSpotZ" value="0" step="100" class="w-full bg-slate-900 border border-slate-600 rounded px-1.5 py-1 text-orange-400 text-[10px] font-mono" oninput="updateLighting()">
</div>
</div>
<div class="grid grid-cols-2 gap-2 mt-2">
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[9px] w-10">각도</span>
<input type="range" id="lightSpotAngle" min="5" max="90" step="1" value="30" class="flex-1 accent-orange-400" oninput="updateLighting()">
<span id="lightSpotAngleVal" class="text-orange-400 text-[9px] font-mono w-6">30°</span>
</div>
<div class="flex items-center gap-2">
<span class="text-slate-500 text-[9px] w-10">페넘브라</span>
<input type="range" id="lightSpotPenumbra" min="0" max="1" step="0.1" value="0.5" class="flex-1 accent-orange-400" oninput="updateLighting()">
<span id="lightSpotPenumbraVal" class="text-orange-400 text-[9px] font-mono w-6">0.5</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Tab Container -->
<div id="settingsContainer" class="absolute inset-x-0 top-0 p-4">
<div class="w-full max-w-4xl mx-auto">
<!-- 조명천장 제작 사이즈 -->
<div class="grid grid-cols-4 gap-3 mb-4">
<!-- 철판두께 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-cyan-500/30">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-cyan-500/20 text-cyan-400 flex items-center justify-center text-sm font-black">T</span>
철판두께
</span>
</label>
<div class="relative">
<input type="number" id="settingThickness" value="1.2" step="0.1" min="0.5" max="5"
class="w-full bg-slate-900 border border-cyan-500/30 rounded px-2 py-1.5 text-cyan-400 font-mono text-sm focus:border-cyan-500 outline-none transition-all"
oninput="applyCeilingSizeSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">좌우/앞뒤 공통</p>
</div>
<!-- 제작 Width -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-cyan-500/30">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-cyan-500/20 text-cyan-400 flex items-center justify-center text-sm font-black">W</span>
제작 Width
</span>
</label>
<div class="relative">
<input type="number" id="settingCeilingWidth" value="1050" step="1" min="100" max="3000"
class="w-full bg-slate-900 border border-cyan-500/30 rounded px-2 py-1.5 text-cyan-400 font-mono text-sm focus:border-cyan-500 outline-none transition-all"
oninput="applyCeilingSizeSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">앞뒤 = Width - (T×2)</p>
</div>
<!-- 제작 Depth -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-cyan-500/30">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-cyan-500/20 text-cyan-400 flex items-center justify-center text-sm font-black">D</span>
제작 Depth
</span>
</label>
<div class="relative">
<input type="number" id="settingCeilingDepth" value="830" step="1" min="100" max="3000"
class="w-full bg-slate-900 border border-cyan-500/30 rounded px-2 py-1.5 text-cyan-400 font-mono text-sm focus:border-cyan-500 outline-none transition-all"
oninput="applyCeilingSizeSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">좌우 프레임 전체 </p>
</div>
<!-- 설정 적용 버튼 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-emerald-500/30 flex flex-col justify-center">
<button onclick="applyGlobalSettings()" class="w-full py-2 bg-gradient-to-r from-emerald-500 to-teal-600 text-white font-black text-sm rounded-lg hover:from-emerald-400 hover:to-teal-500 transition-all shadow-lg shadow-emerald-500/20 flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
설정 적용
</button>
<p class="text-slate-500 text-[9px] mt-1 text-center">자동 업데이트</p>
</div>
</div>
<!-- 프레임 치수 설정 -->
<div class="grid grid-cols-3 gap-3">
<!-- 위면 날개 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-black">1</span>
위면 날개
</span>
</label>
<div class="relative">
<input type="number" id="settingTopWing" value="30" step="0.1" min="10" max="100"
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none transition-all"
onchange="applyGlobalSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">상단 절곡 날개</p>
</div>
<!-- 전체 높이 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-black">2</span>
전체 높이
</span>
</label>
<div class="relative">
<input type="number" id="settingHeight" value="150" step="1" min="50" max="500"
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none transition-all"
onchange="applyGlobalSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">벽면 전체 높이</p>
</div>
<!-- 1 높이 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-violet-500/20 text-violet-400 flex items-center justify-center text-sm font-black">3</span>
1 높이
</span>
</label>
<div class="relative">
<input type="number" id="settingFirstStepHeight" value="70" step="1" min="20" max="300"
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none transition-all"
onchange="applyGlobalSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">수직부 높이</p>
</div>
<!-- 밑면 너비 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-amber-500/20 text-amber-400 flex items-center justify-center text-sm font-black">4</span>
밑면 너비
</span>
</label>
<div class="relative">
<input type="number" id="settingBottomWidth" value="100" step="1" min="50" max="500"
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none transition-all"
onchange="applyGlobalSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">바닥면 너비</p>
</div>
<!-- 팝너트 거리 -->
<div class="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50">
<label class="flex items-center justify-between mb-1.5">
<span class="text-white font-bold flex items-center gap-2 text-base">
<span class="w-6 h-6 rounded bg-rose-500/20 text-rose-400 flex items-center justify-center text-sm font-black">5</span>
팝너트 거리
</span>
</label>
<div class="relative">
<input type="number" id="settingPopnutDistance" value="75" step="1" min="20" max="200"
class="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none transition-all"
onchange="applyGlobalSettings()">
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 text-[10px] font-bold">mm</span>
</div>
<p class="text-slate-500 text-[9px] mt-1">끝점~팝너트</p>
</div>
<!-- 설정 요약 -->
<div class="bg-slate-800/30 rounded-lg p-3 border border-slate-700/30">
<h3 class="text-[10px] font-bold text-slate-400 mb-2 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
설정 요약
</h3>
<div id="settingsSummary" class="space-y-1 text-[9px]">
<div class="flex justify-between"><span class="text-slate-500">날개:</span><span class="text-white font-mono">30</span></div>
<div class="flex justify-between"><span class="text-slate-500">높이:</span><span class="text-white font-mono">150</span></div>
<div class="flex justify-between"><span class="text-slate-500">1:</span><span class="text-white font-mono">70</span></div>
</div>
</div>
</div>
</div>
</div>
<!-- WebGL Fallback UI -->
<div id="webglFallback" class="absolute inset-0 hidden bg-slate-900 overflow-auto p-8">
<div class="max-w-4xl mx-auto">
<div class="bg-amber-500/10 border border-amber-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-amber-500 flex-shrink-0">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<div>
<p class="text-amber-400 font-bold">3D 렌더링을 사용할 없습니다</p>
<p class="text-amber-400/70 text-sm">GPU 리소스가 부족하거나 WebGL이 지원되지 않습니다. 브라우저를 재시작하거나 다른 탭을 닫아보세요.</p>
</div>
</div>
<h3 class="text-xl font-black text-white mb-4 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" class="text-blue-500"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
3D 프레임 수치 정보
</h3>
<div id="webglFallbackContent" class="space-y-4">
<!-- Dynamic content will be injected here -->
</div>
</div>
</div>
<!-- Tooltips Overlay -->
<div id="tooltipOverlay" class="hidden absolute top-8 left-8 flex items-center gap-4 animate-pulse-soft">
<div class="bg-slate-900/80 backdrop-blur px-4 py-2 rounded-xl border border-slate-800 flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-white"></div>
<span class="text-[10px] font-black text-slate-400">외곽 라인</span>
</div>
<div class="bg-slate-900/80 backdrop-blur px-4 py-2 rounded-xl border border-slate-800 flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-red-500"></div>
<span class="text-[10px] font-black text-slate-400">절곡 (CCW)</span>
</div>
<div class="bg-slate-900/80 backdrop-blur px-4 py-2 rounded-xl border border-slate-800 flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-blue-500"></div>
<span class="text-[10px] font-black text-slate-400">절곡 (CW)</span>
</div>
</div>
<!-- 3D Camera Controls -->
<div id="viewPresets" class="absolute top-8 left-8 hidden flex items-center gap-2">
<div class="flex items-center gap-1 bg-slate-800/80 backdrop-blur p-1 rounded-xl border border-slate-700 mr-2">
<button onclick="setView('top')" class="hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-[10px] font-black transition-all">TOP</button>
<button onclick="setView('bottom')" class="hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-[10px] font-black transition-all">BOM</button>
<button onclick="setView('front')" class="hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-[10px] font-black transition-all">FRT</button>
<button onclick="setView('right')" class="hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-[10px] font-black transition-all">RGT</button>
<button onclick="setView('left')" class="hover:bg-blue-600 text-white px-3 py-1.5 rounded-lg text-[10px] font-black transition-all">LFT</button>
</div>
<!-- Color Palette -->
<div class="flex items-center gap-1.5 p-1 bg-slate-900/80 backdrop-blur rounded-xl border border-slate-700 shadow-xl">
<button onclick="updateMetalColor('#94a3b8')" class="w-5 h-5 rounded-full bg-[#94a3b8] border border-white/20 hover:scale-110 transition-transform" title="Silver"></button>
<button onclick="updateMetalColor('#d4af37')" class="w-5 h-5 rounded-full bg-[#d4af37] border border-white/20 hover:scale-110 transition-transform" title="Gold"></button>
<button onclick="updateMetalColor('#b87333')" class="w-5 h-5 rounded-full bg-[#b87333] border border-white/20 hover:scale-110 transition-transform" title="Copper"></button>
<button onclick="updateMetalColor('#334155')" class="w-5 h-5 rounded-full bg-[#334155] border border-white/20 hover:scale-110 transition-transform" title="Dark Steel"></button>
<div class="w-[1px] h-4 bg-slate-700 mx-1"></div>
<input type="color" id="metalColorPicker" oninput="updateMetalColor(this.value)" class="w-6 h-6 rounded-lg bg-transparent border-none cursor-pointer p-0 overflow-hidden" value="#94a3b8" title="Custom Color">
</div>
<!-- Wireframe Toggle -->
<div class="flex items-center gap-2 p-1.5 bg-slate-900/80 backdrop-blur rounded-xl border border-slate-700 shadow-xl">
<label class="flex items-center gap-2 cursor-pointer select-none px-2">
<input type="checkbox" id="wireToggle" onchange="toggleWireframe(this.checked)" class="w-4 h-4 rounded border-slate-700 bg-slate-950 text-blue-600 focus:ring-blue-500/30" />
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Wireframe</span>
</label>
</div>
<!-- Grid (Mesh) Toggle -->
<div class="flex items-center gap-2 p-1.5 bg-slate-900/80 backdrop-blur rounded-xl border border-slate-700 shadow-xl">
<label class="flex items-center gap-2 cursor-pointer select-none px-2 border-l border-slate-700 ml-1 pl-3">
<input type="checkbox" id="gridToggle" onchange="toggleGrid(this.checked)" class="w-4 h-4 rounded border-slate-700 bg-slate-950 text-emerald-600 focus:ring-emerald-500/30" />
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Mesh</span>
</label>
</div>
<!-- Measure Mode Toggle -->
<button id="measureToggleBtn" onclick="toggleMeasureMode()" class="flex items-center gap-2 p-1.5 px-3 bg-slate-900/80 backdrop-blur rounded-xl border border-slate-700 shadow-xl hover:bg-amber-500/20 hover:border-amber-500/50 transition-all group" title="엣지 치수 측정 모드">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-amber-400 group-hover:scale-110 transition-transform">
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>
<path d="m14.5 12.5 2-2"/>
<path d="m11.5 9.5 2-2"/>
<path d="m8.5 6.5 2-2"/>
<path d="m17.5 15.5 2-2"/>
</svg>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest group-hover:text-amber-300">측정</span>
<span class="text-[9px] font-mono text-slate-500 bg-slate-800 px-1 py-0.5 rounded group-hover:text-amber-400 group-hover:bg-slate-700">M</span>
</button>
</div>
<!-- Zoom Reset (View All) -->
<div class="absolute top-8 right-8 flex flex-col gap-2">
<button id="zoomResetBtn" class="hidden w-12 h-12 bg-slate-800/80 backdrop-blur hover:bg-slate-700 text-white rounded-2xl border border-slate-700 flex items-center justify-center shadow-xl transition-all group" title="전체보기">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="group-hover:scale-110 transition-transform"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
</div>
</div>
</section>
<!-- Status Info -->
<div id="statusGrid" class="hidden px-6 grid grid-cols-4 gap-4">
<div class="bg-slate-900/30 p-4 rounded-2xl border border-slate-800/50">
<span class="text-[9px] font-black text-slate-500 uppercase tracking-widest block mb-1"> 높이 (H)</span>
<span id="finalHeightDisplay" class="text-xl font-black text-white">0.00 <span class="text-[10px] text-slate-600">mm</span></span>
</div>
<div class="bg-slate-900/30 p-4 rounded-2xl border border-slate-800/50">
<span class="text-[9px] font-black text-slate-500 uppercase tracking-widest block mb-1">절곡 구간 </span>
<span id="totalSegmentsDisplay" class="text-xl font-black text-white">0 <span class="text-[10px] text-slate-600">BENDS</span></span>
</div>
<div class="bg-slate-900/30 p-4 rounded-2xl border border-slate-800/50">
<span class="text-[9px] font-black text-slate-500 uppercase tracking-widest block mb-1">V-Cut 적용</span>
<span id="vcutEnabledDisplay" class="text-xl font-black text-pink-500">OFF</span>
</div>
<div class="bg-slate-900/30 p-4 rounded-2xl border border-slate-800/50">
<span class="text-[9px] font-black text-slate-500 uppercase tracking-widest block mb-1">데이터 상태</span>
<span id="dataStatusDisplay" class="text-xl font-black text-emerald-500 uppercase tracking-tight">READY</span>
</div>
</div>
</div>
</div>
</main>
<!-- Modal for Part Drawings -->
<div id="partDrawingModal" class="fixed inset-0 bg-slate-950/90 backdrop-blur-xl z-[100] hidden items-center justify-center p-8">
<div class="bg-slate-900 border border-slate-800 rounded-[2.5rem] w-full max-w-6xl h-full flex flex-col shadow-2xl relative">
<button onclick="closePartModal()" class="absolute top-8 right-8 text-slate-400 hover:text-white transition-all p-2 bg-slate-800 rounded-full hover:rotate-90">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="p-10 flex-1 flex flex-col min-h-0">
<div class="flex items-center justify-between mb-8">
<div>
<h2 id="modalPartTitle" class="text-3xl font-black text-white mb-2 uppercase tracking-tight">LR FRAME 1</h2>
<span id="modalViewType" class="px-3 py-1 bg-blue-600/20 text-blue-400 text-[10px] font-black rounded-lg uppercase tracking-widest border border-blue-500/30">정면도</span>
</div>
<div id="modalViewButtons" class="flex gap-2">
<button onclick="updateModalView('front')" class="px-6 py-2.5 bg-slate-800 hover:bg-blue-600 rounded-xl text-white font-black text-sm transition-all border border-slate-700 active:scale-95">정면도</button>
<button onclick="updateModalView('top')" class="px-6 py-2.5 bg-slate-800 hover:bg-blue-600 rounded-xl text-white font-black text-sm transition-all border border-slate-700 active:scale-95">평면도</button>
<button onclick="updateModalView('section')" class="px-6 py-2.5 bg-slate-800 hover:bg-blue-600 rounded-xl text-white font-black text-sm transition-all border border-slate-700 active:scale-95">단면도</button>
<button onclick="updateModalView('unfold')" class="px-6 py-2.5 bg-slate-800 hover:bg-rose-600 rounded-xl text-white font-black text-sm transition-all border border-slate-700 active:scale-95 shadow-lg shadow-rose-900/20">전개도</button>
</div>
</div>
<div id="modalCanvasContainer" class="flex-1 bg-slate-950 rounded-[2rem] border border-slate-800 flex items-center justify-center overflow-hidden relative shadow-inner">
<!-- SVG will be injected here -->
<div class="absolute bottom-6 right-8 text-[10px] font-mono text-slate-600 tracking-tighter">TECHNICAL DRAWING ENGINE V1.0</div>
</div>
</div>
</div>
</div>
<!-- Part Action Menu (Context Menu for 3D) -->
<div id="partActionMenu" class="fixed hidden z-[110] bg-slate-900/90 backdrop-blur-md border border-slate-700 p-2 rounded-2xl shadow-2xl flex flex-col gap-1 min-w-[120px]">
<button id="btnViewPart" class="flex items-center gap-3 px-4 py-2 hover:bg-blue-600 rounded-xl text-white text-xs font-black transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> 보기
</button>
<button id="btnMovePart" class="flex items-center gap-3 px-4 py-2 hover:bg-emerald-600 rounded-xl text-white text-xs font-black transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg> 이동
</button>
<button id="btnIsolatePart" class="flex items-center gap-3 px-4 py-2 hover:bg-purple-600 rounded-xl text-white text-xs font-black transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg> 단독
</button>
<button id="btnShowAllParts" class="hidden items-center gap-3 px-4 py-2 hover:bg-slate-700 rounded-xl text-white text-xs font-black transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><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> 모두 보기
</button>
<div class="h-[1px] bg-slate-800 my-1"></div>
<button onclick="hideActionMenu()" class="px-4 py-1.5 hover:bg-slate-800 rounded-lg text-slate-400 text-[10px] font-bold text-center border-none bg-transparent cursor-pointer">취소</button>
</div>
<!-- Move Mode HUD -->
<div id="moveStatusOverlay" class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none hidden z-[120] text-center">
<div class="bg-emerald-600 text-white px-8 py-6 rounded-[2.5rem] shadow-2xl animate-pulse border border-emerald-400/30 backdrop-blur-sm">
<div id="moveTargetName" class="text-xs font-black uppercase tracking-widest opacity-70 mb-1">PART NAME</div>
<div class="text-2xl font-black mb-3">이동 모드 활성</div>
<div class="flex gap-4 justify-center text-[11px] font-bold">
<span id="axisIndicatorX" class="px-4 py-1.5 rounded-xl bg-white/20 border border-white/10 transition-all">X키</span>
<span id="axisIndicatorY" class="px-4 py-1.5 rounded-xl bg-white/20 border border-white/10 transition-all">Y키</span>
<span id="axisIndicatorZ" class="px-4 py-1.5 rounded-xl bg-white/20 border border-white/10 transition-all">Z키</span>
</div>
<div class="mt-6 flex flex-col gap-1 items-center">
<div class="text-[11px] font-black text-white/90">방향키(↑↓←→): 이동 / SHIFT+방향키: 고속 이동</div>
<div class="text-[10px] opacity-60 uppercase font-black tracking-widest mt-2">ENTER: 위치 확정 / ESC: 이동 취소</div>
</div>
</div>
</div>
</div>
@endsection
@push('styles')
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@100;400;700;900&display=swap" rel="stylesheet">
@endpush
@push('scripts')
<script src="https://cdn.tailwindcss.com/3.4.1"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script type="module">
// --- STATE & APP LOGIC ---
// 공통 절곡 치수 (밑면 이후 계단 부분 - 좌우/앞뒤 동일)
// 이 부분은 LR 탭에서만 편집하고, FB 탭은 자동으로 참조함
const commonStepSegments = [
{ id: 'step_1', length: 20.0, angle: 90, direction: 'CW', isVcut: true },
{ id: 'step_2', length: 20.0, angle: 90, direction: 'CCW', isVcut: true },
{ id: 'step_3', length: 20.0, angle: 90, direction: 'CCW', isVcut: true },
{ id: 'step_4', length: 15.0, angle: 90, direction: 'CW', isVcut: true },
{ id: 'step_5', length: 10.0, angle: 90, direction: 'CW', isVcut: true },
];
// LR 전용 세그먼트 (외곽 형태 - 위면날개, 사선, 1단높이, 밑면)
const lrSpecificSegments = [
{ id: 'lr_1', length: 30, angle: 126.9, direction: 'CCW', isVcut: false }, // 위면 날개
{ id: 'lr_2', length: 100, angle: 143.1, direction: 'CCW', isVcut: false }, // 사선
{ id: 'lr_3', length: 70, angle: 90, direction: 'CCW', isVcut: true }, // 1단 높이
{ id: 'lr_4', length: 100, angle: 90, direction: 'CCW', isVcut: true }, // 밑면 너비
];
// FB 전용 세그먼트 (외곽 형태 - 위면날개, 전체높이, 밑면)
const fbSpecificSegments = [
{ id: 'fb_1', length: 30.0, angle: 90.0, direction: 'CCW', isVcut: false }, // 위면 날개
{ id: 'fb_2', length: 150.0, angle: 90.0, direction: 'CCW', isVcut: true }, // 전체 높이
{ id: 'fb_3', length: 100.0, angle: 90.0, direction: 'CCW', isVcut: true }, // 밑면 너비
];
const initialConfig = {
thickness: 1.2,
sheetWidth: 830,
leftWing: 98.3,
rightWing: 98.3,
wingStartIndex: 3
};
// LR/FB 세그먼트 병합 함수
function buildSegments(specificSegments, commonSteps, prefix) {
const specific = JSON.parse(JSON.stringify(specificSegments));
const common = JSON.parse(JSON.stringify(commonSteps)).map((seg, i) => ({
...seg,
id: `${prefix}_step_${i + 1}`
}));
return [...specific, ...common];
}
const sheetAiAppState = {
// apiKey removed
activeTab: 'Settings',
// 공통 계단 절곡 (LR에서 편집, FB 자동 참조)
commonStepSegments: JSON.parse(JSON.stringify(commonStepSegments)),
// 글로벌 프레임 설정 (설정 탭에서 관리)
globalSettings: {
thickness: 1.2, // 철판두께 (좌우/앞뒤 공통)
topWing: 30, // 위면 날개 길이
height: 150, // 전체 프레임 높이
firstStepHeight: 70, // 1단 높이 (LR segments[2], FB에도 영향)
bottomWidth: 100, // 밑면 너비
popnutDistance: 75, // 상부 팝너트 거리
slantHorizontal: 60, // 사선 수평 이동 거리 (popnutDistance - topWing/2)
ceilingWidth: 1050, // 조명천장 제작 Width (앞뒤 프레임 = Width - T×2)
ceilingDepth: 830 // 조명천장 제작 Depth (좌우 프레임 전체 폭)
},
tabs: {
LR: {
specificSegments: JSON.parse(JSON.stringify(lrSpecificSegments)),
config: JSON.parse(JSON.stringify(initialConfig))
},
FB: {
specificSegments: JSON.parse(JSON.stringify(fbSpecificSegments)),
config: {
thickness: 1.2,
sheetWidth: 1047.6,
leftWing: 98.3,
rightWing: 98.3,
wingStartIndex: 2, // fb_3부터 Flare 시작 (밑면 구간)
}
}
},
// segments getter: 전용 세그먼트 + 공통 계단 세그먼트 병합
get segments() {
const t = this.tabs[this.activeTab] ? this.activeTab : (this.lastDataTab || 'LR');
const tab = this.tabs[t];
if (!tab) return [];
const specific = tab.specificSegments || [];
const common = this.commonStepSegments.map((seg, i) => ({
...seg,
id: `${t}_step_${i + 1}`
}));
return [...specific, ...common];
},
set segments(v) {
// segments 설정 시, 전용 부분과 공통 부분 분리
const t = this.tabs[this.activeTab] ? this.activeTab : (this.lastDataTab || 'LR');
const tab = this.tabs[t];
if (!tab) return;
const specificCount = tab.specificSegments.length;
tab.specificSegments = v.slice(0, specificCount);
// 공통 계단 부분은 commonStepSegments에 저장 (LR 탭에서만 수정)
if (t === 'LR' && v.length > specificCount) {
this.commonStepSegments = v.slice(specificCount).map((seg, i) => ({
...seg,
id: `step_${i + 1}`
}));
}
},
get config() {
const t = this.tabs[this.activeTab] ? this.activeTab : (this.lastDataTab || 'LR');
return this.tabs[t].config;
},
set config(v) {
const t = this.tabs[this.activeTab] ? this.activeTab : (this.lastDataTab || 'LR');
this.tabs[t].config = v;
},
view: {
scale: 1,
offset: { x: 0, y: 0 },
isDragging: false,
lastMousePos: { x: 0, y: 0 }
},
metalColor: '#94a3b8',
showWireframe: false,
showGrid: false,
isIsolated: false,
lastDataTab: 'LR',
// 조명 설정
lighting: {
preset: 'default', // default, studio, outdoor, dramatic, soft
ambient: { intensity: 0.3, color: '#ffffff' },
directional1: { intensity: 0.8, color: '#ffffff', x: 2000, y: 3000, z: 1000 },
directional2: { intensity: 0.4, color: '#ffffff', x: -2000, y: 2000, z: -1000 },
point: { intensity: 0.6, color: '#ffffff', x: 0, y: 1000, z: 0 },
hemisphere: { enabled: false, skyColor: '#87ceeb', groundColor: '#8b4513', intensity: 0.5 },
spot: { enabled: false, intensity: 1.0, color: '#ffffff', x: 0, y: 2000, z: 0, angle: 30, penumbra: 0.5 }
}
};
// 특정 탭의 세그먼트 가져오기 헬퍼 함수
function getSegmentsForTab(tabName) {
const tabData = sheetAiAppState.tabs[tabName];
if (!tabData) return [];
const specific = tabData.specificSegments || [];
const common = (sheetAiAppState.commonStepSegments || []).map((seg, i) => ({
...seg,
id: `${tabName}_step_${i + 1}`
}));
return [...specific, ...common];
}
window.getSegmentsForTab = getSegmentsForTab;
window.switchTab = (tabId) => {
if (tabId === 'LR' || tabId === 'FB') {
sheetAiAppState.lastDataTab = tabId;
}
sheetAiAppState.activeTab = tabId;
// Reset scale and offset when switching tabs
sheetAiAppState.view.scale = 1;
sheetAiAppState.view.offset = { x: 0, y: 0 };
// Update scale display UI
const scaleDisplay = $('scaleDisplay');
if (scaleDisplay) {
scaleDisplay.innerText = 'SCALE: 100%';
}
// Update Tab UI
const tabSettings = $('tabSettings');
const tabLR = $('tabLR');
const tabFB = $('tabFB');
const tab3D = $('tab3D');
if (tabSettings && tabLR && tabFB && tab3D) {
const activeClasses = "px-6 py-2.5 rounded-xl font-black text-sm transition-all flex items-center gap-2 bg-blue-600 text-white shadow-lg shadow-blue-500/20";
const inactiveClasses = "px-6 py-2.5 rounded-xl font-black text-sm transition-all flex items-center gap-2 text-slate-400 hover:text-white hover:bg-slate-800";
tabSettings.className = (tabId === 'Settings') ? activeClasses : inactiveClasses;
tabLR.className = (tabId === 'LR') ? activeClasses : inactiveClasses;
tabFB.className = (tabId === 'FB') ? activeClasses : inactiveClasses;
tab3D.className = (tabId === '3D') ? activeClasses : inactiveClasses;
}
const unfoldedDrawingContainer = $('unfoldedDrawingContainer');
const threeDContainer = $('threeDContainer');
const settingsContainer = $('settingsContainer');
const previewTitle = $('previewTitle');
const downloadDxfBtn = $('downloadDxfBtn');
const tooltipOverlay = $('tooltipOverlay');
const viewPresets = $('viewPresets');
const dimSection = $('dimensionSection');
const paramSection = $('parameterSection');
const detailSection = $('detailsSection');
const statusGrid = $('statusGrid');
const zoomResetBtn = $('zoomResetBtn');
const previewSection = $('previewSection');
const webglFallbackEl = $('webglFallback');
// 모든 컨테이너 기본 숨김
if (unfoldedDrawingContainer) unfoldedDrawingContainer.classList.add('hidden');
if (threeDContainer) threeDContainer.classList.add('hidden');
if (settingsContainer) settingsContainer.classList.add('hidden');
if (webglFallbackEl) webglFallbackEl.classList.add('hidden');
if (tabId === 'Settings') {
// 설정 탭
if (settingsContainer) settingsContainer.classList.remove('hidden');
if (previewTitle) previewTitle.innerHTML = `<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="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg> 프레임 전체 설정`;
if (downloadDxfBtn) downloadDxfBtn.classList.add('hidden');
if (tooltipOverlay) tooltipOverlay.classList.add('hidden');
if (viewPresets) viewPresets.classList.add('hidden');
if (dimSection) dimSection.classList.add('hidden');
if (paramSection) paramSection.classList.add('hidden');
if (detailSection) detailSection.classList.add('hidden');
if (statusGrid) statusGrid.classList.add('hidden');
if (zoomResetBtn) zoomResetBtn.classList.add('hidden');
if (previewSection) {
previewSection.classList.remove('min-h-[450px]');
previewSection.classList.add('min-h-[900px]');
}
// addSegmentBtn 숨김
const addBtnSettings = $('addSegmentBtn');
if (addBtnSettings) addBtnSettings.classList.add('hidden');
// 설정 값 동기화
syncSettingsInputs();
} else if (tabId === '3D') {
if (webglFailed) {
// WebGL 실패 상태면 폴백 UI 표시
if (webglFallbackEl) {
webglFallbackEl.classList.remove('hidden');
updateWebGLFallbackContent();
}
} else if (threeDContainer) {
threeDContainer.classList.remove('hidden');
initThreeJS();
}
if (previewTitle) previewTitle.innerHTML = `<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="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg> 3D 랜더링`;
if (downloadDxfBtn) downloadDxfBtn.classList.add('hidden');
if (tooltipOverlay) tooltipOverlay.classList.add('hidden');
if (viewPresets) viewPresets.classList.remove('hidden');
if (dimSection) dimSection.classList.add('hidden');
if (paramSection) paramSection.classList.add('hidden');
if (detailSection) detailSection.classList.add('hidden');
if (statusGrid) statusGrid.classList.add('hidden');
if (zoomResetBtn) zoomResetBtn.classList.add('hidden');
if (previewSection) {
previewSection.classList.remove('min-h-[450px]');
previewSection.classList.add('min-h-[900px]');
}
// addSegmentBtn 숨김
const addBtn3D = $('addSegmentBtn');
if (addBtn3D) addBtn3D.classList.add('hidden');
} else {
// LR / FB 탭
if (unfoldedDrawingContainer) unfoldedDrawingContainer.classList.remove('hidden');
if (previewTitle) previewTitle.innerHTML = `<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> 전개도 정밀 미리보기`;
if (downloadDxfBtn) downloadDxfBtn.classList.remove('hidden');
if (tooltipOverlay) tooltipOverlay.classList.remove('hidden');
if (viewPresets) viewPresets.classList.add('hidden');
if (dimSection) dimSection.classList.remove('hidden');
if (paramSection) paramSection.classList.remove('hidden');
if (detailSection) detailSection.classList.remove('hidden');
if (statusGrid) statusGrid.classList.remove('hidden');
if (zoomResetBtn) zoomResetBtn.classList.remove('hidden');
if (previewSection) {
previewSection.classList.remove('min-h-[900px]');
previewSection.classList.add('min-h-[450px]');
}
// Sync inputs with active tab data
$('sheetWidthInput').value = sheetAiAppState.config.sheetWidth;
$('wingWidthInput').value = sheetAiAppState.config.leftWing;
// addSegmentBtn 가시성 제어 - LR탭에서만 공통 세그먼트 추가 가능
const addSegmentBtn = $('addSegmentBtn');
if (addSegmentBtn) {
if (tabId === 'LR') {
addSegmentBtn.classList.remove('hidden');
} else {
addSegmentBtn.classList.add('hidden');
}
}
}
renderProfile(); // Always call this
updateUI();
};
// 설정 입력값 동기화
function syncSettingsInputs() {
const gs = sheetAiAppState.globalSettings;
const thicknessInput = $('settingThickness');
const topWingInput = $('settingTopWing');
const heightInput = $('settingHeight');
const firstStepHeightInput = $('settingFirstStepHeight');
const bottomWidthInput = $('settingBottomWidth');
const popnutDistanceInput = $('settingPopnutDistance');
const ceilingWidthInput = $('settingCeilingWidth');
const ceilingDepthInput = $('settingCeilingDepth');
// 철판두께 동기화
if (thicknessInput) thicknessInput.value = gs.thickness || 1.2;
if (topWingInput) topWingInput.value = gs.topWing;
if (heightInput) heightInput.value = gs.height;
if (firstStepHeightInput) firstStepHeightInput.value = gs.firstStepHeight;
if (bottomWidthInput) bottomWidthInput.value = gs.bottomWidth;
if (popnutDistanceInput) popnutDistanceInput.value = gs.popnutDistance;
// 조명천장 제작 사이즈 동기화
if (ceilingWidthInput) ceilingWidthInput.value = gs.ceilingWidth || 1050;
if (ceilingDepthInput) ceilingDepthInput.value = gs.ceilingDepth || 830;
updateSettingsSummary();
}
// 설정 요약 업데이트
function updateSettingsSummary() {
const gs = sheetAiAppState.globalSettings;
const summary = $('settingsSummary');
if (summary) {
// 사선 계산 (slantHorizontal은 gs에서 가져오거나 동적 계산)
const slantVertical = gs.height - gs.firstStepHeight;
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2);
const slantLength = Math.sqrt(slantVertical * slantVertical + slantHorizontal * slantHorizontal);
const slantAngleRad = Math.atan2(slantVertical, slantHorizontal);
const slantAngleDeg = slantAngleRad * 180 / Math.PI;
const bendAngle1 = (180 - slantAngleDeg).toFixed(1);
summary.innerHTML = `
<div class="flex justify-between"><span class="text-slate-500">위면 날개:</span><span class="text-white font-mono">${gs.topWing}mm</span></div>
<div class="flex justify-between"><span class="text-slate-500">전체 높이:</span><span class="text-white font-mono">${gs.height}mm</span></div>
<div class="flex justify-between"><span class="text-slate-500">1단 높이:</span><span class="text-white font-mono">${gs.firstStepHeight}mm</span></div>
<div class="flex justify-between"><span class="text-slate-500">밑면 너비:</span><span class="text-white font-mono">${gs.bottomWidth}mm</span></div>
<div class="flex justify-between"><span class="text-slate-500">팝너트 거리:</span><span class="text-white font-mono">${gs.popnutDistance}mm</span></div>
<div class="col-span-2 border-t border-slate-700/50 pt-2 mt-2">
<div class="flex justify-between text-xs"><span class="text-amber-500/70">사선 수평:</span><span class="text-amber-400 font-mono">${slantHorizontal.toFixed(1)}mm</span></div>
<div class="flex justify-between text-xs"><span class="text-amber-500/70">사선 길이:</span><span class="text-amber-400 font-mono">${slantLength.toFixed(1)}mm</span></div>
<div class="flex justify-between text-xs"><span class="text-amber-500/70">사선 각도:</span><span class="text-amber-400 font-mono">${bendAngle1}°</span></div>
</div>
`;
}
}
// 글로벌 설정 적용 함수
window.applyGlobalSettings = () => {
const topWing = parseFloat($('settingTopWing')?.value) || 30;
const height = parseFloat($('settingHeight')?.value) || 150;
const firstStepHeight = parseFloat($('settingFirstStepHeight')?.value) || 70;
const bottomWidth = parseFloat($('settingBottomWidth')?.value) || 100;
const popnutDistance = parseFloat($('settingPopnutDistance')?.value) || 75;
// 사선 구간 계산 (LR 프레임)
// 사선 수직 높이 = 전체 높이 - 1단 높이
const slantVertical = height - firstStepHeight; // 예: 150 - 70 = 80
// 사선 수평 이동 = 팝너트 거리 - 날개 절반
// 공식: popnutDistance = slantHorizontal + topWing/2
// 따라서: slantHorizontal = popnutDistance - topWing/2
const slantHorizontal = popnutDistance - topWing / 2; // 예: 75 - 15 = 60
// 사선 길이 = sqrt(수직^2 + 수평^2)
const slantLength = Math.sqrt(slantVertical * slantVertical + slantHorizontal * slantHorizontal);
// 사선 각도 계산 (절곡 각도)
// atan2(수직, 수평) = 수평 기준 각도 → 180 - 각도 = 절곡 각도
const slantAngleRad = Math.atan2(slantVertical, slantHorizontal);
const slantAngleDeg = slantAngleRad * 180 / Math.PI;
const bendAngle1 = parseFloat((180 - slantAngleDeg).toFixed(1)); // 위면 날개 → 사선 절곡각
const bendAngle2 = parseFloat((180 - (90 - slantAngleDeg)).toFixed(1)); // 사선 → 수직부 절곡각
// 글로벌 설정 업데이트 (slantHorizontal 포함)
sheetAiAppState.globalSettings = { topWing, height, firstStepHeight, bottomWidth, popnutDistance, slantHorizontal };
// LR 프레임 specificSegments 업데이트
// LR: [위면날개, 사선, 1단높이, 밑면너비]
const lrSpecific = sheetAiAppState.tabs.LR.specificSegments;
if (lrSpecific[0]) {
lrSpecific[0].length = topWing; // 위면 날개
lrSpecific[0].angle = bendAngle1; // 사선 시작 절곡각 (예: 126.9°)
}
if (lrSpecific[1]) {
lrSpecific[1].length = parseFloat(slantLength.toFixed(1)); // 사선 길이 (예: 100)
lrSpecific[1].angle = bendAngle2; // 사선 끝 절곡각 (예: 143.1°)
}
if (lrSpecific[2]) lrSpecific[2].length = firstStepHeight; // 1단 높이 (수직부)
if (lrSpecific[3]) lrSpecific[3].length = bottomWidth; // 밑면 너비
// FB 프레임 specificSegments 업데이트
// FB: [위면날개, 높이, 밑면너비]
const fbSpecific = sheetAiAppState.tabs.FB.specificSegments;
if (fbSpecific[0]) fbSpecific[0].length = topWing; // 위면 날개
if (fbSpecific[1]) fbSpecific[1].length = height; // 전체 높이
if (fbSpecific[2]) fbSpecific[2].length = bottomWidth; // 밑면 너비
// 설정 요약 업데이트
updateSettingsSummary();
console.log('글로벌 설정 적용됨:', sheetAiAppState.globalSettings);
console.log('사선 계산: 수직=' + slantVertical + ', 수평=' + slantHorizontal + ', 길이=' + slantLength.toFixed(1) + ', 각도1=' + bendAngle1 + '°, 각도2=' + bendAngle2 + '°');
console.log('LR specificSegments:', lrSpecific);
console.log('FB specificSegments:', fbSpecific);
// UI 업데이트
renderProfile();
updateUI();
};
// 조명천장 제작 사이즈 적용 함수 (실시간 연동)
window.applyCeilingSizeSettings = () => {
const thickness = parseFloat($('settingThickness')?.value) || 1.2;
const ceilingWidth = parseFloat($('settingCeilingWidth')?.value) || 1050;
const ceilingDepth = parseFloat($('settingCeilingDepth')?.value) || 830;
// 철판두께를 양쪽 탭에 적용
sheetAiAppState.tabs.LR.config.thickness = thickness;
sheetAiAppState.tabs.FB.config.thickness = thickness;
sheetAiAppState.globalSettings.thickness = thickness;
// 앞뒤(FB) 프레임: Width - (소재두께 × 2) 공식 적용
const fbWidth = ceilingWidth - (thickness * 2);
sheetAiAppState.tabs.FB.config.sheetWidth = fbWidth;
// 좌우(LR) 프레임: Depth 그대로 적용
sheetAiAppState.tabs.LR.config.sheetWidth = ceilingDepth;
// 전개 파라미터 UI 업데이트 (현재 탭에 맞게)
const activeTab = sheetAiAppState.activeTab;
if (activeTab === 'LR' || activeTab === 'Settings') {
$('sheetWidthInput').value = ceilingDepth;
} else if (activeTab === 'FB') {
$('sheetWidthInput').value = fbWidth;
}
// 글로벌 설정에 저장
sheetAiAppState.globalSettings.ceilingWidth = ceilingWidth;
sheetAiAppState.globalSettings.ceilingDepth = ceilingDepth;
console.log('조명천장 사이즈 적용됨: T=' + thickness + ', Width=' + ceilingWidth + '(앞뒤=' + fbWidth.toFixed(1) + '), Depth=' + ceilingDepth + '(좌우)');
// UI 업데이트
renderProfile();
updateUI();
};
// --- PART MODAL LOGIC ---
let currentModalPart = null;
let currentModalView = 'front';
window.openPartModal = (partInfo) => {
currentModalPart = partInfo;
currentModalView = 'front';
$('modalPartTitle').innerText = partInfo.name;
$('partDrawingModal').style.display = 'flex';
updateModalView('front');
};
window.closePartModal = () => {
$('partDrawingModal').style.display = 'none';
};
window.updateModalView = (viewType) => {
currentModalView = viewType;
const viewLabels = { front: '정면도', top: '평면도', section: '단면도', unfold: '전개도' };
$('modalViewType').innerText = viewLabels[viewType];
renderModalDrawing();
};
function renderModalDrawing() {
if (!currentModalPart) return;
const tab = currentModalPart.tab;
const res = getUnfoldedData(tab);
const items = res.items;
const totalUy = res.finalHeight;
const sheetWidth = sheetAiAppState.tabs[tab].config.sheetWidth;
const container = $('modalCanvasContainer');
// 공통 프로파일 좌표 추출
let cx = 0, cy = 0, cda = 180;
const profilePts = [{x:0, y:0, uy: 0}];
items.forEach(it => {
const len = Number(it.length);
const rad = cda * Math.PI / 180;
cx += len * Math.cos(rad);
cy += len * Math.sin(rad);
profilePts.push({x:cx, y:cy, uy: it.yEnd});
cda += (180 - Number(it.angle)) * (it.direction === 'CW' ? 1 : -1);
});
const minX = Math.min(...profilePts.map(p=>p.x)), maxX = Math.max(...profilePts.map(p=>p.x));
const minY = Math.min(...profilePts.map(p=>p.y)), maxY = Math.max(...profilePts.map(p=>p.y));
const partHeight = maxY - minY;
const partWidth = maxX - minX;
let svgHtml = '';
if (currentModalView === 'front') {
// Front View: Projection of the long side
svgHtml = `<svg viewBox="-100 -100 ${sheetWidth + 200} ${partHeight + 200}" class="h-[80%] w-auto">
<rect x="0" y="0" width="${sheetWidth}" height="${partHeight}" fill="none" stroke="#60a5fa" stroke-width="4" />
<line x1="0" y1="0" x2="${sheetWidth}" y2="0" stroke="#1e293b" stroke-width="2" />
<text x="${sheetWidth/2}" y="${partHeight/2}" fill="#60a5fa" font-size="32" text-anchor="middle" font-weight="black" font-family="monospace">FRONT PROJECTION</text>
<text x="${sheetWidth/2}" y="${partHeight + 60}" fill="#94a3b8" font-size="24" text-anchor="middle">W: ${sheetWidth} / H: ${partHeight.toFixed(1)}</text>
</svg>`;
} else if (currentModalView === 'section') {
// Section Profile View
svgHtml = `<svg viewBox="${minX-50} ${minY-50} ${maxX-minX+100} ${maxY-minY+100}" class="h-[80%] w-auto text-emerald-400">
<polyline points="${profilePts.map(p=>`${p.x},${p.y}`).join(' ')}" fill="none" stroke="currentColor" stroke-width="6" stroke-linejoin="round" />
<text x="${(minX+maxX)/2}" y="${minY-30}" fill="currentColor" font-size="24" text-anchor="middle" font-weight="black">SECTION PROFILE</text>
<text x="${(minX+maxX)/2}" y="${maxY+60}" fill="#94a3b8" font-size="20" text-anchor="middle">TOTAL LENGTH: ${totalUy.toFixed(1)}</text>
</svg>`;
} else if (currentModalView === 'top') {
// Top View: Looking down
svgHtml = `<svg viewBox="-100 -100 ${sheetWidth + 200} ${partWidth + 200}" class="h-[80%] w-auto">
<rect x="0" y="0" width="${sheetWidth}" height="${partWidth}" fill="none" stroke="#f472b6" stroke-width="4" />
<path d="M0,0 L${partWidth},${partWidth} M${sheetWidth},0 L${sheetWidth-partWidth},${partWidth}" stroke="#f472b6" stroke-width="2" stroke-dasharray="8,4" />
<text x="${sheetWidth/2}" y="${partWidth/2}" fill="#f472b6" font-size="32" text-anchor="middle" font-weight="black" font-family="monospace">TOP VIEW</text>
<text x="${sheetWidth/2}" y="${partWidth + 80}" fill="#94a3b8" font-size="24" text-anchor="middle">LENGTH: ${sheetWidth} / DEPTH: ${partWidth.toFixed(1)}</text>
</svg>`;
} else if (currentModalView === 'unfold') {
// Real Unfolded View with Miters AND Wings/Notches + 치수 및 각도 표시
const samplingYs = [];
for(let u=0; u<=totalUy; u+=5) {
samplingYs.push(u);
}
samplingYs.push(totalUy);
// FB 전용 정밀 샘플링 추가 (globalSettings 연동)
if (tab === 'FB' && items[0]) {
const gs = sheetAiAppState.globalSettings;
const y1 = items[0]?.yEnd || 0;
const fbSlantHeight = (gs.height || 150) - (gs.firstStepHeight || 70); // 전체높이 - 1단높이
const ySlant = y1 + fbSlantHeight;
samplingYs.push(y1 - 0.01, y1 + 0.01, ySlant);
}
samplingYs.sort((a, b) => a - b);
const uniqueYs = [...new Set(samplingYs)];
const miterPts = [];
uniqueYs.forEach(u => {
if (u >= 0 && u <= totalUy) {
miterPts.push({ uy: u, inset: getInsetAtY(tab, u) });
}
});
let pathD = `M ${miterPts[0].inset},0 `;
miterPts.forEach(p => pathD += `L ${p.inset},${p.uy} `);
// 반대편 (Symmetric for now, but following Inset logic)
[...miterPts].reverse().forEach(p => pathD += `L ${sheetWidth - p.inset},${p.uy} `);
pathD += "Z";
// 세그먼트별 치수 및 각도 생성
const segments = sheetAiAppState.tabs[tab]?.segments || [];
const dimOffset = sheetWidth + 60; // 우측 치수선 위치
const leftDimOffset = -30; // 좌측 각도 표시 위치
// 각 세그먼트의 전개 길이와 Y 위치 계산
let segmentDims = '';
let prevYEnd = 0;
items.forEach((item, idx) => {
const yStart = item.yStart;
const yEnd = item.yEnd;
const unfoldedLen = item.unfoldedLength;
const midY = (yStart + yEnd) / 2;
const angle = segments[idx]?.angle || 90;
const isVcut = segments[idx]?.isVcut || false;
// 우측: 세그먼트 길이 치수선
segmentDims += `
<!-- 세그먼트 ${idx + 1} 치수선 -->
<line x1="${dimOffset}" y1="${yStart}" x2="${dimOffset}" y2="${yEnd}" stroke="#60a5fa" stroke-width="2" />
<line x1="${sheetWidth}" y1="${yStart}" x2="${dimOffset + 5}" y2="${yStart}" stroke="#60a5fa" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
<line x1="${sheetWidth}" y1="${yEnd}" x2="${dimOffset + 5}" y2="${yEnd}" stroke="#60a5fa" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
<polygon points="${dimOffset},${yStart} ${dimOffset-4},${yStart+8} ${dimOffset+4},${yStart+8}" fill="#60a5fa" />
<polygon points="${dimOffset},${yEnd} ${dimOffset-4},${yEnd-8} ${dimOffset+4},${yEnd-8}" fill="#60a5fa" />
<text x="${dimOffset + 12}" y="${midY}" fill="#60a5fa" font-size="14" font-weight="bold" alignment-baseline="middle">${unfoldedLen.toFixed(1)}</text>
`;
// 좌측: 각도 표시 (절곡선 위치에)
if (idx < items.length - 1) {
const angleColor = isVcut ? '#ef4444' : '#4ade80';
const vcutMark = isVcut ? ' (V)' : '';
segmentDims += `
<!-- 절곡 ${idx + 1} 각도 -->
<circle cx="${leftDimOffset}" cy="${yEnd}" r="4" fill="${angleColor}" />
<text x="${leftDimOffset - 10}" y="${yEnd}" fill="${angleColor}" font-size="13" font-weight="bold" text-anchor="end" alignment-baseline="middle">${angle}°${vcutMark}</text>
`;
}
prevYEnd = yEnd;
});
// 상단: 전체 너비 치수선
const topDimY = -40;
const widthDim = `
<!-- 전체 너비 치수선 -->
<line x1="0" y1="${topDimY}" x2="${sheetWidth}" y2="${topDimY}" stroke="#f59e0b" stroke-width="2" />
<line x1="0" y1="0" x2="0" y2="${topDimY - 5}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
<line x1="${sheetWidth}" y1="0" x2="${sheetWidth}" y2="${topDimY - 5}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
<polygon points="0,${topDimY} 8,${topDimY-4} 8,${topDimY+4}" fill="#f59e0b" />
<polygon points="${sheetWidth},${topDimY} ${sheetWidth-8},${topDimY-4} ${sheetWidth-8},${topDimY+4}" fill="#f59e0b" />
<text x="${sheetWidth/2}" y="${topDimY - 10}" fill="#f59e0b" font-size="16" font-weight="bold" text-anchor="middle">${sheetWidth}</text>
`;
// 우측 끝: 전체 높이 치수선
const rightDimX = sheetWidth + 70;
const heightDim = `
<!-- 전체 높이 치수선 -->
<line x1="${rightDimX}" y1="0" x2="${rightDimX}" y2="${totalUy}" stroke="#f59e0b" stroke-width="2" />
<line x1="${sheetWidth}" y1="0" x2="${rightDimX + 5}" y2="0" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
<line x1="${sheetWidth}" y1="${totalUy}" x2="${rightDimX + 5}" y2="${totalUy}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
<polygon points="${rightDimX},0 ${rightDimX-4},8 ${rightDimX+4},8" fill="#f59e0b" />
<polygon points="${rightDimX},${totalUy} ${rightDimX-4},${totalUy-8} ${rightDimX+4},${totalUy-8}" fill="#f59e0b" />
<text x="${rightDimX + 15}" y="${totalUy/2}" fill="#f59e0b" font-size="16" font-weight="bold" alignment-baseline="middle" transform="rotate(90, ${rightDimX + 15}, ${totalUy/2})">${totalUy.toFixed(1)}</text>
`;
// 외곽선 꺾이는 점 추출 (좌측 외곽선 기준)
const outlineVertices = [];
let prevInset = miterPts[0]?.inset || 0;
miterPts.forEach((pt, idx) => {
// inset이 변하는 지점 = 꺾이는 점
if (idx > 0 && Math.abs(pt.inset - prevInset) > 0.5) {
outlineVertices.push({
x: pt.inset,
y: pt.uy,
prevX: prevInset,
prevY: miterPts[idx-1].uy
});
}
prevInset = pt.inset;
});
// 상부 디테일 치수선 (y=0 라인의 inset 변화 구간)
const topDetailDimY = -20;
let topDetailDims = '';
const topInset = miterPts[0]?.inset || 0;
if (topInset > 0) {
// 좌측 상부 노치
topDetailDims += `
<!-- 상부 좌측 노치 치수 -->
<line x1="0" y1="${topDetailDimY}" x2="${topInset}" y2="${topDetailDimY}" stroke="#a78bfa" stroke-width="1.5" />
<polygon points="0,${topDetailDimY} 5,${topDetailDimY-2.5} 5,${topDetailDimY+2.5}" fill="#a78bfa" />
<polygon points="${topInset},${topDetailDimY} ${topInset-5},${topDetailDimY-2.5} ${topInset-5},${topDetailDimY+2.5}" fill="#a78bfa" />
<text x="${topInset/2}" y="${topDetailDimY - 5}" fill="#a78bfa" font-size="11" font-weight="bold" text-anchor="middle">${topInset.toFixed(1)}</text>
<!-- 우측 상부 노치 (대칭) -->
<line x1="${sheetWidth - topInset}" y1="${topDetailDimY}" x2="${sheetWidth}" y2="${topDetailDimY}" stroke="#a78bfa" stroke-width="1.5" />
<polygon points="${sheetWidth - topInset},${topDetailDimY} ${sheetWidth - topInset + 5},${topDetailDimY-2.5} ${sheetWidth - topInset + 5},${topDetailDimY+2.5}" fill="#a78bfa" />
<polygon points="${sheetWidth},${topDetailDimY} ${sheetWidth - 5},${topDetailDimY-2.5} ${sheetWidth - 5},${topDetailDimY+2.5}" fill="#a78bfa" />
<text x="${sheetWidth - topInset/2}" y="${topDetailDimY - 5}" fill="#a78bfa" font-size="11" font-weight="bold" text-anchor="middle">${topInset.toFixed(1)}</text>
`;
}
// 하부 디테일 치수선 (실제 전개도와 동일한 계단식 구조)
const bottomDetailDimY = totalUy + 20;
let bottomDetailDims = '';
// LR 탭: 계단식 하부 노치 치수 (98.3, 18.8, 19.4 등)
const cfg = sheetAiAppState.tabs[tab]?.config;
const notchIdx = Number(cfg?.wingStartIndex) || 0;
if (notchIdx > 0 && notchIdx < items.length) {
const leftWing = cfg?.leftWing || 98.3;
let curX = 0;
const bottomDimSegments = [];
// 첫 번째 사선 (0 → leftWing)
bottomDimSegments.push({ x1: 0, x2: leftWing, label: leftWing.toFixed(1) });
curX = leftWing;
// notchIdx 이후 세그먼트들의 수평 구간
for (let i = notchIdx + 1; i < items.length; i++) {
const relIdx = i - notchIdx;
const segmentH = items[i].unfoldedLength;
if (relIdx % 2 === 0) {
// 짝수: 수평 구간 (너비 증가)
bottomDimSegments.push({ x1: curX, x2: curX + segmentH, label: segmentH.toFixed(1) });
curX += segmentH;
}
}
// 좌측 하부 세부 치수선 그리기
bottomDimSegments.forEach((seg, idx) => {
const y = bottomDetailDimY + idx * 15;
bottomDetailDims += `
<line x1="${seg.x1}" y1="${y}" x2="${seg.x2}" y2="${y}" stroke="#a78bfa" stroke-width="1.5" />
<line x1="${seg.x1}" y1="${totalUy}" x2="${seg.x1}" y2="${y + 3}" stroke="#a78bfa" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<line x1="${seg.x2}" y1="${totalUy}" x2="${seg.x2}" y2="${y + 3}" stroke="#a78bfa" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<polygon points="${seg.x1},${y} ${seg.x1+4},${y-2} ${seg.x1+4},${y+2}" fill="#a78bfa" />
<polygon points="${seg.x2},${y} ${seg.x2-4},${y-2} ${seg.x2-4},${y+2}" fill="#a78bfa" />
<text x="${(seg.x1 + seg.x2) / 2}" y="${y + 10}" fill="#a78bfa" font-size="10" font-weight="bold" text-anchor="middle">${seg.label}</text>
`;
// 우측 대칭
bottomDetailDims += `
<line x1="${sheetWidth - seg.x2}" y1="${y}" x2="${sheetWidth - seg.x1}" y2="${y}" stroke="#a78bfa" stroke-width="1.5" />
<line x1="${sheetWidth - seg.x1}" y1="${totalUy}" x2="${sheetWidth - seg.x1}" y2="${y + 3}" stroke="#a78bfa" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<line x1="${sheetWidth - seg.x2}" y1="${totalUy}" x2="${sheetWidth - seg.x2}" y2="${y + 3}" stroke="#a78bfa" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<polygon points="${sheetWidth - seg.x1},${y} ${sheetWidth - seg.x1 - 4},${y-2} ${sheetWidth - seg.x1 - 4},${y+2}" fill="#a78bfa" />
<polygon points="${sheetWidth - seg.x2},${y} ${sheetWidth - seg.x2 + 4},${y-2} ${sheetWidth - seg.x2 + 4},${y+2}" fill="#a78bfa" />
<text x="${sheetWidth - (seg.x1 + seg.x2) / 2}" y="${y + 10}" fill="#a78bfa" font-size="10" font-weight="bold" text-anchor="middle">${seg.label}</text>
`;
});
// 하부 중앙 너비
const centerWidth = sheetWidth - 2 * curX;
if (centerWidth > 0) {
const centerY = bottomDetailDimY;
bottomDetailDims += `
<line x1="${curX}" y1="${centerY}" x2="${sheetWidth - curX}" y2="${centerY}" stroke="#22d3ee" stroke-width="1.5" />
<polygon points="${curX},${centerY} ${curX+4},${centerY-2} ${curX+4},${centerY+2}" fill="#22d3ee" />
<polygon points="${sheetWidth - curX},${centerY} ${sheetWidth - curX - 4},${centerY-2} ${sheetWidth - curX - 4},${centerY+2}" fill="#22d3ee" />
<text x="${sheetWidth/2}" y="${centerY + 10}" fill="#22d3ee" font-size="10" font-weight="bold" text-anchor="middle">${centerWidth.toFixed(1)}</text>
`;
}
} else {
// notchIdx가 없는 경우 기존 로직
const bottomInset = miterPts[miterPts.length - 1]?.inset || 0;
if (bottomInset > 0) {
bottomDetailDims += `
<line x1="0" y1="${bottomDetailDimY}" x2="${bottomInset}" y2="${bottomDetailDimY}" stroke="#a78bfa" stroke-width="1.5" />
<text x="${bottomInset/2}" y="${bottomDetailDimY + 12}" fill="#a78bfa" font-size="11" font-weight="bold" text-anchor="middle">${bottomInset.toFixed(1)}</text>
<line x1="${sheetWidth - bottomInset}" y1="${bottomDetailDimY}" x2="${sheetWidth}" y2="${bottomDetailDimY}" stroke="#a78bfa" stroke-width="1.5" />
<text x="${sheetWidth - bottomInset/2}" y="${bottomDetailDimY + 12}" fill="#a78bfa" font-size="11" font-weight="bold" text-anchor="middle">${bottomInset.toFixed(1)}</text>
`;
const centerWidth = sheetWidth - 2 * bottomInset;
if (centerWidth > 0) {
bottomDetailDims += `
<line x1="${bottomInset}" y1="${bottomDetailDimY}" x2="${sheetWidth - bottomInset}" y2="${bottomDetailDimY}" stroke="#22d3ee" stroke-width="1.5" />
<text x="${sheetWidth/2}" y="${bottomDetailDimY + 12}" fill="#22d3ee" font-size="11" font-weight="bold" text-anchor="middle">${centerWidth.toFixed(1)}</text>
`;
}
}
}
// 사선 구간 추출 (중복 없이 유니크한 사선만)
let outlineAngles = '';
let slantDims = '';
const leftOutlinePts = miterPts.map(p => ({ x: p.inset, y: p.uy }));
// 연속된 사선을 하나로 병합하여 추출
const slantSegments = [];
let segStart = null;
let segEnd = null;
for (let i = 1; i < leftOutlinePts.length; i++) {
const prev = leftOutlinePts[i - 1];
const curr = leftOutlinePts[i];
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
// 사선 여부 (x, y 모두 변화)
const isSlant = Math.abs(dx) > 0.5 && Math.abs(dy) > 0.5;
if (isSlant) {
if (!segStart) {
segStart = { x: prev.x, y: prev.y };
}
segEnd = { x: curr.x, y: curr.y };
} else {
// 사선 구간 종료 → 저장
if (segStart && segEnd) {
slantSegments.push({ x1: segStart.x, y1: segStart.y, x2: segEnd.x, y2: segEnd.y });
}
segStart = null;
segEnd = null;
}
}
// 마지막 사선 처리
if (segStart && segEnd) {
slantSegments.push({ x1: segStart.x, y1: segStart.y, x2: segEnd.x, y2: segEnd.y });
}
// 사선 각도 계산: 실제 전개도와 동일한 방식 사용
// LR: angEntry = atan2(leftWing, items[notchIdx].unfoldedLength)
// FB: 상단 사선은 LR 프레임 절곡각도(126.9°), 중간/하단은 LR과 동일
const leftWingForAngle = cfg?.leftWing || 98.3;
const gs = sheetAiAppState.globalSettings;
// 각 사선 구간에 길이와 각도 표시 (1회씩만)
slantSegments.forEach((seg, idx) => {
const dx = seg.x2 - seg.x1;
const dy = seg.y2 - seg.y1;
const slantLen = Math.sqrt(dx * dx + dy * dy);
const midX = (seg.x1 + seg.x2) / 2;
const midY = (seg.y1 + seg.y2) / 2;
// 사선 각도: 탭별로 다른 계산 방식
let slantAngle = 0;
let isBendAngle = false; // 절곡 각도인지 여부 (126.9° 등)
if (tab === 'FB') {
// FB 탭: 상단 노치 사선 vs 하단 계단 사선 구분
const topNotchHeight = items[0]?.yEnd || 0;
if (idx === 0 && seg.y1 < topNotchHeight + 5) {
// 상단 첫 번째 사선 (ㄴ자 따임): LR 프레임 절곡 각도 (126.9°)
const slantVertical = gs.height - gs.firstStepHeight;
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2) || 60;
slantAngle = 180 - Math.atan2(slantVertical, slantHorizontal) * 180 / Math.PI;
isBendAngle = true;
} else {
// 상단 노치 이후 모든 사선: LR과 동일한 계단 구조
// 모든 사선은 leftWing / notchIdx 세그먼트 길이 기반 (44.9° ~ 45.0°)
const notchSegLen = items[notchIdx]?.unfoldedLength || 98.8;
slantAngle = Math.abs(Math.atan2(leftWingForAngle, notchSegLen) * 180 / Math.PI);
}
} else if (notchIdx > 0 && notchIdx < items.length) {
// LR 탭
if (idx === 0) {
const notchSegLen = items[notchIdx]?.unfoldedLength || dy;
slantAngle = Math.abs(Math.atan2(leftWingForAngle, notchSegLen) * 180 / Math.PI);
} else {
const relSegIdx = notchIdx + 1 + (idx - 1) * 2;
if (relSegIdx < items.length) {
const segLen = items[relSegIdx]?.unfoldedLength || dy;
const prevHorizIdx = relSegIdx - 1;
const horizLen = (prevHorizIdx >= notchIdx + 1) ? items[prevHorizIdx]?.unfoldedLength || Math.abs(dx) : Math.abs(dx);
slantAngle = Math.abs(Math.atan2(horizLen, segLen) * 180 / Math.PI);
} else {
slantAngle = Math.abs(Math.atan2(Math.abs(dx), Math.abs(dy)) * 180 / Math.PI);
}
}
} else {
slantAngle = Math.abs(Math.atan2(Math.abs(dx), Math.abs(dy)) * 180 / Math.PI);
}
// 좌측 사선 길이
slantDims += `
<text x="${midX - 10}" y="${midY}" fill="#fb923c" font-size="10" font-weight="bold" text-anchor="end">${slantLen.toFixed(1)}</text>
`;
// 우측 대칭
slantDims += `
<text x="${sheetWidth - midX + 10}" y="${midY}" fill="#fb923c" font-size="10" font-weight="bold" text-anchor="start">${slantLen.toFixed(1)}</text>
`;
// 사선 각도 표시
// 절곡 각도(126.9° 등)는 90° 이상이어도 표시, 일반 각도는 1~89° 범위만
const shouldShowAngle = isBendAngle ? (slantAngle > 90 && slantAngle < 180) : (slantAngle > 1 && slantAngle < 89);
if (shouldShowAngle) {
outlineAngles += `
<text x="${seg.x1 + 5}" y="${seg.y1 + 12}" fill="#f472b6" font-size="9" font-weight="bold">${slantAngle.toFixed(1)}°</text>
`;
// 우측 대칭
outlineAngles += `
<text x="${sheetWidth - seg.x1 - 5}" y="${seg.y1 + 12}" fill="#f472b6" font-size="9" font-weight="bold" text-anchor="end">${slantAngle.toFixed(1)}°</text>
`;
}
});
svgHtml = `<svg viewBox="-80 -80 ${sheetWidth + 180} ${totalUy + 180}" class="h-[80%] w-auto">
<!-- Background Boundary -->
<rect x="0" y="0" width="${sheetWidth}" height="${totalUy}" fill="#0f172a" stroke="#1e293b" stroke-width="1" />
<!-- Real Unfolded Path -->
<path d="${pathD}" fill="#fb718511" stroke="#fb7185" stroke-width="3" stroke-linejoin="round" />
<!-- Fold Lines (Bends) with segment numbers -->
${profilePts.slice(1,-1).map((p, idx) => `
<line x1="0" y1="${p.uy}" x2="${sheetWidth}" y2="${p.uy}" stroke="#334155" stroke-width="1.5" stroke-dasharray="8,4" />
`).join('')}
<!-- 치수선: 상단 전체 너비 -->
${widthDim}
<!-- 치수선: 상부 디테일 (노치) -->
${topDetailDims}
<!-- 치수선: 하부 디테일 (노치 및 중앙 너비) -->
${bottomDetailDims}
<!-- 치수선: 세그먼트별 길이 및 절곡 각도 -->
${segmentDims}
<!-- 치수선: 우측 전체 높이 -->
${heightDim}
<!-- 외곽선 꺾임 각도 -->
${outlineAngles}
<!-- 사선 구간 길이 -->
${slantDims}
<!-- 범례 -->
<g transform="translate(10, ${totalUy + 45})">
<circle cx="0" cy="0" r="4" fill="#4ade80" />
<text x="10" y="0" fill="#94a3b8" font-size="10" alignment-baseline="middle">일반 절곡</text>
<circle cx="70" cy="0" r="4" fill="#ef4444" />
<text x="80" y="0" fill="#94a3b8" font-size="10" alignment-baseline="middle">V-CUT</text>
</g>
</svg>`;
}
container.innerHTML = svgHtml;
}
window.updateMetalColor = (hex) => {
sheetAiAppState.metalColor = hex;
if ($('metalColorPicker')) $('metalColorPicker').value = hex;
if (frameGroup) {
frameGroup.children.forEach(child => {
if (child instanceof THREE.Mesh && !(child.material instanceof THREE.SpriteMaterial)) {
child.material.color.set(hex);
}
});
}
};
// --- PART INTERACTION & TRANSFORM LOGIC ---
let selectedObject = null;
let isMoveMode = false;
let activeAxis = 'x';
let originalPosition = new THREE.Vector3();
window.hideActionMenu = () => {
$('partActionMenu').classList.add('hidden');
};
window.startMoveMode = (obj) => {
hideActionMenu();
selectedObject = obj;
isMoveMode = true;
activeAxis = 'x';
originalPosition.copy(obj.position);
$('moveTargetName').innerText = obj.userData.name;
$('moveStatusOverlay').classList.remove('hidden');
updateAxisIndicators();
// Add visual highlight
if (obj.material.emissive) {
obj.material.emissive.setHex(0x052e16); // Subtle emerald glow
}
};
window.toggleIsolation = (obj) => {
if (!frameGroup) return;
const isCurrentlyIsolated = sheetAiAppState.isIsolated;
frameGroup.children.forEach(child => {
// If isolatig: only show the selected object and its label
// Labels are SpriteMaterials (or just check userData)
const isTarget = (child === obj);
// Also show the corresponding text sprite for the object
let isLabel = false;
if (child instanceof THREE.Sprite) {
// Check if label is near the object position or has similar naming
// Labels added in updateThreeScene don't have userData link yet,
// let's assume we want to hide/show labels too.
isLabel = true;
}
if (!isCurrentlyIsolated) {
// Start isolation
if (child instanceof THREE.Mesh) {
child.visible = isTarget;
} else if (child instanceof THREE.Sprite) {
// For labels, we'd need a better link. For now, hide all labels except maybe target's?
// Actually, let's hide all labels when isolated to keep it clean, or show all.
child.visible = false;
}
} else {
// End isolation
child.visible = true;
}
});
sheetAiAppState.isIsolated = !isCurrentlyIsolated;
hideActionMenu();
};
window.showAllParts = () => {
if (!frameGroup) return;
frameGroup.children.forEach(child => child.visible = true);
sheetAiAppState.isIsolated = false;
hideActionMenu();
};
function updateAxisIndicators() {
$('axisIndicatorX').className = (activeAxis === 'x') ? "px-4 py-1.5 rounded-xl bg-white text-emerald-950 font-black" : "px-4 py-1.5 rounded-xl bg-white/20 border border-white/10 text-white/50";
$('axisIndicatorY').className = (activeAxis === 'y') ? "px-4 py-1.5 rounded-xl bg-white text-emerald-950 font-black" : "px-4 py-1.5 rounded-xl bg-white/20 border border-white/10 text-white/50";
$('axisIndicatorZ').className = (activeAxis === 'z') ? "px-4 py-1.5 rounded-xl bg-white text-emerald-950 font-black" : "px-4 py-1.5 rounded-xl bg-white/20 border border-white/10 text-white/50";
}
function endMoveMode(confirm) {
if (!isMoveMode || !selectedObject) return;
if (!confirm) {
selectedObject.position.copy(originalPosition);
}
if (selectedObject.material.emissive) {
selectedObject.material.emissive.setHex(0x000000);
}
isMoveMode = false;
selectedObject = null;
$('moveStatusOverlay').classList.add('hidden');
}
// Global Keyboard Listeners
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
// M 키: 측정 모드 토글 (3D 탭에서만)
if (key === 'm' && sheetAiAppState.activeTab === '3D') {
toggleMeasureMode();
return;
}
// ESC 키: 측정 모드 해제
if (key === 'escape') {
if (typeof isMeasureMode !== 'undefined' && isMeasureMode && sheetAiAppState.activeTab === '3D') {
toggleMeasureMode();
return;
}
if (isMoveMode && selectedObject) {
endMoveMode(false);
return;
}
}
// 이동 모드 키 처리
if (!isMoveMode || !selectedObject) return;
const step = e.shiftKey ? 10 : 1;
if (key === 'x') {
activeAxis = 'x';
updateAxisIndicators();
} else if (key === 'y') {
activeAxis = 'y';
updateAxisIndicators();
} else if (key === 'z') {
activeAxis = 'z';
updateAxisIndicators();
} else if (key === 'enter') {
endMoveMode(true);
} else if (e.key === 'ArrowUp') {
selectedObject.position[activeAxis] += step;
} else if (e.key === 'ArrowDown') {
selectedObject.position[activeAxis] -= step;
} else if (e.key === 'ArrowLeft') {
if (activeAxis === 'y') selectedObject.position.y -= step;
else if (activeAxis === 'x') selectedObject.position.x -= step;
else selectedObject.position.z -= step;
} else if (e.key === 'ArrowRight') {
if (activeAxis === 'y') selectedObject.position.y += step;
else if (activeAxis === 'x') selectedObject.position.x += step;
else selectedObject.position.z += step;
}
});
// --- 3D RENDERING ENGINE ---
let scene, camera, renderer, controls, frameGroup, gridHelper, axisSprites = [];
window.setView = (viewName) => {
if (!camera || !controls) return;
const dist = 3000;
switch(viewName) {
case 'top':
camera.position.set(0.01, dist, 0); // slight offset to avoid gimbal lock
break;
case 'bottom':
camera.position.set(0.01, -dist, 0); // Looking up from bottom
break;
case 'back':
camera.position.set(0, 0, -dist);
break;
case 'front':
camera.position.set(0, 0, dist);
break;
case 'right':
camera.position.set(dist, 0, 0);
break;
case 'left':
camera.position.set(-dist, 0, 0);
break;
}
controls.target.set(0, 0, 0);
controls.update();
};
function createTextSprite(text, color = '#60a5fa') {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0,0,0,0)';
ctx.fillRect(0,0,512,128);
ctx.font = 'bold 50px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = color;
ctx.strokeStyle = '#000000';
ctx.lineWidth = 5;
ctx.strokeText(text, 256, 64);
ctx.fillText(text, 256, 64);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(500, 125, 1);
return sprite;
}
// WebGL 지원 체크 함수
function isWebGLAvailable() {
try {
const canvas = document.createElement('canvas');
return !!(window.WebGLRenderingContext &&
(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')));
} catch (e) {
return false;
}
}
// WebGL 실패 시 대체 UI 표시
let webglFailed = false;
function showWebGLFallback() {
webglFailed = true;
const container = $('threeDContainer');
const fallback = $('webglFallback');
if (container) container.classList.add('hidden');
if (fallback) {
fallback.classList.remove('hidden');
updateWebGLFallbackContent();
}
}
// 대체 UI 콘텐츠 업데이트
function updateWebGLFallbackContent() {
const content = $('webglFallbackContent');
if (!content) return;
const lrConfig = sheetAiAppState.tabs?.LR?.config || { sheetWidth: 0, thickness: 0 };
const fbConfig = sheetAiAppState.tabs?.FB?.config || { sheetWidth: 0, thickness: 0 };
const lrSegs = sheetAiAppState.tabs?.LR?.segments || [];
const fbSegs = sheetAiAppState.tabs?.FB?.segments || [];
const formatSegments = (segments, label) => {
if (!segments || segments.length === 0) return '<p class="text-slate-500 text-sm">절곡 정보 없음</p>';
return segments.map((seg, i) => `
<div class="flex items-center gap-4 py-2 border-b border-slate-800 last:border-0">
<span class="w-16 text-slate-500 text-sm">구간 ${i + 1}</span>
<span class="text-white font-bold">${seg.length.toFixed(1)} mm</span>
<span class="px-2 py-0.5 rounded text-xs font-bold ${seg.direction === 'ccw' ? 'bg-red-500/20 text-red-400' : 'bg-blue-500/20 text-blue-400'}">
${seg.direction === 'ccw' ? '↺ CCW' : '↻ CW'} ${seg.angle}°
</span>
${seg.isVcut ? '<span class="px-2 py-0.5 rounded text-xs font-bold bg-pink-500/20 text-pink-400">V-CUT</span>' : ''}
</div>
`).join('');
};
const calcTotalLength = (segments) => {
if (!segments || segments.length === 0) return 0;
return segments.reduce((sum, s) => sum + s.length, 0);
};
content.innerHTML = `
<!-- LR 프레임 (좌우) -->
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700">
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-bold text-blue-400 flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-blue-500"></span>
LR 프레임 (좌우)
</h4>
<span class="text-slate-400 text-sm">× 2개</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">판재 폭</p>
<p class="text-white font-bold text-lg">${lrConfig.sheetWidth} <span class="text-sm text-slate-400">mm</span></p>
</div>
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">판재 두께</p>
<p class="text-white font-bold text-lg">${lrConfig.thickness} <span class="text-sm text-slate-400">mm</span></p>
</div>
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">절곡 수</p>
<p class="text-white font-bold text-lg">${lrSegs.length} <span class="text-sm text-slate-400">회</span></p>
</div>
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">전개 길이</p>
<p class="text-white font-bold text-lg">${calcTotalLength(lrSegs).toFixed(1)} <span class="text-sm text-slate-400">mm</span></p>
</div>
</div>
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-slate-400 text-xs mb-2 font-bold">절곡 상세</p>
${formatSegments(lrSegs, 'LR')}
</div>
</div>
<!-- FB 프레임 (앞뒤) -->
<div class="bg-slate-800/50 rounded-xl p-5 border border-slate-700">
<div class="flex items-center justify-between mb-4">
<h4 class="text-lg font-bold text-green-400 flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-green-500"></span>
FB 프레임 (앞뒤)
</h4>
<span class="text-slate-400 text-sm">× 2개</span>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">판재 폭</p>
<p class="text-white font-bold text-lg">${fbConfig.sheetWidth} <span class="text-sm text-slate-400">mm</span></p>
</div>
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">판재 두께</p>
<p class="text-white font-bold text-lg">${fbConfig.thickness} <span class="text-sm text-slate-400">mm</span></p>
</div>
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">절곡 수</p>
<p class="text-white font-bold text-lg">${fbSegs.length} <span class="text-sm text-slate-400">회</span></p>
</div>
<div class="bg-slate-900/50 rounded-lg p-3">
<p class="text-slate-500 text-xs mb-1">전개 길이</p>
<p class="text-white font-bold text-lg">${calcTotalLength(fbSegs).toFixed(1)} <span class="text-sm text-slate-400">mm</span></p>
</div>
</div>
<div class="bg-slate-900/30 rounded-lg p-3">
<p class="text-slate-400 text-xs mb-2 font-bold">절곡 상세</p>
${formatSegments(fbSegs, 'FB')}
</div>
</div>
<!-- 조립 요약 -->
<div class="bg-gradient-to-r from-blue-500/10 to-green-500/10 rounded-xl p-5 border border-slate-700">
<h4 class="text-lg font-bold text-white mb-3">📦 조립 요약</h4>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p class="text-slate-400 text-sm">총 프레임</p>
<p class="text-white font-bold text-xl">4개</p>
</div>
<div>
<p class="text-slate-400 text-sm">프레임 크기 (LxW)</p>
<p class="text-white font-bold text-xl">${lrConfig.sheetWidth} × ${fbConfig.sheetWidth} <span class="text-sm">mm</span></p>
</div>
<div>
<p class="text-slate-400 text-sm">총 절곡 횟수</p>
<p class="text-white font-bold text-xl">${(lrSegs.length * 2) + (fbSegs.length * 2)}회</p>
</div>
</div>
</div>
<!-- 재시도 버튼 -->
<div class="text-center pt-4">
<button onclick="retryWebGL()" class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl transition-colors inline-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"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
3D 렌더링 재시도
</button>
</div>
`;
}
// 3D 렌더링 재시도
function retryWebGL() {
webglFailed = false;
renderer = null;
const fallback = $('webglFallback');
const container = $('threeDContainer');
if (fallback) fallback.classList.add('hidden');
if (container) container.classList.remove('hidden');
initThreeJS();
}
function initThreeJS() {
// WebGL 실패 상태면 대체 UI 업데이트만
if (webglFailed) {
updateWebGLFallbackContent();
return;
}
if (renderer) {
updateThreeScene();
resetCameraToFitObject(); // 탭 전환시 카메라를 객체 중심으로 리셋
return;
}
// WebGL 지원 체크
if (!isWebGLAvailable()) {
console.warn('WebGL not available');
showWebGLFallback();
return;
}
const container = $('threeDContainer');
try {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xffffff); // 기본 배경색: 흰색
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 1, 20000);
// 초기 카메라 위치 (updateThreeScene에서 객체 크기에 맞게 재조정됨)
camera.position.set(1500, 1200, 1500);
renderer = new THREE.WebGLRenderer({ antialias: true });
// WebGL context 생성 실패 체크
if (!renderer.getContext()) {
throw new Error('WebGL context creation failed');
}
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
} catch (e) {
console.error('WebGL initialization failed:', e);
renderer = null;
showWebGLFallback();
return;
}
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// User requested middle mouse for rotation
controls.mouseButtons = {
LEFT: THREE.MOUSE.PAN,
MIDDLE: THREE.MOUSE.ROTATE,
RIGHT: THREE.MOUSE.ROTATE
};
// 초기 타겟을 원점으로 설정
controls.target.set(0, 0, 0);
controls.update();
// 조명 객체들을 전역으로 관리
window.lights = {
ambient: new THREE.AmbientLight(0xffffff, 0.3),
directional1: new THREE.DirectionalLight(0xffffff, 0.8),
directional2: new THREE.DirectionalLight(0xffffff, 0.4),
point: new THREE.PointLight(0xffffff, 0.6),
hemisphere: new THREE.HemisphereLight(0x87ceeb, 0x8b4513, 0.5),
spot: new THREE.SpotLight(0xffffff, 1.0)
};
// 기본 조명 위치 설정
window.lights.directional1.position.set(2000, 3000, 1000);
window.lights.directional2.position.set(-2000, 2000, -1000);
window.lights.point.position.set(0, 1000, 0);
window.lights.spot.position.set(0, 2000, 0);
window.lights.spot.angle = Math.PI / 6;
window.lights.spot.penumbra = 0.5;
// 기본 조명만 활성화
scene.add(window.lights.ambient);
scene.add(window.lights.directional1);
scene.add(window.lights.directional2);
scene.add(window.lights.point);
// 반구광, 스포트라이트는 비활성화 상태로 시작
window.lights.hemisphere.visible = false;
window.lights.spot.visible = false;
scene.add(window.lights.hemisphere);
scene.add(window.lights.spot);
gridHelper = new THREE.GridHelper(5000, 50, 0x334155, 0x1e293b);
gridHelper.position.y = -500;
gridHelper.visible = sheetAiAppState.showGrid;
scene.add(gridHelper);
// Axis Indicators
axisSprites = [];
const posX = createTextSprite('+X Direction', '#f87171');
posX.position.set(2600, -450, 0);
posX.visible = sheetAiAppState.showGrid;
scene.add(posX);
axisSprites.push(posX);
const negX = createTextSprite('-X Direction', '#f87171');
negX.position.set(-2600, -450, 0);
negX.visible = sheetAiAppState.showGrid;
scene.add(negX);
axisSprites.push(negX);
const posZ = createTextSprite('+Z Direction', '#34d399');
posZ.position.set(0, -450, 2600);
posZ.visible = sheetAiAppState.showGrid;
scene.add(posZ);
axisSprites.push(posZ);
const negZ = createTextSprite('-Z Direction', '#34d399');
negZ.position.set(0, -450, -2600);
negZ.visible = sheetAiAppState.showGrid;
scene.add(negZ);
axisSprites.push(negZ);
frameGroup = new THREE.Group();
scene.add(frameGroup);
window.addEventListener('resize', () => {
if (!renderer) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
});
updateThreeScene();
animate();
}
// 카메라를 객체가 잘 보이는 위치로 리셋
function resetCameraToFitObject() {
if (!frameGroup || !camera || !controls) return;
const box = new THREE.Box3().setFromObject(frameGroup);
// 빈 bounding box인지 확인
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim === 0) return; // 객체가 없으면 리턴
// 적절한 카메라 거리 계산 (더 여유있게 2.5배)
const fov = camera.fov * (Math.PI / 180);
const cameraDistance = (maxDim / 2) / Math.tan(fov / 2) * 2.5;
// 카메라를 대각선 위에서 바라보도록 위치 설정 (객체 중심 기준)
const angle = Math.PI / 4;
camera.position.set(
center.x + cameraDistance * Math.sin(angle),
center.y + cameraDistance * 0.5,
center.z + cameraDistance * Math.cos(angle)
);
// 컨트롤 타겟을 객체의 실제 중심으로 설정
controls.target.copy(center);
camera.lookAt(center);
controls.update();
}
window.toggleWireframe = (visible) => {
sheetAiAppState.showWireframe = visible;
updateThreeScene();
};
window.toggleGrid = (visible) => {
sheetAiAppState.showGrid = visible;
if (gridHelper) gridHelper.visible = visible;
if (axisSprites) {
axisSprites.forEach(s => s.visible = visible);
}
};
// --- MEASURE MODE ---
let isMeasureMode = false;
let edgeLines = []; // 모든 엣지 라인 세그먼트 저장
let highlightLine = null; // 현재 하이라이트된 엣지
let measureLabels = []; // 측정 라벨들
let nearestEdge = null; // 가장 가까운 엣지 정보
const measureRaycaster = new THREE.Raycaster();
measureRaycaster.params.Line.threshold = 5;
// 측정 모드에서 OrbitControls 이벤트 차단용 오버레이
let measureOverlay = null;
function createMeasureOverlay() {
if (measureOverlay) return measureOverlay;
measureOverlay = document.createElement('div');
measureOverlay.id = 'measureOverlay';
measureOverlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
z-index: 10;
background: transparent;
`;
return measureOverlay;
}
window.toggleMeasureMode = () => {
const btn = $('measureToggleBtn');
const container = $('threeDContainer');
// 3D 탭이 아니거나 renderer가 없으면 무시
if (!container || !btn || !renderer || sheetAiAppState.activeTab !== '3D') {
console.warn('측정 모드는 3D 탭에서만 사용 가능합니다.');
return;
}
isMeasureMode = !isMeasureMode;
if (isMeasureMode) {
btn.classList.add('bg-amber-500/30', 'border-amber-500');
btn.classList.remove('bg-slate-900/80', 'border-slate-700');
extractAllEdges();
// 측정용 오버레이 추가 (OrbitControls 이벤트 차단)
const overlay = createMeasureOverlay();
container.style.position = 'relative';
container.appendChild(overlay);
// 오버레이에 측정 이벤트 연결
overlay.addEventListener('mousemove', handleMeasureMouseMove);
overlay.addEventListener('click', handleMeasureClick);
} else {
btn.classList.remove('bg-amber-500/30', 'border-amber-500');
btn.classList.add('bg-slate-900/80', 'border-slate-700');
clearMeasurements();
// 오버레이 제거
if (measureOverlay && measureOverlay.parentElement) {
measureOverlay.removeEventListener('mousemove', handleMeasureMouseMove);
measureOverlay.removeEventListener('click', handleMeasureClick);
measureOverlay.parentElement.removeChild(measureOverlay);
}
}
};
function handleMeasureMouseMove(e) {
if (!isMeasureMode) return;
nearestEdge = findNearestEdge(e);
highlightEdge(nearestEdge);
}
function handleMeasureClick(e) {
if (!isMeasureMode || !nearestEdge) return;
addMeasureLabel(nearestEdge);
highlightEdge(null);
}
function extractAllEdges() {
edgeLines = [];
if (!frameGroup) return;
frameGroup.children.forEach(mesh => {
if (!mesh.geometry) return;
// EdgesGeometry로 엣지 추출
const edges = new THREE.EdgesGeometry(mesh.geometry, 15);
const positions = edges.attributes.position.array;
// 월드 좌표로 변환하여 엣지 라인 세그먼트 저장
for (let i = 0; i < positions.length; i += 6) {
const start = new THREE.Vector3(positions[i], positions[i+1], positions[i+2]);
const end = new THREE.Vector3(positions[i+3], positions[i+4], positions[i+5]);
// 로컬 -> 월드 좌표 변환
start.applyMatrix4(mesh.matrixWorld);
end.applyMatrix4(mesh.matrixWorld);
const length = start.distanceTo(end);
if (length > 0.5) { // 너무 짧은 엣지 제외
edgeLines.push({
start: start.clone(),
end: end.clone(),
length: length,
center: new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5)
});
}
}
});
}
function findNearestEdge(mouse) {
if (!isMeasureMode || edgeLines.length === 0) return null;
// 마우스 좌표를 정규화
const rect = renderer.domElement.getBoundingClientRect();
const x = ((mouse.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((mouse.clientY - rect.top) / rect.height) * 2 + 1;
measureRaycaster.setFromCamera(new THREE.Vector2(x, y), camera);
const ray = measureRaycaster.ray;
let minDist = Infinity;
let closest = null;
edgeLines.forEach(edge => {
// 레이와 엣지 라인 사이의 최소 거리 계산
const lineDir = new THREE.Vector3().subVectors(edge.end, edge.start).normalize();
const lineLen = edge.start.distanceTo(edge.end);
// 레이에서 엣지 중심까지의 대략적 거리
const toCenter = new THREE.Vector3().subVectors(edge.center, ray.origin);
const projLen = toCenter.dot(ray.direction);
const closestOnRay = new THREE.Vector3().copy(ray.direction).multiplyScalar(projLen).add(ray.origin);
// 엣지 라인 위의 가장 가까운 점 찾기
const edgeToPoint = new THREE.Vector3().subVectors(closestOnRay, edge.start);
let t = edgeToPoint.dot(lineDir) / lineLen;
t = Math.max(0, Math.min(1, t));
const closestOnEdge = new THREE.Vector3().copy(lineDir).multiplyScalar(t * lineLen).add(edge.start);
const dist = closestOnRay.distanceTo(closestOnEdge);
// 화면상 거리로 변환 (원근 고려)
const screenDist = dist / (projLen > 0 ? projLen / 1000 : 1);
if (screenDist < minDist && projLen > 0) {
minDist = screenDist;
closest = edge;
}
});
// 일정 거리 이내일 때만 반환
return minDist < 50 ? closest : null;
}
function highlightEdge(edge) {
// 기존 하이라이트 제거
if (highlightLine) {
scene.remove(highlightLine);
highlightLine = null;
}
if (!edge) return;
// 새 하이라이트 라인 생성
const geometry = new THREE.BufferGeometry().setFromPoints([edge.start, edge.end]);
const material = new THREE.LineBasicMaterial({
color: 0xfbbf24,
linewidth: 3,
depthTest: false
});
highlightLine = new THREE.Line(geometry, material);
highlightLine.renderOrder = 999;
scene.add(highlightLine);
}
function addMeasureLabel(edge) {
// CSS2D 라벨 대신 HTML 오버레이 사용
const labelDiv = document.createElement('div');
labelDiv.className = 'measure-label';
labelDiv.innerHTML = `
<div class="bg-amber-500 text-black px-3 py-1.5 rounded-lg shadow-xl font-black text-sm flex items-center gap-2" style="pointer-events: auto;">
<span>${edge.length.toFixed(1)}</span>
<span class="text-amber-900 text-xs">mm</span>
<button onclick="this.parentElement.parentElement.remove()" class="ml-1 text-amber-900 hover:text-black text-lg leading-none">&times;</button>
</div>
`;
labelDiv.style.cssText = 'position: absolute; transform: translate(-50%, -50%); z-index: 1000; pointer-events: none;';
// 3D 좌표를 2D 스크린 좌표로 변환
const updatePosition = () => {
const screenPos = edge.center.clone().project(camera);
const rect = renderer.domElement.getBoundingClientRect();
labelDiv.style.left = ((screenPos.x + 1) / 2 * rect.width + rect.left) + 'px';
labelDiv.style.top = ((-screenPos.y + 1) / 2 * rect.height + rect.top) + 'px';
// 화면 밖이면 숨김
if (screenPos.z > 1) labelDiv.style.display = 'none';
else labelDiv.style.display = 'block';
};
updatePosition();
document.body.appendChild(labelDiv);
// 애니메이션 루프에서 위치 업데이트
const labelData = { element: labelDiv, edge: edge, update: updatePosition };
measureLabels.push(labelData);
// 엣지 표시용 영구 라인 추가
const geometry = new THREE.BufferGeometry().setFromPoints([edge.start, edge.end]);
const material = new THREE.LineBasicMaterial({ color: 0xfbbf24, linewidth: 2 });
const line = new THREE.Line(geometry, material);
line.renderOrder = 998;
scene.add(line);
labelData.line = line;
}
function clearMeasurements() {
// 라벨 제거
measureLabels.forEach(label => {
if (label.element) label.element.remove();
if (label.line) scene.remove(label.line);
});
measureLabels = [];
// 하이라이트 제거
if (highlightLine) {
scene.remove(highlightLine);
highlightLine = null;
}
nearestEdge = null;
}
// 측정 라벨 위치 업데이트 (애니메이션 루프에서 호출)
function updateMeasureLabels() {
measureLabels.forEach(label => {
if (label.update) label.update();
});
}
function animate() {
requestAnimationFrame(animate);
if (controls) controls.update();
if (renderer) renderer.render(scene, camera);
updateMeasureLabels(); // 측정 라벨 위치 업데이트
}
function getInsetAtY(tab, uy) {
const data = getUnfoldedData(tab);
if (!data || !data.items || data.items.length === 0) return 0;
const items = data.items;
const cfg = sheetAiAppState.tabs[tab]?.config;
if (!cfg) return 0;
// 1. Top Notch (Front/Back Frame Only) - 'ㄴ'자 따임 (동적 계산)
// 치수 계산 공식:
// - topWingLength: 상부 첫 번째 세그먼트 원본 길이 (기본 30mm)
// - topNotchDepth: topWingLength + 공차(0.6mm) = LR 프레임 삽입용 따임 깊이
// - topNotchHeight: items[0]?.yEnd = 연신율 적용 후 높이 (V-CUT 없으면 2T 차감)
// - slantHorizontal: LR 프레임의 사선 수평 이동 거리 (globalSettings 연동)
// - totalTopInset: slantHorizontal + topNotchDepth = 상단 전체 inset
// - slantStartInset: totalTopInset - topNotchDepth = 사선 시작점 inset (= slantHorizontal)
if (tab === 'FB') {
const gs = sheetAiAppState.globalSettings;
const fbSpecificSegs = sheetAiAppState.tabs.FB?.specificSegments || [];
const topWingLength = fbSpecificSegs[0]?.length || 30; // 상부 날개 원본 길이
const tolerance = 0.6; // 결합 공차
const topNotchDepth = topWingLength + tolerance; // 따임 깊이 (30.6)
const topNotchHeight = items[0]?.yEnd || 0; // 연신율 적용 후 높이 (28.8)
// LR 프레임 사선 수평 이동 거리 (globalSettings에서 가져옴)
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2) || 60;
const totalTopInset = slantHorizontal + topNotchDepth; // 상단 전체 inset (90)
const slantStartInset = slantHorizontal; // 사선 시작 inset (60) = slantHorizontal
// FB 사선 구간 높이 = LR 프레임의 (전체높이 - 1단높이)
const fbSlantHeight = gs.height - gs.firstStepHeight; // 150 - 70 = 80
// 사선 끝점 y 좌표 (연신율 적용 전 값에서 계산)
const ySlant = topNotchHeight + fbSlantHeight; // 28.8 + 80 = 108.8
if (uy <= ySlant + 0.1) {
if (uy < topNotchHeight) return totalTopInset; // 'ㄴ'자 따임 영역
if (uy <= topNotchHeight + 0.02) return slantStartInset; // 사선 시작점
const ratio = (uy - topNotchHeight) / (ySlant - topNotchHeight);
return slantStartInset * (1 - ratio); // 사선으로 감소
}
}
// 2. Bottom Flare (DXF와 동일한 계단식 로직)
const notchIdx = Number(cfg.wingStartIndex) || 0;
const notchY = (notchIdx > 0 && items[notchIdx - 1]) ? items[notchIdx - 1].yEnd : 0;
const bodyY = (notchIdx > 0 && items[notchIdx - 1]) ? items[notchIdx - 1].yEnd : 0;
// notchY 이전 영역은 inset 0
if (uy <= notchY + 0.01) return 0;
// LR 탭: DXF와 동일한 계단식 노치 계산
// leftWing에서 시작하여 각 세그먼트의 unfoldedLength만큼 계단식으로 증가
const leftWing = cfg.leftWing || 98.3;
// notchIdx 이후 세그먼트들의 Y 구간과 inset 계산
// 첫 번째 사선: bodyY → notchY (= items[notchIdx].yEnd)
// 이후: 홀수 relIdx = 수직(inset 유지), 짝수 relIdx = 수평(inset 증가)
let curInset = leftWing; // 사선 끝점의 inset
// notchIdx 세그먼트 (첫 번째 사선 - 원래대로 선형 보간)
const notchItem = items[notchIdx];
// 마지막 세그먼트의 yStart (노치 시작점)
const lastSegmentYStart = items[items.length - 1]?.yStart || (notchItem?.yEnd || 0);
if (notchItem) {
const slopeStartY = notchItem.yStart; // bodyY
const slopeEndY = notchItem.yEnd; // notchY
if (uy <= slopeEndY + 0.01) {
// 첫 번째 사선: 0에서 leftWing까지 선형 보간 (원래대로)
const ratio = (uy - slopeStartY) / (slopeEndY - slopeStartY || 1);
return leftWing * ratio;
}
}
// 마지막 사선 세그먼트 찾기 (짝수 relIdx 중 마지막)
let lastDiagIdx = -1;
for (let i = notchIdx + 1; i < items.length - 1; i++) {
const relIdx = i - notchIdx;
if (relIdx % 2 === 0) lastDiagIdx = i;
}
// notchIdx 이후 세그먼트들 (계단식, 마지막 사선은 바깥쪽으로)
const lastItemIdx = items.length - 1;
for (let i = notchIdx + 1; i < items.length; i++) {
const item = items[i];
const relIdx = i - notchIdx;
const segmentH = item.unfoldedLength;
const isLastItem = (i === lastItemIdx);
const isLastDiag = (i === lastDiagIdx);
if (uy <= item.yEnd + 0.01) {
if (isLastItem && relIdx % 2 === 1) {
// 마지막 세그먼트 (수직+수평 노치 형태)
// 마지막 날개값 18.2mm 추가 (치수선 13.8 일치)
const lastWingExtra = 18.2;
if (uy <= lastSegmentYStart + 0.01) {
return curInset; // 수직 구간
} else {
return curInset + segmentH + lastWingExtra; // 수평 노치 후 수직 구간 (안쪽으로 + 15mm 확장)
}
} else if (relIdx % 2 === 1) {
// 홀수: 수직 구간 (inset 유지)
return curInset;
} else if (isLastDiag) {
// 마지막 사선: 바깥쪽으로 (inset 감소)
const ratio = (uy - item.yStart) / (item.yEnd - item.yStart || 1);
return curInset - segmentH * ratio;
} else {
// 짝수: 사선 (inset이 segmentH만큼 증가 - 안쪽으로)
const ratio = (uy - item.yStart) / (item.yEnd - item.yStart || 1);
return curInset + segmentH * ratio;
}
}
// 다음 세그먼트를 위해 inset 업데이트
if (relIdx % 2 === 0) {
if (isLastDiag) {
curInset -= segmentH; // 마지막 사선: 바깥쪽으로
} else {
curInset += segmentH; // 일반 사선: 안쪽으로
}
} else if (isLastItem) {
curInset += segmentH; // 마지막 홀수 세그먼트도 inset 증가
}
}
return curInset;
}
function createSheetMetalPiece(tab, material) {
const data = getUnfoldedData(tab);
const items = data.items;
// items가 비어있으면 빈 그룹 반환
if (!items || items.length === 0) {
return new THREE.Group();
}
const segments = getSegmentsForTab(tab);
const sheetWidth = sheetAiAppState.tabs[tab]?.config?.sheetWidth || 830;
const thickness = sheetAiAppState.config?.thickness || sheetAiAppState.globalSettings?.thickness || 1.2;
let profilePoints = [{ x: 0, y: 0, uy: 0, vcut: false }];
let cx = 0, cy = 0, cda = 0; // Changed from 180 to 0 to grow "inward"
items.forEach((it, idx) => {
const rad = cda * Math.PI / 180;
cx += it.unfoldedLength * Math.cos(rad);
cy += it.unfoldedLength * Math.sin(rad);
// V-CUT 정보 저장 (다음 세그먼트의 isVcut 여부)
const nextSeg = segments[idx + 1];
const hasVcut = nextSeg ? nextSeg.isVcut : false;
profilePoints.push({ x: cx, y: cy, uy: it.yEnd, vcut: hasVcut });
cda += (180 - (it.angle || 90)) * ((it.direction === 'CW') ? 1 : -1);
});
// V-CUT 없는 절곡점에 곡선 보간 추가 (부드러운 R=2T 효과)
// V-CUT 있으면 날카로운 각, 없으면 둥근 곡선
const bendRadius = thickness * 2; // R = 2T
const bendSteps = 4; // 곡선 보간 정점 수
let newProfilePoints = [profilePoints[0]];
for (let i = 1; i < profilePoints.length; i++) {
const prev = profilePoints[i - 1];
const curr = profilePoints[i];
const next = profilePoints[i + 1];
// 마지막 정점이거나 V-CUT 있으면 그대로 추가 (날카로운 각)
if (!next || curr.vcut) {
newProfilePoints.push(curr);
continue;
}
// V-CUT 없는 절곡점: 곡선 보간으로 둥글게
// 이전/다음 방향 벡터 계산
const v1 = { x: curr.x - prev.x, y: curr.y - prev.y };
const v2 = { x: next.x - curr.x, y: next.y - curr.y };
const len1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
const len2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
if (len1 < 0.1 || len2 < 0.1) {
newProfilePoints.push(curr);
continue;
}
// 단위 벡터
const u1 = { x: v1.x / len1, y: v1.y / len1 };
const u2 = { x: v2.x / len2, y: v2.y / len2 };
// 절곡 각도 계산
const dot = u1.x * u2.x + u1.y * u2.y;
const bendAngle = Math.acos(Math.max(-1, Math.min(1, dot)));
// 각도가 거의 180도(직선)면 보간 불필요
if (bendAngle < 0.1) {
newProfilePoints.push(curr);
continue;
}
// 곡선 시작/끝점 오프셋 (R에 비례)
const offset = Math.min(bendRadius, len1 * 0.3, len2 * 0.3);
// 곡선 시작점
const startPt = {
x: curr.x - u1.x * offset,
y: curr.y - u1.y * offset,
uy: curr.uy - (curr.uy - prev.uy) * (offset / len1),
vcut: false
};
// 곡선 끝점
const endPt = {
x: curr.x + u2.x * offset,
y: curr.y + u2.y * offset,
uy: curr.uy + (next.uy - curr.uy) * (offset / len2),
vcut: curr.vcut
};
newProfilePoints.push(startPt);
// 베지어 곡선 보간 (quadratic)
for (let s = 1; s < bendSteps; s++) {
const t = s / bendSteps;
const mt = 1 - t;
// Quadratic Bezier: P = (1-t)²·P0 + 2(1-t)t·P1 + t²·P2
const bx = mt * mt * startPt.x + 2 * mt * t * curr.x + t * t * endPt.x;
const by = mt * mt * startPt.y + 2 * mt * t * curr.y + t * t * endPt.y;
const buy = mt * mt * startPt.uy + 2 * mt * t * curr.uy + t * t * endPt.uy;
newProfilePoints.push({ x: bx, y: by, uy: buy, vcut: false });
}
newProfilePoints.push(endPt);
}
profilePoints = newProfilePoints;
// FB 전용: 상단 'ㄴ'자 따임 구간에 정점 추가 (globalSettings 연동)
if (tab === 'FB' && items[0]) {
console.log('FB 실행');
const gs = sheetAiAppState.globalSettings;
const y1 = items[0]?.yEnd || 0; // 28.8
const fbSlantHeight = (gs.height || 150) - (gs.firstStepHeight || 70); // 전체높이 - 1단높이 = 80
const ySlant = y1 + fbSlantHeight; // 사선 끝점 (동적)
// 사선 끝점만 추가 - 'ㄴ'자는 별도 처리
const extraYs = [ySlant];
extraYs.forEach(ey => {
if (!profilePoints.some(p => Math.abs(p.uy - ey) < 0.05)) {
for (let i = 0; i < profilePoints.length - 1; i++) {
if (ey > profilePoints[i].uy && ey < profilePoints[i+1].uy) {
const ratio = (ey - profilePoints[i].uy) / (profilePoints[i+1].uy - profilePoints[i].uy);
const newP = {
x: profilePoints[i].x + (profilePoints[i+1].x - profilePoints[i].x) * ratio,
y: profilePoints[i].y + (profilePoints[i+1].y - profilePoints[i].y) * ratio,
uy: ey
};
profilePoints.splice(i+1, 0, newP);
break;
}
}
}
});
profilePoints.sort((a,b) => a.uy - b.uy);
}
const vertices = [];
const indices = [];
const addQuad = (a, b, c, d) => indices.push(a, b, d, b, c, d);
// FB 상단 'ㄴ'자 따임 정보 (동적 계산 - globalSettings 연동)
// - topWingLength: 상부 첫 번째 세그먼트 원본 길이
// - topNotchDepth: topWingLength + 공차(0.6mm)
// - topNotchHeight: items[0]?.yEnd (연신율 적용 후)
// - slantHorizontal: LR 프레임의 사선 수평 이동 거리 = 60 (기본)
// - totalTopInset: slantHorizontal + topNotchDepth
// - slantStartInset: slantHorizontal
const gs = sheetAiAppState.globalSettings;
const fbSpecificSegs2 = tab === 'FB' ? (sheetAiAppState.tabs.FB?.specificSegments || []) : [];
const topWingLength = fbSpecificSegs2[0]?.length || 30;
const tolerance = 0.6;
const topNotchDepth = topWingLength + tolerance; // 따임 깊이 (30.6)
const topNotchHeight = (tab === 'FB' && items[0]) ? (items[0]?.yEnd || 0) : 0; // 따임 높이 (28.8)
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2) || 60; // globalSettings 연동
const totalTopInset = slantHorizontal + topNotchDepth; // 상단 전체 inset (90)
const slantStartInset = slantHorizontal; // 사선 시작 inset (60)
// 'ㄴ'자 따임을 위한 특별 처리: y=topNotchHeight 지점에 두 개의 정점 세트 삽입
// 하나는 inset=totalTopInset (따임 끝), 하나는 inset=slantStartInset (사선 시작)
if (tab === 'FB') {
for (let i = 0; i < profilePoints.length; i++) {
if (Math.abs(profilePoints[i].uy - topNotchHeight) < 0.1 && !profilePoints[i].notchType) {
const p = profilePoints[i];
// 기존 정점을 두 개로 분리 (같은 위치, 다른 inset)
profilePoints.splice(i, 1,
{ x: p.x, y: p.y, uy: p.uy, notchType: 'end' }, // inset=totalTopInset
{ x: p.x, y: p.y, uy: p.uy, notchType: 'start' } // inset=slantStartInset
);
console.log('FB 따임 정점 분리 완료:', topNotchHeight, '따임깊이:', topNotchDepth);
break;
}
}
}
for (let i = 0; i < profilePoints.length; i++) {
const p = profilePoints[i];
let n = new THREE.Vector2();
if (i > 0 && i < profilePoints.length - 1) {
const v1 = new THREE.Vector2(p.x - profilePoints[i-1].x, p.y - profilePoints[i-1].y).normalize();
const v2 = new THREE.Vector2(profilePoints[i+1].x - p.x, profilePoints[i+1].y - p.y).normalize();
const mid = new THREE.Vector2().addVectors(v1, v2).normalize();
n.set(-mid.y, mid.x);
const dot = n.dot(new THREE.Vector2(-v1.y, v1.x));
n.multiplyScalar(1 / (Math.abs(dot) || 1));
} else {
const idx = i === 0 ? 0 : profilePoints.length - 2;
const v = new THREE.Vector2(profilePoints[idx+1].x - profilePoints[idx].x, profilePoints[idx+1].y - profilePoints[idx].y).normalize();
n.set(-v.y, v.x);
}
let inset;
if (tab === 'FB' && p.notchType === 'end') {
// 'ㄴ'자 따임 끝점: inset = totalTopInset (동적 계산)
inset = totalTopInset;
} else if (tab === 'FB' && p.notchType === 'start') {
// 'ㄴ'자 사선 시작점: inset = slantStartInset (동적 계산)
inset = slantStartInset;
} else {
inset = getInsetAtY(tab, p.uy);
}
// Narrowing 로직: 양쪽 끝에서 inset 만큼 줄여서 대칭 생성
vertices.push(p.x, p.y, -sheetWidth/2 + inset); // 0 (Outer-Start)
vertices.push(p.x + n.x * thickness, p.y + n.y * thickness, -sheetWidth/2 + inset); // 1 (Inner-Start)
vertices.push(p.x, p.y, sheetWidth/2 - inset); // 2 (Outer-End)
vertices.push(p.x + n.x * thickness, p.y + n.y * thickness, sheetWidth/2 - inset); // 3 (Inner-End)
}
for (let i = 0; i < profilePoints.length - 1; i++) {
const c = i*4, next = (i+1)*4;
const pCur = profilePoints[i];
const pNext = profilePoints[i+1];
// 'ㄴ'자 따임: notchType='end'에서 'start'로 넘어갈 때 수평 따임면(z방향) 생성
if (tab === 'FB' && pCur.notchType === 'end' && pNext.notchType === 'start') {
// 같은 x,y 위치, 다른 z 위치 → 수평 따임면 (topNotchDepth 폭의 수평면)
// 좌측 수평 따임면 (z가 -측에서 안쪽으로)
// c+0(z=-sw/2+totalTopInset), next+0(z=-sw/2+slantStartInset)
addQuad(c+0, c+1, next+1, next+0); // 좌측 따임면
// 우측 수평 따임면 (z가 +측에서 안쪽으로)
// c+2(z=+sw/2-totalTopInset), next+2(z=+sw/2-slantStartInset)
addQuad(c+2, next+2, next+3, c+3); // 우측 따임면
} else {
addQuad(c+0, next+0, next+2, c+2); // Outer surface
addQuad(c+1, c+3, next+3, next+1); // Inner surface
addQuad(c+0, c+1, next+1, next+0); // Side thickness (좌측)
addQuad(c+3, c+2, next+2, next+3); // Side thickness (우측)
}
}
// End Caps
const last = (profilePoints.length - 1) * 4;
addQuad(0, 2, 3, 1);
addQuad(last, last+1, last+3, last+2);
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
return new THREE.Mesh(geom, material);
}
function updateThreeScene() {
if (!frameGroup) return;
while(frameGroup.children.length > 0) {
frameGroup.remove(frameGroup.children[0]);
}
const mat = new THREE.MeshStandardMaterial({
color: sheetAiAppState.metalColor,
metalness: 0.9,
roughness: 0.2,
side: THREE.DoubleSide,
wireframe: sheetAiAppState.showWireframe
});
const L = sheetAiAppState.tabs.LR.config.sheetWidth;
const W = sheetAiAppState.tabs.FB.config.sheetWidth;
// 3D 객체 위치 조정
// 1. LEFT SIDE (LR)
const mLR1 = createSheetMetalPiece('LR', mat);
mLR1.rotation.y = Math.PI / 2;
mLR1.position.set(0, 0, -W/2 + 90); // Center along Z
frameGroup.add(mLR1);
const lLR1 = createTextSprite('좌우');
lLR1.position.set(0, 300, -W/2 + 90);
frameGroup.add(lLR1);
// 2. RIGHT SIDE (LR)
const mLR2 = createSheetMetalPiece('LR', mat);
mLR2.rotation.y = -Math.PI / 2;
mLR2.position.set(0, 0, W/2 - 90);
frameGroup.add(mLR2);
const lLR2 = createTextSprite('좌우');
lLR2.position.set(0, 300, W/2 -90);
frameGroup.add(lLR2);
// 3. BACK SIDE (FB)
const mFB1 = createSheetMetalPiece('FB', mat);
mFB1.rotation.y = Math.PI;
mFB1.position.set(-L/2 + 30, 0, 0);
frameGroup.add(mFB1);
const lFB1 = createTextSprite('앞뒤');
lFB1.position.set(-L/2 + 30, 300, 0);
frameGroup.add(lFB1);
// 4. FRONT SIDE (FB)
const mFB2 = createSheetMetalPiece('FB', mat);
mFB2.rotation.y = 0;
mFB2.position.set(L/2 - 30, 0, 0);
frameGroup.add(mFB2);
const lFB2 = createTextSprite('앞뒤');
lFB2.position.set(L/2 - 30, 300, 0);
frameGroup.add(lFB2);
const box = new THREE.Box3().setFromObject(frameGroup);
const center = box.getCenter(new THREE.Vector3());
frameGroup.position.sub(center);
// 카메라와 컨트롤 타겟을 객체 중심에 맞추기
if (controls && camera) {
// 객체 크기 계산
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
// 적절한 카메라 거리 계산 (객체가 화면에 잘 보이도록)
const fov = camera.fov * (Math.PI / 180);
const cameraDistance = (maxDim / 2) / Math.tan(fov / 2) * 2.0; // 2.0배 여유
// 카메라를 대각선 위에서 바라보도록 위치 설정 (객체가 화면 중앙에 오도록)
const angle = Math.PI / 4; // 45도
camera.position.set(
cameraDistance * Math.sin(angle),
cameraDistance * 0.5, // 적당한 높이에서 바라봄
cameraDistance * Math.cos(angle)
);
// 컨트롤 타겟을 원점(객체 중심)으로 설정
controls.target.set(0, 0, 0);
camera.lookAt(0, 0, 0); // 카메라가 객체 중심을 바라보도록
controls.update();
}
// Add custom data for raycasting
mLR1.userData = { name: '좌우', tab: 'LR' };
mLR2.userData = { name: '좌우', tab: 'LR' };
mFB1.userData = { name: '앞뒤', tab: 'FB' };
mFB2.userData = { name: '앞뒤', tab: 'FB' };
frameGroup.rotation.x = 0; // Set to 0 to flip back as requested
scene.add(frameGroup);
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click', (e) => {
if (isMoveMode) return;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(frameGroup.children);
if (intersects.length > 0) {
const obj = intersects[0].object;
if (obj.userData && obj.userData.name) {
const menu = $('partActionMenu');
menu.style.left = `${e.clientX + 10}px`;
menu.style.top = `${e.clientY + 10}px`;
menu.classList.remove('hidden');
$('btnViewPart').onclick = () => {
hideActionMenu();
openPartModal(obj.userData);
};
$('btnMovePart').onclick = () => {
startMoveMode(obj);
};
// Isolation logic
const btnIsolate = $('btnIsolatePart');
const btnShowAll = $('btnShowAllParts');
if (sheetAiAppState.isIsolated) {
btnIsolate.classList.add('hidden');
btnShowAll.classList.remove('hidden');
btnShowAll.classList.add('flex');
} else {
btnIsolate.classList.remove('hidden');
btnIsolate.classList.add('flex');
btnShowAll.classList.add('hidden');
}
btnIsolate.onclick = () => {
toggleIsolation(obj);
};
btnShowAll.onclick = () => {
showAllParts();
};
}
} else {
hideActionMenu();
}
});
}
window.sheetAiAppState = sheetAiAppState;
window.state = sheetAiAppState;
console.log("Sheet Metal AI Tool Loaded - Version 1.2-fixed");
const $ = id => document.getElementById(id);
// === 조명 컨트롤 함수들 ===
// 조명 패널 토글
window.toggleLightingPanel = () => {
const content = $('lightingPanelContent');
const arrow = $('lightingPanelArrow');
if (content.style.display === 'none') {
content.style.display = 'block';
arrow.style.transform = 'rotate(0deg)';
} else {
content.style.display = 'none';
arrow.style.transform = 'rotate(-90deg)';
}
};
// 조명 프리셋 정의
const lightingPresets = {
default: {
ambient: { intensity: 0.3, color: '#ffffff' },
directional1: { intensity: 0.8, color: '#ffffff', x: 2000, y: 3000, z: 1000 },
directional2: { intensity: 0.4, color: '#ffffff', x: -2000, y: 2000, z: -1000 },
point: { intensity: 0.6, color: '#ffffff', x: 0, y: 1000, z: 0 },
hemisphere: { enabled: false, skyColor: '#87ceeb', groundColor: '#8b4513', intensity: 0.5 },
spot: { enabled: false, intensity: 1.0, color: '#ffffff', x: 0, y: 2000, z: 0, angle: 30, penumbra: 0.5 }
},
studio: {
ambient: { intensity: 0.4, color: '#f0f0f0' },
directional1: { intensity: 1.2, color: '#ffffff', x: 1500, y: 2500, z: 2000 },
directional2: { intensity: 0.6, color: '#e8e8ff', x: -1500, y: 1500, z: -1500 },
point: { intensity: 0.3, color: '#fff5e6', x: 0, y: 500, z: 1000 },
hemisphere: { enabled: false, skyColor: '#ffffff', groundColor: '#444444', intensity: 0.3 },
spot: { enabled: true, intensity: 1.5, color: '#ffffff', x: 0, y: 3000, z: 0, angle: 25, penumbra: 0.3 }
},
outdoor: {
ambient: { intensity: 0.5, color: '#e6f0ff' },
directional1: { intensity: 1.5, color: '#fffaf0', x: 3000, y: 4000, z: 1000 },
directional2: { intensity: 0.2, color: '#87ceeb', x: -2000, y: 1000, z: -2000 },
point: { intensity: 0.1, color: '#ffffff', x: 0, y: 500, z: 0 },
hemisphere: { enabled: true, skyColor: '#87ceeb', groundColor: '#8b7355', intensity: 0.6 },
spot: { enabled: false, intensity: 1.0, color: '#ffffff', x: 0, y: 2000, z: 0, angle: 30, penumbra: 0.5 }
},
dramatic: {
ambient: { intensity: 0.1, color: '#1a1a2e' },
directional1: { intensity: 1.8, color: '#ff6b35', x: 2500, y: 2000, z: 500 },
directional2: { intensity: 0.3, color: '#4a69bd', x: -1500, y: 1500, z: -1000 },
point: { intensity: 0.8, color: '#ff9f43', x: 500, y: 800, z: 500 },
hemisphere: { enabled: false, skyColor: '#2d3436', groundColor: '#000000', intensity: 0.2 },
spot: { enabled: true, intensity: 2.5, color: '#ffffff', x: 0, y: 2500, z: 500, angle: 20, penumbra: 0.8 }
},
soft: {
ambient: { intensity: 0.6, color: '#fffaf0' },
directional1: { intensity: 0.5, color: '#ffffff', x: 1000, y: 2000, z: 1500 },
directional2: { intensity: 0.5, color: '#ffffff', x: -1000, y: 2000, z: -1500 },
point: { intensity: 0.4, color: '#fff5e6', x: 0, y: 1500, z: 0 },
hemisphere: { enabled: true, skyColor: '#f0f0f0', groundColor: '#d0d0d0', intensity: 0.4 },
spot: { enabled: false, intensity: 1.0, color: '#ffffff', x: 0, y: 2000, z: 0, angle: 45, penumbra: 1.0 }
}
};
// 프리셋 적용
window.applyLightingPreset = (presetName) => {
const preset = lightingPresets[presetName];
if (!preset) return;
sheetAiAppState.lighting = JSON.parse(JSON.stringify(preset));
sheetAiAppState.lighting.preset = presetName;
// UI 업데이트
updateLightingUI(preset);
// 조명 업데이트
updateLighting();
// 프리셋 버튼 스타일 업데이트
document.querySelectorAll('.lighting-preset-btn').forEach(btn => {
if (btn.dataset.preset === presetName) {
btn.classList.remove('bg-slate-700');
btn.classList.add('bg-amber-500', 'text-slate-900');
} else {
btn.classList.add('bg-slate-700');
btn.classList.remove('bg-amber-500', 'text-slate-900');
}
});
};
// UI 값 업데이트
function updateLightingUI(preset) {
// Ambient
$('lightAmbientIntensity').value = preset.ambient.intensity;
$('lightAmbientIntensityVal').textContent = preset.ambient.intensity.toFixed(2);
$('lightAmbientColor').value = preset.ambient.color;
// Directional 1
$('lightDir1Intensity').value = preset.directional1.intensity;
$('lightDir1IntensityVal').textContent = preset.directional1.intensity.toFixed(2);
$('lightDir1Color').value = preset.directional1.color;
$('lightDir1X').value = preset.directional1.x;
$('lightDir1Y').value = preset.directional1.y;
$('lightDir1Z').value = preset.directional1.z;
// Directional 2
$('lightDir2Intensity').value = preset.directional2.intensity;
$('lightDir2IntensityVal').textContent = preset.directional2.intensity.toFixed(2);
$('lightDir2Color').value = preset.directional2.color;
$('lightDir2X').value = preset.directional2.x;
$('lightDir2Y').value = preset.directional2.y;
$('lightDir2Z').value = preset.directional2.z;
// Point
$('lightPointIntensity').value = preset.point.intensity;
$('lightPointIntensityVal').textContent = preset.point.intensity.toFixed(2);
$('lightPointColor').value = preset.point.color;
$('lightPointX').value = preset.point.x;
$('lightPointY').value = preset.point.y;
$('lightPointZ').value = preset.point.z;
// Hemisphere
$('lightHemisphereEnabled').checked = preset.hemisphere.enabled;
$('lightHemisphereIntensity').value = preset.hemisphere.intensity;
$('lightHemisphereIntensityVal').textContent = preset.hemisphere.intensity.toFixed(2);
$('lightHemisphereSky').value = preset.hemisphere.skyColor;
$('lightHemisphereGround').value = preset.hemisphere.groundColor;
// Spot
$('lightSpotEnabled').checked = preset.spot.enabled;
$('lightSpotIntensity').value = preset.spot.intensity;
$('lightSpotIntensityVal').textContent = preset.spot.intensity.toFixed(2);
$('lightSpotColor').value = preset.spot.color;
$('lightSpotX').value = preset.spot.x;
$('lightSpotY').value = preset.spot.y;
$('lightSpotZ').value = preset.spot.z;
$('lightSpotAngle').value = preset.spot.angle;
$('lightSpotAngleVal').textContent = preset.spot.angle + '°';
$('lightSpotPenumbra').value = preset.spot.penumbra;
$('lightSpotPenumbraVal').textContent = preset.spot.penumbra.toFixed(1);
}
// 배경색 업데이트
window.updateBackgroundColor = (color) => {
if (!scene) return;
scene.background = new THREE.Color(color);
// 색상 선택기 동기화
const colorInput = $('bgColor');
if (colorInput) colorInput.value = color;
// 상태 저장
sheetAiAppState.backgroundColor = color;
};
// 조명 업데이트 (UI에서 호출)
window.updateLighting = () => {
if (!window.lights) return;
// UI에서 값 읽기
const ambient = {
intensity: parseFloat($('lightAmbientIntensity').value),
color: $('lightAmbientColor').value
};
const dir1 = {
intensity: parseFloat($('lightDir1Intensity').value),
color: $('lightDir1Color').value,
x: parseFloat($('lightDir1X').value),
y: parseFloat($('lightDir1Y').value),
z: parseFloat($('lightDir1Z').value)
};
const dir2 = {
intensity: parseFloat($('lightDir2Intensity').value),
color: $('lightDir2Color').value,
x: parseFloat($('lightDir2X').value),
y: parseFloat($('lightDir2Y').value),
z: parseFloat($('lightDir2Z').value)
};
const point = {
intensity: parseFloat($('lightPointIntensity').value),
color: $('lightPointColor').value,
x: parseFloat($('lightPointX').value),
y: parseFloat($('lightPointY').value),
z: parseFloat($('lightPointZ').value)
};
const hemi = {
enabled: $('lightHemisphereEnabled').checked,
intensity: parseFloat($('lightHemisphereIntensity').value),
skyColor: $('lightHemisphereSky').value,
groundColor: $('lightHemisphereGround').value
};
const spot = {
enabled: $('lightSpotEnabled').checked,
intensity: parseFloat($('lightSpotIntensity').value),
color: $('lightSpotColor').value,
x: parseFloat($('lightSpotX').value),
y: parseFloat($('lightSpotY').value),
z: parseFloat($('lightSpotZ').value),
angle: parseFloat($('lightSpotAngle').value),
penumbra: parseFloat($('lightSpotPenumbra').value)
};
// 값 표시 업데이트
$('lightAmbientIntensityVal').textContent = ambient.intensity.toFixed(2);
$('lightDir1IntensityVal').textContent = dir1.intensity.toFixed(2);
$('lightDir2IntensityVal').textContent = dir2.intensity.toFixed(2);
$('lightPointIntensityVal').textContent = point.intensity.toFixed(2);
$('lightHemisphereIntensityVal').textContent = hemi.intensity.toFixed(2);
$('lightSpotIntensityVal').textContent = spot.intensity.toFixed(2);
$('lightSpotAngleVal').textContent = spot.angle + '°';
$('lightSpotPenumbraVal').textContent = spot.penumbra.toFixed(1);
// Three.js 조명 업데이트
window.lights.ambient.intensity = ambient.intensity;
window.lights.ambient.color.set(ambient.color);
window.lights.directional1.intensity = dir1.intensity;
window.lights.directional1.color.set(dir1.color);
window.lights.directional1.position.set(dir1.x, dir1.y, dir1.z);
window.lights.directional2.intensity = dir2.intensity;
window.lights.directional2.color.set(dir2.color);
window.lights.directional2.position.set(dir2.x, dir2.y, dir2.z);
window.lights.point.intensity = point.intensity;
window.lights.point.color.set(point.color);
window.lights.point.position.set(point.x, point.y, point.z);
window.lights.hemisphere.visible = hemi.enabled;
window.lights.hemisphere.intensity = hemi.intensity;
window.lights.hemisphere.color.set(hemi.skyColor);
window.lights.hemisphere.groundColor.set(hemi.groundColor);
window.lights.spot.visible = spot.enabled;
window.lights.spot.intensity = spot.intensity;
window.lights.spot.color.set(spot.color);
window.lights.spot.position.set(spot.x, spot.y, spot.z);
window.lights.spot.angle = (spot.angle * Math.PI) / 180;
window.lights.spot.penumbra = spot.penumbra;
// 상태 저장
sheetAiAppState.lighting = {
preset: 'custom',
ambient, directional1: dir1, directional2: dir2, point, hemisphere: hemi, spot
};
};
// --- SERVICES (DXF Generator Logic) ---
function generateDXF(segments, config) {
console.log('generateDXF called - version 2025-12-27-v2', config);
const { sheetWidth, thickness, leftWing, rightWing, wingStartIndex, centerSlot, bottomCenterWidth } = config;
const jointDeductions = segments.map(seg => {
// 연신율(Elongation) 산정 원칙:
// 90도 절곡 기준 V-CUT 없으면 한쪽당 T(총 2T) 차감, V-CUT 있으면 한쪽당 T/2(총 T) 차감
const factor = seg.isVcut ? thickness : (thickness * 2);
return (Math.abs(180 - Number(seg.angle)) / 90) * factor;
});
let currentY = 0;
const items = segments.map((seg, idx) => {
const startDed = idx === 0 ? 0 : jointDeductions[idx - 1] / 2;
const endDed = idx === segments.length - 1 ? 0 : jointDeductions[idx] / 2;
const unfoldedLen = Number(seg.length) - startDed - endDed;
const prevY = currentY;
currentY += unfoldedLen;
return { ...seg, idx, unfoldedLength: unfoldedLen, yStart: prevY, yEnd: currentY, deduction: endDed };
});
const finalHeight = currentY;
const notchIdx = Math.min(wingStartIndex || 0, items.length - 1);
const notchY = items[notchIdx] ? items[notchIdx].yEnd : 0;
const bodyY = (notchIdx > 0 && items[notchIdx - 1]) ? items[notchIdx - 1].yEnd : 0;
const fy = (y) => finalHeight - y;
const line = (x1, y1, x2, y2, layer = "laser", color = 256) => {
return `0\nLINE\n8\n${layer}\n${color !== 256 ? `62\n${color}\n` : ''}10\n${x1.toFixed(4)}\n20\n${y1.toFixed(4)}\n30\n0.0\n11\n${x2.toFixed(4)}\n21\n${y2.toFixed(4)}\n31\n0.0\n`;
};
const dimension = (x1, y1, x2, y2, dimX, dimY, rotation = 0, layer = "DIMENSIONS", color = 3, isAligned = false) => {
let val = 0;
if (isAligned) val = Math.hypot(x2 - x1, y2 - y1);
else if (rotation === 0) val = Math.abs(x2 - x1);
else if (rotation === 90) val = Math.abs(y2 - y1);
else val = Math.hypot(x2 - x1, y2 - y1);
let res = `0\nDIMENSION\n8\n${layer}\n${color !== 256 ? `62\n${color}\n` : ''}`;
res += `10\n${dimX.toFixed(4)}\n20\n${dimY.toFixed(4)}\n30\n0.0\n`;
res += `11\n${dimX.toFixed(4)}\n21\n${dimY.toFixed(4)}\n31\n0.0\n`;
res += `70\n${isAligned ? 1 : 0}\n3\nSTANDARD\n`;
res += `1\n${Number(val.toFixed(1))}\n`;
res += `13\n${x1.toFixed(4)}\n23\n${y1.toFixed(4)}\n33\n0.0\n`;
res += `14\n${x2.toFixed(4)}\n24\n${y2.toFixed(4)}\n34\n0.0\n`;
if (!isAligned) res += `50\n${rotation.toFixed(4)}\n`;
return res;
};
const tabName = sheetAiAppState.activeTab === 'LR' ? 'Side Frame' : 'Front Frame';
// DXF 헤더 및 레이어 정의 (laser 레이어 추가)
let dxf = `0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1009\n0\nENDSEC\n0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nDIMSTYLE\n70\n1\n0\nDIMSTYLE\n2\nSTANDARD\n70\n0\n41\n7.5\n42\n6.0\n44\n6.0\n140\n18.0\n147\n3.0\n77\n1\n0\nENDTAB\n0\nTABLE\n2\nLAYER\n70\n4\n0\nLAYER\n2\nOUTLINE\n70\n0\n62\n7\n0\nLAYER\n2\nBEND_LINES\n70\n0\n62\n1\n0\nLAYER\n2\nDIMENSIONS\n70\n0\n62\n3\n0\nLAYER\n2\nlaser\n70\n0\n62\n7\n0\nENDTAB\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n`;
// Title
dxf += `0\nTEXT\n8\nDIMENSIONS\n62\n3\n10\n0.0\n20\n${fy(-450).toFixed(4)}\n30\n0.0\n40\n50.0\n1\n${tabName}\n`;
// Width Dimension (Furthest out - Level 3)
dxf += dimension(0, fy(0), sheetWidth, fy(0), sheetWidth / 2, fy(-320), 0);
// Outline Point Generation
if (sheetAiAppState.activeTab === 'FB') {
// 동적 계산: 상부 날개 치수 및 globalSettings 연동
const gs = sheetAiAppState.globalSettings;
const fbSpecificSegs3 = sheetAiAppState.tabs.FB?.specificSegments || [];
const topWingLength = fbSpecificSegs3[0]?.length || 30; // 상부 날개 원본 길이
const tolerance = 0.6; // 결합 공차
const topNotchDepth = topWingLength + tolerance; // 따임 깊이 (30.6)
const topNotchHeight = items[0]?.yEnd || 0; // 연신율 적용 후 높이 (28.8)
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2) || 60; // globalSettings 연동
const totalTopInset = slantHorizontal + topNotchDepth; // 상단 전체 inset (90)
const slantStartInset = slantHorizontal; // 사선 시작 inset (60)
const fbSlantHeight = (gs.height || 150) - (gs.firstStepHeight || 70); // 전체높이 - 1단높이 = 80
const ySlant = topNotchHeight + fbSlantHeight; // 사선 끝점 (동적)
// Left side FB Notch
dxf += line(totalTopInset, fy(0), sheetWidth - totalTopInset, fy(0)); // Top edge
dxf += line(totalTopInset, fy(0), totalTopInset, fy(topNotchHeight)); // Notch vertical drop
dxf += line(totalTopInset, fy(topNotchHeight), slantStartInset, fy(topNotchHeight)); // Notch horizontal in
dxf += line(slantStartInset, fy(topNotchHeight), 0, fy(ySlant)); // Slant
dxf += line(0, fy(ySlant), 0, fy(bodyY)); // Vertical drop to bodyY
// Right side FB Notch (대칭)
dxf += line(sheetWidth, fy(bodyY), sheetWidth, fy(ySlant));
dxf += line(sheetWidth, fy(ySlant), sheetWidth - slantStartInset, fy(topNotchHeight));
dxf += line(sheetWidth - slantStartInset, fy(topNotchHeight), sheetWidth - totalTopInset, fy(topNotchHeight));
dxf += line(sheetWidth - totalTopInset, fy(topNotchHeight), sheetWidth - totalTopInset, fy(0));
} else {
dxf += line(0, fy(0), sheetWidth, fy(0));
dxf += line(0, fy(0), 0, fy(bodyY));
dxf += line(sheetWidth, fy(0), sheetWidth, fy(bodyY));
}
// 마지막 세그먼트(노치 분리) 높이 계산
const lastSegmentYStart = items[items.length - 1]?.yStart || finalHeight;
const lastItem = items[items.length - 1];
const lastSegH = lastItem?.unfoldedLength || 0;
// 마지막 날개값 18.2mm 추가 - ㄱ자 따임 확장 (치수선 13.8 일치)
const lastWingExtra = 18.2;
const notchWidth = lastSegH + lastWingExtra;
// 마지막 사선 세그먼트 찾기 (짝수 relIdx 중 마지막)
let lastDiagIdx = -1;
for (let i = notchIdx + 1; i < items.length - 1; i++) {
const relIdx = i - notchIdx;
if (relIdx % 2 === 0) lastDiagIdx = i;
}
// === 좌측 외곽선 ===
let curLX = leftWing;
// 첫 번째 사선 (bodyY → notchY)
dxf += line(0, fy(bodyY), curLX, fy(notchY));
// 중간 계단 세그먼트들 (마지막 세그먼트 전까지)
for (let i = notchIdx + 1; i < items.length - 1; i++) {
const relIdx = i - notchIdx;
const segmentH = items[i].unfoldedLength;
const isLastDiag = (i === lastDiagIdx);
if (relIdx % 2 === 1) {
// 홀수: 수직 (x 유지)
dxf += line(curLX, fy(items[i].yStart), curLX, fy(items[i].yEnd));
} else if (isLastDiag) {
// 마지막 사선: 바깥쪽으로 (x 감소)
const prevLX = curLX;
curLX -= segmentH;
dxf += line(prevLX, fy(items[i].yStart), curLX, fy(items[i].yEnd));
} else {
// 짝수: 사선 (안쪽으로)
const prevLX = curLX;
curLX += segmentH;
dxf += line(prevLX, fy(items[i].yStart), curLX, fy(items[i].yEnd));
}
}
// 마지막 세그먼트: 수직 → 수평(노치) → 수직
const prevYEnd = items[items.length - 2]?.yEnd || lastSegmentYStart;
// 1. 수직으로 내려감
dxf += line(curLX, fy(prevYEnd), curLX, fy(lastSegmentYStart));
// 2. 수평 노치 (안쪽으로 - lastSegH + 15mm 확장)
dxf += line(curLX, fy(lastSegmentYStart), curLX + notchWidth, fy(lastSegmentYStart));
// 3. 수직으로 끝까지
dxf += line(curLX + notchWidth, fy(lastSegmentYStart), curLX + notchWidth, fy(finalHeight));
const finalLX = curLX + notchWidth;
// === 우측 외곽선 (좌측의 완전한 거울 대칭) ===
// 좌측 하단 선분들을 수집하여 대칭 변환
const leftLines = [];
// 좌측 하단 선분 수집 (notchY부터 finalHeight까지)
// 1. 첫 번째 사선 (bodyY → notchY) - 이미 그려짐, 하단 부분만 수집
leftLines.push({ x1: leftWing, y1: notchY, x2: leftWing, y2: notchY }); // 시작점
// 2. 중간 계단 세그먼트들 재계산하여 선분 수집
let tempLX = leftWing;
for (let i = notchIdx + 1; i < items.length - 1; i++) {
const relIdx = i - notchIdx;
const segmentH = items[i].unfoldedLength;
const isLastDiag = (i === lastDiagIdx);
if (relIdx % 2 === 1) {
// 홀수: 수직
leftLines.push({ x1: tempLX, y1: items[i].yStart, x2: tempLX, y2: items[i].yEnd });
} else if (isLastDiag) {
// 마지막 사선: 바깥쪽으로
const prevLX = tempLX;
tempLX -= segmentH;
leftLines.push({ x1: prevLX, y1: items[i].yStart, x2: tempLX, y2: items[i].yEnd });
} else {
// 짝수: 사선 (안쪽으로)
const prevLX = tempLX;
tempLX += segmentH;
leftLines.push({ x1: prevLX, y1: items[i].yStart, x2: tempLX, y2: items[i].yEnd });
}
}
// 3. 마지막 세그먼트 (안쪽으로 - lastSegH + 15mm 확장)
leftLines.push({ x1: tempLX, y1: prevYEnd, x2: tempLX, y2: lastSegmentYStart }); // 수직
leftLines.push({ x1: tempLX, y1: lastSegmentYStart, x2: tempLX + notchWidth, y2: lastSegmentYStart }); // 수평 노치 (안쪽)
leftLines.push({ x1: tempLX + notchWidth, y1: lastSegmentYStart, x2: tempLX + notchWidth, y2: finalHeight }); // 수직
// 우측 외곽선: 좌측 선분들을 대칭 변환하여 그리기
// 첫 번째 사선 (bodyY → notchY)
dxf += line(sheetWidth, fy(bodyY), sheetWidth - rightWing, fy(notchY));
// 좌측 선분들을 대칭 변환 (x → sheetWidth - x)
for (let i = 0; i < leftLines.length; i++) {
const l = leftLines[i];
if (l.x1 === l.x2 && l.y1 === l.y2) continue; // 시작점 마커 스킵
dxf += line(sheetWidth - l.x1, fy(l.y1), sheetWidth - l.x2, fy(l.y2));
}
// 하단 중앙선
const finalRX = sheetWidth - finalLX;
dxf += line(finalLX, fy(finalHeight), finalRX, fy(finalHeight));
// Bend Lines
items.forEach((item, i) => {
if (i === items.length - 1) return;
const yVal = item.yEnd;
// 안쪽의 점에서 시작 원칙: 전후 인셋 중 더 큰 값을 선택 (더 안쪽)
const insetPrev = getInsetAtY(sheetAiAppState.activeTab, yVal - 0.1);
const insetNext = getInsetAtY(sheetAiAppState.activeTab, yVal + 0.1);
const sx = Math.max(insetPrev, insetNext);
const ex = sheetWidth - sx;
const bendColor = item.direction === 'CW' ? 5 : 1;
dxf += line(sx, fy(yVal), ex, fy(yVal), "BEND_LINES", bendColor);
});
// Vertical Dimensions (Right Side - Tracking Boundary Vertices)
// 겹침 방지를 위한 누적 오프셋 적용 (단면도와 동일한 방식)
const vDimBaseX = sheetWidth + 80; // 기본 X 오프셋
const vDimStepX = 60; // 각 치수선 간격
let vDimLevel = 0; // 누적 레벨 카운터
items.forEach((item, i) => {
// 해당 외곽선의 vertex에서 시작하는 원칙 적용
const x1 = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, item.yStart + 0.1);
const x2 = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, item.yEnd - 0.1);
// 현재 레벨의 X 위치 계산
const currentDimX = vDimBaseX + (vDimLevel * vDimStepX);
if (sheetAiAppState.activeTab === 'FB' && i === 1) {
// FB Segment 1: Slant + Vertical (동적 계산)
const gs = sheetAiAppState.globalSettings;
const fbSlantHeight = gs.height - gs.firstStepHeight; // 전체높이 - 1단높이
const y1 = item.yStart;
const yS = item.yStart + fbSlantHeight; // ySlant (동적)
const y2 = item.yEnd;
const xS = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, yS); // 0 (Inset 0)
dxf += dimension(x1, fy(y1), xS, fy(yS), currentDimX, fy((y1 + yS) / 2), 90);
vDimLevel++;
const nextDimX = vDimBaseX + (vDimLevel * vDimStepX);
dxf += dimension(xS, fy(yS), x2, fy(y2), nextDimX, fy((yS + y2) / 2), 90);
vDimLevel++;
} else {
dxf += dimension(x1, fy(item.yStart), x2, fy(item.yEnd), currentDimX, (fy(item.yStart) + fy(item.yEnd)) / 2, 90);
vDimLevel++;
}
if (i === items.length - 1) {
// 전체 높이 치수선은 가장 바깥쪽에 배치 (간격 축소: 80 → -20)
const totalDimX = vDimBaseX + (vDimLevel * vDimStepX) - 20;
// 최하단 vertex (curRX)
const xEnd = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, finalHeight - 0.1);
dxf += dimension(sheetWidth - getInsetAtY(sheetAiAppState.activeTab, 0.1), fy(0), xEnd, fy(finalHeight), totalDimX, fy(finalHeight/2), 90);
}
});
// FB Top Notch Dimensions (동적 계산 기반)
if (sheetAiAppState.activeTab === 'FB') {
const fbSpecificSegs4 = sheetAiAppState.tabs.FB?.specificSegments || [];
const topWing = fbSpecificSegs4[0]?.length || 30;
const tol = 0.6;
const notchDepth = topWing + tol; // 따임 깊이 (30.6)
const notchHeight = items[0]?.yEnd || 0; // 연신율 적용 후 (28.8)
const topInset = 60 + notchDepth; // 상단 전체 (90)
const slantInset = topInset - notchDepth; // 사선 시작 (59.4)
// notchDepth section (따임 깊이)
dxf += dimension(topInset, fy(notchHeight), slantInset, fy(notchHeight), (topInset + slantInset) / 2, fy(-80), 0);
// slantInset section (사선 시작점까지)
dxf += dimension(slantInset, fy(notchHeight), 0, fy(notchHeight), slantInset / 2, fy(-160), 0);
// topInset section (상단 전체 inset)
dxf += dimension(0, fy(0), topInset, fy(0), topInset / 2, fy(-240), 0);
}
// Bottom Horizontal Dimensions (Stepped to avoid overlap)
let bX = 0;
let dimLv = 0;
const getStepY = (lv) => fy(finalHeight + 110 + (lv % 3) * 80);
if (sheetAiAppState.activeTab === 'LR' || sheetAiAppState.activeTab === 'FB') {
// 1. Initial Left Wing
dxf += dimension(0, fy(bodyY), leftWing, fy(notchY), leftWing/2, getStepY(dimLv++), 0);
bX = leftWing;
// 2. Left Step increments
for(let i = notchIdx + 1; i < items.length; i++) {
if((i - notchIdx) % 2 === 0) {
const delta = items[i].unfoldedLength;
dxf += dimension(bX, fy(items[i].yStart), bX + delta, fy(items[i].yEnd), bX + delta/2, getStepY(dimLv++), 0);
bX += delta;
}
}
// 3. Middle part (Calculate end of symmetry)
let endX = sheetWidth - rightWing;
for(let i = notchIdx + 1; i < items.length; i++) {
if((i - notchIdx) % 2 === 0) endX -= items[i].unfoldedLength;
}
dxf += dimension(bX, fy(finalHeight), endX, fy(finalHeight), (bX + endX)/2, getStepY(dimLv++), 0);
// 4. Right symmetry steps mapping
let curRX_B = endX;
for(let i = items.length - 1; i > notchIdx; i--) {
if((i - notchIdx) % 2 === 0) {
const delta = items[i].unfoldedLength;
dxf += dimension(curRX_B, fy(items[i].yEnd), curRX_B + delta, fy(items[i].yStart), curRX_B + delta/2, getStepY(dimLv++), 0);
curRX_B += delta;
}
}
// 5. Final Wing
dxf += dimension(sheetWidth - rightWing, fy(notchY), sheetWidth, fy(bodyY), sheetWidth - rightWing/2, getStepY(dimLv++), 0);
}
// 6. Angle Labels & FB Detailed Annotations
const diags = items.map((_, idx) => idx).filter(i => (i > notchIdx) && ((i - notchIdx) % 2 === 0));
// DXF 저장시 각도 치수선 제거 - drawProAngle 함수 비활성화
// const drawProAngle = (cx, cy, radius, startAng, endAng, txtX, txtY, layer="DIMENSIONS") => {
// let d = '';
// const actualAng = Math.abs(endAng - startAng);
// d += `0\nARC\n8\n${layer}\n62\n3\n10\n${cx.toFixed(4)}\n20\n${cy.toFixed(4)}\n30\n0.0\n40\n${radius.toFixed(4)}\n50\n${startAng.toFixed(1)}\n51\n${endAng.toFixed(1)}\n`;
// d += line(cx, cy, cx + (radius + 20) * Math.cos(startAng * Math.PI / 180), cy + (radius + 20) * Math.sin(startAng * Math.PI / 180), layer, 3);
// d += line(cx, cy, cx + (radius + 20) * Math.cos(endAng * Math.PI / 180), cy + (radius + 20) * Math.sin(endAng * Math.PI / 180), layer, 3);
// d += `0\nTEXT\n8\n${layer}\n62\n3\n10\n${txtX.toFixed(4)}\n20\n${txtY.toFixed(4)}\n30\n0.0\n40\n24.0\n1\n${Number(actualAng.toFixed(1))}%%d\n`;
// return d;
// };
if (sheetAiAppState.activeTab === 'FB') {
// 동적 계산 기반 각도 표시 (globalSettings 연동)
const gs = sheetAiAppState.globalSettings;
const fbSpecificSegs5 = sheetAiAppState.tabs.FB?.specificSegments || [];
const topWingAngle = fbSpecificSegs5[0]?.length || 30;
const tolAngle = 0.6;
const notchDepthAngle = topWingAngle + tolAngle; // 따임 깊이
const notchHeightAngle = items[0]?.yEnd || 0; // 연신율 적용 후
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2) || 60; // globalSettings 연동
const topInsetAngle = slantHorizontal + notchDepthAngle; // 상단 전체
const slantInsetAngle = slantHorizontal; // 사선 시작
const fbSlantHeight = (gs.height || 150) - (gs.firstStepHeight || 70); // 전체높이 - 1단높이
const yS = notchHeightAngle + fbSlantHeight;
// 동적 사선 각도 계산 (LR 프레임 사선 각도와 동일)
const slantAngleRad = Math.atan2(fbSlantHeight, slantHorizontal);
const slantAngleDeg = slantAngleRad * 180 / Math.PI; // 수평 기준 각도 (예: 53.1°)
const bendAngle = 180 - slantAngleDeg; // 절곡 각도 (예: 126.9°)
const leftSlantStart = 360 - bendAngle; // 좌측 사선 시작 (예: 233.1)
const rightSlantEnd = 180 + bendAngle; // 우측 사선 끝 (예: 306.9)
// Left: Horizontal Right(0) to Slant Down-Left -> 동적 각도 적용
// DXF 저장시 각도 치수선 제거 - drawProAngle 호출 제거
// dxf += drawProAngle(slantInsetAngle, fy(notchHeightAngle), 35, leftSlantStart, 360, 80, fy(notchHeightAngle + 45));
// Slant Aligned Dimension - Aligned Mode
dxf += dimension(slantInsetAngle, fy(notchHeightAngle), 0, fy(yS), -40, fy((notchHeightAngle + yS) / 2), 0, "DIMENSIONS", 3, true);
// Right: Horizontal Left(180) to Slant Down-Right -> 동적 각도 적용
// DXF 저장시 각도 치수선 제거 - drawProAngle 호출 제거
// dxf += drawProAngle(sheetWidth - slantInsetAngle, fy(notchHeightAngle), 35, 180, rightSlantEnd, sheetWidth - 80, fy(notchHeightAngle + 45));
dxf += dimension(sheetWidth - slantInsetAngle, fy(notchHeightAngle), sheetWidth, fy(yS), sheetWidth + 40, fy((notchHeightAngle + yS) / 2), 0, "DIMENSIONS", 3, true);
}
if (diags.length > 0) {
// DXF 저장시 각도 치수선 제거 - 첫 번째 사선 각도 표시 제거
// const angEntry = Math.abs(Math.atan2(leftWing, items[notchIdx].unfoldedLength) * 180 / Math.PI);
// dxf += drawProAngle(0, fy(bodyY), 35, 270, 270 + angEntry, 15, fy(bodyY) - 50);
// dxf += drawProAngle(sheetWidth, fy(bodyY), 35, 270 - angEntry, 270, sheetWidth - 45, fy(bodyY) - 50);
const ltI = diags[0];
if (ltI) {
// DXF 저장시 각도 치수선 제거
// const segH = items[ltI].unfoldedLength;
// let lx = leftWing;
// for(let j = notchIdx + 1; j < ltI; j++) { if((j-notchIdx)%2 === 0) lx += items[j].unfoldedLength; }
// const lcx = lx, lcy = fy(items[ltI].yStart);
// const angL = Math.abs(Math.atan2(-segH, segH) * 180 / Math.PI);
// dxf += drawProAngle(lcx, lcy, 35, 270, 270 + angL, lcx + 15, lcy - 45);
}
// Right Step - DXF 저장시 각도 치수선 제거
// const rbI = diags[diags.length - 1];
// const segHR = items[rbI].unfoldedLength;
// let rx = sheetWidth - rightWing;
// for(let j = notchIdx + 1; j < rbI; j++) { if((j-notchIdx)%2 === 0) rx -= items[j].unfoldedLength; }
// const rcx = rx, rcy = fy(items[rbI].yStart);
// const angR = Math.abs(Math.atan2(-segHR, -segHR) * 180 / Math.PI - (-90));
// dxf += drawProAngle(rcx, rcy, 35, 270 - angR, 270, rcx - 45, rcy - 45);
}
// 7. Section Detail (PIP Detail in DXF)
const profilePts = [];
let pX = 0, pY = 0, pDir = 180;
profilePts.push({x: 0, y: 0});
items.forEach((item, i) => {
const rad = (pDir * Math.PI) / 180;
const len = Number(item.length);
pX += len * Math.cos(rad);
pY += len * Math.sin(rad);
profilePts.push({x: pX, y: pY, angle: item.angle, isVcut: item.isVcut});
if (i < items.length - 1) {
const turn = 180 - Number(items[i].angle);
const mult = items[i].direction === 'CW' ? 1 : -1;
pDir += turn * mult;
}
});
// Positioning for Section Detail in DXF (Match the UI's relative position)
const detailX_Base = sheetWidth + 800;
const detailY_Base = fy(-50); // Matching the UI's detailY = -50 (SVG coordinates)
const detailScale = 1.0; // 1:1 스케일 (현장 요청)
// Draw Section Profile Lines with DIMENSION entities (치수선 형태)
// 치수선 배치 규칙:
// 1. 가로선(상단): 위쪽으로 치수선, 거리 2배
// 2. 사선: 위쪽으로 치수선
// 3. 세로선: 왼쪽에 선이 없으면 왼쪽, 있으면 오른쪽
// 4. 겹침 방지: 아래→위로 올라가며 오프셋 배수로 증가
// 단면도 전용 치수선 생성 함수 (aligned dimension)
const sectionDimension = (x1, y1, x2, y2, dimX, dimY, realLength, layer = "DIMENSIONS", color = 3) => {
let res = `0\nDIMENSION\n8\n${layer}\n62\n${color}\n`;
res += `10\n${dimX.toFixed(4)}\n20\n${dimY.toFixed(4)}\n30\n0.0\n`; // 치수선 위치
res += `11\n${dimX.toFixed(4)}\n21\n${dimY.toFixed(4)}\n31\n0.0\n`; // 텍스트 위치
res += `70\n1\n`; // Aligned dimension type
res += `3\nSTANDARD\n`; // Dimension style
res += `1\n${Number(realLength.toFixed(1))}\n`; // 실제 원본 길이로 텍스트 표시
res += `13\n${x1.toFixed(4)}\n23\n${y1.toFixed(4)}\n33\n0.0\n`; // First extension line origin
res += `14\n${x2.toFixed(4)}\n24\n${y2.toFixed(4)}\n34\n0.0\n`; // Second extension line origin
return res;
};
// 프로파일의 경계값 계산 (DXF 좌표)
const profileMinX = detailX_Base + Math.min(...profilePts.map(p => p.x)) * detailScale;
const profileMinY = detailY_Base - Math.max(...profilePts.map(p => p.y)) * detailScale; // DXF Y 반전
const profileMaxY = detailY_Base - Math.min(...profilePts.map(p => p.y)) * detailScale; // DXF Y 반전
// 겹침 방지를 위한 누적 오프셋 (아래→위로 증가)
const baseOffset = 20; // 기본 오프셋 (절반으로 축소: 40→20)
const stackMultiplier = 1.25; // 배수 증가 (절반으로 축소: 1.5→1.25)
let verticalDimCount = 0; // 세로 치수선 누적 카운트
let horizontalTopCount = 0; // 상단 가로/사선 치수선 누적 카운트
let horizontalBottomCount = 0; // 하단 가로 치수선 누적 카운트
for (let i = 1; i < profilePts.length; i++) {
const p1 = profilePts[i-1];
const p2 = profilePts[i];
// DXF 좌표로 변환
const dx1 = detailX_Base + p1.x * detailScale;
const dy1 = detailY_Base - p1.y * detailScale;
const dx2 = detailX_Base + p2.x * detailScale;
const dy2 = detailY_Base - p2.y * detailScale;
// 프로파일 선 그리기
dxf += line(dx1, dy1, dx2, dy2, "OUTLINE", 7);
// 방향 판단 (원본 좌표 기준)
const pdx = p2.x - p1.x;
const pdy = p2.y - p1.y;
const absDx = Math.abs(pdx);
const absDy = Math.abs(pdy);
// 선 유형 분류: 수직, 수평, 사선
const isVertical = absDy > absDx * 2; // 수직선 (기울기 > 2)
const isHorizontal = absDx > absDy * 2; // 수평선 (기울기 < 0.5)
const isDiagonal = !isVertical && !isHorizontal; // 사선
// 실제 원본 길이
const realLength = Number(items[i-1].length);
// 치수선 위치 계산
let dimX, dimY;
if (isVertical) {
// 세로선: 왼쪽에 선이 없으면 왼쪽, 있으면 오른쪽
const lineMinX = Math.min(dx1, dx2);
const lineMaxX = Math.max(dx1, dx2);
const lineMidY = (dy1 + dy2) / 2;
// 현재 선의 왼쪽이 프로파일 최소 X와 같거나 가까우면 왼쪽에 치수선
const isLeftEdge = Math.abs(lineMinX - profileMinX) < 1;
// 오프셋 계산 (아래→위로 누적)
const offset = baseOffset * Math.pow(stackMultiplier, verticalDimCount);
verticalDimCount++;
if (isLeftEdge) {
// 왼쪽에 치수선
dimX = lineMinX - offset;
} else {
// 오른쪽에 치수선
dimX = lineMaxX + offset;
}
dimY = lineMidY;
} else if (isHorizontal) {
// 가로선: 밑면이면 아래쪽, 상단이면 위쪽으로 치수선
const lineMinY = Math.min(dy1, dy2);
const lineMaxY = Math.max(dy1, dy2);
const lineMidX = (dx1 + dx2) / 2;
const lineMidY = (dy1 + dy2) / 2;
// 밑면 판단: 프로파일 최소 Y(DXF 좌표에서 가장 아래)와 가까우면 밑면
const isBottomEdge = Math.abs(lineMidY - profileMinY) < 5;
if (isBottomEdge) {
// 밑면: 아래쪽으로 치수선
const offset = baseOffset * 2 * Math.pow(stackMultiplier, horizontalBottomCount);
horizontalBottomCount++;
dimX = lineMidX;
dimY = lineMinY - offset; // 아래쪽으로
} else {
// 상단: 위쪽으로 치수선
const offset = baseOffset * 2 * Math.pow(stackMultiplier, horizontalTopCount);
horizontalTopCount++;
dimX = lineMidX;
dimY = lineMaxY + offset; // 위쪽으로
}
} else {
// 사선: 위쪽으로 치수선
const lineMaxY = Math.max(dy1, dy2);
const lineMidX = (dx1 + dx2) / 2;
const lineMidY = (dy1 + dy2) / 2;
// 오프셋 계산 (아래→위로 누적)
const offset = baseOffset * Math.pow(stackMultiplier, horizontalTopCount);
horizontalTopCount++;
// 사선의 법선 방향으로 위쪽 오프셋
const len = Math.hypot(dx2 - dx1, dy2 - dy1);
const nx = -(dy2 - dy1) / len; // 법선 x
const ny = (dx2 - dx1) / len; // 법선 y
// 법선이 위쪽(+Y)을 향하도록 조정
if (ny < 0) {
dimX = lineMidX - nx * offset;
dimY = lineMidY - ny * offset;
} else {
dimX = lineMidX + nx * offset;
dimY = lineMidY + ny * offset;
}
}
// DIMENSION 엔티티로 치수선 생성
dxf += sectionDimension(dx1, dy1, dx2, dy2, dimX, dimY, realLength);
// 단면도에서 90도가 아닌 각도 표시 (복원)
if (i < profilePts.length - 1 && Number(p2.angle) !== 90) {
const angX = detailX_Base + p2.x * detailScale + 15;
const angY = detailY_Base - p2.y * detailScale - 15;
dxf += `0\nTEXT\n8\nDIMENSIONS\n62\n3\n10\n${angX.toFixed(4)}\n20\n${angY.toFixed(4)}\n30\n0.0\n40\n27.0\n1\n${p2.angle}%%d\n`;
}
// V-CUT Marker (CIRCLE in DXF)
if (p2.isVcut && i < profilePts.length - 1) {
const vx = detailX_Base + p2.x * detailScale;
const vy = detailY_Base - p2.y * detailScale;
dxf += `0\nCIRCLE\n8\nBEND_LINES\n62\n1\n10\n${vx.toFixed(4)}\n20\n${vy.toFixed(4)}\n30\n0.0\n40\n6.0\n`;
}
}
// Info text at the bottom of Section Detail (치수선과 간섭 없도록 위치 조정)
const infoX = detailX_Base + ((Math.min(...profilePts.map(p=>p.x)) + Math.max(...profilePts.map(p=>p.x)))/2) * detailScale;
const infoY = detailY_Base - (Math.max(...profilePts.map(p=>p.y)) * detailScale) - 150; // 더 아래로 (-60 → -150)
dxf += `0\nTEXT\n8\nDIMENSIONS\n62\n3\n10\n${infoX.toFixed(4)}\n20\n${infoY.toFixed(4)}\n30\n0.0\n40\n48.0\n72\n1\n11\n${infoX.toFixed(4)}\n21\n${infoY.toFixed(4)}\n1\nSECTION DETAIL (T=${thickness}mm) SCALE 1:1\n`;
dxf += `0\nENDSEC\n0\nEOF\n`;
return dxf.replace(/\n/g, '\r\n');
}
function updateUI() {
renderSegments();
renderProfile();
renderPreview();
updateStats();
}
function updateStats() {
const data = getUnfoldedData();
$('finalHeightDisplay').innerHTML = `${data.finalHeight.toFixed(2)} <span class="text-[10px] text-slate-600">mm</span>`;
$('totalSegmentsDisplay').innerHTML = `${sheetAiAppState.segments.length} <span class="text-[10px] text-slate-600">BENDS</span>`;
$('vcutEnabledDisplay').innerText = sheetAiAppState.segments.some(s => s.isVcut) ? 'ON' : 'OFF';
$('vcutEnabledDisplay').className = sheetAiAppState.segments.some(s => s.isVcut) ? 'text-xl font-black text-pink-500' : 'text-xl font-black text-slate-600';
$('scaleDisplay').innerText = `SCALE: ${Math.round(sheetAiAppState.view.scale * 100)}%`;
// Update Wing Start Select
const select = $('wingStartIndexSelect');
if (select) {
const currentVal = sheetAiAppState.config.wingStartIndex;
select.innerHTML = sheetAiAppState.segments.map((_, i) => `<option value="${i}" ${i === currentVal ? 'selected' : ''}>구간 ${i+1} 이후</option>`).join('');
}
// Update Details Table
const tableBody = $('unfoldingDataTable');
if (tableBody) {
let html = '';
data.items.forEach((item, i) => {
// Current Segment Row
html += `
<tr class="bg-slate-900/20">
<td class="px-4 py-3 font-bold text-blue-500/80 italic">#${i+1} Segment</td>
<td class="px-4 py-3 font-black text-slate-400 text-right">${Number(item.length).toFixed(2)}</td>
<td class="px-4 py-3 text-center">-</td>
<td class="px-4 py-3 font-black text-blue-400 text-right text-sm underline underline-offset-4 decoration-blue-500/30">${item.unfoldedLength.toFixed(2)}</td>
<td class="px-4 py-3 text-center">-</td>
</tr>
`;
// Next Joint Row (If not last)
if (i < data.items.length - 1) {
const jointIdx = i;
const ded = data.jointDeductions[jointIdx];
const isV = sheetAiAppState.segments[jointIdx].isVcut;
html += `
<tr class="bg-slate-800/10 border-y border-slate-800/30">
<td class="px-8 py-2 text-[10px] font-black text-slate-600 uppercase tracking-tighter flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full ${isV ? 'bg-pink-500' : 'bg-slate-700'}"></div>
Joint #${i+1} Bend
</td>
<td class="px-4 py-2 text-right text-[10px] text-slate-700 italic">Deduction: -${ded.toFixed(2)}</td>
<td class="px-4 py-2 font-black text-emerald-500 text-center text-[11px]">${item.angle}°</td>
<td class="px-4 py-2 text-right text-[10px] text-slate-700">(${ (ded/2).toFixed(2) } shared)</td>
<td class="px-4 py-2 text-center">
${isV ? '<span class="px-2 py-0.5 rounded-md bg-pink-500/20 text-pink-500 font-black text-[8px] border border-pink-500/30 shadow-sm shadow-pink-500/10 uppercase">V-CUT</span>' : '<span class="text-slate-800 font-bold opacity-30">NORMAL</span>'}
</td>
</tr>
`;
}
});
tableBody.innerHTML = html;
}
}
function renderSegments() {
const list = $('segmentList');
if (!list) return;
const t = sheetAiAppState.tabs[sheetAiAppState.activeTab] ? sheetAiAppState.activeTab : (sheetAiAppState.lastDataTab || 'LR');
const tab = sheetAiAppState.tabs[t];
if (!tab) return;
const specificSegments = tab.specificSegments || [];
const commonSegments = sheetAiAppState.commonStepSegments || [];
const specificCount = specificSegments.length;
const isLRTab = t === 'LR';
let html = '';
// 전용 세그먼트 (LR/FB 각각 고유한 외곽 형태)
specificSegments.forEach((seg, i) => {
const isLastSpecific = i === specificSegments.length - 1 && commonSegments.length === 0;
html += `
<div class="bg-slate-950/50 p-4 rounded-2xl border border-blue-500/20 group/item hover:border-blue-500/30 transition-all">
<div class="flex items-center justify-between mb-2">
<span class="text-[9px] font-black px-2 py-0.5 rounded bg-blue-900/50 text-blue-400 uppercase">${t} 전용 #${i+1}</span>
</div>
<div class="relative">
<input type="number" value="${seg.length}" onchange="updateSegment('${seg.id}', 'length', this.value)" class="w-full bg-slate-900 border border-slate-800 rounded-xl px-4 py-3 font-black text-blue-400 outline-none focus:border-blue-500/50 transition-all text-sm" />
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-slate-600">mm</span>
</div>
</div>
`;
// 절곡점 (전용 세그먼트 사이, 또는 전용→공통 연결부)
const hasNext = i < specificSegments.length - 1 || commonSegments.length > 0;
if (hasNext) {
html += `
<div class="relative py-2 px-10">
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-slate-800 -translate-x-1/2 z-0"></div>
<div class="relative z-10 bg-slate-900/80 backdrop-blur-sm border border-slate-700/50 rounded-2xl p-4 shadow-xl">
<div class="flex items-center justify-between mb-3">
<span class="text-[8px] font-black text-slate-500 uppercase tracking-tighter">절곡점 #${i+1}</span>
<label class="flex items-center gap-2 cursor-pointer bg-slate-950 px-2 py-1 rounded-lg border border-slate-800">
<input type="checkbox" ${seg.isVcut ? 'checked' : ''} onchange="updateSegment('${seg.id}', 'isVcut', this.checked)" class="w-3 h-3 rounded border-slate-700 bg-slate-900 text-pink-500 focus:ring-pink-500/20">
<span class="text-[9px] font-black ${seg.isVcut ? 'text-pink-500' : 'text-slate-600'} uppercase">V-CUT</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-[8px] font-bold text-slate-600 uppercase">각도</span>
<input type="number" value="${seg.angle}" onchange="updateSegment('${seg.id}', 'angle', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded-lg pl-9 pr-2 py-1.5 font-black text-white text-xs outline-none focus:border-blue-500/50" />
</div>
<select onchange="updateSegment('${seg.id}', 'direction', this.value)" class="bg-slate-950 border border-slate-800 rounded-lg px-2 py-1.5 font-black text-white text-[10px] outline-none">
<option value="CW" ${seg.direction === 'CW' ? 'selected' : ''}>바깥 (CW)</option>
<option value="CCW" ${seg.direction === 'CCW' ? 'selected' : ''}>안쪽 (CCW)</option>
</select>
</div>
</div>
</div>
`;
}
});
// 공통 계단 세그먼트 구분선
if (commonSegments.length > 0) {
html += `
<div class="my-4 flex items-center gap-2">
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-emerald-500/50 to-transparent"></div>
<span class="text-[9px] font-black text-emerald-400 uppercase px-2">공통 계단 (좌우/앞뒤 동일)${!isLRTab ? ' - 좌우 탭에서 편집' : ''}</span>
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-emerald-500/50 to-transparent"></div>
</div>
`;
}
// 공통 계단 세그먼트 (밑면 이후)
commonSegments.forEach((seg, i) => {
const segId = `${t}_step_${i + 1}`;
const isLast = i === commonSegments.length - 1;
const readOnly = !isLRTab;
html += `
<div class="bg-slate-950/50 p-4 rounded-2xl border ${readOnly ? 'border-slate-700/30 opacity-70' : 'border-emerald-500/20 group/item hover:border-emerald-500/30'} transition-all">
<div class="flex items-center justify-between mb-2">
<span class="text-[9px] font-black px-2 py-0.5 rounded ${readOnly ? 'bg-slate-800 text-slate-500' : 'bg-emerald-900/50 text-emerald-400'} uppercase">공통 #${i+1}${readOnly ? ' (읽기전용)' : ''}</span>
${isLRTab ? `<button onclick="removeSegment('${segId}')" class="text-slate-600 hover:text-red-500 transition-colors opacity-0 group-hover/item:opacity-100 p-1">
<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="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>` : ''}
</div>
<div class="relative">
<input type="number" value="${seg.length}" ${readOnly ? 'readonly' : `onchange="updateSegment('${segId}', 'length', this.value)"`} class="w-full bg-slate-900 border border-slate-800 rounded-xl px-4 py-3 font-black ${readOnly ? 'text-slate-500' : 'text-emerald-400'} outline-none focus:border-emerald-500/50 transition-all text-sm ${readOnly ? 'cursor-not-allowed' : ''}" />
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-slate-600">mm</span>
</div>
</div>
`;
// 절곡점 (공통 세그먼트 사이)
if (!isLast) {
html += `
<div class="relative py-2 px-10">
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-slate-800 -translate-x-1/2 z-0"></div>
<div class="relative z-10 bg-slate-900/80 backdrop-blur-sm border ${readOnly ? 'border-slate-700/30 opacity-70' : 'border-slate-700/50'} rounded-2xl p-4 shadow-xl">
<div class="flex items-center justify-between mb-3">
<span class="text-[8px] font-black text-slate-500 uppercase tracking-tighter">절곡점 #${specificCount + i + 1}</span>
<label class="flex items-center gap-2 ${readOnly ? '' : 'cursor-pointer'} bg-slate-950 px-2 py-1 rounded-lg border border-slate-800">
<input type="checkbox" ${seg.isVcut ? 'checked' : ''} ${readOnly ? 'disabled' : `onchange="updateSegment('${segId}', 'isVcut', this.checked)"`} class="w-3 h-3 rounded border-slate-700 bg-slate-900 text-pink-500 focus:ring-pink-500/20 ${readOnly ? 'cursor-not-allowed' : ''}">
<span class="text-[9px] font-black ${seg.isVcut ? 'text-pink-500' : 'text-slate-600'} uppercase">V-CUT</span>
</label>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-[8px] font-bold text-slate-600 uppercase">각도</span>
<input type="number" value="${seg.angle}" ${readOnly ? 'readonly' : `onchange="updateSegment('${segId}', 'angle', this.value)"`} class="w-full bg-slate-950 border border-slate-800 rounded-lg pl-9 pr-2 py-1.5 font-black ${readOnly ? 'text-slate-500' : 'text-white'} text-xs outline-none focus:border-blue-500/50 ${readOnly ? 'cursor-not-allowed' : ''}" />
</div>
<select ${readOnly ? 'disabled' : `onchange="updateSegment('${segId}', 'direction', this.value)"`} class="bg-slate-950 border border-slate-800 rounded-lg px-2 py-1.5 font-black ${readOnly ? 'text-slate-500' : 'text-white'} text-[10px] outline-none ${readOnly ? 'cursor-not-allowed' : ''}">
<option value="CW" ${seg.direction === 'CW' ? 'selected' : ''}>바깥 (CW)</option>
<option value="CCW" ${seg.direction === 'CCW' ? 'selected' : ''}>안쪽 (CCW)</option>
</select>
</div>
</div>
</div>
`;
}
});
list.innerHTML = html;
}
function renderProfile() {
// 3D 탭일 때는 LR 데이터 사용
const getSegmentsForTab = (tab) => {
const tabData = sheetAiAppState.tabs[tab];
if (!tabData) return [];
const specific = tabData.specificSegments || [];
const common = sheetAiAppState.commonStepSegments.map((seg, i) => ({
...seg,
id: `${tab}_step_${i + 1}`
}));
return [...specific, ...common];
};
const segments = (sheetAiAppState.activeTab === '3D') ? getSegmentsForTab('LR') : sheetAiAppState.segments;
let curX = 0, curY = 0, curDirAngle = 180;
const pts = [{ x: 0, y: 0, length: 0, angle: 0 }];
segments.forEach((seg, i) => {
const rad = (curDirAngle * Math.PI) / 180;
const len = Number(seg.length);
const ang = Number(seg.angle);
curX += len * Math.cos(rad);
curY += len * Math.sin(rad);
pts.push({ x: curX, y: curY, isVcut: !!seg.isVcut, length: len, angle: ang });
if (i < segments.length - 1) {
const currentJointAngle = Number(segments[i].angle);
const turnAngle = 180 - currentJointAngle;
const multiplier = segments[i].direction === 'CW' ? 1 : -1;
curDirAngle += turnAngle * multiplier;
}
});
const minX = Math.min(...pts.map(p => p.x));
const maxX = Math.max(...pts.map(p => p.x));
const minY = Math.min(...pts.map(p => p.y));
const maxY = Math.max(...pts.map(p => p.y));
const paddingX = 35;
const paddingTop = 55; // 상단 여백 (치수선용)
const paddingBottom = 35;
const viewBox = `${minX - paddingX} ${minY - paddingTop} ${maxX - minX + paddingX * 2} ${maxY - minY + paddingTop + paddingBottom}`;
// 글로벌 설정에서 치수 가져옴
const gs = sheetAiAppState.globalSettings || {};
const heightValue = gs.height || 150;
const topWing = gs.topWing || 30;
const popnutDistance = gs.popnutDistance || 75;
// 높이 구간 찾기: 위면 날개 끝점(pts[1])부터 밑면 시작 전(pts[3])까지
// LR: pts[0]=시작, pts[1]=위면날개끝, pts[2]=사선끝, pts[3]=수직부끝(밑면시작), pts[4]=밑면끝
// FB: pts[0]=시작, pts[1]=위면날개끝, pts[2]=높이끝(밑면시작)
const heightStartPt = pts[1] || pts[0]; // 위면 날개 끝점
const heightEndPt = pts[3] || pts[2] || pts[1]; // 밑면 시작점 (LR: pts[3], FB: pts[2])
// 높이 치수선의 x 위치 (가장 왼쪽 점 기준)
const heightLineX = Math.min(heightStartPt.x, heightEndPt.x, minX) - 20;
// 팝너트 거리 치수선 계산
// 기준점: 밑면 좌측 시작점 (pts[3] = 100의 왼쪽 끝)
// 목표점: 윗면 날개 중심 (pts[0]에서 topWing/2 위치)
// LR: pts[0]=시작, pts[1]=위면날개끝, pts[2]=사선끝, pts[3]=수직부끝(밑면시작), pts[4]=밑면끝
const bottomLeftPt = pts[3] || pts[2]; // 밑면 시작점 (100의 왼쪽 끝)
const wingCenterX = pts[0].x - topWing / 2; // 윗면 날개 중심 x좌표
const wingCenterY = pts[0].y; // 윗면 날개 y좌표
const popnutLineY = Math.min(minY) - 25; // 팝너트 치수선 y위치 (상단)
const svgContent = `
<svg viewBox="${viewBox}" class="w-full h-full">
<polyline points="${pts.map(p => `${p.x},${p.y}`).join(' ')}" fill="none" stroke="white" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
<!-- 높이 치수선 (위면 날개 끝 ~ 밑면 시작) -->
<g class="height-dimension">
<!-- 상단 연장선 (위면 날개 끝점에서 치수선까지) -->
<line x1="${heightStartPt.x}" y1="${heightStartPt.y}" x2="${heightLineX - 5}" y2="${heightStartPt.y}" stroke="#f59e0b" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<!-- 하단 연장선 (밑면 시작점에서 치수선까지) -->
<line x1="${heightEndPt.x}" y1="${heightEndPt.y}" x2="${heightLineX - 5}" y2="${heightEndPt.y}" stroke="#f59e0b" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<!-- 수직 치수선 -->
<line x1="${heightLineX}" y1="${heightStartPt.y}" x2="${heightLineX}" y2="${heightEndPt.y}" stroke="#f59e0b" stroke-width="1.5" />
<!-- 상단 화살표 -->
<polygon points="${heightLineX},${heightStartPt.y} ${heightLineX-3},${heightStartPt.y+6} ${heightLineX+3},${heightStartPt.y+6}" fill="#f59e0b" />
<!-- 하단 화살표 -->
<polygon points="${heightLineX},${heightEndPt.y} ${heightLineX-3},${heightEndPt.y-6} ${heightLineX+3},${heightEndPt.y-6}" fill="#f59e0b" />
<!-- 높이 수치 (세로 텍스트) -->
<text x="${heightLineX - 8}" y="${(heightStartPt.y + heightEndPt.y) / 2}" fill="#f59e0b" font-size="13" font-weight="900" text-anchor="middle" transform="rotate(-90, ${heightLineX - 8}, ${(heightStartPt.y + heightEndPt.y) / 2})" class="select-none">${heightValue}</text>
</g>
<!-- 팝너트 거리 치수선 (밑면 좌측 끝점 ~ 윗면 날개 중심) - LR 탭에서만 표시 -->
${sheetAiAppState.activeTab !== 'FB' ? `
<g class="popnut-dimension">
<!-- 밑면 끝점에서 위로 연장선 -->
<line x1="${bottomLeftPt.x}" y1="${bottomLeftPt.y}" x2="${bottomLeftPt.x}" y2="${popnutLineY + 5}" stroke="#a855f7" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<!-- 날개 중심에서 위로 연장선 -->
<line x1="${wingCenterX}" y1="${wingCenterY}" x2="${wingCenterX}" y2="${popnutLineY + 5}" stroke="#a855f7" stroke-width="0.5" stroke-dasharray="2,2" opacity="0.5" />
<!-- 수평 치수선 -->
<line x1="${bottomLeftPt.x}" y1="${popnutLineY}" x2="${wingCenterX}" y2="${popnutLineY}" stroke="#a855f7" stroke-width="1.5" />
<!-- 좌측 화살표 -->
<polygon points="${bottomLeftPt.x},${popnutLineY} ${bottomLeftPt.x + 6},${popnutLineY - 3} ${bottomLeftPt.x + 6},${popnutLineY + 3}" fill="#a855f7" />
<!-- 우측 화살표 -->
<polygon points="${wingCenterX},${popnutLineY} ${wingCenterX - 6},${popnutLineY - 3} ${wingCenterX - 6},${popnutLineY + 3}" fill="#a855f7" />
<!-- 팝너트 거리 수치 -->
<text x="${(bottomLeftPt.x + wingCenterX) / 2}" y="${popnutLineY - 6}" fill="#a855f7" font-size="12" font-weight="900" text-anchor="middle" class="select-none">${popnutDistance}</text>
<!-- 팝너트 위치 마커 (날개 중심) -->
<circle cx="${wingCenterX}" cy="${wingCenterY}" r="3" fill="#a855f7" opacity="0.8" />
</g>
` : ''}
${(() => {
// 먼저 모든 치수 위치를 계산
const dimPositions = [];
for (let i = 1; i < pts.length; i++) {
const p = pts[i];
const prev = pts[i-1];
const dx = p.x - prev.x;
const dy = p.y - prev.y;
const isVertical = Math.abs(dy) > Math.abs(dx);
const isGoingUp = dy < 0;
let tx = (p.x + prev.x) / 2;
let ty = (p.y + prev.y) / 2;
let anchor = "middle";
let side = "default"; // 기본 배치 방향
if (isVertical) {
if (isGoingUp) {
tx -= 10;
anchor = "end";
side = "left";
} else {
tx += 10;
anchor = "start";
side = "right";
}
} else {
ty -= 8;
side = "top";
}
dimPositions.push({ i, p, prev, tx, ty, anchor, side, isVertical, dx, dy });
}
// 겹침 감지 및 조정 (인접한 치수끼리 비교)
for (let j = 0; j < dimPositions.length; j++) {
const curr = dimPositions[j];
// 이전 치수와 겹치는지 확인
if (j > 0) {
const prevDim = dimPositions[j - 1];
const distX = Math.abs(curr.tx - prevDim.tx);
const distY = Math.abs(curr.ty - prevDim.ty);
// 겹침 조건: 가까운 거리 (15px 이내)
if (distX < 15 && distY < 15) {
// 수직 선분이면 반대쪽으로 이동
if (curr.isVertical) {
if (curr.side === "left") {
curr.tx = (curr.p.x + curr.prev.x) / 2 + 10;
curr.anchor = "start";
curr.side = "right";
} else {
curr.tx = (curr.p.x + curr.prev.x) / 2 - 10;
curr.anchor = "end";
curr.side = "left";
}
} else {
// 수평 선분이면 아래로 이동
curr.ty = (curr.p.y + curr.prev.y) / 2 + 12;
curr.side = "bottom";
}
}
}
}
// SVG 생성
return dimPositions.map(({ i, p, prev, tx, ty, anchor }) => {
const isTerminal = (i === 0 || i === pts.length - 1);
const showMarker = !isTerminal && p.isVcut;
return `
<g>
<text x="${tx}" y="${ty}" fill="#cbd5e1" font-size="11" font-weight="900" text-anchor="${anchor}" class="select-none overflow-visible">${p.length}</text>
${i < pts.length - 1 && Number(p.angle) !== 90 ? `<text x="${p.x + 12}" y="${p.y + 12}" fill="#4ade80" font-size="12" font-weight="bold" class="select-none">${p.angle}°</text>` : ''}
${showMarker ? `<circle cx="${p.x}" cy="${p.y}" r="6" fill="none" stroke="#ef4444" stroke-width="1" />` : ''}
</g>`;
}).join('');
})()}
</svg>
`;
const container = $('profileSvgContainer');
if (container) container.innerHTML = svgContent;
}
function getUnfoldedData(tab) {
// 새로운 구조에서 세그먼트 가져오기 (specificSegments + commonStepSegments)
let segments;
if (tab && sheetAiAppState.tabs[tab]) {
const tabData = sheetAiAppState.tabs[tab];
const specific = tabData.specificSegments || [];
const common = (sheetAiAppState.commonStepSegments || []).map((seg, i) => ({
...seg,
id: `${tab}_step_${i + 1}`
}));
segments = [...specific, ...common];
} else {
segments = sheetAiAppState.segments || [];
}
// undefined/null 요소 제거 및 빈 배열 체크
segments = segments.filter(seg => seg && typeof seg === 'object');
if (segments.length === 0) {
return { items: [], finalHeight: 0, jointDeductions: [] };
}
const thickness = sheetAiAppState.config?.thickness || sheetAiAppState.globalSettings?.thickness || 1.2;
const jointDeductions = segments.map(seg => {
// 연신율(Elongation) 산정 원칙:
// 90도 절곡 기준 V-CUT 없으면 한쪽당 T(총 2T) 차감, V-CUT 있으면 한쪽당 T/2(총 T) 차감
const factor = seg.isVcut ? thickness : (thickness * 2);
return (Math.abs(180 - Number(seg.angle)) / 90) * factor;
});
let currentY = 0;
const items = segments.map((seg, idx) => {
const startDed = idx === 0 ? 0 : jointDeductions[idx - 1] / 2;
const endDed = idx === segments.length - 1 ? 0 : jointDeductions[idx] / 2;
const unfoldedLen = Number(seg.length) - startDed - endDed;
const prevY = currentY;
currentY += unfoldedLen;
return { ...seg, idx, unfoldedLength: unfoldedLen, yStart: prevY, yEnd: currentY, deduction: endDed };
});
return { items, finalHeight: currentY, jointDeductions };
}
function renderPreview(options = {}) {
const { includeSectionView = false } = options; // DXF 저장 시에만 true
const { items, finalHeight, jointDeductions } = getUnfoldedData();
// items가 없으면 빈 화면 표시
if (!items || items.length === 0) {
const container = $('unfoldedDrawingContainer');
if (container) container.innerHTML = '<div class="flex items-center justify-center h-full text-slate-500">데이터가 없습니다</div>';
return;
}
const { sheetWidth, leftWing, rightWing, wingStartIndex, centerSlot, bottomCenterWidth } = sheetAiAppState.config;
const notchIndex = Math.min(wingStartIndex, items.length - 1);
const notchY = items[notchIndex]?.yEnd || 0;
const bodyY = notchIndex > 0 ? (items[notchIndex - 1]?.yEnd || 0) : 0;
const pts = [];
// FB 상단 따임 동적 계산 (globalSettings 연동)
const gs = sheetAiAppState.globalSettings;
// FB 세그먼트 가져오기 (specificSegments + commonStepSegments)
const fbSpecific = sheetAiAppState.tabs.FB?.specificSegments || [];
const fbCommon = (sheetAiAppState.commonStepSegments || []).map((seg, i) => ({...seg, id: `FB_step_${i+1}`}));
const fbSegments = [...fbSpecific, ...fbCommon];
const topWingLength = fbSegments[0]?.length || 30;
const tolerance = 0.6;
const topNotchDepth = topWingLength + tolerance; // 따임 깊이 (30.6)
const topNotchHeight = items[0]?.yEnd || 0; // 따임 높이 (28.8)
const slantHorizontal = gs.slantHorizontal || (gs.popnutDistance - gs.topWing / 2) || 60; // globalSettings 연동
const totalTopInset = slantHorizontal + topNotchDepth; // 상단 전체 inset (90)
const slantStartInset = slantHorizontal; // 사선 시작 inset (60)
const fbSlantHeight = gs.height - gs.firstStepHeight; // 전체높이 - 1단높이 = 80
// LR 및 FB 프레임 외곽선 로직 (통합 지그재그)
if (sheetAiAppState.activeTab === 'FB') {
const y1 = topNotchHeight;
const ySlant = y1 + fbSlantHeight; // 동적 사선 끝점
pts.push({ x: totalTopInset, y: 0 });
pts.push({ x: totalTopInset, y: y1 });
pts.push({ x: slantStartInset, y: y1 });
pts.push({ x: 0, y: ySlant });
pts.push({ x: 0, y: bodyY });
} else {
pts.push({ x: 0, y: 0 });
pts.push({ x: 0, y: bodyY });
}
// 마지막 세그먼트(노치 분리) 높이 계산
const lastSegmentYStart = items[items.length - 1]?.yStart || finalHeight;
const lastItem = items[items.length - 1];
const lastSegH = lastItem?.unfoldedLength || 0;
// 마지막 날개값 18.2mm 추가 - ㄱ자 따임 확장 (치수선 13.8 일치)
const lastWingExtra = 18.2;
const notchWidth = lastSegH + lastWingExtra;
// 마지막 사선 세그먼트 찾기 (짝수 relIdx 중 마지막)
let lastDiagIdx = -1;
for (let i = notchIndex + 1; i < items.length - 1; i++) {
const relIdx = i - notchIndex;
if (relIdx % 2 === 0) lastDiagIdx = i;
}
// === 좌측 외곽선 ===
let currentLX = leftWing;
// 첫 번째 사선 (bodyY → notchY)
pts.push({ x: currentLX, y: notchY });
// 중간 계단 세그먼트들
for (let i = notchIndex + 1; i < items.length - 1; i++) {
const relIdx = i - notchIndex;
const segmentH = items[i].unfoldedLength;
const isLastDiag = (i === lastDiagIdx);
if (relIdx % 2 === 1) {
// 홀수: 수직 (x 유지, y 변경)
pts.push({ x: currentLX, y: items[i].yEnd });
} else if (isLastDiag) {
// 마지막 사선: 바깥쪽으로 (x 감소, y 증가) - 반대 방향
currentLX -= segmentH;
pts.push({ x: currentLX, y: items[i].yEnd });
} else {
// 짝수: 사선 (x 증가, y 증가) - 안쪽으로
currentLX += segmentH;
pts.push({ x: currentLX, y: items[i].yEnd });
}
}
// 마지막 세그먼트: 수직 → 수평(노치) → 수직
// 1. 수직으로 내려감 (lastSegmentYStart까지)
pts.push({ x: currentLX, y: lastSegmentYStart });
// 2. 수평 노치 (lastSegH + 15mm) - 안쪽으로
pts.push({ x: currentLX + notchWidth, y: lastSegmentYStart });
// 3. 수직으로 끝까지
pts.push({ x: currentLX + notchWidth, y: finalHeight });
const finalLX = currentLX + notchWidth;
// === 우측 외곽선 (좌측의 완전한 거울 대칭) ===
// 좌측 pts를 수집한 후 대칭 변환
// 좌측에서 수집한 점들: notchY부터 finalHeight까지
// 우측은 이것의 x좌표를 sheetWidth - x로 변환하고 역순으로 추가
// 좌측 하단 경로 점들 추출 (notchY 이후부터 finalHeight까지)
// pts 배열에서 좌측 하단 부분만 추출
const leftBottomStartIdx = pts.findIndex(p => Math.abs(p.y - notchY) < 0.1 && p.x === leftWing);
const leftBottomPts = [];
// 좌측 하단 점들 수집 (notchY부터 끝까지)
for (let i = leftBottomStartIdx; i < pts.length; i++) {
leftBottomPts.push(pts[i]);
}
// 우측 하단: 좌측을 대칭 변환하여 역순으로 추가
for (let i = leftBottomPts.length - 1; i >= 0; i--) {
const lp = leftBottomPts[i];
pts.push({ x: sheetWidth - lp.x, y: lp.y });
}
// 수직으로 올라감
if (sheetAiAppState.activeTab === 'FB') {
const y1 = topNotchHeight;
const ySlant = y1 + fbSlantHeight; // 동적 사선 끝점
pts.push({ x: sheetWidth, y: bodyY });
pts.push({ x: sheetWidth, y: ySlant });
pts.push({ x: sheetWidth - slantStartInset, y: y1 });
pts.push({ x: sheetWidth - totalTopInset, y: y1 }); // 동적 따임자리 표현
pts.push({ x: sheetWidth - totalTopInset, y: 0 });
} else {
pts.push({ x: sheetWidth, y: bodyY });
pts.push({ x: sheetWidth, y: 0 });
}
const outerPath = pts.map(p => `${p.x},${p.y}`).join(' ');
const svg = `
<svg preserveAspectRatio="xMidYMin meet" viewBox="-150 -550 ${sheetWidth + 1200} ${finalHeight + 650}" class="w-full h-full drop-shadow-[0_0_30px_rgba(59,130,246,0.1)] transition-transform duration-200" style="transform: translate(${sheetAiAppState.view.offset.x}px, ${sheetAiAppState.view.offset.y}px) scale(${sheetAiAppState.view.scale})">
<text x="${sheetWidth/2}" y="-480" fill="#3b82f6" font-size="56" font-weight="900" text-anchor="middle" font-family="Pretendard">${sheetAiAppState.activeTab === 'LR' ? '좌우 프레임' : '앞뒤 프레임'}</text>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#10b981" />
</marker>
<marker id="arrowhead-reverse" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="10 0, 0 3.5, 10 7" fill="#10b981" />
</marker>
</defs>
<!-- Width Dimension (Furthest out - Level 3) -->
<g transform="translate(0, ${-320})">
<line x1="0" y1="40" x2="0" y2="-20" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${sheetWidth}" y1="40" x2="${sheetWidth}" y2="-20" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="0" y1="0" x2="${sheetWidth}" y2="0" stroke="#10b981" stroke-width="2.5" />
<line x1="-5" y1="5" x2="5" y2="-5" stroke="#10b981" stroke-width="1.5" transform="translate(0,0)" />
<line x1="${sheetWidth-5}" y1="5" x2="${sheetWidth+5}" y2="-5" stroke="#10b981" stroke-width="1.5" />
<text x="${sheetWidth/2}" y="-25" fill="#10b981" font-size="32" font-weight="900" text-anchor="middle" font-family="Pretendard">${sheetWidth}</text>
</g>
<polygon points="${outerPath}" fill="none" stroke="white" stroke-width="2" stroke-linejoin="miter" />
<!-- Bend Lines -->
${items.map((item, i) => {
if (i === items.length - 1) return '';
const y = item.yEnd;
// 안쪽의 점에서 시작 원칙: 전후 인셋 중 더 큰 값을 선택 (더 안쪽)
const insetPrev = getInsetAtY(sheetAiAppState.activeTab, y - 0.1);
const insetNext = getInsetAtY(sheetAiAppState.activeTab, y + 0.1);
const sx = Math.max(insetPrev, insetNext);
const ex = sheetWidth - sx;
const bendStroke = item.direction === 'CW' ? '#3b82f6' : '#ef4444';
return `<line x1="${sx}" y1="${y}" x2="${ex}" y2="${y}" stroke="${bendStroke}" stroke-width="1.5" stroke-dasharray="6,4" />`;
}).join('')}
${(() => {
let res = '';
const vXOffset = sheetWidth + 800;
// 실제 외곽선 Vertex에 기반한 치수선 생성
items.forEach((item, i) => {
const x1 = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, item.yStart + 0.1);
const x2 = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, item.yEnd - 0.1);
if (sheetAiAppState.activeTab === 'FB' && i === 1) {
// FB 분기: 사선 구간과 수직 구간 분할 표시 (동적 계산)
const gs = sheetAiAppState.globalSettings;
const fbSlantH = gs.height - gs.firstStepHeight; // 전체높이 - 1단높이
const yS = item.yStart + fbSlantH;
const xS = sheetWidth - getInsetAtY(sheetAiAppState.activeTab, yS);
res += `
<g>
<line x1="${x1 + 10}" y1="${item.yStart}" x2="${vXOffset + 20}" y2="${item.yStart}" stroke="#10b981" stroke-width="0.5" opacity="0.2" />
<line x1="${xS + 10}" y1="${yS}" x2="${vXOffset + 20}" y2="${yS}" stroke="#10b981" stroke-width="0.5" opacity="0.2" />
<line x1="${vXOffset}" y1="${item.yStart}" x2="${vXOffset}" y2="${yS}" stroke="#10b981" stroke-width="2" />
<text x="${vXOffset + 30}" y="${(item.yStart + yS) / 2}" fill="#10b981" font-size="24" font-weight="900" alignment-baseline="middle" font-family="Pretendard">${fbSlantH.toFixed(1)}</text>
<line x1="${vXOffset}" y1="${yS}" x2="${vXOffset}" y2="${item.yEnd}" stroke="#10b981" stroke-width="2" />
<text x="${vXOffset + 30}" y="${(yS + item.yEnd) / 2}" fill="#10b981" font-size="24" font-weight="900" alignment-baseline="middle" font-family="Pretendard">${(item.unfoldedLength - fbSlantH).toFixed(1)}</text>
</g>
`;
} else {
res += `
<g>
<line x1="${x1 + 10}" y1="${item.yStart}" x2="${vXOffset + 20}" y2="${item.yStart}" stroke="#10b981" stroke-width="0.5" opacity="0.2" />
${i === items.length - 1 ? `<line x1="${x2 + 10}" y1="${item.yEnd}" x2="${vXOffset + 20}" y2="${item.yEnd}" stroke="#10b981" stroke-width="0.5" opacity="0.2" />` : ''}
<line x1="${vXOffset}" y1="${item.yStart}" x2="${vXOffset}" y2="${item.yEnd}" stroke="#10b981" stroke-width="2" />
<text x="${vXOffset + 30}" y="${(item.yStart + item.yEnd) / 2}" fill="#10b981" font-size="24" font-weight="900" alignment-baseline="middle" font-family="Pretendard">${Number(item.unfoldedLength.toFixed(1))}</text>
</g>
`;
}
});
return res;
})()}
<!-- FB Top Notch Dimensions (Preview - 동적 계산) -->
${sheetAiAppState.activeTab === 'FB' ? `
<g transform="translate(0, -80)">
<line x1="${totalTopInset}" y1="20" x2="${totalTopInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${slantStartInset}" y1="20" x2="${slantStartInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${totalTopInset}" y1="0" x2="${slantStartInset}" y2="0" stroke="#10b981" stroke-width="1.5" />
<text x="${(totalTopInset + slantStartInset) / 2}" y="-15" fill="#10b981" font-size="26" font-weight="900" text-anchor="middle">${topNotchDepth.toFixed(1)}</text>
</g>
<g transform="translate(0, -160)">
<line x1="${slantStartInset}" y1="20" x2="${slantStartInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="0" y1="20" x2="0" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${slantStartInset}" y1="0" x2="0" y2="0" stroke="#10b981" stroke-width="1.5" />
<text x="${slantStartInset / 2}" y="-15" fill="#10b981" font-size="26" font-weight="900" text-anchor="middle">${slantStartInset.toFixed(1)}</text>
</g>
<g transform="translate(0, -240)">
<line x1="0" y1="20" x2="0" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${totalTopInset}" y1="20" x2="${totalTopInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="0" y1="0" x2="${totalTopInset}" y2="0" stroke="#10b981" stroke-width="1.5" />
<text x="${totalTopInset / 2}" y="-15" fill="#10b981" font-size="26" font-weight="900" text-anchor="middle">${totalTopInset.toFixed(1)}</text>
</g>
<g transform="translate(${sheetWidth}, -80)">
<line x1="${-totalTopInset}" y1="20" x2="${-totalTopInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${-slantStartInset}" y1="20" x2="${-slantStartInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${-totalTopInset}" y1="0" x2="${-slantStartInset}" y2="0" stroke="#10b981" stroke-width="1.5" />
<text x="${-(totalTopInset + slantStartInset) / 2}" y="-15" fill="#10b981" font-size="26" font-weight="900" text-anchor="middle">${topNotchDepth.toFixed(1)}</text>
</g>
<g transform="translate(${sheetWidth}, -160)">
<line x1="${-slantStartInset}" y1="20" x2="${-slantStartInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="0" y1="20" x2="0" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${-slantStartInset}" y1="0" x2="0" y2="0" stroke="#10b981" stroke-width="1.5" />
<text x="${-slantStartInset / 2}" y="-15" fill="#10b981" font-size="26" font-weight="900" text-anchor="middle">${slantStartInset.toFixed(1)}</text>
</g>
<g transform="translate(${sheetWidth}, -240)">
<line x1="0" y1="20" x2="0" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="${-totalTopInset}" y1="20" x2="${-totalTopInset}" y2="-40" stroke="#10b981" stroke-width="1" opacity="0.3" />
<line x1="0" y1="0" x2="${-totalTopInset}" y2="0" stroke="#10b981" stroke-width="1.5" />
<text x="${-totalTopInset / 2}" y="-15" fill="#10b981" font-size="26" font-weight="900" text-anchor="middle">${totalTopInset.toFixed(1)}</text>
</g>
` : ''}
<!-- Total Height (Far Right) -->
<g transform="translate(${sheetWidth + 600}, ${finalHeight/2})">
<line x1="0" y1="${-finalHeight/2}" x2="0" y2="${finalHeight/2}" stroke="#10b981" stroke-width="4" />
<line x1="-15" y1="${-finalHeight/2 + 15}" x2="15" y2="${-finalHeight/2 - 15}" stroke="#10b981" stroke-width="2" />
<line x1="-15" y1="${finalHeight/2 + 15}" x2="15" y2="${finalHeight/2 - 15}" stroke="#10b981" stroke-width="2" />
<text x="40" y="0" fill="#10b981" font-size="48" font-weight="900" alignment-baseline="middle" font-family="Pretendard" transform="rotate(270, 40, 0)">TOTAL HEIGHT: ${finalHeight.toFixed(2)}</text>
</g>
<!-- Bottom Detailed Dimensions (Part-by-Part - Stepped) -->
<g>
${(() => {
// LR 및 FB 모두 하단 상세 치수 표시 (지그재그 로직 공통)
let res = '';
let bX = 0;
let lv = 0;
const dimLineFull = (x1, x2, y1, y2, val, level) => {
const dimY = finalHeight + 110 + (level % 3) * 75;
return `
<line x1="${x1}" y1="${y1}" x2="${x1}" y2="${dimY + 20}" stroke="#10b981" stroke-width="0.8" opacity="0.2" />
<line x1="${x2}" y1="${y2}" x2="${x2}" y2="${dimY + 20}" stroke="#10b981" stroke-width="0.8" opacity="0.2" />
<line x1="${x1}" y1="${dimY}" x2="${x2}" y2="${dimY}" stroke="#10b981" stroke-width="2" />
<line x1="${x1-5}" y1="${dimY+5}" x2="${x1+5}" y2="${dimY-5}" stroke="#10b981" stroke-width="1.5" />
<line x1="${x2-5}" y1="${dimY+5}" x2="${x2+5}" y2="${dimY-5}" stroke="#10b981" stroke-width="1.5" />
<text x="${(x1+x2)/2}" y="${dimY + 25}" fill="#10b981" font-size="18" font-weight="900" text-anchor="middle" font-family="Pretendard">${Number(val.toFixed(1))}</text>
`;
};
// Left Wing
res += dimLineFull(0, leftWing, bodyY, notchY, leftWing, lv++);
bX = leftWing;
// steps
for(let i = notchIndex + 1; i < items.length; i++) {
if((i-notchIndex)%2 === 0) {
const segH = items[i].unfoldedLength;
res += dimLineFull(bX, bX + segH, items[i].yStart, items[i].yEnd, segH, lv++);
bX += segH;
}
}
// Middle
let endX = sheetWidth - rightWing;
for(let i = notchIndex + 1; i < items.length; i++) {
if((i-notchIndex)%2 === 0) endX -= items[i].unfoldedLength;
}
res += dimLineFull(bX, endX, finalHeight, finalHeight, (endX - bX), lv++);
// Right steps
let curRX_S = endX;
for(let i = items.length - 1; i > notchIndex; i--) {
if((i-notchIndex)%2 === 0) {
const segH = items[i].unfoldedLength;
res += dimLineFull(curRX_S, curRX_S + segH, items[i].yEnd, items[i].yStart, segH, lv++);
curRX_S += segH;
}
}
// Final Wing
res += dimLineFull(sheetWidth - rightWing, sheetWidth, notchY, bodyY, rightWing, lv++);
return res;
})()}
</g>
<!-- 45-degree Angle Labels (SVG Decor) -->
${(() => {
const diags = items.map((_, idx) => idx).filter(i => (i > notchIndex) && ((i - notchIndex) % 2 === 0));
if (diags.length === 0) return '';
const ltI = diags[0];
let lx = leftWing;
for(let j = notchIndex + 1; j < ltI; j++) { if((j-notchIndex)%2 === 0) lx += items[j].unfoldedLength; }
const lcx = lx, lcy = items[ltI].yStart;
const rbI = diags[diags.length - 1];
let rx = sheetWidth - rightWing;
for(let j = notchIndex + 1; j < rbI; j++) { if((j-notchIndex)%2 === 0) rx -= items[j].unfoldedLength; }
const rcx = rx, rcy = items[rbI].yStart;
const drawArcDim = (cx, cy, ang, flip = false) => {
const rad = 45;
const startX = flip ? cx - rad : cx;
const startY = flip ? cy : cy + rad;
const targetX = cx + rad * Math.sin(ang * Math.PI/180) * (flip ? -1 : 1);
const targetY = cy + rad * Math.cos(ang * Math.PI/180);
return `
<g opacity="0.9">
<line x1="${cx}" y1="${cy}" x2="${cx + (flip ? -rad - 20 : 0)}" y2="${cy + (flip ? 0 : rad + 20)}" stroke="#10b981" stroke-width="1" stroke-dasharray="2,2" />
<line x1="${cx}" y1="${cy}" x2="${cx + rad * 1.5 * Math.sin(ang * Math.PI/180) * (flip ? -1 : 1)}" y2="${cy + rad * 1.5 * Math.cos(ang * Math.PI/180)}" stroke="#10b981" stroke-width="1" stroke-dasharray="2,2" />
<path d="M ${startX} ${startY} A ${rad} ${rad} 0 0 ${flip ? 1 : 0} ${targetX} ${targetY}" fill="none" stroke="#10b981" stroke-width="1.5" marker-start="url(#arrowhead-reverse)" marker-end="url(#arrowhead)" />
<text x="${cx + (flip ? -rad - 15 : rad + 15)}" y="${cy + rad}" fill="#10b981" font-size="18" font-weight="900" font-family="Pretendard" text-anchor="${flip ? 'end' : 'start'}">${ang.toFixed(1)}°</text>
</g>
`;
};
return `
<g>
${/* 첫 번째 사선 각도 표시 (원래대로) */ ''}
${drawArcDim(0, bodyY, Math.abs(Math.atan2(leftWing, items[notchIndex].unfoldedLength) * 180 / Math.PI), false)}
${drawArcDim(sheetWidth, bodyY, Math.abs(Math.atan2(leftWing, items[notchIndex].unfoldedLength) * 180 / Math.PI), true)}
${sheetAiAppState.activeTab === 'FB' ? (() => {
const y1 = topNotchHeight;
// 동적 사선 각도 계산: LR 프레임 사선 각도와 동일
const gsSlant = sheetAiAppState.globalSettings;
const fbSlantH = gsSlant.height - gsSlant.firstStepHeight; // 전체높이 - 1단높이
const slantAngleRad = Math.atan2(fbSlantH, slantStartInset);
const ang = parseFloat((180 - slantAngleRad * 180 / Math.PI).toFixed(1)); // 절곡 각도
// 사선 길이 동적 계산: sqrt(slantStartInset^2 + fbSlantH^2)
const slantLength = Math.sqrt(slantStartInset * slantStartInset + fbSlantH * fbSlantH);
const drawBelowArc = (cx, cy, startAng, endAng, labelX, align) => {
const r = 45;
const sRad = startAng * Math.PI / 180;
const eRad = endAng * Math.PI / 180;
const sx = cx + r * Math.cos(sRad);
const sy = cy + r * Math.sin(sRad);
const ex = cx + r * Math.cos(eRad);
const ey = cy + r * Math.sin(eRad);
const sweep = endAng > startAng ? 1 : 0;
return `
<path d="M ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey}" fill="none" stroke="#10b981" stroke-width="1.5" marker-end="url(#arrowhead)" />
<text x="${cx + labelX}" y="${cy + 45}" fill="#10b981" font-size="20" font-weight="900" text-anchor="${align}">${ang}°</text>
<line x1="${cx}" y1="${cy}" x2="${cx + (startAng === 180 ? -r-20 : r+20)}" y2="${cy}" stroke="#10b981" stroke-width="1" stroke-dasharray="3,2" opacity="0.5" />
`;
};
const arcEnd = 180 - ang; // 호 끝 각도
return `
<g opacity="0.9">
${/* Left: 0 to ang (CW) */ drawBelowArc(slantStartInset, y1, 0, ang, 40, "start")}
${/* Right: 180 to (180-ang) (CCW) */ drawBelowArc(sheetWidth - slantStartInset, y1, 180, arcEnd, -40, "end")}
${/* Slant Aligned Dimension Labels */ ''}
<g transform="translate(${slantStartInset / 2 - 40}, ${(y1 + y1 + fbSlantH)/2}) rotate(${-(90 - slantAngleRad * 180 / Math.PI)})">
<text x="0" y="0" fill="#10b981" font-size="22" font-weight="900" text-anchor="middle">${slantLength.toFixed(1)}</text>
<line x1="-40" y1="10" x2="40" y2="10" stroke="#10b981" stroke-width="1.5" />
</g>
<g transform="translate(${sheetWidth - slantStartInset / 2 + 40}, ${(y1 + y1 + fbSlantH)/2}) rotate(${90 - slantAngleRad * 180 / Math.PI})">
<text x="0" y="0" fill="#10b981" font-size="22" font-weight="900" text-anchor="middle">${slantLength.toFixed(1)}</text>
<line x1="-40" y1="10" x2="40" y2="10" stroke="#10b981" stroke-width="1.5" />
</g>
</g>
`;
})() : ''}
${/* Step Diagonals */ ''}
${drawArcDim(lcx, lcy, Math.abs(Math.atan2(items[ltI].unfoldedLength, items[ltI].unfoldedLength) * 180 / Math.PI), false)}
${drawArcDim(rcx, rcy, Math.abs(Math.atan2(items[rbI].unfoldedLength, items[rbI].unfoldedLength) * 180 / Math.PI), true)}
</g>
`;
})()}
<!-- 꼭짓점 라벨 (V1, V2, ...) - 미리보기 전용 -->
${!includeSectionView ? (() => {
return `<g class="vertex-labels">` + pts.map((p, idx) => {
const labelOffset = 12;
const prev = pts[idx - 1] || pts[idx];
const next = pts[idx + 1] || pts[idx];
const inDx = p.x - prev.x;
const inDy = p.y - prev.y;
const outDx = next.x - p.x;
const outDy = next.y - p.y;
let nx = -(inDy + outDy) / 2;
let ny = (inDx + outDx) / 2;
const len = Math.sqrt(nx * nx + ny * ny) || 1;
nx = (nx / len) * labelOffset;
ny = (ny / len) * labelOffset;
const lx = p.x + nx;
const ly = p.y + ny;
return `<text x="${lx}" y="${ly}" fill="#ef4444" font-size="12" font-weight="bold" text-anchor="middle" alignment-baseline="middle" font-family="Pretendard">V${idx + 1}</text>`;
}).join('') + `</g>`;
})() : ''}
<!-- Section View (PIP Detail) - DXF 저장 시에만 표시 -->
${includeSectionView ? (() => {
const profilePts = [];
let pX = 0, pY = 0, pDir = 180;
profilePts.push({x: 0, y: 0});
items.forEach((item, i) => {
const rad = (pDir * Math.PI) / 180;
const len = Number(item.length);
pX += len * Math.cos(rad);
pY += len * Math.sin(rad);
profilePts.push({x: pX, y: pY, angle: item.angle, isVcut: item.isVcut});
if (i < items.length - 1) {
const turn = 180 - Number(items[i].angle);
const mult = items[i].direction === 'CW' ? 1 : -1;
pDir += turn * mult;
}
});
const pMinX = Math.min(...profilePts.map(p => p.x));
const pMaxX = Math.max(...profilePts.map(p => p.x));
const pMinY = Math.min(...profilePts.map(p => p.y));
const pMaxY = Math.max(...profilePts.map(p => p.y));
const pW = pMaxX - pMinX;
const pH = pMaxY - pMinY;
// Positioning: Move further right to avoid overlap with total height dimension
const detailX = sheetWidth + 800;
const detailY = -50;
const detailScale = 2.0;
return `
<g transform="translate(${detailX}, ${detailY})">
<!-- Background Glass effect - Adjusted for 2x scale -->
<rect x="${pMinX * detailScale - 60}" y="${pMinY * detailScale - 60}" width="${(pMaxX - pMinX) * detailScale + 120}" height="${(pMaxY - pMinY) * detailScale + 180}" fill="rgba(15, 23, 42, 0.8)" stroke="#334155" stroke-width="2" rx="20" />
<g transform="scale(${detailScale})">
<polyline points="${profilePts.map(p => `${p.x},${p.y}`).join(' ')}" fill="none" stroke="white" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
${profilePts.map((p, i) => {
if (i === 0) return '';
const prev = profilePts[i-1];
const dx = p.x - prev.x;
const dy = p.y - prev.y;
const isVertical = Math.abs(dy) > Math.abs(dx);
let tx = (p.x + prev.x) / 2;
let ty = (p.y + prev.y) / 2;
let anchor = "middle";
if (isVertical) {
tx += 8; // Offset to right for vertical lines
anchor = "start";
} else {
ty -= 8; // Offset to top for horizontal lines
}
return `
<g>
<text x="${tx}" y="${ty}" fill="#94a3b8" font-size="10" font-weight="900" text-anchor="${anchor}" font-family="Pretendard">${Number(Number(items[i-1].length).toFixed(1))}</text>
${i < profilePts.length - 1 && Number(p.angle) !== 90 ? `<text x="${p.x + 8}" y="${p.y + 8}" fill="#4ade80" font-size="10" font-weight="bold" font-family="Pretendard">${p.angle}°</text>` : ''}
${p.isVcut && i < profilePts.length - 1 ? `<circle cx="${p.x}" cy="${p.y}" r="6" fill="none" stroke="#ef4444" stroke-width="1.6" />` : ''}
</g>
`;
}).join('')}
</g>
<text x="${((pMinX + pMaxX)/2) * detailScale}" y="${(pMaxY * detailScale) + 80}" fill="#3b82f6" font-size="24" font-weight="black" text-anchor="middle" font-family="Pretendard">T=${sheetAiAppState.config.thickness}mm | SCALE 2:1</text>
</g>
`;
})() : ''}
</svg>
`;
const container = $('unfoldedDrawingContainer');
if (container) container.innerHTML = svg;
}
// --- HANDLERS (Exported to Window) ---
window.updateSegment = (id, field, value) => {
const stateObj = window.sheetAiAppState || sheetAiAppState;
const t = stateObj.tabs[stateObj.activeTab] ? stateObj.activeTab : (stateObj.lastDataTab || 'LR');
const tab = stateObj.tabs[t];
if (!tab) return;
// 전용 세그먼트에서 찾기
const specificIdx = tab.specificSegments.findIndex(s => s.id === id);
if (specificIdx !== -1) {
if (field === 'isVcut') {
tab.specificSegments[specificIdx].isVcut = !!value;
} else if (field === 'direction') {
tab.specificSegments[specificIdx].direction = value;
} else {
tab.specificSegments[specificIdx][field] = Number(value);
}
updateUI();
return;
}
// 공통 계단 세그먼트에서 찾기 (ID 패턴: {tab}_step_{n})
const commonIdx = stateObj.commonStepSegments.findIndex((s, i) => `${t}_step_${i + 1}` === id);
if (commonIdx !== -1) {
if (field === 'isVcut') {
stateObj.commonStepSegments[commonIdx].isVcut = !!value;
} else if (field === 'direction') {
stateObj.commonStepSegments[commonIdx].direction = value;
} else {
stateObj.commonStepSegments[commonIdx][field] = Number(value);
}
updateUI();
}
};
window.removeSegment = (id) => {
const stateObj = window.sheetAiAppState || sheetAiAppState;
const t = stateObj.tabs[stateObj.activeTab] ? stateObj.activeTab : (stateObj.lastDataTab || 'LR');
const tab = stateObj.tabs[t];
if (!tab) return;
// 전용 세그먼트에서 제거
const specificIdx = tab.specificSegments.findIndex(s => s.id === id);
if (specificIdx !== -1 && tab.specificSegments.length > 1) {
tab.specificSegments = tab.specificSegments.filter(s => s.id !== id);
updateUI();
return;
}
// 공통 계단 세그먼트에서 제거 (LR 탭에서만 가능)
if (t === 'LR') {
const commonIdx = stateObj.commonStepSegments.findIndex((s, i) => `${t}_step_${i + 1}` === id);
if (commonIdx !== -1 && stateObj.commonStepSegments.length > 0) {
stateObj.commonStepSegments.splice(commonIdx, 1);
updateUI();
}
}
};
const addBtn = $('addSegmentBtn');
if (addBtn) {
addBtn.onclick = () => {
const stateObj = window.sheetAiAppState;
const t = stateObj.tabs[stateObj.activeTab] ? stateObj.activeTab : (stateObj.lastDataTab || 'LR');
// 공통 계단 세그먼트에 추가 (LR 탭에서만)
if (t === 'LR') {
const lastCommon = stateObj.commonStepSegments[stateObj.commonStepSegments.length - 1];
const newDir = lastCommon ? (lastCommon.direction === 'CW' ? 'CCW' : 'CW') : 'CW';
stateObj.commonStepSegments.push({
id: `step_${stateObj.commonStepSegments.length + 1}`,
length: 20,
angle: 90,
direction: newDir,
isVcut: true
});
updateUI();
}
};
}
const wIn = $('sheetWidthInput'); if (wIn) wIn.onchange = e => { sheetAiAppState.config.sheetWidth = Number(e.target.value); updateUI(); };
const wwIn = $('wingWidthInput'); if (wwIn) wwIn.onchange = e => { sheetAiAppState.config.leftWing = Number(e.target.value); sheetAiAppState.config.rightWing = Number(e.target.value); updateUI(); };
const wsiIn = $('wingStartIndexSelect'); if (wsiIn) wsiIn.onchange = e => { sheetAiAppState.config.wingStartIndex = Number(e.target.value); updateUI(); };
// Zoom & Drag
const viewport = $('canvasViewport');
if (viewport) {
viewport.onmousedown = (e) => {
sheetAiAppState.view.isDragging = true;
sheetAiAppState.view.lastMousePos = { x: e.clientX, y: e.clientY };
};
viewport.onwheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
sheetAiAppState.view.scale = Math.max(0.1, Math.min(sheetAiAppState.view.scale + delta, 10));
updateUI();
};
}
window.onmousemove = (e) => {
if (!sheetAiAppState.view.isDragging) return;
const dx = e.clientX - sheetAiAppState.view.lastMousePos.x;
const dy = e.clientY - sheetAiAppState.view.lastMousePos.y;
sheetAiAppState.view.offset.x += dx;
sheetAiAppState.view.offset.y += dy;
sheetAiAppState.view.lastMousePos = { x: e.clientX, y: e.clientY };
renderPreview();
};
window.onmouseup = () => { sheetAiAppState.view.isDragging = false; };
const ziBtn = $('zoomInBtn'); if (ziBtn) ziBtn.onclick = () => { sheetAiAppState.view.scale = Math.min(sheetAiAppState.view.scale + 0.2, 10); updateUI(); };
const zoBtn = $('zoomOutBtn'); if (zoBtn) zoBtn.onclick = () => { sheetAiAppState.view.scale = Math.max(sheetAiAppState.view.scale - 0.2, 0.1); updateUI(); };
const zrBtn = $('zoomResetBtn'); if (zrBtn) zrBtn.onclick = () => { sheetAiAppState.view.scale = 1; sheetAiAppState.view.offset = { x: 0, y: 0 }; updateUI(); };
const dlBtn = $('downloadDxfBtn');
if (dlBtn) {
dlBtn.onclick = () => {
const dxf = generateDXF(sheetAiAppState.segments, sheetAiAppState.config);
const blob = new Blob([dxf], { type: 'application/dxf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Unfolded_Drawing_${new Date().getTime()}.dxf`;
a.click();
URL.revokeObjectURL(url);
};
}
// Initialize
switchTab('Settings');
</script>
@endpush