Update Pier_v1 project

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
CopilotandCopilot committed 2026-11-03 12:04:58 +09:00
1 parent f25e36c050
commit ad73ddec2b
2476 files changed
+3423 -4902

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process';
import { existsSync, statSync, realpathSync } from 'node:fs';
import { mkdir, readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Plugin } from 'vite';
export const DRAWING_API_PATH = '/__pierzainer/drawing-extractor';
type Book = { name: string; xlsx_path: string; review_count: number };
type Result = { status: string; output_dir: string; review_count: number; failed_file_count: number; workbooks: Book[] };
type Job = { id: string; folder: string; output: string; status: 'running' | 'completed' | 'needs_review' | 'error'; logs: string[]; error?: string; result?: Result; child?: ChildProcess };
type Runtime = {
platform?: NodeJS.Platform;
exists?: (file: string) => boolean;
isDirectory?: (folder: string) => boolean;
start?: (file: string, args: string[], options: SpawnOptions) => ChildProcess;
};
/** Popup adapter: reuse the packaged engine headlessly; keep outputs inside this project. */
export function drawingLauncherPlugin(root: string, runtime: Runtime = {}): Plugin {
const app = path.join(root, '도면추출', 'app');
const pythonHome = path.join(app, 'runtime', 'python');
const executable = path.join(pythonHome, 'python.exe');
const script = path.join(root, '랜딩페이지', 'drawing-worker.py');
const outputRoot = path.join(root, '도면추출', 'output');
const exists = runtime.exists ?? existsSync;
const isDirectory = runtime.isDirectory ?? ((folder: string) => { try { return statSync(folder).isDirectory(); } catch { return false; } });
const start = runtime.start ?? spawn;
const jobs = new Map<string, Job>();
let latest: Job | undefined;
let choosing: Promise<string> | undefined;
const children = new Set<ChildProcess>();
const runWorker = (args: string[], event: (value: Record<string, unknown>) => void): { child: ChildProcess; done: Promise<void> } => {
const child = start(executable, ['-S', script, ...args], {
cwd: root, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, PYTHONHOME: pythonHome,
PYTHONPATH: [path.join(app, 'Code', 'bridge_extractor_package', 'src'), path.join(pythonHome, 'Lib', 'site-packages')].join(path.delimiter),
PYTHONUTF8: '1', PYTHONDONTWRITEBYTECODE: '1' },
});
children.add(child);
let pending = '', details = '', workerError = '';
const consume = (line: string): void => {
if (!line.trim()) return;
let value: Record<string, unknown>;
try { value = JSON.parse(line) as Record<string, unknown>; }
catch { event({ type: 'log', message: line }); return; }
if (value.type === 'error') workerError = String(value.message ?? '도면 추출 실패');
event(value);
};
child.stdout?.setEncoding('utf8');
child.stdout?.on('data', (chunk: string) => {
pending += chunk;
let index: number;
while ((index = pending.indexOf('\n')) >= 0) { consume(pending.slice(0, index)); pending = pending.slice(index + 1); }
if (pending.length > 4_000_000) { workerError = '추출 응답이 너무 큽니다.'; child.kill(); }
});
child.stderr?.setEncoding('utf8');
child.stderr?.on('data', (chunk: string) => { if (details.length < 4000) details += chunk.slice(0, 4000-details.length); });
const done = new Promise<void>((resolve, reject) => {
child.once('error', error => { children.delete(child); reject(error); });
child.once('close', code => {
children.delete(child);
consume(pending);
if (code === 0 && !workerError) resolve();
else reject(new Error(workerError || details || `도면 추출 프로세스가 종료됐습니다 (${code}).`));
});
});
return { child, done };
};
const snapshot = (job?: Job) => job ? {
id: job.id, folder: job.folder, status: job.status, logs: job.logs, error: job.error,
result: job.result ? { status: job.result.status, outputDir: job.result.output_dir,
reviewCount: job.result.review_count, failedFileCount: job.result.failed_file_count,
workbooks: job.result.workbooks.map((b, index) => ({ index, name: b.name, fileName: path.basename(b.xlsx_path), reviewCount: b.review_count })) } : undefined,
} : null;
const handle = async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
const route = new URL(req.url ?? '/', 'http://localhost').pathname;
if (!route.startsWith(DRAWING_API_PATH + '/')) { next(); return; }
const reply = (status: number, body: object): void => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', Allow: 'POST' });
res.end(JSON.stringify(body));
};
if (req.method !== 'POST') { reply(405, { error: 'POST 요청만 지원합니다.' }); return; }
const host = req.headers.host ?? '', address = req.socket.remoteAddress ?? '', origin = req.headers.origin;
const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host) && ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(address);
if (!local || (origin && origin !== `http://${host}` && origin !== `https://${host}`) || req.headers['x-pierzainer-action'] !== 'drawing-extractor') {
reply(403, { error: '이 PC의 Pierzainer 화면에서 실행해 주세요.' }); return;
}
if ((runtime.platform ?? process.platform) !== 'win32') { reply(501, { error: '도면추출은 Windows PC에서 실행할 수 있습니다.' }); return; }
if (!exists(executable) || !exists(script)) { reply(503, { error: '도면추출 실행 파일을 찾을 수 없습니다.' }); return; }
let body: Record<string, unknown>;
try {
let text = '';
req.setEncoding('utf8');
for await (const chunk of req) { text += String(chunk); if (text.length > 8192) { reply(413, { error: '요청이 너무 큽니다.' }); return; } }
body = JSON.parse(text || '{}') as Record<string, unknown>;
if (!body || Array.isArray(body) || typeof body !== 'object') throw new Error();
} catch { reply(400, { error: '요청 형식이 맞지 않습니다.' }); return; }
const action = route.slice(DRAWING_API_PATH.length + 1);
if (action === 'status') {
const job = typeof body.jobId === 'string' ? jobs.get(body.jobId) : latest;
reply(200, { job: snapshot(job) }); return;
}
if (action === 'choose-folder') {
if (!choosing) choosing = (async () => {
let folder = '';
const worker = runWorker(['choose'], value => { if (value.type === 'folder') folder = String(value.folder ?? ''); });
await worker.done;
return folder;
})();
try { reply(200, { folder: await choosing }); }
finally { choosing = undefined; }
return;
}
if (action === 'start') {
if (latest?.status === 'running') { reply(409, { error: '이미 추출 중입니다.', job: snapshot(latest) }); return; }
if (typeof body.folder !== 'string' || !body.folder.trim() || body.folder.includes('\0') || !isDirectory(body.folder.trim())) {
reply(400, { error: 'DWG 도면이 들어 있는 폴더를 선택하세요.' }); return;
}
const id = randomUUID();
const job: Job = { id, folder: path.resolve(body.folder.trim()), output: path.join(outputRoot, id), status: 'running', logs: ['도면을 읽고 있습니다…'] };
jobs.set(id, job); latest = job;
try { await mkdir(job.output, { recursive: true }); }
catch (error) { job.status = 'error'; job.error = (error as Error).message; reply(500, { error: job.error }); return; }
for (const [key, old] of jobs) { if (jobs.size <= 10) break; if (old !== job && old.status !== 'running') jobs.delete(key); }
try {
const worker = runWorker(['extract', job.folder, '--output', job.output], value => {
if (value.type === 'log') { job.logs.push(String(value.message ?? '').slice(0, 1500)); if (job.logs.length > 500) job.logs.shift(); }
if (value.type === 'result') job.result = value.result as Result;
});
job.child = worker.child;
void worker.done.then(() => {
if (!job.result || !Array.isArray(job.result.workbooks) || !job.result.workbooks.length) throw new Error('생성된 Excel이 없습니다. 도면 판독 로그를 확인하세요.');
job.status = job.result.status === 'needs_review' ? 'needs_review' : 'completed';
}).catch(error => { job.status = 'error'; job.error = String(error.message); job.logs.push(job.error!); });
} catch (error) { job.status = 'error'; job.error = (error as Error).message; }
reply(202, { job: snapshot(job) }); return;
}
if (action === 'workbook') {
const job = typeof body.jobId === 'string' ? jobs.get(body.jobId) : undefined;
const index = body.index;
if (!job?.result || !['completed', 'needs_review'].includes(job.status) || !Number.isInteger(index) || (index as number) < 0) {
reply(400, { error: '추출이 완료된 Excel을 선택하세요.' }); return;
}
const book = job.result.workbooks[index as number];
if (!book || !exists(book.xlsx_path)) { reply(404, { error: '결과 파일을 찾을 수 없습니다.' }); return; }
const file = realpathSync(book.xlsx_path), output = realpathSync(job.output);
if (!file.toLowerCase().startsWith((output + path.sep).toLowerCase()) || path.extname(file).toLowerCase() !== '.xlsx') {
reply(403, { error: '이 작업의 결과 Excel만 불러올 수 있습니다.' }); return;
}
const bytes = await readFile(file);
res.writeHead(200, { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Cache-Control': 'no-store' });
res.end(bytes); return;
}
reply(404, { error: '지원하지 않는 요청입니다.' });
};
const middleware = (req: IncomingMessage, res: ServerResponse, next: () => void): void => {
void handle(req, res, next).catch(error => {
console.error('[drawing-extractor]', error);
if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ error: (error as Error).message || '도면추출 요청에 실패했습니다.' })); }
});
};
const cleanup = (): void => { for (const child of children) child.kill(); };
return {
name: 'pierzainer-drawing-popup',
configureServer(server) { server.middlewares.use(middleware); server.httpServer?.once('close', cleanup); },
configurePreviewServer(server) { server.middlewares.use(middleware); server.httpServer.once('close', cleanup); },
};
}
+32
View File
@@ -0,0 +1,32 @@
.landing-white .drawing-popup { box-sizing: border-box; width: min(900px, calc(100vw - 32px)); max-height: 88dvh; margin: auto; padding: 28px; overflow: auto; border: 1px solid #d3e4ef; border-radius: 22px; background: #fbfdff; color: #14263d; box-shadow: 0 24px 90px #183c652b; color-scheme: light; }
.landing-white .drawing-popup::backdrop { background: #203a5754; backdrop-filter: blur(6px); }
.drawing-popup-head { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
.drawing-popup-head span { color: #087fcb; font-size: 10px; font-weight: 700; letter-spacing: .18em; }
.drawing-popup h2 { margin: 6px 0 0; font-size: 25px; letter-spacing: -.04em; }
.drawing-popup h3 { margin: 0 0 10px; font-size: 14px; }
.drawing-popup button { padding: 10px 15px; border-radius: 9px; border: 1px solid #cddfec; background: #fff; color: #28516d; cursor: pointer; font: inherit; font-size: 12px; transition: background .15s, border-color .15s; }
.drawing-popup button:hover { background: #edf7ff; border-color: #087fcb; }
.drawing-popup button:disabled { opacity: .5; cursor: default; }
.drawing-popup button:focus-visible, .drawing-popup input:focus-visible { outline: 2px solid #087fcb; outline-offset: 3px; }
.drawing-popup .drawing-primary { color: #fff; background: #087fcb; border-color: #087fcb; }
.drawing-popup .drawing-primary:hover { background: #076ba9; }
.drawing-popup .drawing-popup-close { font-size: 24px; line-height: 1; padding: 8px 12px; }
.drawing-popup-intro { color: #64748b; font-size: 13px; line-height: 1.8; margin: 18px 0 22px; }
.drawing-folder-label { display: block; font-size: 12px; font-weight: 700; margin-bottom: 8px; }
.drawing-folder-row { display: flex; gap: 8px; }
.drawing-folder-row input { flex: 1; min-width: 0; padding: 11px 12px; border: 1px solid #cddfec; border-radius: 9px; color: #14263d; background: #fff; font: inherit; font-size: 12px; }
.drawing-run-row { display: flex; align-items: center; gap: 14px; margin: 16px 0; }
#drawing-status { font-size: 12px; color: #47718f; line-height: 1.7; overflow-wrap: anywhere; }
#drawing-status[data-state=error] { color: #ae352b; }
#drawing-progress { width: 100%; height: 5px; accent-color: #087fcb; margin-bottom: 15px; }
.drawing-log-section { padding: 16px; border: 1px solid #dce8f1; border-radius: 12px; background: #f1f6fa; }
#drawing-logs { height: 155px; overflow: auto; margin: 0; font: 11px/1.9 Consolas, 'Malgun Gothic', monospace; color: #426079; white-space: pre-wrap; overflow-wrap: anywhere; }
#drawing-results { margin-top: 20px; }
#drawing-result-note, #drawing-output-path { font-size: 11px; color: #64748b; line-height: 1.7; overflow-wrap: anywhere; }
.drawing-book { display: grid; grid-template-columns: 1fr auto; gap: 7px 16px; border: 1px solid #d5e5f0; border-radius: 12px; padding: 14px; margin-top: 10px; background: #fff; }
.drawing-book strong { font-size: 13px; overflow-wrap: anywhere; }
.drawing-book > span { font-size: 11px; color: #7b6a35; grid-column: 1; }
.drawing-book-actions { display: flex; gap: 7px; grid-column: 2; grid-row: 1 / 3; align-items: center; }
.drawing-popup-foot { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-top: 20px; margin-top: 22px; border-top: 1px solid #e0e9f1; }
.drawing-popup-foot span { font-size: 11px; line-height: 1.6; color: #718399; }
@media(max-width: 600px) { .landing-white .drawing-popup { padding: 19px; width: calc(100vw - 20px); max-height: 92dvh; border-radius: 16px; } .drawing-popup h2 { font-size: 21px; } .drawing-run-row { flex-direction: column; align-items: flex-start; gap: 8px; } .drawing-book { grid-template-columns: 1fr; } .drawing-book-actions { grid-column: 1; grid-row: auto; } #drawing-logs { height: 130px; } }
+135
View File
@@ -0,0 +1,135 @@
import './drawing-popup.css';
export const drawingPopupMarkup = `
<dialog class="drawing-popup" id="drawing-popup" aria-labelledby="drawing-popup-title">
<header class="drawing-popup-head"><div><span>DRAWING IMPORT</span><h2 id="drawing-popup-title">도면에서 교각 정보 추출</h2></div><button type="button" class="drawing-popup-close" aria-label="도면추출 닫기">×</button></header>
<p class="drawing-popup-intro">종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하세요. 기존 추출 엔진으로 Excel을 만들고 바로 불러옵니다.</p>
<label class="drawing-folder-label" for="drawing-folder">도면 폴더</label>
<div class="drawing-folder-row"><input id="drawing-folder" type="text" placeholder="도면 폴더 경로를 입력하거나 선택하세요" autocomplete="off" /><button id="drawing-choose" type="button">폴더 선택</button></div>
<div class="drawing-run-row"><button id="drawing-start" type="button" class="drawing-primary" disabled>자동 추출 시작</button><span id="drawing-status" role="status" aria-live="polite">도면 폴더를 선택하세요.</span></div>
<progress id="drawing-progress" aria-label="도면 추출 진행 중" hidden></progress>
<section class="drawing-log-section" aria-labelledby="drawing-log-title"><h3 id="drawing-log-title">진행 내용</h3><pre id="drawing-logs" tabindex="0" aria-label="도면추출 진행 로그">아직 추출을 시작하지 않았습니다.</pre></section>
<section id="drawing-results" hidden><h3>추출 결과</h3><p id="drawing-result-note"></p><div id="drawing-books"></div><p id="drawing-output-path"></p></section>
<footer class="drawing-popup-foot"><span>닫아도 진행 중인 추출은 계속됩니다. 미확정 값은 Excel에서 확인하세요.</span><button type="button" class="drawing-popup-done">닫기</button></footer>
</dialog>`;
type Book = { index: number; name: string; fileName: string; reviewCount: number };
type Job = { id: string; folder: string; status: 'running' | 'completed' | 'needs_review' | 'error'; logs: string[]; error?: string;
result?: { outputDir: string; reviewCount: number; failedFileCount: number; workbooks: Book[] } };
export function createDrawingPopup(root: HTMLElement, beforeOpen: () => void): { open: () => void } {
const popup = root.querySelector<HTMLDialogElement>('#drawing-popup')!;
const folder = popup.querySelector<HTMLInputElement>('#drawing-folder')!;
const choose = popup.querySelector<HTMLButtonElement>('#drawing-choose')!;
const start = popup.querySelector<HTMLButtonElement>('#drawing-start')!;
const status = popup.querySelector<HTMLElement>('#drawing-status')!;
const progress = popup.querySelector<HTMLProgressElement>('#drawing-progress')!;
const logs = popup.querySelector<HTMLElement>('#drawing-logs')!;
const results = popup.querySelector<HTMLElement>('#drawing-results')!;
const books = popup.querySelector<HTMLElement>('#drawing-books')!;
const cardNote = root.querySelector<HTMLElement>('#landing-drawing-status')!;
const cardButton = root.querySelector<HTMLButtonElement>('[data-card="drawing"] button')!;
let job: Job | undefined, timer: number | undefined, busy = false, rendered = '';
const request = async (action: string, body: object = {}): Promise<Response> => {
const response = await fetch(`${import.meta.env.BASE_URL}__pierzainer/drawing-extractor/${action}`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Pierzainer-Action': 'drawing-extractor' }, body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(data?.error || '로컬 도면추출 연결을 확인하세요. 이 PC에서 Pierzainer를 실행해야 합니다.');
}
return response;
};
const json = async <T>(action: string, body: object = {}): Promise<T> => {
const response = await request(action, body);
try { return await response.json() as T; }
catch { throw new Error('도면추출 서버에 연결할 수 없습니다. Pierzainer를 다시 실행해 주세요.'); }
};
const controls = (): void => {
const running = job?.status === 'running';
folder.disabled = choose.disabled = busy || running;
start.disabled = busy || running || !folder.value.trim();
progress.hidden = !running;
start.textContent = running ? '추출 중…' : '자동 추출 시작';
};
const showError = (error: unknown): void => { status.textContent = (error as Error).message; status.dataset.state = 'error'; };
const loadBook = async (book: Book, download: boolean): Promise<void> => {
if (!job) return;
books.querySelectorAll('button').forEach(button => { button.disabled = true; });
try {
const response = await request('workbook', { jobId: job.id, index: book.index });
const bytes = new Uint8Array(await response.arrayBuffer());
if (download) {
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
const link = document.createElement('a'); link.href = url; link.download = book.fileName; link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} else {
if (!window.loadBytesToApp) throw new Error('교각 Excel 가져오기를 찾을 수 없습니다.');
window.loadBytesToApp(bytes, book.fileName);
const name = document.querySelector<HTMLInputElement>('#project-name')!;
const bridge = document.querySelector<HTMLInputElement>('#bridge-name')!;
name.value = book.name; bridge.value = book.name;
name.dispatchEvent(new Event('input', { bubbles: true }));
popup.close();
root.classList.add('modal-open');
const importer = document.querySelector<HTMLDialogElement>('#import-dialog')!;
if (!importer.open) importer.showModal();
name.focus();
}
} catch (error) { showError(error); }
finally { books.querySelectorAll('button').forEach(button => { button.disabled = false; }); }
};
const render = (value: Job): void => {
job = value; folder.value = value.folder;
logs.textContent = value.logs.join('\n'); logs.scrollTop = logs.scrollHeight;
const running = value.status === 'running';
status.dataset.state = value.status;
status.textContent = running ? '도면을 읽고 표·좌표를 분석하고 있습니다…' : value.status === 'error' ? value.error || '추출에 실패했습니다.' : '추출이 완료됐습니다. 결과 Excel을 확인하세요.';
cardNote.textContent = running ? '도면 추출 진행 중' : value.status === 'error' ? '추출 로그를 확인하세요.' : '추출 완료 · 결과 확인';
results.hidden = !value.result || running || value.status === 'error';
if (value.result && rendered !== value.id) {
rendered = value.id; books.replaceChildren();
popup.querySelector<HTMLElement>('#drawing-result-note')!.textContent = `Excel ${value.result.workbooks.length}개 · 검토 항목 ${value.result.reviewCount}개 · 파일 읽기 실패 ${value.result.failedFileCount}개`;
popup.querySelector<HTMLElement>('#drawing-output-path')!.textContent = `저장 위치: ${value.result.outputDir}`;
for (const book of value.result.workbooks) {
const row = document.createElement('div'); row.className = 'drawing-book';
const title = document.createElement('strong'); title.textContent = book.name;
const note = document.createElement('span'); note.textContent = `검토 항목 ${book.reviewCount}개`;
const actions = document.createElement('div'); actions.className = 'drawing-book-actions';
const download = document.createElement('button'); download.type = 'button'; download.textContent = 'Excel 저장';
const load = document.createElement('button'); load.type = 'button'; load.className = 'drawing-primary'; load.textContent = '교각 불러오기';
download.addEventListener('click', () => { void loadBook(book, true); });
load.addEventListener('click', () => { void loadBook(book, false); });
actions.append(download, load); row.append(title, note, actions); books.append(row);
}
}
controls();
};
const poll = async (): Promise<void> => {
clearTimeout(timer);
try {
const data = await json<{ job: Job | null }>('status', job ? { jobId: job.id } : {});
if (data.job) render(data.job);
else if (job?.status === 'running') { job = undefined; controls(); showError(new Error('실행 서버가 다시 시작됐습니다. 폴더를 확인하고 다시 추출해 주세요.')); }
if (popup.open && job?.status === 'running') timer = window.setTimeout(() => { void poll(); }, 900);
} catch (error) { showError(error); if (popup.open && job?.status === 'running') timer = window.setTimeout(() => { void poll(); }, 2000); }
};
folder.addEventListener('input', controls);
choose.addEventListener('click', async () => {
busy = true; controls(); status.textContent = '폴더 선택 창에서 도면 폴더를 선택하세요.';
try { const data = await json<{ folder: string }>('choose-folder'); if (data.folder) folder.value = data.folder; status.textContent = '도면 폴더를 확인하고 자동 추출을 시작하세요.'; status.dataset.state = ''; }
catch (error) { showError(error); }
finally { busy = false; controls(); }
});
start.addEventListener('click', async () => {
busy = true; controls(); status.textContent = '추출을 준비하고 있습니다…'; results.hidden = true;
try { const data = await json<{ job: Job }>('start', { folder: folder.value.trim() }); rendered = ''; render(data.job); void poll(); }
catch (error) { showError(error); }
finally { busy = false; controls(); }
});
popup.querySelectorAll('.drawing-popup-close, .drawing-popup-done').forEach(button => button.addEventListener('click', () => popup.close()));
popup.addEventListener('close', () => { clearTimeout(timer); cardButton.focus(); });
return { open() { beforeOpen(); if (!popup.open) popup.showModal(); controls(); void poll(); } };
}
+45
View File
@@ -0,0 +1,45 @@
"""Run the existing extraction API headlessly and stream JSON lines to the popup."""
import argparse
import json
from pathlib import Path
import sys
def emit(kind, **values):
print(json.dumps(dict(type=kind, **values), ensure_ascii=False), flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['choose', 'extract'])
parser.add_argument('folder', nargs='?')
parser.add_argument('--output')
args = parser.parse_args()
if args.mode == 'choose':
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
try:
folder = filedialog.askdirectory(parent=root, title='DWG 도면이 들어 있는 폴더 선택')
finally:
root.destroy()
emit('folder', folder=folder)
return
if not args.folder or not args.output:
raise ValueError('도면 폴더와 결과 경로가 필요합니다.')
project = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project / '도면추출'))
from integration.bridge_extract import extract_folder
result = extract_folder(args.folder, output_dir=args.output,
on_progress=lambda message: emit('log', message=message))
emit('result', result=result.to_dict(include_data=False))
if __name__ == '__main__':
try:
main()
except Exception as error:
emit('error', message=str(error))
sys.exit(1)
+4 -5
View File
@@ -44,22 +44,21 @@ export function createLandingGuide(root: HTMLElement): {close:(restoreFocus?:boo
const setText=(selector:string,text:string):void=>{dialog.querySelector<HTMLElement>(selector)!.textContent=text;};
const render=():void=>{
const excelFormats=document.querySelector<HTMLInputElement>('#file')?.accept||'.xlsx';
const drawingFormats=root.querySelector<HTMLInputElement>('#landing-drawing-longitudinal-input')?.accept||'.dwg,.dxf,.pdf,.png,.jpg,.jpeg';
const titles=['시작 방식 선택','프로젝트 데이터 준비','프로젝트 시작'];
const descriptions: Record<StartMode,string[]>={
excel:['작성한 교각 설계정보가 있다면 엑셀 불러오기를 선택하세요. 원본 시트의 인식 결과를 확인하며 시작할 수 있습니다.',
'프로젝트명을 입력하고 Excel 파일을 선택하세요. 인식된 시트와 교각을 확인한 뒤 사용할 항목을 고릅니다.',
'가져오기 창에서 선택한 교각의 형상 생성 버튼을 누르면 기존 3D 형상 화면으로 이동합니다. 이후 기능은 프로그램에서 제공하는 범위 안에서 사용할 수 있습니다.'],
drawing:['도면 파일을 준비했다면 도면 불러오기를 선택하세요. 현재는 도면 선택과 기존 화면으로의 이동 흐름을 제공합니다.',
'종평면도·교량받침배치도·ELEVATION 산출근거도를 각각 선택합니다. 세 파일을 모두 고르면 다음 버튼이 활성화됩니다.',
'세 파일을 선택한 뒤 도면 분석 시작 버튼으로 기존 프로그램 화면에 진입합니다. 자동 도면 분석·제원 추출 기능은 아직 이 화면에 연결되지 않았습니다.'],
drawing:['도면 불러오기를 누르면 프로그램 안에 도면추출 팝업이 열립니다.',
'종평면도·받침배치도·ELEVATION DWG가 들어 있는 폴더를 선택하고 자동 추출 시작을 누르세요. 진행 내용이 팝업에 표시됩니다.',
'결과 목록의 교각 불러오기를 눌러 Excel 인식 결과를 확인하고 형상을 생성합니다. Excel 저장으로 파일만 받을 수도 있습니다.'],
sample:['파일 준비 없이 실제 교각과 지반 모델을 살펴보려면 샘플 프로젝트를 선택하세요.',
'샘플 선택 패널에서 산외교·운곡교 등 12개 교량의 이름과 교각 수를 확인하고 원하는 교량을 선택합니다.',
`현재 ${sampleProjects.filter(s=>!s.blockReason).length}개 교량은 교각과 원도면 지반면, 위성사진, 2D 지도를 함께 엽니다. 확인이 필요한 샘플은 이유를 안내합니다. 설계 지반고는 Excel 원래 값을 유지하며, 기존 검증 알림은 확인이 필요합니다.`]
};
const facts: Record<StartMode,string[]>={
excel:['선택 가이드 · 교각 설계정보가 담긴 Excel 파일이 있을 때',`현재 주 가져오기 창의 지원 확장자: ${excelFormats.replaceAll(',', ' · ')}`,'확인할 내용 · 시트 인식 결과, 선택 교각, 프로젝트명'],
drawing:['지원 범위 · 파일 선택 흐름 제공 / 자동 분석 연결 준비 중',`현재 파일 선택 확장자: ${drawingFormats.replaceAll(',', ' · ')}`,'안내 · 구조해석·자동설계가 완료된 기능이라는 의미는 아닙니다.'],
drawing:['실행 방식 · 프로그램 내부 팝업 / 기존 추출 엔진','입력 · DWG가 들어 있는 폴더 / AI 연결 없음','결과 · 프로젝트의 도면추출/output에 저장 / 미확정 항목은 검토 필요'],
sample:['시작 방식 · 실제 교량 12개 중 선택','포함 자료 · 교각 + 지반면 + 위성사진 + 2D 지도','확인 사항 · 원본 CRS 확인이 필요한 추정 좌표계 포함']
};
setText('.landing-guide-step-label',`STEP 0${step+1} / ${modeNames[mode]}`);
+12 -82
View File
@@ -3,6 +3,7 @@ import { createHeroMotion, heroMotionMarkup } from './landing-motion';
import './landing-white.css';
import { sampleProjects } from './sample-projects';
import { parseProject } from '../src/core/project';
import { createDrawingPopup, drawingPopupMarkup } from './drawing-popup';
const appRoot = document.querySelector<HTMLDivElement>('.app');
const homeButton = document.querySelector<HTMLButtonElement>('#home-from-app');
@@ -71,11 +72,11 @@ const landingPanelMarkup = `
<div class="landing-card-top"><div class="landing-card-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v16H4zM4 9h7v11M11 4v5h9M15 13h2v3h-2M7 13v3"/></svg></div><span class="landing-card-number">02 / EXTRACT</span></div>
<span class="landing-card-eyebrow">Drawing Import</span>
<h3>도면 불러오기</h3>
<p>종평면도와 받침배치도, 고저 근거도를 선택합니다.</p>
<p>도면 폴더에서 교각 정보를 추출하고 결과 Excel을 바로 불러옵니다.</p>
</div>
<div class="landing-card-actions">
<span class="landing-card-note">개발 중</span>
<button class="landing-primary" type="button">설계도면 불러오기</button>
<span class="landing-card-note" id="landing-drawing-status" role="status" aria-live="polite">프로그램 안에서 도면 추출</span>
<button class="landing-primary" type="button">도면 불러오기</button>
</div>
</article>
@@ -116,50 +117,7 @@ const landingPanelMarkup = `
</div>
</section>
<section id="landing-drawing-panel" class="landing-panel" aria-label="도면 입력 패널">
<div class="landing-panel-head">
<h3>도면 입력</h3>
<button type="button" class="landing-close-button" data-close-panel="landing-drawing-panel">닫기</button>
</div>
<div class="landing-panel-body">
<div class="landing-sheet-list">
<div class="landing-sheet">
<div>
<h4>종평면도</h4>
<p>CAD 도면 또는 이미지 파일을 불러옵니다.</p>
</div>
<div class="landing-file-name" id="landing-drawing-longitudinal-name">미선택</div>
<input id="landing-drawing-longitudinal-input" type="file" accept=".dwg,.dxf,.pdf,.png,.jpg,.jpeg" hidden />
<button type="button" class="landing-ghost" data-pick-file="drawing-longitudinal">종평면도 불러오기</button>
</div>
<div class="landing-sheet">
<div>
<h4>교량받침배치도</h4>
<p>받침 배치 제원을 확인할 설계도면입니다.</p>
</div>
<div class="landing-file-name" id="landing-drawing-bearing-name">미선택</div>
<input id="landing-drawing-bearing-input" type="file" accept=".dwg,.dxf,.pdf,.png,.jpg,.jpeg" hidden />
<button type="button" class="landing-ghost" data-pick-file="drawing-bearing">교량받침배치도 불러오기</button>
</div>
<div class="landing-sheet">
<div>
<h4>ELEVATION 산출근거도</h4>
<p>고저 및 해석 기준이 되는 근거도를 선택합니다.</p>
</div>
<div class="landing-file-name" id="landing-drawing-elevation-name">미선택</div>
<input id="landing-drawing-elevation-input" type="file" accept=".dwg,.dxf,.pdf,.png,.jpg,.jpeg" hidden />
<button type="button" class="landing-ghost" data-pick-file="drawing-elevation">ELEVATION 산출근거도 불러오기</button>
</div>
</div>
<div class="landing-actions-row">
<button type="button" class="landing-ghost" data-close-panel="landing-drawing-panel">취소</button>
<button id="landing-drawing-start" class="landing-primary" type="button" disabled>도면 분석 시작</button>
</div>
</div>
</section>
${drawingPopupMarkup}
<section id="landing-sample-panel" class="landing-panel" aria-label="샘플 모델 패널">
<div class="landing-panel-head">
@@ -282,10 +240,6 @@ const bindCard = (cardSelector: string, panelId: string, mode: 'panel' | 'picker
if (projectName) projectName.focus();
return;
}
if (cardSelector === '[data-card="drawing"]') {
window.openDrawingFlow?.();
return;
}
if (cardSelector === '[data-card="sample"]') {
void window.openSampleProject?.();
return;
@@ -296,9 +250,15 @@ const bindCard = (cardSelector: string, panelId: string, mode: 'panel' | 'picker
};
bindCard('[data-card="excel"]', 'landing-excel-panel', 'picker');
bindCard('[data-card="drawing"]', 'landing-drawing-panel');
bindCard('[data-card="sample"]', 'landing-sample-panel');
const drawingPopup = createDrawingPopup(landingOverlay, () => {
landingGuide.dismissHint();
landingGuide.close(false);
closePanels();
});
document.querySelector<HTMLButtonElement>('[data-card="drawing"] button')!.addEventListener('click', () => drawingPopup.open());
document.querySelectorAll<HTMLElement>('[data-close-panel]').forEach((button) => {
button.addEventListener('click', () => {
const panelId = button.getAttribute('data-close-panel');
@@ -314,9 +274,6 @@ document.querySelectorAll<HTMLButtonElement>('[data-pick-file]').forEach((button
const targetMap: Record<string, string> = {
excel: 'landing-excel-input',
'drawing-longitudinal': 'landing-drawing-longitudinal-input',
'drawing-bearing': 'landing-drawing-bearing-input',
'drawing-elevation': 'landing-drawing-elevation-input',
};
const targetId = targetMap[kind];
@@ -328,33 +285,6 @@ document.querySelectorAll<HTMLButtonElement>('[data-pick-file]').forEach((button
});
bindFilePicker(document.querySelector('[data-pick-file="excel"]') as HTMLButtonElement, 'landing-excel-input', 'landing-excel-name');
bindFilePicker(document.querySelector('[data-pick-file="drawing-longitudinal"]') as HTMLButtonElement, 'landing-drawing-longitudinal-input', 'landing-drawing-longitudinal-name');
bindFilePicker(document.querySelector('[data-pick-file="drawing-bearing"]') as HTMLButtonElement, 'landing-drawing-bearing-input', 'landing-drawing-bearing-name');
bindFilePicker(document.querySelector('[data-pick-file="drawing-elevation"]') as HTMLButtonElement, 'landing-drawing-elevation-input', 'landing-drawing-elevation-name');
const drawingInputs = [
document.querySelector<HTMLInputElement>('#landing-drawing-longitudinal-input'),
document.querySelector<HTMLInputElement>('#landing-drawing-bearing-input'),
document.querySelector<HTMLInputElement>('#landing-drawing-elevation-input'),
];
const drawingStart = document.querySelector<HTMLButtonElement>('#landing-drawing-start');
const refreshDrawingSelection = (): void => {
const ready = drawingInputs.every((input) => !!input?.files?.length);
if (drawingStart) drawingStart.disabled = !ready;
};
drawingInputs.forEach((input) => {
input?.addEventListener('change', refreshDrawingSelection);
});
if (drawingStart) {
drawingStart.addEventListener('click', () => {
if (drawingInputs.every((input) => !!input?.files?.length)) {
showApp();
}
});
}
const sampleGrid = document.querySelector<HTMLElement>('#landing-sample-grid');
let sampleLoading = false;