- .env.development / .env.production 에 GEMINI_API_KEY 추가 (PDF 평면도 분석 기능용) - docs/ 에 PDF → Gemini → 캔버스 반영 기능 구현 계획서(html) 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1072 lines
40 KiB
HTML
1072 lines
40 KiB
HTML
<!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">응답이 거대한 폴리곤(> 500 vertex)</div><div class="resp">Douglas–Peucker 단순화 또는 거부</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>
|