qcast-front/docs/PDF Gemini 평면도 분석 기능 구현 계획.html
sangwook yoo e381ae0ebf chore(pdf-gemini): Gemini API 키 env 추가 + PDF 평면도 분석 구현 계획 문서
- .env.development / .env.production 에 GEMINI_API_KEY 추가 (PDF 평면도 분석 기능용)
- docs/ 에 PDF → Gemini → 캔버스 반영 기능 구현 계획서(html) 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:05:18 +09:00

1072 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF 평면도 → Gemini 분석 → 캔버스 반영 · 구현 계획</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght,SOFT@9..144,300..800,0..100&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
--ink:#100d08;
--ink-1:#171310;
--ink-2:#1e1814;
--ink-3:#26201a;
--line:#2e2620;
--line-soft:#241e19;
--bone:#f3ecde;
--bone-2:#d9cfba;
--bone-3:#a89c84;
--bone-4:#6b6051;
--bone-5:#3f3830;
--amber:#e9a23b;
--amber-soft:#f3c074;
--amber-deep:#b8761c;
--amber-glow:rgba(233,162,59,.18);
--teal:#7fb9b0;
--rose:#d97c6a;
--rose-soft:#e8a896;
--radius:14px;
--shadow-soft:0 1px 0 rgba(255,255,255,.02) inset, 0 24px 60px -30px rgba(0,0,0,.6);
--sans:'Pretendard Variable',Pretendard,system-ui,-apple-system,sans-serif;
--display:'Fraunces','Pretendard Variable',serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
html{scroll-behavior:smooth;scroll-padding-top:96px}
body{
font-family:var(--sans);
background:var(--ink);
color:var(--bone);
font-feature-settings:'ss03','cv11','tnum';
-webkit-font-smoothing:antialiased;
-moz-osx-font-smoothing:grayscale;
line-height:1.7;
font-size:15.5px;
letter-spacing:-0.005em;
}
/* warm grain background */
body::before{
content:'';
position:fixed;inset:0;
pointer-events:none;
background:
radial-gradient(1200px 600px at 78% -10%, rgba(233,162,59,.06), transparent 60%),
radial-gradient(900px 700px at -10% 30%, rgba(127,185,176,.035), transparent 60%);
z-index:0;
}
a{color:inherit;text-decoration:none}
/* ------- top bar ------- */
.topbar{
position:sticky;top:0;z-index:50;
backdrop-filter:saturate(140%) blur(14px);
background:rgba(16,13,8,.78);
border-bottom:1px solid var(--line-soft);
}
.topbar-inner{
max-width:1280px;margin:0 auto;
padding:14px 32px;
display:flex;align-items:center;gap:18px;
font-size:13px;color:var(--bone-3);
}
.topbar .mark{
display:inline-flex;align-items:center;gap:10px;
color:var(--bone);font-family:var(--display);
font-weight:500;font-size:15px;letter-spacing:.01em;
}
.topbar .mark::before{
content:'';display:block;width:9px;height:9px;border-radius:50%;
background:radial-gradient(circle at 30% 30%,var(--amber-soft),var(--amber-deep));
box-shadow:0 0 0 4px var(--amber-glow);
}
.topbar .crumb{font-family:var(--mono);font-size:12px}
.topbar .crumb span{color:var(--bone-4);margin:0 6px}
.topbar .right{margin-left:auto;display:flex;gap:14px;align-items:center}
.topbar .pill{
font-family:var(--mono);font-size:11px;
padding:5px 9px;border:1px solid var(--line);border-radius:999px;
color:var(--bone-2);
}
/* ------- hero ------- */
.hero{
position:relative;z-index:1;
max-width:1280px;margin:0 auto;
padding:88px 32px 56px;
border-bottom:1px solid var(--line-soft);
}
.hero .kicker{
font-family:var(--mono);font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;
color:var(--amber);display:inline-flex;align-items:center;gap:10px;
}
.hero .kicker::before{
content:'';width:32px;height:1px;background:var(--amber);display:block;
}
.hero h1{
font-family:var(--display);
font-weight:380;
font-size:clamp(38px,5.4vw,72px);
line-height:1.06;
letter-spacing:-0.025em;
margin:18px 0 0;
color:var(--bone);
font-variation-settings:'opsz' 144,'SOFT' 30;
}
.hero h1 em{
font-style:italic;font-weight:380;color:var(--amber-soft);
font-variation-settings:'opsz' 144,'SOFT' 80;
}
.hero .lead{
margin-top:22px;max-width:760px;
color:var(--bone-2);font-size:17px;line-height:1.65;
}
.meta{
margin-top:42px;display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;
}
.meta .chip{
border:1px solid var(--line);
background:linear-gradient(180deg,rgba(255,255,255,.012),rgba(255,255,255,0));
border-radius:12px;padding:14px 16px;
display:flex;flex-direction:column;gap:6px;
min-height:78px;
}
.meta .chip .k{
font-family:var(--mono);font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;
color:var(--bone-4);
}
.meta .chip .v{
font-family:var(--mono);font-size:13.5px;color:var(--bone);font-weight:500;
word-break:break-all;
}
.meta .chip.accent .v{color:var(--amber-soft)}
@media (max-width:880px){
.meta{grid-template-columns:repeat(2,1fr)}
}
/* ------- layout ------- */
.wrap{
max-width:1280px;margin:0 auto;
padding:64px 32px 120px;
display:grid;
grid-template-columns:260px minmax(0,1fr);
gap:72px;
position:relative;z-index:1;
}
@media (max-width:980px){
.wrap{grid-template-columns:1fr;gap:32px;padding:40px 24px 80px}
}
/* ------- TOC ------- */
aside.toc{
position:sticky;top:84px;align-self:start;
height:max-content;
}
aside.toc .toc-label{
font-family:var(--mono);font-size:10.5px;letter-spacing:.18em;
text-transform:uppercase;color:var(--bone-4);
padding-bottom:14px;border-bottom:1px solid var(--line-soft);
margin-bottom:14px;
}
aside.toc ol{
list-style:none;padding:0;margin:0;
counter-reset:toc;
display:flex;flex-direction:column;gap:2px;
}
aside.toc li a{
display:flex;gap:14px;align-items:baseline;
padding:9px 4px;
color:var(--bone-3);font-size:13.5px;line-height:1.4;
border-left:2px solid transparent;
padding-left:14px;margin-left:-14px;
transition:color .2s,border-color .2s,background .2s;
}
aside.toc li a::before{
counter-increment:toc;
content:counter(toc,decimal-leading-zero);
font-family:var(--mono);font-size:11px;color:var(--bone-5);
flex-shrink:0;letter-spacing:.04em;
}
aside.toc li a:hover{color:var(--bone)}
aside.toc li a.active{
color:var(--amber-soft);
border-left-color:var(--amber);
background:linear-gradient(90deg,var(--amber-glow),transparent 60%);
}
aside.toc li a.active::before{color:var(--amber)}
@media (max-width:980px){
aside.toc{position:relative;top:0;border:1px solid var(--line);border-radius:12px;padding:16px}
aside.toc .toc-label{border-bottom:none;padding-bottom:6px;margin-bottom:6px}
}
/* ------- sections ------- */
section.sec{
scroll-margin-top:96px;
padding-top:18px;
padding-bottom:64px;
border-bottom:1px solid var(--line-soft);
}
section.sec:last-child{border-bottom:none}
.sec-head{
display:flex;gap:24px;align-items:flex-start;
margin-bottom:32px;
}
.sec-head .num{
font-family:var(--display);font-style:italic;
font-weight:300;font-size:60px;line-height:1;
color:transparent;
-webkit-text-stroke:1px var(--bone-5);
letter-spacing:-0.02em;
flex-shrink:0;min-width:84px;
font-variation-settings:'opsz' 144,'SOFT' 100;
}
.sec-head .num strong{
color:var(--amber);-webkit-text-stroke:0;
}
.sec-head h2{
font-family:var(--display);
font-weight:430;font-size:30px;line-height:1.2;
margin:8px 0 6px;letter-spacing:-0.015em;
color:var(--bone);
font-variation-settings:'opsz' 60,'SOFT' 40;
}
.sec-head .sub{
font-family:var(--mono);font-size:11.5px;letter-spacing:.14em;
text-transform:uppercase;color:var(--bone-4);
}
p{margin:0 0 14px;color:var(--bone-2)}
ul,ol{padding-left:1.25em;margin:0 0 16px;color:var(--bone-2)}
ul li,ol li{margin:6px 0}
strong{color:var(--bone);font-weight:600}
code{
font-family:var(--mono);font-size:.88em;
background:rgba(255,255,255,.04);
padding:1px 6px;border-radius:5px;
color:var(--bone);border:1px solid var(--line-soft);
}
/* ------- requirement list (sec 1) ------- */
.reqs{
display:grid;grid-template-columns:repeat(2,1fr);gap:14px;
margin-top:8px;
}
.reqs .req{
border:1px solid var(--line);
background:var(--ink-1);
border-radius:12px;padding:18px 20px;
display:flex;gap:16px;
}
.reqs .req .n{
font-family:var(--mono);font-size:12px;color:var(--amber);
flex-shrink:0;padding-top:2px;letter-spacing:.04em;
}
.reqs .req p{margin:0}
@media (max-width:760px){.reqs{grid-template-columns:1fr}}
/* ------- decisions table (sec 2) ------- */
.decisions{
border:1px solid var(--line);border-radius:14px;overflow:hidden;
background:var(--ink-1);
}
.decisions .row{
display:grid;grid-template-columns:220px 1fr;
border-top:1px solid var(--line-soft);
}
.decisions .row:first-child{border-top:none}
.decisions .k{
padding:14px 20px;
font-family:var(--mono);font-size:12px;
color:var(--bone-3);letter-spacing:.04em;
border-right:1px solid var(--line-soft);
background:rgba(255,255,255,.012);
}
.decisions .v{
padding:14px 20px;color:var(--bone);font-size:14.5px;
}
.decisions .v.accent{color:var(--amber-soft)}
.decisions .v code{font-size:13px}
@media (max-width:600px){
.decisions .row{grid-template-columns:1fr}
.decisions .k{border-right:none;border-bottom:1px solid var(--line-soft)}
}
/* ------- flow diagram (sec 3) ------- */
.flow{
margin:8px 0 8px;
display:flex;flex-direction:column;gap:0;
border:1px solid var(--line);border-radius:14px;
padding:36px 28px;
background:
radial-gradient(420px 220px at 80% 0%, rgba(233,162,59,.07), transparent 60%),
var(--ink-1);
position:relative;overflow:hidden;
}
.flow .lane{
display:flex;align-items:center;gap:18px;
padding:6px 0;
}
.flow .node{
flex:1;
border:1px solid var(--line);
background:var(--ink-2);
border-radius:10px;
padding:14px 18px;
display:flex;flex-direction:column;gap:4px;
min-height:64px;justify-content:center;
position:relative;
}
.flow .node .t{
font-family:var(--mono);font-size:10.5px;letter-spacing:.16em;
text-transform:uppercase;color:var(--bone-4);
}
.flow .node .l{
font-size:14.5px;color:var(--bone);font-weight:500;
}
.flow .node.client{border-color:rgba(127,185,176,.35)}
.flow .node.client .t{color:var(--teal)}
.flow .node.server{border-color:rgba(233,162,59,.4)}
.flow .node.server .t{color:var(--amber)}
.flow .node.success{
border-color:rgba(233,162,59,.55);
background:linear-gradient(180deg,rgba(233,162,59,.08),rgba(233,162,59,0));
}
.flow .arrow{
display:flex;justify-content:center;align-items:center;
height:28px;
}
.flow .arrow .line{
width:2px;height:100%;
background:linear-gradient(180deg,var(--line) 0%,var(--amber) 90%);
position:relative;
}
.flow .arrow .line::after{
content:'';position:absolute;left:50%;bottom:-1px;
transform:translateX(-50%);
width:0;height:0;
border-left:5px solid transparent;
border-right:5px solid transparent;
border-top:7px solid var(--amber);
}
.flow .arrow .lbl{
font-family:var(--mono);font-size:10.5px;
color:var(--bone-4);margin-left:14px;letter-spacing:.06em;
}
/* ------- file table (sec 4) ------- */
.files{
border:1px solid var(--line);border-radius:14px;overflow:hidden;
background:var(--ink-1);
}
.files .head,.files .row{
display:grid;grid-template-columns:160px minmax(0,1.2fr) minmax(0,1.6fr) 88px;
align-items:center;
}
.files .head{
background:rgba(255,255,255,.014);
border-bottom:1px solid var(--line);
font-family:var(--mono);font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;
color:var(--bone-4);
}
.files .head > div,.files .row > div{
padding:13px 18px;border-right:1px solid var(--line-soft);
}
.files .head > div:last-child,.files .row > div:last-child{border-right:none}
.files .row{border-top:1px solid var(--line-soft)}
.files .row:hover{background:rgba(255,255,255,.015)}
.files .row .area{color:var(--bone);font-size:14px}
.files .row .path{font-family:var(--mono);font-size:12.5px;color:var(--bone-2);word-break:break-all}
.files .row .desc{color:var(--bone-2);font-size:13.5px}
.badge{
display:inline-flex;align-items:center;gap:6px;
font-family:var(--mono);font-size:10.5px;letter-spacing:.1em;
padding:4px 10px;border-radius:999px;font-weight:600;text-transform:uppercase;
}
.badge.new{background:rgba(233,162,59,.14);color:var(--amber);border:1px solid rgba(233,162,59,.35)}
.badge.edit{background:rgba(127,185,176,.10);color:var(--teal);border:1px solid rgba(127,185,176,.3)}
@media (max-width:880px){
.files .head{display:none}
.files .row{grid-template-columns:1fr;gap:4px;padding:14px 16px}
.files .row > div{padding:4px 0;border-right:none}
}
/* ------- 4 cards grid (sec 5) ------- */
.quad{
display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;
}
.quad .card{
border:1px solid var(--line);background:var(--ink-1);
border-radius:14px;padding:22px 22px 20px;
position:relative;overflow:hidden;
}
.quad .card::before{
content:'';position:absolute;left:0;top:0;height:2px;width:48px;
background:var(--amber);
}
.quad .card .ttl{
display:flex;align-items:center;gap:12px;margin-bottom:14px;
}
.quad .card .ttl .ix{
font-family:var(--mono);font-size:10.5px;color:var(--amber);letter-spacing:.16em;
}
.quad .card h4{
margin:0;font-family:var(--display);font-weight:430;font-size:20px;letter-spacing:-0.01em;
color:var(--bone);
font-variation-settings:'opsz' 60;
}
.quad .card ul{padding-left:1em;margin:0}
.quad .card li{font-size:13.5px;color:var(--bone-2);margin:5px 0}
.quad .card li code{font-size:12px}
@media (max-width:760px){.quad{grid-template-columns:1fr}}
/* generic content cards (sec 6 etc) */
.twocol{
display:grid;grid-template-columns:repeat(2,1fr);gap:16px;
}
.twocol .card{
border:1px solid var(--line);background:var(--ink-1);
border-radius:14px;padding:22px 24px;
}
.twocol .card h4{
font-family:var(--display);font-weight:430;font-size:18px;margin:0 0 12px;color:var(--bone);
font-variation-settings:'opsz' 36;
}
.twocol .card h4 .tag{
font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;
color:var(--amber);text-transform:uppercase;margin-right:10px;
}
.twocol .card ul{padding-left:1em;margin:0}
.twocol .card li{font-size:13.5px;margin:6px 0;color:var(--bone-2)}
@media (max-width:760px){.twocol{grid-template-columns:1fr}}
/* ------- edge case table (sec 7) ------- */
.edge{
display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;
}
.edge .item{
border:1px solid var(--line);background:var(--ink-1);
border-radius:12px;padding:16px 18px;
display:flex;flex-direction:column;gap:10px;
transition:border-color .2s,transform .2s;
}
.edge .item:hover{border-color:rgba(233,162,59,.4);transform:translateY(-1px)}
.edge .item .case{
font-size:14px;color:var(--bone);font-weight:500;
display:flex;align-items:flex-start;gap:10px;
}
.edge .item .case::before{
content:'';flex-shrink:0;margin-top:7px;
width:6px;height:6px;border-radius:50%;background:var(--rose-soft);
box-shadow:0 0 0 3px rgba(217,124,106,.15);
}
.edge .item .resp{
font-size:13px;color:var(--bone-3);padding-left:16px;
border-left:1px solid var(--line);
line-height:1.55;
}
.edge .item .resp code{font-size:11.5px}
@media (max-width:760px){.edge{grid-template-columns:1fr}}
/* ------- conventions (sec 8) ------- */
.conv-grid{
display:grid;grid-template-columns:1fr 1fr;gap:18px;
margin-bottom:24px;
}
.conv-grid .block{
border:1px solid var(--line);background:var(--ink-1);
border-radius:14px;padding:20px 22px;
}
.conv-grid .block h4{
font-family:var(--mono);font-size:11px;letter-spacing:.16em;
text-transform:uppercase;color:var(--amber);margin:0 0 12px;
}
.conv-grid ul{margin:0;padding-left:1.1em}
.conv-grid li{font-size:13.5px;color:var(--bone-2)}
@media (max-width:760px){.conv-grid{grid-template-columns:1fr}}
.commits{
counter-reset:com;
display:flex;flex-direction:column;gap:8px;
}
.commits .commit{
counter-increment:com;
display:flex;gap:14px;align-items:center;
border:1px solid var(--line);background:var(--ink-1);
border-radius:10px;padding:11px 16px;
font-family:var(--mono);font-size:12.5px;color:var(--bone-2);
}
.commits .commit::before{
content:counter(com,decimal-leading-zero);
color:var(--amber);font-size:11px;letter-spacing:.06em;
width:24px;flex-shrink:0;
}
.commits .commit b{color:var(--bone);font-weight:600;margin-right:8px}
/* ------- timeline (sec 9) ------- */
.tline{
position:relative;padding-left:42px;
counter-reset:step;
}
.tline::before{
content:'';position:absolute;left:14px;top:8px;bottom:8px;
width:1px;
background:linear-gradient(180deg,var(--amber) 0%,var(--line) 40%,var(--line) 60%,var(--amber) 100%);
opacity:.6;
}
.tline .step{
counter-increment:step;
position:relative;
padding:14px 0 16px;
border-bottom:1px dashed var(--line-soft);
}
.tline .step:last-child{border-bottom:none}
.tline .step::before{
content:counter(step,decimal-leading-zero);
position:absolute;left:-42px;top:14px;
width:28px;height:28px;border-radius:50%;
background:var(--ink);border:1px solid var(--amber);
color:var(--amber);
font-family:var(--mono);font-size:11px;font-weight:600;
display:flex;align-items:center;justify-content:center;
letter-spacing:.02em;
}
.tline .step .tt{
font-size:15px;color:var(--bone);margin-bottom:4px;font-weight:500;
}
.tline .step .dd{
font-size:13.5px;color:var(--bone-3);
}
.tline .step .dd code{font-size:12px}
/* ------- footer ------- */
footer.foot{
max-width:1280px;margin:0 auto;
padding:36px 32px 56px;
border-top:1px solid var(--line-soft);
display:flex;justify-content:space-between;align-items:center;gap:24px;
color:var(--bone-4);font-family:var(--mono);font-size:11px;
letter-spacing:.06em;flex-wrap:wrap;
}
footer.foot .sig{color:var(--bone-3)}
footer.foot .sig em{color:var(--amber);font-style:normal}
/* ------- print ------- */
@media print{
body{background:#fff;color:#1a1610}
body::before{display:none}
.topbar{position:static;background:#fff;border-color:#e5e0d6}
.topbar .pill,.topbar .mark,.topbar .crumb{color:#1a1610}
.topbar .pill{border-color:#d8d2c4}
.hero h1,.hero h1 em{color:#1a1610;-webkit-text-fill-color:#1a1610}
.hero .lead,p,li,.decisions .v,.files .row .area,.files .row .desc,
.quad .card li,.twocol .card li,.edge .item .resp,.conv-grid li,
.tline .step .dd{color:#3a342a}
.meta .chip,.decisions,.files,.flow,.quad .card,.twocol .card,
.edge .item,.conv-grid .block,.commits .commit,.tline .step::before{
background:#fff;border-color:#d8d2c4;color:#1a1610;
}
.sec-head h2,.quad .card h4,.twocol .card h4{color:#1a1610}
.sec-head .num{-webkit-text-stroke:1px #b5ad9b}
aside.toc{display:none}
.wrap{grid-template-columns:1fr;padding-top:32px}
section.sec{break-inside:avoid;border-color:#e5e0d6;padding-bottom:32px}
.flow .node{background:#faf7ef}
code{background:#f3ecde;border-color:#d8d2c4;color:#1a1610}
.badge.new{background:#fdecd0;color:#8a5a18;border-color:#e6c98c}
.badge.edit{background:#e6f1ee;color:#3a6f66;border-color:#bdd8d2}
}
/* utility */
.note{
margin-top:10px;font-size:12.5px;color:var(--bone-4);
font-family:var(--mono);letter-spacing:.02em;
}
</style>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<span class="mark">Q.CAST</span>
<span class="crumb">docs <span>/</span> floor-plan <span>/</span> pdf-import</span>
<span class="right">
<span class="pill">v0.1 · draft</span>
<span class="pill">2026 · 05 · 18</span>
</span>
</div>
</header>
<section class="hero">
<div class="kicker">Engineering Spec · Floor Plan / PDF Import</div>
<h1>PDF 평면도 <em></em> Gemini 분석 <em></em> 캔버스 반영 구현 계획</h1>
<p class="lead">
사용자가 업로드한 평면도 PDF 를 Gemini 멀티모달 모델로 분석해
건물 외곽선 폴리곤 좌표(mm)를 얻고, Fabric.js 기반 floor-plan
캔버스에 자동으로 반영한다. 업로드 파일은 분석 종료와 동시에
Gemini File API 측에서 폐기한다.
</p>
<div class="meta">
<div class="chip accent">
<span class="k">모델</span>
<span class="v">gemini-3.1-pro-preview</span>
</div>
<div class="chip">
<span class="k">좌표 단위</span>
<span class="v">mm · clockwise</span>
</div>
<div class="chip">
<span class="k">진입 위치</span>
<span class="v">floor-plan toolbar</span>
</div>
<div class="chip">
<span class="k">브랜치</span>
<span class="v">claude/lucid-shaw-91e51f</span>
</div>
</div>
</section>
<div class="wrap">
<aside class="toc" aria-label="목차">
<div class="toc-label">Contents</div>
<ol>
<li><a href="#s1" class="active">기능 요구 사항</a></li>
<li><a href="#s2">결정사항</a></li>
<li><a href="#s3">전체 흐름</a></li>
<li><a href="#s4">핵심 파일</a></li>
<li><a href="#s5">서버 라우트 설계</a></li>
<li><a href="#s6">클라이언트 흐름</a></li>
<li><a href="#s7">검증 / 엣지 케이스</a></li>
<li><a href="#s8">컨벤션 / 운영</a></li>
<li><a href="#s9">작업 순서</a></li>
</ol>
</aside>
<main>
<!-- 1 -->
<section class="sec" id="s1">
<div class="sec-head">
<div class="num"><strong>01</strong></div>
<div>
<div class="sub">Requirements</div>
<h2>기능 요구 사항</h2>
</div>
</div>
<div class="reqs">
<div class="req"><span class="n">REQ-01</span><p>기본 프로세스 상에서 특정 버튼 클릭으로 사용자가 PDF 문서를 업로드한다.</p></div>
<div class="req"><span class="n">REQ-02</span><p>Gemini API 로 해당 문서의 내용 중 평면도를 분석한다.</p></div>
<div class="req"><span class="n">REQ-03</span><p>분석 내용을 응답으로 전달받고 현재 프로세스(캔버스)로 전달한다.</p></div>
<div class="req"><span class="n">REQ-04</span><p>분석이 성공적으로 마무리되면 업로드한 파일은 폐기 처리한다.</p></div>
</div>
</section>
<!-- 2 -->
<section class="sec" id="s2">
<div class="sec-head">
<div class="num"><strong>02</strong></div>
<div>
<div class="sub">Decisions</div>
<h2>결정사항</h2>
</div>
</div>
<div class="decisions">
<div class="row"><div class="k">워크트리</div><div class="v">현재 워크트리 <code>lucid-shaw-91e51f</code> 에서 진행</div></div>
<div class="row"><div class="k">캔버스 반영 형태</div><div class="v">외곽선 폴리곤 좌표(JSON)</div></div>
<div class="row"><div class="k">버튼 위치</div><div class="v">floor-plan 상단 툴바</div></div>
<div class="row"><div class="k">API 호출 위치</div><div class="v">서버 라우트 <code>/api/gemini/floor-plan</code></div></div>
<div class="row"><div class="k">Gemini 모델</div><div class="v accent"><code>gemini-3.1-pro-preview</code></div></div>
<div class="row"><div class="k">좌표 단위</div><div class="v accent"><code>mm</code></div></div>
<div class="row"><div class="k">툴바 아이콘</div><div class="v">신규 PDF 아이콘 추가</div></div>
</div>
</section>
<!-- 3 -->
<section class="sec" id="s3">
<div class="sec-head">
<div class="num"><strong>03</strong></div>
<div>
<div class="sub">End-to-end Flow</div>
<h2>전체 흐름</h2>
</div>
</div>
<div class="flow">
<div class="lane">
<div class="node client">
<span class="t">Client · UI</span>
<span class="l">툴바 버튼 클릭</span>
</div>
</div>
<div class="arrow"><div class="line"></div></div>
<div class="lane">
<div class="node client">
<span class="t">Client · Modal</span>
<span class="l">PdfImportModal — 파일 선택 및 업로드</span>
</div>
</div>
<div class="arrow"><div class="line"></div></div>
<div class="lane">
<div class="node server">
<span class="t">Server · Route</span>
<span class="l">POST /api/gemini/floor-plan (multipart)</span>
</div>
</div>
<div class="arrow"><div class="line"></div></div>
<div class="lane">
<div class="node server">
<span class="t">Gemini · File API</span>
<span class="l">PDF 업로드 → fileUri 획득</span>
</div>
</div>
<div class="arrow"><div class="line"></div></div>
<div class="lane">
<div class="node server">
<span class="t">Gemini · gemini-3.1-pro-preview</span>
<span class="l">외곽선 좌표 JSON 응답 (mm 단위)</span>
</div>
</div>
<div class="arrow"><div class="line"></div></div>
<div class="lane">
<div class="node server">
<span class="t">Server · finally</span>
<span class="l">Gemini 파일 삭제 (성공 / 실패 무관)</span>
</div>
</div>
<div class="arrow"><div class="line"></div></div>
<div class="lane">
<div class="node success">
<span class="t">Client · Canvas</span>
<span class="l">좌표 검증 → outerLinePointsState + QLine / QPolygon 생성</span>
</div>
</div>
</div>
<p class="note"> 클라이언트는 서버 라우트만 호출하며 Gemini API 키와 직접 통신하지 않는다.</p>
</section>
<!-- 4 -->
<section class="sec" id="s4">
<div class="sec-head">
<div class="num"><strong>04</strong></div>
<div>
<div class="sub">Files</div>
<h2>핵심 파일 (수정 / 신규)</h2>
</div>
</div>
<div class="files">
<div class="head">
<div>영역</div><div>경로</div><div>변경 내용</div><div>구분</div>
</div>
<div class="row">
<div class="area">서버 라우트</div>
<div class="path">src/app/api/gemini/floor-plan/route.js</div>
<div class="desc">multipart 수신 + Gemini 호출 + finally 폐기</div>
<div><span class="badge new">신규</span></div>
</div>
<div class="row">
<div class="area">환경설정</div>
<div class="path">src/config/config.*.js</div>
<div class="desc"><code>GEMINI_API_KEY</code>, <code>GEMINI_MODEL</code> 추가</div>
<div><span class="badge edit">수정</span></div>
</div>
<div class="row">
<div class="area">의존성</div>
<div class="path">package.json</div>
<div class="desc"><code>@google/generative-ai</code> 추가</div>
<div><span class="badge edit">수정</span></div>
</div>
<div class="row">
<div class="area">메뉴 등록</div>
<div class="path">src/store/menuAtom.js (menusState)</div>
<div class="desc"><code>{ type: 'pdf-import', name, icon }</code> 항목</div>
<div><span class="badge edit">수정</span></div>
</div>
<div class="row">
<div class="area">툴바 분기</div>
<div class="path">src/components/floor-plan/CanvasMenu.jsx (onClickNav)</div>
<div class="desc"><code>case 'pdf-import'</code> 추가</div>
<div><span class="badge edit">수정</span></div>
</div>
<div class="row">
<div class="area">업로드 모달</div>
<div class="path">src/components/floor-plan/modal/pdfImport/PdfImportModal.jsx</div>
<div class="desc">파일 선택 / 진행 / 에러 상태머신</div>
<div><span class="badge new">신규</span></div>
</div>
<div class="row">
<div class="area">캔버스 반영 hook</div>
<div class="path">src/hooks/pdf-import/usePdfImport.js</div>
<div class="desc">좌표 검증 + 단위 변환 + 캔버스 반영</div>
<div><span class="badge new">신규</span></div>
</div>
<div class="row">
<div class="area">다국어 메시지</div>
<div class="path">메시지 키 추가 — pdf.import.*</div>
<div class="desc">ja / ko 양쪽</div>
<div><span class="badge edit">수정</span></div>
</div>
<div class="row">
<div class="area">아이콘</div>
<div class="path">public/ 또는 기존 아이콘 디렉토리</div>
<div class="desc">PDF 임포트용 SVG 추가</div>
<div><span class="badge new">신규</span></div>
</div>
</div>
</section>
<!-- 5 -->
<section class="sec" id="s5">
<div class="sec-head">
<div class="num"><strong>05</strong></div>
<div>
<div class="sub">Server Route · /api/gemini/floor-plan</div>
<h2>서버 라우트 설계</h2>
</div>
</div>
<div class="quad">
<div class="card">
<div class="ttl"><span class="ix">5 · 1</span><h4>입력 (Input)</h4></div>
<ul>
<li><strong>Content-Type</strong> · <code>multipart/form-data</code></li>
<li><strong>file</strong> · PDF (≤ 20MB, MIME <code>application/pdf</code>)</li>
<li><strong>unitHint</strong> (옵션) · <code>mm</code> · <code>auto</code></li>
</ul>
</div>
<div class="card">
<div class="ttl"><span class="ix">5 · 2</span><h4>처리 (Pipeline)</h4></div>
<ul>
<li><code>formData.get('file')</code> → 사이즈 / MIME 검증</li>
<li><code>GoogleGenerativeAI(GEMINI_API_KEY)</code> 클라이언트 생성</li>
<li>Gemini File API 업로드 → <code>fileUri</code> 획득</li>
<li><code>gemini-3.1-pro-preview</code> 호출 · 외곽선 추출 프롬프트 + JSON 강제</li>
<li>응답 JSON 파싱 + 좌표 검증 (vertex ≥ 3, 자기교차 / 0면적 거부)</li>
<li><strong>finally</strong> · <code>genai.files.delete(uploadedFile.name)</code></li>
</ul>
</div>
<div class="card">
<div class="ttl"><span class="ix">5 · 3</span><h4>출력 (Output)</h4></div>
<ul>
<li><strong>성공</strong> · <code>{ outerline:[{x,y},…], unit:'mm', confidence }</code></li>
<li><strong>실패</strong> · <code>{ error:{ code, message } }</code></li>
<li>응답 스키마: <code>{ outerline, unit:'mm', scale?, confidence:0..1, notes? }</code></li>
<li><code>response_mime_type=application/json</code> · <code>response_schema</code> 강제</li>
</ul>
</div>
<div class="card">
<div class="ttl"><span class="ix">5 · 4</span><h4>보안 / 운영 (Ops)</h4></div>
<ul>
<li><code>GEMINI_API_KEY</code> 는 서버 env 만 사용 · 클라이언트 노출 금지</li>
<li>S3 등 영구 저장소에 절대 저장하지 않음 (REQ-04 폐기)</li>
<li>로깅은 <code>src/util/logger.js</code> 만 사용</li>
<li>raw 응답 출력은 <code>NEXT_PUBLIC_ENABLE_LOGGING=true</code> 일 때만</li>
</ul>
</div>
</div>
</section>
<!-- 6 -->
<section class="sec" id="s6">
<div class="sec-head">
<div class="num"><strong>06</strong></div>
<div>
<div class="sub">Client Flow</div>
<h2>클라이언트 흐름</h2>
</div>
</div>
<div class="twocol">
<div class="card">
<h4><span class="tag">6 · 1</span>모달 <code>PdfImportModal</code></h4>
<ul>
<li>상태머신 · <code>idle → uploading → analyzing → success | error</code></li>
<li>호출 · <code>fetch('/api/gemini/floor-plan', { method:'POST', body: formData })</code></li>
<li>성공 시 <code>usePdfImport.applyOuterline(points, unit)</code> 호출 후 모달 닫기</li>
<li>실패 시 <code>useSwal</code> 로 에러 메시지 표시</li>
</ul>
</div>
<div class="card">
<h4><span class="tag">6 · 2</span>캔버스 반영 hook <code>usePdfImport</code></h4>
<ul>
<li>단위 변환 · <code>unit === 'mm'</code> → canvas px/mm 비율로 변환</li>
<li>정규화 · 시계방향 정렬 + bounding box 중앙 정렬(translate)</li>
<li>기존 외곽선 존재 시 <code>useSwal</code> confirm — 덮어쓰기 분기</li>
<li>점 → <code>name === 'outerLine'</code> QLine 객체 add</li>
<li><code>setOuterLinePoints(points)</code><code>addPolygonByLines(lines)</code> 재사용</li>
<li><code>canvas.renderAll()</code></li>
</ul>
</div>
</div>
</section>
<!-- 7 -->
<section class="sec" id="s7">
<div class="sec-head">
<div class="num"><strong>07</strong></div>
<div>
<div class="sub">Edge Cases</div>
<h2>검증 / 엣지 케이스</h2>
</div>
</div>
<div class="edge">
<div class="item"><div class="case">PDF 가 아닌 파일</div><div class="resp">라우트에서 MIME 거부</div></div>
<div class="item"><div class="case">평면도가 아닌 PDF</div><div class="resp">모델이 빈 outerline 반환 → 친화적 메시지</div></div>
<div class="item"><div class="case">좌표 ≤ 2개 / 자기교차 / 0면적</div><div class="resp">라우트에서 거부 (<code>SizeSetting</code> 가드와 일관)</div></div>
<div class="item"><div class="case">모델 JSON 파싱 실패</div><div class="resp">retry 1회 후 실패 메시지</div></div>
<div class="item"><div class="case">Gemini 호출 실패</div><div class="resp">finally 에서 File API 삭제 보장</div></div>
<div class="item"><div class="case">캔버스 기존 외곽선 존재</div><div class="resp">confirm 후 덮어쓰기</div></div>
<div class="item"><div class="case">응답이 거대한 폴리곤(&gt; 500 vertex)</div><div class="resp">DouglasPeucker 단순화 또는 거부</div></div>
<div class="item"><div class="case">업로드 파일 크기 초과</div><div class="resp">클라이언트 / 서버 양쪽 가드</div></div>
<div class="item"><div class="case">네트워크 실패</div><div class="resp">모달에 재시도 버튼 + 파일 폐기 보장</div></div>
</div>
</section>
<!-- 8 -->
<section class="sec" id="s8">
<div class="sec-head">
<div class="num"><strong>08</strong></div>
<div>
<div class="sub">Conventions · Ops</div>
<h2>컨벤션 / 운영</h2>
</div>
</div>
<div class="conv-grid">
<div class="block">
<h4>Formatting</h4>
<ul>
<li>Prettier · single quote · <strong>no semicolons</strong></li>
<li><code>tabWidth: 2</code> · <code>printWidth: 150</code></li>
<li>경로 alias <code>@/...</code> 우선 (상대경로 체이닝 지양)</li>
</ul>
</div>
<div class="block">
<h4>Messaging / I18n</h4>
<ul>
<li>모든 알림 · 다이얼로그는 <code>useSwal</code> + <code>useMessage</code></li>
<li>raw <code>alert()</code> 금지</li>
<li>다국어 키 <code>pdf.import.*</code> ja / ko 양쪽 추가</li>
</ul>
</div>
<div class="block">
<h4>Logging</h4>
<ul>
<li><code>console.log</code> 직접 사용 금지</li>
<li><code>src/util/logger.js</code> · <code>NEXT_PUBLIC_ENABLE_LOGGING</code> 게이트</li>
<li>Gemini raw 응답은 로깅 플래그 ON 시에만 출력</li>
</ul>
</div>
<div class="block">
<h4>Verification</h4>
<ul>
<li>마지막에 lint / type / build subagent 점검</li>
<li>dev 에서 실제 평면도 PDF 1~2건 manual test</li>
</ul>
</div>
</div>
<div class="commits" aria-label="commit units">
<div class="commit"><b>[xxxx] feat(api)</b> — Gemini 평면도 분석 라우트 신설</div>
<div class="commit"><b>[xxxx] feat(floor-plan)</b> — PDF 임포트 모달 / 툴바 진입</div>
<div class="commit"><b>[xxxx] feat(floor-plan)</b> — 분석 결과 외곽선 캔버스 반영</div>
<div class="commit"><b>[xxxx] chore</b> — 다국어 메시지 / lint 정리</div>
</div>
</section>
<!-- 9 -->
<section class="sec" id="s9">
<div class="sec-head">
<div class="num"><strong>09</strong></div>
<div>
<div class="sub">Work Order</div>
<h2>작업 순서</h2>
</div>
</div>
<div class="tline">
<div class="step">
<div class="tt"><code>@google/generative-ai</code> 의존성 추가</div>
<div class="dd"><code>GEMINI_API_KEY</code> · <code>GEMINI_MODEL</code> env / config 매핑</div>
</div>
<div class="step">
<div class="tt">서버 라우트 <code>src/app/api/gemini/floor-plan/route.js</code> 작성</div>
<div class="dd">Gemini 호출 + 파일 폐기 (try / finally)</div>
</div>
<div class="step">
<div class="tt"><code>menusState</code><code>pdf-import</code> 항목 + PDF 아이콘 SVG 추가</div>
<div class="dd">기존 menusState 아이콘 컨벤션에 맞춤</div>
</div>
<div class="step">
<div class="tt"><code>CanvasMenu.jsx</code> <code>onClickNav</code> switch 에 케이스 추가</div>
<div class="dd"><code>modalAtom</code> 으로 PdfImportModal 오픈</div>
</div>
<div class="step">
<div class="tt"><code>PdfImportModal.jsx</code> 작성</div>
<div class="dd">idle → uploading → analyzing → success / error 상태머신</div>
</div>
<div class="step">
<div class="tt"><code>usePdfImport.js</code> 작성</div>
<div class="dd">단위 / 방향 정규화 · 외곽선 덮어쓰기 분기 · QLine / QPolygon 반영</div>
</div>
<div class="step">
<div class="tt">다국어 메시지 키 추가</div>
<div class="dd"><code>pdf.import.*</code> · ja / ko 양쪽</div>
</div>
<div class="step">
<div class="tt"><code>yarn lint</code> + dev 환경 manual test</div>
<div class="dd">실제 평면도 PDF 1~2건으로 회귀 확인</div>
</div>
<div class="step">
<div class="tt">의미 단위로 커밋</div>
<div class="dd">§ 8 의 commit 단위 표 참고</div>
</div>
</div>
</section>
</main>
</div>
<footer class="foot">
<span class="sig">Q.CAST · Engineering Spec — <em>floor-plan / pdf-import</em></span>
<span>worktree · claude/lucid-shaw-91e51f</span>
<span>2026 · 05 · 18</span>
</footer>
<script>
// sticky TOC 활성화 — IntersectionObserver
const links = document.querySelectorAll('aside.toc a')
const targets = document.querySelectorAll('section.sec')
const map = new Map()
links.forEach(a => map.set(a.getAttribute('href').slice(1), a))
const io = new IntersectionObserver(
entries => {
entries.forEach(en => {
if (en.isIntersecting) {
links.forEach(a => a.classList.remove('active'))
const link = map.get(en.target.id)
if (link) link.classList.add('active')
}
})
},
{ rootMargin: '-30% 0px -60% 0px', threshold: 0 }
)
targets.forEach(t => io.observe(t))
</script>
</body>
</html>