Compare commits
6 Commits
0187b234fb
...
654b8d6e08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
654b8d6e08 | ||
|
|
49b2d0940f | ||
|
|
761c42a637 | ||
|
|
0418dd84ee | ||
|
|
afda525be9 | ||
|
|
3e37b7448c |
408
docs/PDF 도면 외벽선 자동작성 - 코드베이스 현황 분석.html
Normal file
408
docs/PDF 도면 외벽선 자동작성 - 코드베이스 현황 분석.html
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>PDF 도면 → 외벽선 자동작성 · 코드베이스 현황 분석</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0e1117;
|
||||||
|
--panel: #161b22;
|
||||||
|
--panel-2: #1c232d;
|
||||||
|
--border: #2a323d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #8b949e;
|
||||||
|
--accent: #4493f8;
|
||||||
|
--green: #3fb950;
|
||||||
|
--amber: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--code-bg: #0b0f14;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1080px; margin: 0 auto; padding: 48px 24px 96px; }
|
||||||
|
header.hero {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 36px 36px 32px;
|
||||||
|
background: linear-gradient(135deg, #161b22 0%, #1a2330 100%);
|
||||||
|
margin-bottom: 36px;
|
||||||
|
}
|
||||||
|
.eyebrow { color: var(--accent); font-weight: 600; font-size: 13px; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
h1 { font-size: 30px; margin: 10px 0 6px; line-height: 1.25; }
|
||||||
|
.sub { color: var(--muted); font-size: 15px; margin: 0; }
|
||||||
|
.meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 22px; }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12.5px; padding: 5px 12px; border-radius: 999px;
|
||||||
|
border: 1px solid var(--border); background: var(--panel-2); color: var(--muted);
|
||||||
|
}
|
||||||
|
.pill.ok { color: var(--green); border-color: rgba(63,185,80,.4); background: rgba(63,185,80,.08); }
|
||||||
|
.pill.warn { color: var(--amber); border-color: rgba(210,153,34,.4); background: rgba(210,153,34,.08); }
|
||||||
|
|
||||||
|
h2 { font-size: 21px; margin: 48px 0 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
||||||
|
h2 .num { color: var(--accent); font-variant-numeric: tabular-nums; margin-right: 10px; }
|
||||||
|
h3 { font-size: 16.5px; margin: 28px 0 10px; color: #c9d4e0; }
|
||||||
|
p { margin: 12px 0; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code {
|
||||||
|
font-family: var(--mono); font-size: 12.8px;
|
||||||
|
background: var(--code-bg); border: 1px solid var(--border);
|
||||||
|
padding: 1.5px 6px; border-radius: 5px; color: #9fd2ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.path { color: var(--purple); }
|
||||||
|
|
||||||
|
/* TL;DR callout */
|
||||||
|
.tldr {
|
||||||
|
border: 1px solid rgba(63,185,80,.35);
|
||||||
|
background: rgba(63,185,80,.07);
|
||||||
|
border-radius: 14px; padding: 22px 26px; margin: 8px 0 12px;
|
||||||
|
}
|
||||||
|
.tldr h3 { margin-top: 0; color: var(--green); font-size: 15px; letter-spacing:.04em; text-transform: uppercase; }
|
||||||
|
.tldr p { margin: 8px 0; }
|
||||||
|
.tldr strong { color: var(--text); }
|
||||||
|
|
||||||
|
/* cards grid */
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 14px; margin: 18px 0; }
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border); background: var(--panel);
|
||||||
|
border-radius: 12px; padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.card .k { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.card .v { font-size: 17px; font-weight: 650; margin-top: 4px; }
|
||||||
|
.card .d { font-size: 13px; color: var(--muted); margin-top: 6px; }
|
||||||
|
|
||||||
|
/* timeline */
|
||||||
|
.timeline { list-style: none; padding: 0; margin: 18px 0; }
|
||||||
|
.timeline li {
|
||||||
|
position: relative; padding: 0 0 22px 32px; border-left: 2px solid var(--border); margin-left: 8px;
|
||||||
|
}
|
||||||
|
.timeline li:last-child { border-left-color: transparent; }
|
||||||
|
.timeline .dot {
|
||||||
|
position: absolute; left: -9px; top: 2px; width: 16px; height: 16px; border-radius: 50%;
|
||||||
|
background: var(--bg); border: 3px solid var(--muted);
|
||||||
|
}
|
||||||
|
.timeline li.now .dot { border-color: var(--green); box-shadow: 0 0 0 4px rgba(63,185,80,.15); }
|
||||||
|
.timeline li.off .dot { border-color: var(--amber); }
|
||||||
|
.timeline .when { font-family: var(--mono); font-size: 12px; color: var(--muted); }
|
||||||
|
.timeline .what { font-weight: 600; margin: 2px 0; }
|
||||||
|
.timeline .desc { font-size: 13.5px; color: var(--muted); }
|
||||||
|
.hash { font-family: var(--mono); color: var(--accent); font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* flow diagram */
|
||||||
|
.flow {
|
||||||
|
background: var(--code-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 24px; margin: 18px 0; overflow-x: auto;
|
||||||
|
}
|
||||||
|
.flow pre { margin: 0; font-family: var(--mono); font-size: 12.5px; line-height: 1.75; color: #c9d4e0; white-space: pre; }
|
||||||
|
.flow .c-cli { color: var(--accent); }
|
||||||
|
.flow .c-srv { color: var(--green); }
|
||||||
|
.flow .c-ext { color: var(--purple); }
|
||||||
|
.flow .c-cmt { color: var(--muted); }
|
||||||
|
|
||||||
|
/* table */
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13.8px; }
|
||||||
|
th, td { text-align: left; padding: 11px 13px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: 12.5px; text-transform: uppercase; letter-spacing: .04em; background: var(--panel-2); }
|
||||||
|
tr:hover td { background: rgba(255,255,255,.015); }
|
||||||
|
td.center { text-align: center; }
|
||||||
|
.tag { font-size: 11.5px; padding: 2px 8px; border-radius: 6px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.tag.done { color: var(--green); background: rgba(63,185,80,.12); }
|
||||||
|
.tag.diff { color: var(--amber); background: rgba(210,153,34,.12); }
|
||||||
|
.tag.miss { color: var(--red); background: rgba(248,81,73,.12); }
|
||||||
|
.tag.add { color: var(--purple); background: rgba(188,140,255,.12); }
|
||||||
|
|
||||||
|
/* risk blocks */
|
||||||
|
.risk { border-left: 3px solid var(--border); padding: 4px 0 4px 18px; margin: 18px 0; }
|
||||||
|
.risk.high { border-color: var(--red); }
|
||||||
|
.risk.mid { border-color: var(--amber); }
|
||||||
|
.risk.low { border-color: var(--accent); }
|
||||||
|
.risk h4 { margin: 0 0 6px; font-size: 15px; }
|
||||||
|
.risk h4 .sev { font-size: 11px; padding: 2px 8px; border-radius: 6px; margin-left: 8px; vertical-align: middle; font-weight: 700; }
|
||||||
|
.sev.high { color: var(--red); background: rgba(248,81,73,.14); }
|
||||||
|
.sev.mid { color: var(--amber); background: rgba(210,153,34,.14); }
|
||||||
|
.sev.low { color: var(--accent); background: rgba(68,147,248,.14); }
|
||||||
|
.risk p { margin: 6px 0; font-size: 14px; }
|
||||||
|
.risk .fix { color: var(--muted); font-size: 13.5px; }
|
||||||
|
.risk .fix b { color: #c9d4e0; }
|
||||||
|
|
||||||
|
ul.clean { padding-left: 20px; }
|
||||||
|
ul.clean li { margin: 7px 0; }
|
||||||
|
|
||||||
|
.file-list { font-family: var(--mono); font-size: 13px; }
|
||||||
|
.file-list td:first-child { color: var(--purple); }
|
||||||
|
|
||||||
|
footer { margin-top: 64px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12.5px; }
|
||||||
|
.legend { display:flex; flex-wrap:wrap; gap:14px; margin: 6px 0 0; font-size:12px; color:var(--muted);}
|
||||||
|
.legend span { display:inline-flex; align-items:center; gap:6px;}
|
||||||
|
.sw { width:11px;height:11px;border-radius:3px;display:inline-block;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="hero">
|
||||||
|
<div class="eyebrow">코드베이스 현황 분석 리포트</div>
|
||||||
|
<h1>PDF 도면 → 외벽선 자동작성 기능</h1>
|
||||||
|
<p class="sub">배치면 초기설정 단계에서 PDF 도면을 업로드하면 AI(Gemini)가 외벽선을 추출해 캔버스에 그려주는 기능의 구현 현황</p>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="pill">Q.CAST Front · Next.js 14 · Fabric.js 5.5.2</span>
|
||||||
|
<span class="pill">branch <code style="background:none;border:none;padding:0;color:inherit">feature/pv-btob</code></span>
|
||||||
|
<span class="pill ok">● 이미 구현됨 · 현재 활성</span>
|
||||||
|
<span class="pill">분석일 2026-05-29</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ================= TL;DR ================= -->
|
||||||
|
<div class="tldr">
|
||||||
|
<h3>핵심 결론 (TL;DR)</h3>
|
||||||
|
<p>요청하신 <strong>“PDF 도면 업로드 → AI 분석 → 외벽선 자동 그리기”</strong> 기능은 <strong>새로 만들 필요가 없습니다.</strong> 이미 <strong>완전히 구현되어 동작 중</strong>입니다.</p>
|
||||||
|
<p>커밋 <span class="hash">849f7a1d</span>(2026-05-20)에서 Google <strong>Gemini</strong> 기반으로 전체 구현 → <span class="hash">f8641079</span>에서 일시 비활성화 → 현재 HEAD <span class="hash">0187b234</span>에서 <strong>다시 활성화</strong>되어 있습니다.</p>
|
||||||
|
<p>따라서 이번 작업의 실제 성격은 <strong>“신규 개발”이 아니라 “기존 구현의 검증 · 정합성 보완 · 품질 개선”</strong>입니다. 아래에서 구현 위치, 데이터 흐름, 그리고 보강이 필요한 갭/리스크를 정리합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 1. 한눈에 ================= -->
|
||||||
|
<h2><span class="num">01</span>한눈에 보기</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">AI 엔진</div>
|
||||||
|
<div class="v">Gemini</div>
|
||||||
|
<div class="d"><code>gemini-3.1-pro-preview</code> · <code>@google/generative-ai ^0.24.1</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">진입점 (UI)</div>
|
||||||
|
<div class="v">배치면 초기설정 모달</div>
|
||||||
|
<div class="d">치수 입력방법 라디오에 <b>“도면 파일 업로드”</b> 옵션 추가</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">서버 처리</div>
|
||||||
|
<div class="v">Next.js API Route</div>
|
||||||
|
<div class="d"><code>POST /api/gemini/floor-plan</code> · 분석 후 파일 즉시 폐기</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">캔버스 반영</div>
|
||||||
|
<div class="v">외벽선 + WALL 폴리곤</div>
|
||||||
|
<div class="d"><code>QLine</code>(outerLine) + <code>QPolygon</code>(WALL) 생성</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">좌표 단위</div>
|
||||||
|
<div class="v">mm → canvas</div>
|
||||||
|
<div class="d">10mm = 1 canvas unit, 이후 화면 맞춤 스케일</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">다국어</div>
|
||||||
|
<div class="v">ko / ja 완비</div>
|
||||||
|
<div class="d">각 30개 <code>pdf.import.*</code> / <code>...size.pdf.*</code> 키</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 2. 타임라인 ================= -->
|
||||||
|
<h2><span class="num">02</span>구현 타임라인 (git 이력)</h2>
|
||||||
|
<ul class="timeline">
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<div class="when">2026-05-18</div>
|
||||||
|
<div class="what">구현 계획 수립</div>
|
||||||
|
<div class="desc"><span class="path">docs/PDF Gemini 평면도 분석 기능 구현 계획.md</span> 작성 — 요구사항·흐름·검증 정의</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<div class="when">2026-05-20 · <span class="hash">849f7a1d</span></div>
|
||||||
|
<div class="what">feat(pdf-gemini): 전체 기능 구현</div>
|
||||||
|
<div class="desc">서버 라우트 + <code>usePdfImport</code> 훅 + 배치면 초기설정 통합 + env/의존성/로케일. 총 12개 파일, +2137 라인.</div>
|
||||||
|
</li>
|
||||||
|
<li class="off">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<div class="when">2026-05-27 · <span class="hash">f8641079</span></div>
|
||||||
|
<div class="what">Disable PDF input mode option</div>
|
||||||
|
<div class="desc">라디오 옵션 일시 비활성화 (운영 노출 차단 목적으로 추정)</div>
|
||||||
|
</li>
|
||||||
|
<li class="now">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<div class="when">2026-05-27 · <span class="hash">0187b234</span> · <b>HEAD</b></div>
|
||||||
|
<div class="what">Revert: PDF 도면 파일 업로드 라디오 옵션 재표시</div>
|
||||||
|
<div class="desc"><b>현재 상태 — 기능 활성.</b> 사용자에게 “도면 파일 업로드” 옵션이 다시 노출됨.</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- ================= 3. 데이터 흐름 ================= -->
|
||||||
|
<h2><span class="num">03</span>전체 데이터 흐름 (실제 구현)</h2>
|
||||||
|
<div class="flow">
|
||||||
|
<pre><span class="c-cli">[클라이언트 · PlacementShapeSetting.jsx]</span>
|
||||||
|
배치면 초기설정 모달 열기
|
||||||
|
│
|
||||||
|
▼ "도면 파일 업로드" 라디오 선택 (INPUT_MODE.PDF)
|
||||||
|
파일 선택 handlePdfFileChange() <span class="c-cmt"># MIME=application/pdf, ≤20MB 검증</span>
|
||||||
|
│
|
||||||
|
▼ [저장] 버튼 → handleSaveBtn() → analyzePdfAndApply()
|
||||||
|
FormData(file, unitHint=mm, pageMode, facadePages?, floorPages?)
|
||||||
|
│
|
||||||
|
▼ POST
|
||||||
|
<span class="c-srv">[서버 · /api/gemini/floor-plan/route.js]</span>
|
||||||
|
① MIME / 20MB 재검증
|
||||||
|
② <span class="c-ext">GoogleAIFileManager.uploadFile()</span> ──→ Gemini File API 업로드
|
||||||
|
③ waitForFileActive() <span class="c-cmt"># PROCESSING→ACTIVE, 60s 타임아웃</span>
|
||||||
|
④ <span class="c-ext">model.generateContent()</span> <span class="c-cmt"># responseSchema 강제, temperature 0.1</span>
|
||||||
|
⑤ validateOuterline() <span class="c-cmt"># 정점 3~500개, 면적≠0 검증</span>
|
||||||
|
⑥ <span class="c-ext">finally: deleteFile()</span> <span class="c-cmt"># 성공/실패 무관 Gemini 파일 폐기</span>
|
||||||
|
│
|
||||||
|
▼ { outerline:[{x,y}…], unit:'mm', scale, confidence, notes }
|
||||||
|
<span class="c-cli">[클라이언트 · usePdfImport.js]</span> applyOuterline()
|
||||||
|
⑦ toCanvasUnits() mm ÷ 10 → canvas unit
|
||||||
|
⑧ 중복점 필터 인접거리 < 0.5 제거
|
||||||
|
⑨ fitAndCenter() 캔버스 60% / 최대 1500 내로 스케일 + 중앙정렬
|
||||||
|
⑩ ensureCounterClockwise() CCW 정규화
|
||||||
|
⑪ [기존 외벽선 존재 시] useSwal confirm "덮어쓰시겠습니까?"
|
||||||
|
⑫ clearExistingCanvasState() → setOuterLinePoints() (Recoil)
|
||||||
|
⑬ drawOuterLines() QLine(name='outerLine') 생성
|
||||||
|
⑭ addPolygonByLines() QPolygon(name=WALL) 생성
|
||||||
|
⑮ canvas.renderAll()
|
||||||
|
│
|
||||||
|
▼ roofSizeSet='1'(복시도)로 저장 → setSelectedMenu('outline')
|
||||||
|
외벽선 메뉴로 자동 전환 (수동 입력 흐름과 동일 지점에 합류)</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 4. 구성요소 상세 ================= -->
|
||||||
|
<h2><span class="num">04</span>구성요소별 상세</h2>
|
||||||
|
|
||||||
|
<h3>① 서버 라우트 — <span class="path">src/app/api/gemini/floor-plan/route.js</span></h3>
|
||||||
|
<ul class="clean">
|
||||||
|
<li><b>역할</b>: multipart 수신 → Gemini File API 업로드 → 외곽선 JSON 추출 → 파일 폐기. (210 라인, <code>POST</code> 단일 핸들러)</li>
|
||||||
|
<li><b>모델/한도</b>: <code>gemini-3.1-pro-preview</code> (L9), 20MB (L7), 최대 500 정점 (L8).</li>
|
||||||
|
<li><b>구조화 출력 강제</b>: <code>responseMimeType: 'application/json'</code> + <code>responseSchema</code>(FLOOR_PLAN_SCHEMA, L11-31) + <code>temperature: 0.1</code> (L156-160).</li>
|
||||||
|
<li><b>프롬프트</b>: “건축 평면도 외곽선 추출 전문가” — mm 환산·CCW·자기교차 없는 단순 폴리곤·JSON only를 명시 (BASE_PROMPT, L33-52). 페이지 지정 시 입면도/평면도 힌트 주입(buildPrompt, L54-62).</li>
|
||||||
|
<li><b>서버측 검증</b>: <code>validateOuterline()</code> (L64-93) — 배열/정점수/숫자형/<b>면적≠0</b>(|area|<1 거부, L89). 빈 배열은 “평면도 아님”으로 정상 처리.</li>
|
||||||
|
<li><b>리소스 정리</b>: <code>finally</code>에서 <code>deleteFile()</code>로 업로드 PDF 폐기 (L202-208) — 계획서 요구사항 “분석 후 폐기” 충족.</li>
|
||||||
|
<li><b>키 관리</b>: <code>process.env.GEMINI_API_KEY</code> 직접 사용 (서버 전용, 클라이언트 미노출).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>② 캔버스 반영 훅 — <span class="path">src/hooks/pdf-import/usePdfImport.js</span></h3>
|
||||||
|
<ul class="clean">
|
||||||
|
<li><b>핵심 상수</b>: <code>MM_PER_CANVAS_UNIT = 10</code> (L11), <code>MAX_DIM = 1500</code> (L12), <code>MIN_CANVAS_UNIT_LENGTH = 0.5</code> (L13).</li>
|
||||||
|
<li><b>좌표 파이프라인</b>: <code>toCanvasUnits</code>(mm/10) → 중복점 필터 → <code>fitAndCenter</code>(화면 60% 맞춤·중앙정렬, L32-58) → <code>ensureCounterClockwise</code>(L27-30).</li>
|
||||||
|
<li><b>그리기</b>: <code>drawOuterLines</code>(L89-102)가 점 배열을 순회하며 <code>QLine</code>(stroke #000, width 3, <code>name:'outerLine'</code>, idx) 생성 → canvas.add.</li>
|
||||||
|
<li><b>폴리곤화</b>: 외벽선 ≥3개면 <code>addPolygonByLines(lines, { name: POLYGON_TYPE.WALL })</code>로 WALL 폴리곤 생성(L148).</li>
|
||||||
|
<li><b>덮어쓰기 가드</b>: 기존 outerLine/WALL/ROOF 존재 시 confirm 후 진행(L157-169), 진행 시 <code>clearExistingCanvasState</code>로 관련 객체 정리.</li>
|
||||||
|
<li><b>외부 API</b>: <code>applyOuterline(mmPoints, { unit })</code>, <code>hasExistingOuterLine()</code> 노출.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>③ UI 통합 — <span class="path">src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx</span></h3>
|
||||||
|
<ul class="clean">
|
||||||
|
<li><b>입력 모드</b>: <code>INPUT_MODE = { SIZE_ROOF:'1', SIZE_ACTUAL:'2', PDF:'pdf' }</code> (L34-38). 라디오 배열에 <code>ra-pdf</code> 항목 추가(L107-111).</li>
|
||||||
|
<li><b>모드 매핑</b>: PDF 선택 시 내부 <code>roofSizeSet</code>은 <b>‘1’(복시도)</b>로 저장(L113-121) → 저장 후 외벽선 메뉴로 자동 합류(L427-429).</li>
|
||||||
|
<li><b>파일 검증</b>: <code>handlePdfFileChange</code>(L128-142) — MIME/20MB 클라이언트 가드.</li>
|
||||||
|
<li><b>분석 트리거</b>: <code>analyzePdfAndApply</code>(L144-198) — fetch → 에러/빈응답 분기 → <code>applyOuterline</code> 호출.</li>
|
||||||
|
<li><b>페이지 지정</b>: 전체/지정 라디오 + 입면도·평면도 페이지 입력(L534-589) — <span class="tag add">계획서에 없던 추가 기능</span>.</li>
|
||||||
|
<li><b>로딩 UX</b>: 분석 중 버튼 비활성화 + “분석 중…” 텍스트(L867-872). 별도 진행률 없음.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>④ 의존성 · 환경변수 · 로케일</h3>
|
||||||
|
<ul class="clean">
|
||||||
|
<li><b>의존성</b>: <code>package.json</code> L23 — <code>@google/generative-ai: ^0.24.1</code>.</li>
|
||||||
|
<li><b>환경변수</b>: <code>GEMINI_API_KEY</code>, <code>GEMINI_MODEL</code> — <code>.env.development / .env.local.dev / .env.localhost / .env.production</code> 4개 파일 모두 존재. <span style="color:var(--muted)">(값은 본 리포트에 미기재)</span></li>
|
||||||
|
<li><b>로케일</b>: <code>ko.json</code>(L19-45) / <code>ja.json</code> 각 30개 메시지 — <code>pdf.import.*</code>(동작/에러) + <code>modal.placement.initial.setting.size.pdf.*</code>(UI 라벨/안내).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- ================= 5. 계획 대비 실제 ================= -->
|
||||||
|
<h2><span class="num">05</span>계획서 대비 실제 구현 차이</h2>
|
||||||
|
<p>구현 과정에서 초기 계획(<span class="path">docs/…구현 계획.md</span>)과 달라진 설계 결정들입니다. 대부분 <b>더 나은 통합 방향</b>으로 수렴했습니다.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:18%">항목</th><th style="width:30%">계획서</th><th style="width:34%">실제 구현 (HEAD)</th><th style="width:18%">평가</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>UI 진입점</td><td>floor-plan 상단 툴바 + 신규 PDF 아이콘</td><td>배치면 초기설정 모달의 치수 입력방법 라디오 옵션</td><td><span class="tag diff">변경</span> 기존 흐름과 자연스럽게 통합</td></tr>
|
||||||
|
<tr><td>업로드 모달</td><td>별도 <code>PdfImportModal.jsx</code> (상태머신)</td><td>없음 — PlacementShapeSetting에 통합</td><td><span class="tag diff">변경</span> 모달 중첩 회피</td></tr>
|
||||||
|
<tr><td>메뉴 등록</td><td><code>menuAtom</code>에 <code>pdf-import</code> 항목</td><td>미등록 (툴바 방식 폐기로 불필요)</td><td><span class="tag done">정합</span></td></tr>
|
||||||
|
<tr><td>페이지 지정</td><td>(없음)</td><td>전체/지정 + 입면도·평면도 페이지 입력</td><td><span class="tag add">추가</span> 분석 속도·정확도 개선</td></tr>
|
||||||
|
<tr><td>config 매핑</td><td><code>config.*.js</code>에 GEMINI 추가</td><td>미적용 — route가 <code>process.env</code> 직접 사용</td><td><span class="tag diff">상이</span> 아래 리스크 R3 참조</td></tr>
|
||||||
|
<tr><td>자기교차 검증</td><td>turf 로 self-intersection 검출</td><td>미구현 — 정점수·면적만 검증, 나머지는 프롬프트+CCW 의존</td><td><span class="tag miss">미구현</span> 리스크 R2</td></tr>
|
||||||
|
<tr><td>거대 폴리곤</td><td>Douglas–Peucker 단순화 or 거부</td><td>500 정점 초과 시 거부만 (단순화 없음)</td><td><span class="tag miss">미구현</span></td></tr>
|
||||||
|
<tr><td>파일 폐기</td><td><code>finally</code>에서 삭제 보장</td><td>동일 구현</td><td><span class="tag done">정합</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- ================= 6. 갭 / 리스크 ================= -->
|
||||||
|
<h2><span class="num">06</span>갭 · 리스크 · 보완 권장</h2>
|
||||||
|
<p>기능은 동작하지만, “외벽선을 도면 그대로 정확히” 재현하려는 목표 관점에서 다음 항목들의 검증/보완을 권장합니다.</p>
|
||||||
|
|
||||||
|
<div class="risk high">
|
||||||
|
<h4>R1 · 실측 치수가 캔버스에 보존되지 않을 수 있음<span class="sev high">HIGH</span></h4>
|
||||||
|
<p><code>fitAndCenter()</code>가 외벽선을 <b>캔버스 60%(≈840 canvas unit, 약 8.4m)에 맞춰 추가 스케일링</b>합니다. 도면이 그보다 크면 <code>scale < 1</code>이 곱해져 <b>실제 치수 ↔ 캔버스 길이 정합이 깨집니다.</b> 수동 입력 흐름은 <code>실측/10</code>을 그대로 좌표로 쓰므로, PDF 경로만 길이가 달라질 위험이 있습니다.</p>
|
||||||
|
<p class="fix"><b>권장:</b> 일반 주택(외곽 8.4m 초과 흔함) 기준으로 <b>실측 비율을 보존하는 변환</b>으로 재검토. 화면 맞춤이 필요하면 좌표 자체가 아니라 canvas <b>viewport zoom/pan</b>으로 처리하거나, 그리드 축척과 동일한 mm/unit 비율을 강제할 것.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="risk mid">
|
||||||
|
<h4>R2 · 자기교차(self-intersection) 폴리곤 통과 가능<span class="sev mid">MID</span></h4>
|
||||||
|
<p>서버 <code>validateOuterline()</code>은 정점수·면적만 본다. 자기교차는 <b>Gemini 프롬프트 지침과 CCW 정규화에만 의존</b>한다. 꼬인 폴리곤이 들어오면 <code>QPolygon</code>의 <code>sortedPointLessEightPoint</code> 정렬과 충돌해 형상이 깨질 수 있다.</p>
|
||||||
|
<p class="fix"><b>권장:</b> 계획서대로 turf(또는 경량 segment-intersection)로 서버/클라이언트에서 자기교차 거부. <code>SizeSetting</code>의 0-area 가드 정신과 일관되게 처리.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="risk mid">
|
||||||
|
<h4>R3 · 지붕면(ROOF) 자동 생성 부재 — 안내 문구와 동작 갭<span class="sev mid">MID</span></h4>
|
||||||
|
<p>로케일 <code>…pdf.file.info</code>는 “외벽선과 <b>지붕형상</b>이 함께 생성”된다고 안내하지만, <code>usePdfImport</code>는 <b>외벽선(QLine) + WALL 폴리곤</b>까지만 만든다. 수동 흐름의 <code>ccwCheck → RoofShapeSetting 자동 오픈 → drawRoofPolygon</code> 단계가 PDF 경로에선 호출되지 않아 <b>지붕면은 수동 작성</b>이 필요하다.</p>
|
||||||
|
<p class="fix"><b>권장:</b> ①요청 목표(외벽선 그리기)만 보면 충족이므로 문구를 동작에 맞게 정정하거나, ②PDF 적용 후 기존 <code>useRoofShapeSetting</code> 자산을 재사용해 지붕 자동화를 확장. (요청 범위 명확화 필요)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="risk low">
|
||||||
|
<h4>R4 · GEMINI 키의 config 우회 — 컨벤션 관점<span class="sev low">LOW</span></h4>
|
||||||
|
<p>CLAUDE.md는 “feature code에서 env 직접 읽지 말고 <code>src/config</code>에서 import”를 규정한다. route는 <code>process.env</code>를 직접 읽는다. <b>다만 서버 시크릿은 클라이언트 config로 노출하면 안 되므로</b> 서버 라우트에서의 직접 사용은 정당화 가능한 회색지대다.</p>
|
||||||
|
<p class="fix"><b>권장:</b> 현행 유지 가능. 일관성을 원하면 서버 전용 config 헬퍼로 묶되 클라이언트 번들 유입이 없도록 주의.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="risk low">
|
||||||
|
<h4>R5 · 비결정적 분석 · 검증 자동화 부재<span class="sev low">LOW</span></h4>
|
||||||
|
<p>프로젝트에 테스트 러너가 없고(CLAUDE.md), Gemini 출력은 비결정적이다. 회귀 검증은 수동 의존. 분석 소요(최대 60s 타임아웃)에 비해 진행률 UX가 약하다.</p>
|
||||||
|
<p class="fix"><b>권장:</b> 좌표 변환부(<code>toCanvasUnits</code>/<code>fitAndCenter</code>/<code>ensureCounterClockwise</code>)는 순수 함수이므로 <b>단위 테스트로 분리 검증</b> 가능. 분석 중 스피너/단계 표시 추가 검토.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 7. 파일 인벤토리 ================= -->
|
||||||
|
<h2><span class="num">07</span>파일 인벤토리</h2>
|
||||||
|
<table class="file-list">
|
||||||
|
<thead><tr><th style="width:46%">경로</th><th style="width:14%">구분</th><th>역할 / 핵심 라인</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>src/app/api/gemini/floor-plan/route.js</td><td><span class="tag done">신규</span></td><td>Gemini 분석 서버 라우트 · POST(L111) · validate(L64) · finally 폐기(L202)</td></tr>
|
||||||
|
<tr><td>src/hooks/pdf-import/usePdfImport.js</td><td><span class="tag done">신규</span></td><td>좌표 변환·캔버스 반영 · applyOuterline(L104) · drawOuterLines(L89)</td></tr>
|
||||||
|
<tr><td>src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx</td><td><span class="tag diff">수정</span></td><td>UI 통합 · INPUT_MODE(L34) · analyzePdfAndApply(L144) · UI(L486-591)</td></tr>
|
||||||
|
<tr><td>package.json</td><td><span class="tag diff">수정</span></td><td>@google/generative-ai ^0.24.1 (L23)</td></tr>
|
||||||
|
<tr><td>.env.{development,local.dev,localhost,production}</td><td><span class="tag diff">수정</span></td><td>GEMINI_API_KEY · GEMINI_MODEL</td></tr>
|
||||||
|
<tr><td>src/locales/ko.json · ja.json</td><td><span class="tag diff">수정</span></td><td>pdf.import.* / …size.pdf.* (각 30개)</td></tr>
|
||||||
|
<tr><td>docs/PDF Gemini 평면도 분석 기능 구현 계획.md / .html</td><td><span class="tag add">문서</span></td><td>초기 설계 문서 (2026-05-18)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<span><span class="sw" style="background:var(--green)"></span>신규 파일</span>
|
||||||
|
<span><span class="sw" style="background:var(--amber)"></span>수정 파일</span>
|
||||||
|
<span><span class="sw" style="background:var(--purple)"></span>문서/참고</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================= 8. 다음 단계 제안 ================= -->
|
||||||
|
<h2><span class="num">08</span>다음 단계 제안</h2>
|
||||||
|
<p>“새 기능 개발”이 아니라 <b>기존 구현의 검증·보완</b>으로 방향을 잡는 것을 권합니다. 우선순위 순:</p>
|
||||||
|
<ul class="clean">
|
||||||
|
<li><b>1. 실측 정합성 검증 (R1)</b> — 실제 PDF 한두 건으로 “도면 치수 vs 캔버스 외벽선 길이”를 측정. 어긋나면 <code>fitAndCenter</code> 스케일 전략부터 재설계.</li>
|
||||||
|
<li><b>2. 요구 범위 확정 (R3)</b> — “외벽선만”인지 “지붕면까지”인지 결정. 결정에 따라 안내 문구 정정 또는 지붕 자동화 확장.</li>
|
||||||
|
<li><b>3. 자기교차/단순화 가드 (R2)</b> — 꼬인 폴리곤·과다 정점에 대한 방어 추가.</li>
|
||||||
|
<li><b>4. 좌표 변환 단위 테스트</b> — 순수 함수부를 분리해 회귀 안전망 마련.</li>
|
||||||
|
<li><b>5. lint/build 점검</b> — 보완 작업 시 CLAUDE.md 규약(no-semicolon, single-quote, <code>yarn lint</code>)에 맞춰 마무리.</li>
|
||||||
|
</ul>
|
||||||
|
<p style="color:var(--muted);font-size:13.5px;">실제 코드 보완에 착수하기 전, 위 R1·R3는 제품 의사결정이 필요하므로 먼저 합의하는 것을 권합니다. 합의되면 brainstorming → 구현 → 검증 순으로 진행하겠습니다.</p>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
생성: Claude Code · 코드베이스 정적 분석 기반 (Gemini 실호출/런타임 검증은 미포함) · 라인 번호는 분석 시점(HEAD <span class="hash">0187b234</span>) 기준<br/>
|
||||||
|
저장 위치: <span class="path">docs/PDF 도면 외벽선 자동작성 - 코드베이스 현황 분석.html</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -26,37 +26,58 @@ const FLOOR_PLAN_SCHEMA = {
|
|||||||
scale: { type: SchemaType.NUMBER },
|
scale: { type: SchemaType.NUMBER },
|
||||||
confidence: { type: SchemaType.NUMBER },
|
confidence: { type: SchemaType.NUMBER },
|
||||||
notes: { type: SchemaType.STRING },
|
notes: { type: SchemaType.STRING },
|
||||||
|
selectedDrawingType: { type: SchemaType.STRING },
|
||||||
},
|
},
|
||||||
required: ['outerline', 'unit'],
|
required: ['outerline', 'unit'],
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_PROMPT = `당신은 건축 평면도(주택 도면)의 외곽선 추출 전문가입니다.
|
const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영면(屋根伏図 footprint)" 외곽선을 추출하는 전문가입니다.
|
||||||
첨부된 PDF에서 건물의 외곽(외벽선) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요.
|
첨부된 PDF 에서 지붕을 위에서 내려다본 외곽(처마 끝선 포함) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요.
|
||||||
|
|
||||||
규칙:
|
[도면 종류 식별 — 매우 중요]
|
||||||
1. 모든 좌표는 밀리미터(mm) 단위의 실측 치수로 환산해야 합니다. 도면의 축척(scale)을 식별하여 적용하세요.
|
- 추출 대상: 屋根伏図(지붕평면도)가 있으면 그 지붕 외곽선(처마/軒先·케라바/ケラバ 끝선 포함)을 사용합니다.
|
||||||
|
- 屋根伏図 가 없으면 平面図(1층/1階 평면도)의 외벽 외주에 처마 출폭(軒の出)을 더한 지붕 투영 외곽을 추정합니다.
|
||||||
|
- 절대 사용 금지: 立面図(입면도)·配置図(배치도)·求積図(면적도)·断面図(단면도). 이 도면들에서는 윤곽을 추출하지 마세요.
|
||||||
|
- 도머(ドーマー)·천창·개구부(開口部) 등 지붕 위 돌출/구멍은 외곽선에 포함하지 마세요(외곽 윤곽이 아닙니다).
|
||||||
|
- 평면도가 여러 층이면 1층(1F) 기준으로 지붕 투영을 판단합니다.
|
||||||
|
|
||||||
|
[축척·치수 결정 — 우선순위]
|
||||||
|
1. 도면에 적힌 寸法線(치수선)의 실제 mm 숫자를 최우선 근거로 좌표를 환산합니다.
|
||||||
|
2. 그 다음 縮尺 표기(예: 縮尺 1/100, S=1:100)를 사용합니다.
|
||||||
|
3. 마지막으로 通り芯(grid axis) 간격으로 보정합니다.
|
||||||
|
4. 치수선 숫자와 축척이 충돌하면 치수선 숫자를 신뢰하세요.
|
||||||
|
5. 일본 주택은 尺貫法 모듈(1間=1820mm, 半間=910mm, 455mm, 303mm)에 정렬되는 경향이 있습니다 — 픽셀비례로 추정한 길이를 가장 가까운 모듈값과 대조해 검토하세요(강제 아님, 참고용).
|
||||||
|
|
||||||
|
[형상 규칙]
|
||||||
|
1. 모든 좌표는 밀리미터(mm) 단위의 실측 치수로 환산합니다.
|
||||||
2. 좌표계는 도면의 좌상단을 원점으로 하고, x 는 우측 양수, y 는 하단 양수로 합니다.
|
2. 좌표계는 도면의 좌상단을 원점으로 하고, x 는 우측 양수, y 는 하단 양수로 합니다.
|
||||||
3. 외곽선은 시계 반대 방향(CCW) 으로 정렬된 정점 배열로 반환합니다.
|
3. 외곽선은 시계 반대 방향(CCW) 으로 정렬된 정점 배열로 반환합니다.
|
||||||
4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3개 이상이어야 합니다.
|
4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3개 이상이어야 합니다.
|
||||||
5. PDF 가 평면도가 아니거나 외곽을 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다.
|
5. L자·凹凸(요철) 평면의 모든 꺾임을 정점으로 보존하고, 직각(直角)에 가까운 코너는 직각으로 간주합니다(비스듬히 근사 금지).
|
||||||
6. 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
|
6. 분리된 복수 동(棟)이 있으면 가장 큰 주 건물 하나만 반환하고, 별동은 notes 에 기재합니다.
|
||||||
7. 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
|
7. 지붕 투영을 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다.
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
- 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
|
||||||
|
- 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
|
||||||
|
- selectedDrawingType 에 사용한 도면 종류('roof_plan' | 'floor_plan_1f' | 'other')를 기재합니다.
|
||||||
|
|
||||||
스키마:
|
스키마:
|
||||||
{
|
{
|
||||||
"outerline": [{"x": number, "y": number}, ...],
|
"outerline": [{"x": number, "y": number}, ...],
|
||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"scale": number (선택, 도면 1mm 당 실제 mm),
|
"scale": number (선택, 축척 분모. 1/100 이면 100),
|
||||||
"confidence": number (0~1),
|
"confidence": number (0~1),
|
||||||
"notes": string (선택)
|
"notes": string (선택),
|
||||||
|
"selectedDrawingType": string (선택)
|
||||||
}`
|
}`
|
||||||
|
|
||||||
const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => {
|
const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => {
|
||||||
if (pageMode === 'specify' && (facadePages || floorPages)) {
|
if (pageMode === 'specify' && (facadePages || floorPages)) {
|
||||||
const hints = []
|
const hints = []
|
||||||
if (floorPages) hints.push(`평면도(平面図) 페이지: ${floorPages}`)
|
if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`)
|
||||||
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
|
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
|
||||||
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 외곽선은 위에서 지정한 평면도 페이지 기준으로 추출하세요.`
|
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
|
||||||
}
|
}
|
||||||
return BASE_PROMPT
|
return BASE_PROMPT
|
||||||
}
|
}
|
||||||
@ -157,6 +178,8 @@ export async function POST(req) {
|
|||||||
responseMimeType: 'application/json',
|
responseMimeType: 'application/json',
|
||||||
responseSchema: FLOOR_PLAN_SCHEMA,
|
responseSchema: FLOOR_PLAN_SCHEMA,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
|
// Gemini 3 고fidelity 경로 — 얇은 외벽선/작은 寸法 텍스트 가독성 확보(미지원 시 무시됨). 적용 여부는 usageMetadata 토큰 증가로 검증.
|
||||||
|
mediaResolution: 'MEDIA_RESOLUTION_HIGH',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -174,6 +197,7 @@ export async function POST(req) {
|
|||||||
|
|
||||||
const raw = result.response.text()
|
const raw = result.response.text()
|
||||||
logger.debug('[gemini/floor-plan] raw response', raw)
|
logger.debug('[gemini/floor-plan] raw response', raw)
|
||||||
|
logger.debug('[gemini/floor-plan] usageMetadata', result.response.usageMetadata)
|
||||||
|
|
||||||
let parsed
|
let parsed
|
||||||
try {
|
try {
|
||||||
@ -193,6 +217,7 @@ export async function POST(req) {
|
|||||||
scale: parsed.scale ?? null,
|
scale: parsed.scale ?? null,
|
||||||
confidence: parsed.confidence ?? null,
|
confidence: parsed.confidence ?? null,
|
||||||
notes: parsed.notes ?? null,
|
notes: parsed.notes ?? null,
|
||||||
|
selectedDrawingType: parsed.selectedDrawingType ?? null,
|
||||||
empty: validation.empty,
|
empty: validation.empty,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -200,11 +225,11 @@ export async function POST(req) {
|
|||||||
return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 })
|
return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 })
|
||||||
} finally {
|
} finally {
|
||||||
if (uploadedFileName) {
|
if (uploadedFileName) {
|
||||||
try {
|
// temp 파일 삭제는 응답을 지연시키지 않도록 await 하지 않고 분리(fire-and-forget)한다.
|
||||||
await fileManager.deleteFile(uploadedFileName)
|
// 정리용 네트워크 호출이 행(hang)되더라도 클라이언트 응답이 막히지 않는다(미삭제 시 Gemini 측에서 자동 만료).
|
||||||
} catch (deleteError) {
|
fileManager.deleteFile(uploadedFileName).catch((deleteError) => {
|
||||||
logger.warn('[gemini/floor-plan] deleteFile failed', deleteError?.message || deleteError)
|
logger.warn('[gemini/floor-plan] deleteFile failed', deleteError?.message || deleteError)
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, useContext } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useMessage } from '@/hooks/useMessage'
|
import { useMessage } from '@/hooks/useMessage'
|
||||||
import { usePopup } from '@/hooks/usePopup'
|
import { usePopup } from '@/hooks/usePopup'
|
||||||
@ -25,6 +25,7 @@ import { normalizeDecimal } from '@/util/input-utils'
|
|||||||
import { CalculatorInput } from '@/components/common/input/CalcInput'
|
import { CalculatorInput } from '@/components/common/input/CalcInput'
|
||||||
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
|
import { usePdfImport } from '@/hooks/pdf-import/usePdfImport'
|
||||||
import { logger } from '@/util/logger'
|
import { logger } from '@/util/logger'
|
||||||
|
import { QcastContext } from '@/app/QcastProvider'
|
||||||
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
|
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
|
||||||
import { useSwal } from '@/hooks/useSwal'
|
import { useSwal } from '@/hooks/useSwal'
|
||||||
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
|
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
|
||||||
@ -39,11 +40,17 @@ const INPUT_MODE = {
|
|||||||
|
|
||||||
const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024
|
const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
// 분석 요청 타임아웃(ms). 서버 라우트는 파일 처리(최대 60s)+generateContent 로 길어질 수 있어
|
||||||
|
// 클라이언트에서 backstop 을 둔다. 이 시간을 넘기면 abort 하여 스피너가 영구 멈추지 않도록 한다.
|
||||||
|
const PDF_ANALYZE_TIMEOUT_MS = 120 * 1000
|
||||||
|
|
||||||
const PDF_PAGE_MODE = {
|
const PDF_PAGE_MODE = {
|
||||||
ALL: 'all',
|
ALL: 'all',
|
||||||
SPECIFY: 'specify',
|
SPECIFY: 'specify',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PDF_CONFIDENCE_THRESHOLD = 0.6 // 이 값 미만이면 반영 전 사용자 확인
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지붕 레이아웃
|
* 지붕 레이아웃
|
||||||
*/
|
*/
|
||||||
@ -100,6 +107,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
const [pdfFacadePages, setPdfFacadePages] = useState('')
|
const [pdfFacadePages, setPdfFacadePages] = useState('')
|
||||||
const [pdfFloorPages, setPdfFloorPages] = useState('')
|
const [pdfFloorPages, setPdfFloorPages] = useState('')
|
||||||
const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
|
const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
|
||||||
|
const { setIsGlobalLoading } = useContext(QcastContext)
|
||||||
const pdfInputRef = useRef(null)
|
const pdfInputRef = useRef(null)
|
||||||
/**
|
/**
|
||||||
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
|
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
|
||||||
@ -157,28 +165,37 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPdfAnalyzing(true)
|
setPdfAnalyzing(true)
|
||||||
let response
|
setIsGlobalLoading(true)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), PDF_ANALYZE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let response = null
|
||||||
|
let payload = null
|
||||||
|
let errorKey = null
|
||||||
try {
|
try {
|
||||||
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData })
|
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData, signal: controller.signal })
|
||||||
|
try {
|
||||||
|
payload = await response.json()
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e)
|
||||||
|
errorKey = 'pdf.import.error.analyze'
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e)
|
logger.error('[PlacementShapeSetting] PDF analyze request failed', e?.message || e)
|
||||||
|
errorKey = e?.name === 'AbortError' ? 'pdf.import.error.timeout' : 'pdf.import.error.network'
|
||||||
|
} finally {
|
||||||
|
// 성공·실패·타임아웃 어떤 경로로 끝나든 스피너를 반드시 해제한다(영구 멈춤 방지).
|
||||||
|
clearTimeout(timeoutId)
|
||||||
setPdfAnalyzing(false)
|
setPdfAnalyzing(false)
|
||||||
swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' })
|
setIsGlobalLoading(false)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload
|
// alert/confirm 은 스피너 해제 후 표시한다(스피너 z-index 가 alert 보다 높아 가려지는 것 방지).
|
||||||
try {
|
if (errorKey) {
|
||||||
payload = await response.json()
|
swalFire({ text: getMessage(errorKey), type: 'alert' })
|
||||||
} catch (e) {
|
|
||||||
logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e)
|
|
||||||
setPdfAnalyzing(false)
|
|
||||||
swalFire({ text: getMessage('pdf.import.error.analyze'), type: 'alert' })
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
setPdfAnalyzing(false)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const msg = payload?.error?.message || getMessage('pdf.import.error.analyze')
|
const msg = payload?.error?.message || getMessage('pdf.import.error.analyze')
|
||||||
swalFire({ text: msg, type: 'alert' })
|
swalFire({ text: msg, type: 'alert' })
|
||||||
@ -189,6 +206,18 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof payload.confidence === 'number' && payload.confidence < PDF_CONFIDENCE_THRESHOLD) {
|
||||||
|
const proceed = await new Promise((resolve) => {
|
||||||
|
swalFire({
|
||||||
|
text: getMessage('pdf.import.confirm.low.confidence', [Math.round(payload.confidence * 100)]),
|
||||||
|
type: 'confirm',
|
||||||
|
confirmFn: () => resolve(true),
|
||||||
|
denyFn: () => resolve(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (!proceed) return false
|
||||||
|
}
|
||||||
|
|
||||||
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' })
|
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' })
|
||||||
if (!applied) {
|
if (!applied) {
|
||||||
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
|
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
|
||||||
|
|||||||
@ -7,10 +7,14 @@ import { usePolygon } from '@/hooks/usePolygon'
|
|||||||
import { useSwal } from '@/hooks/useSwal'
|
import { useSwal } from '@/hooks/useSwal'
|
||||||
import { useMessage } from '@/hooks/useMessage'
|
import { useMessage } from '@/hooks/useMessage'
|
||||||
import { logger } from '@/util/logger'
|
import { logger } from '@/util/logger'
|
||||||
|
import { snapNearAxisEdges, cleanSelfIntersectingPolygon } from '@/util/qpolygon-utils'
|
||||||
|
import * as turf from '@turf/turf'
|
||||||
|
|
||||||
const MM_PER_CANVAS_UNIT = 10
|
const MM_PER_CANVAS_UNIT = 10
|
||||||
const MAX_DIM = 1500
|
const MAX_DIM = 1500
|
||||||
const MIN_CANVAS_UNIT_LENGTH = 0.5
|
const MIN_CANVAS_UNIT_LENGTH = 0.5
|
||||||
|
const SIMPLIFY_TOLERANCE = 0.5 // canvas unit ≈ 5mm. 직선 위 잉여 정점/미세 엣지 제거(보수적)
|
||||||
|
const SNAP_ANGLE_TOL_DEG = 2 // 거의 수평/수직 엣지를 정확히 축정렬로 스냅
|
||||||
|
|
||||||
const toCanvasUnits = (mmPoints) => mmPoints.map((p) => ({ x: p.x / MM_PER_CANVAS_UNIT, y: p.y / MM_PER_CANVAS_UNIT }))
|
const toCanvasUnits = (mmPoints) => mmPoints.map((p) => ({ x: p.x / MM_PER_CANVAS_UNIT, y: p.y / MM_PER_CANVAS_UNIT }))
|
||||||
|
|
||||||
@ -57,6 +61,30 @@ const fitAndCenter = (points, canvas) => {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const simplifyRing = (points, tolerance) => {
|
||||||
|
if (points.length < 4) return points
|
||||||
|
try {
|
||||||
|
const coords = points.map((p) => [p.x, p.y])
|
||||||
|
coords.push([points[0].x, points[0].y])
|
||||||
|
const simplified = turf.simplify(turf.polygon([coords]), { tolerance, highQuality: true })
|
||||||
|
const ring = simplified.geometry.coordinates[0]
|
||||||
|
const out = ring.slice(0, -1).map((c) => ({ x: c[0], y: c[1] }))
|
||||||
|
return out.length >= 3 ? out : points
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[usePdfImport] simplifyRing 실패', e?.message || e)
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 추출 좌표의 결정적 정규화: 축정렬 스냅 → 과다정점 단순화 → 자기교차 복구.
|
||||||
|
// 순서 주의 — 스냅(정점 이동) 후 단순화(정점 제거)해야 축정렬이 보존된다.
|
||||||
|
const normalizeGeometry = (points) => {
|
||||||
|
let pts = snapNearAxisEdges(points, SNAP_ANGLE_TOL_DEG)
|
||||||
|
pts = simplifyRing(pts, SIMPLIFY_TOLERANCE)
|
||||||
|
pts = cleanSelfIntersectingPolygon(pts)
|
||||||
|
return pts.length >= 3 ? pts : points
|
||||||
|
}
|
||||||
|
|
||||||
export function usePdfImport() {
|
export function usePdfImport() {
|
||||||
const canvas = useRecoilValue(canvasState)
|
const canvas = useRecoilValue(canvasState)
|
||||||
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
|
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
|
||||||
@ -133,6 +161,7 @@ export function usePdfImport() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canvasPoints = normalizeGeometry(canvasPoints)
|
||||||
canvasPoints = fitAndCenter(canvasPoints, canvas)
|
canvasPoints = fitAndCenter(canvasPoints, canvas)
|
||||||
canvasPoints = ensureCounterClockwise(canvasPoints)
|
canvasPoints = ensureCounterClockwise(canvasPoints)
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,9 @@
|
|||||||
"pdf.import.button.analyzing": "解析中...",
|
"pdf.import.button.analyzing": "解析中...",
|
||||||
"pdf.import.success": "外壁線をキャンバスに反映しました。",
|
"pdf.import.success": "外壁線をキャンバスに反映しました。",
|
||||||
"pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?",
|
"pdf.import.confirm.overwrite": "既存の外壁線があります。新しい外壁線で上書きしますか?",
|
||||||
|
"pdf.import.confirm.low.confidence": "図面の認識信頼度が低いです({0}%)。それでも外壁線を反映しますか?ページを指定して再度お試しいただくこともできます。",
|
||||||
"pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。",
|
"pdf.import.error.network": "ネットワークエラーで解析リクエストに失敗しました。",
|
||||||
|
"pdf.import.error.timeout": "解析リクエストがタイムアウトしました。ネットワーク状態を確認して再度お試しください。",
|
||||||
"pdf.import.error.analyze": "PDF 解析に失敗しました。",
|
"pdf.import.error.analyze": "PDF 解析に失敗しました。",
|
||||||
"pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。",
|
"pdf.import.error.no.floorplan": "平面図を認識できませんでした。別の PDF で再度お試しください。",
|
||||||
"pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。",
|
"pdf.import.error.too.few.vertices": "外壁線の頂点が不足しています。別の PDF で再度お試しください。",
|
||||||
|
|||||||
@ -19,7 +19,9 @@
|
|||||||
"pdf.import.button.analyzing": "분석 중...",
|
"pdf.import.button.analyzing": "분석 중...",
|
||||||
"pdf.import.success": "외곽선이 캔버스에 반영되었습니다.",
|
"pdf.import.success": "외곽선이 캔버스에 반영되었습니다.",
|
||||||
"pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?",
|
"pdf.import.confirm.overwrite": "기존 외곽선이 있습니다. 새 외곽선으로 덮어쓰시겠습니까?",
|
||||||
|
"pdf.import.confirm.low.confidence": "도면 인식 신뢰도가 낮습니다({0}%). 그래도 외곽선을 반영하시겠습니까? 페이지를 직접 지정해 다시 시도할 수도 있습니다.",
|
||||||
"pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.",
|
"pdf.import.error.network": "네트워크 오류로 분석 요청에 실패했습니다.",
|
||||||
|
"pdf.import.error.timeout": "분석 요청 시간이 초과되었습니다. 네트워크 상태를 확인한 뒤 다시 시도해 주세요.",
|
||||||
"pdf.import.error.analyze": "PDF 분석에 실패했습니다.",
|
"pdf.import.error.analyze": "PDF 분석에 실패했습니다.",
|
||||||
"pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.",
|
"pdf.import.error.no.floorplan": "평면도를 인식하지 못했습니다. 다른 PDF 로 다시 시도해 주세요.",
|
||||||
"pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.",
|
"pdf.import.error.too.few.vertices": "외곽선 정점이 부족합니다. 다른 PDF 로 다시 시도해 주세요.",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user