Update Pier_v1 project
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
f25e36c050
commit
ad73ddec2b
2476 files changed
+3423
-4902
No files matched your search
@@ -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); },
|
||||
};
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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(); } };
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user