Compare commits

...

6 Commits

Author SHA1 Message Date
sangwook yoo
654b8d6e08 fix(gemini): 라우트 정리(deleteFile)를 응답에서 분리
finally 에서 await fileManager.deleteFile(...) 가 catch 의 return 을 게이팅해,
정리용 네트워크 호출이 행되면 500 응답조차 클라이언트로 송신되지 않던 문제를 해소한다.

- deleteFile 을 await 하지 않고 fire-and-forget + .catch 로 분리해 응답을 정리 완료에 묶지 않음
- 상주 Node 서버(PM2 next start)이므로 응답 후에도 detached 삭제가 정상 완료되며,
  미삭제 시에도 Gemini 측에서 자동 만료
- 클라이언트 120s 타임아웃과 짝을 이뤄 한쪽이 행되어도 스피너에 갇히지 않음

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:52:20 +09:00
sangwook yoo
49b2d0940f fix(pdf-import): 클라이언트 분석 타임아웃·스피너 해제 보장
PDF 분석 요청 실패/행(hang) 시 전역 스피너가 영구히 멈춰 화면 복귀가
불가능하던 문제를 해소한다.

- 네트워크 구간을 try/finally 로 감싸 성공·실패·타임아웃 어떤 경로든
  스피너(pdfAnalyzing·isGlobalLoading)를 반드시 해제하도록 보장
- AbortController + 120s 타임아웃으로 무한 행 차단
- alert 은 스피너 해제 후 표시(스피너 z-index 가 alert 보다 높아 가려지는 것 방지)
- pdf.import.error.timeout 메시지 키 추가(ko/ja)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:52:10 +09:00
sangwook yoo
761c42a637 feat(pdf-import): 분석 신뢰도 게이팅 + 저신뢰 확인 메시지(ko/ja)
- analyzePdfAndApply에서 confidence < 0.6이면 반영 전 useSwal confirm으로 사용자 확인
- pdf.import.confirm.low.confidence 메시지 키 ko/ja 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:12:15 +09:00
sangwook yoo
0418dd84ee feat(pdf-import): 외곽선 기하 후처리(축정렬 스냅·단순화·자기교차 복구) 추가
- normalizeGeometry 단계 신설: snapNearAxisEdges(축정렬 스냅) → turf.simplify(과다 정점 단순화) → cleanSelfIntersectingPolygon(자기교차 복구)
- applyOuterline의 중복점 제거 직후·fitAndCenter 전에 삽입(스냅→단순화 순서로 축정렬 보존)
- 기존 검증 순수함수 재사용, 신규 의존성 없음

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:12:14 +09:00
sangwook yoo
afda525be9 feat(gemini): 屋根伏図 지붕투영면 외곽선 추출 프롬프트 강화 + media_resolution HIGH
- BASE_PROMPT를 지붕 투영면(屋根伏図 footprint) 기준으로 재작성: 屋根伏図 우선(처마/케라바 포함), 立面図/配置図/求積図/断面図 배제, 도머/개구부 제외
- 寸法線 mm 1순위 축척 규칙, 910/1820mm 모듈 단서, L자/직각 형상 보존 규칙 추가
- generationConfig에 mediaResolution: MEDIA_RESOLUTION_HIGH 추가(미지원 SDK 시 무시), usageMetadata 로깅
- FLOOR_PLAN_SCHEMA 및 응답에 selectedDrawingType 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:12:13 +09:00
sangwook yoo
3e37b7448c Add new HTML report for PDF 도면 → 외벽선 자동작성 기능
This commit introduces a new HTML document detailing the functionality of the PDF 도면 to 외벽선 자동작성 feature, including styles and layout for improved presentation.
2026-05-29 18:24:04 +09:00
6 changed files with 525 additions and 30 deletions

View 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
⑧ 중복점 필터 인접거리 &lt; 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|&lt;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>DouglasPeucker 단순화 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 &lt; 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>

View File

@ -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)
} })
} }
} }
} }

View File

@ -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,27 +165,36 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
} }
setPdfAnalyzing(true) setPdfAnalyzing(true)
let response setIsGlobalLoading(true)
try { const controller = new AbortController()
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData }) const timeoutId = setTimeout(() => controller.abort(), PDF_ANALYZE_TIMEOUT_MS)
} catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze fetch failed', e?.message || e)
setPdfAnalyzing(false)
swalFire({ text: getMessage('pdf.import.error.network'), type: 'alert' })
return false
}
let payload let response = null
let payload = null
let errorKey = null
try {
response = await fetch('/api/gemini/floor-plan', { method: 'POST', body: formData, signal: controller.signal })
try { try {
payload = await response.json() payload = await response.json()
} catch (e) { } catch (e) {
logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e) logger.error('[PlacementShapeSetting] PDF analyze response parse failed', e?.message || e)
errorKey = 'pdf.import.error.analyze'
}
} catch (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.analyze'), type: 'alert' }) setIsGlobalLoading(false)
return false
} }
setPdfAnalyzing(false) // alert/confirm ( z-index alert ).
if (errorKey) {
swalFire({ text: getMessage(errorKey), type: 'alert' })
return 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')
@ -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' })

View File

@ -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)

View File

@ -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 で再度お試しください。",

View File

@ -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 로 다시 시도해 주세요.",