- 레거시 전개도 시뮬레이터를 MNG 환경으로 마이그레이션 - RdController에 autoDrawing 메서드 추가 (HX-Request 체크 포함) - 라우트: GET /rd/auto-drawing - R&D 대시보드에 자동도면 생성 카드 추가 - 레거시 PHP 코드 제거 (세션, API키, 서버기록 등) - Three.js 3D 렌더링, SVG 미리보기, DXF 도면 생성 기능 유지
4885 lines
299 KiB
PHP
4885 lines
299 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', '자동도면 생성')
|
||
|
||
@section('content')
|
||
<style>
|
||
body { font-family: 'Pretendard', sans-serif; }
|
||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||
|
||
.glass-panel {
|
||
background: rgba(15, 23, 42, 0.7);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.neon-border {
|
||
box-shadow: 0 0 15px rgba(59, 130, 246, 0.1);
|
||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||
}
|
||
|
||
@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">×</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
|