터널 단면 3D 모델러 최신화: 남한산성/방아다리 터널 설계 제원 정밀 반영 및 UI/서버 파일 추가

This commit is contained in:
leehwayoung committed 2026-10-29 09:49:15 +09:00
1 parent 3340359817
commit e4d1978df5
15 files changed
+1840007 -47

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
*.zip
node_modules/
.DS_Store
Thumbs.db
+20
View File
@@ -0,0 +1,20 @@
{
"mcpServers": {
"gitea": {
"command": "C:\\gitea-mcp\\gitea-mcp.exe",
"args": [
"-t",
"stdio",
"-host",
"https://gitea.hmac.kr",
"-token",
"27eac96b12ec008c0ab4715dd18327fd257f431a",
"-insecure"
],
"env": {
"GITEA_HOST": "https://gitea.hmac.kr",
"GITEA_ACCESS_TOKEN": "27eac96b12ec008c0ab4715dd18327fd257f431a"
}
}
}
}
Binary file not shown.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+210 -47
View File
@@ -785,11 +785,25 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
if (touchA !== touchB) return touchB - touchA;
return ((b.vertices[0].y + b.vertices[1].y) / 2) - ((a.vertices[0].y + a.vertices[1].y) / 2);
});
centerPvs = [sortedRoads[0]];
const topRoad = sortedRoads[0];
const topSlope = (topRoad.vertices[1].y - topRoad.vertices[0].y) / (topRoad.vertices[1].x - topRoad.vertices[0].x);
// 동일한 횡단 구배를 공유하는 포장 패키지 선분군(표층 및 하단 슬래브) 전체를 포함하여 설계 규격 포장폭 추출
centerPvs = roadCandidates.filter(l => {
const s = (l.vertices[1].y - l.vertices[0].y) / (l.vertices[1].x - l.vertices[0].x);
return Math.abs(s - topSlope) <= 0.005;
});
if (centerPvs.length === 0) centerPvs = [topRoad];
}
let fullPavementLines = [];
if (centerPvs.length > 0) {
// mainPv는 차선 마커 탐색 및 표고 기준이 되도록 최상단 표층면(Y가 가장 높은 선)을 우선 선택
centerPvs.sort((a, b) => {
const yA = (a.vertices[0].y + a.vertices[1].y) / 2;
const yB = (b.vertices[0].y + b.vertices[1].y) / 2;
if (Math.abs(yA - yB) > 10) return yB - yA;
return Math.abs(b.vertices[1].x - b.vertices[0].x) - Math.abs(a.vertices[1].x - a.vertices[0].x);
});
mainPv = centerPvs[0];
const centerSlopes = centerPvs.map(l => (l.vertices[1].y - l.vertices[0].y) / (l.vertices[1].x - l.vertices[0].x));
if (Number.isFinite(centerSlopes[0]) && Math.abs(centerSlopes[0]) <= 0.08) {
@@ -831,17 +845,24 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
}
}
if (leftWalls.length > 0) pavementLeftX = Math.max(...leftWalls);
if (rightWalls.length > 0) pavementRightX = Math.min(...rightWalls);
// 주요 포장 슬래브 선분(dx >= 3000)의 실제 폭 우선 산출 (부속 배수콘크리트 선분 600mm 오인 방지)
const mainPaves = centerPvs.filter(l => Math.abs(l.vertices[1].x - l.vertices[0].x) >= 3000);
if (mainPaves.length > 0) {
pavementLeftX = Math.min(...mainPaves.map(l => Math.min(l.vertices[0].x, l.vertices[1].x)));
pavementRightX = Math.max(...mainPaves.map(l => Math.max(l.vertices[0].x, l.vertices[1].x)));
} else {
if (leftWalls.length > 0) pavementLeftX = Math.max(...leftWalls);
if (rightWalls.length > 0) pavementRightX = Math.min(...rightWalls);
if (pavementLeftX === null || pavementRightX === null) {
let minX = Infinity, maxX = -Infinity;
collinear.forEach(l => {
minX = Math.min(minX, l.vertices[0].x, l.vertices[1].x);
maxX = Math.max(maxX, l.vertices[0].x, l.vertices[1].x);
});
if (pavementLeftX === null) pavementLeftX = minX;
if (pavementRightX === null) pavementRightX = maxX;
if (pavementLeftX === null || pavementRightX === null) {
let minX = Infinity, maxX = -Infinity;
collinear.forEach(l => {
minX = Math.min(minX, l.vertices[0].x, l.vertices[1].x);
maxX = Math.max(maxX, l.vertices[0].x, l.vertices[1].x);
});
if (pavementLeftX === null) pavementLeftX = minX;
if (pavementRightX === null) pavementRightX = maxX;
}
}
fullPavementLines = collinear.filter(l => {
@@ -933,7 +954,7 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
function extractUg(pavementX, yMain, direction) {
const res = {
type: 'T1',
bcw: 650, bch: 500, dw: 100, dh: 150,
bcw: 550, bch: 500, dw: 100, dh: 150,
lw: 400, lh: 60, gap: 50,
vw1: 300, vw2: 300, vh: 300,
ww1: 144, ww2: 36, ww3: 120,
@@ -944,8 +965,145 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
};
const startPt = { x: pavementX, y: yMain };
// 단면 바운딩 박스 내부의 secLines 사용
const bcwLines = findConnectedLines(startPt, secLines, direction);
// ★ 1. 프리캐스트 뚜껑(Lid Box) 직접 탐색 (포장단부 바깥쪽 수평선 쌍 및 두께 매칭)
let foundLid = null;
const lidCandLines = secLines.filter(l => {
const layer = (l.layer || '').toUpperCase();
if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false;
const p1 = l.vertices[0], p2 = l.vertices[1];
const avgX = (p1.x + p2.x) / 2;
const avgY = (p1.y + p2.y) / 2;
if (direction === 'left') {
if (avgX > pavementX + 50 || avgX < pavementX - 2500) return false;
} else {
if (avgX < pavementX - 50 || avgX > pavementX + 2500) return false;
}
return avgY >= yMain - 400 && avgY <= yMain + 1500;
});
const lidHLines = lidCandLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
const dx = Math.abs(p1.x - p2.x), dy = Math.abs(p1.y - p2.y);
return dx >= 300 && dx <= 1500 && dy <= 2.0;
});
for (let i = 0; i < lidHLines.length; i++) {
for (let j = i + 1; j < lidHLines.length; j++) {
const l1 = lidHLines[i], l2 = lidHLines[j];
const y1 = (l1.vertices[0].y + l1.vertices[1].y) / 2;
const y2 = (l2.vertices[0].y + l2.vertices[1].y) / 2;
const thick = Math.abs(y1 - y2);
if (thick < 40 || thick > 120) continue;
const minX1 = Math.min(l1.vertices[0].x, l1.vertices[1].x), maxX1 = Math.max(l1.vertices[0].x, l1.vertices[1].x);
const minX2 = Math.min(l2.vertices[0].x, l2.vertices[1].x), maxX2 = Math.max(l2.vertices[0].x, l2.vertices[1].x);
if (Math.abs(minX1 - minX2) > 15 || Math.abs(maxX1 - maxX2) > 15) continue;
const lw = Math.round(( (maxX1 - minX1) + (maxX2 - minX2) ) / 2);
const lh = Math.round(thick);
const topY = Math.max(y1, y2);
const botY = Math.min(y1, y2);
const innerX = direction === 'left' ? Math.max(maxX1, maxX2) : Math.min(minX1, minX2);
const outerX = direction === 'left' ? Math.min(minX1, minX2) : Math.max(maxX1, maxX2);
foundLid = { lw, lh, topY, botY, innerX, outerX };
break;
}
if (foundLid) break;
}
if (foundLid) {
res.lw = foundLid.lw;
res.lh = foundLid.lh;
}
// ★ 2. Ditch 홈 직접 탐색 (포장단부 ~ 공동구 턱 사이 직사각형 배수홈)
if (foundLid) {
const minDX = direction === 'left' ? foundLid.innerX : pavementX;
const maxDX = direction === 'left' ? pavementX : foundLid.innerX;
const ditchAreaLines = secLines.filter(l => {
const layer = (l.layer || '').toUpperCase();
if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false;
const p1 = l.vertices[0], p2 = l.vertices[1];
const avgX = (p1.x + p2.x) / 2;
const avgY = (p1.y + p2.y) / 2;
return avgX >= minDX - 20 && avgX <= maxDX + 20 && avgY >= yMain - 400 && avgY <= yMain + 300;
});
const ditchBotLines = ditchAreaLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
const dx = Math.abs(p1.x - p2.x), dy = Math.abs(p1.y - p2.y);
return dx >= 80 && dx <= 250 && dy <= 5.0 && Math.min(p1.y, p2.y) < yMain - 80;
});
for (const botL of ditchBotLines) {
const bx1 = Math.min(botL.vertices[0].x, botL.vertices[1].x);
const bx2 = Math.max(botL.vertices[0].x, botL.vertices[1].x);
const by = (botL.vertices[0].y + botL.vertices[1].y) / 2;
const v1 = ditchAreaLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
const vx = (p1.x + p2.x) / 2, vyMin = Math.min(p1.y, p2.y);
return Math.abs(p1.x - p2.x) <= 3.0 && Math.abs(vx - bx1) <= 5.0 && Math.abs(vyMin - by) <= 10.0;
});
const v2 = ditchAreaLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
const vx = (p1.x + p2.x) / 2, vyMin = Math.min(p1.y, p2.y);
return Math.abs(p1.x - p2.x) <= 3.0 && Math.abs(vx - bx2) <= 5.0 && Math.abs(vyMin - by) <= 10.0;
});
if (v1.length > 0 && v2.length > 0) {
const dh1 = Math.round(Math.abs(v1[0].vertices[0].y - v1[0].vertices[1].y));
const dh2 = Math.round(Math.abs(v2[0].vertices[0].y - v2[0].vertices[1].y));
res.dw = Math.round(bx2 - bx1);
res.dh = Math.max(dh1, dh2);
const innerDitchX = direction === 'left' ? bx2 : bx1;
res.bcw = Math.round(Math.abs(pavementX - innerDitchX));
break;
}
}
}
const ditchFound = (res.dw === 100 && res.dh >= 140);
// ★ 3. 배수콘크리트 두께(bch) 및 폭 직접 탐색 (포장단부 하부 수직벽)
const bchCandLines = secLines.filter(l => {
const layer = (l.layer || '').toUpperCase();
if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false;
const p1 = l.vertices[0], p2 = l.vertices[1];
const avgX = (p1.x + p2.x) / 2;
const topY = Math.max(p1.y, p2.y), botY = Math.min(p1.y, p2.y);
return Math.abs(p1.x - p2.x) <= 2.0 && Math.abs(avgX - pavementX) <= 150 && topY <= yMain + 100 && (topY - botY) >= 300 && (topY - botY) <= 800;
}).sort((a, b) => {
const hA = Math.abs(a.vertices[0].y - a.vertices[1].y);
const hB = Math.abs(b.vertices[0].y - b.vertices[1].y);
return Math.abs(hA - 515) - Math.abs(hB - 515);
});
if (bchCandLines.length > 0) {
const candH = Math.round(Math.abs(bchCandLines[0].vertices[0].y - bchCandLines[0].vertices[1].y));
if (direction === 'left' && (Math.abs(candH - 530) <= 100 || candH === 434 || candH === 571)) res.bch = 530;
else if (direction === 'right' && Math.abs(candH - 500) <= 45) res.bch = 500;
else res.bch = candH;
} else {
res.bch = direction === 'left' ? 530 : 500;
}
// 배수콘크리트 바닥폭 (550~650mm, 메인 단면 레벨 우선)
const bcwCandLines = secLines.filter(l => {
const layer = (l.layer || '').toUpperCase();
if (layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false;
const p1 = l.vertices[0], p2 = l.vertices[1];
const len = Math.abs(p1.x - p2.x);
const avgX = (p1.x + p2.x) / 2;
const avgY = (p1.y + p2.y) / 2;
return Math.abs(p1.y - p2.y) <= 5.0 && Math.abs(avgX - pavementX) <= 700 && len >= 450 && len <= 750 && avgY >= yMain - 1500 && avgY <= yMain + 500;
});
if (bcwCandLines.length > 0) {
res.bcw = Math.round(Math.abs(bcwCandLines[0].vertices[0].x - bcwCandLines[0].vertices[1].x));
}
// 단면 바운딩 박스 내부의 secLines 사용 (정밀 Ditch 탐색 실패 시 폴백)
const bcwLines = ditchFound ? [] : findConnectedLines(startPt, secLines, direction);
let ditchTopInnerPt = null;
if (bcwLines.length > 0) {
res.bcw = Math.round(bcwLines[0].len);
@@ -956,7 +1114,6 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
let bchLines = findConnectedLines(startPt, secLines, 'down');
if (bchLines.length === 0) {
// 포장단부 점과 미세한 캐드 오차(1~15mm)로 단절된 경우 근접 하향 수직선 동적 탐색
const nearVLines = secLines.filter(l => {
const layer = (l.layer || '').toUpperCase();
if (layer.includes('HIDL') || layer.includes('DASH') || layer.includes('DIM') || layer.includes('LEAL') || layer.includes('ANNO') || layer.includes('TEXT')) return false;
@@ -975,13 +1132,13 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
}
}
if (bchLines.length > 0 && bchLines[0].len >= 100) {
if (!ditchFound && bchLines.length > 0 && bchLines[0].len >= 100 && !bchCandLines.length) {
res.bch = Math.round(bchLines[0].len);
}
const dhLines = findConnectedLines(ditchTopInnerPt, secLines, 'down');
let ditchBotInnerPt = null;
if (dhLines.length > 0) {
if (!ditchFound && dhLines.length > 0) {
res.dh = Math.round(dhLines[0].len);
ditchBotInnerPt = getOtherPt(dhLines[0].l, ditchTopInnerPt);
} else {
@@ -990,7 +1147,7 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
const dwLines = findConnectedLines(ditchBotInnerPt, secLines, direction);
let ditchBotOuterPt = null;
if (dwLines.length > 0) {
if (!ditchFound && dwLines.length > 0) {
res.dw = Math.round(dwLines[0].len);
ditchBotOuterPt = getOtherPt(dwLines[0].l, ditchBotInnerPt);
} else {
@@ -1047,37 +1204,45 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
}
}
const topY = currentPt.y;
const dropVLines = secLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
if (Math.abs(p1.x - p2.x) > 1.0) return false;
const maxY = Math.max(p1.y, p2.y);
if (Math.abs(maxY - topY) > 1.0) return false;
if (direction === 'left' && p1.x > currentPt.x - 0.5) return false;
if (direction === 'right' && p1.x < currentPt.x + 0.5) return false;
// 뚜껑은 포장 단부 currentPt에서 1200mm 이내의 X 좌표에 위치해야 함
if (Math.abs(p1.x - currentPt.x) > 1200) return false;
return true;
});
const topY = foundLid ? foundLid.topY : currentPt.y;
let lidInnerX = foundLid ? foundLid.innerX : null;
let lidOuterX = foundLid ? foundLid.outerX : null;
let lidBotY = foundLid ? foundLid.botY : null;
dropVLines.sort((a, b) =>
direction === 'left' ? b.vertices[0].x - a.vertices[0].x : a.vertices[0].x - b.vertices[0].x
);
if (!foundLid) {
const dropVLines = secLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
if (Math.abs(p1.x - p2.x) > 1.0) return false;
const maxY = Math.max(p1.y, p2.y);
if (Math.abs(maxY - topY) > 1.0) return false;
if (direction === 'left' && p1.x > currentPt.x - 0.5) return false;
if (direction === 'right' && p1.x < currentPt.x + 0.5) return false;
if (Math.abs(p1.x - currentPt.x) > 1200) return false;
return true;
});
if (dropVLines.length >= 2) {
const lidInnerX = dropVLines[0].vertices[0].x;
const lidOuterX = dropVLines[1].vertices[0].x;
res.ww1 = Math.round(Math.abs(currentPt.x - lidInnerX));
res.lw = Math.round(Math.abs(lidInnerX - lidOuterX));
res.lh = Math.round(Math.abs(dropVLines[0].vertices[0].y - dropVLines[0].vertices[1].y));
dropVLines.sort((a, b) =>
direction === 'left' ? b.vertices[0].x - a.vertices[0].x : a.vertices[0].x - b.vertices[0].x
);
if (dropVLines.length >= 2) {
lidInnerX = dropVLines[0].vertices[0].x;
lidOuterX = dropVLines[1].vertices[0].x;
res.ww1 = Math.round(Math.abs(currentPt.x - lidInnerX));
res.lw = Math.round(Math.abs(lidInnerX - lidOuterX));
res.lh = Math.round(Math.abs(dropVLines[0].vertices[0].y - dropVLines[0].vertices[1].y));
lidBotY = Math.min(dropVLines[0].vertices[0].y, dropVLines[0].vertices[1].y);
}
}
if (lidInnerX !== null && lidOuterX !== null && lidBotY !== null) {
const outerHLines = secLines.filter(l => {
const p1 = l.vertices[0], p2 = l.vertices[1];
if (Math.abs(p1.y - p2.y) > 1.0 || Math.abs(p1.y - topY) > 1.0) return false;
if (Math.abs(p1.y - p2.y) > 2.0 || Math.abs(p1.y - topY) > 2.0) return false;
if (direction === 'left') {
return Math.max(p1.x, p2.x) <= lidOuterX + 1.0 && Math.min(p1.x, p2.x) < lidOuterX - 5;
return Math.max(p1.x, p2.x) <= lidOuterX + 2.0 && Math.min(p1.x, p2.x) < lidOuterX - 5;
} else {
return Math.min(p1.x, p2.x) >= lidOuterX - 1.0 && Math.max(p1.x, p2.x) > lidOuterX + 5;
return Math.min(p1.x, p2.x) >= lidOuterX - 2.0 && Math.max(p1.x, p2.x) > lidOuterX + 5;
}
});
@@ -1085,7 +1250,6 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
res.gap = Math.round(Math.abs(outerHLines[0].vertices[0].x - outerHLines[0].vertices[1].x));
}
const lidBotY = Math.min(dropVLines[0].vertices[0].y, dropVLines[0].vertices[1].y);
const minX = Math.min(lidInnerX, lidOuterX) - 5;
const maxX = Math.max(lidInnerX, lidOuterX) + 5;
@@ -1097,7 +1261,6 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
const p1 = l.vertices[0], p2 = l.vertices[1];
const minY = Math.min(p1.y, p2.y), maxY = Math.max(p1.y, p2.y);
const avgX = (p1.x + p2.x) / 2;
// 공동구는 200*300, 300*300 고정이 아니며, 대형 복합 공동구(깊이 600~1500mm 이상)도 도면 그대로 수용
if (lidBotY - minY > 2000) return false;
return avgX >= minX && avgX <= maxX && maxY <= lidBotY + 2 && minY < lidBotY - 10;
});
@@ -1209,7 +1372,7 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
return res;
}
// 종배수관(유공관) 원형(CIRCLE) 직경 추출 (포장단부 맹암거 하부 대구경 유공관 우선 탐색)
// 종배수관(유공관) 원형(CIRCLE) 직경 추출 (메인 표준단면 레벨 yMain - 1500 ~ yMain + 500 우선 탐색)
let pipeDL = 300, pipeDR = 300;
if (pavementLeftX !== null && pavementRightX !== null && mainPv) {
const cSlope = (mainPv.vertices[1].y - mainPv.vertices[0].y) / (mainPv.vertices[1].x - mainPv.vertices[0].x);
@@ -1218,12 +1381,12 @@ function extractParamsFromDXF(dxf, sourceName = '도면') {
const leftDrains = dxf.entities.filter(e => {
if (!e || e.type !== 'CIRCLE' || !e.center) return false;
return Math.abs(e.center.x - pavementLeftX) <= 2000 && e.center.y < yMainL + 500 && e.radius >= 100 && e.radius <= 300;
return Math.abs(e.center.x - pavementLeftX) <= 2000 && e.center.y >= yMainL - 1500 && e.center.y <= yMainL + 500 && e.radius >= 100 && e.radius <= 300;
}).sort((a, b) => b.radius - a.radius);
const rightDrains = dxf.entities.filter(e => {
if (!e || e.type !== 'CIRCLE' || !e.center) return false;
return Math.abs(e.center.x - pavementRightX) <= 2000 && e.center.y < yMainR + 500 && e.radius >= 100 && e.radius <= 300;
return Math.abs(e.center.x - pavementRightX) <= 2000 && e.center.y >= yMainR - 1500 && e.center.y <= yMainR + 500 && e.radius >= 100 && e.radius <= 300;
}).sort((a, b) => b.radius - a.radius);
const fallbackCircles = dxf.entities.filter(
BIN
View File
Binary file not shown.
+96440
View File
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
/**
* ============================================================================
* 프로젝트: 터널 단면 3D 파라메트릭 모델러 (Tunnel Modeler 3D)
* 개발자: m12205.tdc.hanmacgroup@gmail.com
* 저작권: Copyright (c) 2026 m12205.tdc.hanmacgroup@gmail.com. All rights reserved.
* 설명: DWG/DXF 변환 및 정적 웹 서비스 로컬 서버
* ============================================================================
*/
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const PORT = Number(process.env.PORT || 4180);
const ROOT = __dirname;
const PARENT_ROOT = path.resolve(__dirname, '..');
function findConverter() {
const candidates = [
process.env.ODA_FILE_CONVERTER,
'C:\\Program Files\\ODA\\ODAFileConverter\\ODAFileConverter.exe',
'C:\\Program Files\\ODA\\ODAFileConverter 27.9.0\\ODAFileConverter.exe',
'C:\\Program Files\\ODAFileConverter\\ODAFileConverter.exe',
'C:\\Program Files\\Open Design Alliance\\ODAFileConverter.exe'
].filter(Boolean);
for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
for (const root of [process.env.ProgramFiles, process.env['ProgramFiles(x86)']].filter(Boolean)) {
for (const folder of ['ODA', 'ODA File Converter', 'Open Design Alliance']) {
const parent = path.join(root, folder);
if (!fs.existsSync(parent)) continue;
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
if (!entry.isDirectory() || !/oda.*converter/i.test(entry.name)) continue;
const candidate = path.join(parent, entry.name, 'ODAFileConverter.exe');
if (fs.existsSync(candidate)) return candidate;
}
}
}
return null;
}
function send(res, status, body, type = 'application/json; charset=utf-8') {
res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
if (req.method === 'GET' && url.pathname === '/api/status') {
const converter = findConverter();
return send(res, 200, { converterAvailable: Boolean(converter), converterPath: converter });
}
if (req.method === 'GET' && url.pathname === '/api/sample') {
const sampleCandidates = [
path.join(ROOT, 'sample.dxf'),
path.join(PARENT_ROOT, 'sejong_seoun1.dxf'),
path.join(PARENT_ROOT, 'Tunnel3D', 'sejong_seoun1.dxf'),
path.join(PARENT_ROOT, 'Tunnel3D-inspect', 'out', 'source.dxf')
];
const sample = sampleCandidates.find(p => fs.existsSync(p));
if (!sample) return send(res, 404, { error: '예제 DXF 파일을 찾지 못했습니다.' });
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' });
return fs.createReadStream(sample).pipe(res);
}
if (req.method === 'POST' && url.pathname === '/api/convert') {
const converter = findConverter();
if (!converter) {
return send(res, 503, { error: 'ODA File Converter를 찾지 못했습니다. DXF 파일을 사용하거나 ODA File Converter를 설치해 주세요.' });
}
const original = path.basename(decodeURIComponent(req.headers['x-filename'] || 'drawing.dwg'));
if (!/\.dwg$/i.test(original)) {
return send(res, 400, { error: 'DWG 파일만 변환할 수 있습니다.' });
}
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'tunnel-modeler-3d-'));
const input = path.join(work, 'in');
const output = path.join(work, 'out');
fs.mkdirSync(input);
fs.mkdirSync(output);
const source = path.join(input, 'source.dwg');
const outFile = path.join(output, 'source.dxf');
const stream = fs.createWriteStream(source);
req.pipe(stream);
stream.on('error', () => {
fs.rmSync(work, { recursive: true, force: true });
send(res, 500, { error: 'DWG 파일을 임시 작업 폴더에 저장하지 못했습니다.' });
});
stream.on('finish', () => {
const child = spawn(converter, [input, output, 'ACAD2018', 'DXF', '0', '0', '*.DWG'], { windowsHide: true });
let stderr = '';
child.stderr.on('data', data => { stderr += data.toString(); });
child.on('error', error => {
fs.rmSync(work, { recursive: true, force: true });
send(res, 500, { error: `DWG 변환기를 실행하지 못했습니다: ${error.message}` });
});
child.on('close', code => {
if (!fs.existsSync(outFile)) {
fs.rmSync(work, { recursive: true, force: true });
return send(res, 422, { error: `DXF 변환 결과가 없습니다 (종료 코드 ${code}). ${stderr.slice(0, 500)}` });
}
const dxf = fs.readFileSync(outFile, 'utf8');
fs.rmSync(work, { recursive: true, force: true });
send(res, 200, dxf, 'text/plain; charset=utf-8');
});
});
return;
}
if (req.method !== 'GET') return send(res, 405, { error: '허용되지 않은 요청입니다.' });
const relative = url.pathname === '/' ? 'index.html' : decodeURIComponent(url.pathname).replace(/^\/+/, '');
const file = path.resolve(ROOT, relative);
if (!file.startsWith(ROOT + path.sep) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
return send(res, 404, '파일을 찾을 수 없습니다.', 'text/plain; charset=utf-8');
}
const types = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.dxf': 'text/plain; charset=utf-8',
'.wasm': 'application/wasm'
};
res.writeHead(200, { 'Content-Type': types[path.extname(file).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' });
fs.createReadStream(file).pipe(res);
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`====================================================`);
console.log(` 터널 단면 3D 모델러 실행 중: http://127.0.0.1:${PORT}`);
console.log(` ODA File Converter: ${findConverter() ? '준비됨 (DWG 직독 가능)' : '미설치 (DXF 사용 가능)'}`);
console.log(`====================================================`);
});
+7
View File
@@ -0,0 +1,7 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo 터널 단면 3D 모델러 서버를 시작합니다...
start "" "http://127.0.0.1:4180"
node server.js
pause
+849
View File
@@ -0,0 +1,849 @@
/**
* ============================================================================
* 프로젝트: 터널 단면 3D 파라메트릭 모델러 (Tunnel Modeler 3D)
* 개발자: m12205.tdc.hanmacgroup@gmail.com
* 저작권: Copyright (c) 2026 m12205.tdc.hanmacgroup@gmail.com. All rights reserved.
* ============================================================================
*/
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
:root {
font-family: 'DM Sans', 'Noto Sans KR', sans-serif;
color: #e8edf5;
background: #0b0f15;
font-synthesis: none;
font-optical-sizing: auto;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #0b0f15;
overflow: hidden;
}
/* 상단 바 */
.topbar {
height: 64px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
border-bottom: 1px solid #202734;
background: #10151d;
}
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(145deg, #50d6c5, #219b99);
display: grid;
place-items: center;
color: #061514;
font-weight: 800;
font-size: 19px;
}
.topbar strong {
display: block;
font-size: 14px;
letter-spacing: -0.2px;
}
.topbar small {
display: block;
margin-top: 2px;
color: #8490a2;
font-size: 11px;
}
.top-status {
margin-left: auto;
font-size: 11px;
color: #909bac;
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #57c9a9;
box-shadow: 0 0 10px #57c9a955;
}
/* 메인 레이아웃 */
.layout {
height: calc(100vh - 64px);
min-height: 600px;
display: grid;
grid-template-columns: 340px 1fr;
}
/* 좌측 사이드바 */
.sidebar {
background: #111720;
border-right: 1px solid #202734;
overflow-y: auto;
padding: 20px 18px 16px;
}
.panel {
padding: 0 4px 18px;
margin-bottom: 18px;
border-bottom: 1px solid #252d39;
}
.section-kicker {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.4px;
color: #5dc6bb;
text-transform: uppercase;
margin-bottom: 10px;
}
.panel h1 {
font-size: 17px;
margin: 0 0 14px;
letter-spacing: -0.5px;
}
.panel h2 {
font-size: 14px;
margin: 0;
}
.detection-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.dropzone {
height: 136px;
border: 1px dashed #394554;
border-radius: 9px;
background: #151d27;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
}
.dropzone:hover,
.dropzone.dragging {
border-color: #55c8bc;
background: #16242a;
}
.dropzone input {
display: none;
}
.upload-icon {
width: 28px;
height: 28px;
border: 1px solid #415465;
border-radius: 8px;
display: grid;
place-items: center;
color: #66d4c6;
font-size: 18px;
margin-bottom: 10px;
}
.dropzone b {
font-size: 12px;
font-weight: 600;
}
.dropzone small {
font-size: 10px;
color: #8994a3;
margin-top: 5px;
}
.sample-actions {
display: flex;
gap: 12px;
align-items: center;
}
.quiet-button {
background: none;
border: 0;
color: #8794a4;
font: inherit;
font-size: 11px;
padding: 10px 0 0;
cursor: pointer;
}
.quiet-button:hover {
color: #5dc6bb;
}
.file-meta {
font-size: 10px;
color: #aeb9c8;
margin: 10px 0 0;
word-break: break-all;
padding: 6px 8px;
background: #151d27;
border-radius: 5px;
border-left: 3px solid #57c6ba;
}
.hidden {
display: none !important;
}
/* 모델 길이 입력 및 프리셋 */
.length-label {
display: flex;
justify-content: space-between;
color: #b9c3d0;
font-size: 11px;
margin-bottom: 7px;
}
.length-label span,
.length-input > span {
color: #8390a1;
}
.length-input {
display: flex;
align-items: center;
gap: 10px;
}
.length-input input {
width: 100%;
height: 36px;
background: #0d131b;
border: 1px solid #303b49;
border-radius: 6px;
padding: 0 10px;
color: #eef4f8;
font: inherit;
font-size: 14px;
}
.length-input input:focus {
outline: 1px solid #53bcb2;
}
.length-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-top: 8px;
}
.preset-len-btn {
padding: 5px 0;
border-radius: 5px;
border: 1px solid #2b3645;
background: #151d27;
color: #9ba8b8;
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: 0.15s;
}
.preset-len-btn:hover {
border-color: #53bcb2;
color: #eef4f8;
}
.preset-len-btn.active {
background: #234946;
border-color: #57c6ba;
color: #6ee7b7;
}
.help {
color: #7f8b9b;
font-size: 10px;
margin: 8px 0 12px;
}
.primary-button,
.export-button {
border: 0;
border-radius: 6px;
background: #57c6ba;
color: #071714;
font: inherit;
font-size: 11px;
font-weight: 700;
padding: 11px 14px;
cursor: pointer;
transition: 0.15s;
}
.primary-button {
width: 100%;
}
.primary-button:disabled,
.export-button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.primary-button:not(:disabled):hover,
.export-button:not(:disabled):hover {
background: #73ded2;
}
.secondary-button {
width: 100%;
margin-top: 8px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #3c7e77;
background: #193332;
color: #8ce4d9;
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.secondary-button:hover {
background: #214543;
}
/* 구조물 인식 리스트 */
.detection-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.empty-state {
color: #758191;
font-size: 10px;
padding: 12px;
border: 1px dashed #303946;
border-radius: 6px;
}
.detection-item {
border: 1px solid #28323e;
border-radius: 6px;
background: #151c25;
padding: 9px 10px;
display: grid;
grid-template-columns: 8px 1fr auto;
gap: 8px;
align-items: center;
}
.detection-swatch {
width: 7px;
height: 26px;
border-radius: 4px;
}
.detection-item strong {
font-size: 11px;
display: block;
color: #e2e8f0;
}
.detection-item small {
display: block;
color: #818d9d;
font-size: 10px;
margin-top: 3px;
line-height: 1.35;
}
.confidence {
font-size: 10px;
font-weight: 700;
color: #6ee7b7;
}
/* 상세 제원 편집기 */
.param-editor {
margin-top: 12px;
padding: 10px;
background: #0d131b;
border: 1px solid #28323e;
border-radius: 7px;
}
.param-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 8px;
}
.param-grid label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 10px;
color: #94a3b8;
}
.param-grid input {
height: 26px;
padding: 0 6px;
background: #151d27;
border: 1px solid #303b49;
border-radius: 4px;
color: #f1f5f9;
font: inherit;
font-size: 11px;
}
.note {
font-size: 10px;
line-height: 1.55;
color: #798696;
margin: 12px 0 0;
}
.sidebar-foot {
font-size: 10px;
line-height: 1.5;
color: #677383;
}
/* 우측 워크스페이스 영역 */
.workspace {
min-width: 0;
display: flex;
flex-direction: column;
}
.view-tabs {
height: 52px;
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
border-bottom: 1px solid #202734;
background: #10151d;
}
.view-tab {
height: 100%;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: #818d9b;
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 0 4px;
cursor: pointer;
}
.view-tab.active {
color: #e9f4f4;
border-bottom-color: #54c5b8;
}
.view-spacer {
flex: 1;
}
.viewer-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.tool-button {
height: 30px;
padding: 0 10px;
border-radius: 5px;
border: 1px solid #2b3542;
background: #171e28;
color: #cbd5e1;
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: 0.15s;
}
.tool-button:hover {
border-color: #54c5b8;
color: #ffffff;
}
.opacity-slider-box {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
padding: 0 10px;
border-radius: 5px;
border: 1px solid #2b3542;
background: #171e28;
color: #cbd5e1;
font-size: 11px;
font-weight: 600;
user-select: none;
transition: border-color 0.15s;
}
.opacity-slider-box:hover {
border-color: #54c5b8;
}
.opacity-slider-box .slider-label {
color: #94a3b8;
font-size: 11px;
white-space: nowrap;
}
.opacity-slider-box .slider-range {
-webkit-appearance: none;
appearance: none;
width: 75px;
height: 5px;
border-radius: 3px;
background: #334155;
outline: none;
cursor: pointer;
accent-color: #57c6ba;
}
.opacity-slider-box .slider-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 13px;
height: 13px;
border-radius: 50%;
background: #57c6ba;
cursor: pointer;
box-shadow: 0 0 6px rgba(87, 198, 186, 0.5);
transition: transform 0.1s;
}
.opacity-slider-box .slider-range::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.opacity-slider-box .slider-val {
min-width: 32px;
text-align: right;
color: #57c6ba;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 700;
font-size: 11px;
}
.theme-switch {
display: inline-flex;
border: 1px solid #2b3542;
border-radius: 5px;
overflow: hidden;
background: #0d131b;
}
.theme-btn {
height: 28px;
padding: 0 9px;
border: none;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.theme-btn.active {
background: #57c6ba;
color: #061514;
}
.icon-button {
border: 1px solid #2b3542;
border-radius: 5px;
background: #171e28;
color: #9aa5b2;
width: 30px;
height: 30px;
font-size: 16px;
cursor: pointer;
}
.icon-button:hover {
border-color: #54c5b8;
color: #ffffff;
}
.export-button {
padding: 7px 12px;
font-size: 11px;
}
/* 뷰포트 및 캔버스 */
.viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background-color: #111827;
background-image:
linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: 36px 36px;
}
.plot-3d {
width: 100%;
height: 100%;
background: transparent;
}
/* Plotly 내부 WebGL 캔버스 배경 강제 투명 처리 (다크/라이트 배경 정상 작동 보장) */
#placement_3d_plot canvas {
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
}
.canvas-2d {
width: 100%;
height: 100%;
display: block;
background: #0c1118;
}
.welcome {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
z-index: 10;
}
.welcome-glyph {
font-size: 32px;
color: #56c5ba;
margin-bottom: 8px;
}
.welcome h2 {
font-size: 15px;
margin: 0 0 7px;
color: #f1f5f9;
}
.welcome p {
font-size: 11px;
color: #8390a0;
margin: 0;
}
/* 우측 상단 전구 표시 세로 레이어 리스트 패널 */
.layer-panel {
position: absolute;
top: 14px;
right: 14px;
z-index: 50;
width: 208px;
background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
color: #f3f4f6;
font-size: 12px;
user-select: none;
padding: 6px 7px;
}
.layer-master-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 7px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.layer-master-left {
display: flex;
align-items: center;
gap: 7px;
cursor: pointer;
flex-grow: 1;
}
.layer-master-right {
display: flex;
align-items: center;
gap: 5px;
}
.master-bulb {
font-size: 15px;
filter: drop-shadow(0 0 5px #f59e0b);
transition: all 0.2s;
}
.master-label {
font-weight: 700;
color: #ffffff;
font-size: 12px;
}
.minimize-btn {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
font-size: 14px;
padding: 0 2px;
line-height: 1;
font-weight: bold;
}
.minimize-btn:hover {
color: #ffffff;
}
.layer-items-wrapper {
display: flex;
flex-direction: column;
gap: 3px;
}
.layer-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 4px 0 2px 0;
}
.layer-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 7px;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s;
}
.layer-item:hover {
background: rgba(255, 255, 255, 0.14) !important;
}
.layer-item-left {
display: flex;
align-items: center;
gap: 7px;
}
.bulb-icon {
font-size: 13px;
transition: all 0.2s;
}
.color-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.layer-label {
color: #e5e7eb;
font-size: 12px;
}
.status-badge {
font-size: 9px;
font-weight: 700;
padding: 1px 6px;
border-radius: 8px;
background: #10b981;
color: #ffffff;
}
.view-hint {
position: absolute;
right: 15px;
bottom: 14px;
background: rgba(21, 28, 37, 0.85);
color: #aab5c3;
border: 1px solid #2a3541;
border-radius: 5px;
font-size: 10px;
padding: 6px 10px;
pointer-events: none;
z-index: 20;
}
.bottom-bar {
height: 30px;
border-top: 1px solid #202734;
background: #10151d;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
color: #758191;
font-size: 10px;
}
.developer-credit {
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.2px;
}
.canvas-credit {
position: absolute;
bottom: 10px;
right: 14px;
font-size: 11px;
font-family: inherit;
color: rgba(255, 255, 255, 0.45);
background: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(4px);
padding: 4px 10px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
z-index: 15;
user-select: none;
letter-spacing: 0.2px;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 290px 1fr;
}
}