diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 37ed31cb..0266dab6 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -251,6 +251,13 @@ const SHELL_CSS = ` align-items: center; gap: var(--spacing-8); } +/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다 + (2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */ +.app-actions__auth { + display: flex; + align-items: center; + gap: var(--spacing-8); +} .app-outlet { min-height: calc(100vh - 64px - 56px); } diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index a89ac6dc..036ddb65 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -111,6 +111,67 @@ export async function purgeOtherProjects(projectId: string): Promise { }); } +/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null. + * 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */ +export async function readCachedBytes(projectId: string, url: string): Promise { + try { + const cached = await readAsset(projectId, url); + return cached?.body ?? null; + } catch { + return null; + } +} + +/** 받아 둔 바이트를 보관함에 담는다. 실패해도 화면은 그대로 간다(용량 초과 등). */ +export async function writeCachedBytes( + projectId: string, + url: string, + body: ArrayBuffer, +): Promise { + try { + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag: null, + savedAt: Date.now(), + body, + }); + } catch { + // 담지 못해도 다음에 다시 받으면 된다 — 막지 않는다. + } +} + +/** + * 한 프로젝트 안에서 **주소 앞머리가 같은 옛 보관본**을 지운다. + * + * 3D 코리도처럼 주소에 열쇠(해시)가 박히는 자료는 정본이 바뀔 때마다 새 주소가 되어 + * 옛 보관본이 그대로 쌓인다. 하나가 17MB 대라 두어 벌만 남아도 브라우저 보관함을 크게 + * 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06). + */ +export async function purgeAssetsWithPrefix(projectId: string, prefix: string): Promise { + const db = await openDatabase(); + if (!db) return; + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, "readwrite"); + const store = transaction.objectStore(STORE); + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const value = cursor.value as CachedAsset; + if (value.projectId === projectId && value.url.startsWith(prefix)) cursor.delete(); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + export interface CachedFetchOptions { /** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */ onProgress?: (ratio: number | null) => void; diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts new file mode 100644 index 00000000..fd30b73b --- /dev/null +++ b/A00_Common/b_page_state.ts @@ -0,0 +1,466 @@ +/* ============================================================================= + * A00_Common/b_page_state.ts + * 화면 상태 보관소 **한 곳** — B05·B06 이 한 페이지처럼 움직이기 위한 뼈대 + * (2026-09-06 사용자 지시). + * + * 왜 필요한가 — 세션 접근이 19개 파일 64곳에 흩어져 있었고 키 이름이 네 갈래 + * (`b05:`, `b06:`, `b05-route-`, `aislo:`)로 제각각이었다. 같은 설계값인데 키가 + * 페이지 이름으로 갈려 두 화면이 서로의 값을 못 보는 자리도 있었다. 값마다 **어느 통에 + * 속하는지**를 아래 등록표에 한 줄로 적어 두고, 저장·복원·비우기가 전부 그 표만 보고 + * 움직이게 한다. 나중에 캐시↔즉시를 바꿀 때도 표 한 줄만 고치면 된다. + * + * 통은 넷이다. + * ① pref 화면 취향 — 패널 열림·높이, 보기 토글. **계정**에 붙는다(다른 PC에서도 같은 + * 배치). 세션은 그 값의 사본이라 서버가 없어도 화면은 돈다. + * ② draft 설계 초안 — 사용자가 만진 설계값. [저장]·[확정] 때만 서버로 간다. + * ③ (즉시) 누르는 순간 다시 계산을 부르는 명령. 여기 쌓지 않는다 — 표에도 없다. + * ④ result 계산 결과 — 서버·브라우저가 만들어 낸 값. 저장 대상이 아니고, 입력이 + * 바뀌면 버리고 다시 만든다. 페이지를 오갈 때 다시 부르지 않으려고 둔다. + * + * 키 형식은 `aislo:{통}:{이름}:{프로젝트}[:{노선}]` 하나다. **페이지 이름을 키에 넣지 + * 않는다** — ②·④는 두 화면이 같은 키를 쓴다. + * ========================================================================== */ + +/** 값이 속한 통. `pref`·`draft`·`result` 셋만 저장소를 쓴다(즉시 반영은 표에 없다). */ +export type StateBucket = "pref" | "draft" | "result"; + +/** 값을 가르는 범위 — 키에 무엇을 덧붙일지 정한다. */ +export type StateScope = "global" | "project" | "route"; + +export interface StateEntry { + bucket: StateBucket; + scope: StateScope; + /** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */ + version?: number; + /** 옛 키(있으면 한 번 읽어 옮기고 지운다). `project`·`route` 범위는 함수로 받는다. */ + legacy?: (projectId?: string, routeId?: number | string) => string; +} + +/** + * 등록표 — **새 값은 여기 줄부터 넣고 코드를 쓴다.** + * + * `scope: "global"` 은 프로젝트를 가리지 않는 화면 취향이다(패널 높이 등). 설계값은 + * 반드시 `project` 또는 `route` 범위를 쓴다 — 프로젝트를 옮겼는데 앞 프로젝트의 조작이 + * 남으면 안 된다. + */ +export const STATE_REGISTRY = { + /* ── ① 화면 취향 ─────────────────────────────────────────────────────── */ + "profile-collapsed": { + bucket: "pref", + scope: "global", + legacy: () => "b05-route-profile-collapsed", + }, + "profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" }, + "drainage-collapsed": { + bucket: "pref", + scope: "global", + legacy: () => "b05-route-drainage-collapsed", + }, + "drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" }, + "masshaul-open": { + bucket: "pref", + scope: "global", + legacy: () => "b05-route-profile-masshaul-open", + }, + "masshaul-height": { + bucket: "pref", + scope: "global", + legacy: () => "b05-route-profile-masshaul-height", + }, + /** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */ + "masshaul-visible": { + bucket: "pref", + scope: "global", + version: 5, + legacy: () => "b06:masshaul-visible-v4", + }, + "table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" }, + "table-height": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:height" }, + "section-panel-collapsed": { + bucket: "pref", + scope: "global", + legacy: () => "b06:profile-panel-collapsed", + }, + "section-panel-height": { + bucket: "pref", + scope: "global", + legacy: () => "b06:profile-panel-height", + }, + + /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ + /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ + structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, + /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ + uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` }, + /** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림. + * 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사). + * 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정]에서 `flushPendingPipes` 가 + * 내보낸 뒤 비운다. */ + pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` }, + /** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */ + "structure-pick": { + bucket: "draft", + scope: "project", + legacy: (p) => `aislo:structure-pick:${p}`, + }, + /** 배수관·암거 조정창에서 예약한 옵션 값. */ + culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` }, + /** 배수관 이동(측점 옮김) 예약. */ + culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, + /** 암 경계선 오프셋(측점별). */ + rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. + * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 + * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ + berm: { bucket: "draft", scope: "route" }, + /** 표준 횡단면 설정 패널의 편집값. + * + * ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은 + * 브라우저가 가진 **유일한** 사용자 표준단면이다 — `sections/context` 가 주는 + * `standard_cross_section` 은 저장분이 아니라 **config 기본값**이고(`B06_Section_Router.py:132`), + * 저장분(`stored_standard_cross_section`)은 서버 안에서만 쓰인다. 그래서 비우면 브라우저 + * 횡단 계산이 그 순간 config 기본값으로 되돌아간다. [초기화]에서만 버린다 + * (`clearStandardCrossSession`). */ + "std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` }, + /** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */ + "cross-display": { + bucket: "draft", + scope: "route", + legacy: (p, r) => `b06:cross-display:${p}:${r}`, + }, + /* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다. + 옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */ + crossw: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:crossw:${p}:${r}` }, + revetx: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetx:${p}:${r}` }, + revetlink: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetlink:${p}:${r}` }, + basinadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:basinadjust:${p}:${r}` }, + inletstruct: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:inletstruct:${p}:${r}` }, + extrawall: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extrawall:${p}:${r}` }, + fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` }, + boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` }, + extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` }, + /** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06. + * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 + * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ + crossdesign: { bucket: "draft", scope: "route" }, + + /* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */ + /** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */ + latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` }, + /** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을 + * 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리). + * 노선이 바뀌면 `latest` 와 함께 버린다. */ + "section-context": { bucket: "result", scope: "project" }, + /** 횡단 상세 응답 — 예전에는 메모리에만 있어 페이지를 떠나면 사라졌다. */ + "section-detail": { bucket: "result", scope: "route" }, + /** 서버가 준 규격 횡단 기본값 — 사용자가 만든 값이 아니라 **기억해 둔 서버 값**이다. */ + "std-cross-default": { + bucket: "result", + scope: "project", + legacy: (p) => `b06:std-cross-default:${p}`, + }, + /** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */ + "rock-boundary-default": { + bucket: "result", + scope: "project", + legacy: (p) => `b06:rock-boundary-default:${p}`, + }, +} as const satisfies Record; + +export type StateName = keyof typeof STATE_REGISTRY; + +const warnedNames = new Set(); + +/** + * 등록표에서 한 줄을 꺼낸다. 표에 없는 이름이면 **화면을 죽이지 않고** null 을 돌려준다 — + * 그 값만 저장이 안 될 뿐 페이지는 그대로 선다(2026-09-06: `extraspan` 이 빠져 B06 이 + * 통째로 안 뜬 적이 있다). 대신 콘솔에 한 번 알려 표에 줄을 넣게 한다. + */ +function entryOf(name: StateName): StateEntry | null { + const entry = STATE_REGISTRY[name] as StateEntry | undefined; + if (entry) return entry; + if (!warnedNames.has(name)) { + warnedNames.add(name); + console.warn(`[page-state] 등록표에 없는 이름입니다 — 저장하지 않습니다: ${name}`); + } + return null; +} + +/** + * 저장소 키. 범위가 요구하는 값이 없으면 `null` — 부를 쪽은 그때 저장을 건너뛴다 + * (프로젝트를 아직 모를 때 전역 키에 적으면 다음 프로젝트가 그 값을 물려받는다). + */ +export function stateKey( + name: StateName, + projectId?: string | null, + routeId?: number | string | null, +): string | null { + const entry = entryOf(name); + if (!entry) return null; + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + const head = `aislo:${entry.bucket}:${name}${suffix}`; + if (entry.scope === "global") return head; + if (!projectId) return null; + if (entry.scope === "project") return `${head}:${projectId}`; + if (routeId === null || routeId === undefined) return null; + return `${head}:${projectId}:${routeId}`; +} + +/** + * 어느 저장소에 둘지 — **화면 취향(`pref`)만 `localStorage`**, 나머지는 세션이다. + * + * 2026-09-07 확인 — 취향까지 세션에 있어 **탭을 새로 열거나 브라우저를 껐다 켤 때마다** + * 패널 높이·접힘이 초기화됐다(계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제 불편은 + * 훨씬 잦았다). 취향은 설계 데이터가 아니므로 5장 「캐시=세션」 규칙에 걸리지 않는다 — + * **설계값·초안·계산 결과는 세션 그대로** 둔다(그 경계를 흐리지 말 것). + */ +function storageFor(key: string): Storage { + return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage; +} + +/** + * **키만 아는 자리**를 위한 읽기·쓰기 창구 — 등록표 이름 대신 완성된 키를 든 곳이 쓴다 + * (`ui_template_resizer`·`_overlay`·`_overlay_drag`, 조정창 저장소). + * + * 저장소를 직접 고르게 두면 두 가지가 샜다 — 취향이 세션에 남아 탭마다 초기화됐고 + * (2026-09-07), 계정 올려보내기도 안 걸렸다. **저장소 선택과 올려보내기를 여기 한 곳**에 둔다. + */ +export function readByKey(key: string): string | null { + return readRaw(key); +} + +export function writeByKey(key: string, value: string | null): void { + writeRaw(key, value); +} + +/** + * 세션에 남아 있던 옛 취향을 **한 번에 브라우저 저장소로 옮긴다**(2026-09-07). + * + * 취향을 쓰는 화면 부품(높이 조절자·접이식 패널)은 키만 들고 저장소를 직접 만지므로 + * 읽는 자리마다 건져 올리는 방식으로는 **높이 값이 안 옮겨졌다**(실측: 접힘 4개는 옮겨지고 + * 높이 3개는 세션에 남음). 그래서 첫 로드 때 한 번 쓸어 옮긴다 — 쓰던 패널 크기가 + * 이 변경 때문에 초기화되지 않게 한다. 여러 번 불려도 안전하다. + */ +function liftPrefsToLocalStorage(): void { + try { + const stale = Object.keys(window.sessionStorage).filter((key) => key.startsWith("aislo:pref:")); + for (const key of stale) { + const value = window.sessionStorage.getItem(key); + if (value !== null && window.localStorage.getItem(key) === null) { + window.localStorage.setItem(key, value); + } + window.sessionStorage.removeItem(key); + } + } catch { + /* 저장소가 막힌 브라우저 — 취향은 이번 세션 동안 화면 메모리로만 산다. */ + } +} + +liftPrefsToLocalStorage(); + +/* ── 계정에 붙는 화면 취향 (2026-09-07 사용자 승인) ───────────────────────────── + * 브라우저에 남긴 값 위에 **계정 값을 얹는다** — PC 를 바꿔도 같은 배치가 되게. + * 규칙 셋: + * · 서버가 없거나 못 읽으면 **로컬 값으로 그대로 돈다**(계획서 원문). + * · 서버에 있는 키만 덮는다 — 서버가 비었다고 로컬을 지우지 않는다. + * · 취향이 바뀌면 잠시 모아 한 번에 올린다. **설계값은 여기 오지 않는다** — + * 자동저장 금지(CLAUDE.md 5장)는 설계값 규칙이고, 화면 배치는 [저장] 단추가 없다. + * ------------------------------------------------------------------------- */ +const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs"; +const PREFS_PUSH_DELAY_MS = 1500; +let applyingServerPrefs = false; +let prefsPushTimer: ReturnType | null = null; + +function localPrefs(): Record { + const out: Record = {}; + try { + for (const key of Object.keys(window.localStorage)) { + if (!key.startsWith("aislo:pref:")) continue; + const value = window.localStorage.getItem(key); + if (value !== null) out[key] = value; + } + } catch { + /* 저장소가 막힌 브라우저 — 올릴 것이 없다. */ + } + return out; +} + +/** 취향이 바뀌면 잠시 모아 한 번에 올린다. 실패는 조용히 넘긴다(다음 변경 때 다시 올라간다). */ +function pushUiPrefs(): void { + if (applyingServerPrefs) return; + if (prefsPushTimer) clearTimeout(prefsPushTimer); + prefsPushTimer = setTimeout(() => { + prefsPushTimer = null; + void fetch(PREFS_ENDPOINT, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prefs: localPrefs() }), + }).catch(() => undefined); + }, PREFS_PUSH_DELAY_MS); +} + +/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */ +export async function syncUiPrefs(): Promise { + let stored: Record = {}; + try { + const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); + if (!response.ok) return; + const body = (await response.json()) as { prefs?: Record }; + stored = body.prefs ?? {}; + } catch { + return; // 서버가 없으면 로컬 값으로 그대로 돈다. + } + applyingServerPrefs = true; + try { + for (const [key, value] of Object.entries(stored)) { + if (!key.startsWith("aislo:pref:") || typeof value !== "string") continue; + window.localStorage.setItem(key, value); + } + } catch { + /* 저장소가 막힌 브라우저 — 이번 세션은 화면 메모리로 돈다. */ + } finally { + applyingServerPrefs = false; + } +} + +/** 저장소 접근은 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */ +function readRaw(key: string): string | null { + try { + return storageFor(key).getItem(key); + } catch { + return null; + } +} + +function writeRaw(key: string, value: string | null): void { + try { + const store = storageFor(key); + if (value === null) store.removeItem(key); + else store.setItem(key, value); + } catch { + /* 무시 — 값은 화면 메모리에 남는다. */ + } + if (key.startsWith("aislo:pref:")) pushUiPrefs(); +} + +/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */ +function migrate( + name: StateName, + key: string, + projectId?: string | null, + routeId?: number | string | null, +): void { + const legacy = entryOf(name)?.legacy?.(projectId ?? undefined, routeId ?? undefined); + if (!legacy || legacy === key) return; + const old = readRaw(legacy); + if (old !== null && readRaw(key) === null) writeRaw(key, old); + if (old !== null) writeRaw(legacy, null); +} + +/** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */ +export function readStateRaw( + name: StateName, + projectId?: string | null, + routeId?: number | string | null, +): string | null { + const key = stateKey(name, projectId, routeId); + if (!key) return null; + migrate(name, key, projectId, routeId); + return readRaw(key); +} + +export function writeStateRaw( + name: StateName, + value: string | null, + projectId?: string | null, + routeId?: number | string | null, +): void { + const key = stateKey(name, projectId, routeId); + if (key) writeRaw(key, value); +} + +/** JSON 값을 읽는다. 없거나 손상됐으면 `null` — 부를 쪽이 기본값으로 시작한다. */ +export function readState( + name: StateName, + projectId?: string | null, + routeId?: number | string | null, +): T | null { + const raw = readStateRaw(name, projectId, routeId); + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function writeState( + name: StateName, + value: unknown, + projectId?: string | null, + routeId?: number | string | null, +): void { + writeStateRaw(name, value === null ? null : JSON.stringify(value), projectId, routeId); +} + +export function clearState( + name: StateName, + projectId?: string | null, + routeId?: number | string | null, +): void { + const entry = entryOf(name); + // 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다. + // 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`), + // 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169 + // 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 — + // 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다. + if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) { + const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : ""; + const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`; + try { + const doomed: string[] = []; + for (let i = 0; i < window.sessionStorage.length; i += 1) { + const key = window.sessionStorage.key(i); + if (key && key.startsWith(prefix)) doomed.push(key); + } + doomed.forEach((key) => writeRaw(key, null)); + } catch { + /* 무시 — 저장소가 막힌 브라우저 */ + } + return; + } + writeStateRaw(name, null, projectId, routeId); +} + +/** 등록표에서 한 통에 속한 이름만 고른다. */ +export function namesInBucket(bucket: StateBucket): StateName[] { + return (Object.keys(STATE_REGISTRY) as StateName[]).filter( + (name) => entryOf(name)?.bucket === bucket, + ); +} + +/** + * 초안을 통째로 비운다 — **[저장]·[확정]·[초기화]·노선 변경 뒤 여기 한 곳**만 부른다 + * (예전에는 파일마다 따로 지웠다). 노선을 안 주면 그 프로젝트의 **모든 노선** 초안이 나간다 + * (노선 변경 자리가 그렇게 부른다 — 새 번호를 아직 모르기 때문). + */ +export function clearDrafts(projectId: string | null, routeId?: number | string | null): void { + namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId)); +} + +/** 계산 결과를 통째로 버린다 — 입력(설계값·노선)이 바뀌어 다시 만들어야 할 때. */ +export function clearResults(projectId: string | null, routeId?: number | string | null): void { + namesInBucket("result").forEach((name) => clearState(name, projectId, routeId)); +} + +/** + * 지금 이 브라우저에 쌓인 초안이 있는가 — [저장] 버튼의 미저장 표시에 쓴다. + * 값이 빈 객체(`{}`)면 없는 것으로 본다. + */ +export function hasDrafts(projectId: string | null, routeId?: number | string | null): boolean { + return namesInBucket("draft").some((name) => { + const raw = readStateRaw(name, projectId, routeId); + return raw !== null && raw !== "{}" && raw !== "[]" && raw !== ""; + }); +} diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index bbd26970..52a2b613 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -30,15 +30,39 @@ export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [ ROUTES.B09_ESTIMATION, ]; +/* 워크플로 상태는 한 화면을 여는 동안 준비 화면 가드와 페이지 본체가 각각 부른다 — + 실측 B05 진입에서 두 번 나갔다(2026-09-06). 짧은 시간 동안 한 번만 부르고 나눠 쓴다. + 단계가 바뀌는 자리(`goToWorkflowStage`)에서는 즉시 버린다. */ +const WORKFLOW_CACHE_MS = 5000; +let workflowCache: { at: number; projectId: string; value: Promise } | null = null; + +/** 워크플로 상태 캐시를 버린다 — 단계가 바뀌는 자리에서 부른다. */ +export function clearWorkflowStateCache(): void { + workflowCache = null; +} + export async function fetchWorkflowState(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { - credentials: "include", - }); - if (!response.ok) { - throw new Error(`Workflow state request failed: ${response.status}`); + const now = Date.now(); + if ( + workflowCache && + workflowCache.projectId === projectId && + now - workflowCache.at < WORKFLOW_CACHE_MS + ) { + return workflowCache.value; } - const data = await response.json(); - return data.workflow_state ?? data; + const value = (async (): Promise => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Workflow state request failed: ${response.status}`); + } + const data = await response.json(); + return data.workflow_state ?? data; + })(); + workflowCache = { at: now, projectId, value }; + value.catch(() => clearWorkflowStateCache()); + return value; } /** @@ -52,6 +76,8 @@ const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, RO export function goToWorkflowStage(projectId: string, route: RoutePath): void { localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. + clearWorkflowStateCache(); if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { navigateTo(route); return; diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index ac768d3b..157cb129 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -1,4 +1,5 @@ import { API_BASE_URL } from "@config/config_frontend"; +import { syncUiPrefs } from "../A00_Common/b_page_state"; export interface ApiResult { status: string; @@ -32,25 +33,58 @@ export function requestLogin(email: string, password: string): Promise { + clearSessionCache(); return post("/auth/login/verify", { email, otp_code: otpCode }); } +/* 세션 조회는 한 화면을 여는 동안 라우터 가드·상단 바·워크플로 확인이 각각 부른다 — + 실측 B05 진입에서 `GET /auth/session` 이 네 번 나갔다(2026-09-06). 같은 답을 네 번 + 받을 이유가 없으므로 **짧은 시간 동안 한 번만** 부르고 나눠 쓴다. 로그인·로그아웃 + 에서는 즉시 버려 다음 조회가 서버를 다시 본다. */ +const SESSION_CACHE_MS = 5000; +let sessionCache: { at: number; value: Promise } | null = null; +/** 계정 취향 받아오기는 한 번만 — 세션 조회는 화면마다 여러 번 불린다. */ +let uiPrefsSynced = false; + +/** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */ +export function clearSessionCache(): void { + sessionCache = null; + // 로그인·로그아웃이면 다음 세션에서 취향을 다시 받아 온다(다른 계정일 수 있다). + uiPrefsSynced = false; +} + +function sessionOnce(): Promise { + const now = Date.now(); + if (sessionCache && now - sessionCache.at < SESSION_CACHE_MS) return sessionCache.value; + const value = (async (): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); + if (!response.ok) return null; + const data = (await response.json()) as { status: string; user?: SessionUser }; + // 로그인이 확인된 첫 순간에 계정 취향을 한 번 받아 온다 — PC 를 바꿔도 같은 배치가 + // 되게(2026-09-07). 실패해도 화면은 브라우저에 남은 값으로 그대로 돈다. + if (data.user && !uiPrefsSynced) { + uiPrefsSynced = true; + void syncUiPrefs(); + } + return data.user ?? null; + } catch { + return null; + } + })(); + sessionCache = { at: now, value }; + return value; +} + export async function fetchSession(): Promise { - const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); - return response.ok; + return (await sessionOnce()) !== null; } export async function fetchSessionUser(): Promise { - try { - const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); - if (!response.ok) return null; - const data = (await response.json()) as { status: string; user?: SessionUser }; - return data.user ?? null; - } catch { - return null; - } + return sessionOnce(); } export function logout(): Promise { + clearSessionCache(); return post("/auth/logout"); } diff --git a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts index 94f672e5..e258ca87 100644 --- a/B01_Dashboard/B01_Dashboard_Api_Fetch.ts +++ b/B01_Dashboard/B01_Dashboard_Api_Fetch.ts @@ -59,9 +59,10 @@ export interface ProjectItem { designer_user_id?: number | null; logo_asset_id?: number | null; signature_asset_id?: number | null; + /** 참여자 (2026-09-06 사용자 확정) — 여기 든 사람은 일반 사용자여도 수정할 수 있다. */ + member_user_ids?: number[]; owner_name?: string | null; workflow_stage: number; - progress_percent: number; workflow_state?: WorkflowState; updated_at?: string | null; } @@ -116,6 +117,9 @@ export interface AuditLog { action: string; resource_type?: string | null; resource_id?: number | null; + /** 대상 식별자 문자열 — 프로젝트는 UUID 라 숫자 칸에 못 담는다 (2026-09-06). */ + resource_ref?: string | null; + ip_address?: string | null; timestamp: string; } @@ -171,6 +175,7 @@ export interface UpdateProjectRequest { designer_user_id?: number | null; logo_asset_id?: number | null; signature_asset_id?: number | null; + member_user_ids?: number[] | null; } export interface AdminUpdateUserRequest extends UpdateUserRequest { @@ -306,17 +311,56 @@ export function deleteCompanyAsset(assetId: number): Promise { export const companyAssetFileUrl = (assetId: number): string => `${API_BASE_URL}/dashboard/company/assets/${assetId}/file`; -/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */ -export function addCompanyMember( - email: string, - profile?: { name?: string; position?: string | null; department?: string | null }, -): Promise<{ member: Member }> { +/** 주소를 좌표로 바꾼다 (회사 주소 지도 미리보기). */ +export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number }> { + return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`); +} + +/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */ +export function updateCompany( + payload: { + name: string; + business_registration_number: string; + business_address: string | null; + business_owner: string | null; + }, + companyId?: number | null, +): Promise { + return request(`/dashboard/company${companyQuery(companyId)}`, { + method: "PUT", + body: body(payload), + }); +} + +/** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */ +export async function searchMemberCandidates(query: string): Promise { + const data = await request<{ users: Member[] }>( + `/dashboard/admin/members/candidates?q=${encodeURIComponent(query)}`, + ); + return data.users; +} + +/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */ +export function addCompanyMember(userId: number): Promise<{ member: Member }> { return request("/dashboard/admin/members", { method: "POST", - body: body({ email, ...profile }), + body: body({ user_id: userId }), }); } +/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */ +export function inviteMember(email: string, name?: string | null): Promise { + return request("/dashboard/admin/members/invite", { + method: "POST", + body: body({ email, name: name || null }), + }); +} + +/** 계정 삭제 (회사에서 빼기가 아니라 계정 자체). 회사 관리자는 자기 회사 사람만. */ +export function deleteDashboardUser(userId: number): Promise { + return request(`/dashboard/admin/users/${userId}`, { method: "DELETE" }); +} + /** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */ export function setCompanyLogo( logoAssetId: number | null, diff --git a/B01_Dashboard/B01_Dashboard_Map.py b/B01_Dashboard/B01_Dashboard_Map.py new file mode 100644 index 00000000..6fbcf33c --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Map.py @@ -0,0 +1,59 @@ +"""회사 주소 지도 — VWorld 주소검색·배경지도 타일 (2026-09-06 사용자 지시). + +이미 쓰던 VWorld 키를 그대로 쓴다. 키가 화면으로 새지 않게 타일도 서버가 받아 넘긴다. +""" + +from __future__ import annotations + +import asyncio +import json +import urllib.parse +import urllib.request +from typing import Any + +from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY + +_GEOCODE_URL = "https://api.vworld.kr/req/address" +_TILE_URL = "http://api.vworld.kr/req/wmts/1.0.0/{key}/Base/{z}/{y}/{x}.png" + + +def _fetch(url: str, timeout: int = 5) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + +def _geocode_sync(address: str) -> dict[str, Any] | None: + """도로명으로 먼저 찾고, 없으면 지번으로 다시 찾는다.""" + for address_type in ("ROAD", "PARCEL"): + query = urllib.parse.urlencode( + { + "service": "address", + "request": "getcoord", + "version": "2.0", + "crs": "epsg:4326", + "address": address, + "refine": "true", + "simple": "false", + "format": "json", + "type": address_type, + "key": VWORLD_API_KEY, + } + ) + try: + body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8")) + except Exception: + continue + point = (body.get("response") or {}).get("result", {}).get("point") + if point: + return {"lon": float(point["x"]), "lat": float(point["y"])} + return None + + +async def geocode_address(address: str) -> dict[str, Any] | None: + return await asyncio.to_thread(_geocode_sync, address.strip()) + + +async def fetch_base_tile(z: int, x: int, y: int) -> bytes: + url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x) + return await asyncio.to_thread(_fetch, url) diff --git a/B01_Dashboard/B01_Dashboard_Repository.py b/B01_Dashboard/B01_Dashboard_Repository.py index 5f284ae0..b7fe4ac8 100644 --- a/B01_Dashboard/B01_Dashboard_Repository.py +++ b/B01_Dashboard/B01_Dashboard_Repository.py @@ -10,18 +10,49 @@ from typing import Any import aiomysql import psutil +from common_util.common_util_audit import record_audit from config.config_db import get_db_pool -from config.config_system import EMAIL_REVERIFY_DAYS +from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS def _role(value: str | None) -> str: return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER") -def _stage_from_status(status: str | None) -> tuple[int, int]: +async def get_system_company_id() -> int | None: + """시스템 관리 회사 = `.env` 관리자 계정이 속한 회사 (2026-09-06 사용자 확정). + + 개발사 자기 회사 한 곳뿐이라 따로 표시 칸을 두지 않고 이 한 줄로 판정한다. + """ + if not ADMIN_EMAIL: + return None + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT company_id FROM users WHERE email = %s AND deleted_at IS NULL", + (ADMIN_EMAIL.lower(),), + ) + row = await cursor.fetchone() + return int(row["company_id"]) if row and row["company_id"] else None + + +async def role_for_company(company_id: int | None) -> str: + """회사에 들어갈 때 받는 역할 — 시스템 회사면 시스템관리자, 아니면 일반사용자.""" + if company_id is not None and int(company_id) == (await get_system_company_id() or 0): + return "SYSTEM_ADMIN" + return "USER" + + +def _stage_from_status(status: str | None) -> int: + """프로젝트 상태 문자열에서 워크플로 단계만 뽑는다. + + 진행도(%)는 내지 않는다 (2026-09-06 사용자 지시) — 화면은 워크플로 배지로 보여 주고, + 배지는 `project_workflow_stages` 표를 근거로 삼는다. 상태 문자열로 따로 세면 근거가 + 둘이 되어 배지와 숫자가 어긋났다. + """ value = status or "NEW" if value in {"WF1_ANALYZING", "WF1_FAILED"}: - return 1, round(1 / 7 * 100) + return 1 order = [ ("FILE_UPLOADED", 1), ("WF1_COMPLETE", 2), @@ -37,20 +68,24 @@ def _stage_from_status(status: str | None) -> tuple[int, int]: for token, idx in order: if token in value: stage = max(stage, idx) - return stage, round(stage / 7 * 100) + return stage def _project_row(row: dict[str, Any]) -> dict[str, Any]: - stage, progress = _stage_from_status(row.get("status")) - return {**row, "workflow_stage": stage, "progress_percent": progress} + return {**row, "workflow_stage": _stage_from_status(row.get("status"))} async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + from .B01_Dashboard_Repository_Members import list_project_member_ids + states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows]) + members = await list_project_member_ids(cursor, [r["id"] for r in rows]) result = [] for r in rows: p_row = _project_row(r) p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []}) + # 참여자는 일반 사용자여도 그 프로젝트를 수정할 수 있다 (2026-09-06 사용자 확정). + p_row["member_user_ids"] = members.get(str(r["id"]), []) result.append(p_row) return result @@ -222,7 +257,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None: return await cursor.fetchone() -async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -> bool: +async def update_project( + project_id: str, data: dict[str, Any], actor_id: int, request: Any | None = None +) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await connection.begin() @@ -258,17 +295,27 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) - ), ) changed = cursor.rowcount > 0 - if changed: + if not changed: + # 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 프로젝트가 없는 것과는 다르다 + # (참여자만 바꿀 때 「찾을 수 없습니다」로 튕기던 자리, 2026-09-06). await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""", - (actor_id,), + "SELECT 1 FROM projects WHERE id = %s AND deleted_at IS NULL", (project_id,) + ) + changed = await cursor.fetchone() is not None + else: + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_UPDATE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() return changed -async def soft_delete_project(project_id: str, actor_id: int) -> bool: +async def soft_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await connection.begin() @@ -279,211 +326,18 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool: ) changed = cursor.rowcount > 0 if changed: - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_DELETE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() return changed -async def get_user_company(company_id: int | None) -> dict[str, Any] | None: - if company_id is None: - return None - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT c.id, c.name, c.business_registration_number, c.business_address, - c.business_owner, c.business_status, c.logo_asset_id, - COUNT(DISTINCT u.id) AS user_count, - COUNT(DISTINCT p.id) AS project_count - FROM companies c - LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL - LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL - WHERE c.id = %s AND c.deleted_at IS NULL - GROUP BY c.id""", - (company_id,), - ) - return await cursor.fetchone() - - -async def search_companies(query: str) -> list[dict[str, Any]]: - pool = get_db_pool() - pattern = f"%{query}%" - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT id, name, business_registration_number, business_status - FROM companies - WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) - ORDER BY name LIMIT 20""", - (pattern, pattern), - ) - return list(await cursor.fetchall()) - - -async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, business_owner, - business_status, master_user_id, created_by, status) - VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - user_id, - user_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - """UPDATE users - SET company_id = %s, role = 'ADMIN', is_master = TRUE, - status = 'ACTIVE' - WHERE id = %s""", - (company_id, user_id), - ) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (user_id, company_id), - ) - await connection.commit() - return {"company_id": company_id, "status": "ACTIVE"} - except Exception: - await connection.rollback() - raise - - -async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO companies - (name, business_registration_number, business_address, business_owner, - business_status, created_by, status) - VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", - ( - data["name"], - data["business_registration_number"], - data.get("business_address"), - data.get("business_owner"), - actor_id, - ), - ) - company_id = cursor.lastrowid - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'COMPANY_CREATE', 'company', %s)""", - (actor_id, company_id), - ) - await connection.commit() - return {"company_id": company_id, "status": "ACTIVE"} - except Exception: - await connection.rollback() - raise - - -async def join_company(user_id: int, company_id: int) -> dict[str, Any]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor() as cursor: - try: - await connection.begin() - await cursor.execute( - """INSERT INTO join_requests (user_id, company_id, status) - VALUES (%s, %s, 'PENDING') - ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, - reviewed_by = NULL, reviewed_at = NULL""", - (user_id, company_id), - ) - request_id = cursor.lastrowid - await cursor.execute( - "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) - ) - await connection.commit() - return {"join_request_id": request_id, "status": "PENDING"} - except Exception: - await connection.rollback() - raise - - -async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: - where = "WHERE jr.company_id = %s" if company_id else "" - params = (company_id,) if company_id else () - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, - u.email AS user_email, u.name AS user_name, c.name AS company_name - FROM join_requests jr - JOIN users u ON u.id = jr.user_id - JOIN companies c ON c.id = jr.company_id - {where} - ORDER BY jr.requested_at DESC LIMIT 100""", - params, - ) - return list(await cursor.fetchall()) - - -async def process_join_request( - request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None -) -> bool: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await connection.begin() - where = "id = %s AND status = 'PENDING'" - params: tuple[Any, ...] = (request_id,) - if company_id is not None: - where += " AND company_id = %s" - params = (request_id, company_id) - await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) - row = await cursor.fetchone() - if not row: - await connection.rollback() - return False - status = "APPROVED" if approved else "REJECTED" - await cursor.execute( - """UPDATE join_requests SET status = %s, reviewed_by = %s, - reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", - (status, reviewer_id, request_id), - ) - if approved: - await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE' - WHERE id = %s""", - (row["company_id"], row["user_id"]), - ) - else: - await cursor.execute( - "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) - ) - await connection.commit() - return True - - -async def list_all_companies() -> list[dict[str, Any]]: - pool = get_db_pool() - async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - """SELECT c.id, c.name, c.business_registration_number, c.business_status, - c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count, - COUNT(DISTINCT p.id) AS project_count - FROM companies c - LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL - LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL - WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" - ) - return list(await cursor.fetchall()) - - async def list_all_users() -> list[dict[str, Any]]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: @@ -503,7 +357,11 @@ async def list_all_users() -> list[dict[str, Any]]: async def change_user_role(user_id: int, role: str) -> bool: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: - await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id)) + # is_master 도 권한 판정에 쓰이므로 역할과 어긋나지 않게 함께 맞춘다. + await cursor.execute( + "UPDATE users SET role = %s, is_master = %s WHERE id = %s", + (role, role in ("ADMIN", "SYSTEM_ADMIN"), user_id), + ) changed = cursor.rowcount > 0 await connection.commit() return changed @@ -558,6 +416,26 @@ async def count_company_admins(company_id: int) -> int: return int(row["cnt"] if row else 0) +async def soft_delete_user(user_id: int) -> bool: + """사용자 계정을 지운다 (2026-09-06 사용자 확정 — 회사 관리자도 자기 회사 사람은 삭제). + + 지운 표시만 남기고 소속을 푼다. 상태가 INACTIVE 라 다음 요청에서 세션이 끊긴다. + 이메일은 그대로 둔다 — 같은 주소로 다시 가입하려면 시스템 관리자가 되살려야 한다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE users + SET deleted_at = CURRENT_TIMESTAMP, status = 'INACTIVE', + company_id = NULL, is_master = FALSE + WHERE id = %s AND deleted_at IS NULL""", + (user_id,), + ) + changed = cursor.rowcount > 0 + await connection.commit() + return changed + + async def assign_user_company(user_id: int, company_id: int | None) -> bool: status = "ACTIVE" if company_id else "NO_COMPANY" pool = get_db_pool() @@ -578,7 +456,7 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]: total = (await cursor.fetchone())["total"] await cursor.execute( """SELECT l.id, l.user_id, u.email, l.action, l.resource_type, - l.resource_id, l.timestamp + l.resource_id, l.resource_ref, l.ip_address, l.timestamp FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""", (limit, offset), @@ -642,3 +520,38 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]: (bucket_seconds, bucket_seconds, since, bucket_seconds), ) return {"current": current, "history": list(await cursor.fetchall()), "stats": current} + + +async def get_user_ui_prefs(user_id: int) -> dict[str, Any]: + """계정에 붙은 화면 취향(배치·표시). 없으면 빈 묶음. + + 설계값이 아니라 **패널 높이·접힘 같은 배치 값**만 담는다(표 `user_ui_prefs` 주석 참조). + 읽기 실패는 비치명 — 화면은 브라우저에 남은 값으로 그대로 돈다. + """ + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user_id,)) + row = await cursor.fetchone() + if not row or not row[0]: + return {} + raw = row[0] + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return {} + return raw if isinstance(raw, dict) else {} + + +async def save_user_ui_prefs(user_id: int, prefs: dict[str, Any]) -> None: + """화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """INSERT INTO user_ui_prefs (user_id, prefs) VALUES (%s, %s) + ON DUPLICATE KEY UPDATE prefs = VALUES(prefs)""", + (user_id, json.dumps(prefs, ensure_ascii=False)), + ) + await connection.commit() diff --git a/B01_Dashboard/B01_Dashboard_Repository_Company.py b/B01_Dashboard/B01_Dashboard_Repository_Company.py new file mode 100644 index 00000000..c4a6ae6a --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Repository_Company.py @@ -0,0 +1,264 @@ +"""회사·가입 신청 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한). + +회사를 만들고 고치고 찾는 일, 그리고 그 회사에 들어가겠다는 신청을 다루는 곳이다. +사용자·프로젝트 쪽은 `B01_Dashboard_Repository` 에 남는다. +""" + +from __future__ import annotations + +from typing import Any + +import aiomysql + +from common_util.common_util_audit import record_audit +from config.config_db import get_db_pool + +from .B01_Dashboard_Repository import role_for_company + + +async def get_user_company(company_id: int | None) -> dict[str, Any] | None: + if company_id is None: + return None + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT c.id, c.name, c.business_registration_number, c.business_address, + c.business_owner, c.business_status, c.logo_asset_id, + COUNT(DISTINCT u.id) AS user_count, + COUNT(DISTINCT p.id) AS project_count + FROM companies c + LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.id = %s AND c.deleted_at IS NULL + GROUP BY c.id""", + (company_id,), + ) + return await cursor.fetchone() + + +async def update_company(company_id: int, data: dict[str, Any]) -> bool: + """회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + """UPDATE companies + SET name = %s, business_registration_number = %s, + business_address = %s, business_owner = %s + WHERE id = %s AND deleted_at IS NULL""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + company_id, + ), + ) + changed = cursor.rowcount > 0 + if not changed: + # 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 회사가 없는 것과는 다르다. + await cursor.execute( + "SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (company_id,) + ) + changed = await cursor.fetchone() is not None + await connection.commit() + return changed + + +async def search_companies(query: str) -> list[dict[str, Any]]: + pool = get_db_pool() + pattern = f"%{query}%" + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, business_registration_number, business_status + FROM companies + WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s) + ORDER BY name LIMIT 20""", + (pattern, pattern), + ) + return list(await cursor.fetchall()) + + +async def create_company( + user_id: int, data: dict[str, Any], request: Any | None = None +) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, master_user_id, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + user_id, + user_id, + ), + ) + company_id = cursor.lastrowid + await cursor.execute( + """UPDATE users + SET company_id = %s, role = 'ADMIN', is_master = TRUE, + status = 'ACTIVE' + WHERE id = %s""", + (company_id, user_id), + ) + await record_audit( + cursor, + actor_id=user_id, + action="COMPANY_CREATE", + resource_type="company", + resource_ref=company_id, + request=request, + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def create_system_company( + actor_id: int, data: dict[str, Any], request: Any | None = None +) -> dict[str, Any]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + await cursor.execute( + """INSERT INTO companies + (name, business_registration_number, business_address, business_owner, + business_status, created_by, status) + VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""", + ( + data["name"], + data["business_registration_number"], + data.get("business_address"), + data.get("business_owner"), + actor_id, + ), + ) + company_id = cursor.lastrowid + await record_audit( + cursor, + actor_id=actor_id, + action="COMPANY_CREATE", + resource_type="company", + resource_ref=company_id, + request=request, + ) + await connection.commit() + return {"company_id": company_id, "status": "ACTIVE"} + except Exception: + await connection.rollback() + raise + + +async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + try: + await connection.begin() + # 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) — + # 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다. + await cursor.execute( + "SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE", + (user_id,), + ) + current = await cursor.fetchone() + if current and current[0]: + await connection.rollback() + return None + await cursor.execute( + """INSERT INTO join_requests (user_id, company_id, status) + VALUES (%s, %s, 'PENDING') + ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP, + reviewed_by = NULL, reviewed_at = NULL""", + (user_id, company_id), + ) + request_id = cursor.lastrowid + await cursor.execute( + "UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,) + ) + await connection.commit() + return {"join_request_id": request_id, "status": "PENDING"} + except Exception: + await connection.rollback() + raise + + +async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]: + # 처리 끝난 신청은 목록에 남기지 않는다 (2026-09-06 사용자 지시) — 승인된 사람은 + # 사용자 관리 목록에 이미 있어 같은 사람이 두 번 보였다. + where = "WHERE jr.status = 'PENDING'" + (" AND jr.company_id = %s" if company_id else "") + params = (company_id,) if company_id else () + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status, + u.email AS user_email, u.name AS user_name, c.name AS company_name + FROM join_requests jr + JOIN users u ON u.id = jr.user_id + JOIN companies c ON c.id = jr.company_id + {where} + ORDER BY jr.requested_at DESC LIMIT 100""", + params, + ) + return list(await cursor.fetchall()) + + +async def process_join_request( + request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None +) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await connection.begin() + where = "id = %s AND status = 'PENDING'" + params: tuple[Any, ...] = (request_id,) + if company_id is not None: + where += " AND company_id = %s" + params = (request_id, company_id) + await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params) + row = await cursor.fetchone() + if not row: + await connection.rollback() + return False + status = "APPROVED" if approved else "REJECTED" + await cursor.execute( + """UPDATE join_requests SET status = %s, reviewed_by = %s, + reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""", + (status, reviewer_id, request_id), + ) + if approved: + # 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정). + role = await role_for_company(int(row["company_id"])) + await cursor.execute( + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s + WHERE id = %s""", + (row["company_id"], role, row["user_id"]), + ) + else: + await cursor.execute( + "UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],) + ) + await connection.commit() + return True + + +async def list_all_companies() -> list[dict[str, Any]]: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT c.id, c.name, c.business_registration_number, c.business_status, + c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count, + COUNT(DISTINCT p.id) AS project_count + FROM companies c + LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL + LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL + WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC""" + ) + return list(await cursor.fetchall()) diff --git a/B01_Dashboard/B01_Dashboard_Repository_Members.py b/B01_Dashboard/B01_Dashboard_Repository_Members.py index 0a7d9c1d..14a57d54 100644 --- a/B01_Dashboard/B01_Dashboard_Repository_Members.py +++ b/B01_Dashboard/B01_Dashboard_Repository_Members.py @@ -6,16 +6,14 @@ from __future__ import annotations -import secrets from typing import Any import aiomysql from fastapi import HTTPException -from common_util.common_util_auth import hash_password from config.config_db import get_db_pool -from .B01_Dashboard_Repository import _role, get_dashboard_me +from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company from .B01_Dashboard_Repository_Assets import list_company_assets @@ -34,53 +32,56 @@ async def list_company_members(company_id: int) -> list[dict[str, Any]]: return rows -async def add_company_member( - company_id: int, - email: str, - profile: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """회사에 사람을 붙인다. +async def list_unassigned_users(query: str) -> list[dict[str, Any]]: + """소속이 없는 가입자만 찾는다 (2026-09-06 사용자 확정). - `profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다** - (2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는 - 비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다. + 다른 회사 소속자는 보이지 않는다 — 팀원 등록은 「이미 가입한 사람을 고르는」 일이다. """ + like = f"%{query.strip()}%" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """SELECT id, name, position, email, department, status + FROM users + WHERE company_id IS NULL AND deleted_at IS NULL + AND status IN ('NO_COMPANY', 'PENDING') + AND (name LIKE %s OR email LIKE %s) + ORDER BY name, email LIMIT 20""", + (like, like), + ) + return list(await cursor.fetchall()) + + +async def attach_company_member(company_id: int, user_id: int) -> dict[str, Any] | None: + """가입한 사람을 회사에 붙인다. 소속이 이미 있으면 붙이지 않는다.""" pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await connection.begin() await cursor.execute( """SELECT id, company_id FROM users - WHERE email = %s AND deleted_at IS NULL FOR UPDATE""", - (email.lower(),), + WHERE id = %s AND deleted_at IS NULL FOR UPDATE""", + (user_id,), ) user = await cursor.fetchone() - if not user and profile and profile.get("name"): - await cursor.execute( - """INSERT INTO users (email, password_hash, name, position, department, - company_id, role, status) - VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""", - ( - email.lower(), - hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호 - profile["name"], - profile.get("position"), - profile.get("department"), - company_id, - ), - ) - await connection.commit() - return await get_dashboard_me(cursor.lastrowid) - if not user or user["company_id"] == company_id: + if not user or user["company_id"] is not None: await connection.rollback() return None await cursor.execute( - """UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER', + """UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s, is_master = FALSE WHERE id = %s""", - (company_id, user["id"]), + (company_id, await role_for_company(company_id), user_id), + ) + # 남아 있던 가입 신청은 닫는다 — 목록에 같은 사람이 두 번 보이지 않게 한다. + await cursor.execute( + """UPDATE join_requests + SET status = CASE WHEN company_id = %s THEN 'APPROVED' ELSE 'REJECTED' END, + reviewed_at = CURRENT_TIMESTAMP + WHERE user_id = %s AND status = 'PENDING'""", + (company_id, user_id), ) await connection.commit() - return await get_dashboard_me(user["id"]) + return await get_dashboard_me(user_id) async def remove_company_member(company_id: int, user_id: int) -> bool: @@ -121,6 +122,8 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: user_ids = { data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k) } + # 참여자도 같은 회사 사람이어야 한다 (2026-09-06 사용자 확정). + user_ids |= {int(uid) for uid in (data.get("member_user_ids") or [])} if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}: raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.") wanted = { @@ -132,3 +135,45 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None: kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)} if any(kinds.get(data[k]) != kind for k, kind in wanted.items()): raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.") + + +async def list_project_member_ids(cursor: Any, project_ids: list[str]) -> dict[str, list[int]]: + """프로젝트별 참여자 id 목록 (2026-09-06 사용자 확정).""" + if not project_ids: + return {} + marks = ", ".join(["%s"] * len(project_ids)) + await cursor.execute( + f"""SELECT project_id, user_id FROM project_members + WHERE project_id IN ({marks}) ORDER BY user_id""", + tuple(project_ids), + ) + result: dict[str, list[int]] = {} + for row in await cursor.fetchall(): + key = row["project_id"] if isinstance(row, dict) else row[0] + value = row["user_id"] if isinstance(row, dict) else row[1] + result.setdefault(str(key), []).append(int(value)) + return result + + +async def set_project_members(project_id: str, user_ids: list[int]) -> None: + """참여자 목록을 통째로 맞춘다. 만든 사람은 화면에서 늘 포함해 보낸다.""" + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await cursor.execute("DELETE FROM project_members WHERE project_id = %s", (project_id,)) + for user_id in dict.fromkeys(user_ids): + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, int(user_id)), + ) + await connection.commit() + + +async def is_project_member(project_id: str, user_id: int) -> bool: + pool = get_db_pool() + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "SELECT 1 FROM project_members WHERE project_id = %s AND user_id = %s", + (project_id, user_id), + ) + return await cursor.fetchone() is not None diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index a0227f68..0635b8dc 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -1,36 +1,48 @@ """B01_Dashboard 역할별 대시보드 API.""" +import html import mimetypes import os from typing import Any -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Path, + Query, + Request, + Response, + UploadFile, +) +from pymysql.err import IntegrityError from common_util.common_util_auth import require_company, require_system_admin, verify_session +from common_util.common_util_email import send_email from common_util.common_util_project_delete import hard_delete_project from common_util.common_util_storage import read_stored_asset -from config.config_system import PROJECT_DELETE_HARD_ENABLED +from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED +from .B01_Dashboard_Map import fetch_base_tile, geocode_address from .B01_Dashboard_Repository import ( assign_user_company, change_user_role, - create_company, + count_company_admins, get_dashboard_me, get_project, get_system_resources, get_user_admin_target, - get_user_company, - join_company, - list_all_companies, + get_user_ui_prefs, list_all_projects, list_all_users, list_audit_logs, list_company_projects, - list_join_requests, list_user_projects, - process_join_request, - search_companies, + save_user_ui_prefs, soft_delete_project, + soft_delete_user, update_admin_user, update_project, update_user_profile, @@ -46,12 +58,25 @@ from .B01_Dashboard_Repository_Assets import ( update_company_asset, write_company_asset_file, ) +from .B01_Dashboard_Repository_Company import ( + create_company, + get_user_company, + join_company, + list_all_companies, + list_join_requests, + process_join_request, + search_companies, + update_company, +) from .B01_Dashboard_Repository_Members import ( - add_company_member, + attach_company_member, check_project_refs, + is_project_member, list_company_members, + list_unassigned_users, remove_company_member, set_company_logo, + set_project_members, ) from .B01_Dashboard_Schema import ( AddMemberRequest, @@ -59,8 +84,10 @@ from .B01_Dashboard_Schema import ( AssignCompanyRequest, ChangeUserRoleRequest, CreateCompanyRequest, + InviteMemberRequest, JoinCompanyRequest, ProcessJoinRequest, + UiPrefsRequest, UpdateCompanyAssetRequest, UpdateCompanyLogoRequest, UpdateProjectRequest, @@ -107,12 +134,15 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An return asset -def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: +async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool: if session["role"] == "SYSTEM_ADMIN": return True if session["role"] == "ADMIN": return _same_company(session, project.get("company_id")) - return False + # 참여자로 지정된 일반 사용자도 수정할 수 있다 (2026-09-06 사용자 확정). + return _same_company(session, project.get("company_id")) and await is_project_member( + str(project["id"]), int(session["user_id"]) + ) def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool: @@ -142,9 +172,34 @@ async def patch_dashboard_me( return {"status": "success", "user": user} +# ── 화면 취향 (2026-09-07 사용자 승인) ─────────────────────────────────────── +# 패널 높이·접힘 같은 **배치 값**만 계정에 붙인다. 설계값은 여기 오지 않는다 — 초안·결과는 +# 브라우저에 두고 [저장]·[확정]에서만 정본으로 간다(CLAUDE.md 5장). +# 브라우저는 이 값을 **로컬 사본 위에 병합**하고, 서버가 없거나 못 읽으면 로컬 값으로 돈다. +@router.get("/me/ui-prefs") +async def read_ui_prefs(session: dict[str, Any] = Depends(verify_session)): + return {"status": "success", "prefs": await get_user_ui_prefs(int(session["user_id"]))} + + +@router.put("/me/ui-prefs") +async def write_ui_prefs( + payload: UiPrefsRequest, + session: dict[str, Any] = Depends(verify_session), +): + await save_user_ui_prefs(int(session["user_id"]), payload.prefs) + return {"status": "success"} + + @router.get("/user/projects") async def user_projects(session: dict[str, Any] = Depends(verify_session)): - return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))} + # 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다. + company_id = session.get("company_id") + projects = ( + await list_company_projects(int(company_id)) + if company_id + else await list_user_projects(int(session["user_id"])) + ) + return {"status": "success", "projects": projects} @router.get("/user/company") @@ -163,19 +218,74 @@ async def user_company_search( @router.post("/user/company/create") async def user_company_create( + request: Request, payload: CreateCompanyRequest, session: dict[str, Any] = Depends(verify_session), ): - result = await create_company(int(session["user_id"]), payload.model_dump()) + result = await create_company(int(session["user_id"]), payload.model_dump(), request) return {"status": "success", **result} +@router.put("/company") +async def company_update( + payload: CreateCompanyRequest, + company_id: int | None = Query(None, gt=0), + session: dict[str, Any] = Depends(require_company_admin), +): + """회사 정보 수정 — 시스템관리자만 남의 회사를 지정할 수 있다.""" + try: + changed = await update_company(_scope_company(session, company_id), payload.model_dump()) + except IntegrityError as exc: + raise HTTPException( + status_code=409, detail="같은 회사명 또는 사업자등록번호가 이미 있습니다." + ) from exc + if not changed: + raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.") + return {"status": "success"} + + +@router.get("/company/geocode") +async def company_geocode( + address: str = Query(min_length=2, max_length=500), + session: dict[str, Any] = Depends(verify_session), +): + """주소를 좌표로 바꾼다 — 회사 등록·수정 화면의 지도 미리보기용.""" + _ = session + point = await geocode_address(address) + if not point: + raise HTTPException(status_code=404, detail="주소를 찾지 못했습니다.") + return {"status": "success", **point} + + +@router.get("/map/tile/{z}/{x}/{y}") +async def map_tile( + z: int = Path(ge=0, le=19), + x: int = Path(ge=0), + y: int = Path(ge=0), + session: dict[str, Any] = Depends(verify_session), +): + """배경지도 타일 — VWorld 키가 화면으로 새지 않게 서버가 받아 넘긴다.""" + _ = session + if x >= 2**z or y >= 2**z: + raise HTTPException(status_code=400, detail="지도 타일 번호가 범위를 벗어났습니다.") + try: + tile = await fetch_base_tile(z, x, y) + except Exception as exc: # 지도 한 칸이 비는 것은 화면을 막을 일이 아니다 + raise HTTPException(status_code=502, detail="지도를 불러오지 못했습니다.") from exc + return Response(content=tile, media_type="image/png", headers={"Cache-Control": "max-age=3600"}) + + @router.post("/user/company/join") async def user_company_join( payload: JoinCompanyRequest, session: dict[str, Any] = Depends(verify_session), ): result = await join_company(int(session["user_id"]), payload.company_id) + if result is None: + raise HTTPException( + status_code=409, + detail="이미 회사에 소속돼 있습니다. 소속을 옮기려면 회사 관리자에게 요청하십시오.", + ) return {"status": "success", **result} @@ -193,25 +303,52 @@ async def admin_members( } +@router.get("/admin/members/candidates") +async def admin_member_candidates( + q: str = Query(min_length=2, max_length=100), + session: dict[str, Any] = Depends(require_company_admin), +): + """팀원으로 부를 수 있는 사람 — 소속이 없는 가입자만 (2026-09-06 사용자 확정).""" + _ = session + return {"status": "success", "users": await list_unassigned_users(q)} + + @router.post("/admin/members") async def admin_add_member( payload: AddMemberRequest, session: dict[str, Any] = Depends(require_company_admin), ): - member = await add_company_member( - _require_company_id(session), - payload.email, - { - "name": payload.name, - "position": payload.position, - "department": payload.department, - }, - ) + member = await attach_company_member(_require_company_id(session), payload.user_id) if not member: - raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.") + raise HTTPException( + status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다." + ) return {"status": "success", "member": member} +@router.post("/admin/members/invite") +async def admin_invite_member( + payload: InviteMemberRequest, + session: dict[str, Any] = Depends(require_company_admin), +): + """아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다.""" + company = await get_user_company(session.get("company_id")) + company_name = (company or {}).get("name") or "회사" + # 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다. + safe_company = html.escape(company_name) + safe_name = html.escape(payload.name or "") + sent = await send_email( + payload.email, + f"[Aislo] {company_name} 팀원 등록 안내", + f"

{safe_name}님, {safe_company} 에서 Aislo 팀원으로 등록하려 합니다.

" + f"

아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.

" + f'

{APP_PUBLIC_BASE_URL}

', + ) + if not sent: + raise HTTPException(status_code=502, detail="안내 메일을 보내지 못했습니다.") + return {"status": "success"} + + @router.delete("/admin/members/{user_id}") async def admin_remove_member( user_id: int, session: dict[str, Any] = Depends(require_company_admin) @@ -255,13 +392,14 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin @router.put("/projects/{project_id}") async def dashboard_update_project( project_id: str, + request: Request, payload: UpdateProjectRequest, session: dict[str, Any] = Depends(verify_session), ): project = await get_project(project_id) if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - if not _can_edit_project(session, project): + if not await _can_edit_project(session, project): raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.") data = payload.model_dump() # 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시). @@ -272,34 +410,35 @@ async def dashboard_update_project( detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.", ) await check_project_refs(int(project["company_id"]), data) - if not await update_project(project_id, data, int(session["user_id"])): + member_ids = data.pop("member_user_ids", None) + if not await update_project(project_id, data, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if member_ids is not None: + # 만든 사람은 늘 참여자로 남는다. + await set_project_members(project_id, [int(project["user_id"]), *member_ids]) return {"status": "success"} @router.delete("/projects/{project_id}") async def dashboard_delete_project( project_id: str, + request: Request, session: dict[str, Any] = Depends(verify_session), ): project = await get_project(project_id) if not project: raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") - can_del = False - if session["role"] == "SYSTEM_ADMIN": - can_del = True - # 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리 - # elif session["role"] == "ADMIN": - # if int(project.get("user_id") or 0) == int(session["user_id"]): - # can_del = True - + # 회사 관리자는 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). + can_del = session["role"] == "SYSTEM_ADMIN" or ( + session["role"] == "ADMIN" and _same_company(session, project.get("company_id")) + ) if not can_del: raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.") # 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다. delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project - if not await delete_project(project_id, int(session["user_id"])): + if not await delete_project(project_id, int(session["user_id"]), request): raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") return {"status": "success"} @@ -312,10 +451,11 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi @router.post("/admin/companies") async def system_create_company( + request: Request, payload: CreateCompanyRequest, session: dict[str, Any] = Depends(require_system_admin), ): - result = await create_company(int(session["user_id"]), payload.model_dump()) + result = await create_company(int(session["user_id"]), payload.model_dump(), request) return {"status": "success", **result} @@ -329,14 +469,29 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)): async def system_change_role( user_id: int, payload: ChangeUserRoleRequest, - session: dict[str, Any] = Depends(require_system_admin), + session: dict[str, Any] = Depends(require_company_admin), ): target = await get_user_admin_target(user_id) if not target: raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + # 회사 관리자는 자기 회사 사람만 (2026-09-06 사용자 확정). 시스템관리자는 전역이다. + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사 사용자는 바꿀 수 없습니다.") if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN": raise HTTPException( - status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다." + status_code=403, + detail="시스템 관리자 역할은 시스템 관리 회사 소속 여부로 정해집니다.", + ) + # 회사에 관리자가 하나도 남지 않게 되는 강등은 막는다 (본인 강등 포함). + if ( + target["role"] == "ADMIN" + and payload.role != "ADMIN" + and target.get("company_id") + and await count_company_admins(int(target["company_id"])) <= 1 + ): + raise HTTPException( + status_code=409, + detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", ) if not await change_user_role(user_id, payload.role): raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") @@ -362,6 +517,33 @@ async def admin_update_user( return {"status": "success"} +@router.delete("/admin/users/{user_id}") +async def admin_delete_user( + user_id: int, + session: dict[str, Any] = Depends(require_company_admin), +): + target = await get_user_admin_target(user_id) + if not target: + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")): + raise HTTPException(status_code=403, detail="다른 회사 사용자는 지울 수 없습니다.") + if int(session["user_id"]) == user_id: + raise HTTPException(status_code=409, detail="본인 계정은 지울 수 없습니다.") + # 회사에 관리자가 하나도 남지 않게 되는 삭제는 막는다. + if ( + target["role"] == "ADMIN" + and target.get("company_id") + and await count_company_admins(int(target["company_id"])) <= 1 + ): + raise HTTPException( + status_code=409, + detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.", + ) + if not await soft_delete_user(user_id): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.") + return {"status": "success"} + + @router.patch("/admin/users/{user_id}/company") async def system_assign_company( user_id: int, diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index b9d791e0..9218ab0d 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -12,6 +12,16 @@ class UpdateUserRequest(BaseModel): phone: str | None = Field(default=None, max_length=50) +class UiPrefsRequest(BaseModel): + """화면 취향 묶음 — 배치·표시 값만. 키는 브라우저 등록표(`b_page_state.ts`)가 정본이다. + + 값은 문자열 하나(높이는 `"438"`, 접힘은 `"true"`)라 저장소에 넣던 모양 그대로다. + 설계값이 섞여 들어오지 않게 **문자열만** 받는다. + """ + + prefs: dict[str, str] = Field(default_factory=dict) + + class CreateCompanyRequest(BaseModel): name: str = Field(min_length=1, max_length=255) business_registration_number: str = Field(min_length=1, max_length=20) @@ -24,12 +34,16 @@ class JoinCompanyRequest(BaseModel): class AddMemberRequest(BaseModel): + # 팀원 등록은 「이미 가입한 사람을 고르는」 일이다 (2026-09-06 사용자 확정) — + # 계정을 대신 만들지 않는다. 소속이 없는 가입자만 고를 수 있다. + user_id: int = Field(gt=0) + + +class InviteMemberRequest(BaseModel): + """아직 가입하지 않은 사람에게 보내는 가입 안내 메일.""" + email: str = Field(min_length=3, max_length=255) - # 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면 - # 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다. name: str | None = Field(default=None, max_length=100) - position: str | None = Field(default=None, max_length=100) - department: str | None = Field(default=None, max_length=100) class UpdateCompanyLogoRequest(BaseModel): @@ -52,6 +66,9 @@ class AssignCompanyRequest(BaseModel): class UpdateProjectRequest(BaseModel): name: str = Field(min_length=1, max_length=255) + # 참여자 (2026-09-06 사용자 확정) — 도면 표제란 3역할과 별개로, 설계에 손대는 사람들. + # 참여자면 일반 사용자도 그 프로젝트를 수정할 수 있다. 비우면 지금 값을 그대로 둔다. + member_user_ids: list[int] | None = Field(default=None) region: str | None = Field(default=None, max_length=100) road_type: str | None = Field(default=None, max_length=100) project_year: int | None = Field(default=None, ge=1900, le=2100) diff --git a/B01_Dashboard/B01_Dashboard_UI_Admin.ts b/B01_Dashboard/B01_Dashboard_UI_Admin.ts index 7874e27b..7aca5024 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Admin.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Admin.ts @@ -1,9 +1,19 @@ import { createButton } from "@ui/ui_template_elements"; import type { AuditLog, DashboardUser } from "./B01_Dashboard_Api_Fetch"; -import { canChangeRole } from "./B01_Dashboard_UI_Helper"; -import { openChangeRoleModal, openEditUserModal } from "./B01_Dashboard_UI_Modals"; +import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; +import { + openChangeRoleModal, + openDeleteUserModal, + openEditUserModal, +} from "./B01_Dashboard_UI_Modals"; import { table, text } from "@ui/ui_template_general_blocks"; -import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common"; +import { + DASHBOARD_VISIBLE_ROWS, + formatDate, + formatTime, + L, + stackedCell, +} from "./B01_Dashboard_UI_Common"; export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement { return table( @@ -39,6 +49,17 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H ); } + // 회사에서 빼기·계정 삭제 (2026-09-06 사용자 지시) — 범위는 백엔드가 다시 본다. + if (canDeleteUser(currentUser, user) && user.id !== currentUser.id) { + actionsEl.append( + createButton({ + label: L("B01_Dashboard_DeleteUser"), + variant: "ghost", + onClick: () => openDeleteUserModal(user), + }), + ); + } + return [ text(user.email), text(user.name), @@ -54,14 +75,44 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H ); } +/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */ +function auditTarget(log: AuditLog): string { + // 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다. + const key = (log.resource_type ?? "").toUpperCase(); + const kind = TARGET_LABELS[key] ?? log.resource_type ?? ""; + const reference = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : ""); + if (!kind) return reference || "-"; + // 프로젝트 UUID 는 길어 앞 8자만 — 어느 프로젝트인지 가리기에는 충분하다. + const shortened = reference.length > 12 ? `${reference.slice(0, 8)}…` : reference; + return shortened ? `${kind} ${shortened}` : kind; +} + +const TARGET_LABELS: Record = { + PROJECT: "프로젝트", + COMPANY: "회사", + USER: "사용자", + ASSET: "자산", +}; + export function auditLogTable(logs: AuditLog[]): HTMLElement { return table( [ L("B01_Dashboard_Table_Email"), - L("B01_Dashboard_Table_Action"), - L("B01_Dashboard_Table_Updated"), + // 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적). + L("B01_Dashboard_Table_Event"), + L("B01_Dashboard_Table_Target"), + L("B01_Dashboard_Table_Origin"), + L("B01_Dashboard_Table_When"), ], - logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]), + logs.map((log) => [ + text(log.email), + text(log.action), + text(auditTarget(log)), + // 접속 주소 — 기록이 없는 옛 줄은 빈칸으로 남는다 (2026-09-06부터 기록). + text(log.ip_address ?? "-"), + // 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다. + stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)), + ]), DASHBOARD_VISIBLE_ROWS, ); } diff --git a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts index 1aadb7eb..780028be 100644 --- a/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts +++ b/B01_Dashboard/B01_Dashboard_UI_AssetPicker.ts @@ -303,13 +303,16 @@ function openAssetPickerModal( mineBox.checked = owner !== null || kind === "SIGNATURE"; // 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다. mineBox.disabled = owner !== null; + // 무슨 뜻인지 읽히게 고침 (2026-09-06 사용자 지시) — 체크를 풀면 회사 공용이 된다. mine.append( mineBox, - document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`), + document.createTextNode( + ` 이 그림을 ${owner ? owner.name : `내 계정(${user.name})`}의 것으로 지정 (풀면 회사 공용)`, + ), ); const pad = kind === "SIGNATURE" ? createSignaturePad() : null; const add = createButton({ - label: "올리고 선택", + label: "파일 올리고 이 프로젝트에 쓰기", onClick: async () => { if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요."); const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null; diff --git a/B01_Dashboard/B01_Dashboard_UI_Common.ts b/B01_Dashboard/B01_Dashboard_UI_Common.ts index 65b6eccd..b7ba551d 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Common.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Common.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { + createInputField, hideLoadingOverlay, showConfirmDialog, showLoadingOverlay, @@ -39,6 +40,32 @@ export function formatDate(value?: string | null): string { return value ? value.slice(0, 10) : "-"; } +/** 시:분 — 표에서 날짜 아래 줄에 붙인다 (2026-09-06 사용자 지시). */ +export function formatTime(value?: string | null): string { + if (!value) return ""; + const time = value.includes("T") ? value.split("T")[1] : value.slice(11); + return time ? time.slice(0, 5) : ""; +} + +/** + * 위·아래 두 줄짜리 표 칸. 아랫줄은 작은 글씨라 **행 높이는 한 줄일 때와 같다**. + * 날짜/시각처럼 한 칸에 두 값을 넣을 때 쓴다. + */ +export function stackedCell(top: string, bottom: string): HTMLElement { + const cell = document.createElement("div"); + cell.className = "b01-dashboard__stacked"; + const first = document.createElement("span"); + first.textContent = top; + cell.append(first); + if (bottom) { + const second = document.createElement("span"); + second.className = "b01-dashboard__stacked-sub"; + second.textContent = bottom; + cell.append(second); + } + return cell; +} + /* ----------------------------------------------------------------------------- * 모달 바깥 클릭으로 닫기 (2026-09-04 사용자 지시) * @@ -142,3 +169,71 @@ export function attachModalDismiss( return { isDirty, tryClose }; } + +/** + * 사용자 정보 입력칸 한 벌 — 기본정보 폼과 사용자 관리 수정 모달이 같은 것을 쓴다 + * (2026-09-06 사용자 지시, 템플릿 일원화). 순서는 이름 > 직급 > 이메일 > 부서 > 전화이며 + * 이메일은 계정 식별자라 읽기 전용이다. + */ +export function buildUserFields( + source: { + name: string; + email?: string; + position?: string | null; + department?: string | null; + phone?: string | null; + }, + /** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */ + options: { self?: boolean } = {}, +): { + grid: HTMLElement; + validate: () => boolean; + values: () => { + name: string; + position: string | null; + department: string | null; + phone: string | null; + }; +} { + const name = createInputField({ + label: L("B01_Account_Field_Name"), + value: source.name, + required: true, + }); + const position = createInputField({ + label: L("B01_Dashboard_Table_Position"), + value: source.position ?? "", + }); + const email = createInputField({ + label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"), + type: "email", + value: source.email ?? "", + }); + email.input.disabled = true; + const department = createInputField({ + label: L("B01_Dashboard_Table_Department"), + value: source.department ?? "", + }); + const phone = createInputField({ + label: L("B01_Account_Field_Phone"), + value: source.phone ?? "", + }); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__form-grid"; + grid.append(name.root, position.root, email.root, department.root, phone.root); + return { + grid, + validate: () => { + name.setError(); + if (name.input.value.trim()) return true; + name.setError(L("Common_Msg_RequiredField")); + return false; + }, + values: () => ({ + name: name.input.value.trim(), + position: position.input.value.trim() || null, + department: department.input.value.trim() || null, + phone: phone.input.value.trim() || null, + }), + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Company.ts b/B01_Dashboard/B01_Dashboard_UI_Company.ts index 1da33720..b6a7af21 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Company.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Company.ts @@ -12,6 +12,7 @@ import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicke import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper"; import { openChangeRoleModal, + openEditCompanyModal, openCreateCompanyModal, openDeleteUserModal, openEditUserModal, @@ -44,6 +45,17 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement { text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`), text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`), ); + // 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다. + if (state.user.role !== "USER" && state.company) { + const company = state.company; + wrap.append( + createButton({ + label: "회사 정보 수정", + variant: "ghost", + onClick: () => openEditCompanyModal(company), + }), + ); + } // 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정). // 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다. if (state.user.role !== "USER") { @@ -173,12 +185,20 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT L("B01_Dashboard_Field_BusinessNumber"), L("B01_Dashboard_Table_Status"), "로고", + L("B01_Dashboard_Table_Action"), ], companies.map((company) => [ text(company.name), text(company.business_registration_number), text(company.business_status), logoCell(company), + user + ? createButton({ + label: L("Common_Btn_Edit"), + variant: "ghost", + onClick: () => openEditCompanyModal(company), + }) + : text(""), ]), DASHBOARD_VISIBLE_ROWS, ); diff --git a/B01_Dashboard/B01_Dashboard_UI_Helper.ts b/B01_Dashboard/B01_Dashboard_UI_Helper.ts index 261f491b..fa423208 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Helper.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Helper.ts @@ -1,15 +1,15 @@ import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch"; -export function canEditProject(user: DashboardUser, _project: ProjectItem): boolean { +export function canEditProject(user: DashboardUser, project: ProjectItem): boolean { if (user.role === "SYSTEM_ADMIN") return true; if (user.role === "ADMIN") return user.company_id !== null; - return false; // USER는 수정 불가 + // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). + return (project.member_user_ids ?? []).includes(user.id); } export function canDeleteProject(user: DashboardUser): boolean { - // SYSTEM_ADMIN만 가능 (ADMIN 프로젝트 삭제는 나중을 위해 주석 처리) - return user.role === "SYSTEM_ADMIN"; - // return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; + // 회사 관리자도 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). 범위는 백엔드가 다시 본다. + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); } export function canAddUser(user: DashboardUser): boolean { @@ -17,8 +17,9 @@ export function canAddUser(user: DashboardUser): boolean { } export function canChangeRole(user: DashboardUser, _targetUser: Member | DashboardUser): boolean { - // 역할 변경은 오직 SYSTEM_ADMIN만 가능 - return user.role === "SYSTEM_ADMIN"; + // 회사 관리자도 자기 회사 안에서 역할을 바꾼다 (2026-09-06 사용자 확정). + // 마지막 관리자 이탈·시스템 관리자 변경은 백엔드가 막는다. + return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null); } export function canDeleteUser(user: DashboardUser, _targetUser: Member | DashboardUser): boolean { diff --git a/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts new file mode 100644 index 00000000..d87137b6 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_MapPreview.ts @@ -0,0 +1,72 @@ +import { API_BASE_URL } from "@config/config_frontend"; +import { geocodeAddress } from "./B01_Dashboard_Api_Fetch"; + +/** + * 주소 지도 미리보기 (2026-09-06 사용자 지시). + * + * 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다. + * 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다. + */ +const ZOOM = 15; +const TILE = 256; +const GRID = 3; + +function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } { + const n = 2 ** ZOOM; + const rad = (lat * Math.PI) / 180; + const fx = ((lon + 180) / 360) * n; + const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n; + return { x: Math.floor(fx), y: Math.floor(fy), dx: fx - Math.floor(fx), dy: fy - Math.floor(fy) }; +} + +export function buildAddressMap(): { + root: HTMLElement; + show: (address: string) => Promise; +} { + const root = document.createElement("div"); + root.className = "b01-dashboard__map"; + const note = document.createElement("p"); + note.className = "b01-dashboard__modal-text"; + note.textContent = "주소를 넣고 「지도 확인」을 누르십시오."; + root.append(note); + + const show = async (address: string): Promise => { + root.innerHTML = ""; + if (!address.trim()) { + note.textContent = "주소를 먼저 입력하십시오."; + root.append(note); + return; + } + const point = await geocodeAddress(address).catch(() => null); + if (!point) { + note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오."; + root.append(note); + return; + } + const center = tileIndex(point.lat, point.lon); + const grid = document.createElement("div"); + grid.className = "b01-dashboard__map-grid"; + const half = Math.floor(GRID / 2); + for (let row = -half; row <= half; row += 1) { + for (let col = -half; col <= half; col += 1) { + const img = document.createElement("img"); + img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`; + img.width = TILE; + img.height = TILE; + img.alt = ""; + grid.append(img); + } + } + const marker = document.createElement("span"); + marker.className = "b01-dashboard__map-marker"; + // 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다. + marker.style.left = `${(half + center.dx) * TILE * 0.5}px`; + marker.style.top = `${(half + center.dy) * TILE * 0.5}px`; + const frame = document.createElement("div"); + frame.className = "b01-dashboard__map-frame"; + frame.append(grid, marker); + root.append(frame); + }; + + return { root, show }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Modals.ts b/B01_Dashboard/B01_Dashboard_UI_Modals.ts index 59845ba2..9af03c60 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Modals.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Modals.ts @@ -14,24 +14,31 @@ import { updateDashboardUser, removeCompanyMember, createCompany, + updateCompany, joinCompany, searchCompanies, addCompanyMember, + searchMemberCandidates, + inviteMember, + deleteDashboardUser, fetchCompanyMembers, fetchCompanyAssets, fetchUserCompany, updateCompanyAsset, createCompanyAsset, setCompanyLogo, + type CompanyInfo, type DashboardUser, type ProjectItem, type Member, } from "./B01_Dashboard_Api_Fetch"; import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; -import { attachModalDismiss, type ModalDismissHandle } from "./B01_Dashboard_UI_Common"; - -/** 담당자 select 의 「신규 등록…」 항목 — 값이 아니라 동작이다. */ -const NEW_MEMBER = "__new__"; +import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview"; +import { + attachModalDismiss, + buildUserFields, + type ModalDismissHandle, +} from "./B01_Dashboard_UI_Common"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -89,7 +96,8 @@ export async function openEditProjectModal( user: DashboardUser, project: ProjectItem, ): Promise { - const isUserOnly = user.role === "USER"; + // 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정). + const isUserOnly = user.role === "USER" && !(project.member_user_ids ?? []).includes(user.id); // 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정). // 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시). const [members, assets, company] = await Promise.all([ @@ -164,9 +172,10 @@ export async function openEditProjectModal( const personOptions = [ { value: "", text: "(미지정)" }, ...members.map((member) => ({ value: String(member.id), text: memberText(member) })), - { value: NEW_MEMBER, text: "+ 신규 등록…" }, ]; const persons: HTMLSelectElement[] = []; + // 담당자는 이미 등록된 팀원 중에서만 고른다 (2026-09-06 사용자 확정) — + // 이 자리에서 계정을 만드는 「신규 등록…」은 없앴다. const person = (label: string, current: number | null | undefined) => { const field = createSelectField({ label, @@ -174,25 +183,6 @@ export async function openEditProjectModal( value: String(current ?? ""), }); persons.push(field.select); - let last = field.select.value; - // 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다. - field.select.addEventListener("change", () => { - if (field.select.value !== NEW_MEMBER) { - last = field.select.value; - return; - } - field.select.value = last; - openAddMemberModal((member) => { - for (const select of persons) { - const option = document.createElement("option"); - option.value = String(member.id); - option.textContent = memberText(member); - select.insertBefore(option, select.options[select.options.length - 1]); - } - field.select.value = String(member.id); - last = field.select.value; - }); - }); return field; }; const pm = person("과업책임자 (도면 표제란)", project.pm_user_id); @@ -255,6 +245,27 @@ export async function openEditProjectModal( designer.root, logo.root, ); + // 참여자 — 도면 표제란 3역할과 별개로 설계에 손대는 사람들 (2026-09-06 사용자 확정). + const memberBox = document.createElement("div"); + memberBox.className = "b01-dashboard__members"; + const memberLabel = document.createElement("p"); + memberLabel.className = "b01-dashboard__modal-text"; + memberLabel.textContent = "참여자 (고른 사람은 이 프로젝트를 수정할 수 있음)"; + memberBox.append(memberLabel); + const memberChecks: HTMLInputElement[] = []; + for (const member of members) { + const row = document.createElement("label"); + row.className = "b01-dashboard__member-row"; + const check = document.createElement("input"); + check.type = "checkbox"; + check.value = String(member.id); + check.checked = (project.member_user_ids ?? []).includes(member.id); + check.disabled = isUserOnly; + memberChecks.push(check); + row.append(check, document.createTextNode(` ${memberText(member)}`)); + memberBox.append(row); + } + const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null); // 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다. @@ -262,7 +273,7 @@ export async function openEditProjectModal( openModal( L("B01_Dashboard_EditProject"), - [grid], + [grid, memberBox], async () => { await updateProject(project.id, { name: name.input.value.trim(), @@ -284,6 +295,7 @@ export async function openEditProjectModal( logo_asset_id: logo.value(), // 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다. signature_asset_id: null, + member_user_ids: memberChecks.filter((c) => c.checked).map((c) => Number(c.value)), }); showToast(L("B01_Dashboard_Saved"), "success"); }, @@ -306,22 +318,25 @@ export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem } export function openEditUserModal(user: DashboardUser, target: Member | DashboardUser): void { - const name = createInputField({ - label: L("B01_Dashboard_Table_Name"), - value: target.name, - required: true, + // 기본정보 폼과 같은 칸 한 벌을 쓴다 (2026-09-06 사용자 지시). + const fields = buildUserFields({ + name: target.name, + email: target.email, + position: target.position, + department: target.department, + phone: (target as DashboardUser).phone, }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: target.position ?? "", + const isAdmin = user.role === "SYSTEM_ADMIN" || user.role === "ADMIN"; + // 관리자는 계정을 비활성화할 수 있다 (2026-09-06 사용자 지시) — 비활성 계정은 다음 + // 요청에서 세션이 끊긴다. + const statusField = createSelectField({ + label: L("B01_Dashboard_Table_Status"), + options: [ + { value: "ACTIVE", text: "활성" }, + { value: "INACTIVE", text: "비활성" }, + ], + value: target.status === "INACTIVE" ? "INACTIVE" : "ACTIVE", }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: target.department ?? "", - }); - - const phoneVal = (target as DashboardUser).phone || ""; - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal }); // 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를 // 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다. @@ -330,51 +345,30 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar void fetchCompanyAssets(companyId).then((assets) => { const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id); signatureSlot.append( - createAssetField( - "서명 (도면 표제란)", - "SIGNATURE", - assets, - owned?.id ?? null, - companyId, - user, - { - owner: { id: target.id, name: target.name }, - onChange: async (assetId) => { - if (assetId === null) return; - const picked = assets.find((asset) => asset.id === assetId); - if (picked) - await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); - }, + createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, companyId, user, { + owner: { id: target.id, name: target.name }, + onChange: async (assetId) => { + if (assetId === null) return; + const picked = assets.find((asset) => asset.id === assetId); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id }); }, - ).root, + }).root, ); }); - if (user.role === "ADMIN") { - // ADMIN은 직책(position) / 부서(department)만 수정 정보 가능 - name.input.disabled = true; - phone.input.disabled = true; - } else if (user.role === "USER" && user.id !== target.id) { - name.input.disabled = true; - position.input.disabled = true; - department.input.disabled = true; - phone.input.disabled = true; - } - - openModal( - L("B01_Dashboard_EditUser"), - [name.root, position.root, department.root, phone.root, signatureSlot], - async () => { - await updateDashboardUser(target.id, { - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - status: target.status, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - }, - ); + const rows = isAdmin + ? [fields.grid, statusField.root, signatureSlot] + : [fields.grid, signatureSlot]; + openModal(L("B01_Dashboard_EditUser"), rows, async () => { + if (!fields.validate()) return; + await updateDashboardUser(target.id, { + ...fields.values(), + // 상태는 관리자만 바꾼다. 그 밖에는 지금 값을 그대로 되돌려 보낸다. + status: isAdmin ? statusField.select.value : target.status, + }); + showToast(L("B01_Dashboard_Saved"), "success"); + }); } export function openChangeRoleModal(target: Member | DashboardUser): void { @@ -394,45 +388,141 @@ export function openChangeRoleModal(target: Member | DashboardUser): void { } export function openDeleteUserModal(target: Member | DashboardUser): void { + // 두 가지가 다르다 — 회사에서 빼면 계정은 남고(무소속), 계정 삭제는 로그인 자체가 막힌다 + // (2026-09-06 사용자 확정). + const mode = createSelectField({ + label: "처리 방식", + options: [ + { value: "REMOVE", text: "회사에서 빼기 (계정은 남음)" }, + { value: "DELETE", text: "계정 삭제 (로그인 불가)" }, + ], + value: "REMOVE", + }); const warning = document.createElement("p"); warning.className = "b01-dashboard__modal-text"; warning.textContent = L("B01_Dashboard_Confirm_DeleteUser"); - openModal(L("B01_Dashboard_DeleteUser"), [warning], async () => { - await removeCompanyMember(target.id); + openModal(L("B01_Dashboard_DeleteUser"), [mode.root, warning], async () => { + if (mode.select.value === "DELETE") await deleteDashboardUser(target.id); + else await removeCompanyMember(target.id); showToast(L("B01_Dashboard_Saved"), "success"); }); } -export function openCreateCompanyModal(): void { - const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true }); +/** 회사 등록·수정이 함께 쓰는 입력칸 — 순서는 사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소. */ +function companyFields(company?: CompanyInfo): { + rows: HTMLElement[]; + values: () => { + name: string; + business_registration_number: string; + business_address: string | null; + business_owner: string | null; + }; + logoFile: () => File | undefined; + valid: () => boolean; +} { const number = createInputField({ label: L("B01_Dashboard_Field_BusinessNumber"), + value: company?.business_registration_number ?? "", required: true, }); - const address = createInputField({ label: L("B01_Dashboard_Field_Address") }); - const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") }); - // 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다. + const name = createInputField({ + label: L("B01_Dashboard_Table_Company"), + value: company?.name ?? "", + required: true, + }); + const owner = createInputField({ + label: L("B01_Dashboard_Field_Owner"), + value: company?.business_owner ?? "", + }); const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" }); logo.input.type = "file"; logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg"; + const address = createInputField({ + label: L("B01_Dashboard_Field_Address"), + value: company?.business_address ?? "", + }); + // 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시). + const map = buildAddressMap(); + const mapBtn = createButton({ + label: "지도 확인", + variant: "ghost", + onClick: async function onB01_Company_Map_Click() { + await map.show(address.input.value.trim()); + }, + }); + const addressRow = document.createElement("div"); + addressRow.append(address.root, mapBtn, map.root); + + return { + rows: [number.root, name.root, owner.root, logo.root, addressRow], + values: () => ({ + name: name.input.value.trim(), + business_registration_number: number.input.value.trim(), + business_address: address.input.value.trim() || null, + business_owner: owner.input.value.trim() || null, + }), + logoFile: () => logo.input.files?.[0], + valid: () => Boolean(name.input.value.trim() && number.input.value.trim()), + }; +} + +export function openCreateCompanyModal(): void { + const fields = companyFields(); + // 같은 회사를 두 번 만들지 않게, 등록 전에 이미 있는 회사를 찾아 보여 준다 + // (2026-09-06 사용자 지시). + const matches = document.createElement("div"); + matches.className = "b01-dashboard__actions"; + const findBtn = createButton({ + label: "이미 있는 회사인지 찾기", + variant: "ghost", + onClick: async function onB01_Company_Duplicate_Click() { + matches.innerHTML = ""; + const values = fields.values(); + const query = values.business_registration_number || values.name; + if (!query) return; + const found = await searchCompanies(query); + if (found.length === 0) { + const empty = document.createElement("p"); + empty.className = "b01-dashboard__modal-text"; + empty.textContent = "같은 회사가 없습니다. 그대로 등록하십시오."; + matches.append(empty); + return; + } + for (const company of found) { + matches.append( + createButton({ + label: `${company.name} — ${L("B01_Dashboard_JoinCompany")}`, + variant: "ghost", + onClick: async () => { + showLoadingOverlay(); + try { + await joinCompany(company.id); + showToast(L("B01_Dashboard_Saved"), "success"); + } catch { + showToast("요청 실패", "error"); + } finally { + hideLoadingOverlay(); + } + }, + }), + ); + } + }, + }); openModal( L("B01_Dashboard_Modal_CreateCompany"), - [name.root, number.root, address.root, owner.root, logo.root], + [...fields.rows, findBtn, matches], async () => { - if (!name.input.value.trim() || !number.input.value.trim()) return; - const created = await createCompany({ - name: name.input.value.trim(), - business_registration_number: number.input.value.trim(), - business_address: address.input.value.trim() || null, - business_owner: owner.input.value.trim() || null, - }); - const file = logo.input.files?.[0]; + if (!fields.valid()) return; + const values = fields.values(); + const created = await createCompany(values); + const file = fields.logoFile(); if (file && created?.company_id) { const form = new FormData(); form.append("kind", "LOGO"); - form.append("label", `${name.input.value.trim()} 로고`); + form.append("label", `${values.name} 로고`); form.append("file", file); form.append("company_id", String(created.company_id)); const assetId = await createCompanyAsset(form); @@ -443,6 +533,27 @@ export function openCreateCompanyModal(): void { ); } +/** 회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체. */ +export function openEditCompanyModal(company: CompanyInfo): void { + const fields = companyFields(company); + openModal("회사 정보 수정", fields.rows, async () => { + if (!fields.valid()) return; + const values = fields.values(); + await updateCompany(values, company.id); + const file = fields.logoFile(); + if (file) { + const form = new FormData(); + form.append("kind", "LOGO"); + form.append("label", `${values.name} 로고`); + form.append("file", file); + form.append("company_id", String(company.id)); + const assetId = await createCompanyAsset(form); + await setCompanyLogo(assetId, company.id); + } + showToast(L("B01_Dashboard_Saved"), "success"); + }); +} + export function openFindCompanyModal(): void { const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true }); const results = document.createElement("div"); @@ -482,32 +593,80 @@ export function openFindCompanyModal(): void { } /** - * 구성원 추가 — 이름을 넣으면 **계정이 없는 사람도 그 자리에서 만든다** - * (2026-09-02 사용자 확정). 이름을 비우면 이미 가입한 사람을 회사에 붙이는 옛 동작이다. + * 팀원 등록 — 이미 가입한 사람 중 **소속이 없는 사람**만 골라 붙인다 + * (2026-09-06 사용자 확정). 계정을 대신 만들지 않는다. 아직 가입하지 않은 사람에게는 + * 안내 메일만 보낸다. */ -// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`). -// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것. export function openAddMemberModal(onCreated?: (member: Member) => void): void { - const email = createInputField({ - label: L("B01_Dashboard_Field_MemberEmail"), - type: "email", - required: true, + const query = createInputField({ + label: "이름 또는 이메일로 찾기", + placeholder: "두 글자 이상", }); - const name = createInputField({ label: L("B01_Dashboard_Table_Name") }); - const position = createInputField({ label: L("B01_Dashboard_Table_Position") }); - const department = createInputField({ label: L("B01_Dashboard_Table_Department") }); - openModal( - L("B01_Dashboard_Modal_AddMember"), - [email.root, name.root, position.root, department.root], - async () => { - if (!email.input.value.trim()) return; - const created = await addCompanyMember(email.input.value.trim(), { - name: name.input.value.trim() || undefined, - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - }); - showToast(L("B01_Dashboard_Saved"), "success"); - if (created?.member) onCreated?.(created.member); + const results = document.createElement("div"); + results.className = "b01-dashboard__actions"; + let picked: Member | null = null; + + const search = createButton({ + label: L("Common_Btn_Search"), + variant: "ghost", + onClick: async function onB01_Member_Search_Click() { + results.innerHTML = ""; + const text = query.input.value.trim(); + if (text.length < 2) { + query.setError("두 글자 이상 입력하십시오."); + return; + } + query.setError(); + const users = await searchMemberCandidates(text); + if (users.length === 0) { + const empty = document.createElement("p"); + empty.className = "b01-dashboard__modal-text"; + empty.textContent = "소속 없는 가입자가 없습니다. 아직 가입 전이면 안내 메일을 보내십시오."; + results.append(empty); + return; + } + for (const candidate of users) { + results.append( + createButton({ + label: `${candidate.name} (${candidate.email})`, + variant: picked?.id === candidate.id ? "filled" : "ghost", + onClick: () => { + picked = candidate; + query.input.value = `${candidate.name} (${candidate.email})`; + results.innerHTML = ""; + }, + }), + ); + } }, - ); + }); + + const invite = createButton({ + label: "가입 안내 메일 보내기", + variant: "ghost", + onClick: async function onB01_Member_Invite_Click() { + const text = query.input.value.trim(); + if (!text.includes("@")) { + query.setError("메일을 보낼 이메일 주소를 입력하십시오."); + return; + } + query.setError(); + await inviteMember(text); + showToast("안내 메일을 보냈습니다.", "success"); + }, + }); + + const bar = document.createElement("div"); + bar.className = "b01-dashboard__actions"; + bar.append(search, invite); + + openModal(L("B01_Dashboard_Modal_AddMember"), [query.root, bar, results], async () => { + if (!picked) { + query.setError("등록할 사람을 먼저 고르십시오."); + throw new Error("등록할 사람을 먼저 고르십시오."); + } + const created = await addCompanyMember(picked.id); + showToast(L("B01_Dashboard_Saved"), "success"); + if (created?.member) onCreated?.(created.member); + }); } diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 66009c67..6b445015 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -7,6 +7,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -130,9 +131,8 @@ async function loadRoleData(state: DashboardState): Promise { } function buildPage(state: DashboardState): HTMLElement { + // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); - page.className = "b01-dashboard"; - page.append(buildHeader(state.user)); const grid = document.createElement("div"); grid.className = "b01-dashboard__grid"; @@ -151,7 +151,6 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [ createButton({ label: "+", onClick: () => openCreateCompanyModal() }), ]), - section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true), ); } else if (state.user.role === "ADMIN") { grid.append( @@ -181,23 +180,23 @@ function buildPage(state: DashboardState): HTMLElement { section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)), section(L("B01_Account_Section_Security"), buildSecurityForm()), ); + // 시스템 로그는 맨 아래 (2026-09-06 사용자 지시). + if (state.user.role === "SYSTEM_ADMIN") { + grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); + } page.append(grid); - return page; -} - -function buildHeader(user: DashboardUser): HTMLElement { - const header = document.createElement("header"); - header.className = "b01-dashboard__header"; - const text = document.createElement("div"); - const title = document.createElement("h1"); - title.className = "b01-dashboard__title"; - title.textContent = L("B01_Dashboard_Title"); - const subtitle = document.createElement("p"); - subtitle.className = "b01-dashboard__subtitle"; - subtitle.textContent = L("B01_Dashboard_Subtitle"); - text.append(title, subtitle); - const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral"); + const layout = createGeneralLayout({ + pageClass: "b01-dashboard", + title: L("B01_Dashboard_Title"), + subtitle: L("B01_Dashboard_Subtitle"), + content: page, + }); + // 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다). + const tag = createTag( + roleLabel(state.user.role), + state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral", + ); tag.classList.add("b01-dashboard__role"); - header.append(text, tag); - return header; + layout.root.querySelector(".ui-general-layout__header")?.append(tag); + return layout.root; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index ef110687..f7c39ec3 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -1,48 +1,49 @@ import { isBlank } from "@util/common_util_validate"; import { createButton, createInputField } from "@ui/ui_template_elements"; -import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch"; -import { L, runRequest } from "./B01_Dashboard_UI_Common"; +import { + changePassword, + fetchCompanyAssets, + updateCompanyAsset, + updateUserProfile, + type DashboardUser, +} from "./B01_Dashboard_Api_Fetch"; +import { createAssetField } from "./B01_Dashboard_UI_AssetPicker"; +import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common"; const PASSWORD_MIN_LENGTH = 8; export function buildProfileForm(user: DashboardUser): HTMLElement { - const name = createInputField({ - label: L("B01_Account_Field_Name"), - value: user.name, - required: true, - }); - const position = createInputField({ - label: L("B01_Dashboard_Table_Position"), - value: user.position ?? "", - }); - const department = createInputField({ - label: L("B01_Dashboard_Table_Department"), - value: user.department ?? "", - }); - const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" }); - const grid = document.createElement("div"); - grid.className = "b01-dashboard__form-grid"; - grid.append(name.root, position.root, department.root, phone.root); + // 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시). + const fields = buildUserFields(user, { self: true }); + const grid = fields.grid; + // 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때 + // 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시). + const signatureSlot = document.createElement("div"); + if (user.company_id) { + void fetchCompanyAssets(user.company_id).then((assets) => { + const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id); + signatureSlot.append( + createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, { + owner: { id: user.id, name: user.name }, + onChange: async (assetId) => { + if (assetId === null) return; + const picked = assets.find((asset) => asset.id === assetId); + if (picked) + await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id }); + }, + }).root, + ); + }); + } const save = createButton({ label: L("B01_Dashboard_SaveProfile"), onClick: async function onB01_Profile_Save_Click() { - name.setError(); - if (isBlank(name.input.value)) { - name.setError(L("Common_Msg_RequiredField")); - return; - } - await runRequest(() => - updateUserProfile({ - name: name.input.value.trim(), - position: position.input.value.trim() || null, - department: department.input.value.trim() || null, - phone: phone.input.value.trim() || null, - }), - ); + if (!fields.validate()) return; + await runRequest(() => updateUserProfile(fields.values())); }, }); const wrap = document.createElement("div"); - wrap.append(grid, save); + wrap.append(grid, signatureSlot, save); return wrap; } diff --git a/B01_Dashboard/B01_Dashboard_UI_Projects.ts b/B01_Dashboard/B01_Dashboard_UI_Projects.ts index 7352226e..a07fe807 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Projects.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Projects.ts @@ -13,7 +13,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser [ L("B01_Dashboard_Table_Project"), L("B01_Dashboard_Table_Region"), - L("B01_Dashboard_Table_Progress"), + // 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다. L("B01_Dashboard_Table_Workflow"), L("B01_Dashboard_Table_Updated"), L("B01_Dashboard_Table_Action"), @@ -44,7 +44,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser return [ text(project.name), text(project.region), - text(`${project.progress_percent}%`), workflow(project), text(formatDate(project.updated_at)), actCell, diff --git a/B01_Dashboard/B01_Dashboard_UI_Style.css b/B01_Dashboard/B01_Dashboard_UI_Style.css index de0a5d3f..9eb0b919 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style.css @@ -1,28 +1,19 @@ -.b01-dashboard { - max-width: var(--page-max-width); - margin: 0 auto; - padding: var(--spacing-40) var(--spacing-24) var(--spacing-64); +/* 폭·제목·여백은 공용 템플릿(ui_template_general_layout)이 잡는다 (2026-09-06 사용자 지시). + 여기서는 역할 배지를 제목 줄 오른쪽에 세우는 것만 한다. */ +.b01-dashboard .ui-general-layout__header { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; } -.b01-dashboard__header { - display: flex; - justify-content: space-between; - gap: var(--spacing-24); - align-items: flex-end; - margin-bottom: var(--spacing-32); -} - -.b01-dashboard__title { - font-size: var(--text-heading); -} - -.b01-dashboard__subtitle { - margin: var(--spacing-8) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-body); +.b01-dashboard .ui-general-layout__title, +.b01-dashboard .ui-general-layout__subtitle { + grid-column: 1; } .b01-dashboard__role { + grid-column: 2; + grid-row: 1 / span 2; flex-shrink: 0; } @@ -46,6 +37,32 @@ gap: var(--spacing-8); } +/* 표 안의 관리 버튼은 **한 줄로** 세운다 (2026-09-06 사용자 지시) — 두 줄로 접히면 + 행 높이가 두 배가 된다. 버튼을 작게 만들고 줄바꿈을 막되, 폭이 모자라면 표가 + 가로로 스크롤한다(`.b01-dashboard__table-wrap` 이 이미 그렇게 돼 있다). */ +.b01-dashboard__table .b01-dashboard__actions { + flex-wrap: nowrap; + gap: var(--spacing-4); +} + +.b01-dashboard__table .b01-dashboard__actions .ui-btn { + padding: var(--spacing-4) var(--spacing-8); + font-size: var(--text-caption); + white-space: nowrap; +} + +/* 한 칸에 두 줄(날짜/시각) — 아랫줄이 작아 행 높이는 한 줄일 때와 같다. */ +.b01-dashboard__stacked { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.b01-dashboard__stacked-sub { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + .b01-dashboard__table-wrap { overflow-x: auto; border: 1px solid var(--color-border); @@ -273,3 +290,46 @@ grid-template-columns: 1fr; } } + +/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */ +.b01-dashboard__map-frame { + position: relative; + width: 100%; + max-width: 384px; + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 8px); +} + +.b01-dashboard__map-grid { + display: grid; + grid-template-columns: repeat(3, 256px); + transform: scale(0.5); + transform-origin: top left; +} + +.b01-dashboard__map-marker { + position: absolute; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border: 2px solid var(--color-surface, #fff); + border-radius: 50%; + background: var(--color-danger, #d33); +} + +/* 프로젝트 참여자 고르기 (2026-09-06). */ +.b01-dashboard__members { + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); + max-height: 160px; + overflow-y: auto; +} + +.b01-dashboard__member-row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} diff --git a/B02_ProjRegister/B02_ProjRegister_Repository.py b/B02_ProjRegister/B02_ProjRegister_Repository.py index 8a184ec7..4714daa9 100644 --- a/B02_ProjRegister/B02_ProjRegister_Repository.py +++ b/B02_ProjRegister/B02_ProjRegister_Repository.py @@ -9,6 +9,7 @@ from uuid import uuid4 import aiomysql +from common_util.common_util_audit import record_audit from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2 from common_util.common_util_workflow import load_project_workflow @@ -43,6 +44,7 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None: async def create_project( *, + request: Any | None = None, user_id: int, company_id: int, name: str, @@ -102,13 +104,21 @@ async def create_project( fields.get("logo_asset_id"), ), ) + # 만든 사람은 곧 참여자다 (2026-09-06 사용자 확정) — 참여자는 수정 권한을 가진다. + await cursor.execute( + "INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)", + (project_id, user_id), + ) # 워크플로우 단계별 상태 초기화 시드 await initialize_project_stages(cursor, project_id) - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""", - (user_id,), + await record_audit( + cursor, + actor_id=user_id, + action="PROJECT_CREATE", + resource_type="project", + resource_ref=project_id, + request=request, ) _initialize_project_storage(project_root, project_id) await connection.commit() diff --git a/B02_ProjRegister/B02_ProjRegister_Router.py b/B02_ProjRegister/B02_ProjRegister_Router.py index f8127d0c..54934c5f 100644 --- a/B02_ProjRegister/B02_ProjRegister_Router.py +++ b/B02_ProjRegister/B02_ProjRegister_Router.py @@ -2,7 +2,7 @@ from typing import Any -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs from common_util.common_util_auth import require_company @@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/b02", tags=["B02_ProjRegister"]) @router.post("/project", response_model=CreateProjectResponse) async def post_project( + request: Request, payload: CreateProjectRequest, session: dict[str, Any] = Depends(require_company), ) -> CreateProjectResponse: @@ -50,6 +51,7 @@ async def post_project( try: result = await create_project( + request=request, user_id=int(session["user_id"]), company_id=int(company_id), name=payload.name.strip(), diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Name.ts b/B02_ProjRegister/B02_ProjRegister_UI_Name.ts new file mode 100644 index 00000000..cc4db59b --- /dev/null +++ b/B02_ProjRegister/B02_ProjRegister_UI_Name.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * B02_ProjRegister_UI_Name.ts + * 프로젝트명 조합기 — 사업연도·사업지역·임도종류를 이름 칸에 넣어 두고, 사용자가 그 글을 + * 고쳐도 위 항목과의 연결이 끊기지 않게 지킨다 (2026-09-06 사용자 지시). + * + * 글자마다 「누가 넣은 글자인가」를 같이 들고 다닌다. 문자열을 찾아 바꾸던 종전 방식은 + * 사용자가 자동 글자 사이에 한 글자만 끼워 넣어도 찾기가 실패해 연동이 통째로 끊겼다 + * (실측: 사업지역 「울진군 금강송면」의 「금강송면」 앞에 글자 삽입). + * + * 규칙 + * - 사용자가 이름을 고치기 전에는 위 세 항목으로 통째로 다시 쓴다. + * - 고친 뒤에는 **그 항목이 차지한 구간만** 새 값으로 갈아 끼운다. 사용자가 그 구간 + * 밖(사이·끝)에 넣은 글은 그대로 남는다. + * - 사용자가 지워 버린 항목은 되살리지 않는다 — 지운 것도 사용자의 뜻이다. + * ========================================================================== */ + +/** 이름을 이루는 자동 조각의 종류. 순서가 곧 이름에 놓이는 차례다. */ +export type NameSlot = "year" | "region" | "type"; + +const SLOTS: readonly NameSlot[] = ["year", "region", "type"]; + +export type NameParts = Record; + +export interface NameComposer { + /** 지금 이름. */ + text(): string; + /** 사용자가 이름 칸에 친 글을 반영한다. */ + edit(next: string): void; + /** 위 항목이 바뀌었을 때 — 갈아 끼운 이름을 돌려준다. */ + update(parts: NameParts): string; +} + +export function createNameComposer(): NameComposer { + let text = ""; + /** 글자마다 그 글자를 넣은 항목(사용자가 친 글자는 null). `text`와 길이가 같다. */ + let owners: (NameSlot | null)[] = []; + let touched = false; + + const fill = (slot: NameSlot | null, count: number): (NameSlot | null)[] => + Array.from({ length: count }, () => slot); + + const rebuild = (parts: NameParts): void => { + text = ""; + owners = []; + for (const slot of SLOTS) { + const value = parts[slot].trim(); + if (!value) continue; + if (text.length > 0) { + text += " "; + owners.push(null); + } + text += value; + owners.push(...fill(slot, value.length)); + } + }; + + return { + text: () => text, + + edit(next: string): void { + touched = true; + // 앞뒤로 그대로인 부분을 뺀 **바뀐 구간**만 계산한다 — 어디를 고쳤는지 알아야 + // 나머지 글자의 주인이 밀리지 않는다. + let head = 0; + while (head < text.length && head < next.length && text[head] === next[head]) head += 1; + let tail = 0; + while ( + tail < text.length - head && + tail < next.length - head && + text[text.length - 1 - tail] === next[next.length - 1 - tail] + ) { + tail += 1; + } + const removed = text.length - tail - head; + const inserted = next.slice(head, next.length - tail); + owners.splice(head, removed, ...fill(null, inserted.length)); + text = next; + }, + + update(parts: NameParts): string { + if (!touched) { + rebuild(parts); + return text; + } + for (const slot of SLOTS) { + const first = owners.indexOf(slot); + if (first < 0) continue; // 사용자가 지운 항목은 되살리지 않는다. + const last = owners.lastIndexOf(slot); + const value = parts[slot].trim(); + text = text.slice(0, first) + value + text.slice(last + 1); + owners.splice(first, last - first + 1, ...fill(slot, value.length)); + } + return text; + }, + }; +} diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 5609d184..68faf689 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -19,15 +19,10 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { isBlank } from "@util/common_util_validate"; -import { - fetchCompanyAssets, - fetchCompanyMembers, - fetchDashboardMe, - fetchUserCompany, -} from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker"; -import { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals"; +import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; import { navigateTo } from "../A00_Common/router"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import "./B02_ProjRegister_UI_Style.css"; @@ -41,23 +36,13 @@ function L(key: keyof typeof ui_locales): string { * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderB02ProjRegister(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "b02-proj"; - - const header = document.createElement("div"); - header.className = "b02-proj__header"; - const title = document.createElement("h1"); - title.className = "b02-proj__title"; - title.textContent = L("B02_Proj_Title"); - const subtitle = document.createElement("p"); - subtitle.className = "b02-proj__subtitle"; - subtitle.textContent = L("B02_Proj_Subtitle"); - header.append(title, subtitle); - // 입력 필드 + // 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이다 + // (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도 + // 위 세 항목을 다시 바꾸면 그 조각만 갈아 끼워 손댄 글이 살아남는다. const nameField = createInputField({ - label: L("B02_Proj_Field_Name"), - placeholder: L("B02_Proj_Field_Name_Placeholder"), + label: "프로젝트명", + placeholder: "예: 2026 가리왕산 간선임도 1구간", required: true, }); const regionField = createInputField({ @@ -84,12 +69,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { min: 2000, max: currentYear + 5, }); - const lengthField = createInputField({ - label: L("B02_Proj_Field_Length"), - placeholder: L("B02_Proj_Field_Length_Placeholder"), - type: "number", - min: 0, - }); // 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시). // 둘 다 비우면 전 구간을 쓴다. const routeStartField = createInputField({ @@ -113,33 +92,22 @@ export function renderB02ProjRegister(root: HTMLElement): void { // 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다 // (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다. const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" }); - const projectNumberField = createInputField({ - label: "연도·기번 (표지)", - placeholder: "예: 2026년 간선임도(기번3-울진.대흥)", - }); - const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" }); + // 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과 + // 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다. const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" }); - const NEW_MEMBER = "__new__"; const person = (label: string) => createSelectField({ label, options: [{ value: "", text: "(미지정)" }] }); const pmField = person("과업책임자 (도면 표제란)"); const fieldLeadField = person("분야별책임자 (도면 표제란)"); const designerField = person("설계자 (도면 표제란)"); const personSelects = [pmField.select, fieldLeadField.select, designerField.select]; - // 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다. - const logoSlot = document.createElement("div"); - let logoValue: () => number | null = () => null; + // 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어 + // 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`). void (async () => { - const [me, company] = await Promise.all([ - fetchDashboardMe(), - fetchUserCompany().catch(() => null), - ]); - const [members, assets] = await Promise.all([ - fetchCompanyMembers().catch(() => []), - fetchCompanyAssets().catch(() => []), - ]); + const me = await fetchDashboardMe(); + const members = await fetchCompanyMembers().catch(() => []); const memberText = (member: (typeof members)[number]) => member.position ? `${member.name} (${member.position})` : member.name; const addOption = (select: HTMLSelectElement, value: string, text: string) => { @@ -150,68 +118,82 @@ export function renderB02ProjRegister(root: HTMLElement): void { }; for (const select of personSelects) { for (const member of members) addOption(select, String(member.id), memberText(member)); - // 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다. - if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…"); - } - for (const select of personSelects) { - let last = select.value; - select.addEventListener("change", () => { - if (select.value !== NEW_MEMBER) { - last = select.value; - return; - } - select.value = last; - openAddMemberModal((member) => { - for (const other of personSelects) { - const option = document.createElement("option"); - option.value = String(member.id); - option.textContent = memberText(member); - other.insertBefore(option, other.options[other.options.length - 1]); - } - select.value = String(member.id); - last = select.value; - }); - }); - } - if (company) { - // 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다. - const logo = createAssetField( - "회사 로고 (도면 표제란)", - "LOGO", - assets, - company.logo_asset_id ?? null, - company.id, - me, - ); - logoValue = logo.value; - logoSlot.append(logo.root); + // 담당자 기본값은 만든 사람 (2026-09-06 사용자 확정). 계정을 그 자리에서 만드는 + // 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다. + if (members.some((member) => member.id === me.id)) select.value = String(me.id); } })(); + const roadTypeText = (): string => + roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? ""; + /** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */ + const nameComposer = createNameComposer(); + const nameParts = (): NameParts => ({ + year: yearField.input.value.trim(), + region: regionField.input.value.trim(), + type: roadTypeText(), + }); + nameField.input.addEventListener("input", () => { + nameComposer.edit(nameField.input.value); + }); + const syncName = (): void => { + nameField.input.value = nameComposer.update(nameParts()); + }; + const composedName = (): string => nameField.input.value.trim(); + const routeLength = (): number | null => { + const start = isBlank(routeStartField.input.value) + ? null + : Number.parseFloat(routeStartField.input.value); + const end = isBlank(routeEndField.input.value) + ? null + : Number.parseFloat(routeEndField.input.value); + if (end === null || !Number.isFinite(end)) return null; + const from = start !== null && Number.isFinite(start) ? start : 0; + return end > from ? end - from : null; + }; + const refresh = (): void => { + syncName(); + const length = routeLength(); + routeEndField.root.querySelector(".ui-field__label")!.textContent = + length === null + ? "노선 종료 누가거리 (m)" + : `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`; + }; + // 이름 칸 자신은 여기서 제외한다 — 스스로 고치는 도중에 값을 되돌리면 안 된다. + for (const field of [regionField, yearField, routeStartField, routeEndField]) { + field.input.addEventListener("input", refresh); + } + roadTypeField.select.addEventListener("change", refresh); + refresh(); + const submitBtn = createButton({ label: L("B02_Proj_Submit"), variant: "filled", onClick: onB02_Proj_Submit_Click, }); - submitBtn.classList.add("b02-proj__submit"); + const cancelBtn = createButton({ + label: L("Common_Btn_Cancel"), + variant: "ghost", + onClick: function onB02_Proj_Cancel_Click() { + navigateTo(ROUTES.B01_ACCOUNT); + }, + }); const idOrNull = (select: HTMLSelectElement): number | null => - select.value && select.value !== NEW_MEMBER ? Number(select.value) : null; + select.value ? Number(select.value) : null; async function onB02_Proj_Submit_Click(): Promise { nameField.setError(); regionField.setError(); yearField.setError(); - lengthField.setError(); routeStartField.setError(); routeEndField.setError(); // 1차 유효성: 필수값 검사 let hasError = false; const projectYear = Number.parseInt(yearField.input.value, 10); - const estimatedLength = isBlank(lengthField.input.value) - ? null - : Number.parseFloat(lengthField.input.value); + // 예상 연장은 따로 받지 않는다 — 노선 구간 길이가 곧 연장이다 (2026-09-06 사용자 확정). + const estimatedLength = routeLength(); if (isBlank(nameField.input.value)) { nameField.setError(L("B02_Proj_Error_Required")); hasError = true; @@ -224,10 +206,6 @@ export function renderB02ProjRegister(root: HTMLElement): void { yearField.setError(L("Common_Validation_NumberRange")); hasError = true; } - if (estimatedLength !== null && (!Number.isFinite(estimatedLength) || estimatedLength < 0)) { - lengthField.setError(L("Common_Validation_NumberRange")); - hasError = true; - } const routeStart = isBlank(routeStartField.input.value) ? null : Number.parseFloat(routeStartField.input.value); @@ -256,7 +234,7 @@ export function renderB02ProjRegister(root: HTMLElement): void { headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - name: nameField.input.value.trim(), + name: composedName(), region: regionField.input.value.trim(), road_type: roadTypeField.select.value, project_year: projectYear, @@ -265,13 +243,14 @@ export function renderB02ProjRegister(root: HTMLElement): void { route_end_m: routeEnd, memo: memoField.input.value.trim() || null, client_org: clientOrgField.input.value.trim() || null, - project_number: projectNumberField.input.value.trim() || null, - work_amount: workAmountField.input.value.trim() || null, + // 표지 값은 프로젝트명·연장에서 끌어 쓴다 (2026-09-06 사용자 확정). + project_number: composedName(), + work_amount: + estimatedLength === null ? null : `L=${(estimatedLength / 1000).toFixed(2)}km`, design_date: designDateField.input.value || null, pm_user_id: idOrNull(pmField.select), field_lead_user_id: idOrNull(fieldLeadField.select), designer_user_id: idOrNull(designerField.select), - logo_asset_id: logoValue(), }), }); @@ -301,25 +280,32 @@ export function renderB02ProjRegister(root: HTMLElement): void { const grid = document.createElement("div"); grid.className = "b02-proj__grid"; grid.append( - nameField.root, + yearField.root, regionField.root, roadTypeField.root, - yearField.root, - lengthField.root, + nameField.root, routeStartField.root, routeEndField.root, - memoField.root, clientOrgField.root, - projectNumberField.root, - workAmountField.root, designDateField.root, pmField.root, fieldLeadField.root, designerField.root, - logoSlot, + // 비고는 맨 끝 (2026-09-06 사용자 지시). + memoField.root, ); - const card = createCard({ body: [grid, submitBtn], raised: true }); - page.append(header, card); - root.append(page); + // 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은 + // 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다. + const actions = document.createElement("div"); + actions.className = "ui-general-block__actions b02-proj__actions"; + actions.append(cancelBtn, submitBtn); + const card = createCard({ body: [grid, actions], raised: true }); + const layout = createGeneralLayout({ + pageClass: "b02-proj", + title: L("B02_Proj_Title"), + subtitle: L("B02_Proj_Subtitle"), + content: card, + }); + root.append(layout.root); } diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index 233f8f2d..4186b3f8 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -3,30 +3,13 @@ * 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용) * ========================================================================== */ -.b02-proj { +/* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout)을 쓴다 — 여기서는 + 이 화면에만 필요한 것(폭·2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬)만 둔다. */ + +/* 입력 칸 두 줄짜리 폼이라 종전처럼 720px 로 좁혀 가운데 둔다 (2026-09-06 사용자 지시 — + 템플릿을 쓰되 폭은 바꾸지 않는다). */ +.b02-proj .ui-general-layout__inner { max-width: 720px; - margin: 0 auto; - padding: var(--spacing-40) var(--spacing-24); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} - -.b02-proj__header { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.b02-proj__title { - font-family: var(--font-display); - font-size: var(--text-heading); - color: var(--color-plum-velvet); -} - -.b02-proj__subtitle { - font-size: var(--text-body-sm); - color: var(--color-text-muted); } /* 2열 필드 그리드 (좁은 화면에서는 1열) */ @@ -53,13 +36,14 @@ padding-right: var(--spacing-32); } -.b02-proj__submit { - align-self: flex-start; - margin-top: var(--spacing-8); -} - @media (max-width: 640px) { .b02-proj__grid { grid-template-columns: 1fr; } } + +/* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */ +.b02-proj__actions { + justify-content: flex-end; + margin-top: var(--spacing-16); +} diff --git a/B03_FileInput/B03_FileInput_Router.py b/B03_FileInput/B03_FileInput_Router.py index 6747cb5b..2af94831 100644 --- a/B03_FileInput/B03_FileInput_Router.py +++ b/B03_FileInput/B03_FileInput_Router.py @@ -94,6 +94,9 @@ from B03_FileInput.B03_FileInput_Router_Helpers import ( from B03_FileInput.B03_FileInput_Router_Helpers import ( _write_stage_metadata as _write_stage_metadata, ) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + upload_file_types as upload_file_types, +) from B03_FileInput.B03_FileInput_Schema import ( FileUploadDescriptor, FileUploadResponse, @@ -156,12 +159,13 @@ async def upload_project_files( "message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.", }, ) - if not las_free and las_count != 1: + # 지형 파일은 도엽별로 여러 장이 올 수 있다 — 합쳐서 전처리한다(2026-09-06 사용자 확정). + if not las_free and las_count < 1: return JSONResponse( status_code=400, content={ "status": "error", - "message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.", + "message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.", }, ) # 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다 @@ -175,7 +179,7 @@ async def upload_project_files( "message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.", }, ) - request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames} + request_file_types = upload_file_types(filenames) missing_required = _missing_required_file_types(request_file_types, las_free) if missing_required: return JSONResponse( @@ -350,8 +354,10 @@ async def get_project_upload_overview( ) for row in sessions ], - required_complete=_REQUIRED_FILE_TYPES <= file_types - and (point_cloud_id is not None or stage0_complete), + # stage 0 을 마쳤으면 서버 필수검사를 이미 통과한 것이다 — LAS 없이 설계는 + # 지형 한 벌(prj·tfw)이 아예 없으므로 여기서 다시 세면 B04 이동이 막힌다. + required_complete=stage0_complete + or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None), analysis_complete=analysis_complete, ) except Exception: diff --git a/B03_FileInput/B03_FileInput_Router_Helpers.py b/B03_FileInput/B03_FileInput_Router_Helpers.py index 7b895adb..2251fc9c 100644 --- a/B03_FileInput/B03_FileInput_Router_Helpers.py +++ b/B03_FileInput/B03_FileInput_Router_Helpers.py @@ -62,14 +62,34 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool: return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES +def upload_file_types(filenames: list[str]) -> set[str]: + """업로드 요청의 파일명을 **완료 검사와 같은 기준**으로 유형화한다. + + 확장자만 세면 노선 세트의 `.prj`가 지형 PRJ로 오인돼, 요청 검사는 통과하고 저장 뒤 + 완료 검사에서 누락으로 갈린다(2026-09-06 실측 — LAS 없이 설계가 이 어긋남으로 막혔다). + 노선 도형(`.shp`)과 basename이 같은 PRJ는 화면의 카드 배정과 같은 규칙으로 `route_prj`. + """ + route_stem = next( + (Path(name).stem for name in filenames if Path(name).suffix.lower() == ".shp"), None + ) + types: set[str] = set() + for name in filenames: + suffix = Path(name).suffix.lower().lstrip(".") + if suffix == "prj" and route_stem is not None and Path(name).stem == route_stem: + suffix = "route_prj" + types.add(suffix) + return types + + def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]: - missing = sorted(_REQUIRED_FILE_TYPES - file_types) + # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 지형 한 벌(포인트클라우드·지형 PRJ· + # TFW)을 통째로 받지 않는다 — 화면도 그 카드들을 필수에서 뺀다. + missing = [] if las_free else sorted(_REQUIRED_FILE_TYPES - file_types) if not file_types.intersection(_ROUTE_FILE_TYPES): missing.append("csv/shp") # shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다. if "shp" in file_types: missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types)) - # LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다. if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES): missing.append("las/laz") return missing @@ -143,6 +163,15 @@ async def _complete_file_input_if_ready( # 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다. stored_path = await get_project_storage_relative_path(connection, project_id) project_root = Path(resolve_stored_project_path(stored_path)) + # 지형 파일 여러 장은 합쳐서 전처리한다 — 다른 사업지 파일이 섞이면 합친 범위가 + # 통째로 어긋나므로 여기서 막는다(2026-09-06 사용자 지시). 머리글만 읽어 즉시 끝난다. + from B04_PreProcess.B04_PreProcess_Engine_Structurize import merge_gap_error + from B04_PreProcess.B04_PreProcess_Repository import list_project_point_cloud_paths + + terrain_paths = await list_project_point_cloud_paths(connection, project_id, project_root) + gap_message = merge_gap_error(terrain_paths) + if gap_message: + raise ValueError(gap_message) clear_designing(project_root) discard_initial_snapshot(project_root) await purge_project_outputs(connection, str(project_id), project_root) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index b826fda4..6e8dfec7 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -16,6 +16,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어 """ import logging +import time from pathlib import Path from typing import Any from uuid import UUID @@ -25,6 +26,18 @@ from fastapi.responses import JSONResponse logger = logging.getLogger(__name__) +def _log_steps(title: str, marks: list[tuple[str, float]]) -> None: + """단계별 경과시간을 한 줄로 남긴다 — 어디서 시간을 쓰는지 보려는 계측용.""" + total = (marks[-1][1] - marks[0][1]) or 1e-9 + parts = [ + f"{name} {marks[i][1] - marks[i - 1][1]:.1f}s" + f"({(marks[i][1] - marks[i - 1][1]) / total * 100:.0f}%)" + for i, (name, _) in enumerate(marks) + if i + ] + logger.info("[계측] %s 총 %.1fs = %s", title, total, " | ".join(parts)) + + def _planned_route_points_in_project_crs( project_root: Path, surface: dict[str, Any] | None = None, @@ -70,6 +83,7 @@ async def _prepare_drainage_pipes_and_reprofile( from config.config_db import get_db_pool from config.config_system import SECTION_CROSS_HALF_WIDTH_M + marks = [("시작", time.perf_counter())] try: # 1) 배수유역 분석(30초 내외). 저장분이 있으면 그대로 쓴다. # 저장분은 노선이 바뀌었는지 보지 않으므로, 노선을 다시 푼 뒤에는 refresh로 부른다. @@ -82,6 +96,8 @@ async def _prepare_drainage_pipes_and_reprofile( ) return False + marks.append(("배수유역 분석", time.perf_counter())) + # 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다. pipes = await put_pipe_points(project_id, None) if isinstance(pipes, JSONResponse): @@ -93,6 +109,8 @@ async def _prepare_drainage_pipes_and_reprofile( return False pipe_count = int(pipes.get("saved_count") or 0) + marks.append(("관 지점 확정", time.perf_counter())) + # 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다. pool = get_db_pool() async with pool.acquire() as connection: @@ -110,6 +128,8 @@ async def _prepare_drainage_pipes_and_reprofile( regenerated.status_code, ) return False + marks.append(("종횡단 측점 재생성", time.perf_counter())) + _log_steps("배수·관·측점", marks) logger.info( "자동 설계 체인 배수유역·배관 정착 계획선 완료: project_id=%s route_id=%s 관=%d개", project_id, @@ -148,6 +168,10 @@ async def run_auto_design_chain( mark_designing, save_initial_snapshot, ) + from common_util.common_util_route_geometry import ( + expected_route_csv_path, + write_route_csv, + ) from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from config.config_db import get_db_pool @@ -195,12 +219,36 @@ async def run_auto_design_chain( mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.") return None + # 2.5) 예상노선(원본) 정본을 남긴다 — 초기값 스냅샷과 달리 재확정 체인이 지우지 + # 않는 자리라, 노선 초기화가 언제나 이 값으로 돌아갈 수 있다(2026-09-06 PLAN 0-7). + expected_path = expected_route_csv_path(project_root) + if not expected_path.is_file(): + write_route_csv(expected_path, points) + + # 2.6) 계획노선 **초기 폴리라인**도 여기서 세운다(2026-09-06 PLAN 0-10). + # 예상노선은 점 묶음이라 그대로는 설계선이 못 된다 — 지식DB 의 R 기준으로 + # 곡선을 끼운 폴리라인이 불변 초기 데이터다. 화면이 처음 열릴 때 만들면 + # 「화면을 안 열면 값이 없다」가 되므로 초기값은 서버가 낸다(0-4 원칙). + await _ensure_initial_polyline(project_id, project_root, points) + + # 2.7) 노선의 기준을 **초기 폴리라인**으로 갈아 끼운다(2026-09-06 사용자 지시). + # 다시 읽으면 `load_design_route` 가 방금 만든 초기 폴리라인을 집는다 + # (읽는 순서: 수정본 → 초기 폴리라인 → 예상노선). 종횡단 측점·유토곡선· + # 3D 코리도가 전부 이 노선에서 나오므로, 여기서 갈아 끼우지 않으면 화면에는 + # 곡선 없는 원본 점군(용화: 3.3m 간격 331점)이 그대로 선다. + polyline_points = _planned_route_points_in_project_crs(project_root, defaults, route_range) + if polyline_points and len(polyline_points) >= 2: + points = polyline_points + # 3) B05 경로 계산 request = RouteSolveRequest( filter_key=str(defaults["source_filter"]), method=str(defaults["method"]), smooth=bool(defaults["smooth"]), surface_model_id=surface_model_id, + # 계획노선을 **그대로** 쓴다 — 격자 재탐색은 이 선을 자기 제약으로 다시 풀어 + # 곡선을 뭉개거나 통째로 막는다(재확정 체인과 같은 이유, 0-10). + algorithm="as_planned", bp=RoutePoint(**points[0]), ep=RoutePoint(**points[-1]), cp=[ @@ -280,6 +328,17 @@ async def run_auto_design_chain( except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다 logger.exception("코리도 사전 생성 실패: project_id=%s", project_id) + # 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에 + # 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는 + # 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴 + # 기준선에도 보정이 담긴다. + try: + from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side + + await recompute_server_side(project_id, route_id) + except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다 + logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id) + # 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장). # 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨 # [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정). @@ -306,11 +365,35 @@ async def run_auto_design_chain( clear_designing(project_root) +async def _ensure_initial_polyline( + project_id: UUID, project_root: Path, points: list[dict[str, float]] +) -> None: + """계획노선 초기 폴리라인을 세운다 — 이미 있으면 그대로 둔다. 실패는 비치명적.""" + import asyncio + + from B05_Profile.B05_Profile_Router_Replan import _ensure_planned_initial, _min_plan_radius_m + + try: + radius_m = await _min_plan_radius_m(project_id) + summary = await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) + if summary: + logger.info( + "초기 계획노선 폴리라인: project_id=%s 노드 %d · 곡선 %d · 위반 %d (R %.1fm)", + project_id, + summary["nodes"], + summary["curves"], + summary["violations"], + radius_m, + ) + except Exception: # noqa: BLE001 — 없으면 화면이 처음 열릴 때 만든다 + logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id) + + async def run_redesign_chain( project_id: UUID, surface_model_id: int, selection: dict[str, Any], -) -> None: +) -> str | None: """B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장. 관리자가 B04에서 다른 지표면 모델로 재확정하면(2026-08-04 사용자 확정) 그 값을 @@ -331,8 +414,8 @@ async def run_redesign_chain( from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_longitudinal_section, - update_cross_section_design, ) + from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs from B06_Section.B06_Section_Router_Confirm import confirm_sections from B06_Section.B06_Section_Schema import SectionConfirmRequest from common_util.common_util_initial_snapshot import ( @@ -346,6 +429,7 @@ async def run_redesign_chain( pool = get_db_pool() project_root: Path | None = None + marks = [("시작", time.perf_counter())] try: async with pool.acquire() as connection: latest = await get_latest_route(connection, project_id) @@ -384,7 +468,9 @@ async def run_redesign_chain( logger.warning( "재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id ) - return + return "노선 제어점(BP·EP)이 없어 재계산할 수 없습니다." + + marks.append(("준비·사용자입력 회수", time.perf_counter())) # 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분. request = RouteSolveRequest( @@ -392,7 +478,11 @@ async def run_redesign_chain( method=str(selection.get("method") or params.get("method") or "dtm"), smooth=bool(selection.get("smooth", params.get("smooth", False))), surface_model_id=surface_model_id, - algorithm=str(params.get("algorithm") or "dijkstra"), + # 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사· + # 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측: + # 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음). + # PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다. + algorithm="as_planned", bp=points["bp"], ep=points["ep"], cp=points.get("cp") or [], @@ -422,13 +512,21 @@ async def run_redesign_chain( ) solve_result: Any = await solve_route(project_id, request) if isinstance(solve_result, JSONResponse): + # 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06 + # 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음). + try: + reason = bytes(solve_result.body).decode("utf-8", "replace")[:300] + except Exception: # noqa: BLE001 — 로그용이라 실패해도 흐름을 막지 않는다 + reason = "(본문 없음)" logger.error( - "재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s", + "재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s reason=%s", project_id, solve_result.status_code, + reason, ) - return + return f"노선 재계산 실패({solve_result.status_code}): {reason}" new_route_id = int(solve_result.route_id) + marks.append(("노선 재계산(solve)", time.perf_counter())) logger.info( "재확정 체인 B05 재계산 완료: project_id=%s %s→%s", project_id, @@ -436,33 +534,11 @@ async def run_redesign_chain( new_route_id, ) - # 3) 옛 측점별 사용자 설계를 chainage 매칭으로 새 경로에 이월(비치명적). + # 3) 옛 측점별 사용자 설계 이월은 **측점을 다시 만든 뒤**(아래 5) 한다. + # 여기서 하면 `_prepare_drainage_pipes_and_reprofile(refresh=True)` 가 측점을 + # 새로 만들며 덮어써 값이 사라진다(2026-09-06 실측: 다단 구간값 `extra_spans` 가 + # 노선 변경 뒤 0건이 됐음). 옛 설계는 `old_designs` 로 이미 손에 있다. carried = 0 - try: - async with pool.acquire() as connection: - await connection.begin() - try: - for record in old_designs: - design = record.get("design") - if not isinstance(design, dict): - continue - await update_cross_section_design( - connection, - route_id=new_route_id, - chainage_m=float(record["chainage_m"]), - design=design, - project_id=project_id, - ) - carried += 1 - await connection.commit() - except Exception: - await connection.rollback() - raise - except Exception: - logger.exception( - "재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id - ) - logger.info("재확정 체인 설계 이월: project_id=%s %d건", project_id, carried) # 4) B05 확정 → B06 확정(옛 표준단면 설정 이월, 미지정 측점 기본값 채움). # 재확정 후에도 stage 2·3은 IN_PROGRESS로 남겨 사용자 재검토를 받는다. @@ -473,11 +549,44 @@ async def run_redesign_chain( project_id, confirm_result.status_code, ) - return + return f"노선 확정 실패({confirm_result.status_code})" + marks.append(("B05 확정", time.perf_counter())) # 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로 # 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다. await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True) + marks.append(("배수·관·측점 재생성", time.perf_counter())) + + # 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 설계를 **누가거리로** 얹는다. + # 묶어 쓰므로 행 수와 무관하게 왕복 두 번이다(`merge_cross_section_designs`). + try: + entries = [ + (float(record["chainage_m"]), record["design"]) + for record in old_designs + if isinstance(record.get("design"), dict) + ] + async with pool.acquire() as connection: + await connection.begin() + try: + carried = await merge_cross_section_designs( + connection, + route_id=new_route_id, + entries=entries, + replace=True, + project_id=project_id, + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + except Exception: + logger.exception( + "재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id + ) + logger.info("재확정 체인 설계 이월: project_id=%s %d건", project_id, carried) + + marks.append(("설계 이월", time.perf_counter())) + old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {} section_request = ( SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"]) @@ -493,7 +602,9 @@ async def run_redesign_chain( project_id, sections_result.status_code, ) - return + return f"종횡단 확정 실패({sections_result.status_code})" + marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter())) + _log_steps("재확정 체인", marks) logger.info( "재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)", project_id, @@ -501,8 +612,10 @@ async def run_redesign_chain( new_route_id, carried, ) - except Exception: + return None + except Exception as exc: logger.exception("재확정 체인 실패: project_id=%s", project_id) + return f"재계산 중 오류: {exc}" finally: if project_root is not None: clear_designing(project_root) diff --git a/B03_FileInput/B03_FileInput_Service_WF1.py b/B03_FileInput/B03_FileInput_Service_WF1.py index 5ffb7cb3..053b97ac 100644 --- a/B03_FileInput/B03_FileInput_Service_WF1.py +++ b/B03_FileInput/B03_FileInput_Service_WF1.py @@ -79,11 +79,18 @@ async def trigger_wf1_analysis_and_email( await connection.commit() stored_path = await get_project_storage_relative_path(connection, project_id) project_info = await _get_project_notification_info(connection, project_id) - from B04_PreProcess.B04_PreProcess_Repository import get_input_file + from B04_PreProcess.B04_PreProcess_Repository import ( + get_input_file, + list_project_point_cloud_paths, + ) input_file = await get_input_file(connection, project_id, input_file_id) + project_root = Path(resolve_stored_project_path(stored_path)) + # 지형 파일이 여러 장이면 합쳐서 한 벌로 전처리한다(2026-09-06 사용자 확정). + terrain_paths = await list_project_point_cloud_paths( + connection, project_id, project_root + ) - project_root = Path(resolve_stored_project_path(stored_path)) source_path = project_root / Path(str(input_file["raw_file_path"])) # LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다. las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"} @@ -118,7 +125,7 @@ async def trigger_wf1_analysis_and_email( analysis_result = await asyncio.to_thread( run_surface_analysis, project_root, - source_path, + terrain_paths or [source_path], source_filters=None, methods=methods, force=False, diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index 6a431bef..d4d4d6e2 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -24,15 +24,11 @@ import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow"; import { isSlotRequired, slotForOverviewFile, + terrainCoverage, validateFileForSlot, validateSlots, } from "./B03_FileInput_UI_Page_Rules"; -import { - readCrsLabel, - readExtent, - renderSlotPreview, - type PreviewExtent, -} from "./B03_FileInput_UI_Preview"; +import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview"; import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload"; import { createFileCardTemplate, @@ -41,9 +37,11 @@ import { initializeSlots, makeSessionKey, planSlotAssignments, + pushExtraFile, ROUTE_SLOTS, SHAPEFILE_DEPENDENT_SLOTS, slotConfigs, + slotFileLabel, TERRAIN_SLOTS, type FileSlot, type FileSlotState, @@ -209,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; // 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다. - if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? ""; + if (fileName) fileName.textContent = slotFileLabel(state); if (fileSize) { fileSize.textContent = state.file ? formatBytes(state.file.size) @@ -240,7 +238,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const preview = card.querySelector(".b03-file__preview"); if (preview) { - const terrain = terrainCoverage(); + const terrain = terrainCoverage(slots); renderSlotPreview(preview, { metadata: state.serverUploaded?.metadata, // 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시). @@ -258,26 +256,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise { * 들어오는지 대조하는 기준. 초기 계산 실패의 주된 원인이 범위 불일치라 * 카드에서 바로 보이게 한다(2026-09-04). */ - function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } { - let extent: PreviewExtent | null = null; - let crs: string | null = null; - for (const slot of TERRAIN_SLOTS) { - const metadata = slots.get(slot)?.serverUploaded?.metadata; - const next = readExtent(metadata); - if (!next) continue; - crs ??= readCrsLabel(metadata); - extent = extent - ? { - xMin: Math.min(extent.xMin, next.xMin), - xMax: Math.max(extent.xMax, next.xMax), - yMin: Math.min(extent.yMin, next.yMin), - yMax: Math.max(extent.yMax, next.yMax), - } - : next; - } - return { extent, crs }; - } - function showErrorMessage(slot: FileSlot, error: string): void { const state = slots.get(slot); if (!state) return; @@ -297,8 +275,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise { showErrorMessage(state.slot, `${validation} ${file.name}`); return; } - if (!targetSlot && state.file && state.file.name !== file.name) { - showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); + // 지형 자료는 도엽별로 여러 장이 온다 — 카드에 더 담고 전처리가 합쳐 쓴다. + if (state.file && state.file.name !== file.name) { + if (!pushExtraFile(state, file)) { + showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`); + return; + } + renderSlot(state.slot); return; } // 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로 @@ -382,6 +365,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { localStorage.removeItem(makeSessionKey(activeProjectId, state.file)); } state.file = undefined; + state.extraFiles = undefined; state.uploadSessionId = undefined; state.uploadStatus = "pending"; state.progressBytes = 0; diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts index eeda5ac1..bc8c3104 100644 --- a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -201,14 +201,24 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { ctx.pageError.textContent = ""; ctx.setUploading(true); try { - for (let index = 0; index < targetStates.length; index += 1) { - const state = targetStates[index]; + // 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다. + const jobs = targetStates.flatMap((state) => + [state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })), + ); + for (let index = 0; index < jobs.length; index += 1) { + const { state, file } = jobs[index]; + if (file !== state.file) { + // 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다. + state.uploadSessionId = undefined; + state.progressBytes = 0; + } await uploadOneFile( ctx.projectId(), state, - index === targetStates.length - 1, + index === jobs.length - 1, () => ctx.renderSlot(state.slot), ctx.lasFreeDesign(), + file, ); } clearDerivedCaches(ctx.projectId()); diff --git a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts index 54e0ab3a..a60aa3f7 100644 --- a/B03_FileInput/B03_FileInput_UI_Page_Rules.ts +++ b/B03_FileInput/B03_FileInput_UI_Page_Rules.ts @@ -10,6 +10,7 @@ import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch"; +import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview"; import { getExtension, SHAPEFILE_DEPENDENT_SLOTS, @@ -105,3 +106,31 @@ export function slotForOverviewFile( (candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension), )?.slot; } + +/** + * 서버에 올라온 지형 자료가 덮는 범위와 좌표계 — 카드 미리보기가 쓴다. + * 여러 카드(포인트클라우드·좌표계·래스터)의 범위를 합친다. 화면 조립부가 700줄을 + * 넘어 옮겨 온 순수 함수다(2026-09-06). + */ +export function terrainCoverage(slots: SlotMap): { + extent: PreviewExtent | null; + crs: string | null; +} { + let extent: PreviewExtent | null = null; + let crs: string | null = null; + for (const slot of TERRAIN_SLOTS) { + const metadata = slots.get(slot)?.serverUploaded?.metadata; + const next = readExtent(metadata); + if (!next) continue; + crs ??= readCrsLabel(metadata); + extent = extent + ? { + xMin: Math.min(extent.xMin, next.xMin), + xMax: Math.max(extent.xMax, next.xMax), + yMin: Math.min(extent.yMin, next.yMin), + yMax: Math.max(extent.yMax, next.yMax), + } + : next; + } + return { extent, crs }; +} diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 609e9fad..9caaa008 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -28,6 +28,12 @@ export interface SlotConfig { export interface FileSlotState extends SlotConfig { file?: File; + /** + * 같은 카드에 더 담은 파일 — 지형 자료(포인트클라우드)만 여러 장을 받는다. + * 드론 라이다는 사업지가 넓으면 도엽별로 나뉘어 오고, 전처리가 합쳐서 쓴다 + * (2026-09-06 사용자 확정). 업로드는 이 목록을 한 장씩 차례로 올린다. + */ + extraFiles?: File[]; uploadSessionId?: string; uploadStatus: UploadStatus; progressBytes: number; @@ -123,6 +129,25 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [ }, ]; +/** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */ +export function slotFileLabel(state: FileSlotState): string { + const name = state.file?.name ?? state.serverUploaded?.name ?? ""; + const extras = state.extraFiles?.length ?? 0; + return extras > 0 ? `${name} 외 ${extras}장` : name; +} + +/** + * 같은 카드에 파일을 더 담는다 — 담았으면 true, 이 카드가 한 장짜리면 false. + * 지형 자료(포인트클라우드)만 여러 장을 받는다. 같은 이름은 다시 담지 않는다. + */ +export function pushExtraFile(state: FileSlotState, file: File): boolean { + if (state.slot !== "las_laz") return false; + const extras = state.extraFiles ?? []; + if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file]; + state.error = undefined; + return true; +} + export function getExtension(fileName: string): string { const index = fileName.lastIndexOf("."); return index >= 0 ? fileName.slice(index).toLowerCase() : ""; diff --git a/B03_FileInput/B03_FileInput_UI_Upload.ts b/B03_FileInput/B03_FileInput_UI_Upload.ts index 3f69e449..b3bceb42 100644 --- a/B03_FileInput/B03_FileInput_UI_Upload.ts +++ b/B03_FileInput/B03_FileInput_UI_Upload.ts @@ -83,8 +83,10 @@ export async function uploadOneFile( completeUpload: boolean, onProgress: () => void, lasFree = false, + // 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06). + target?: File, ): Promise { - const file = state.file; + const file = target ?? state.file; if (!file) return []; state.error = undefined; state.uploadStatus = "uploading"; diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index f1e48d4b..b94ef1c3 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -7,7 +7,7 @@ import json import logging import time -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path from typing import Any @@ -37,14 +37,22 @@ GROUND_POINT_SAMPLE_LIMIT = 500_000 GROUND_POINT_CACHE_VERSION = 2 -def _source_identity(las_path: Path) -> dict[str, Any]: - """입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).""" - stat = las_path.stat() - return { - "filename": las_path.name, - "size_bytes": int(stat.st_size), - "mtime": float(stat.st_mtime), - } +def _source_identity(las_paths: list[Path]) -> dict[str, Any]: + """입력 지형 파일들의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2). + + 여러 장을 병합하므로 **한 장이라도 바뀌거나 늘고 줄면** 다시 계산해야 한다 + (2026-09-06 다중 입력). + """ + files = [ + { + "filename": path.name, + "size_bytes": int(path.stat().st_size), + "mtime": float(path.stat().st_mtime), + } + for path in sorted(las_paths, key=lambda item: item.name) + ] + # 한 장일 때는 옛 형식과 같은 모양을 유지한다 — 이미 만든 캐시를 헛되이 버리지 않는다. + return files[0] if len(files) == 1 else {"files": files} def _relative_to_project(project_root: Path, path: Path) -> str: @@ -108,7 +116,7 @@ def cache_ground_points( def run_surface_analysis( project_root: Path, - las_path: Path, + las_path: Path | Sequence[Path], *, source_filters: list[str] | None, methods: list[str], @@ -117,6 +125,9 @@ def run_surface_analysis( ) -> dict[str, Any]: """구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다. + `las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을 + 만든다(2026-09-06 사용자 확정). + `source_filters`가 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로). 반환 dict: @@ -132,6 +143,9 @@ def run_surface_analysis( on_progress(percent, stage, message) total_started = time.monotonic() + las_paths = [las_path] if isinstance(las_path, Path) else [Path(item) for item in las_path] + if not las_paths: + raise ValueError("지형 파일이 없습니다.") stage_root = project_root / "B04_PreProcess" processed_dir = stage_root / "processed" models_dir = stage_root / "models" @@ -140,7 +154,7 @@ def run_surface_analysis( # 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2) identity_path = processed_dir / "source_identity.json" - current_identity = _source_identity(las_path) + current_identity = _source_identity(las_paths) stored_identity: dict[str, Any] | None = None if identity_path.is_file(): try: @@ -154,10 +168,12 @@ def run_surface_analysis( if rebuild or not structured_path.is_file(): _report(10, "structurize", "LAS 구조화 중") step_started = time.monotonic() - structured_path = structurize_las(las_path, processed_dir) + structured_path = structurize_las(las_paths, processed_dir) atomic_write_json(identity_path, current_identity) logger.info( - "B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started + "B04 LAS 구조화 완료: %s (%.1fs)", + ", ".join(path.name for path in las_paths), + time.monotonic() - step_started, ) else: _report(10, "structurize", "구조화 캐시 재사용") @@ -254,7 +270,7 @@ def run_surface_analysis( "z": [float(bounds[2, 0]), float(bounds[2, 1])], } download_geodata( - project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report + project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report ) # 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다 diff --git a/B04_PreProcess/B04_PreProcess_Engine_Structurize.py b/B04_PreProcess/B04_PreProcess_Engine_Structurize.py index f9e38b4b..42552faa 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Structurize.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Structurize.py @@ -1,82 +1,308 @@ -"""B04 LAS/LAZ 고속 구조화 엔진.""" +"""B04 LAS/LAZ 고속 구조화 엔진 — 여러 장을 한 벌로 병합한다 (2026-09-06 사용자 확정). +드론 라이다는 사업지가 넓으면 도엽별로 여러 장이 온다. 여기서 **합친 범위**로 한 벌을 +만들고, 뒤 단계(지면필터·모델·등고선·배수)는 받는 형식이 그대로라 손대지 않는다. + +점이 임계를 넘으면 **칸(기본 0.5m)마다 최저점 하나만** 남긴다(씨닝). 설계가 쓰는 격자가 +1m(지면필터 2m·CSF 천 1.5m)라 0.5m 는 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB 두 +장이 메모리 29GB → 1.4GB 로 내려간다. 임계 아래면 원본 점을 그대로 쓴다 — 작은 자료의 +결과는 바뀌지 않는다. +""" + +import logging import os import tempfile -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path +from typing import Any import laspy import numpy as np from common_util.common_util_json import replace_with_retry -from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE +from config.config_system import ( + SURFACE_DEFAULT_RGB_VALUE, + SURFACE_LAS_CHUNK_SIZE, + SURFACE_MERGE_MAX_GAP_M, + SURFACE_THIN_CELL_SIZE_M, + SURFACE_THIN_MAX_CELLS, + SURFACE_THIN_TRIGGER_POINTS, +) + +logger = logging.getLogger(__name__) + +ProgressCallback = Callable[[int], None] +PathLike = str | Path +# 청크에서 점과 함께 옮기는 속성들 — 파일에 없으면 기본값이 남는다. +_ATTRIBUTES = ("intensity", "rgb", "return_number", "number_of_returns", "classification") + + +def _as_list(las_path: PathLike | Sequence[PathLike]) -> list[Path]: + if isinstance(las_path, (str, Path)): + return [Path(las_path)] + return [Path(item) for item in las_path] + + +def point_cloud_extent(path: PathLike) -> tuple[int, tuple[float, float, float, float]]: + """머리글만 읽어 점 수와 XY 범위를 돌려준다 — 파일 크기와 무관하게 즉시 끝난다.""" + with laspy.open(Path(path)) as las_file: + header = las_file.header + return int(header.point_count), ( + float(header.mins[0]), + float(header.mins[1]), + float(header.maxs[0]), + float(header.maxs[1]), + ) + + +def merge_gap_error( + paths: Sequence[PathLike], gap_m: float = SURFACE_MERGE_MAX_GAP_M +) -> str | None: + """서로 멀리 떨어진 지형 파일이 섞였는지 — 문제면 안내 문구, 없으면 None. + + 다른 사업지 파일이나 좌표계가 다른 파일이 섞이면 합친 범위가 통째로 어긋나 격자가 + 터진다. 도엽으로 나뉜 자료는 경계가 맞닿으므로 여유를 두고 **어느 파일과도 만나지 + 않는 파일**만 걸러 낸다. + """ + sources = _as_list(paths) + if len(sources) < 2: + return None + boxes = [(path, point_cloud_extent(path)[1]) for path in sources] + for index, (path, box) in enumerate(boxes): + near = any( + box[0] - gap_m <= other[2] + and other[0] - gap_m <= box[2] + and box[1] - gap_m <= other[3] + and other[1] - gap_m <= box[3] + for other_index, (_, other) in enumerate(boxes) + if other_index != index + ) + if not near: + return ( + f"지형 파일 「{path.name}」의 좌표가 다른 파일과 " + f"{int(gap_m):,}m 넘게 떨어져 있습니다." + " 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오." + ) + return None + + +class _Merged: + """합친 점을 담는 그릇 — 원본 유지형과 씨닝형이 같은 모양으로 낸다.""" + + def __init__(self, capacity: int) -> None: + self.xyz = np.empty((capacity, 3), dtype=np.float64) + self.intensity = np.zeros(capacity, dtype=np.uint16) + self.rgb = np.full((capacity, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) + self.return_number = np.ones(capacity, dtype=np.uint8) + self.number_of_returns = np.ones(capacity, dtype=np.uint8) + self.classification = np.zeros(capacity, dtype=np.uint8) + self.size = 0 + + def arrays(self) -> dict[str, np.ndarray]: + end = self.size + return { + "xyz": self.xyz[:end], + "intensity": self.intensity[:end], + "rgb": self.rgb[:end], + "return_number": self.return_number[:end], + "number_of_returns": self.number_of_returns[:end], + "classification": self.classification[:end], + } + + def append(self, columns: dict[str, np.ndarray]) -> None: + count = len(columns["x"]) + section = slice(self.size, self.size + count) + self.xyz[section, 0] = columns["x"] + self.xyz[section, 1] = columns["y"] + self.xyz[section, 2] = columns["z"] + for key in _ATTRIBUTES: + if key in columns: + getattr(self, key)[section] = columns[key] + self.size += count + + +def _chunk_columns(chunk: Any, dimensions: set[str]) -> dict[str, np.ndarray]: + """청크에서 쓸 값만 꺼낸다. 파일에 없는 항목은 키를 빼서 기본값이 남게 한다.""" + columns: dict[str, np.ndarray] = { + "x": np.asarray(chunk.x, dtype=np.float64), + "y": np.asarray(chunk.y, dtype=np.float64), + "z": np.asarray(chunk.z, dtype=np.float64), + } + if "intensity" in dimensions: + columns["intensity"] = np.asarray(chunk.intensity, dtype=np.uint16) + if {"red", "green", "blue"}.issubset(dimensions): + colors = np.stack( + [ + np.asarray(chunk.red, dtype=np.float64), + np.asarray(chunk.green, dtype=np.float64), + np.asarray(chunk.blue, dtype=np.float64), + ], + axis=1, + ) + if colors.size and float(colors.max()) > 255.0: + colors /= 256.0 + columns["rgb"] = colors.clip(0, 255).astype(np.uint8) + if {"return_number", "number_of_returns"}.issubset(dimensions): + columns["return_number"] = np.asarray(chunk.return_number, dtype=np.uint8) + columns["number_of_returns"] = np.asarray(chunk.number_of_returns, dtype=np.uint8) + if "classification" in dimensions: + columns["classification"] = np.asarray(chunk.classification, dtype=np.uint8) + return columns + + +class _ThinGrid: + """씨닝형 — **지면 분류점은 전부** 남기고, 나머지는 칸마다 최저점 하나만 남긴다. + + 설계 지표면을 만드는 것은 지면점이다(업체가 분류해 준 ASPRS class 2). 그 점을 하나도 + 버리지 않으므로 **지면 결과는 씨닝 전과 완전히 같다**(2026-09-06 용화 실측: 1m 지면 + 격자 141,969칸 전부 표고 차이 0). 지면점은 원본의 2~3%뿐이라 남겨도 가볍다. + + 나머지(수목·구조물·잡음)는 칸마다 최저점만 남긴다 — 분류가 없는 자료에서 CSF·PMF가 + 지면을 찾을 밑그림으로 충분하다(CSF 천 간격 1.5m > 칸 0.5m). + + 한 청크 안에서 같은 칸이 여러 번 나오면 뒤에 쓴 값이 이겨 최저점이 아니게 된다. + 그래서 청크를 (칸, 표고)로 정렬해 **칸마다 첫 점**만 골라 낸 뒤 격자와 견준다. + """ + + #: ASPRS 지면 분류 코드. + GROUND_CLASS = 2 + + def __init__(self, bounds: np.ndarray, cell_size: float) -> None: + self.cell_size = cell_size + self.x_min = float(bounds[0, 0]) + self.y_min = float(bounds[1, 0]) + self.width = int(np.ceil((float(bounds[0, 1]) - self.x_min) / cell_size)) + 1 + self.height = int(np.ceil((float(bounds[1, 1]) - self.y_min) / cell_size)) + 1 + cells = self.width * self.height + if cells > SURFACE_THIN_MAX_CELLS: + raise ValueError( + "지형 자료의 합친 범위가 너무 넓습니다." + " 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오." + ) + self.best_z = np.full(cells, np.inf, dtype=np.float64) + self.x = np.zeros(cells, dtype=np.float64) + self.y = np.zeros(cells, dtype=np.float64) + self.intensity = np.zeros(cells, dtype=np.uint16) + self.rgb = np.full((cells, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) + self.return_number = np.ones(cells, dtype=np.uint8) + self.number_of_returns = np.ones(cells, dtype=np.uint8) + self.classification = np.zeros(cells, dtype=np.uint8) + # 그대로 남길 지면점 — 청크마다 모아 두었다가 마지막에 잇는다. + self.ground: list[dict[str, np.ndarray]] = [] + + def add(self, columns: dict[str, np.ndarray]) -> None: + classification = columns.get("classification") + if classification is not None: + is_ground = classification == self.GROUND_CLASS + if is_ground.any(): + self.ground.append({key: value[is_ground] for key, value in columns.items()}) + keep = ~is_ground + columns = {key: value[keep] for key, value in columns.items()} + x, y, z = columns["x"], columns["y"], columns["z"] + if not len(x): + return + grid_x = np.clip(((x - self.x_min) / self.cell_size).astype(np.int64), 0, self.width - 1) + grid_y = np.clip(((y - self.y_min) / self.cell_size).astype(np.int64), 0, self.height - 1) + cell = grid_y * self.width + grid_x + order = np.lexsort((z, cell)) + sorted_cell = cell[order] + first = np.ones(len(order), dtype=bool) + first[1:] = sorted_cell[1:] != sorted_cell[:-1] + candidate = order[first] + candidate_cell = cell[candidate] + better = z[candidate] < self.best_z[candidate_cell] + chosen = candidate[better] + target = candidate_cell[better] + self.best_z[target] = z[chosen] + self.x[target] = x[chosen] + self.y[target] = y[chosen] + for key in _ATTRIBUTES: + if key in columns: + getattr(self, key)[target] = columns[key][chosen] + + def collect(self) -> _Merged: + occupied = np.flatnonzero(np.isfinite(self.best_z)) + ground_count = sum(len(item["x"]) for item in self.ground) + merged = _Merged(len(occupied) + ground_count) + merged.xyz[: len(occupied), 0] = self.x[occupied] + merged.xyz[: len(occupied), 1] = self.y[occupied] + merged.xyz[: len(occupied), 2] = self.best_z[occupied] + for key in _ATTRIBUTES: + getattr(merged, key)[: len(occupied)] = getattr(self, key)[occupied] + merged.size = len(occupied) + for item in self.ground: + merged.append(item) + return merged + + +def _headers(sources: list[Path]) -> tuple[int, np.ndarray, bool]: + """전체 점 수·합친 범위(3x2)·색 보유 여부를 머리글만 읽어 구한다.""" + total = 0 + has_rgb = False + mins = np.full(3, np.inf, dtype=np.float64) + maxs = np.full(3, -np.inf, dtype=np.float64) + for source in sources: + with laspy.open(source) as las_file: + header = las_file.header + total += int(header.point_count) + mins = np.minimum(mins, np.asarray(header.mins, dtype=np.float64)) + maxs = np.maximum(maxs, np.asarray(header.maxs, dtype=np.float64)) + dimensions = set(header.point_format.dimension_names) + has_rgb = has_rgb or {"red", "green", "blue"}.issubset(dimensions) + if not np.isfinite(mins).all(): + mins = np.zeros(3, dtype=np.float64) + maxs = np.zeros(3, dtype=np.float64) + return total, np.column_stack((mins, maxs)), has_rgb + + +def _merge_sources( + sources: list[Path], + total_points: int, + bounds: np.ndarray, + thin: bool, + progress_callback: ProgressCallback | None, +) -> _Merged: + grid = _ThinGrid(bounds, SURFACE_THIN_CELL_SIZE_M) if thin else None + merged = _Merged(total_points) if grid is None else None + done = 0 + for source in sources: + with laspy.open(source) as las_file: + dimensions = set(las_file.header.point_format.dimension_names) + for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE): + columns = _chunk_columns(chunk, dimensions) + if grid is not None: + grid.add(columns) + else: + merged.append(columns) + done += len(columns["x"]) + if progress_callback: + progress_callback(int(done / total_points * 100) if total_points else 100) + return grid.collect() if grid is not None else merged def structurize_las( - las_path: str | Path, + las_path: PathLike | Sequence[PathLike], output_dir: str | Path, - progress_callback: Callable[[int], None] | None = None, + progress_callback: ProgressCallback | None = None, ) -> Path: - """LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다.""" - source = Path(las_path) + """지형 파일 한 장 또는 여러 장을 청크로 읽어 B04 structured.npz로 원자적 저장한다.""" + sources = _as_list(las_path) + if not sources: + raise ValueError("구조화할 지형 파일이 없습니다.") target_dir = Path(output_dir) target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / "structured.npz" - with laspy.open(source) as las_file: - header = las_file.header - total_points = int(header.point_count) - point_format = header.point_format - dimensions = set(point_format.dimension_names) - has_rgb = {"red", "green", "blue"}.issubset(dimensions) - has_intensity = "intensity" in dimensions - has_returns = {"return_number", "number_of_returns"}.issubset(dimensions) - has_classification = "classification" in dimensions - bounds = np.array( - [ - [float(header.mins[0]), float(header.maxs[0])], - [float(header.mins[1]), float(header.maxs[1])], - [float(header.mins[2]), float(header.maxs[2])], - ], - dtype=np.float64, - ) - - xyz = np.empty((total_points, 3), dtype=np.float64) - intensity = np.zeros(total_points, dtype=np.uint16) - rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8) - return_number = np.ones(total_points, dtype=np.uint8) - number_of_returns = np.ones(total_points, dtype=np.uint8) - classification = np.zeros(total_points, dtype=np.uint8) - - offset = 0 - for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE): - chunk_size = len(chunk) - section = slice(offset, offset + chunk_size) - xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64) - xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64) - xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64) - if has_intensity: - intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16) - if has_rgb: - colors = np.stack( - [ - np.asarray(chunk.red, dtype=np.float64), - np.asarray(chunk.green, dtype=np.float64), - np.asarray(chunk.blue, dtype=np.float64), - ], - axis=1, - ) - if colors.size and float(colors.max()) > 255.0: - colors /= 256.0 - rgb[section] = colors.clip(0, 255).astype(np.uint8) - if has_returns: - return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8) - number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8) - if has_classification: - classification[section] = np.asarray(chunk.classification, dtype=np.uint8) - offset += chunk_size - if progress_callback: - progress_callback(int(offset / total_points * 100) if total_points else 100) + total_points, bounds, has_rgb = _headers(sources) + thin = total_points > SURFACE_THIN_TRIGGER_POINTS + merged = _merge_sources(sources, total_points, bounds, thin, progress_callback) + logger.info( + "B04 구조화: 파일 %d장 원본 %d점 → 저장 %d점 (씨닝 %s)", + len(sources), + total_points, + merged.size, + f"{SURFACE_THIN_CELL_SIZE_M}m 칸" if thin else "없음", + ) temporary_path: Path | None = None try: @@ -90,14 +316,12 @@ def structurize_las( temporary_path = Path(temporary.name) np.savez_compressed( temporary, - xyz=xyz, - intensity=intensity, - rgb=rgb, - return_number=return_number, - number_of_returns=number_of_returns, - classification=classification, + **merged.arrays(), bounds=bounds, - total_points=np.array([total_points], dtype=np.int64), + total_points=np.array([merged.size], dtype=np.int64), + source_point_count=np.array([total_points], dtype=np.int64), + source_file_count=np.array([len(sources)], dtype=np.int64), + thinned=np.array([int(thin)], dtype=np.int8), has_rgb=np.array([int(has_rgb)], dtype=np.int8), ) temporary.flush() @@ -109,6 +333,6 @@ def structurize_las( if temporary_path is not None: temporary_path.unlink(missing_ok=True) - if progress_callback and total_points == 0: + if progress_callback: progress_callback(100) return target diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py index 6534d92d..2fadf6dd 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Analyze.py @@ -166,8 +166,12 @@ def preview_stages( """ if len(vertices) < 2: return None + from B03_FileInput.B03_FileInput_Service_Chain import _log_steps + + marks = [("시작", time.perf_counter())] route_line = LineString([(vertex.x, vertex.y) for vertex in vertices]) region = resolve_primary_region(vertices, route_line, contour_features, stream_features) + marks.append(("1차 영역(resolve_primary_region)", time.perf_counter())) if region is None: return None @@ -185,6 +189,7 @@ def preview_stages( logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.") return StagePreview(region=region) + marks.append(("격자 확장·흐름 판정(expand_by_red_boundary)", time.perf_counter())) analysis = expansion.analysis spec = analysis.spec red = analysis.flow.reaches_road & analysis.flow.analyzed @@ -194,6 +199,7 @@ def preview_stages( # 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다. routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None strength_curve = _preview_strength(analysis, routing, red, route_line.length) + marks.append(("흐름 강도(trace_flow)", time.perf_counter())) # ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽. boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols)) @@ -209,6 +215,8 @@ def preview_stages( # B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다. flow_arrows = build_flow_arrows(analysis, analysis.flow) + marks.append(("외곽선·기본 관·화살표", time.perf_counter())) + _log_steps("배수유역 preview_stages", marks) logger.info( "배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — " diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py index b0c0331a..e854be43 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Descent.py @@ -23,6 +23,7 @@ from __future__ import annotations import logging +import time import math from dataclasses import dataclass from typing import Any @@ -71,12 +72,25 @@ class ContourDescent: levels: list[float] # 사용된 등고 표고(내림차순) -def rasterize_contours( - spec: GridSpec, - contour_features: list[dict[str, Any]], - elevation_floor_m: float | None = None, -) -> tuple[np.ndarray, list[float]]: - """등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN).""" +_LINES_CACHE: list[tuple[Any, int, float | None, dict[float, list[Any]]]] = [] + + +def _lines_by_level( + contour_features: list[dict[str, Any]], elevation_floor_m: float | None +) -> dict[float, list[Any]]: + """등고선 피처를 표고별 선 묶음으로 푼다 — **격자와 무관**하므로 한 번만 푼다. + + 확장 회차마다 다시 부르는데 피처 4,200개를 매번 `shape()` 로 푸는 비용이 그대로 + 붙었다. 같은 목록·같은 하한이면 그대로 돌려준다(목록 객체를 함께 들고 있어 id 가 + 다른 목록에 재사용되지 않는다). + """ + for holder, count, floor, cached in _LINES_CACHE: + if ( + holder is contour_features + and count == len(contour_features) + and floor == elevation_floor_m + ): + return cached by_level: dict[float, list[Any]] = {} for feature in contour_features: geometry = feature.get("geometry") @@ -95,7 +109,18 @@ def rasterize_contours( if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M: continue by_level.setdefault(float(elevation), []).append(line) + _LINES_CACHE.append((contour_features, len(contour_features), elevation_floor_m, by_level)) + del _LINES_CACHE[:-2] + return by_level + +def rasterize_contours( + spec: GridSpec, + contour_features: list[dict[str, Any]], + elevation_floor_m: float | None = None, +) -> tuple[np.ndarray, list[float]]: + """등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN).""" + by_level = _lines_by_level(contour_features, elevation_floor_m) burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32) levels = sorted(by_level, reverse=True) transform = grid_transform(spec) @@ -120,6 +145,107 @@ def rasterize_contours( return burned, levels +# 밴드별 하강거리 EDT 를 **쓸 자리 둘레**에서만 돌리기 위한 값들. +# +# 왜(2026-09-07 실측) — 확장 회차마다 방향장을 다시 만드는데, 그 안의 밴드별 EDT 가 +# 격자 전체를 단계마다 훑어 확장 95.8s 중 50.4s 를 썼다. 정작 쓰는 값은 그 단계의 +# 밴드 셀뿐이다. 다만 `analyze_domain` 이 `domain` 을 안 넘겨 밴드가 **격자 전역에 +# 흩어지므로** 바운딩박스 하나로는 안 좁아진다. 그래서 격자를 타일로 나눠 **셀이 있는 +# 타일만** 그 둘레 `margin` 까지 잘라 EDT 를 돌린다. +# +# 창 밖에 더 가까운 등고선이 있을 수 있으면(창 안 최대 거리가 여유에 닿거나 창에 낮은 +# 라인이 없으면) **그 타일만** 창을 4배로 넓혀 다시 잰다 — 근사가 아니라 같은 값을 싸게 +# 구하는 것. 단계 전체를 격자 전체로 되돌리면 이득이 사라진다(그 방식일 때 폴백 25/91). +# +# 고른 근거(745×1035 격자·166단 중 EDT 도는 91단, 전체격자 방식 5.2s): +# **타일 128 여유 32 → 2.1s(넓힘 15)** 타일 128 여유 24 → 1.9s(넓힘 52) +# 타일 128 여유 48 → 2.5s(넓힘 7) 타일 256 여유 32 → 2.7s(넓힘 15) +# 타일 512 여유 32 → 4.5s(넓힘 15) +# 여유 24 가 0.2s 빠르지만 넓힘이 52회로 지형에 예민해 32 로 뒀다. 한 단 아래 등고선은 +# 주곡선 5m·셀 1m 에서 대개 수십 칸 안이다. 넓힘 횟수는 계측 줄에 찍힌다. +DESCENT_WINDOW_MARGIN_CELLS = 32 +DESCENT_TILE_CELLS = 128 + + +def _distance_to_lower( + members: np.ndarray, + lower: np.ndarray, + margin: int | None = None, + tile: int | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]: + """`members` 셀에서 한 단 낮은 등고 라인까지의 거리와 목표 셀(**전체 격자 좌표**). + + 네 번째 값은 창을 넓혀 다시 잰 타일 수다(0 = 전부 첫 창에서 끝남). 반환 순서는 + `members` 의 행우선 순서 — 호출부가 `distance[members] = ...` 로 그대로 넣는다. + """ + margin = DESCENT_WINDOW_MARGIN_CELLS if margin is None else margin + tile = DESCENT_TILE_CELLS if tile is None else tile + rows, cols = members.shape + out_distance = np.zeros((rows, cols), dtype=np.float64) + out_row = np.zeros((rows, cols), dtype=np.int32) + out_col = np.zeros((rows, cols), dtype=np.int32) + widened = 0 + + for row_start in range(0, rows, tile): + row_stop = min(rows, row_start + tile) + for col_start in range(0, cols, tile): + col_stop = min(cols, col_start + tile) + tile_members = members[row_start:row_stop, col_start:col_stop] + if not tile_members.any(): + continue + # 첫 창에서 안 닿으면 **그 타일만** 창을 넓혀 다시 잰다 — 단계 전체를 격자 + # 전체로 되돌리면 이득이 사라진다(실측 폴백 25/91). + reach = margin + attempt = 0 + while True: + win_row0 = max(0, row_start - reach) + win_row1 = min(rows, row_stop + reach) + win_col0 = max(0, col_start - reach) + win_col1 = min(cols, col_stop + reach) + whole = win_row0 == 0 and win_col0 == 0 and win_row1 == rows and win_col1 == cols + window_lower = lower[win_row0:win_row1, win_col0:win_col1] + if not window_lower.any(): + if whole: + return _full_distance_to_lower(members, lower) + reach *= 4 + attempt += 1 + widened += 1 + continue + step, (step_row, step_col) = distance_transform_edt( + ~window_lower, return_indices=True + ) + in_window = np.zeros(window_lower.shape, dtype=bool) + in_window[ + row_start - win_row0 : row_stop - win_row0, + col_start - win_col0 : col_stop - win_col0, + ] = tile_members + hit_rows, hit_cols = np.nonzero(in_window) + values = step[hit_rows, hit_cols] + # 타일 둘레로 `reach` 를 뒀으므로, 거리가 그보다 짧으면 창 밖에 더 + # 가까운 것은 있을 수 없다. 닿으면 창을 넓혀 다시 잰다. + if not whole and values.size and float(values.max()) >= reach: + reach *= 4 + attempt += 1 + widened += 1 + continue + break + global_rows = hit_rows + win_row0 + global_cols = hit_cols + win_col0 + out_distance[global_rows, global_cols] = values + out_row[global_rows, global_cols] = step_row[hit_rows, hit_cols] + win_row0 + out_col[global_rows, global_cols] = step_col[hit_rows, hit_cols] + win_col0 + + return out_distance[members], out_row[members], out_col[members], widened + + +def _full_distance_to_lower( + members: np.ndarray, lower: np.ndarray +) -> tuple[np.ndarray, np.ndarray, np.ndarray, bool]: + """격자 전체 EDT — 창으로 못 믿을 때만 쓰는 폴백.""" + step, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True) + return step[members], step_row[members], step_col[members], True + + def build_contour_descent( spec: GridSpec, contour_features: list[dict[str, Any]], @@ -127,8 +253,12 @@ def build_contour_descent( elevation_floor_m: float | None = None, ) -> ContourDescent: """등고선만으로 셀별 흐름 방향을 세운다. 보간면을 만들지 않는다.""" + from B03_FileInput.B03_FileInput_Service_Chain import _log_steps + + marks = [("시작", time.perf_counter())] rows, cols = spec.n_rows, spec.n_cols burned, levels = rasterize_contours(spec, contour_features, elevation_floor_m) + marks.append(("등고선 굽기(rasterize_contours)", time.perf_counter())) empty = ContourDescent( spec=spec, band_elevation=np.full((rows, cols), np.nan, dtype=np.float32), @@ -146,6 +276,7 @@ def build_contour_descent( # ② 셀마다 가장 가까운 등고 라인의 표고 = 그 셀의 밴드. _, (near_row, near_col) = distance_transform_edt(~on_contour, return_indices=True) band_elevation = burned[near_row, near_col].astype(np.float32) + marks.append(("밴드 표고(EDT 1회)", time.perf_counter())) inside = domain if domain is not None else np.ones((rows, cols), dtype=bool) band_elevation = np.where(inside, band_elevation, np.nan) @@ -154,6 +285,8 @@ def build_contour_descent( target_row = np.zeros((rows, cols), dtype=np.int32) target_col = np.zeros((rows, cols), dtype=np.int32) band_rank = np.full((rows, cols), -1, dtype=np.int32) + window_widened = 0 + edt_steps = 0 for rank, elevation in enumerate(levels[:-1]): members = inside & (band_elevation == elevation) if not members.any(): @@ -161,14 +294,22 @@ def build_contour_descent( lower = on_contour & (burned < elevation) if not lower.any(): continue - step_distance, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True) - distance[members] = step_distance[members].astype(np.float32) - target_row[members] = step_row[members] - target_col[members] = step_col[members] + step_distance, step_row, step_col, widened = _distance_to_lower(members, lower) + window_widened += widened + edt_steps += 1 + distance[members] = step_distance.astype(np.float32) + target_row[members] = step_row + target_col[members] = step_col band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값 # 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 — # 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다. + marks.append( + ( + f"밴드별 하강거리(EDT {edt_steps}단 · 창 넓힘 {window_widened}회)", + time.perf_counter(), + ) + ) lowest = inside & (band_elevation == levels[-1]) & (band_rank < 0) if lowest.any(): distance[lowest] = 0.0 @@ -185,9 +326,12 @@ def build_contour_descent( distance[valid & ~np.isfinite(distance)] = 0.0 potential = np.where(valid, band_rank.astype(np.float64) * span + distance, np.inf) + marks.append(("위치에너지 조립", time.perf_counter())) receiver, step_length, azimuth = _route_by_potential( spec, potential, valid, target_row, target_col ) + marks.append(("흐름 경로(_route_by_potential)", time.perf_counter())) + _log_steps(f"하강 방향장({rows}×{cols})", marks) logger.info( "배수유역: 등고선 하강 방향 %d셀 (밴드 %d단), 최하단 정지 %d셀", int(valid.sum()), diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py index 88def1e2..7ef8234a 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Expand.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +import time from dataclasses import dataclass from typing import Any @@ -80,8 +81,13 @@ def analyze_domain( 회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`). 해석 영역은 마지막에 마스크로만 씌운다. """ - if descent is None or descent.spec != spec: + from B03_FileInput.B03_FileInput_Service_Chain import _log_steps + + marks = [("시작", time.perf_counter())] + reused = descent is not None and descent.spec == spec + if not reused: descent = build_contour_descent(spec, contour_features, None, elevation_floor_m) + marks.append(("하강 방향장" + ("(재사용)" if reused else "(새로 만듦)"), time.perf_counter())) valid = descent.valid & domain if not valid.any(): return None @@ -92,9 +98,14 @@ def analyze_domain( receiver=descent.receiver, step_length=descent.step_length, ) + marks.append(("지형 격자 조립", time.perf_counter())) road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M) + marks.append(("도로 굽기(rasterize_road)", time.perf_counter())) terrain, burned = burn_stream_flow(terrain, road, upstream_streams) + marks.append(("세류 새김(burn_stream_flow)", time.perf_counter())) flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth) + marks.append(("도로 도달 판정(classify_flow)", time.perf_counter())) + _log_steps(f"격자 해석 1회({spec.n_rows}×{spec.n_cols})", marks) return GridAnalysis( spec=spec, domain=domain, descent=descent, terrain=terrain, road=road, flow=flow ) @@ -111,6 +122,7 @@ def expand_by_red_boundary( max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS, ) -> RedExpansion | None: """최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다.""" + _expand_started = time.perf_counter() band_cells = max(1, int(round(band_m / spec.cell_m))) # 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를 # 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다. @@ -122,9 +134,17 @@ def expand_by_red_boundary( if analysis is None: return None + logger.info( + "[계측] 확장 0회차(첫 해석) %.1fs · 격자 %d×%d · 셀 %d", + time.perf_counter() - _expand_started, + analysis.spec.n_rows, + analysis.spec.n_cols, + started_cells, + ) rounds = 0 closed = False for attempt in range(max_rounds): + _round_started = time.perf_counter() current = analysis.spec reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols) rim_red = outermost_cells(analysis.domain) & reaches @@ -162,6 +182,16 @@ def expand_by_red_boundary( break analysis = widened_analysis rounds += 1 + logger.info( + "[계측] 확장 %d회차 %.1fs · 격자 %d×%d · 셀 %d (+%d) · 방향장 %s", + attempt + 1, + time.perf_counter() - _round_started, + grown_spec.n_rows, + grown_spec.n_cols, + int(widened.sum()), + int(added_mask.sum()), + "재사용" if grown_spec == current else "새로", + ) added_reaches = widened_analysis.flow.reaches_road.reshape( grown_spec.n_rows, grown_spec.n_cols diff --git a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Stream.py b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Stream.py index c6951a5f..6a6e8cf4 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_Watershed_Stream.py +++ b/B04_PreProcess/B04_PreProcess_Engine_Watershed_Stream.py @@ -90,14 +90,25 @@ def split_streams_at_road( node_edges.setdefault(head, []).append(index) node_edges.setdefault(tail, []).append(index) - sampler = ElevationSampler(cloud) + # 표고를 묻는 조각은 **도로 교차 노드에 닿은 것뿐**이다(나머지는 확산으로만 정해진다). + # 그 조각들만 먼저 골라 두고, 그 둘레로 삼각망을 좁힌다 — 위 클래스 주석 참조. + seed_pieces = [ + (index, piece, touching) + for index, piece in enumerate(pieces) + if (touching := [node for node in ends[index] if node in crossing_nodes]) + ] + focus_xy = ( + np.concatenate( + [np.asarray(piece.coords, dtype=np.float64)[:, :2] for _index, piece, _n in seed_pieces] + ) + if seed_pieces + else None + ) + sampler = ElevationSampler(cloud, focus_xy=focus_xy) # 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다. upper_seeds: dict[int, tuple[float, float]] = {} lower_seeds: dict[int, tuple[float, float]] = {} - for index, piece in enumerate(pieces): - touching = [node for node in ends[index] if node in crossing_nodes] - if not touching: - continue + for index, piece, touching in seed_pieces: heights = sampler.at(np.array(touching, dtype=np.float64)) crossing_node = touching[int(np.argmin(heights))] if _mean_elevation(piece, sampler) > float(np.min(heights)): @@ -210,16 +221,42 @@ class ElevationSampler: 최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가 실제보다 한 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을 1차로 쓰고, TIN 밖(볼록껍질 외부)만 최근접 정점으로 메운다. + + **`focus_xy` 를 꼭 줄 것 — 노선 [확인] 이 3분 반이던 원인이 여기였다**(2026-09-07 실측). + `LinearNDInterpolator` 는 들로네 삼각망을 **첫 호출 때** 만드는데, 등고선 구름을 통째로 + (용화 317,348점) 넘기면 그 한 번이 **256~1,481초**로 뛴다(같은 입력에도 편차가 큼). + 쓰는 곳은 세류 조각 위 몇 점뿐이므로, 물어볼 자리 둘레만 남기면 삼각망이 작아져 + **1초 아래**가 된다. 남기는 여유(`margin_m`)는 등고선 재샘플 간격(5m)의 스무 배라 + 물어볼 점은 언제나 볼록껍질 한참 안쪽에 있고 보간값도 그대로다. """ - def __init__(self, cloud: ContourCloud) -> None: + def __init__( + self, + cloud: ContourCloud, + focus_xy: np.ndarray | None = None, + margin_m: float = 100.0, + ) -> None: self._z = cloud.z if cloud.is_empty: self._interpolator = None self._tree = None return - self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z) - self._tree = cKDTree(cloud.xy) + xy = np.asarray(cloud.xy, dtype=np.float64) + z = np.asarray(cloud.z, dtype=np.float64) + if focus_xy is not None and len(focus_xy): + focus = np.asarray(focus_xy, dtype=np.float64) + near = cKDTree(focus).query(xy, workers=-1)[0] <= margin_m + if near.any(): + xy, z = xy[near], z[near] + logger.info( + "배수유역: 표고 보간 구름을 물어볼 자리 %.0fm 안으로 줄임 — %d → %d점", + margin_m, + len(cloud.z), + len(z), + ) + self._z = z + self._interpolator = LinearNDInterpolator(xy, z) + self._tree = cKDTree(xy) def at(self, xy: np.ndarray) -> np.ndarray: """(N, 2) 좌표의 표고 (N,).""" @@ -230,6 +267,15 @@ class ElevationSampler: if missing.any(): _, indices = self._tree.query(xy[missing]) values[missing] = self._z[indices] + # 볼록껍질 밖으로 빠진 점은 최근접 정점 값이라 **등고선 간격(5m)만큼 틀릴 수** + # 있고, 그러면 그 조각의 상·하류 판정이 갈린다. 구름을 좁힌 뒤로는 「둘레 + # 100m 안에 등고선이 없는 자리」가 그 원인이 되므로 조용히 넘기지 않고 남긴다 + # (2026-09-07 보조 창 제안 — 다른 현장에서 갈리는 것을 잡는 값싼 그물). + logger.warning( + "배수유역: 표고를 TIN 밖에서 읽음 — %d/%d점은 최근접 등고선 정점 값", + int(missing.sum()), + int(missing.size), + ) return values diff --git a/B04_PreProcess/B04_PreProcess_Repository.py b/B04_PreProcess/B04_PreProcess_Repository.py index 231c4531..3e30cd68 100644 --- a/B04_PreProcess/B04_PreProcess_Repository.py +++ b/B04_PreProcess/B04_PreProcess_Repository.py @@ -6,7 +6,7 @@ terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로 """ import json -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath from typing import Any from uuid import UUID @@ -230,6 +230,25 @@ async def list_project_point_cloud_inputs( ] +async def list_project_point_cloud_paths( + connection: aiomysql.Connection, project_id: UUID, project_root: Path +) -> list[Path]: + """전처리가 병합할 지형 파일 경로 목록 — 실제로 있는 파일만 (2026-09-06 다중 입력). + + 교체된 옛 행(`SUPERSEDED`)은 조회에서 이미 빠지므로, 지금 살아 있는 파일만 남는다. + """ + rows = await list_project_point_cloud_inputs(connection, project_id) + paths: list[Path] = [] + for row in rows: + raw = str(row.get("raw_file_path") or "") + if not raw: + continue + path = project_root / Path(raw) + if path.is_file() and path not in paths: + paths.append(path) + return paths + + async def list_surface_models( connection: aiomysql.Connection, project_id: UUID ) -> list[dict[str, Any]]: diff --git a/B04_PreProcess/B04_PreProcess_Router.py b/B04_PreProcess/B04_PreProcess_Router.py index 6de4818d..ad43bcaa 100644 --- a/B04_PreProcess/B04_PreProcess_Router.py +++ b/B04_PreProcess/B04_PreProcess_Router.py @@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Repository import ( clear_confirmed_surface_models, get_input_file, list_project_point_cloud_inputs, + list_project_point_cloud_paths, list_surface_models, save_surface_analysis_to_db, ) @@ -117,6 +118,10 @@ async def analyze_surface( status_code=404, content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."}, ) + # 지형 파일이 여러 장이면 합쳐서 다시 만든다 — 자동 전처리와 같은 대상을 쓴다. + terrain_paths = await list_project_point_cloud_paths( + connection, project_id, project_root + ) # 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록). write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.") @@ -128,7 +133,7 @@ async def analyze_surface( result = await asyncio.to_thread( run_surface_analysis, project_root, - las_path, + terrain_paths or [las_path], source_filters=source_filters, methods=methods, force=request.force, diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 86edeea1..453e3de1 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -37,6 +37,7 @@ from common_util.common_util_drainage_pipes import ( save_detail_basins, save_pipe_points, ) +from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_route_geometry import StructureCandidate from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M @@ -122,7 +123,23 @@ def _payload( points: list[PipePoint], saved: bool, ) -> dict[str, Any]: - """관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다.""" + """관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다. + + 좌표는 위경도 7자리(약 1.1cm)로 맞춰 내보낸다 — 저장된 값에는 뜻 없는 자리가 붙어 있어 + 응답이 151KB → 100KB(압축까지 얹으면 22KB)가 된다(2026-09-06 실측). 화면이 그리는 값이고 + 다시 계산에 넣지 않으므로 줄여도 결과가 안 갈린다. + """ + return round_floats(_raw_payload(project_id, context, detail, points, saved), LONLAT_DIGITS) + + +def _raw_payload( + project_id: UUID, + context: DrainageContext, + detail: DrainageDetail, + points: list[PipePoint], + saved: bool, +) -> dict[str, Any]: + """자릿수를 줄이기 전의 응답 본문.""" to_lonlat = context.to_lonlat return { "status": "success", diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index 4dd54146..652b70d0 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -150,7 +151,13 @@ async def get_project_geojson( if layer in _SHEET_GEOJSON_FILES: # 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시). # 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다. - return cached_file_response(request, filepath, "application/geo+json") + # 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은 + # 그대로고 뜻 없는 자리만 사라진다. + return cached_file_response( + request, + await asyncio.to_thread(_display_copy, filepath), + "application/geo+json", + ) if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" @@ -349,3 +356,36 @@ async def build_sheet_surface(project_id: UUID, request: Request) -> dict[str, A await connection.rollback() raise return {"status": "success", "method": method, "surface_model_ids": model_ids} + + +def _display_copy(source: Path) -> Path: + """도엽 GeoJSON 의 **표시용 사본**(좌표 자릿수만 줄인 것) 경로를 돌려준다. + + 저장된 파일에는 `1.4000000000000001` 꼴로 뜻 없는 자리가 붙어 있다 — 위경도 7자리 + (1.1cm)로 맞추면 형상은 그대로면서 등고선 도엽이 65.9MB → 36.8MB 가 된다(2026-09-06 실측). + 압축까지 얹으면 11.3MB. + + 사본은 **한 번만** 만든다(66MB 기준 약 3초). 원본이 새로 깔리면 수정시각이 앞서므로 + 저절로 다시 만든다. 만들다 실패하면 원본을 그대로 보낸다 — 화면을 막지 않는다. + """ + target = source.with_suffix(".display.geojson") + try: + if target.exists() and target.stat().st_mtime >= source.stat().st_mtime: + return target + payload = json.loads(source.read_text(encoding="utf-8")) + target.write_text( + json.dumps( + round_floats(payload, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":") + ), + encoding="utf-8", + ) + logger.info( + "도엽 표시용 사본 생성: %s (%.1fMB → %.1fMB)", + source.name, + source.stat().st_size / 1e6, + target.stat().st_size / 1e6, + ) + return target + except Exception as exc: # 사본 실패가 화면을 막으면 안 된다. + logger.warning("도엽 표시용 사본을 만들지 못해 원본을 보냅니다 (%s): %s", source.name, exc) + return source diff --git a/B04_PreProcess/B04_PreProcess_Router_Watershed.py b/B04_PreProcess/B04_PreProcess_Router_Watershed.py index 1ac02733..9beacc38 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Watershed.py +++ b/B04_PreProcess/B04_PreProcess_Router_Watershed.py @@ -11,6 +11,7 @@ import asyncio import json import logging import math +import time from pathlib import Path from typing import Any from uuid import UUID @@ -58,7 +59,7 @@ from common_util.common_util_wamis_station import ( build_station_rainfall_table, is_jeju, ) -from config.config_db import get_db_pool +from config.config_db import get_db_pool, run_with_connection from config.config_system import ( DRAINAGE_ARROW_SPACING_M, DRAINAGE_DESIGN_RETURN_PERIOD_YR, @@ -172,11 +173,13 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse: 잘라 프로젝트 좌표계로 돌려준다. 원본을 그대로 쓰면 유역·관이 확정 노선 밖에도 찍히고 좌표계마저 갈린다(2026-09-01 실측: 관은 5179, 노선은 5176이었다). """ - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - epsg = await get_surface_crs_epsg(connection, project_id, 0) - surface_params = await get_surface_confirmation_params(connection, str(project_id)) + # 셋은 서로 기다릴 이유가 없다 — DB 가 원격이라 순차로 내면 왕복 12ms 가 세 번 붙는다 + # (2026-09-06 실측). 커넥션을 갈라 같이 보낸다. + stored_path, epsg, surface_params = await asyncio.gather( + run_with_connection(get_project_storage_relative_path, project_id), + run_with_connection(get_surface_crs_epsg, project_id, 0), + run_with_connection(get_surface_confirmation_params, str(project_id)), + ) project_root = Path(resolve_stored_project_path(stored_path)) route_file = find_planned_route_file(_route_input_dir(stored_path)) @@ -387,9 +390,13 @@ async def get_primary_region( logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path) return {**saved, "from_cache": True} + from B03_FileInput.B03_FileInput_Service_Chain import _log_steps + + marks = [("시작", time.perf_counter())] prepared = await _prepare(project_id) if isinstance(prepared, JSONResponse): return prepared + marks.append(("_prepare(도엽 읽기·좌표변환)", time.perf_counter())) preview = await asyncio.to_thread( preview_stages, prepared["vertices"], @@ -401,6 +408,7 @@ async def get_primary_region( status_code=400, content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."}, ) + marks.append(("preview_stages(유역 산정)", time.perf_counter())) region = preview.region to_lonlat = prepared["to_lonlat"] # 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다. @@ -514,5 +522,7 @@ async def get_primary_region( _write_stage_arrays(prepared["stored_path"], preview, domain, spec) _write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat) # 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다. + marks.append(("응답 만들기·저장", time.perf_counter())) + _log_steps("배수유역 분석 내부", marks) _save_response(prepared["stored_path"], payload) return payload diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index b137cef6..bf89201a 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -11,6 +11,8 @@ * - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환. * ========================================================================== */ +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; + import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 경로 제어점 (BP/EP/CP) */ @@ -264,6 +266,19 @@ export async function confirmRoute( }); } +/** 세션에 쌓인 상단측(측구 방향) 변경분을 정본으로 내보낸다 — B06 [저장]·[확정]용. + * 3D 램프 클릭은 B05 화면에서만 생기지만 저장 버튼은 B06 에도 있다(B05·B06 은 한 페이지). + * B06 에서 저장하면 이 값이 세션에만 남아 확정 뒤 옛 측구 방향이 그대로 쓰였다 + * (2026-09-06 대응표 조사). 비어 있으면 요청을 내지 않는다. */ +export async function flushUphillOverrides(projectId: string): Promise { + const stored = readState>("uphill", projectId); + const overrides = Object.entries(stored ?? {}) + .filter(([, side]) => side === "left" || side === "right") + .map(([chainage, side]) => ({ chainage_m: Number(chainage), side })); + if (!overrides.length) return; + await confirmRoute(projectId, { uphill_overrides: overrides }, false); +} + /** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */ export interface RouteResetResponse { status: string; @@ -292,39 +307,22 @@ export async function fetchLatestRoute(projectId: string): Promise `b05:latest:${projectId}`; +/* 최신 경로 응답은 ④ 계산 결과다 — 키·이관은 등록표(`b_page_state`)가 맡는다. + B04에서 지표면을 다시 확정하면 옛 확정값이 남아 B05가 이전 지형을 그리므로, 확정 + 직후 `clearRouteLatestCache`로 버린다. */ -/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아 - * B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */ /** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */ export function readRouteLatestCache(projectId: string): RouteLatestResponse | null { - try { - const raw = window.sessionStorage.getItem(routeLatestCacheKey(projectId)); - return raw ? (JSON.parse(raw) as RouteLatestResponse) : null; - } catch { - return null; - } + return readState("latest", projectId); } -/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패하면 캐시를 비운다. */ +/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패해도 화면은 그대로 돈다. */ export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void { - const key = routeLatestCacheKey(projectId); - try { - window.sessionStorage.setItem(key, JSON.stringify(value)); - } catch { - try { - window.sessionStorage.removeItem(key); - } catch { - /* noop */ - } - } + writeState("latest", value, projectId); } export function clearRouteLatestCache(projectId: string): void { - try { - window.sessionStorage.removeItem(routeLatestCacheKey(projectId)); - } catch { - /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ - } + clearState("latest", projectId); + // 설정값에 노선 id 가 실려 있어 함께 버려야 옛 노선을 가리키지 않는다(2026-09-06). + clearState("section-context", projectId); } diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts new file mode 100644 index 00000000..60a15c8e --- /dev/null +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_Api_HaulPlan.ts + * 유토 **배분**(평형선·운반거리·장비)을 서버에서 미리 받아 두는 자리. + * + * 왜 서버인가(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에 + * 남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul`), 그 결과를 + * 여기로 보내 배분을 받아 쥔다. 계산은 여전히 한 벌이다 — 서버가 같은 TS 를 Node 로 돈다. + * + * **조용히 따라오게 한다** — 편집이 멈추면 뒤에서 물어 두므로, 유토곡선 패널을 펼치는 + * 순간에는 이미 도착해 있다. 늦게 온 응답은 버린다(최신 요청만 채택). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { HaulPlan } from "@util/common_util_mass_haul_balance"; + +/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다. + * `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */ +export type HaulPlanPayload = HaulPlan | null; + +/** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */ +const SETTLE_MS = 400; +/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */ +const TIMEOUT_MS = 20000; + +export interface HaulPlanPrefetch { + /** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */ + schedule: (result: unknown) => void; + /** 지금 쥐고 있는 배분. 아직 못 받았으면 null. */ + current: () => HaulPlanPayload; + /** 화면을 떠날 때 예약을 지운다. */ + dispose: () => void; +} + +/** + * 배분 선반입기를 만든다. `onReady` 는 값이 새로 도착했을 때만 불린다 — + * 부르는 쪽은 그때 곡선을 다시 그리면 된다. + */ +export function createHaulPlanPrefetch( + projectId: string, + routeId: () => number | undefined, + onReady: () => void, +): HaulPlanPrefetch { + let timer = 0; + let sequence = 0; + let plan: HaulPlanPayload = null; + let pending: unknown = null; + /** 마지막으로 보낸 입력의 표식 — 같은 값이면 다시 묻지 않는다. */ + let sentKey: string | null = null; + /** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */ + let applying = false; + + /** + * 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 + * `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측). + * 누가토량이 바뀌면 마지막 누계와 측점 수 중 하나는 반드시 달라진다. + */ + function keyOf(result: unknown): string { + const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points; + if (!Array.isArray(points) || !points.length) return "empty"; + const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0; + const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0; + return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`; + } + + async function send(result: unknown, seq: number): Promise { + const route = routeId(); + if (!route) return; + const controller = new AbortController(); + const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ result }), + signal: controller.signal, + }, + ); + if (!response.ok) return; + const payload = (await response.json()) as { haul_plan?: HaulPlanPayload }; + // 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다. + if (seq !== sequence) return; + plan = payload.haul_plan ?? null; + // 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는 + // 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다 + // (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건). + applying = true; + try { + onReady(); + } finally { + applying = false; + } + } catch { + // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다. + } finally { + window.clearTimeout(abort); + } + } + + return { + schedule(result) { + if (applying) return; // 응답을 반영하려고 다시 그리는 중이다. + const key = keyOf(result); + if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다. + sentKey = key; + pending = result; + sequence += 1; + const seq = sequence; + window.clearTimeout(timer); + timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS); + }, + current: () => plan, + dispose() { + window.clearTimeout(timer); + sequence += 1; // 남아 있는 응답을 모두 무효로 만든다. + }, + }; +} diff --git a/B05_Profile/B05_Profile_Api_Pipes_Draft.ts b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts new file mode 100644 index 00000000..03c7fe96 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_Pipes_Draft.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B05_Profile_Api_Pipes_Draft.ts + * B05 배수유역도에서 고친 **관 목록 초안**(세션) — [저장]·[확정]에서만 정본으로 나간다. + * + * 왜 생겼나(2026-09-06 대응표 조사) — 관 추가·이동·삭제가 패널 메모리에만 있어, B06 으로 + * 넘어가 [저장]하면 그 편집이 통째로 사라졌다(`savePipes()` 를 부르는 곳이 B05 [임시저장] + * 하나뿐이었다). 다른 조작값과 같은 규칙으로 세션에 쌓고 저장 앞단에서 함께 내보낸다. + * + * 자동저장은 하지 않는다(CLAUDE.md 5장). + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +/** 세션에 담는 관 한 벌 — 정본 PUT 이 그대로 받는 꼴이다. */ +export type PendingPipes = DetailPipeInput[]; + +export function readPendingPipes(projectId: string): PendingPipes | null { + return readState("pipes", projectId); +} + +export function writePendingPipes(projectId: string, next: PendingPipes | null): void { + writeState("pipes", next, projectId); +} + +/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */ +export async function flushPendingPipes(projectId: string): Promise { + const pending = readPendingPipes(projectId); + if (!pending) return; + await saveDetailPipePoints(projectId, pending); + writePendingPipes(projectId, null); +} diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts new file mode 100644 index 00000000..4f6c4539 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -0,0 +1,138 @@ +/* ============================================================================= + * B05_Profile_Api_Replan.ts + * 계획노선 두 벌(예상노선·계획노선) 읽기와 노선 갈아 끼우기 요청. + * + * GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m) + * POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 + * POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 + * + * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) + * 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */ +const REPLAN_TIMEOUT_MS = 15 * 60 * 1000; + +/** 사용자가 잡아 옮기는 제어점 하나 — 서버가 이 노드로 폴리라인을 만든다. */ +export interface RoutePlanNode { + x: number; + y: number; + /** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */ + inner_angle_deg: number | null; + /** 이 자리에 끼운 원호 반지름(m). 곡선을 지운 자리는 null. */ + radius_m: number | null; + tangent_m: number | null; + /** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */ + violations: string[]; +} + +/** 직선 사이에 놓인 **곡선 성분 하나** — 화면이 손잡이와 R 칸을 그리는 재료. */ +export interface RoutePlanCurve { + /** 앞뒤 직선을 늘려 만나는 자리(교각점). **반지름을 바꿔도 여기는 안 움직인다.** */ + apex: [number, number]; + radius_m: number; + tangent_m: number; + inner_angle_deg: number; + /** 곡선 시작점 — 직선이 곡선에 닿는 자리. 사용자가 잡는 손잡이다. */ + start: [number, number]; + /** 곡선 끝점. */ + end: [number, number]; + /** 이 곡선이 대신하는 꺾임점 구간(첫·끝) — 편집이 어느 노드를 건드리는지 알려 준다. */ + node_first: number; + node_last: number; + violations: string[]; +} + +export interface RoutePlanResponse { + status: string; + project_id: string; + /** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */ + expected: Array<[number, number]>; + /** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */ + planned: Array<[number, number]>; + /** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */ + nodes: RoutePlanNode[]; + /** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */ + curves: RoutePlanCurve[]; + /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ + min_radius_m: number; + curve_count: number; + violation_count: number; + /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ + edited: boolean; +} + +export interface RouteReplanResponse { + status: string; + project_id: string; + route_id: number | null; + total_length_m: number | null; + vertex_count: number; +} + +async function requestJson(path: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + signal: controller.signal, + }); + const payload = (await response.json()) as T & { message?: string }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; + } finally { + window.clearTimeout(timer); + } +} + +/** 예상노선·계획노선을 함께 읽는다(편집 모달이 점선·실선으로 그린다). */ +export async function fetchRoutePlan(projectId: string): Promise { + return requestJson( + `/projects/${projectId}/route/plan`, + { method: "GET" }, + 60000, + ); +} + +/** 꺾임점 하나에 실어 보내는 편집값 — 곡선을 둘지, 반지름을 못박을지. */ +export interface RouteReplanVertex { + x: number; + y: number; + /** 이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다(곡선 삭제). */ + curve?: boolean; + /** 못박을 반지름(m). 없으면 서버가 고른다. */ + radius_m?: number | null; +} + +/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다. + * + * 편집 세 가지가 모두 이 한 목록으로 나간다(2026-09-07 사용자 지시) — + * **직선 삭제·추가**는 점을 빼거나 더하는 것, **곡선 삭제·추가**는 `curve` 를 끄고 켜는 것, + * **반지름 변경**은 `radius_m` 을 주는 것. */ +export async function replanRoute( + projectId: string, + vertices: Array<[number, number]> | RouteReplanVertex[], +): Promise { + const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) => + Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex, + ); + return requestJson( + `/projects/${projectId}/route/replan`, + { method: "POST", body: JSON.stringify({ vertices: payload }) }, + REPLAN_TIMEOUT_MS, + ); +} + +/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */ +export async function resetRoutePlan(projectId: string): Promise { + return requestJson( + `/projects/${projectId}/route/replan/reset`, + { method: "POST" }, + REPLAN_TIMEOUT_MS, + ); +} diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 9c7a48a2..f7f83289 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -11,6 +11,7 @@ * 하려는 것이라, 목록은 반드시 서버에서 받아 온다. * ========================================================================== */ +import { readState, writeState } from "../A00_Common/b_page_state"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ @@ -25,6 +26,8 @@ export interface StructureOptionField { default: string | number | null; /** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */ required?: boolean; + /** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */ + enabled?: boolean; /** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다 * (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */ phase?: "b05" | "detail"; @@ -45,6 +48,9 @@ export interface StructureType { drawing_views: string[]; /** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */ managed_by: string | null; + /** 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름. + * 측구(옆도랑) = `"횡단 설계"`. 항목에 「~에서 관리」를 붙이고 수량 집계는 건너뛴다. */ + design_owner: string | null; reference_only: boolean; enabled: boolean; } @@ -128,18 +134,28 @@ async function requestJson(path: string, init: RequestInit = {}): Promise /** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */ let typesCache: Promise | null = null; -export function fetchStructureTypes(): Promise { +/** + * 구조물 타입 목록. + * + * `includeDisabled` 를 주면 `enabled:false` 타입(B군 종단배수·F군 생태/녹화·G군 일부)도 + * 함께 준다. 레지스트리 주석(2026-08-17)이 「B05 선택지에서 빼고 **B06 개별 횡단도 + * 옵션으로 재사용**」이라 적어 둔 그 자리다 — 2026-09-07 사용자 지시 「A군뿐 아니라 + * 구조물 전체를 넣을 수 있어야 함」으로 B06 이 그 목록을 쓴다. B05 는 종전대로 켜진 것만. + */ +export function fetchStructureTypes(includeDisabled = false): Promise { if (!typesCache) { typesCache = requestJson("/projects/structure-types", { method: "GET", }) - .then((payload) => payload.types.filter((type) => type.enabled)) + .then((payload) => payload.types) .catch((error) => { typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다. throw error; }); } - return typesCache; + return typesCache.then((types) => + includeDisabled ? types : types.filter((type) => type.enabled), + ); } export async function fetchStructures(projectId: string): Promise { @@ -173,6 +189,32 @@ export async function migrateLegacyStations( /** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ +/** 관 지점을 **알약 레인용 가상 구조물**로 만든다 — 정본은 `pipe_points.json` 이라 + * 저장하지 않는다. B05 종단과 B06 종단이 같은 표기를 쓰도록 한 벌만 둔다 + * (2026-09-07 사용자 지시 4 「구조물 표시 통일」). */ +export function pipesToStructureMarks( + pipes: ReadonlyArray<{ + chainage_m: number; + facility: string; + options?: Record | null; + }>, +): StructureInstance[] { + return pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + placement: "point", + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + options: (pipe.options ?? {}) as Record, + memo: "", + placement_source: "automatic", + status: "draft", + revision: 0, + geometry: null, + })) as StructureInstance[]; +} + export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } @@ -193,26 +235,13 @@ export function defaultOptions(type: StructureType): Record `b05:structures:${projectId}`; - -/** 미저장 조작분. 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */ +/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */ export function readPendingStructures(projectId: string): StructureInstance[] | null { - try { - const raw = window.sessionStorage.getItem(pendingStructuresKey(projectId)); - return raw ? (JSON.parse(raw) as StructureInstance[]) : null; - } catch { - return null; - } + return readState("structures", projectId); } export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void { - try { - const key = pendingStructuresKey(projectId); - if (next) window.sessionStorage.setItem(key, JSON.stringify(next)); - else window.sessionStorage.removeItem(key); - } catch { - /* 세션 저장 실패는 무시 — 값은 화면에 남아 있다. */ - } + writeState("structures", next, projectId); } /** diff --git a/B05_Profile/B05_Profile_Corridor_Prebuild.py b/B05_Profile/B05_Profile_Corridor_Prebuild.py index a69be261..9361fac2 100644 --- a/B05_Profile/B05_Profile_Corridor_Prebuild.py +++ b/B05_Profile/B05_Profile_Corridor_Prebuild.py @@ -16,8 +16,6 @@ from __future__ import annotations import asyncio import json import logging -import os -import subprocess import tempfile from pathlib import Path from typing import Any @@ -25,76 +23,13 @@ from uuid import UUID from B05_Profile.B05_Profile_Repository import get_route_points from B05_Profile.B05_Profile_Router_Corridor import corridor_path +from common_util.common_util_node_bundle import build_bundle, bundle_stale, run_node from config.config_db import get_db_pool logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js" -# 번들이 낡았는지 재는 대상 — 빌더 계통이 걸쳐 있는 폴더. -_SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") -# 번들 만들기·실행 상한(초). 실측 번들 0.1초, 빌드 65측점 3초 수준이라 넉넉하다. -_BUILD_TIMEOUT_S = 300 -_RUN_TIMEOUT_S = 600 - - -def _node_env() -> dict[str, str]: - """config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약).""" - env = os.environ.copy() - node_modules = ROOT / "config" / "node_modules" - env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}" - env["NODE_PATH"] = str(node_modules) - return env - - -def _bundle_stale() -> bool: - """번들이 없거나 TS 원본보다 오래됐으면 참. - - 번들이 낡으면 서버와 화면이 **다른 기하**를 만든다 — 이 판정이 그것을 막는 유일한 - 장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다. - """ - if not BUNDLE.is_file(): - return True - built_at = BUNDLE.stat().st_mtime - for directory in _SOURCE_DIRS: - for path in (ROOT / directory).rglob("*.ts"): - if path.stat().st_mtime > built_at: - return True - return False - - -def _build_bundle() -> bool: - result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음 - "npm run build:corridor", - shell=True, - cwd=str(ROOT), - env=_node_env(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_BUILD_TIMEOUT_S, - ) - if result.returncode != 0: - logger.error("코리도 번들 빌드 실패:\n%s", result.stderr) - return False - return True - - -def _run_node(input_path: Path, output_path: Path) -> int: - result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐 - ["node", str(BUNDLE), str(input_path), str(output_path)], - cwd=str(ROOT), - env=_node_env(), - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=_RUN_TIMEOUT_S, - ) - if result.returncode != 0: - logger.warning("코리도 사전 생성 실패(끝 코드 %s): %s", result.returncode, result.stderr) - return result.returncode async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None: @@ -121,7 +56,7 @@ async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id) return False - if _bundle_stale() and not await asyncio.to_thread(_build_bundle): + if bundle_stale(BUNDLE) and not await asyncio.to_thread(build_bundle, "build:corridor"): return False target = corridor_path(Path(project_root), route_id) @@ -133,7 +68,7 @@ async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: json.dumps({"detail": detail, "route_points": points}, default=float), "utf-8", ) - if await asyncio.to_thread(_run_node, source, built) != 0: + if await asyncio.to_thread(run_node, BUNDLE, source, built) != 0: return False await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(_replace, built, target) diff --git a/B05_Profile/B05_Profile_Engine.py b/B05_Profile/B05_Profile_Engine.py index eae556fd..46f360d9 100644 --- a/B05_Profile/B05_Profile_Engine.py +++ b/B05_Profile/B05_Profile_Engine.py @@ -106,7 +106,20 @@ def run_route_design( }, options=options, ) - if algorithm == "ridge_valley": + if algorithm == "as_planned": + # 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀지 않는다(PLAN 0-3 자동탐색 접기). + # 제어점 목록(bp·cp·ep)이 곧 노선이며, 표고만 지표면에서 뜬다. + from B05_Profile.B05_Profile_Engine_AsPlanned import solve_as_planned + + sequence = [points_data.get("bp")] + list(points_data.get("cp") or []) + sequence.append(points_data.get("ep")) + vertices = [ + (float(point["x"]), float(point["y"])) for point in sequence if isinstance(point, dict) + ] + result = solve_as_planned( + project_root, filter_key, smooth, vertices, options, method=method + ) + elif algorithm == "ridge_valley": result = solve_ridge_valley_route( project_root, filter_key, smooth, points_data, options, method=method ) diff --git a/B05_Profile/B05_Profile_Engine_AsPlanned.py b/B05_Profile/B05_Profile_Engine_AsPlanned.py new file mode 100644 index 00000000..d176d08c --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_AsPlanned.py @@ -0,0 +1,169 @@ +"""사용자가 고친 계획노선을 **그대로 쓰는** 길 — 다시 풀지 않는다. + +왜 필요한가(2026-09-06 실측) — 노선 편집 [확인]이 재확정 체인을 타는데, 그 체인이 +`solve_route` 로 **BP·CP·EP 제어점 사이를 격자에서 다시 풀었다**. 사용자가 노드를 조금만 +비틀어도 탐색 제약에 걸려 체인이 통째로 멈췄다: + + 세그먼트 1 (BP → CP1) 경로 탐색 실패: 종단경사 한계(26%)·최소곡선반지름(12m)· + 회피지역 제약으로 통과 경로가 없습니다. + +그 결과 배수유역·관은 새 노선으로 가고 종횡단만 옛 노선에 남아 배수관 측점이 9 → 0 이 됐다. +PLAN 0-3 에서 **노선 자동탐색은 접기로** 했고(사용자 확정), 계획노선은 사용자가 직접 고친다. +여기서는 그 노선을 **그대로 받아** 표고만 지표면에서 떠서 결과 꼴을 맞춘다. + +**위반은 세되 막지 않는다** — 종단기울기·곡선반지름을 재어 개수로 알리되 경로를 바꾸지 +않는다(사용자 확정: 자동 보정·차단 없이 경고만). 막는 순간 「내가 그린 선이 안 들어간다」가 된다. + +솔버(`B05_Profile_Engine_Solver`)와 지표 계산이 겹치지만 **합치지 않았다** — 솔버는 0-3 으로 +접히는 코드라 그쪽을 리팩터링해 두 곳을 얽을 값어치가 없다. +""" + +from __future__ import annotations + +import logging +import math +from pathlib import Path +from typing import Any + +import numpy as np + +logger = logging.getLogger(__name__) + +_MODELS_SUBDIR = "B04_PreProcess/models" + + +def _elevation_lookup( + project_root: Path, filter_key: str, method: str, smooth: bool +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """지표면 격자(x, y, z) — 노선 정점의 표고를 뜨는 데 쓴다.""" + from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _sample_surface_on_grid + + models_dir = Path(project_root) / _MODELS_SUBDIR + x_coords, y_coords, dtm_z, _valid = _load_dtm_grid(models_dir, filter_key, smooth) + surface_z = _sample_surface_on_grid( + models_dir, filter_key, method, smooth, x_coords, y_coords, dtm_z + ) + return x_coords, y_coords, surface_z + + +def _z_at( + x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray, x: float, y: float +) -> float: + """격자에서 가장 가까운 칸의 표고. 격자 밖이면 가장자리 값.""" + col = int(np.clip(np.searchsorted(x_coords, x), 0, len(x_coords) - 1)) + row = int(np.clip(np.searchsorted(y_coords, y), 0, len(y_coords) - 1)) + value = float(z_grid[row, col]) + return value if math.isfinite(value) else 0.0 + + +def solve_as_planned( + project_root: Path, + filter_key: str, + smooth: bool, + vertices: list[tuple[float, float]], + options: dict[str, Any], + method: str = "dtm", +) -> dict[str, Any]: + """계획노선 정점을 그대로 노선으로 삼는다. 반환 꼴은 솔버와 같다.""" + if len(vertices) < 2: + raise ValueError("계획노선 정점이 2개 미만입니다.") + x_coords, y_coords, z_grid = _elevation_lookup(project_root, filter_key, method, smooth) + polyline = [[float(x), float(y), _z_at(x_coords, y_coords, z_grid, x, y)] for x, y in vertices] + + max_uphill = float(options.get("max_uphill_grade") or 0.26) + max_downhill = float(options.get("max_downhill_grade") or 0.26) + min_radius = float(options.get("min_curve_radius_m") or 12.0) + + count = len(polyline) + chainage_m = [0.0] * count + length_m = 0.0 + grade_sums = 0.0 + max_grade = 0.0 + max_up = 0.0 + max_down = 0.0 + slope_violations = 0 + for index in range(count - 1): + x1, y1, z1 = polyline[index] + x2, y2, z2 = polyline[index + 1] + horizontal = math.hypot(x2 - x1, y2 - y1) + chainage_m[index + 1] = chainage_m[index] + horizontal + if horizontal <= 0.01: + continue + dz = z2 - z1 + slope = abs(dz) / horizontal + length_m += horizontal + grade_sums += slope * horizontal + max_grade = max(max_grade, slope) + if dz > 0: + max_up = max(max_up, slope) + else: + max_down = max(max_down, slope) + if slope > (max_uphill if dz > 0 else max_downhill): + slope_violations += 1 + avg_grade = (grade_sums / length_m) if length_m > 0 else 0.0 + + # 곡선반지름 — 세 점을 지나는 원으로 재고, 하한을 밑도는 자리를 **세기만** 한다. + from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d + + curve_violations = 0 + min_radius_actual = float("inf") + for index in range(1, count - 1): + radius = circumradius_2d(polyline[index - 1], polyline[index], polyline[index + 1]) + min_radius_actual = min(min_radius_actual, radius) + if radius < min_radius: + curve_violations += 1 + + total_length = chainage_m[-1] if chainage_m else 0.0 + segments = [ + { + "index": 0, + "from": "BP", + "to": "EP", + "point_start": 0, + "point_end": count - 1, + "chainage_start_m": 0.0, + "chainage_end_m": round(total_length, 2), + "length_m": round(total_length, 2), + "max_grade_pct": round(max_grade * 100, 2), + } + ] + logger.info( + "계획노선 그대로 사용: 정점 %d · 연장 %.1fm · 경사위반 %d · 곡선위반 %d", + count, + total_length, + slope_violations, + curve_violations, + ) + return { + "polyline": polyline, + "chainage_m": [round(value, 3) for value in chainage_m], + "segments": segments, + # 사용자가 그린 선이므로 제어점 도달 검사는 뜻이 없다 — 통과로 둔다. + "required_point_checks": [], + "required_points_ok": True, + "avoid_intrusions": [], + "forbidden_intrusions": [], + "curve_warning_segments": [], + "avoid_retry_performed": False, + "conditions_snapshot": { + "filter": filter_key, + "method": method, + "smooth": smooth, + "source": "as_planned", + "min_curve_radius_m": round(min_radius, 2), + }, + "metrics": { + "length_m": round(length_m, 2), + "avg_grade_pct": round(avg_grade * 100, 2), + "max_grade_pct": round(max_grade * 100, 2), + "max_uphill_pct": round(max_up * 100, 2), + "max_downhill_pct": round(max_down * 100, 2), + "slope_violations": slope_violations, + "search_max_grade_pct": round(max(max_uphill, max_downhill) * 100, 2), + "curve_violations": curve_violations, + "min_curve_radius_m": round(min_radius_actual, 2) + if math.isfinite(min_radius_actual) + else None, + "min_curve_radius_limit_m": round(min_radius, 2), + }, + } diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index c7574a34..e250d8f4 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -139,6 +139,18 @@ def legal_grade_limit_pct( return legal_max +def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") -> float: + """설계속도 × 지형 구분에 따른 법정 **평면** 최소곡선반지름(m, 별표2 Ⅰ.2.다.(1)). + + 경로탐색 제약이 아니라 **위반 표시** 기준이다(2026-09-06 사용자 확정). 설계속도는 + 이미 확정된 값을 받는다(계획선 옵션이 `resolve_design_speed`로 눌러 둔 값). + """ + table = FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"] + terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" + speeds = table.get(int(design_speed_kph)) or table[20] + return float(speeds[terrain]) + + def _pick(*candidates: Any) -> Any: """요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다.""" for value in candidates: diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index 005d5a8a..254b252d 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -20,7 +20,7 @@ from typing import Any import numpy as np -from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT +from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA ALIGNMENT_SCHEMA_VERSION = 1 # chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다. @@ -51,6 +51,10 @@ class AlignmentPolicy: max_grade_pct: float curve_skip_delta_pct: float paved: bool + #: 법정 평면 최소곡선반지름(m) — 위반 표시 기준(2026-09-06). 0이면 판정하지 않는다. + min_plan_radius_m: float = 0.0 + #: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다. + hairpin_min_radius_m: float = 0.0 @classmethod def from_config( @@ -60,6 +64,7 @@ class AlignmentPolicy: max_grade_pct: float, curve_skip_delta_pct: float, paved: bool, + min_plan_radius_m: float = 0.0, ) -> "AlignmentPolicy": config = FOREST_ROAD_PROFILE_ALIGNMENT return cls( @@ -76,6 +81,8 @@ class AlignmentPolicy: max_grade_pct=float(max_grade_pct), curve_skip_delta_pct=float(curve_skip_delta_pct), paved=bool(paved), + min_plan_radius_m=float(min_plan_radius_m), + hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]), ) @classmethod @@ -140,6 +147,9 @@ class AlignmentPolicy: "max_grade_pct": self.max_grade_pct, "curve_skip_delta_pct": self.curve_skip_delta_pct, "paved": self.paved, + # 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06). + "min_plan_radius_m": self.min_plan_radius_m, + "hairpin_min_radius_m": self.hairpin_min_radius_m, } diff --git a/B05_Profile/B05_Profile_Engine_Grade_Profile.py b/B05_Profile/B05_Profile_Engine_Grade_Profile.py index fc7e51db..b5991a44 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Profile.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Profile.py @@ -30,6 +30,7 @@ from B05_Profile.B05_Profile_Engine_Grade import ( GradeDesignOptions, detect_main_direction, ground_profile, + legal_plan_radius_min_m, ) from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( ALIGNMENT_SCHEMA_VERSION, @@ -179,6 +180,8 @@ def design_ground_following_profile( max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, + # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) @@ -270,6 +273,8 @@ def design_pipe_anchored_profile( max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, + # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) @@ -385,6 +390,8 @@ def design_alignment_profile( max_grade_pct=options.max_grade_pct, curve_skip_delta_pct=options.vertical_curve_skip_delta_pct, paved=options.paved, + # 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개. + min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type), ) warnings = list(options.warnings) diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 33ca8bd3..491a76fc 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -100,6 +100,8 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]: "chainage_m": cross_section.get("chainage_m"), "center_z": cross_section.get("center_z"), "azimuth_deg": cross_section.get("azimuth_deg"), + # 평면 곡선반경 — 곡선부 확폭·최소곡선반지름 판정이 쓴다(2026-09-06). + "plan_radius_m": cross_section.get("plan_radius_m"), "sample_count": len(samples), "min_elevation_m": min(valid_z) if valid_z else None, "max_elevation_m": max(valid_z) if valid_z else None, diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 94b4eb5a..9c1274e9 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -11,13 +11,16 @@ from typing import Any import numpy as np +from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d from common_util.common_util_surface_sampler import SurfaceElevationSampler from config.config_system import ( + CURVE_WIDENING_TAPER_M, SECTION_CROSS_HALF_WIDTH_M, SECTION_CROSS_SAMPLE_INTERVAL_M, SECTION_INCLUDE_ENDPOINT, SECTION_LONG_SAMPLE_INTERVAL_M, SECTION_STATION_INTERVAL_M, + curve_widening_m, ) SECTION_SCHEMA_VERSION = 1 @@ -116,6 +119,100 @@ def _float_or_none(value: float) -> float | None: return round(float(value), 6) if math.isfinite(float(value)) else None +#: 평면 곡선반경을 재는 앞뒤 거리(m). 측점 간격(20m)의 절반이라 한 측점의 곡률을 +#: 이웃 측점에 번지지 않게 재고, 짧은 곡선도 놓치지 않는다. +PLAN_RADIUS_ARM_M = 10.0 +#: 이보다 크면 직선으로 본다(m). 별표2 확폭표가 45m 이상을 "확폭 없음"으로 두므로 +#: 그보다 넉넉한 값이면 판정에 영향이 없다. +PLAN_RADIUS_STRAIGHT_M = 10000.0 + + +def _plan_radii( + points: np.ndarray, + route_chainage: np.ndarray, + station_chainage: np.ndarray, + total: float, +) -> tuple[list[float | None], list[str | None]]: + """측점마다 (평면 곡선반경 m, 곡선 **바깥쪽**). 직선·측정 불가는 (None, None). + + 바깥쪽은 확폭이 붙는 쪽이다(2026-09-06 사용자 확정). 좌회전(반시계)이면 곡선 안쪽이 + 좌측이므로 바깥은 우측이다. offset 부호는 +가 좌측이라는 횡단 규약을 따른다. + """ + arm = min(PLAN_RADIUS_ARM_M, max(total / 2.0, 0.0)) + if arm < 0.5: + return [None] * len(station_chainage), [None] * len(station_chainage) + radii: list[float | None] = [] + outer_sides: list[str | None] = [] + for value in station_chainage: + center = float(value) + back = max(0.0, center - arm) + ahead = min(total, center + arm) + # 시·종점에서는 한쪽 팔이 짧아진다 — 양쪽이 다 확보될 때만 잰다. + if center - back < arm * 0.5 or ahead - center < arm * 0.5: + radii.append(None) + outer_sides.append(None) + continue + trio = _interpolate_xy(points, route_chainage, np.array([back, center, ahead])) + radius = circumradius_2d(trio[0], trio[1], trio[2]) + if not math.isfinite(radius) or radius >= PLAN_RADIUS_STRAIGHT_M: + radii.append(None) + outer_sides.append(None) + continue + radii.append(round(radius, 3)) + # 외적 z 부호로 회전 방향을 본다: 양수 = 좌회전 → 안쪽이 좌측 → 바깥은 우측. + first = trio[1] - trio[0] + second = trio[2] - trio[1] + cross = float(first[0] * second[1] - first[1] * second[0]) + outer_sides.append("right" if cross > 0 else "left" if cross < 0 else None) + return radii, outer_sides + + +def _curve_widenings( + station_chainage: np.ndarray, + radii: list[float | None], + outer_sides: list[str | None], +) -> tuple[list[float], list[str | None]]: + """측점별 확폭량(m)과 그것이 붙는 쪽 — 곡선 앞뒤 테이퍼까지 반영한다. + + 표(별표2 Ⅰ.2.나.(4))는 곡선 안에서의 확폭량만 준다. 곡선 시·종점에서 폭이 뚝 + 끊기면 안 되므로, 앞뒤 `CURVE_WIDENING_TAPER_M` 구간에서 0 → W 로 잇는다 + (2026-09-06 사용자 지시). 측점 간격이 테이퍼보다 넓으면 이 함수가 낼 중간값이 + 없고, 화면·3D 가 측점 사이를 이어 그리는 것으로 대신한다. + """ + base = [curve_widening_m(radius) for radius in radii] + widenings = list(base) + sides: list[str | None] = list(outer_sides) + # 확폭이 붙는 측점의 연속 덩어리(=곡선 구간)를 찾아 그 바깥으로 테이퍼를 편다. + runs: list[tuple[int, int]] = [] + start: int | None = None + for index, value in enumerate(base): + if value > 0.0 and outer_sides[index] in ("left", "right"): + if start is None: + start = index + elif start is not None: + runs.append((start, index - 1)) + start = None + if start is not None: + runs.append((start, len(base) - 1)) + + for first, last in runs: + for edge, step in ((first, -1), (last, 1)): + edge_chainage = float(station_chainage[edge]) + index = edge + step + while 0 <= index < len(base): + distance = abs(float(station_chainage[index]) - edge_chainage) + if distance > CURVE_WIDENING_TAPER_M or base[index] > 0.0: + break + ratio = max(0.0, 1.0 - distance / CURVE_WIDENING_TAPER_M) + tapered = round(base[edge] * ratio, 4) + # 양쪽 곡선 사이에 낀 측점은 넓은 쪽을 따른다. + if tapered > widenings[index]: + widenings[index] = tapered + sides[index] = outer_sides[edge] + index += step + return widenings, sides + + def generate_sections( polyline: np.ndarray | list[list[float]], sampler: SurfaceElevationSampler, @@ -167,6 +264,14 @@ def generate_sections( ] ) left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]]) + # 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반 + # 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세 + # 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다. + plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total) + # 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다. + plan_widenings, plan_outer_sides = _curve_widenings( + station_chainage, plan_radii, plan_outer_sides + ) offsets = np.arange( -options.cross_half_width_m, @@ -240,6 +345,12 @@ def generate_sections( "center_y": round(float(station_xy[index, 1]), 6), "center_z": _float_or_none(center_z), "azimuth_deg": round(azimuth, 6), + "plan_radius_m": plan_radii[index], + # 곡선 바깥쪽 — 곡선부 확폭이 붙는 쪽(2026-09-06 사용자 확정). + "curve_outer_side": plan_outer_sides[index], + # 확폭량(m) — 표값에 곡선 앞뒤 테이퍼를 얹은 값. 설계는 반경이 아니라 + # 이 값을 쓴다(테이퍼 측점은 반경이 없거나 커도 확폭이 남아 있다). + "curve_widening_m": plan_widenings[index], # 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다. "uphill_side": uphill_side, "frame": frame, diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 14539e1f..ac8d637e 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -90,13 +90,16 @@ async def sync_uphill_overrides_into_designs( 양성(both_fill)은 측구가 없으므로 건드리지 않는다. """ # 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다. - from B06_Section.B06_Section_Engine_Design import compute_cross_design - from B06_Section.B06_Section_Repository import ( - get_cross_section_designs, - update_cross_section_design, + from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args + from B06_Section.B06_Section_Repository import get_cross_section_designs + from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs + from B06_Section.B06_Section_Router_Design import ( + ford_drop_at, + ford_surface_drops, + pavement_suggestions, + read_cross_design_inputs, + resolve_longitudinal_path, ) - from B06_Section.B06_Section_Router import _read_cross_design_inputs - from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops if not overrides: return @@ -109,7 +112,8 @@ async def sync_uphill_overrides_into_designs( options = longitudinal["data"].get("options") if isinstance(options, dict): stored_standard = options.get("standard_cross_section") - ford_drops = ford_surface_drops(Path(project_root)) + # 다시 계산할 측점만 먼저 고른다 — 파일·계산은 아래에서 **스레드 한 번**에 몰아 한다. + jobs: list[tuple[float, dict[str, Any], str, str]] = [] for record in designs: chainage = round(float(record["chainage_m"]), 3) side = by_chainage.get(chainage) @@ -120,38 +124,55 @@ async def sync_uphill_overrides_into_designs( if mode == "both_fill": continue next_mode = f"{side}_cut" if mode in ("left_cut", "right_cut") else mode - next_ditch = side - if next_mode == mode and design.get("ditch_side") == next_ditch: + if next_mode == mode and design.get("ditch_side") == side: continue - samples, design_elevation, pavement_suggested = await asyncio.to_thread( - _read_cross_design_inputs, project_root, longitudinal_file_path, float(chainage) - ) - next_design = compute_cross_design( - samples, - design_elevation, - ground_type=str(design.get("ground_type", "soil")), - section_mode=str(next_mode), - ditch_side=next_ditch, - ditch_type=str(design.get("ditch_type", "standard")), - paved=bool(design.get("paved", False)), - standard=stored_standard, - rock_boundary_offset_m=design.get("rock_boundary_offset_m"), - two_stage_slope=bool(design.get("two_stage_slope", True)), - ditch_enabled=design.get("ditch_enabled"), - surface_drop_m=ford_drop_at(float(chainage), ford_drops), - ) - next_design["status"] = design.get("status", "provisional") - next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) - # 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다. - if design.get("display_half_width_m") is not None: - next_design["display_half_width_m"] = design["display_half_width_m"] - await update_cross_section_design( - connection, - route_id=route_id, - chainage_m=float(chainage), - design=next_design, - project_id=project_id, - ) + jobs.append((chainage, design, str(next_mode), side)) + if not jobs: + return + + def _recompute() -> list[tuple[float, dict[str, Any]]]: + """측점마다 종단 정본을 다시 열던 것을 한 번으로 줄인다(측점당 13.8ms 였다).""" + root = Path(project_root) + longitudinal_path = resolve_longitudinal_path(root, longitudinal_file_path) + longitudinal_json = json.loads(longitudinal_path.read_text(encoding="utf-8")) + preloaded = (longitudinal_path, longitudinal_json, pavement_suggestions(longitudinal_json)) + ford_drops = ford_surface_drops(root) + out: list[tuple[float, dict[str, Any]]] = [] + for chainage, design, next_mode, side in jobs: + samples, design_elevation, pavement_suggested, cross_record = read_cross_design_inputs( + root, longitudinal_file_path, float(chainage), preloaded + ) + next_design = compute_cross_design( + samples, + design_elevation, + ground_type=str(design.get("ground_type", "soil")), + section_mode=next_mode, + ditch_side=side, + ditch_type=str(design.get("ditch_type", "standard")), + paved=bool(design.get("paved", False)), + standard=stored_standard, + rock_boundary_offset_m=design.get("rock_boundary_offset_m"), + two_stage_slope=bool(design.get("two_stage_slope", True)), + ditch_enabled=design.get("ditch_enabled"), + surface_drop_m=ford_drop_at(float(chainage), ford_drops), + **curve_widening_args(cross_record), + ) + next_design["status"] = design.get("status", "provisional") + next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested) + # 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다. + if design.get("display_half_width_m") is not None: + next_design["display_half_width_m"] = design["display_half_width_m"] + out.append((float(chainage), next_design)) + return out + + # 쓰기도 한 문장으로 — 행마다 내면 원격 DB 왕복이 측점 수만큼 난다(건당 24.5ms). + await merge_cross_section_designs( + connection, + route_id=route_id, + entries=await asyncio.to_thread(_recompute), + replace=True, + project_id=project_id, + ) def _merge_irregular_into_longitudinal( diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py index 4da39233..97d46137 100644 --- a/B05_Profile/B05_Profile_Router_Corridor.py +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -9,10 +9,11 @@ import asyncio import json import logging +import re from pathlib import Path from uuid import UUID -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Query from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -64,9 +65,39 @@ async def _resolve_project_root(project_id: UUID) -> Path | None: return Path(resolve_stored_project_path(stored_path)) +# 저장본 머리에서 열쇠(해시)만 떼어 볼 만큼. 봉투 첫 두 칸이 `version`·`hash` 라 +# (`B05_Profile_UI_Corridor_Envelope.serialize`) 이 안에 반드시 들어온다. +_HASH_HEAD_BYTES = 4096 +_HASH_PATTERN = re.compile(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"') + + +async def _stored_hash(path: Path) -> str | None: + """저장본을 통째로 읽지 않고 머리 4KB 에서 열쇠만 뽑는다. 못 찾으면 None.""" + + def read_head() -> bytes: + with path.open("rb") as handle: + return handle.read(_HASH_HEAD_BYTES) + + found = _HASH_PATTERN.search(await asyncio.to_thread(read_head)) + return found.group(1).decode() if found else None + + @router.get("/{project_id}/routes/{route_id}/corridor", response_model=None) -async def get_corridor(project_id: UUID, route_id: int) -> Response: - """저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백).""" +async def get_corridor( + project_id: UUID, + route_id: int, + hash: str | None = Query( + None, + description="지금 정본의 열쇠. 저장본이 다르면 파일 대신 stale 만 돌려준다.", + max_length=64, + ), +) -> Response: + """저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백). + + `hash` 를 주면 **먼저 열쇠부터 맞춰 본다**(2026-09-06 실측). 저장본이 낡았으면 + 18.6MB 를 다 내려보낸 뒤 브라우저가 버리는 일이 벌어졌다 — 어긋날 때는 수십 바이트만 + 돌려주고 끝낸다. 맞으면 종전처럼 파일을 그대로 흘려보내므로 왕복은 여전히 한 번이다. + """ try: project_root = await _resolve_project_root(project_id) if project_root is None: @@ -79,6 +110,13 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response: status_code=404, content={"status": "error", "message": "저장된 코리도가 없습니다."}, ) + # `isinstance` 로 좁히는 이유 — 이 함수를 파이썬에서 곧바로 부르면 `hash` 에 `None` + # 대신 **`Query(...)` 기본값 객체**가 들어오고 그것이 truthy 라, 열쇠를 안 준 호출이 + # 통째로 `stale` 로 떨어졌다(2026-09-06 보조 창 지적). HTTP 경로만 보면 안 드러난다. + if isinstance(hash, str) and hash: + stored = await _stored_hash(path) + if stored is not None and stored != hash: + return JSONResponse(content={"status": "stale", "hash": stored}) payload = await asyncio.to_thread(path.read_bytes) return Response(content=payload, media_type="application/json") except OSError as exc: diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py new file mode 100644 index 00000000..cfbe16d5 --- /dev/null +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -0,0 +1,541 @@ +"""계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫. + +노선은 **세 벌**이다(2026-09-06 사용자 확정 → 같은 날 정정, PLAN 0-7). + + - **예상노선**(원본) — 파일 업로드 자동 체인이 낸 것. `B05_Profile/route/expected_route.csv`. + ⚠ 이것은 폴리라인이 아니라 **점 묶음**이고 규칙 없는 폴리라인과도 맞지 않는다 + (2026-09-06 사용자 확인). 어떤 경로로도 고치지 않는다. + - **계획노선 초기본** — 위를 복사해 **폴리라인으로 바꾼 것**. `planned_route_initial.csv`. + **불변의 초기 데이터**이며, 유토곡선·3D 에 투영되는 선도 이것이다. 곡선은 지식DB + 기준(별표2 Ⅰ.2.다 — 설계속도·지형별 최소곡선반지름, 내각 155° 이상은 생략)으로 끼운다. + - **계획노선**(수정본) — 초기본에서 시작해 사용자가 **노드를 잡아** 고친 것. + `planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다(`load_design_route`). + +노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을 +수정본으로 복사」와 결과가 같다. + +[확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다 +(2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관 +정착 계획선 재산출 → 종횡단 재생성 → 옛 측점 설계를 누가거리로 이월 → B05·B06 확정까지 한 +줄로 돈다. 여기서 하는 일은 그 앞에 **노선을 갈아 끼우는 것**뿐이다. + +시설 처리(2026-09-06 사용자 확정) — 횡단배수 지점은 노선 자리에 따라 생기고 없어지므로 +저장분을 버리고 **새로 계산한 값**을 쓴다. 그 밖의 구조물은 **옛 측점값을 그대로** 이어받는다 +(`structures.json`은 프로젝트 정본이라 손대지 않으면 그대로 남는다). + + GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) + POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 + POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 +""" + +import asyncio +import csv +import functools +import json +import logging +import time +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_initial_snapshot import design_route_csv_path +from common_util.common_util_route_geometry import ( + densify_route, + expected_route_csv_path, + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, + write_route_csv, +) +from common_util.common_util_route_polyline import build_planned_polyline +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from config.config_system import ( + ROUTE_DIRECT_LINK_CELL_FACTOR, + ROUTE_GRID_RES_M, + ROUTE_PLANNED_DENSIFY_SAFETY, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) + +_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."} + + +class RouteVertexInput(BaseModel): + """계획노선 꺾임점 하나 — 사업지 좌표계(m). + + 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — + · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. + · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. + · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. + """ + + x: float + y: float + curve: bool = True + """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" + radius_m: float | None = None + """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" + + +class RouteReplanRequest(BaseModel): + """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" + + vertices: list[RouteVertexInput] = Field(default_factory=list) + + +async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: + """(프로젝트 실경로, 저장소 상대경로). 없으면 None.""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + if not stored: + return None + return Path(resolve_stored_project_path(stored)), str(stored) + + +def _vertices_of(path: Path) -> list[list[float]]: + """CSV 정점을 [[x, y], …]로. 없거나 못 읽으면 빈 목록.""" + if not path.is_file(): + return [] + route = read_planned_route_csv(path) + if route is None: + return [] + return [[float(vertex.x), float(vertex.y)] for vertex in route.vertices] + + +def _ensure_expected_route(project_root: Path) -> str: + """예상노선(원본) 정본이 없으면 만들어 둔다. 어디서 씨앗을 얻었는지 돌려준다. + + 새 프로젝트는 자동 체인이 만들어 두지만(2026-09-06), 그 전에 만들어진 프로젝트는 + 초기값 스냅샷 안에만 있거나 그마저 재확정 체인이 지운 뒤일 수 있다. 노선 초기화가 + 성립하려면 이 파일이 반드시 있어야 하므로 여기서 한 번 세워 둔다. + """ + target = expected_route_csv_path(project_root) + if target.is_file(): + return "already" + for source, label in ( + (design_route_csv_path(project_root), "snapshot"), + (planned_route_working_path(project_root), "working"), + ): + vertices = _vertices_of(source) + if len(vertices) >= 2: + write_route_csv(target, [{"x": x, "y": y} for x, y in vertices]) + logger.info("예상노선 정본을 %s 에서 세웠습니다: %s", label, target) + return label + return "none" + + +async def _min_plan_radius_m(project_id: UUID) -> float: + """이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다. + + 값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미 + `B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다. + + 읽는 자리 — 임도 종류는 `projects.road_type`, 설계속도·지형은 **워크플로 stage 2 params** + (노선 풀기 요청이 남긴 값)다. `projects` 에는 설계속도·지형 칸이 없다(2026-09-06 확인: + 있는 것은 `road_type` 뿐). 못 읽으면 가장 완화된 조건으로 떨어진다 — 막지 않고 위반 + 표시만 하기 때문이다. + """ + import aiomysql + + from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed + from common_util.common_util_workflow_state import get_workflow_state + + grade_class, design_speed, terrain = "work", None, "special" + try: + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT road_type FROM projects WHERE id = %s", (str(project_id),) + ) + row = await cursor.fetchone() + if row and row[0]: + grade_class = str(row[0]) + async with connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, str(project_id)) + stage2 = next( + (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None + ) + params = (stage2 or {}).get("params") or {} + speed = params.get("design_speed_kph") + if isinstance(speed, (int, float)): + design_speed = int(speed) + if params.get("terrain_type") in ("normal", "special"): + terrain = str(params["terrain_type"]) + except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다 + logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id) + return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain) + + +def _nodes_path(path: Path) -> Path: + """그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`.""" + return path.with_name(f"{path.stem}_nodes.csv") + + +def _curves_path(path: Path) -> Path: + """그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`. + + 왜 따로 남기나(2026-09-07 사용자 확정) — 계획노선은 「직선 > 곡선 > 직선」이고 사용자가 + 잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터 + 어디까지가 한 곡선인지, 그 반지름이 얼마인지 알 수 없어 편집·도면이 같은 값을 못 본다. + """ + return path.with_name(f"{path.stem}_curves.json") + + +def _read_curves(path: Path) -> list[dict]: + """저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트).""" + target = _curves_path(path) + if not target.is_file(): + return [] + try: + data = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target) + return [] + curves = data.get("curves") if isinstance(data, dict) else None + return curves if isinstance(curves, list) else [] + + +def _write_planned_polyline( + path: Path, + points: list[tuple[float, float]], + radius_m: float, + *, + simplify: bool = True, + curve_flags: list[bool] | None = None, + radii: list[float | None] | None = None, +) -> dict: + """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. + + **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 + 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 + 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. + """ + result = build_planned_polyline( + points, + min_radius_m=radius_m, + simplify=simplify, + curve_flags=curve_flags, + radii=radii, + ) + write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) + write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) + _curves_path(path).write_text( + json.dumps( + {"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]}, + ensure_ascii=False, + ), + encoding="utf-8", + ) + return { + "nodes": len(result.nodes), + "curves": result.curve_count, + "violations": result.violation_count, + "vertices": len(result.vertices), + } + + +def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None: + """계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다. + + 「낡았다」 = 예상노선 파일이 더 나중에 쓰였다. 파일을 다시 올리면 예상노선이 새로 + 깔리는데 초기본이 옛 노선인 채로 남으면 노선 초기화가 옛 자리로 돌아간다. + """ + target = planned_route_initial_path(project_root) + source = expected_route_csv_path(project_root) + if target.is_file(): + if not source.is_file() or source.stat().st_mtime <= target.stat().st_mtime: + return None + logger.info("예상노선이 새로 깔려 초기 폴리라인을 다시 만듭니다: %s", target) + points = [(x, y) for x, y in _vertices_of(source)] + if len(points) < 2: + return None + summary = _write_planned_polyline(target, points, radius_m) + logger.info( + "계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)", + target, + summary["nodes"], + summary["curves"], + summary["violations"], + summary["vertices"], + ) + return summary + + +def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int: + """계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것. + + 쓰기 전에 **조밀화**한다 — 정점이 성기면 B05 격자 탐색이 원좌표를 그대로 잇지 못하고 + 제 나름의 길을 찾아 사용자가 그린 선과 달라진다(`densify_route` 주석). 평면 형상은 + 바뀌지 않고 같은 선 위에 점만 더 찍힌다. + """ + dense = densify_route( + vertices, + ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY, + ) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file) + writer.writerow(("sequence", "x", "y")) + writer.writerows((index, round(x, 4), round(y, 4)) for index, (x, y) in enumerate(dense)) + return len(dense) + + +async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> dict[str, Any]: + """노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다.""" + import aiomysql + + from B03_FileInput.B03_FileInput_Service_Chain import ( + _log_steps, + _planned_route_points_in_project_crs, + run_redesign_chain, + ) + from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection + from B05_Profile.B05_Profile_Repository import get_latest_route + from common_util.common_util_drainage_pipes import clear_pipe_points + from common_util.common_util_surface_confirmation import get_surface_confirmation_params + from common_util.common_util_workflow_state import get_workflow_state, start_stage + + marks = [("시작", time.perf_counter())] + + # 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다. + await asyncio.to_thread(clear_pipe_points, stored_path) + + pool = get_db_pool() + async with pool.acquire() as connection: + selection = await get_surface_confirmation_params(connection, str(project_id)) + latest = await get_latest_route(connection, project_id) + # 지금 노선이 쓰던 지표면 모델이 1순위다 — 확정 선택값으로 다시 찾으면 프로젝트마다 + # 모델 조합이 달라 못 찾는 경우가 있다(용화: filter=classification/method=dtm 무매칭). + surface_model_id = ( + int(latest["surface_model_id"]) if latest and latest.get("surface_model_id") else None + ) + if surface_model_id is None: + try: + surface_model_id = await find_surface_model_for_selection( + connection, project_id, selection + ) + except Exception: + surface_model_id = None + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT route_start_m, route_end_m FROM projects WHERE id = %s", + (str(project_id),), + ) + range_row = await cursor.fetchone() + if surface_model_id is None: + return {"error": "확정된 지표면 모델을 찾지 못해 노선을 다시 계산할 수 없습니다."} + route_range = (range_row[0], range_row[1]) if range_row else None + + # 갈아 끼운 노선을 stage 2 제어점으로 세운다 — 재확정 체인이 여기서 BP·EP·경유점을 + # 읽어 경로를 다시 푼다. 읽기는 설계 계통과 같은 한 곳(`load_design_route`)을 지난다. + points = await asyncio.to_thread( + _planned_route_points_in_project_crs, project_root, selection, route_range + ) + if not points or len(points) < 2: + return {"error": "계획노선을 읽지 못했습니다(정점이 2개 미만)."} + + # 수정본도 예상노선 정본도 없던 프로젝트 — 지금 읽은 것이 곧 예상노선이므로 여기서 세운다. + expected_path = expected_route_csv_path(project_root) + if not expected_path.is_file() and not planned_route_working_path(project_root).is_file(): + await asyncio.to_thread(write_route_csv, expected_path, points) + logger.info("예상노선 정본을 원본 재판독으로 세웠습니다: %s", expected_path) + + pool = get_db_pool() + async with pool.acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + state = await get_workflow_state(cursor, str(project_id)) + stage2 = next( + (s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None + ) + params = dict((stage2 or {}).get("params") or {}) + params["points"] = { + "bp": points[0], + "ep": points[-1], + "cp": [{**point, "order": index} for index, point in enumerate(points[1:-1], start=1)], + } + async with connection.cursor(aiomysql.DictCursor) as cursor: + await start_stage(cursor, str(project_id), 2, params) + await connection.commit() + + marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter())) + + failure = await run_redesign_chain(project_id, int(surface_model_id), selection) + marks.append(("재확정 체인", time.perf_counter())) + _log_steps("노선 [확인] 재계산", marks) + if failure: + # 체인이 끊기면 **노선만 새것으로 갈아 끼워진 채** 종횡단은 옛 노선에 남아 어긋난다 + # (2026-09-06 실측: 배수유역·관은 새 노선, 종횡단은 옛 노선 → 배수관 측점 9 → 0). + # 부르는 쪽이 계획노선을 되돌리도록 사유를 올려 보낸다. + return {"error": failure} + + pool = get_db_pool() + async with pool.acquire() as connection: + made = await get_latest_route(connection, project_id) + return { + "route_id": int(made["id"]) if made else None, + "total_length_m": float(made.get("total_length_m") or 0.0) if made else None, + "vertex_count": len(points), + } + + +@router.get("/{project_id}/route/plan", response_model=None) +async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: + """예상노선(점 묶음)·계획노선(폴리라인)·편집할 노드를 함께 돌려준다. + + 화면이 그리는 것은 셋이다 — 예상노선은 **점선**, 계획노선은 **실선**, 그리고 사용자가 + 잡아 옮기는 **노드**(꺾임점). 노드에는 그 자리에 끼운 반지름·내각·법정 위반 표시가 + 붙어 있어 화면이 그대로 보여 줄 수 있다(2026-09-06 사용자 지시). + """ + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, _ = paths + # 예상노선 정본이 먼저, 없으면 옛 자리(초기값 스냅샷)를 본다. + expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root)) + if not expected: + expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root)) + + radius_m = await _min_plan_radius_m(project_id) + await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) + working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root)) + initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root)) + planned = working or initial or expected + + # 노드는 **저장해 둔 것을 그대로** 쓴다 — 폴리라인에서 되뽑으면 안 된다. 정점에 원호 + # 위 점이 섞여 있어 다시 단순화하면 꺾임점이 지워지고, 그 결과로 만든 폴리라인을 또 + # 단순화하게 되어 [확인]마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). + saved_nodes = await asyncio.to_thread( + _vertices_of, + _nodes_path( + planned_route_working_path(project_root) + if working + else planned_route_initial_path(project_root) + ), + ) + node_source = saved_nodes or (working or expected) + saved_curves = await asyncio.to_thread( + _read_curves, + planned_route_working_path(project_root) + if working + else planned_route_initial_path(project_root), + ) + outline = await asyncio.to_thread( + build_planned_polyline, + [(x, y) for x, y in node_source], + min_radius_m=radius_m, + # 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다. + simplify=not saved_nodes, + ) + return { + "status": "success", + "project_id": str(project_id), + "expected": expected, + "planned": planned, + "nodes": [node.as_dict() for node in outline.nodes], + # 곡선 성분 — 화면이 **곡선 시작·끝점**을 손잡이로 그리고 반지름 칸을 띄우는 재료다 + # (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다. + "curves": saved_curves or [curve.as_dict() for curve in outline.curves], + "min_radius_m": round(radius_m, 2), + "curve_count": len(saved_curves) if saved_curves else outline.curve_count, + "violation_count": outline.violation_count, + "edited": bool(working), + } + + +@router.post("/{project_id}/route/replan", response_model=None) +async def replan_route( + project_id: UUID, request: RouteReplanRequest +) -> dict[str, Any] | JSONResponse: + """고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다(모달 [확인]).""" + if len(request.vertices) < 2: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "계획노선은 정점이 2개 이상이어야 합니다."}, + ) + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, stored_path = paths + + # 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다. + await asyncio.to_thread(_ensure_expected_route, project_root) + radius_m = await _min_plan_radius_m(project_id) + await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) + # 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다. + # 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자). + # 받은 것은 이미 꺾임점이므로 **다시 뽑지 않는다** — 뽑으면 사용자가 둔 노드가 지워진다. + nodes = [(vertex.x, vertex.y) for vertex in request.vertices] + curve_flags = [bool(vertex.curve) for vertex in request.vertices] + radii = [vertex.radius_m for vertex in request.vertices] + # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 + # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). + working_path = planned_route_working_path(project_root) + previous = working_path.read_bytes() if working_path.is_file() else None + summary = await asyncio.to_thread( + functools.partial( + _write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii + ), + working_path, + nodes, + radius_m, + ) + written = summary["vertices"] + logger.info( + "계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)", + project_id, + len(nodes), + written, + summary["curves"], + summary["violations"], + radius_m, + ) + result = await _recompute(project_id, project_root, stored_path) + if "error" in result: + # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. + if previous is None: + working_path.unlink(missing_ok=True) + else: + working_path.write_bytes(previous) + logger.warning( + "계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s", + project_id, + result["error"], + ) + return JSONResponse( + status_code=409, content={"status": "error", "message": result["error"]} + ) + return {"status": "success", "project_id": str(project_id), **result} + + +@router.post("/{project_id}/route/replan/reset", response_model=None) +async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: + """계획노선을 예상노선으로 되돌리고 다시 계산한다(노선 초기화). + + 수정본 파일을 지우면 설계 계통이 원본을 읽으므로 「원본을 수정본으로 복사」와 같다. + """ + paths = await _project_paths(project_id) + if paths is None: + return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING) + project_root, stored_path = paths + await asyncio.to_thread(_ensure_expected_route, project_root) + radius_m = await _min_plan_radius_m(project_id) + await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m) + working_path = planned_route_working_path(project_root) + if working_path.is_file(): + working_path.unlink() + logger.info("계획노선 초기화(초기 폴리라인으로): project_id=%s", project_id) + result = await _recompute(project_id, project_root, stored_path) + if "error" in result: + return JSONResponse( + status_code=409, content={"status": "error", "message": result["error"]} + ) + return {"status": "success", "project_id": str(project_id), **result} diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0ccfff61..d2790ec5 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -67,7 +67,10 @@ class RouteSolveRequest(BaseModel): method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)") smooth: bool = Field(default=False) surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id") - algorithm: str = Field(default="dijkstra", description="경로 알고리즘 (dijkstra/ridge_valley)") + algorithm: str = Field( + default="dijkstra", + description="경로 알고리즘 (dijkstra/ridge_valley/as_planned)", + ) bp: RoutePoint ep: RoutePoint @@ -116,7 +119,8 @@ class RouteSolveRequest(BaseModel): def validate_choices(self) -> "RouteSolveRequest": if self.grade_class not in ROUTE_GRADE_CLASSES: raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.") - if self.algorithm not in ("dijkstra", "ridge_valley"): + # `as_planned` = 사용자가 고친 계획노선을 그대로 씀(PLAN 0-3 자동탐색 접기). + if self.algorithm not in ("dijkstra", "ridge_valley", "as_planned"): raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.") if self.design_speed_kph is not None and self.design_speed_kph not in (20, 30, 40): raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.") diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 3cd0db4e..f6862a92 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -448,6 +448,7 @@ "enabled": false, "group": "B", "name": "측구(옆도랑)", + "design_owner": "횡단 설계", "placement": "interval", "style": { "color": "#2eaadc", @@ -639,6 +640,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -697,6 +705,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -755,6 +770,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -813,6 +835,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, @@ -871,6 +900,13 @@ "default": null, "required": true, "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["자동(성토 쪽)", "좌", "우"], + "default": "자동(성토 쪽)" } ] }, diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index fc59be63..d570578e 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -44,6 +44,10 @@ class StructureOptionField(BaseModel): # (2026-08-17 사용자 확정). `detail`이면 required여도 B05 저장에서 강제하지 않는다 # — 필수 원칙은 유지되고 강제 시점만 B06/B07로 미뤄진다. phase: Literal["b05", "detail"] = "b05" + # 폼에 칸은 두되 **지금은 못 고르게** 할 때 거짓으로 둔다 — 회색으로 그려지고 값은 + # 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에 + # 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다. + enabled: bool = True class StructureType(BaseModel): @@ -61,6 +65,14 @@ class StructureType(BaseModel): drawing_views: list[str] = Field(default_factory=list) # 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다. managed_by: str | None = None + # 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다 + # (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고, + # ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다. + # + # 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로 + # (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면 + # **같은 것을 두 번 계상**한다(2026-09-07 조사). + design_owner: str | None = None # 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다. reference_only: bool = False enabled: bool = True diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 8e96f937..7ce3ea85 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -14,6 +14,11 @@ * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { + purgeAssetsWithPrefix, + readCachedBytes, + writeCachedBytes, +} from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; @@ -58,18 +63,55 @@ async function requestCorridor(path: string, init: RequestInit): Promise { +/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */ +type StoredLookup = + { kind: "envelope"; envelope: CorridorEnvelope } | { kind: "stale" } | { kind: "missing" }; + +/** + * 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면 + * 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤 + * 해시가 다르다고 버렸다. + */ +/** 이 노선 코리도의 보관 주소 앞머리 — 열쇠만 다른 옛 보관본을 치울 때 쓴다. */ +function corridorUrlPrefix(projectId: string, routeId: number): string { + return `${API_BASE_URL}/projects/${projectId}/routes/${routeId}/corridor`; +} + +async function fetchStored( + projectId: string, + routeId: number, + hash: string, +): Promise { + const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`; try { - const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, { - method: "GET", - }); - if (!response.ok) return null; - const payload = (await response.json()) as CorridorEnvelope; - return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons) - ? payload - : null; + // **브라우저 보관함을 먼저 본다**(2026-09-06) — 저장본이 최신이면 열쇠가 맞아 매번 + // 17MB 를 받았고, 새로고침 한 번이면 메모리 캐시가 날아가 또 받았다. 주소에 열쇠가 + // 박혀 있으므로 정본이 바뀌면 저절로 다른 주소가 되어 옛 보관본을 안 쓴다. + const cached = await readCachedBytes(projectId, url); + if (cached) { + const payload = JSON.parse(new TextDecoder().decode(cached)) as CorridorEnvelope; + if (payload?.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { + return { kind: "envelope", envelope: payload }; + } + } + const response = await requestCorridor( + `/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`, + { method: "GET" }, + ); + if (!response.ok) return { kind: "missing" }; + const text = await response.text(); + const payload = JSON.parse(text) as CorridorEnvelope & { status?: string }; + if (payload?.status === "stale") return { kind: "stale" }; + if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { + // 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게. + void purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)).then(() => + writeCachedBytes(projectId, url, new TextEncoder().encode(text).buffer as ArrayBuffer), + ); + return { kind: "envelope", envelope: payload }; + } + return { kind: "missing" }; } catch { - return null; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. + return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. } } @@ -120,16 +162,19 @@ export async function ensureCorridor( detail: SectionDetailResponse, routePoints: RoutePoint[], designSamples?: ProfileSamples, + /** 저장본이 낡았을 때 **브라우저가 다시 만들지** 여부. 진입 경로는 false 로 부른다 — + * 3D 는 [3D 업데이트]로만 도는 수동 조작인데 진입만 자동으로 남아 있었다(2026-09-06). */ + rebuild = true, ): Promise { const key = keyOf(projectId, routeId); const hash = corridorHash(detail, routePoints); const cached = cache.get(key); if (cached && cached.hash === hash) return cached.build; - const stored = await fetchStored(projectId, routeId); - if (stored && stored.hash === hash) { + const lookup = await fetchStored(projectId, routeId, hash); + if (lookup.kind === "envelope" && lookup.envelope.hash === hash) { try { - const build = deserialize(stored); + const build = deserialize(lookup.envelope); cache.set(key, { hash, build, dirty: false }); markSource("stored", hash); return build; @@ -137,6 +182,12 @@ export async function ensureCorridor( // 손상 저장본 — 빌드로 폴백. } } + // 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아 + // 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫). + if (!rebuild) return null; + // 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이 + // 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다. + const hasStored = lookup.kind !== "missing"; // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 @@ -151,7 +202,7 @@ export async function ensureCorridor( return null; } markSource("built", hash); - if (stored === null) { + if (!hasStored) { // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). cache.set(key, { hash, build, dirty: false }); void putStored(projectId, routeId, serialize(build, hash)).then((ok) => { @@ -173,8 +224,16 @@ function markDirty(projectId: string, routeId: number): void { export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise { const entry = cache.get(keyOf(projectId, routeId)); if (!entry || !entry.dirty) return; - const ok = await putStored(projectId, routeId, serialize(entry.build, entry.hash)); - if (ok) entry.dirty = false; + const envelope = serialize(entry.build, entry.hash); + const ok = await putStored(projectId, routeId, envelope); + if (!ok) return; + entry.dirty = false; + // 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가 + // 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다. + const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`; + const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer; + await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)); + await writeCachedBytes(projectId, url, bytes); } /** Page 훅 — 현재 종횡단 정본 그대로 코리도를 확보해 뷰어에 반영(실패 시 제거). @@ -186,9 +245,32 @@ export function refreshCorridor( detail: SectionDetailResponse, routePoints: RoutePoint[], designSamples?: ProfileSamples, + rebuild = true, ): Promise { if (!routeId) return Promise.resolve(); - return ensureCorridor(projectId, routeId, detail, routePoints, designSamples) + return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) .then((build) => viewer.setCorridor(build)) .catch(() => viewer.setCorridor(null)); } + +/** + * 진입 전용 — 저장본이 **그대로 맞을 때만** 3D 에 올린다. 낡았으면 받지도 만들지도 않고 + * `false` 를 돌려주므로, 부르는 쪽이 [3D 업데이트] 대기 표시를 켜면 된다(2026-09-06). + * 돌아온 값이 곧 「지금 화면의 3D 가 최신인가」다. + */ +export function loadCorridorIfFresh( + viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, + projectId: string, + routeId: number | undefined, + detail: SectionDetailResponse, + routePoints: RoutePoint[], + designSamples?: ProfileSamples, +): Promise { + if (!routeId) return Promise.resolve(false); + return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) + .then((build) => { + if (build) viewer.setCorridor(build); + return build !== null; + }) + .catch(() => false); +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 75f865cb..a4c7eb77 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -1,4 +1,5 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { computeDetailBasins, fetchDetailPipePoints, @@ -27,6 +28,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples"; import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; +import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; @@ -39,7 +41,6 @@ import { fitViewToRoute, observeViewportSize, bindPipeContextMenu, - COLLAPSED_KEY, MAX_PANEL_WIDTH_RATIO, MIN_PANEL_WIDTH, renderBasinRows, @@ -129,7 +130,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra scheduleDraw(); }, // 배치가 실제로 바뀐 순간(추가·삭제·이동 완료)에만 세부유역을 다시 나눈다. - () => void analyze(), + // 같은 순간에 **세션 초안**에도 담는다 — 예전에는 패널 메모리에만 있어 B06 으로 + // 넘어가 저장하면 편집이 사라졌다(2026-09-06 대응표 조사). + () => { + rememberPipes(); + void analyze(); + }, ); // 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다 // (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시). @@ -389,6 +395,20 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra } } + /** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */ + function rememberPipes(): void { + if (!projectId) return; + writePendingPipes( + projectId, + facilityStore.attach( + pipeEditor.pipes().map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: (pipe.reason || "user") as PipeSource, + })), + ), + ); + } + /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */ const loadSaved = (): Promise => run(() => fetchDetailPipePoints(projectId as string)); @@ -526,7 +546,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra function setCollapsed(collapsed: boolean): void { root.classList.toggle("is-collapsed", collapsed); panelHandle.setOpen(!collapsed); - sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + writeStateRaw("drainage-collapsed", String(collapsed)); if (!collapsed) scheduleDraw(); } panelHandle.root.addEventListener("click", () => @@ -534,7 +554,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); // 상세 배수유역 정보는 페이지에 들어오면 바로 보여야 한다 — 저장값이 없으면 펼침이 기본이다 // (같은 페이지의 하단 종단 패널과 같은 규칙). - setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true"); + setCollapsed(readStateRaw("drainage-collapsed") === "true"); return { root, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index e64ed302..d246c7fd 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -6,6 +6,7 @@ * 분리한 것으로, 여기 있는 것들은 패널의 내부 상태를 알지 못한다 — 전부 인자로 받는다. * ========================================================================== */ +import { stateKey } from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { themeColor } from "@ui/ui_template_palette"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; @@ -52,9 +53,10 @@ export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4", /** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */ export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b"); -export const COLLAPSED_KEY = "b05-route-drainage-collapsed"; +/** 접힘·폭은 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ +export const COLLAPSED_KEY = stateKey("drainage-collapsed") ?? ""; /** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ -export const WIDTH_KEY = "b05-route-drainage-width"; +export const WIDTH_KEY = stateKey("drainage-width") ?? ""; /** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */ export const MIN_PANEL_WIDTH = 320; /** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */ diff --git a/B05_Profile/B05_Profile_UI_Markers.ts b/B05_Profile/B05_Profile_UI_Markers.ts index 7e304fa5..f115c28c 100644 --- a/B05_Profile/B05_Profile_UI_Markers.ts +++ b/B05_Profile/B05_Profile_UI_Markers.ts @@ -95,6 +95,14 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) { return { x: point.x + cx, y: -point.z + cy, z: point.y + cz }; } +/** 노선 선을 지형에 붙일 때 정점 사이를 나누는 간격(m) — 2m 면 20m 측점 사이가 10토막이라 + * 능선을 가로질러도 선이 지면 아래로 잠기지 않는다(2026-09-06). */ +const DRAPE_STEP_M = 2; +/** 한 구간을 나누는 최대 토막 수 — 정점이 아주 멀리 떨어진 자료에서 점이 폭주하지 않게 한다. */ +const MAX_DRAPE_STEPS = 64; +/** 노선 선을 지형 위로 띄우는 높이(m) — 지형과 겹쳐 깜빡이지 않을 만큼만. */ +const ROUTE_LINE_LIFT_M = 0.35; + export function createRouteMarkers( scene: THREE.Scene, getBounds: () => ModelBounds | null, @@ -254,17 +262,40 @@ export function createRouteMarkers( disposeGroup(routeGroup); const bounds = getBounds(); if (!bounds || polyline.length < 2) return; + // 정점 사이를 잘게 나눠 **각 점의 지형고를 찍어** 선을 지면에 붙인다(2026-09-06). + // 원래는 정점(간격 중앙값 10m)을 곧바로 이었는데, 볼록한 능선에서는 그 10m 직선이 + // 지면을 뚫고 들어가 선이 땅속에 잠겼다 — 화면에는 폴리라인이 끊긴 것처럼 보였다 + // (실측: 161+0.0~163+0.0 구간이 통째로 사라짐). 원래 정점은 모두 남기므로 평면 + // 형상은 바뀌지 않는다. + const dense: Array<{ x: number; y: number; z?: number }> = []; + /** 원래 정점 i 가 조밀화 뒤 몇 번째인지 — 경고 구간이 인덱스로 자르므로 필요하다. */ + const denseIndex: number[] = []; + polyline.forEach((point, index) => { + if (index > 0) { + const previous = polyline[index - 1]; + const span = Math.hypot(point.x - previous.x, point.y - previous.y); + const steps = Math.min(MAX_DRAPE_STEPS, Math.ceil(span / DRAPE_STEP_M)); + for (let step = 1; step < steps; step += 1) { + const ratio = step / steps; + dense.push({ + x: previous.x + (point.x - previous.x) * ratio, + y: previous.y + (point.y - previous.y) * ratio, + }); + } + } + denseIndex[index] = dense.length; + dense.push(point); + }); // 표고 없는 점을 0으로 삼키면 마커와 같은 함정에 빠진다(지형 한참 아래 평면에 눕는다). - // 지형 표면에서 찾아 채우고, 그래도 모르면 직전 점 높이를 이어 쓴다. 경고 구간이 - // 인덱스로 이 배열을 다시 자르므로 점 개수는 그대로 두어야 한다. + // 지형 표면에서 찾아 채우고, 그래도 모르면 직전 점 높이를 이어 쓴다. 끼워 넣은 점과 + // 원래 정점이 **같은 지형면**에서 높이를 받아야 선이 매끄러우므로 지형고를 먼저 본다. let lastZ: number | null = null; - const linePoints = polyline.map((point) => { - const resolved = Number.isFinite(point.z) - ? (point.z as number) - : (getTerrainZ?.(point.x, point.y) ?? lastZ); + const linePoints = dense.map((point) => { + const sampled = getTerrainZ?.(point.x, point.y) ?? null; + const resolved = sampled ?? (Number.isFinite(point.z) ? (point.z as number) : lastZ); if (resolved !== null) lastZ = resolved; return modelToScene({ x: point.x, y: point.y, z: resolved ?? bounds.z[0] }, bounds).add( - new THREE.Vector3(0, 0.35, 0), + new THREE.Vector3(0, ROUTE_LINE_LIFT_M, 0), ); }); routeGroup.add( @@ -274,10 +305,10 @@ export function createRouteMarkers( ), ); warnings.forEach((warning) => { - const segment = linePoints.slice( - Math.max(0, warning.polyline_start_index), - warning.polyline_end_index + 1, - ); + // 경고 구간의 인덱스는 **원래 정점 기준**이라 조밀화 뒤 자리로 옮겨 자른다. + const from = denseIndex[Math.max(0, warning.polyline_start_index)] ?? 0; + const to = denseIndex[Math.min(warning.polyline_end_index, polyline.length - 1)]; + const segment = linePoints.slice(from, (to ?? linePoints.length - 1) + 1); if (segment.length > 1) { routeGroup.add( new THREE.Line( diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index e35323b4..bdc2df3b 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -1,3 +1,4 @@ +import { dropStationsNear } from "./B05_Profile_Util_Station"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { showToast } from "@ui/ui_template_elements"; @@ -12,8 +13,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { fetchConfirmedSurface, - listSurfaceModels, - type SurfaceModelSummary, + type SurfaceConfirmedResponse, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchLatestRoute, @@ -26,6 +26,7 @@ import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel" import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { navigateTo } from "../A00_Common/router"; +import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit"; import { createSelectionSync } from "./B05_Profile_UI_Selection"; import { restoreStructurePick, @@ -45,7 +46,11 @@ import { } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations } from "./B05_Profile_Api_Structures"; -import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; +import { + loadCorridorIfFresh, + refreshCorridor, + saveCorridorIfDirty, +} from "./B05_Profile_UI_Corridor"; import { resetDesignAction, solveRouteAction, @@ -72,6 +77,9 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ +const migratedRoutes = new Set(); + export async function renderB05Route(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) { @@ -143,6 +151,9 @@ export async function renderB05Route(root: HTMLElement): Promise { // 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설 // (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17). onStructureSelect: (structureId) => { + // 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만 + // 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06). + writeStructurePick(activeProjectId, bridge.markChainage(structureId)); const chainage = bridge.pipeMarkChainage(structureId); if (chainage !== null) { panel.structures.selectPipeByChainage(chainage); @@ -168,7 +179,10 @@ export async function renderB05Route(root: HTMLElement): Promise { * 선택은 3D·그래프·사이드바가 서로를 갱신하므로, `selectionSyncing` 가드로 재진입을 막아 * 무한 재귀(스택 오버플로우·프리즈)를 방지한다. */ - let confirmedSurface: SurfaceModelSummary | null = null; + /** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). + * 예전에는 목록(`listSurfaceModels`)으로 id 를 찾고 3D 단계에서 `surface/confirmed` + * 를 또 불러 요청이 두 번 나갔다. 확정 응답 하나에 둘 다 들어 있다. */ + let confirmedSurface: SurfaceConfirmedResponse | null = null; let latest: RouteLatestResponse | null = null; let roadWidths = DEFAULT_ROAD_WIDTHS; let currentSectionDetail: SectionDetailResponse | null = null; @@ -233,8 +247,43 @@ export async function renderB05Route(root: HTMLElement): Promise { return fresh; } + /** 이 응답이 "같은 자료"인지 가리는 서명 — 노선 번호·지표면 모델·지표면 설정. */ + const latestSignature = (value: RouteLatestResponse): string => + [ + value.route?.id ?? "", + value.route?.surface_model_id ?? "", + value.surface_params.source_filter, + value.surface_params.method, + String(value.surface_params.smooth), + ].join("|"); + + /** + * 캐시로 먼저 그린 뒤 **뒤에서** 신선도를 확인한다(2026-09-06 캐시·세션 일원화). + * + * 예전에는 진입 때마다 `loadLatest(true)`로 DB를 다시 읽었다 — 다른 탭의 재업로드로 + * 옛 노선번호가 남을까 봐 넣은 안전장치인데, 그 탓에 B05↔B06을 오갈 때마다 화면이 + * 처음부터 다시 섰다. 이제 서명(노선번호·지표면)이 달라졌을 때만 다시 그린다. + */ + async function verifyLatestFreshness(shown: RouteLatestResponse): Promise { + try { + const fresh = await fetchLatestRoute(activeProjectId); + writeLatestCache(fresh); + if (latestSignature(fresh) === latestSignature(shown)) return; + renderLatest(fresh); + if (fresh.route?.id) await restoreSections(fresh.route.id); + } catch { + /* 확인 실패는 조용히 넘긴다 — 화면은 캐시로 이미 서 있다. */ + } + } + const panel = createRoutePanel({ onSolve: () => void solveRouteAction(actionContext), + // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 + // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). + onEditPlannedRoute: () => + void openRouteEditModal(activeProjectId, () => { + navigateTo(ROUTES.B05_PROFILE); + }), onTempSave: () => void tempSaveAction(actionContext), onGoCross: () => { // 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. @@ -273,6 +322,10 @@ export async function renderB05Route(root: HTMLElement): Promise { ); // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). renderStationLines(currentSectionDetail); + // 만든 것을 **바로 올려 둔다**(2026-09-06) — 예전에는 B05→B06 이동 때만 올려서, + // 누른 뒤 새로고침하면 저장본이 낡은 채라 다음 사람이 또 만들어야 했다. + // 브라우저 보관함이 생긴 지금은 올려 두면 다음 진입이 네트워크 없이 선다. + void saveCorridorIfDirty(activeProjectId, latest.route.id); }, onMovePoint: viewer.beginMoveSelected, onDeletePoint: viewer.markers.deleteSelected, @@ -420,7 +473,9 @@ export async function renderB05Route(root: HTMLElement): Promise { return best.elevation_m; }; // 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용. - const withUphill = [...regular, ...injected].map((station) => { + // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와 + // 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06). + const withUphill = [...dropStationsNear(regular, injected), ...injected].map((station) => { const design = designAt(station.chainage_m); return { ...station, @@ -465,16 +520,17 @@ export async function renderB05Route(root: HTMLElement): Promise { // renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지). const routePoints = latest?.route_points ?? []; if (routePoints.length > 1) { - void refreshCorridor( + // 저장본이 지금 정본과 맞을 때만 올린다 — 낡았으면 18.6MB 를 받지도, 다시 만들지도 + // 않고 [3D 업데이트] 대기 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 + // 만드느라 화면 전환이 16.7초까지 갔다). 3D 는 원래 수동인데 진입만 자동이었다. + void loadCorridorIfFresh( viewer, activeProjectId, routeId ?? latest?.route?.id, detail, routePoints, profilePanel.alignmentSamples() ?? undefined, - ); - // 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01). - panel.setCorridorPending(false); + ).then((fresh) => panel.setCorridorPending(!fresh)); } } @@ -504,7 +560,11 @@ export async function renderB05Route(root: HTMLElement): Promise { chainage_m: station.chainage_m, structure: station.structure ?? "", })); - if (legacy.length) { + // 이관은 멱등이지만 **진입할 때마다** POST 가 나갔다(2026-09-06 실측). 탭 수명 동안 + // 노선마다 한 번만 시도한다 — 옮길 것이 남아 있으면 다음 새로고침에 다시 본다. + const migrateKey = `${activeProjectId}:${routeId}`; + if (legacy.length && !migratedRoutes.has(migrateKey)) { + migratedRoutes.add(migrateKey); void migrateLegacyStations(activeProjectId, legacy) .then((result) => { if (result.migrated > 0) { @@ -618,9 +678,11 @@ export async function renderB05Route(root: HTMLElement): Promise { try { // ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다. + // 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 — B05↔B06 이동에서 + // 서버를 다시 부르지 않으려는 것이다(2026-09-06). 캐시가 없을 때만 DB를 기다린다. + const cachedLatest = readLatestCache(); const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([ - // 다른 탭의 재업로드로 옛 route_id가 남을 수 있어 진입 때는 DB 최신값을 읽는다. - loadLatest(true), + cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true), fetchSectionContext(activeProjectId), fetchRoadWidths(activeProjectId), ]); @@ -651,9 +713,9 @@ export async function renderB05Route(root: HTMLElement): Promise { latest = latestResponse; advanceLoading("확정 지표면 모델을 확인하는 중…"); - // ③ 확정 지표면 모델 목록. - const models = await listSurfaceModels(activeProjectId); - confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; + // ③ 확정 지표면 — 모델 id·범위를 한 번에 받는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + confirmedSurface = confirmed.model_id === null ? null : confirmed; if (!confirmedSurface) { // 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다. leaveForDashboard(); @@ -666,6 +728,9 @@ export async function renderB05Route(root: HTMLElement): Promise { // 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후). await bridge.load(); advanceLoading(""); + // 캐시로 그렸다면 이제 뒤에서 신선도만 확인한다 — 화면은 이미 서 있으므로 기다리지 + // 않는다. 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다. + if (cachedLatest) void verifyLatestFreshness(latestResponse); } catch (error) { showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error"); } finally { @@ -679,16 +744,15 @@ export async function renderB05Route(root: HTMLElement): Promise { const [surface, current] = [confirmedSurface, latest]; if (!surface || !current) return; try { - // 가장자리만 받는다 — 포인트클라우드 전체(수십 MB)는 안 받는다. - const confirmed = await fetchConfirmedSurface(activeProjectId); - if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); + // 범위는 ③에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다. + if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); await viewer.loadSurface( activeProjectId, - surface.id, + surface.model_id as number, current.surface_params.method, current.surface_params.smooth, current.surface_params.contour_interval_m, - toBounds(confirmed.bounds), + toBounds(surface.bounds), ); renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. if (currentSectionDetail) renderStationLines(currentSectionDetail); diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 0b4b0424..b709f688 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -16,7 +16,6 @@ import { showToast, } from "@ui/ui_template_elements"; import { navigateTo } from "../A00_Common/router"; -import type { SurfaceModelSummary } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { clearRouteLatestCache, confirmRoute, @@ -25,11 +24,12 @@ import { type RouteLatestResponse, } from "./B05_Profile_Api_Fetch"; import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; +import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; -import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel"; +import { clearDrafts } from "../A00_Common/b_page_state"; import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers"; import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit"; @@ -46,7 +46,8 @@ function L(key: keyof typeof ui_locales): string { export interface PageActionContext { projectId: string; latest: () => RouteLatestResponse | null; - confirmedSurface: () => SurfaceModelSummary | null; + /** 확정 지표면 응답 — 모델 id 는 `model_id` 다(2026-09-06 호출 정리). */ + confirmedSurface: () => { model_id: number | null } | null; routeReady: () => boolean; viewer: () => ReturnType; panel: () => ReturnType; @@ -77,7 +78,7 @@ export async function solveRouteAction(ctx: PageActionContext): Promise { filter_key: latest.surface_params.source_filter, method: latest.surface_params.method, smooth: latest.surface_params.smooth, - surface_model_id: confirmedSurface.id, + surface_model_id: confirmedSurface.model_id ?? undefined, algorithm: values.algorithm, bp: routePoint(points.bp), ep: routePoint(points.ep), @@ -131,6 +132,8 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { const latest = ctx.latest(); const projectId = ctx.projectId; // 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다. + // 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다. + await flushPendingPipes(projectId).catch(() => undefined); await ctx .profilePanel() .drainage.savePipes() @@ -149,7 +152,7 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { filter_key: latest?.surface_params.source_filter, method: latest?.surface_params.method, smooth: latest?.surface_params.smooth, - surface_model_id: ctx.confirmedSurface()?.id, + surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined, irregular_stations: ctx .bridge() .irregularStations() @@ -202,11 +205,11 @@ export async function resetDesignAction(ctx: PageActionContext): Promise { // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. clearRouteLatestCache(ctx.projectId); invalidateSectionDetail(ctx.projectId); - // 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로 - // 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다. + // 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화). + // 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은 + // 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다. + clearDrafts(ctx.projectId, ctx.latest()?.route?.id ?? null); ctx.uphillOverrides.clear(); - ctx.persistUphillOverrides(); - clearStandardCrossSession(ctx.projectId); // 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이 // 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남). clearAlignmentDrafts(); diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index a623b345..b0b158bb 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -7,6 +7,7 @@ * 전부 상태를 갖지 않는 변환 함수라 화면 흐름과 독립적이다. * ========================================================================== */ +import { readState, writeState } from "../A00_Common/b_page_state"; import type { RoutePanelValues } from "./B05_Profile_UI_Panel"; import type { ModelBounds, @@ -246,19 +247,15 @@ export const FACILITY_NAMES: Record = { /* ── 측점 상단측(=측구 방향) 사용자 변경분 세션 보관 ───────────────────── * 3D 램프 클릭으로 바꾼 값. 경로 확정 때 uphill_overrides로 백엔드에 병합한다. * 화면 본체가 700줄에 닿아 읽기·쓰기만 여기로 뺐다(2026-09-04, 동작 불변). */ -const uphillSessionKey = (projectId: string): string => `b05:uphill:${projectId}`; - /** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */ export function loadUphillOverrides(projectId: string): Map { const overrides = new Map(); try { - const raw = window.sessionStorage.getItem(uphillSessionKey(projectId)); - if (!raw) return overrides; - Object.entries(JSON.parse(raw) as Record).forEach( - ([chainage, side]) => { - if (side === "left" || side === "right") overrides.set(chainage, side); - }, - ); + const stored = readState>("uphill", projectId); + if (!stored) return overrides; + Object.entries(stored).forEach(([chainage, side]) => { + if (side === "left" || side === "right") overrides.set(chainage, side); + }); } catch { /* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */ } @@ -271,10 +268,7 @@ export function saveUphillOverrides( overrides: ReadonlyMap, ): void { try { - window.sessionStorage.setItem( - uphillSessionKey(projectId), - JSON.stringify(Object.fromEntries(overrides)), - ); + writeState("uphill", Object.fromEntries(overrides), projectId); } catch { /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ } diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 7402e314..b90159f4 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -119,9 +119,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { } /* ── 구조물 정본(structures.json) ──────────────────────────────────── - * 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에 - * 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 - * 받아 화면을 맞추고 사용자에게 알린다. */ + * 목록이 바뀌면 **세션 초안**에 담고(`writePending`), 정본에는 [저장]·[확정]에서만 + * 쓴다(`saveStructuresIfDirty`). 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 + * 받아 화면을 맞추고 사용자에게 알린다. + * (옛 주석은 「곧바로 서버에 저장한다」였다 — 2026-08-29 에 초안 방식으로 바뀌었고 + * 주석만 남아 있었다. 2026-09-06 정정.) */ let structureRevision = 0; let structureSaving: Promise = Promise.resolve(); @@ -155,6 +157,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]); } + /** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null). + * 3D·사이드 목록과 마찬가지로 **종단 알약 선택도 B06 으로 이어져야** 해서 둔다 + * (2026-09-06: 알약만 `structure-pick` 을 안 써 B06 이 못 잡았음). */ + function markChainage(structureId: string | null): number | null { + if (!structureId) return null; + const pipe = pipeMarkChainage(structureId); + if (pipe !== null) return pipe; + const found = ownStructures.find((item) => item.structure_id === structureId); + if (!found) return null; + const chainage = found.chainage_m ?? found.start_m ?? null; + return typeof chainage === "number" && Number.isFinite(chainage) ? chainage : null; + } + /** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */ function pipeMarkChainage(structureId: string | null): number | null { if (!structureId?.startsWith("pipe-")) return null; @@ -246,7 +261,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { async function persistStructures(next: StructureInstance[]): Promise { if (deps.isRestoring()) return; try { - const saved = await saveStructures(deps.projectId, structureRevision, next); + // 판번호는 **쓰기 직전에** 서버에서 다시 받는다. 진입 때 받은 값으로 보내면, + // 화면이 떠 있는 동안 정본이 한 번이라도 바뀌었을 때(다른 창 저장·노선 재계산) + // 409 로 거절되고 아래 실패 처리가 초안을 지워 **사용자가 넣은 구조물이 통째로 + // 사라졌다**(2026-09-06 실측: B06에서 구조물을 넣고 [저장]해도 정본에 안 남음). + const current = await fetchStructures(deps.projectId).catch(() => null); + const saved = await saveStructures( + deps.projectId, + current ? current.revision : structureRevision, + next, + ); structureRevision = saved.revision; writePending(null); // 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다. @@ -261,14 +285,15 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { ); } } catch (error) { + // 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다 + // (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다. if (error instanceof StructureConflictError) { - await refreshStructuresFromServer(); - showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error"); + showToast( + "다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.", + "error", + ); return; } - // 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다. - // 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1). - await refreshStructuresFromServer(); showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error"); } } @@ -276,15 +301,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { /** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */ async function loadStructures(): Promise { try { - const [types, stored] = await Promise.all([ - fetchStructureTypes(), + // 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07). + // · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군). + // · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서 + // 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다. + const [allTypes, stored] = await Promise.all([ + fetchStructureTypes(true), fetchStructures(deps.projectId), ]); - deps.panel().structures.setTypes(types); - deps.profilePanel().setStructureTypes(types); + const menuTypes = allTypes.filter((type) => type.enabled); + deps.panel().structures.setTypes(menuTypes); + deps.profilePanel().setStructureTypes(allTypes); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( - types.map((type) => [type.type_id, { group: type.group, name: type.name }]), + allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]), ); structureRevision = stored.revision; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29). @@ -333,6 +363,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { clearProjectedStations, /** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */ pipeMarkChainage, + /** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */ + markChainage, /** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */ applyStructures, saveStructuresIfDirty, diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 905b0ca2..2480ac40 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -86,6 +86,8 @@ const SPEED_CHOICES: Record> = { interface PanelCallbacks { onSolve: () => void; + /** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */ + onEditPlannedRoute: () => void; /** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */ onTempSave: () => void; /** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */ @@ -302,6 +304,17 @@ export function createRoutePanel(callbacks: PanelCallbacks) { // 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에 // 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다 // (2026-08-08 사용자 지시). + // 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색 + // (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다. + const plannedRoute = section("계획노선"); + const plannedRouteBtn = document.createElement("button"); + plannedRouteBtn.type = "button"; + plannedRouteBtn.className = "b05-route__btn"; + plannedRouteBtn.textContent = "계획노선 편집"; + plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다."; + plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute()); + plannedRoute.body.append(plannedRouteBtn); + const routeCalc = section("경로 계산 설정"); routeCalc.root.classList.add("is-collapsed"); const paletteGrid = document.createElement("div"); @@ -387,7 +400,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade.wrapper, minDownhillGrade.wrapper, ); + // [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고 + // 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다. + // 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다. const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); + solveButton.hidden = true; routeCalc.body.append( algorithmField.root, paved.wrapper, @@ -599,7 +616,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) { // 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트 // (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정). - root.append(structures.root, sectionOptions.root, routeCalc.root, selected.root, actionDock); + root.append( + structures.root, + sectionOptions.root, + plannedRoute.root, + routeCalc.root, + selected.root, + actionDock, + ); // 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도 // 접힘 항목은 손대지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 929494dc..1a6c62b1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -28,6 +28,10 @@ export interface AlignmentPolicy { max_grade_pct: number; curve_skip_delta_pct: number; paved: boolean; + /** 법정 평면 최소곡선반지름(m) — 위반 표시 기준. 0이면 판정하지 않는다(옛 저장분). */ + min_plan_radius_m?: number; + /** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */ + hairpin_min_radius_m?: number; } export interface AlignmentNode { diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 3d5732bf..16dbea88 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -36,6 +36,11 @@ export interface BalanceBarParams { minCoverViolations?: MinCoverViolation[]; /** 요약줄 **오른쪽 끝**에 붙이는 묶음(줌·Y레인지 조작구) — 2026-09-04. */ trailing?: HTMLElement; + /** 법정 평면 최소곡선반지름 위반(2026-09-06) — 자동 보정 없이 **경고만** 낸다. */ + planCurve?: { count: number; worstRadiusM: number; limitM: number; hairpin: number } | null; + // 유토곡선 총괄값은 이 줄에서 **뺐다**(2026-09-06 사용자 지시) — 그래프 좌측 상단에 + // 겹쳐 뜨는 배지(`common_util_mass_haul_badge`)가 맡는다. B06 은 곡선을 아예 빼므로 + // 같은 배지를 써야 두 화면이 같은 자리에서 같은 값을 보인다. /** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */ onResetAll: () => void; } @@ -77,6 +82,19 @@ export function renderBalanceBar(params: BalanceBarParams): void { // 절·성토 불균형은 2026-09-03 사용자 지시로 뺐다. 이 값은 **종단 기준**이었다 — // 계획선과 지반선 사이 세로 면적(㎡)의 절·성토 비이지 실제 토량이 아니다. 의미 있는 // 균형은 횡단 단면적을 쌓아 부피로 내는 하단 유토곡선 요약(㎥)이고, 그쪽이 이미 있다. + // 유토곡선 총괄값은 2026-09-06 사용자 지시로 이 줄에서 **뺐다** — 그래프 좌측 상단에 + // 겹쳐 뜨는 배지(`common_util_mass_haul_badge`)가 맡는다. B06 은 곡선을 아예 빼므로 + // 같은 배지를 써야 두 화면이 같은 자리에서 같은 값을 보인다. + // 평면 곡선반경 — 법정 하한을 밑도는 측점이 있을 때만 적는다(별표2 Ⅰ.2.다.(1)). + // 프로그램은 값만 드러내고 고치지 않는다: 노선을 바꿀지는 사용자 판단이다. + const plan = params.planCurve; + if (plan && plan.count > 0) { + entries.push([ + "곡선반경 부족", + `${plan.count} 곳 / 최소 ${plan.worstRadiusM.toFixed(1)} m`, + "over", + ]); + } // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다. if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 @@ -91,6 +109,15 @@ export function renderBalanceBar(params: BalanceBarParams): void { item.append(caption, document.createTextNode(value)); // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — // 같은 사실을 두 번 적지 않는다(2026-08-19 재편). + if (label === "곡선반경 부족" && plan) { + item.title = [ + `법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}곳`, + `가장 급한 곳 R=${plan.worstRadiusM.toFixed(1)}m`, + plan.hairpin > 0 + ? `그중 배향곡선 하한 미만 ${plan.hairpin}곳 — 경고만 낸다(자동 보정 없음)` + : "배향곡선 하한 미만은 없음", + ].join("\n"); + } if (label === "최대 기울기" && violations.length) { item.title = violations .map( diff --git a/B05_Profile/B05_Profile_UI_Profile_Layout.ts b/B05_Profile/B05_Profile_UI_Profile_Layout.ts index 987aa863..cbf42e8a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Layout.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Layout.ts @@ -17,8 +17,9 @@ import { type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; -/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */ -export const TABLE_ROW_COUNT = 12; +/** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서 + * 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */ +export const TABLE_ROW_COUNT = 6; /** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */ export const CELL_GAP_PX = 2; diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index bc6979ad..93524115 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -22,6 +22,7 @@ * 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. * ========================================================================== */ +import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import type { EarthworkConversion, HaulEquipmentLimit, @@ -31,6 +32,7 @@ import type { } from "@util/common_util_mass_haul_types"; import type { MassHaulSeries } from "@util/common_util_mass_haul"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; +import type { HaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan"; import type { MassHaulAxis } from "@util/common_util_mass_haul_view"; import { applyLegendToggle, @@ -39,12 +41,11 @@ import { MASS_HAUL_DEFAULT_VISIBLE, normalizeVisibleBasis, } from "@util/common_util_mass_haul"; -import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; +import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge"; import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createMassHaulChart, createMassHaulLegend, - createMassHaulSummary, createMassHaulWindowState, MASS_HAUL_MIN_HEIGHT, scheduleMassHaulSettle, @@ -57,14 +58,16 @@ import "./B05_Profile_UI_Style_MassHaul.css"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ -const OPEN_KEY = "b05-route-profile-masshaul-open"; +/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ /** 오버레이 높이(px) 세션 키 — 메인 하단 패널 높이와 같은 수명. */ -const OVERLAY_HEIGHT_KEY = "b05-route-profile-masshaul-height"; +const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? ""; /** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */ const OVERLAY_MIN_HEIGHT = 160; /** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_DEFAULT_HEIGHT = 280; +/** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */ +const MASS_FIT_PAD_RIGHT = 12; /** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */ const OVERLAY_MAX_RATIO = 0.8; /** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */ @@ -74,8 +77,10 @@ const HEIGHT_VAR = "--b05-masshaul-height"; * 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정). * 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다. */ -const VISIBLE_KEY = "b06:masshaul-visible-v4"; -const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; +/** 처음 열 때는 **곡선만** 보인다 — 토량 분배(평형선·운반 블록)는 범례에서 켠다 + * (2026-09-06 사용자 지시). 판을 v4 → v5 로 올려 이미 켜 둔 브라우저도 새 기본값으로 + * 시작하게 한다. */ +const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE]; /** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */ export interface RouteMassHaulContext { @@ -162,7 +167,7 @@ export interface RouteMassHaulPanel { function readVisible(): Set { try { - const raw = sessionStorage.getItem(VISIBLE_KEY); + const raw = readStateRaw("masshaul-visible"); if (!raw) return new Set(DEFAULT_VISIBLE); const parsed = JSON.parse(raw) as unknown; // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙). @@ -177,7 +182,19 @@ function readVisible(): Set { /** * @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw). */ -export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel { +/** + * 종단 상단줄이 **접힌 상태에서도** 보여 주는 유토곡선 총괄값(2026-09-06 사용자 지시). + * 곡선 자체는 접으면 사라져도 이 값은 계획선을 만지며 계속 봐야 한다. + */ +export type MassHaulSummaryValues = MassHaulBadgeValues; + +export function createRouteMassHaulPanel( + onChanged: () => void, + /** 총괄값이 새로 나올 때마다 부른다 — 종단 상단줄이 받아 적는다. */ + onSummary?: (summary: MassHaulSummaryValues | null) => void, + /** 유토 배분 선반입기 — **배분은 서버가 만든다**(2026-09-06). 없으면 분배는 안 그린다. */ + haulPrefetch?: HaulPlanPrefetch, +): RouteMassHaulPanel { // 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자 // 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는 // 툴팁으로 밝힌다. @@ -187,7 +204,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); - // 오버레이 뼈대 — 위 경계 리사이저 + 요약 막대 + 가로 스크롤러 + 범례. + // 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례. + // (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.) const overlay = document.createElement("div"); overlay.className = "b05-profile__masshaul-overlay"; const bar = document.createElement("div"); @@ -200,7 +218,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 높이를 복원하며 생성 중에 onResize → syncHandlePosition을 부르는데, `open`이 그 아래 // 있으면 TDZ ReferenceError로 B05 페이지 전체가 죽는다(2026-08-04 사용자 보고 — // 저장된 높이가 있는 브라우저에서만 재현되는 이유). - let open = sessionStorage.getItem(OPEN_KEY) === "true"; + let open = readStateRaw("masshaul-open") === "true"; let context: RouteMassHaulContext | null = null; // 높이 조절 — 메인 하단 패널과 같은 공용 리사이저(위 경계, 위로 끌면 커짐, 세션 보존). let resizeRedrawPending = false; @@ -253,7 +271,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa function applyOpen(next: boolean): void { open = next; - sessionStorage.setItem(OPEN_KEY, String(next)); + writeStateRaw("masshaul-open", String(next)); handleControl.setOpen(next); // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; @@ -271,7 +289,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa function toggleSeries(key: string): void { // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다. const visible = applyLegendToggle(readVisible(), key); - sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible])); + writeStateRaw("masshaul-visible", JSON.stringify([...visible])); onChanged(); } @@ -313,12 +331,48 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ let lastParams: RouteMassHaulDrawParams | null = null; + /** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */ + function emitSummary(series: MassHaulSeries[]): void { + if (!onSummary) return; + if (!series.length) return onSummary(null); + // 총괄값 기준은 **횡단 고정**이다(2026-09-06 사용자: 종단 기준 곡선은 뒤에 없앨 예정). + // 범례에서 무엇을 켜 두었든 배지·상단줄이 흔들리지 않는다. + const picked = series.find((entry) => entry.basis === "cross") ?? series[0]; + // 배분 선반입도 **여기서** 건다 — 접힘 여부와 무관하게 지나는 자리다(2026-09-06). + // 아래 `draw` 안에 두었더니 접힘 가드 뒤라 한 번도 안 불렸고, 그래서 「편집이 멈추면 + // 조용히 받아 둔다」가 성립하지 않았다(펼친 뒤에야 첫 요청이 나갔다). + haulPrefetch?.schedule(picked.result); + onSummary(badgeValuesFrom(picked.result)); + } + + /** 총괄값만 따로 낸다 — 패널을 접어도 상단줄이 값을 잃지 않게(2026-09-06). + * **횡단 기준만** 적분한다 — 접힌 상태에서 종단 기준까지 내던 것은 버려질 값이었다 + * (2026-09-06 조작 실측: 계획고 ▲ → 라벨 갱신 중앙 130ms 중 낭비분). */ + function reportSummary(params: RouteMassHaulDrawParams): void { + if (!onSummary) return; + if (!context || params.pendingRecalc) return; + emitSummary( + computeMassHaulSeries( + params.longitudinal, + params.crossSections, + context.conversion, + context.naturalSpoilMinSlope, + ["cross"], + ), + ); + } + function draw(params: RouteMassHaulDrawParams): void { lastParams = params; clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); - if (!open) return; + // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. + // 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번). + if (!open) { + reportSummary(params); + return; + } if (!context) { note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); return; @@ -345,6 +399,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa context.conversion, context.naturalSpoilMinSlope, ); + // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. + emitSummary(series); if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; @@ -354,12 +410,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa const visible = readVisible(); // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. const banded = series.find((entry) => visible.has(entry.key)); + // 배분은 **서버가 만든다** — 그 산식을 번들에 남기지 않으려고 옮겼다(2026-09-06). + // 요청은 `emitSummary` 가 걸어 두므로(접힘 여부와 무관) 펼칠 때는 이미 도착해 있다. + // 아직 못 받았으면 곡선만 그린다(분배 도형은 값이 오면 다시 그려진다). const haulPlan: HaulPlan | null = - banded && visible.has(MASS_HAUL_BALANCE_KEY) - ? computeHaulPlan(banded.result, context.haulLimits) - : null; - const summarySeries = banded ?? series[0]; - bar.append(createMassHaulSummary(summarySeries, haulPlan)); + banded && visible.has(MASS_HAUL_BALANCE_KEY) ? (haulPrefetch?.current() ?? null) : null; + // 요약 막대는 2026-09-06 사용자 지시로 뺐다 — 총괄값은 종단 상단줄이 늘 보여 주고, + // 이 자리는 곡선이 넓게 쓴다. `bar` 는 안내 문구 자리로만 남는다. // 곡선 몫 = 스크롤러의 **안쪽 높이**(clientHeight = 가로 스크롤바 제외). 오버레이 전체 // 높이로 잡으면 스크롤바가 곡선 바닥(Y축 -200 라벨)을 덮는다(2026-08-04 사용자 보고). @@ -370,17 +427,35 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa (overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12; const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT); + // 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시). + // 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로 + // 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다. + // 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼 + // 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」). + const fitAll = haulPlan !== null; + const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx; + const chartAxis = fitAll + ? { + ...params.axis, + padLeft: params.axis.axisX ?? params.axis.padLeft, + padRight: MASS_FIT_PAD_RIGHT, + viewRange: undefined, + window: undefined, + hideStations: true, + } + : { ...params.axis, window: windowState }; + let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( series, visible, params.stationSource, - { ...params.axis, window: windowState }, + chartAxis, params.selectedStationId, params.stationInterval, - params.widthPx, + widthPx, chartHeight, - params.widthPx, + widthPx, params.onSelectStation, haulPlan, (axis) => { @@ -390,14 +465,16 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa // 종단 캔버스와 같은 폭의 래퍼 — sticky Y축 앵커의 기준(position: relative)이 된다. const wrap = document.createElement("div"); wrap.className = "b05-profile__chart b05-profile__masshaul-chart"; - wrap.style.width = `${params.widthPx}px`; + wrap.style.width = `${widthPx}px`; wrap.style.height = `${chartHeight}px`; wrap.append(chart); // 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙). if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight)); scroll.replaceChildren(wrap); // 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다. - if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft; + // 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다. + if (fitAll) scroll.scrollLeft = 0; + else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft; legendLayer.append( createMassHaulLegend(series, visible, toggleSeries, () => { @@ -423,7 +500,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa commitHeight(px) { const next = Math.round(px); overlay.style.setProperty(HEIGHT_VAR, `${next}px`); - sessionStorage.setItem(OVERLAY_HEIGHT_KEY, String(next)); + writeStateRaw("masshaul-height", String(next)); syncHandlePosition(); }, syncHandle: () => syncHandlePosition(), diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 3ea62def..dff62835 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -10,9 +10,12 @@ * `saveProfileAlignment()`로 편집 델타만 보낸다. * ========================================================================== */ +import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; +import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { createHaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan"; import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import { @@ -46,6 +49,7 @@ import { structureGroupMenuItems } from "./B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createRouteMassHaulPanel, + type MassHaulSummaryValues, MASSHAUL_MIN_HEIGHT, type RouteMassHaulContext, } from "./B05_Profile_UI_Profile_MassHaul"; @@ -66,9 +70,8 @@ import "../B06_Section/B06_Section_UI_Style.css"; import "../B06_Section/B06_Section_UI_Style_Cross.css"; import "../B06_Section/B06_Section_UI_Style_Cross_Areas.css"; -const COLLAPSED_KEY = "b05-route-profile-collapsed"; -/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ -const HEIGHT_KEY = "b05-route-profile-height"; +/** 접힘·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ +const HEIGHT_KEY = stateKey("profile-height") ?? ""; /* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 — 예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */ const MIN_CHART_HEIGHT = 100; @@ -83,8 +86,10 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9; /** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms). * ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다. * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — - * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */ -const CROSS_PREVIEW_DEBOUNCE_MS = 60; + * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. + * 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가 + * 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */ +const CROSS_PREVIEW_DEBOUNCE_MS = 16; /** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는 * mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */ const STATION_SNAP_TOLERANCE_M = 0.1; @@ -144,7 +149,29 @@ export function createRouteProfilePanel( if (subPanelDragging) scheduleLightSync(); else draw(); }; - const massHaul = createRouteMassHaulPanel(subPanelChanged); + /** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시). + * 유토곡선을 접어도, B06 처럼 곡선을 아예 안 그려도 이 값은 남아야 한다. */ + const massBadge = createMassHaulBadge(); + // B05 는 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다. + massBadge.root.style.setProperty("--mass-haul-badge-top", "34px"); + bodyWrap.append(massBadge.root); + let massHaulSummary: MassHaulSummaryValues | null = null; + // 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면 + // 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다. + const haulPrefetch = createHaulPlanPrefetch( + projectId, + () => routeId ?? undefined, + subPanelChanged, + ); + const massHaul = createRouteMassHaulPanel( + subPanelChanged, + (summary) => { + const before = massHaulSummary; + massHaulSummary = summary; + if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary); + }, + haulPrefetch, + ); bodyWrap.append(massHaul.overlay, massHaul.handle); // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. massHaul.attachScrollSync(body); @@ -317,6 +344,34 @@ export function createRouteProfilePanel( return samples[samples.length - 1][field]; } + /** + * 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06). + * 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다. + * **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다. + */ + function planCurveWarning(): { + count: number; + worstRadiusM: number; + limitM: number; + hairpin: number; + } | null { + const limit = alignment?.policy.min_plan_radius_m ?? 0; + const sections = detail?.cross_sections ?? []; + if (!limit || !sections.length) return null; + const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0; + let count = 0; + let hairpin = 0; + let worst = Number.POSITIVE_INFINITY; + sections.forEach((section) => { + const radius = section.plan_radius_m; + if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return; + count += 1; + worst = Math.min(worst, radius); + if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1; + }); + return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null; + } + function renderBalance(): void { const minCoverViolations = findMinCoverViolations( minCoverTargets, @@ -324,6 +379,7 @@ export function createRouteProfilePanel( (chainageM) => sampleAt(chainageM, "elevation_m"), ); renderBalanceBar({ + planCurve: planCurveWarning(), minCoverViolations, balanceBar, tools: tools.render(), @@ -590,14 +646,14 @@ export function createRouteProfilePanel( function setCollapsed(collapsed: boolean): void { root.classList.toggle("is-collapsed", collapsed); panelHandle.setOpen(!collapsed); - sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + writeStateRaw("profile-collapsed", String(collapsed)); if (!collapsed) requestAnimationFrame(draw); } panelHandle.root.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")), ); - setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true"); + setCollapsed(readStateRaw("profile-collapsed") === "true"); return { root, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index cc84fe5d..062d250e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -8,6 +8,7 @@ * 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다. * ========================================================================== */ +import { dropStationsNear } from "./B05_Profile_Util_Station"; import { createLongitudinalProfile, longitudinalMinimumWidth, @@ -237,9 +238,13 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 그대로 남는다(2026-08-02 사용자 보고). const regular = graphData.stations.filter((station) => station.kind !== "irregular"); const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal)); + // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로 + // 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다. const graphLongitudinal = { ...graphData, - stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m), + stations: [...dropStationsNear(regular, injected), ...injected].sort( + (a, b) => a.chainage_m - b.chainage_m, + ), }; // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 // 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다. @@ -427,9 +432,9 @@ export function renderProfile(ctx: ProfileRenderContext): void { padRight: LONG_PAD.right + originOffset, // 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게. axisX: LONG_PAD.left, - // 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다 - // (2026-08-05 사용자 보고: 버튼과 커브 겹침). - padTop: 40, + // 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다 + // (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음). + padTop: 30, // 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도 // 곡선이 납작하게 눌린다(2026-09-04 사용자 지시). viewRange: { fromM: viewFromM, toM: viewToM }, diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 581345f4..e1cb163f 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B05_Profile_UI_Profile_Table.ts - * 종단면도 하단 도면 테이블 (구배 3행 · 측점값 7행 · 곡선 2행 = 12행). + * 종단면도 하단 도면 테이블 (구배 1행 · 측점값 3행 · 곡선 2행 = 6행). * - * 실무 종단면도 좌측 하단 표를 그대로 옮긴 구성이다. 구배와 곡선은 단일값 행이 아니라 - * 도면에서도 여러 줄로 찍히므로 각각 물리적인 행으로 분리했다. - * - 구배: 연장 / 고저차 / 기울기 3행. 블록은 곡선 구간을 뺀 실제 직선부만 덮는다. + * 실무 종단면도 좌측 하단 표를 옮긴 구성이되, **화면에서는 판단에 쓰는 행만** 둔다 + * (2026-09-06 사용자 지시 — 표가 그래프 자리를 너무 먹었다). 뺀 6행은 구배 L·H, + * 절토고, 성토고, 누가거리, 거리이며 **B07 도면 종단도는 12행 그대로**다(별도 코드). + * - 구배: 기울기(S) 1행. 블록은 곡선 구간을 뺀 실제 직선부만 덮는다. * - 곡선: 곡선길이 / 반경 2행. 반경 R만 입력 가능하고 L = R × |대수차| 로 따라온다. * * 값에는 `L=` 같은 접두를 붙이지 않는다 — 행 이름표가 이미 항목과 단위를 말해준다. @@ -173,10 +174,9 @@ function createRow(className: string, label: string, unit: string): HTMLElement return row; } -/** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */ +/** 화면 구배 블록 1행: 기울기(%). 연장·고저차 두 행은 2026-09-06 사용자 지시로 뺐다 — + * 두 값은 블록 툴팁에 그대로 남아 있어 필요할 때 짚어 볼 수 있다. */ const SEGMENT_ROWS: SegmentRowSpec[] = [ - { label: "구배 L", unit: "m 구간 연장", cell: (segment) => segment.length_m.toFixed(2) }, - { label: "구배 H", unit: "m 구간 고저차", cell: (segment) => segment.height_m.toFixed(2) }, { label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) }, ]; @@ -194,19 +194,9 @@ function buildStationRows( display: { station: number; cumulative: number }, ): StationRowSpec[] { const stations = alignment.stations; + // 절토고·성토고·누가거리·거리 네 행은 2026-09-06 사용자 지시로 뺐다 — 절·성토는 + // 그래프 음영과 유토곡선이, 누가거리는 측점 라벨이 이미 말해 준다. return [ - { - label: "절토고", - unit: "m", - modifier: "cut", - cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""), - }, - { - label: "성토고", - unit: "m", - modifier: "fill", - cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""), - }, { label: "계획고", unit: "m", @@ -214,16 +204,6 @@ function buildStationRows( cell: (index) => stations[index].plan_elevation_m.toFixed(2), }, { label: "지반고", unit: "m", cell: (index) => stations[index].ground_elevation_m.toFixed(2) }, - { - label: "누가거리", - unit: "m", - cell: (index) => (stations[index].chainage_m + display.cumulative).toFixed(2), - }, - { - label: "거리", - unit: "m 전 측점 대비", - cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""), - }, { label: "측점", unit: "측점번호+잔여거리", @@ -233,7 +213,7 @@ function buildStationRows( } /** - * 구배 3행. 각 블록은 **변화점에서 변화점까지**를 덮는다. + * 구배 행. 각 블록은 **변화점에서 변화점까지**를 덮는다. * * 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고, * 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의 @@ -268,7 +248,7 @@ function buildSegmentRows( const span = x(segment.to_m) - left; const text = spec.cell(segment); const node = element("span", "b05-profile-table__segment", ""); - // 구배 3행(L·H·S)은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다 + // 구배 행은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다 // (2026-09-04 사용자 확정: 값 열을 접는 규칙에서 구배 행은 제외). // 옛 규칙(2026-08-03: 구조물 구간은 고를 때만 값 표시)은 여기서 걷어냈고, // 구조물 구간 표시(색·하이라이트)와 툴팁은 그대로 둔다. @@ -499,27 +479,13 @@ function buildSelectedColumn( const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; - // 거리: 바로 앞 측점(계획선 측점)까지의 간격. - const previous = alignment.stations - .filter((row) => row.chainage_m < chainage - 1e-6) - .reduce( - (acc, row) => (acc === null ? row.chainage_m : Math.max(acc, row.chainage_m)), - null, - ); - const distance = previous !== null ? chainage - previous : null; - - // 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. - // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변). + // 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. + // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변). + // 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [ - { value: gradeCell((segment) => segment.length_m, 2), modifier: "" }, // 구배 L - { value: gradeCell((segment) => segment.height_m, 2), modifier: "" }, // 구배 H { value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S - { value: { text: cut !== null ? cut.toFixed(2) : "" }, modifier: "cut" }, // 절토고 - { value: { text: fill !== null ? fill.toFixed(2) : "" }, modifier: "fill" }, // 성토고 { value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고 { value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고 - { value: { text: (chainage + display.cumulative).toFixed(2) }, modifier: "" }, // 누가거리 - { value: { text: distance !== null ? distance.toFixed(2) : "" }, modifier: "" }, // 거리 { value: { text: displayStationLabel(chainage, interval, display.station) }, modifier: "" }, // 측점 { value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L { value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R @@ -560,9 +526,16 @@ function buildSelectedColumn( } column.append(cell); }); - column.title = `${displayStationLabel(chainage, interval, display.station)} · ${( + // 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. + const cutFillText = + cut !== null + ? ` · 절토 ${cut.toFixed(2)}m` + : fill !== null + ? ` · 성토 ${fill.toFixed(2)}m` + : ""; + column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${( chainage + display.cumulative - ).toFixed(2)}m`; + ).toFixed(2)}m${cutFillText}`; return column; } diff --git a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts index 2738f154..ea11fbce 100644 --- a/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts +++ b/B05_Profile/B05_Profile_UI_Profile_TableOverlay.ts @@ -10,18 +10,28 @@ * 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. * ========================================================================== */ +import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; +import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; -const OPEN_KEY = "b05:profile:table:open"; -const HEIGHT_KEY = "b05:profile:table:height"; +/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ +const HEIGHT_KEY = stateKey("table-height") ?? ""; const OVERLAY_MIN_HEIGHT = 140; /** 테이블 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ export const TABLE_OVERLAY_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_MAX_RATIO = 0.75; -/** CSS 기본 높이 — `var(--b05-table-height, 300px)`(_Style_MassHaul.css)와 같아야 한다. */ -const OVERLAY_DEFAULT_HEIGHT = 300; +/** 행 하나의 높이 상한(px). 행이 이보다 커지면 글자만 커지고 읽을 내용은 그대로다 — + * 12행에서 6행으로 줄인 뒤 같은 높이를 나눠 가지며 행이 두 배로 부푼 것을 막는다 + * (2026-09-06 사용자 지시: 「최대값 제한, 레이아웃 제어는 현재 유지」). */ +const MAX_ROW_HEIGHT = 30; +/** 오버레이 높이 상한 = 6행 × 행 상한 + 가로 스크롤바·경계 몫. */ +const OVERLAY_MAX_HEIGHT = TABLE_ROW_COUNT * MAX_ROW_HEIGHT + 24; +/** CSS 기본 높이 — `var(--b05-table-height, 204px)`(_Style_MassHaul.css)와 같아야 한다. */ +const OVERLAY_DEFAULT_HEIGHT = OVERLAY_MAX_HEIGHT; +/** 어느 경로로 들어온 높이든 상한을 넘지 않게 한다(리사이저·패널 비례 연동 공용). */ +const clampHeight = (px: number): number => Math.min(px, OVERLAY_MAX_HEIGHT); /** 리사이저가 높이를 담는 CSS 변수. */ const HEIGHT_VAR = "--b05-table-height"; @@ -61,7 +71,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa // 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다 // (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인). - let open = sessionStorage.getItem(OPEN_KEY) !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) + let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) let bottomOffset = 0; let resizeRedrawPending = false; const resizer = createPanelResizer({ @@ -70,7 +80,8 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa cssVar: HEIGHT_VAR, direction: -1, min: OVERLAY_MIN_HEIGHT, - max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, + max: () => + clampHeight((overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO), storageKey: HEIGHT_KEY, onResize: () => { syncHandlePosition(); @@ -99,7 +110,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa function applyOpen(next: boolean): void { open = next; - sessionStorage.setItem(OPEN_KEY, String(next)); + writeStateRaw("table-open", String(next)); handleControl.setOpen(next); handleControl.root.title = next ? "테이블 접기" : "테이블 펼치기"; handle.classList.toggle("is-open", next); @@ -131,12 +142,12 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa isOpen: () => open, desiredHeight: () => { const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR)); - return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT; + return clampHeight(Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT); }, commitHeight: (px) => { - const next = Math.round(px); + const next = Math.round(clampHeight(px)); overlay.style.setProperty(HEIGHT_VAR, `${next}px`); - sessionStorage.setItem(HEIGHT_KEY, String(next)); + writeStateRaw("table-height", String(next)); syncHandlePosition(); }, contentHeight: () => Math.max(0, overlay.offsetHeight - 6), diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index 5ee726db..d174fc83 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -12,6 +12,10 @@ * 거리의 일정 비율씩 좁힌다. 목표가 늘어나도 속도가 이어지므로 굴릴수록 뒤처지지 않는다. * * Shift+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다. + * + * **끌어서 이동**(2026-09-06 사용자 지시) — 스크롤바를 잡지 않고 그림 위에서 바로 끌어 + * 옮긴다. 좌클릭·휠 버튼 둘 다 되며, 여기 한 곳만 고치면 이것을 쓰는 세 곳(종단 그래프· + * 유토곡선·측점 테이블)이 함께 얻는다. * ========================================================================== */ /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — @@ -19,6 +23,14 @@ const EASE = 0.35; /** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */ const SNAP_PX = 0.5; +/** 이만큼 넘게 움직여야 "끌기"로 본다(px). 그 아래는 클릭(측점 선택)으로 흘려보낸다. */ +/** 이만큼 움직여야 「끌기」로 본다 — 그 아래는 클릭이다. + * 4px 은 손 떨림에도 넘어가 측점을 고르기 어려웠다(2026-09-06 사용자 지적). */ +const DRAG_THRESHOLD_PX = 6; +/** 끌기를 시작하면 안 되는 조작 부품 — 이 위에서 누른 것은 그 부품의 몫이다. + * 구조물 마크는 자기 `pointerdown`에서 전파를 끊으므로 여기 적지 않아도 된다. */ +const DRAG_BLOCK_SELECTOR = + "button, input, select, textarea, a, .ui-resizer, .b05-profile-edit__btn"; /** EASE의 기준 프레임 간격(ms) — 60Hz 한 칸. */ const BASE_FRAME_MS = 1000 / 60; /** 한 프레임으로 인정하는 최대 간격(ms). 탭이 잠들었다 깨면 간격이 수백 ms라 그대로 @@ -83,4 +95,84 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { }, { passive: false }, ); + + /* ── 끌어서 이동 ───────────────────────────────────────────────────────── + * 좌클릭(0)·휠 버튼(1)으로 잡아 끈다. 문턱을 넘긴 뒤에야 이동으로 보고, 넘긴 경우에만 + * 뒤따르는 click을 한 번 삼킨다 — 그래야 "그림을 조금 움직였는데 측점 선택이 풀리는" + * 일이 없다. + * + * ⚠ 포인터 잡기(`setPointerCapture`)는 **문턱을 넘긴 뒤에만** 한다(2026-09-06). + * 누르는 즉시 잡으면 뒤따르는 click 의 대상이 측점 `` 가 아니라 스크롤러가 되어, + * 측점 클릭이 배경 클릭으로 처리되고 선택이 그 자리에서 풀렸다 — 「측점을 골라도 + * 하이라이트가 안 된다」는 증상의 원인. 끌기가 실제로 시작된 뒤에는 잡아 두어야 + * 창 밖에서 손을 떼도 이동이 풀리지 않는다. + */ + let dragId: number | null = null; + let dragX = 0; + let dragY = 0; + let dragLeft = 0; + let dragTop = 0; + let dragMoved = false; + + function endDrag(): void { + if (dragId === null) return; + if (scroller.hasPointerCapture(dragId)) scroller.releasePointerCapture(dragId); + dragId = null; + scroller.style.cursor = ""; + scroller.style.userSelect = ""; + // 끈 뒤의 click 한 번만 삼킨다(캡처 단계라 아래 리스너보다 먼저 잡는다). + if (dragMoved) { + const swallow = (event: MouseEvent): void => { + event.stopPropagation(); + event.preventDefault(); + }; + scroller.addEventListener("click", swallow, { capture: true, once: true }); + // 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다. + requestAnimationFrame(() => + scroller.removeEventListener("click", swallow, { capture: true }), + ); + } + dragMoved = false; + } + + scroller.addEventListener("pointerdown", (event) => { + if (dragId !== null || (event.button !== 0 && event.button !== 1)) return; + if ((event.target as HTMLElement | null)?.closest(DRAG_BLOCK_SELECTOR)) return; + // 끌기가 시작되면 휠 관성 이동은 접는다 — 둘이 같은 scrollLeft를 다투면 튄다. + stop(); + dragId = event.pointerId; + dragX = event.clientX; + dragY = event.clientY; + dragLeft = scroller.scrollLeft; + dragTop = scroller.scrollTop; + dragMoved = false; + // 여기서는 포인터를 잡지 않는다 — 위 주석 참고(클릭이 측점에 닿아야 한다). + // 휠 버튼은 브라우저 자동 스크롤이 뜨므로 막는다. + if (event.button === 1) event.preventDefault(); + }); + + scroller.addEventListener("pointermove", (event) => { + if (dragId !== event.pointerId) return; + const dx = event.clientX - dragX; + const dy = event.clientY - dragY; + if (!dragMoved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return; + if (!dragMoved) { + dragMoved = true; + scroller.style.cursor = "grabbing"; + // 이제부터 진짜 끌기다 — 창 밖으로 나가도 이어지도록 포인터를 잡는다. + scroller.setPointerCapture(event.pointerId); + // 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가 + // 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다. + scroller.style.userSelect = "none"; + window.getSelection()?.removeAllRanges(); + } + scroller.scrollLeft = dragLeft - dx; + scroller.scrollTop = dragTop - dy; + event.preventDefault(); + }); + + scroller.addEventListener("pointerup", endDrag); + scroller.addEventListener("pointercancel", endDrag); + // 잡아 둔 포인터를 잃으면(창 전환 등) 끌기도 함께 끝낸다. + scroller.addEventListener("lostpointercapture", endDrag); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts new file mode 100644 index 00000000..fdf9159f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -0,0 +1,692 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit.ts + * 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다. + * + * 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지 + * 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을 + * 쓰고, **편집 중에는 아무 계산도 나가지 않는다**. + * + * 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침). + * [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산). + * + * 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은 + * 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다. + * ========================================================================== */ + +import { + computeMapRect, + computeRouteView, + drawPreparedLayer, + createNormalizer, + metricToScreen, + prepareLayer, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; +import { showToast } from "@ui/ui_template_elements"; +import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; +import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; +import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; +import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import "./B05_Profile_UI_Style_RouteEdit.css"; + +/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ +const NODE_HIT_PX = 9; +/** 노드 반지름(px). */ +const NODE_R = 4; +/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ +const DRAG_THRESHOLD_PX = 3; +/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. + * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ +const CURVE_HANDLE_PX = 5; + +type Vertex = [number, number]; + +/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */ +export async function openRouteEditModal( + projectId: string, + onApplied: () => void | Promise, +): Promise { + const overlay = document.createElement("div"); + overlay.className = "b05-routeedit"; + overlay.innerHTML = ` + `; + document.body.append(overlay); + + const canvas = overlay.querySelector(".b05-routeedit__canvas")!; + const status = overlay.querySelector(".b05-routeedit__status")!; + const busy = overlay.querySelector(".b05-routeedit__busy")!; + const context = canvas.getContext("2d")!; + + let expected: Vertex[] = []; + /** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */ + let plannedLine: Vertex[] = []; + /** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */ + let planned: Vertex[] = []; + /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ + let nodeInfo: Array<{ + radius_m: number | null; + inner_angle_deg: number | null; + violations: string[]; + }> = []; + let minRadiusM = 0; + /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ + let curveInfo: RoutePlanCurve[] = []; + /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ + let curveOn: boolean[] = []; + let curveRadius: Array = []; + /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ + let picked = -1; + let meta: VWorldMeta | null = null; + let sheets: PreparedLayer[] = []; + let view: ViewState = { + width: 0, + height: 0, + scale: 1, + offsetX: 0, + offsetY: 0, + mapRect: computeMapRect(null, 0, 0), + }; + let closed = false; + + const close = (): void => { + closed = true; + window.removeEventListener("resize", resize); + overlay.remove(); + }; + overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); + overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close); + // 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게. + + function resize(): void { + if (closed) return; + const wrap = canvas.parentElement!; + const ratio = window.devicePixelRatio || 1; + const width = wrap.clientWidth; + const height = wrap.clientHeight; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.setTransform(ratio, 0, 0, ratio, 0, 0); + view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; + draw(); + } + window.addEventListener("resize", resize); + + const toScreen = (vertex: Vertex): [number, number] => + meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; + + /** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */ + function toMetric(px: number, py: number): Vertex { + if (!meta) return [0, 0]; + const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min); + const [x1, y1] = metricToScreen( + meta, + view, + meta.x_min + meta.width_meters, + meta.y_min + meta.height_meters, + ); + const sx = (x1 - x0) / (meta.width_meters || 1); + const sy = (y1 - y0) / (meta.height_meters || 1); + return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)]; + } + + function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void { + if (points.length < 2) return; + context.save(); + context.setLineDash(dash); + context.strokeStyle = color; + context.lineWidth = width; + context.beginPath(); + points.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + context.restore(); + } + + function draw(): void { + if (closed) return; + const style = getComputedStyle(document.documentElement); + context.clearRect(0, 0, view.width, view.height); + context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; + context.fillRect(0, 0, view.width, view.height); + + context.save(); + context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; + context.lineWidth = 0.8; + for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot"); + context.restore(); + + strokePolyline( + expected, + [6, 5], + style.getPropertyValue("--color-text-secondary") || "#9ca3af", + 1.6, + ); + // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. + // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. + strokePolyline( + plannedLine.length ? plannedLine : planned, + [], + style.getPropertyValue("--map-route") || "#f97316", + 2.4, + ); + + context.save(); + context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.lineWidth = 1; + planned.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). + const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0; + context.fillStyle = bad + ? style.getPropertyValue("--color-danger") || "#dc2626" + : style.getPropertyValue("--map-route") || "#f97316"; + context.beginPath(); + context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); + context.fill(); + context.stroke(); + // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. + if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) { + context.save(); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.beginPath(); + context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); + context.fill(); + context.restore(); + } + }); + + // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). + // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. + context.lineWidth = 2; + curveInfo.forEach((curve) => { + const on = curveOn[curve.node_first] !== false; + if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. + [curve.start, curve.end].forEach((point) => { + const [x, y] = toScreen([point[0], point[1]]); + context.beginPath(); + context.rect( + x - CURVE_HANDLE_PX, + y - CURVE_HANDLE_PX, + CURVE_HANDLE_PX * 2, + CURVE_HANDLE_PX * 2, + ); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + context.fill(); + context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.stroke(); + }); + }); + context.restore(); + } + + /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ + function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { + let best: { curve: number; end: "start" | "end" } | null = null; + let bestDistance = NODE_HIT_PX + 2; + curveInfo.forEach((curve, index) => { + (["start", "end"] as const).forEach((which) => { + const point = which === "start" ? curve.start : curve.end; + const [x, y] = toScreen([point[0], point[1]]); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = { curve: index, end: which }; + } + }); + }); + return best; + } + + /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ + function dragHandleTo( + curveIndex: number, + which: "start" | "end", + to: Vertex, + ): { apex: Vertex; radius: number } | null { + const curve = curveInfo[curveIndex]; + if (!curve) return null; + const node = curve.node_first; + const before = planned[node - 1]; + const after = planned[node + 1]; + if (!before || !after) return null; + return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); + } + + /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ + function nodeAt(px: number, py: number): number { + let best = -1; + let bestDistance = NODE_HIT_PX; + planned.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = index; + } + }); + return best; + } + + /** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */ + function segmentAt(px: number, py: number): number { + let best = -1; + let bestDistance = 12; + for (let index = 0; index < planned.length - 1; index += 1) { + const [ax, ay] = toScreen(planned[index]); + const [bx, by] = toScreen(planned[index + 1]); + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); + const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + } + return best; + } + + /** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. + * 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ + function markEdited(): void { + plannedLine = []; + nodeInfo = []; + curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. + } + + /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ + function curveHint(): string { + const off = curveOn.filter( + (on, index) => !on && index > 0 && index < planned.length - 1, + ).length; + const forced = curveRadius.filter((value) => value !== null).length; + const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""] + .filter(Boolean) + .join(" · "); + if (!nodeInfo.length) { + const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; + return edits ? `${base}${base ? " · " : ""}${edits}` : base; + } + const bad = nodeInfo.filter((node) => node.violations.length).length; + const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length; + return ( + `곡선 ${curves}곳(하한 R ${minRadiusM}m)` + + `${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}` + ); + } + + // ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ── + const curveBar = overlay.querySelector(".b05-routeedit__curve")!; + const curveLabel = curveBar.querySelector(".b05-routeedit__curve-label")!; + const curveRadiusInput = curveBar.querySelector( + ".b05-routeedit__curve-radius", + )!; + const curveInfoText = curveBar.querySelector(".b05-routeedit__curve-info")!; + const curveOffBtn = curveBar.querySelector('[data-act="curve-off"]')!; + const curveOnBtn = curveBar.querySelector('[data-act="curve-on"]')!; + const curveAutoBtn = curveBar.querySelector('[data-act="curve-auto"]')!; + + /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + function syncCurveBar(): void { + const editable = picked > 0 && picked < planned.length - 1; + curveBar.hidden = !editable; + if (!editable) return; + const on = curveOn[picked] !== false; + curveLabel.textContent = `${picked + 1}번째 꺾임점`; + curveOffBtn.hidden = !on; + curveOnBtn.hidden = on; + curveRadiusInput.disabled = !on; + curveAutoBtn.disabled = !on || curveRadius[picked] === null; + const forced = curveRadius[picked]; + const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null; + curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10); + const inner = nodeInfo[picked]?.inner_angle_deg; + curveInfoText.textContent = on + ? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${minRadiusM}m` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + } + + curveRadiusInput.addEventListener("change", () => { + if (picked < 0) return; + const value = Number(curveRadiusInput.value); + curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; + draw(); + }); + + curveOffBtn.addEventListener("click", () => { + if (picked < 0) return; + curveOn[picked] = false; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`; + draw(); + }); + + curveOnBtn.addEventListener("click", () => { + if (picked < 0) return; + curveOn[picked] = true; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`; + draw(); + }); + + curveAutoBtn.addEventListener("click", () => { + if (picked < 0) return; + curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`; + draw(); + }); + + // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + let dragNode = -1; + /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ + let dragHandle: { curve: number; end: "start" | "end" } | null = null; + let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + + canvas.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + const rect = canvas.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + // 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작). + dragHandle = handleAt(px, py); + dragNode = dragHandle ? -1 : nodeAt(px, py); + if (dragHandle) { + picked = curveInfo[dragHandle.curve]?.node_first ?? -1; + syncCurveBar(); + draw(); + } else if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else { + panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; + } + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener("pointermove", (event) => { + const rect = canvas.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + if (dragHandle) { + // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). + const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py)); + if (moved) { + const node = curveInfo[dragHandle.curve].node_first; + planned[node] = moved.apex; + curveRadius[node] = Math.round(moved.radius * 100) / 100; + curveOn[node] = true; + picked = node; + // 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. + const which = dragHandle.end === "start" ? "start" : "end"; + curveInfo[dragHandle.curve] = { + ...curveInfo[dragHandle.curve], + apex: [moved.apex[0], moved.apex[1]], + radius_m: moved.radius, + [which]: toMetric(px, py), + } as (typeof curveInfo)[number]; + plannedLine = []; + nodeInfo = []; + syncCurveBar(); + draw(); + } + return; + } + if (dragNode >= 0) { + planned[dragNode] = toMetric(px, py); + markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. + draw(); + return; + } + if (panFrom) { + if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return; + view = { + ...view, + offsetX: panFrom.offsetX + (px - panFrom.x), + offsetY: panFrom.offsetY + (py - panFrom.y), + }; + draw(); + return; + } + canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default"; + }); + + const endDrag = (event: PointerEvent): void => { + if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + dragNode = -1; + dragHandle = null; + panFrom = null; + }; + canvas.addEventListener("pointerup", endDrag); + canvas.addEventListener("pointercancel", endDrag); + + canvas.addEventListener("dblclick", (event) => { + const rect = canvas.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + const segment = segmentAt(px, py); + if (segment < 0) return; + planned.splice(segment + 1, 0, toMetric(px, py)); + // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. + curveOn.splice(segment + 1, 0, true); + curveRadius.splice(segment + 1, 0, null); + picked = segment + 1; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; + draw(); + }); + + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top); + if (index < 0) return; + if (planned.length <= 2) { + showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + return; + } + planned.splice(index, 1); + curveOn.splice(index, 1); + curveRadius.splice(index, 1); + picked = -1; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; + draw(); + }); + + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const px = event.clientX - rect.left; + const py = event.clientY - rect.top; + const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2; + const nextScale = Math.max(1, Math.min(2000, view.scale * factor)); + const ratio = nextScale / view.scale; + // 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다. + view = { + ...view, + scale: nextScale, + offsetX: px - (px - view.offsetX) * ratio, + offsetY: py - (py - view.offsetY) * ratio, + }; + draw(); + }, + { passive: false }, + ); + + async function runHeavy(label: string, task: () => Promise): Promise { + busy.hidden = false; + busy.querySelector("span")!.textContent = + `${label} — 배수유역부터 다시 계산 중입니다. 몇 분 걸립니다.`; + try { + await task(); + // 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5). + clearDrafts(projectId); + clearResults(projectId); + showToast("노선을 다시 계산했습니다.", "success"); + close(); + await onApplied(); + } catch (error) { + busy.hidden = true; + showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error"); + } + } + + overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => { + if (planned.length < 2) { + showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); + return; + } + void runHeavy("계획노선 반영", () => + replanRoute( + projectId, + planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + ), + ); + }); + overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { + void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); + }); + + // ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ── + try { + const [plan, drainage] = await Promise.all([ + fetchRoutePlan(projectId), + fetchDrainageLayers(projectId, () => {}), + ]); + if (closed) return; + expected = plan.expected as Vertex[]; + plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]); + // 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다 + // (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다). + const nodes = plan.nodes ?? []; + minRadiusM = plan.min_radius_m ?? 0; + // 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07). + // + // 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 + // 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 = + // 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로 + // 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m). + // + // 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로 + // 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다. + const curves = plan.curves ?? []; + const replaced = new Map(); + const dropped = new Set(); + curves.forEach((curve) => { + replaced.set(curve.node_first, curve); + for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { + dropped.add(index); + } + }); + planned = []; + nodeInfo = []; + curveOn = []; + curveRadius = []; + curveInfo = []; + nodes.forEach((node, index) => { + if (dropped.has(index)) return; + const curve = replaced.get(index); + const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]; + const seat = planned.length; + planned.push(at); + nodeInfo.push({ + radius_m: curve ? curve.radius_m : node.radius_m, + inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, + violations: curve ? (curve.violations ?? []) : (node.violations ?? []), + }); + curveOn.push(true); + // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. + curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); + if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); + }); + picked = -1; + syncCurveBar(); + if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); + meta = drainage.meta; + const normalizer = createNormalizer(drainage.meta); + sheets = drainage.layers + .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) + .filter((layer): layer is PreparedLayer => layer !== null); + resize(); + const xs = planned.map((vertex) => vertex[0]); + const ys = planned.map((vertex) => vertex[1]); + const fitted = computeRouteView( + meta, + { + x_min: Math.min(...xs), + x_max: Math.max(...xs), + y_min: Math.min(...ys), + y_max: Math.max(...ys), + }, + view.width, + view.height, + ); + view = { ...view, ...fitted }; + status.textContent = + `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + + curveHint(); + draw(); + } catch (error) { + status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; + } +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts new file mode 100644 index 00000000..49b97466 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Curve.ts + * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 + * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * + * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — + * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 + * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ +export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { + const dx1 = a2[0] - a1[0]; + const dy1 = a2[1] - a1[1]; + const dx2 = b2[0] - b1[0]; + const dy2 = b2[1] - b1[1]; + const denominator = dx1 * dy2 - dy1 * dx2; + if (Math.abs(denominator) <= 1e-12) return null; + const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator; + return [a1[0] + dx1 * t, a1[1] + dy1 * t]; +} + +/** 세 점이 이루는 내각(도). 일직선이면 180. */ +export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number { + const ax = before[0] - at[0]; + const ay = before[1] - at[1]; + const bx = after[0] - at[0]; + const by = after[1] - at[1]; + const la = Math.hypot(ax, ay); + const lb = Math.hypot(bx, by); + if (la <= 0 || lb <= 0) return 180; + const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); + return (Math.acos(cosine) * 180) / Math.PI; +} + +/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. + * + * 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께 + * 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다). + * + * 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다. + * 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이 + * T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다. + */ +export function dragHandleTo( + before: Vertex, + oldApex: Vertex, + after: Vertex, + which: "start" | "end", + to: Vertex, +): { apex: Vertex; radius: number } | null { + // 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다. + const apex = + which === "start" + ? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다 + : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 + if (!apex) return null; + const inner = innerAngleDeg(before, apex, after); + const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + if (!(halfTan > 1e-9)) return null; + const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); + const radius = tangent / halfTan; + if (!(radius > 0) || !Number.isFinite(radius)) return null; + return { apex, radius }; +} diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index 082e66b9..a0214bf3 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -9,6 +9,7 @@ * 적혀, 어느 쪽으로 오가든 마지막 선택이 그대로 살아 있다. * ========================================================================== */ +import { readState, writeState } from "../A00_Common/b_page_state"; import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick"; import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import type { StructureInstance } from "./B05_Profile_Api_Structures"; @@ -19,21 +20,15 @@ export interface StructurePickHandoff { key?: string; } -const sessionKey = (projectId: string): string => `aislo:structure-pick:${projectId}`; +/* 넘김값도 ② 설계 초안이다 — 별도 임시 키를 두지 않고 등록표(`b_page_state`)를 쓴다 + (2026-09-06 캐시·세션 일원화). */ /** 세션에 남긴다(선택 해제면 지운다). */ export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void { if (!projectId) return; - try { - if (!pick) { - window.sessionStorage.removeItem(sessionKey(projectId)); - return; - } - const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key }; - window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(handoff)); - } catch { - /* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */ - } + if (!pick) return writeState("structure-pick", null, projectId); + const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key }; + writeState("structure-pick", handoff, projectId); } /** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */ @@ -43,21 +38,20 @@ export function writeStructurePick( key?: string, ): void { if (!projectId) return; - try { - if (at === null) window.sessionStorage.removeItem(sessionKey(projectId)); - else window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify({ at, key })); - } catch { - /* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */ - } + if (at === null) return writeState("structure-pick", null, projectId); + // 부재키 없이 **측점만** 오는 길(사이드 목록·선택 동기화)이 3D 로 고른 부재키를 지웠다 + // (2026-09-05 진단). 같은 측점이면 이미 적힌 부재키를 지킨다 — 3D 로 고른 직후 선택 + // 동기화가 이 길을 타도 조정창이 그대로 열린다. + const kept = key ?? readStructurePick(projectId)?.key; + writeState("structure-pick", { at, key: kept }, projectId); } /** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */ export function readStructurePick(projectId: string | null): StructurePickHandoff | null { if (!projectId) return null; try { - const raw = window.sessionStorage.getItem(sessionKey(projectId)); - if (!raw) return null; - const value = JSON.parse(raw) as Partial; + const value = readState>("structure-pick", projectId); + if (!value) return null; if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null; return { at: value.at, key: typeof value.key === "string" ? value.key : undefined }; } catch { diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index d7410144..f09bc1cf 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -28,6 +28,8 @@ export interface StructuresFormElements { body: HTMLElement; groupSelect: HTMLSelectElement; typeSelect: HTMLSelectElement; + /** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */ + ownerNote: HTMLElement; startFields: StationFields; anchorFields: StationFields; endFields: StationFields; @@ -96,6 +98,13 @@ export function buildStructuresForm(options: { typeRow.className = "b05-structure__grid"; typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); + // 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라 + // (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자: + // 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다. + const ownerNote = document.createElement("p"); + ownerNote.className = "b05-structure__owner-note"; + ownerNote.hidden = true; + // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); optionRow.className = "b05-structure__grid"; @@ -129,13 +138,22 @@ export function buildStructuresForm(options: { const positionDivider = document.createElement("hr"); positionDivider.className = "b05-structure__divider"; - body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions); + body.append( + typeRow, + ownerNote, + positionRow, + positionDivider, + optionRow, + facilityOptions.root, + actions, + ); return { root, body, groupSelect, typeSelect, + ownerNote, startFields, anchorFields, endFields, diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 9f4c4bb7..1c52a483 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -27,10 +27,11 @@ import { formatStation } from "./B05_Profile_Util_Station"; * 고르려다 옮겨지는 일이 잦았다(2026-08-19 사용자 보고 — 민감도 완화). */ const GRAB_SLACK_PX = 14; /** 겹쳐 두 줄로 갈릴 때의 알약 높이(px) — 레인 높이를 정하는 값이다. */ -const PILL_HEIGHT_PX = 24; -/** 혼자 있는(가운데 한 줄) 알약 높이(px) — 자리가 남으니 30%만 키운다 - * (2026-08-17 사용자 지시 1: 32px는 과했다). */ -const SOLO_PILL_HEIGHT_PX = 31; +/** 알약 높이(px) — 2026-09-06 사용자: 「너무 커서 높이를 많이 차지함」. 24 → 18 로 낮춰 + * 레인이 54 → 42px 가 되고 그만큼 그래프가 넓게 쓴다. 글자는 10px 로 함께 줄인다(CSS). */ +const PILL_HEIGHT_PX = 18; +/** 혼자 있는(가운데 한 줄) 알약 높이(px) — 겹칠 때보다 조금만 크게(옛 31 → 22). */ +const SOLO_PILL_HEIGHT_PX = 22; /** 두 줄 사이 간격(px). */ const ROW_GAP_PX = 2; /** 레인 위아래 여백(px) — 그래프를 밀어내되 여유는 최소로. */ @@ -156,15 +157,19 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement ? (STRUCTURE_LANE_HEIGHT_PX - SOLO_PILL_HEIGHT_PX) / 2 : LANE_PADDING_PX + row * (PILL_HEIGHT_PX + ROW_GAP_PX); - // 구간형은 고른 동안만 시~종점을 띠로 펼친다. - if (selected && structure.placement === "interval") { + // 구간형은 시~종점을 **늘 띠로** 펼친다(2026-09-07 사용자 지시 — B군을 넣어도 어디에 + // 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는 + // 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다. + // 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다. + if (structure.placement === "interval") { const band = document.createElement("div"); - band.className = "b05-structure__band"; + band.className = `b05-structure__band${selected ? " is-selected" : ""}`; const startPx = options.x(structure.start_m ?? 0); const endPx = options.x(structure.end_m ?? 0); band.style.left = `${Math.min(startPx, endPx)}px`; band.style.width = `${Math.max(Math.abs(endPx - startPx), 2)}px`; - band.style.top = `${top + height / 2 - 1}px`; + // 알약 세로 가운데에 맞춘다 — 띠 두께가 골랐을 때만 4px 라 반값이 다르다. + band.style.top = `${top + height / 2 - (selected ? 2 : 1.5)}px`; band.style.background = type?.style?.color ?? "#888"; lane.append(band); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 81fabbd1..6363fb7f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -60,6 +60,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu body, groupSelect, typeSelect, + ownerNote, startFields, anchorFields, endFields, @@ -208,6 +209,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; + // 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면 + // 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자). + ownerNote.textContent = type?.design_owner + ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` + : ""; + ownerNote.hidden = !type?.design_owner; anchorFields.wrap.querySelector("span")!.textContent = isInterval ? "기준 측점 (비우면 시작)" : "기준 측점"; @@ -322,11 +329,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu input.value = String(preset ?? ""); } const label = option.unit ? `${option.label} (${option.unit})` : option.label; + // `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다. + const locked = option.enabled === false; + if (locked) { + input.disabled = true; + input.classList.add("is-locked"); + input.title = "지금은 고를 수 없는 항목입니다."; + } optionRow.append(field(label, input)); input.addEventListener("change", () => liveCommit()); optionInputs.push({ key: option.key, - required: !!option.required, + required: !locked && !!option.required, input, read: () => (option.input === "number" ? Number(input.value) || 0 : input.value), isEmpty: () => input.value.trim() === "", diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index a2f2b510..d0b76f53 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -509,7 +509,9 @@ flex-wrap: nowrap; gap: var(--spacing-16); align-items: center; - height: 24px; + /* 유토곡선 총괄값이 이 줄로 올라와(2026-09-06) 항목이 늘었다 — 두 픽셀 높여 글자가 + 위아래로 눌리지 않게 한다. */ + height: 28px; padding: 0 var(--spacing-8); overflow-x: auto; overflow-y: hidden; diff --git a/B05_Profile/B05_Profile_UI_Style_MassHaul.css b/B05_Profile/B05_Profile_UI_Style_MassHaul.css index 85c65516..02b120e3 100644 --- a/B05_Profile/B05_Profile_UI_Style_MassHaul.css +++ b/B05_Profile/B05_Profile_UI_Style_MassHaul.css @@ -70,6 +70,12 @@ top: -3px; } +/* 요약 막대는 2026-09-06 종단 상단줄로 올라갔다 — 이 자리는 안내 문구가 있을 때만 쓴다. + 비어 있으면 테두리 한 줄도 남기지 않고 접어 곡선이 그만큼 넓게 쓴다. */ +.b05-profile__masshaul-bar:empty { + display: none; +} + .b05-profile__masshaul-bar { display: flex; flex: none; @@ -95,7 +101,7 @@ 공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */ .b05-profile__masshaul-legend { position: absolute; - top: 34px; + top: 4px; right: 0; left: 0; z-index: 3; @@ -114,10 +120,10 @@ /* 재계산 대기 표시 — 곡선을 지우지 않고(2026-09-03 깜빡임 제거) 곡선 영역 우측 상단에 **떠 있는** 알림으로 둔다. 요약 막대에 붙이면 막대가 줄바꿈되며 곡선을 밀어냈다 - (2026-09-04 사용자 보고). 범례(top: 34px) 아래 줄에 앉혀 서로 가리지 않는다. */ + (2026-09-04 사용자 보고). 범례(top: 4px) 아래 줄에 앉혀 서로 가리지 않는다. */ .b05-profile__masshaul-pending { position: absolute; - top: 62px; + top: 32px; right: var(--spacing-8); z-index: 4; padding: 2px var(--spacing-8); @@ -167,7 +173,7 @@ z-index: 5; display: flex; flex-direction: column; - height: var(--b05-table-height, 300px); + height: var(--b05-table-height, 204px); border-top: 1px solid var(--color-border); background: var(--color-surface-raised); box-shadow: 0 -4px 12px rgb(0 0 0 / 18%); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css new file mode 100644 index 00000000..9933d6fb --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -0,0 +1,183 @@ +/* 계획노선 편집 모달 — 큰 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다 + (2026-09-06 사용자 확정). 색은 전부 테마 토큰을 쓴다. */ + +.b05-routeedit { + position: fixed; + inset: 0; + z-index: var(--z-modal, 1000); + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 55%); +} + +.b05-routeedit__box { + position: relative; + display: flex; + flex-direction: column; + width: min(1200px, 94vw); + height: min(820px, 92vh); + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-16, 12px); + background: var(--color-surface-raised); + box-shadow: 0 12px 40px rgb(0 0 0 / 45%); +} + +.b05-routeedit__head { + display: flex; + flex: none; + align-items: center; + gap: var(--spacing-12); + padding: var(--spacing-12) var(--spacing-16); + border-bottom: 1px solid var(--color-border); +} + +.b05-routeedit__hint { + flex: 1 1 auto; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__close { + flex: none; + border: none; + background: none; + color: var(--color-text-secondary); + font-size: 16px; + cursor: pointer; +} + +.b05-routeedit__canvas-wrap { + position: relative; + flex: 1 1 auto; + min-height: 0; + background: var(--color-surface); +} + +.b05-routeedit__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +.b05-routeedit__foot { + display: flex; + flex: none; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + border-top: 1px solid var(--color-border); +} + +.b05-routeedit__status { + flex: 1 1 auto; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__legend { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__legend i { + display: inline-block; + width: 22px; + height: 0; + margin-right: 2px; + vertical-align: middle; +} + +.b05-routeedit__legend i.is-expected { + border-top: 2px dashed var(--color-text-secondary, #9ca3af); +} + +.b05-routeedit__legend i.is-planned { + border-top: 2px solid var(--map-route, #f97316); +} + +.b05-routeedit__btn { + flex: none; + padding: var(--spacing-8) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface); + color: var(--color-text-body); + cursor: pointer; +} + +.b05-routeedit__btn.is-primary { + border-color: transparent; + background: var(--color-primary, #7c3aed); + color: #fff; +} + +/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */ +.b05-routeedit__busy { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-16); + background: rgb(0 0 0 / 55%); + color: #fff; + text-align: center; +} + +/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). + 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ +.b05-routeedit__curve { + /* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고 + 이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */ + flex: none; + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-top: 1px solid var(--color-border, #e5e7eb); + background: var(--color-surface-2, #f9fafb); + font-size: var(--font-size-13, 13px); +} + +.b05-routeedit__curve-label { + font-weight: 600; + white-space: nowrap; +} + +.b05-routeedit__curve-field { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.b05-routeedit__curve-radius { + width: 5.5rem; + padding: 2px 6px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-4, 4px); + font: inherit; + text-align: right; +} + +.b05-routeedit__curve-radius:disabled { + background: var(--color-surface-3, #f3f4f6); + color: var(--color-text-muted, #9ca3af); +} + +.b05-routeedit__curve-info { + flex: 1 1 auto; + color: var(--color-text-muted, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 762147d4..650db38c 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -41,17 +41,18 @@ pointer-events: none; } -/* 알약 — 종류를 3자 이하 이름으로 보여 준다. 높이는 구 서클마크와 같은 20px. */ +/* 알약 — 종류를 3자 이하 이름으로 보여 준다. 2026-09-06 사용자 지시로 한 단 낮췄다 + (높이 24 → 18, 글자 11 → 10) — 그래프가 그만큼 세로를 더 쓴다. */ .b05-structure__mark { position: absolute; transform: translateX(-50%); - height: 24px; - min-width: 24px; - padding: 0 9px; - border-radius: 12px; + height: 18px; + min-width: 18px; + padding: 0 7px; + border-radius: 9px; border: 1.5px solid currentColor; background: var(--color-surface, #fff); - font-size: 11px; + font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap; @@ -62,13 +63,13 @@ justify-content: center; } -/* 혼자 있는 알약 — 겹치는 알약보다 30%만 키운다(2026-08-17 사용자 지시 1). */ +/* 혼자 있는 알약 — 겹치는 알약보다 조금만 키운다(옛 31px 는 과했다, 2026-09-06). */ .b05-structure__mark.is-solo { - height: 31px; - min-width: 31px; - padding: 0 12px; - border-radius: 16px; - font-size: 12px; + height: 22px; + min-width: 22px; + padding: 0 9px; + border-radius: 11px; + font-size: 11px; } .b05-structure__mark:hover { @@ -85,12 +86,21 @@ } /* 구간 띠 — 고른 동안에만 시~종점을 펼쳐 보여 준다. */ +/* 구간형 시설이 놓인 자리 — 늘 보인다(2026-09-07). 고르지 않은 것은 옅은 실선으로 깔려 + 있고, 고른 것만 두껍고 진해진다. 길게 이어지는 시설(측구·맹암거)이 많아도 알약을 가리지 + 않도록 얇게 둔다. */ .b05-structure__band { position: absolute; + height: 3px; + border-radius: 1.5px; + opacity: 0.45; + pointer-events: none; +} + +.b05-structure__band.is-selected { height: 4px; border-radius: 2px; opacity: 0.55; - pointer-events: none; } /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ @@ -193,6 +203,17 @@ border-top: 1px solid var(--color-border, #3a3f4a); } +/* 「제원·수량은 다른 화면이 냅니다」 안내 — 경고가 아니라 안내라 색은 흐리게, + * 왼쪽 선 하나로만 구분한다(2026-09-07). */ +.b05-structure__owner-note { + margin: 0; + padding: 4px 0 4px 8px; + border-left: 2px solid var(--color-border, #3a3f4a); + color: var(--color-text-muted, #9aa1ad); + font-size: var(--text-caption, 12px); + line-height: 1.5; +} + /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 * (2026-08-17 사용자 지시 2). */ .b05-structure__position-row { diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 09b5674e..65375e32 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -37,6 +37,31 @@ type ViewKind = "iso" | "top" | "front" | "side"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; +/** + * 파싱해 둔 지표면 한 벌 — **뷰어보다 오래 산다**. + * + * B05 는 해시가 바뀔 때마다 `renderB05Route` 로 통째로 다시 조립되므로 뷰어 안의 상태는 + * 매번 비워진다. 그러면 8MB 짜리 지표면을 화면에 들어올 때마다 다시 읽고 다시 파싱하는데, + * 실측에서 그 값이 **단일 동기 블록 14.4초**였다(2026-09-06 공용 브라우저 3왕복). + * 같은 모델이면 이 자리에 둔 것을 새 장면에 그대로 붙인다. + * + * 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게). + * 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다. + */ +const cachedTerrain: { + key: string | null; + object: THREE.Object3D | null; + /** 높이 격자 색인 — 만드는 값이 O(삼각형)이라 지형과 한 벌로 쥔다. */ + heightIndex: TerrainHeightIndex | null; +} = { key: null, object: null, heightIndex: null }; + +declare global { + interface Window { + /** 지표면 적재 단계별 시간(ms) — 화면 밖에서 수치로 확인하는 디버그 훅. */ + __surfaceTiming?: Array<{ step: string; ms: number }>; + } +} + /** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ function countTriangles(root: THREE.Object3D | null): number { let total = 0; @@ -184,6 +209,9 @@ export function createRouteViewer(): RouteViewer { let terrain: THREE.Object3D | null = null; // 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23). + // + // 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시 + // 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다). let corridorBuild: CorridorBuildResult | null = null; let corridorGroup: THREE.Group | null = null; let clippedTerrain: THREE.Object3D | null = null; @@ -205,6 +233,17 @@ export function createRouteViewer(): RouteViewer { function terrainElevation(x: number, y: number): number | null { if (!terrain || !bounds) return null; const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds); + // 격자 색인으로 찾는다 — Raycaster 는 한 번 쏠 때마다 삼각형을 전부 훑는다. + // 마커·측점선이 점마다 부르는 자리라, B05 진입에서 **13.5초 중 11.6초**가 여기였다 + // (2026-09-06 CPU 프로파일: getVertexPosition·intersectTriangle·checkGeometryIntersection). + // 같은 교훈을 2026-08-23 비탈 투영에서 이미 한 번 겪어 색인을 만들어 뒀다. + const index = ensureHeightIndex(); + if (index) { + const height = index.heightAt(origin.x, origin.z); + return height === null + ? null + : sceneToModel(new THREE.Vector3(origin.x, height, origin.z), bounds).z; + } const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0)); const hit = raycaster.intersectObject(terrain, true)[0]; return hit ? sceneToModel(hit.point, bounds).z : null; @@ -476,7 +515,12 @@ export function createRouteViewer(): RouteViewer { function ensureHeightIndex(): TerrainHeightIndex | null { if (heightIndex) return heightIndex; if (!terrain) return null; - heightIndex = new TerrainHeightIndex(terrain); + // 보관해 둔 지형이면 색인도 같이 쓴다 — 화면을 드나들 때마다 다시 만들지 않는다. + heightIndex = + cachedTerrain.object === terrain && cachedTerrain.heightIndex + ? cachedTerrain.heightIndex + : new TerrainHeightIndex(terrain); + if (cachedTerrain.object === terrain) cachedTerrain.heightIndex = heightIndex; return heightIndex.triangleCount > 0 ? heightIndex : null; } @@ -609,38 +653,67 @@ export function createRouteViewer(): RouteViewer { markers, structurePick, async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { + const step = (label: string, from: number): void => { + timing.push({ step: label, ms: Math.round(performance.now() - from) }); + }; + const timing: Array<{ step: string; ms: number }> = []; + const started = performance.now(); bounds = nextBounds; current = { projectId, modelId, smooth, interval }; if (terrain) { scene.remove(terrain); - disposeObject(terrain); + if (terrain !== cachedTerrain.object) disposeObject(terrain); } heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다. bandSplit?.dispose(); bandSplit = null; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; - // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). - const buffer = await fetchCachedBytes(projectId, url); - terrain = await new Promise((resolve, reject) => { - if (method === "meshfree") { - const geometry = new PLYLoader().parse(buffer); - resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); - } else { - new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); + const key = `${projectId}:${modelId}:${method}:${smooth}`; + if (cachedTerrain.key === key && cachedTerrain.object) { + // 같은 지표면 모델을 이미 파싱해 뒀다 — 다시 읽지도 파싱하지도 않는다(2026-09-06). + terrain = cachedTerrain.object; + step("reuse", started); + } else { + const fetched = performance.now(); + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(projectId, url); + step("fetch", fetched); + const parsed = performance.now(); + terrain = await new Promise((resolve, reject) => { + if (method === "meshfree") { + const geometry = new PLYLoader().parse(buffer); + resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); + } else { + new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); + } + }); + terrain.traverse((child) => { + if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide; + }); + step("parse", parsed); + // 한 벌만 쥔다 — 다른 모델로 바뀌면 옛것을 버린다. + if (cachedTerrain.object && cachedTerrain.object !== terrain) { + disposeObject(cachedTerrain.object); } - }); - terrain.traverse((child) => { - if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide; - }); + cachedTerrain.key = key; + cachedTerrain.object = terrain; + cachedTerrain.heightIndex = null; // 새 지형이면 색인도 새로. + } scene.add(terrain); // 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다. applySurfaceGrayscale(); // 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단 // 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다. if (corridorBuild) setCorridor(corridorBuild); + const fitted = performance.now(); fit("top"); markers.renderMarkers(); + step("fit+markers", fitted); + const contoured = performance.now(); await reloadContours(interval); + step("contours", contoured); + step("total", started); + window.__surfaceTiming = timing; // 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가 // 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다. status.textContent = ""; diff --git a/B05_Profile/B05_Profile_Util_Station.ts b/B05_Profile/B05_Profile_Util_Station.ts index da5ee30a..b6859a0f 100644 --- a/B05_Profile/B05_Profile_Util_Station.ts +++ b/B05_Profile/B05_Profile_Util_Station.ts @@ -52,3 +52,24 @@ export function parseStationText(text: string, intervalM: number): number | null const direct = Number(trimmed); return Number.isFinite(direct) && direct >= 0 ? direct : null; } + +/** 구조물 측점과 겹쳤다고 볼 거리(m) — 이보다 가까우면 규칙 측점을 지운다. */ +export const STATION_MERGE_TOLERANCE_M = 0.1; + +/** + * 구조물 측점과 **겹치는 규칙 측점을 지운다**(2026-09-06). + * + * 구조물은 보조말뚝을 박아 그 자리가 정본 측점이 된다. 규칙 격자(20m)와 0.1m 안에서 + * 만나면 세로선·라벨이 두 겹으로 겹쳐 읽히지 않으므로 구조물 쪽만 남긴다. + */ +export function dropStationsNear( + stations: readonly T[], + anchors: ReadonlyArray<{ chainage_m: number }>, + toleranceM: number = STATION_MERGE_TOLERANCE_M, +): T[] { + if (anchors.length === 0) return [...stations]; + return stations.filter( + (station) => + !anchors.some((anchor) => Math.abs(anchor.chainage_m - station.chainage_m) <= toleranceM), + ); +} diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index ab2ff622..26e84d89 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -16,6 +16,7 @@ * 기존 호출 코드가 깨지지 않도록 여기서 그대로 다시 내보낸다. * ========================================================================== */ +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; import type { CrossDesign, @@ -58,10 +59,43 @@ async function requestJson(path: string, init: RequestInit): Promise { } /** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */ +/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다 + * (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */ export async function fetchSectionContext(projectId: string): Promise { - return requestJson(`/projects/${projectId}/sections/context`, { - method: "GET", - }); + const cached = readState("section-context", projectId); + if (cached) return seedStandardCross(projectId, cached); + const fresh = await requestJson( + `/projects/${projectId}/sections/context`, + { method: "GET" }, + ); + writeState("section-context", fresh, projectId); + return seedStandardCross(projectId, fresh); +} + +/** + * 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07). + * + * 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만 + * 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해 + * 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭 + * **0.9m**, config 기본값은 **3%**·**0.69m**). + * + * 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가 + * 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다. + */ +function seedStandardCross( + projectId: string, + context: SectionContextResponse, +): SectionContextResponse { + const stored = context.stored_standard_cross_section; + if (stored && readState("std-cross", projectId) === null) { + writeState("std-cross", stored, projectId); + } + return context; +} + +export function clearSectionContextCache(projectId: string): void { + clearState("section-context", projectId); } /** 경로의 종단면 요약을 조회한다. */ @@ -219,6 +253,8 @@ export async function previewCrossDesigns( fullDesigns?: boolean; /** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */ rockBoundaryOffsets?: Record; + /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ + berms?: Record; }, ): Promise { return requestJson( @@ -230,6 +266,7 @@ export async function previewCrossDesigns( standard_cross_section: standardCrossSection ?? null, full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, + berms: options?.berms ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index e0c0b13c..590c76c5 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -45,7 +45,14 @@ export interface StandardCrossGroup { /** 표준 횡단면 설정 패널 그룹 키. */ export type StandardCrossKey = "soil" | "rock" | "paved"; -export type StandardCrossSection = Record; +export type StandardCrossSection = Record & { + /** 절토 법정 기울기 판정에 쓸 별표2 줄 — 지반유형(리핑암·발파암) → `soft_rock`/`hard_rock`. + * + * 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가르는데 둘을 잇는 + * 문장이 법령·교본에 없다. 그래서 법정 근거가 아니라 **사용자가 고르는 설정**으로 두고 + * 표준단면 설정과 함께 저장한다(2026-09-07 사용자 확정). 없으면 기본값을 쓴다. */ + cut_slope_class?: Record; +}; // 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를 // `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지). @@ -74,6 +81,16 @@ export interface SectionContextResponse { defaults: SectionOptionDefaults; /** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */ standard_cross_section: StandardCrossSection; + /** 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 확정한 값). 없으면 null. + * 위 `standard_cross_section` 은 config 기본값이라 둘은 다른 것이다(2026-09-07). */ + stored_standard_cross_section?: StandardCrossSection | null; + /** 절토 비탈 법정 기울기 범위(별표2) — `hard_rock`·`soft_rock`·`soil` → [최소, 최대]. + * 상수를 화면에 복제하지 않으려고 **서버가 준 값을 그대로** 쓴다(2026-09-07). */ + cut_slope_limits?: Record; + /** 지반유형(리핑암·발파암) → 별표2 줄. **사용자가 바꿀 수 있는 설정값**의 기본값이다. */ + cut_slope_class_default?: Record; + /** 절토 기울기 규정이 없는 등급(작업임도). */ + cut_slope_exempt_grades?: string[]; /** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */ rock_boundary_default_offset_m: number; rock_boundary_step_m: number; @@ -116,6 +133,12 @@ export interface SectionStation { structure?: string; center_z: number | null; azimuth_deg: number | null; + /** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */ + plan_radius_m?: number | null; + /** 곡선 바깥쪽 — 확폭이 붙는 쪽(2026-09-06 사용자 확정). 직선이면 null. */ + curve_outer_side?: "left" | "right" | null; + /** 확폭량(m) — 표값에 곡선 앞뒤 테이퍼를 얹은 값. 설계는 이 값을 우선 쓴다. */ + curve_widening_m?: number | null; center_x: number; center_y: number; /** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */ @@ -399,6 +422,11 @@ export interface CrossDesign { fill_slope_ratio: number; roadbed_width_m: number; carriageway_width_m: number; + /** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */ + carriageway_standard_width_m?: number; + /** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */ + widening_left_m?: number; + widening_right_m?: number; cross_slope_pct: number; ditch: | { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number } @@ -495,4 +523,17 @@ export interface CrossSectionPatch { /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ revet_link_detached?: boolean; revet_follow_grade?: boolean; + /** 카드 버튼 선택(2026-09-06) — 예전에는 버튼을 누를 때 서버가 계산·저장했다. + * 이제 조작은 세션 초안에 쌓이고 [저장]·[확정]에서 이 patch 로만 나간다. */ + ground_type?: string; + section_mode?: string; + ditch_side?: string | null; + ditch_type?: string | null; + paved?: boolean; + two_stage_slope?: boolean; + /** 절·성토 면적(㎡) — 브라우저가 계산해 보낸다(2026-09-06 사용자 확정). */ + cut_area_m2?: number; + fill_area_m2?: number; + cut_soil_area_m2?: number; + cut_rock_area_m2?: number; } diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts new file mode 100644 index 00000000..7b6ec3f9 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * B06_Section_Cross_Design_Session.ts + * 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택의 **세션 보관소**. + * + * 왜 생겼나(2026-09-06 사용자 확정) — 예전에는 버튼을 누를 때마다 + * `POST …/sections/{route}/cross-design` 이 나가 서버가 계산하고 **바로 저장**했다. + * 조작은 캐시에 쌓이고 [저장]·[확정]에서만 정본으로 나가야 한다는 규칙에 어긋난다. + * 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다. + * + * 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다. + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; + +/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */ +export interface CrossDesignChoice { + ground_type: string; + section_mode: string; + ditch_side?: string | null; + ditch_type?: string | null; + paved?: boolean; + two_stage_slope?: boolean; +} + +type ChoiceMap = Record; + +/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */ +const keyOf = (chainageM: number): string => chainageM.toFixed(2); + +function readAll(projectId: string, routeId: number): ChoiceMap { + return readState("crossdesign", projectId, routeId) ?? {}; +} + +/** 이 측점의 선택값(없으면 null). */ +export function readCrossDesignChoice( + projectId: string, + routeId: number, + chainageM: number, +): CrossDesignChoice | null { + return readAll(projectId, routeId)[keyOf(chainageM)] ?? null; +} + +/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */ +export function writeCrossDesignChoice( + projectId: string, + routeId: number, + chainageM: number, + choice: CrossDesignChoice, +): void { + const all = readAll(projectId, routeId); + all[keyOf(chainageM)] = choice; + writeState("crossdesign", all, projectId, routeId); +} + +/** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */ +export function crossDesignChoices( + projectId: string | null, + routeId: number | null, +): Map { + const choices = new Map(); + if (!projectId || routeId === null) return choices; + for (const [key, value] of Object.entries(readAll(projectId, routeId))) { + const chainage = Number(key); + if (Number.isFinite(chainage) && value) choices.set(chainage, value); + } + return choices; +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 8f91e139..6509d231 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,9 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import type { BermSpec } from "@util/common_util_cross_berm"; +import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { effectiveStandardCross, readRockBoundaryDefault, @@ -60,10 +62,15 @@ export interface CrossRefreshInput { shouldApply?: () => boolean; } -/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */ -const PRESERVED_KEYS = [ - "status", - "pavement_suggested", +/** + * 계산이 만들지 않는 **사용자 값** — 다시 계산해도 살려 두고, [저장]·[확정]에도 이 목록으로 + * 실어 보낸다(`B06_Section_Section_Store`). 서버 `B06_Section_Router_Design.USER_TOUCHED_KEYS` + * 와 **짝**이며 갈리면 시험이 깨진다(`tmp/tests/test_b06_user_touched_keys.py`). + * + * 손으로 나열하는 자리를 하나로 모은 것이다(2026-09-07) — 나열이 흩어져 있어 새 값을 + * 더할 때 한 곳만 빠지면 그 값이 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). + */ +export const USER_TOUCHED_KEYS = [ "display_half_width_m", "inlet_structure", "basin_adjust", @@ -74,8 +81,13 @@ const PRESERVED_KEYS = [ "extra_spans", "revet_link_detached", "revet_follow_grade", + // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", ] as const; +/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ +const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; + function preserveUserFields( next: NonNullable, previous: CrossSection["design"], @@ -123,6 +135,25 @@ function readRockOffsets(projectId: string, routeId: number): Map { + const spans = readBermSpans(projectId, routeId); + const out: Record = {}; + if (!spans.length) return out; + for (const section of detail.cross_sections) { + const spec = bermSpecAt(spans, section.chainage_m); + if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec; + } + return out; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -153,6 +184,19 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 + // 순간 계단이 사라진다(계획서 3-9). + const bermSpans = readBermSpans(projectId, input.routeId); + const bermAt = (chainageM: number): BermSpec | null => { + const spec = bermSpecAt(bermSpans, chainageM); + return spec + ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } + : null; + }; + // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 + // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + const choices = crossDesignChoices(projectId, input.routeId); + const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); const updated: number[] = []; for (const section of detail.cross_sections) { @@ -169,25 +213,43 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { : typeof storedOffset === "number" ? storedOffset : rockDefault; + const choice = choiceAt(section.chainage_m); let next; try { next = computeCrossDesign( section.samples ?? [], planElevationAt(alignment, section.chainage_m), { - groundType: typeof design.ground_type === "string" ? design.ground_type : "ripping_rock", - sectionMode: typeof design.section_mode === "string" ? design.section_mode : "left_cut", - ditchSide: typeof design.ditch_side === "string" ? design.ditch_side : null, + groundType: + choice?.ground_type ?? + (typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"), + sectionMode: + choice?.section_mode ?? + (typeof design.section_mode === "string" ? design.section_mode : "left_cut"), + ditchSide: + choice?.ditch_side ?? + (typeof design.ditch_side === "string" ? design.ditch_side : null), // 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다. - ditchType: typeof design.ditch_type === "string" ? design.ditch_type : "standard", - paved: Boolean(design.paved), + ditchType: + choice?.ditch_type ?? + (typeof design.ditch_type === "string" ? design.ditch_type : "standard"), + paved: choice?.paved ?? Boolean(design.paved), standard, + berm: bermAt(section.chainage_m), rockBoundaryOffsetM, twoStageSlope: - design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope), + choice?.two_stage_slope ?? + (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null, // 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다. surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0, + // 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06). + planRadiusM: section.plan_radius_m ?? null, + curveOuterSide: + section.curve_outer_side === "left" || section.curve_outer_side === "right" + ? section.curve_outer_side + : null, + curveWideningM: section.curve_widening_m ?? null, }, ); } catch { @@ -213,6 +275,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), + berms: bermPayload(projectId, routeId, detail), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Cut_Slope_Check.ts b/B06_Section/B06_Section_Cut_Slope_Check.ts new file mode 100644 index 00000000..ba64fa88 --- /dev/null +++ b/B06_Section/B06_Section_Cut_Slope_Check.ts @@ -0,0 +1,137 @@ +/* ============================================================================= + * B06_Section_Cut_Slope_Check.ts + * 절토 비탈 **법정 기울기 검사**(별표2) — 순수 판정만 둔다(그리기는 카드 몫). + * + * 왜 필요한가 — 평면 곡선반경도, 성토사면 길이(5m·기슭막이 의무)도 위반 표시가 있는데 + * **절토 경사만 검사가 없었다**(2026-09-07). 절토 경사비는 표준 횡단면 설정에서 오는 + * 입력값일 뿐이라 별표2 범위를 벗어나도 아무 표시가 없었다. + * + * ⚠ **실효 경사로 보면 안 된다.** 소단이 서면 사면 전체를 하나로 잰 경사가 완만해져 + * **위반이 사라진 것처럼** 보인다(실측: 폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). + * 그래서 `design.cut_slope_segments`(소단을 뺀 **구간별** 경사)를 읽는다. + * + * 기준값·매핑은 **서버가 준 값**을 쓴다(config 상수를 화면에 복제하지 않는다). + * ========================================================================== */ + +import type { CrossSection, SectionContextResponse } from "./B06_Section_Api_Fetch"; + +/** 별표2 한 줄 — 수직 1 에 대한 수평(1:n 의 n) 범위. */ +export interface CutSlopeLimit { + min: number; + max: number; +} + +export interface CutSlopeCriteria { + /** 별표2 범위표 — `hard_rock`·`soft_rock`·`soil`. */ + limits: Record; + /** 지반유형(리핑암·발파암) → 별표2 줄. 사용자가 바꿀 수 있는 **설정값**이다. */ + classOf: Record; + /** 절토 기울기 규정이 없는 등급(작업임도). */ + exemptGrades: ReadonlyArray; +} + +export interface CutSlopeViolation { + side: string; + ratio: number; + /** 어느 별표2 줄로 판정했나 — 툴팁에 적는다. */ + limitKey: string; + limit: CutSlopeLimit; + /** 급한 쪽 위반인지(`steep`) 완만한 쪽 위반인지(`gentle`). */ + kind: "steep" | "gentle"; + startOffsetM: number; + endOffsetM: number; +} + +/** 서버 컨텍스트에서 기준을 꺼낸다. 값이 안 왔으면 null — 검사를 하지 않는다. */ +export function criteriaFrom(context: SectionContextResponse | null): CutSlopeCriteria | null { + const limits = context?.cut_slope_limits; + if (!limits || !Object.keys(limits).length) return null; + const table: Record = {}; + for (const [key, pair] of Object.entries(limits)) { + if (Array.isArray(pair) && pair.length >= 2) table[key] = { min: pair[0], max: pair[1] }; + } + return { + limits: table, + classOf: context?.cut_slope_class_default ?? {}, + exemptGrades: context?.cut_slope_exempt_grades ?? [], + }; +} + +/** + * 이 측점의 절토 구간이 별표2 범위를 벗어났는지. + * + * · 등급이 면제(작업임도)면 빈 목록. + * · 구간이 없으면(성토 측점 등) 빈 목록. + * · 암 구간은 측점의 `cut_rock_kind` 로 별표2 줄을 고른다 — 구간에는 `rock`/`soil` 만 있다. + */ +export function cutSlopeViolations( + section: CrossSection, + criteria: CutSlopeCriteria | null, + gradeClass: string | null, +): CutSlopeViolation[] { + if (!criteria) return []; + if (gradeClass && criteria.exemptGrades.includes(gradeClass)) return []; + const design = section.design as + { cut_slope_segments?: unknown; cut_rock_kind?: string | null } | undefined; + const segments = design?.cut_slope_segments; + if (!Array.isArray(segments) || !segments.length) return []; + + const rockKey = criteria.classOf[design?.cut_rock_kind ?? ""] ?? "hard_rock"; + const out: CutSlopeViolation[] = []; + for (const raw of segments) { + const segment = raw as { + side?: string; + ratio?: number; + material?: string | null; + start_offset_m?: number; + end_offset_m?: number; + }; + const ratio = segment.ratio; + if (typeof ratio !== "number" || !Number.isFinite(ratio)) continue; + const limitKey = segment.material === "rock" ? rockKey : "soil"; + const limit = criteria.limits[limitKey]; + if (!limit) continue; + // 경계값은 통과 — 1:0.8 은 「0.3~0.8」 안이다. + if (ratio >= limit.min - 1e-9 && ratio <= limit.max + 1e-9) continue; + out.push({ + side: segment.side ?? "", + ratio, + limitKey, + limit, + kind: ratio < limit.min ? "steep" : "gentle", + startOffsetM: segment.start_offset_m ?? 0, + endOffsetM: segment.end_offset_m ?? 0, + }); + } + return out; +} + +/** 별표2 줄 이름 — 화면·툴팁에 적는 말. */ +export function limitLabel(key: string): string { + if (key === "hard_rock") return "경암"; + if (key === "soft_rock") return "연암"; + return "토사"; +} + +/* ── 기억해 둔 기준 ────────────────────────────────────────────────────────── + * 카드는 그리는 자리마다 컨텍스트를 들고 있지 않다. 표준단면 기본값을 기억해 두는 것과 + * 같은 방식으로(`rememberStandardDefaults`) 여기 한 번 담아 두고 카드가 읽는다. + * ------------------------------------------------------------------------ */ +let remembered: CutSlopeCriteria | null = null; +let rememberedGrade: string | null = null; + +export function rememberCutSlopeCriteria(context: SectionContextResponse | null): void { + remembered = criteriaFrom(context); + rememberedGrade = context?.road_type ?? null; +} + +/** 사용자가 고른 매핑(리핑암·발파암 → 별표2 줄)을 얹는다. 없으면 기본값 그대로. */ +export function applyCutSlopeClassChoice(choice: Record | null | undefined): void { + if (!remembered || !choice) return; + remembered = { ...remembered, classOf: { ...remembered.classOf, ...choice } }; +} + +/** 지금 측점의 위반 목록 — 기준이 없거나 면제 등급이면 빈 목록. */ +export function violationsOf(section: CrossSection): CutSlopeViolation[] { + return cutSlopeViolations(section, remembered, rememberedGrade); +} diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 43c39bca..b513afd5 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -4,6 +4,10 @@ B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고 **정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다. +⚠ TS 짝: `common_util/common_util_culvert_sets.ts` (2026-09-06). 한쪽만 고치면 화면과 +저장본이 갈린다 — 거울 테스트 `tmp/tests/test_b06_culvert_sets_mirror.py` 로 지킨다. +매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 골랐다(CLAUDE.md 5장 「계산 자리」 ①). + 근거(지식DB): · 관은 **지반선상 매설** — 뜨게 매설은 부적합 (교본 8장, 그림 8-3·8-9) · 관 지름 1,000㎜ 이상(현지여건 800㎜ 이상) — 별표2 Ⅰ.2.사.(2) diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index a9fa8a11..7c7a86a7 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -27,6 +27,7 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ +import math from collections.abc import Callable from typing import Any @@ -34,14 +35,22 @@ from B06_Section.B06_Section_Engine_Areas import ( _split_cut_areas, _trapezoid_areas, ) +from common_util.common_util_cross_berm import ( + BermSpec, + cut_profile_points, +) +from common_util.common_util_cross_berm import elevation_at as berm_elevation_at from config.config_system import ( + CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, SECTION_DITCH_TYPES, SECTION_GROUND_TYPE_PRESET, SECTION_MODES, STANDARD_CROSS_SECTION, ) - +from config.config_system import ( + curve_widening_m as _curve_widening_m, +) # 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다. _SLOPE_CLOSE_TOLERANCE_M = 0.01 @@ -159,11 +168,18 @@ class _SectionGeometry: rock_boundary_offset_m: float | None = None, two_stage_slope: bool = False, ditch_enabled: bool | None = None, + widening_left_m: float = 0.0, + widening_right_m: float = 0.0, + berm: BermSpec | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 - self.half_road = half_road # 차도 반폭(노견 제외) — 포장 범위 기준 - self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝 - self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝 + # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). + # 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다. + self.half_road_left = half_road + max(widening_left_m, 0.0) + self.half_road_right = half_road + max(widening_right_m, 0.0) + self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준 + self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝 + self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝 self.z_center = design_elevation_m self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사 self.fill_ratio = max(group["fill_slope_ratio"], 1e-6) @@ -177,7 +193,9 @@ class _SectionGeometry: ) self._ground_at = ground_at self._rock_offset = rock_boundary_offset_m or 0.0 - self._rock_knee: dict[str, tuple[float, float] | None] = {} + # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. + self.berm = berm + self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). self._cut_cross: dict[str, float | None] = {} self._fill_cross: dict[str, float | None] = {} @@ -266,52 +284,98 @@ class _SectionGeometry: assert self._ground_at is not None # two_stage일 때만 호출 return self._ground_at(signed) + self._rock_offset - def knee(self, side: str) -> tuple[float, float] | None: - """절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시). + def cut_points(self, side: str) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. - 노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터 - 토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면 - None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다. + 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). + 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). """ - if not self.two_stage: - return None - if side in self._rock_knee: - return self._rock_knee[side] + if side in self._cut_points_cache: + return self._cut_points_cache[side] start_dist, start_z = self._slope_start(side) - diff_prev = start_z - self._rock_boundary_z(side, start_dist) - result: tuple[float, float] | None - if diff_prev >= 0: - result = (start_dist, start_z) # 시작부터 토사(경계 위) - else: - result = None - step = 0.05 - dist_prev = start_dist - dist = start_dist + step - while dist <= start_dist + 200.0: - z_rock = start_z + (dist - start_dist) / self.cut_ratio - diff = z_rock - self._rock_boundary_z(side, dist) - if diff >= 0: - span = diff - diff_prev - ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0 - knee_dist = dist_prev + (dist - dist_prev) * ratio - knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio - result = (knee_dist, knee_z) - break - dist_prev, diff_prev = dist, diff - dist += step - self._rock_knee[side] = result - return result + boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None + points = cut_profile_points( + start_dist, + start_z, + self.cut_ratio, + self.soil_cut_ratio, + boundary, + self.berm, + # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 + # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 + # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). + multi_knee=self.berm is not None, + ) + self._cut_points_cache[side] = points + return points def _cut_slope_z(self, side: str, dist: float) -> float: - """절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다.""" - start_dist, start_z = self._slope_start(side) - knee = self.knee(side) if self.two_stage else None - if knee is not None: - knee_dist, knee_z = knee - if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사 - return start_z + (dist - start_dist) / self.cut_ratio - return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만 - return start_z + (dist - start_dist) / self.cut_ratio + """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" + return berm_elevation_at(self.cut_points(side), dist) + + def cut_slope_segments(self) -> list[dict[str, Any]]: + """절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다. + + 왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 + **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는 + 소단을 뺀 **사면 구간 자체의 경사**를 봐야 하므로 그 구간을 여기서 내보낸다. + + · 평탄부(소단)는 싣지 않는다 — 검사 대상이 아니고 경사비가 무한대가 된다. + · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. + · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. + 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). + """ + segments: list[dict[str, Any]] = [] + for side in ("left", "right"): + role = self.left_role if side == "left" else self.right_role + if role != "cut": + continue + cross = self.cut_cross_dist(side) + points = self.cut_points(side) + sign = 1.0 if side == "left" else -1.0 + for index in range(1, len(points)): + start_d, start_z = points[index - 1] + end_d, end_z = points[index] + if cross is not None and start_d >= cross - 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + if cross is not None and end_d > cross: + # 지반과 만나는 점에서 구간을 자른다. + end_z = berm_elevation_at(points, cross) + end_d = cross + run = end_d - start_d + rise = end_z - start_z + if run <= 1e-9 or rise <= 1e-6: + continue # 길이 0·역방향은 검사 대상이 아니다 + if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: + # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. + berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m + if abs(rise - berm_rise) < 1e-9: + continue + # 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면 + # 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사 + # 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로 + # 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창 + # 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다. + material: str | None = None + if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9: + drawn = run / rise + material = ( + "soil" + if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio) + else "rock" + ) + segments.append( + { + "side": side, + "ratio": round(run / rise, 4), + "rise_m": round(rise, 4), + "run_m": round(run, 4), + "start_offset_m": round(sign * start_d, 4), + "end_offset_m": round(sign * end_d, 4), + "material": material, + } + ) + return segments def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). @@ -430,15 +494,20 @@ class _SectionGeometry: return max(fill_line, ground_m) def breakpoints(self) -> list[float]: - """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함).""" + """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" points = [0.0, self.left_extent, -self.right_extent] points.extend(offset for offset, _z in self.ditch_points) - if self.two_stage: + # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if self.two_stage or self.berm is not None: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role - knee = self.knee(side) if role == "cut" else None - if knee is not None: - points.append(knee[0] if side == "left" else -knee[0]) + if role != "cut": + continue + cross = self.cut_cross_dist(side) + for offset, _z in self.cut_points(side): + if cross is not None and offset > cross + 1e-9: + break # 지반과 만난 뒤는 절토가 없다 + points.append(offset if side == "left" else -offset) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role @@ -448,6 +517,22 @@ class _SectionGeometry: return points +def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]: + """측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다. + + 측점마다 실려 오는 값이라 호출자마다 따로 꺼내 쓰면 빠뜨리기 쉽다(2026-09-06). + 옛 저장분에는 두 값이 없어 확폭 없이 예전과 같은 단면이 나온다. + """ + if not isinstance(section, dict): + return {"plan_radius_m": None, "curve_outer_side": None, "curve_widening_m": None} + return { + "plan_radius_m": section.get("plan_radius_m"), + "curve_outer_side": section.get("curve_outer_side"), + # 곡선 앞뒤 테이퍼가 얹힌 값 — 있으면 반경 표값 대신 이걸 쓴다(2026-09-06). + "curve_widening_m": section.get("curve_widening_m"), + } + + def compute_cross_design( samples: list[dict[str, Any]], design_elevation_m: float | None, @@ -462,6 +547,10 @@ def compute_cross_design( two_stage_slope: bool = True, ditch_enabled: bool | None = None, surface_drop_m: float = 0.0, + plan_radius_m: float | None = None, + curve_outer_side: str | None = None, + curve_widening_m: float | None = None, + berm: BermSpec | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -472,6 +561,10 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 + 입력이며, None·45m 이상이면 확폭이 없다. + curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 + (2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다. surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). @@ -515,6 +608,22 @@ def compute_cross_design( preset_key == "rock" and two_stage_slope and rock_boundary_offset_m is not None ) soil_cut_ratio = _resolve_group("soil", standard)["cut_slope_ratio"] + # 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다. + # 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다. + if curve_outer_side in ("left", "right"): + widening = ( + float(curve_widening_m) + if isinstance(curve_widening_m, (int, float)) + else _curve_widening_m(plan_radius_m) + ) + else: + widening = 0.0 + if widening > 0.0: + room = max(CURVE_WIDENING_MAX_WIDTH_M - group["road_width_m"], 0.0) + widening = min(widening, room) + widening_left = widening if curve_outer_side == "left" else 0.0 + widening_right = widening if curve_outer_side == "right" else 0.0 + geometry = _SectionGeometry( design_elevation_m=design_elevation_m, group=group, @@ -527,6 +636,9 @@ def compute_cross_design( rock_boundary_offset_m=rock_boundary_offset_m, two_stage_slope=enable_two_stage, ditch_enabled=ditch_enabled, + widening_left_m=widening_left, + widening_right_m=widening_right, + berm=berm, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 @@ -614,10 +726,19 @@ def compute_cross_design( "ditch_type": ditch_type if geometry.has_ditch else None, "cut_slope_ratio": round(geometry.cut_ratio, 4), "soil_cut_slope_ratio": round(geometry.soil_cut_ratio, 4), - "two_stage_slope": bool(geometry.two_stage), + # 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.two_stage`)가 + # 아니다(2026-09-07). 적용 결과를 저장하면, 토사 측점처럼 못 쓰는 자리에서 false 가 + # 저장되고 그 false 가 다음 재계산 인자로 되먹여져 **사용자의 「켬」이 영구히 사라졌다.** + # 「실제로 적용됐나」를 읽는 곳은 코드 전체에 하나도 없다(2026-09-07 전수 확인) — + # 읽는 쪽은 모두 사용자 설정으로 쓴다. TS 짝: `common_util_cross_design.ts`. + "two_stage_slope": bool(two_stage_slope), "fill_slope_ratio": round(geometry.fill_ratio, 4), "roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4), - "carriageway_width_m": round(group["road_width_m"], 4), + "carriageway_width_m": round(geometry.half_road_left + geometry.half_road_right, 4), + # 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다. + "carriageway_standard_width_m": round(group["road_width_m"], 4), + "widening_left_m": round(geometry.half_road_left - geometry.half_road, 4), + "widening_right_m": round(geometry.half_road_right - geometry.half_road, 4), "cross_slope_pct": round(cross_slope_pct, 4), "ditch": ditch_spec, "ditch_enabled": bool(geometry.has_ditch), @@ -636,12 +757,12 @@ def compute_cross_design( # 차도(노견 제외) 양 끝점 — 포장 범위 기준(D-5). "carriageway_edges": { "left": { - "offset_m": round(geometry.half_road, 4), - "elevation_m": round(geometry.road_z(geometry.half_road), 4), + "offset_m": round(geometry.half_road_left, 4), + "elevation_m": round(geometry.road_z(geometry.half_road_left), 4), }, "right": { - "offset_m": round(-geometry.half_road, 4), - "elevation_m": round(geometry.road_z(-geometry.half_road), 4), + "offset_m": round(-geometry.half_road_right, 4), + "elevation_m": round(geometry.road_z(-geometry.half_road_right), 4), }, }, "design_elevation_m": round(float(design_elevation_m), 4), @@ -659,10 +780,20 @@ def compute_cross_design( ), "ditch_area_m2": round(ditch_area, 4), "design_line": design_line, + # 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외). + "cut_slope_segments": geometry.cut_slope_segments(), } if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. result["surface_drop_m"] = round(drop, 4) + if berm is not None: + # 소단 제원을 설계에 되싣는다 — 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 + # 계단이 다시 서야 한다. 암 경계선 오프셋을 echo 하는 것과 같은 까닭이다. + result["berm"] = { + "width_m": round(float(berm.width_m), 4), + "interval_m": round(float(berm.interval_m), 4), + "slope_deg": round(float(berm.slope_deg), 4), + } if paved: result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4) # 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다. diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py new file mode 100644 index 00000000..e4f9c143 --- /dev/null +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -0,0 +1,107 @@ +"""구조물 정본(C군 사면안정 벽)을 횡단 측점에 얹는다. + +왜 필요한가(2026-09-06 사용자 확정) — 좌측 「구조물 배치」로 넣은 옹벽·돌쌓기 같은 벽이 +횡단도에도 서고 **절·성토 면적에도 반영**돼야 한다. 지금까지 이 목록은 측점만 심고 +(`B05_Profile_Engine_Sections.resolve_extra_stations`) 기하가 없어 면적이 그대로였다. + +방법은 **이미 도는 길을 그대로 태우는 것**이다. 독립 기슭막이가 쓰는 `section.revetment` +제원과 같은 꼴로 얹으면 횡단 기하(`B06_Section_UI_Cross_Revetment`)·설계선 트림·폐회로 +면적(`B06_Section_Structure_Layouts`)·3D 가 손대지 않고 따라온다. 옛 D군 기슭막이가 +관 정본으로 이관되며 사라졌던 `attach_revetments`(2026-08-28)를 C군 벽으로 되살린 것이다. + +치수는 여기서 정하지 않는다 — 높이·형태만 넘기고 도형은 화면·3D 가 같은 산식으로 그린다. + +**짝**: `common_util/common_util_structure_walls.ts` — 브라우저는 아직 저장하지 않은 +목록으로 같은 제원을 만들어야 해서 한 벌을 더 둔다. 거울 테스트 +`tmp/tests/test_b06_structure_walls_mirror.py` 가 같은 값이 나오는지 대조한다. +""" + +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +logger = logging.getLogger(__name__) + +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. +_EDGE_TOLERANCE_M = 0.02 + +# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태가 벽 높이 한계·두께를 정하므로 +# (`B06_Section_UI_Cross_Revetment.revetHeightLimit`) 가장 가까운 것으로 잇는다. +_FORM_BY_TYPE = { + "masonry_wet": "돌쌓기(찰)", + "masonry_dry": "돌쌓기(메)", + "boulder_masonry": "돌쌓기(메)", + "retaining_wall": "콘크리트", + "soil_guard": "통나무·목재틀", +} + + +def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: + """구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록.""" + try: + types = structure_type_map() + found: list[dict[str, Any]] = [] + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.group != "C" or definition.placement != "interval": + continue + start, end = structure.start_m, structure.end_m + if start is None or end is None: + continue + options = structure.options or {} + found.append( + { + "structure_id": structure.structure_id, + "type_id": structure.type_id, + "name": definition.name, + "start_m": float(min(start, end)), + "end_m": float(max(start, end)), + "anchor_m": float(structure.anchor_m()), + "form": options.get("form") or _FORM_BY_TYPE.get(structure.type_id), + "height_m": options.get("height_m"), + # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 + # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. + "side": options.get("side"), + "tiers": options.get("tiers"), + "lift_m": options.get("lift_m"), + "shift_m": options.get("shift_m"), + } + ) + return found + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 + logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)") + return [] + + +def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다. + + 이미 관 정본이 얹은 세트(`culvert`·`revetment`)가 있는 측점은 **건드리지 않는다** — + 관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다. + 한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫). + """ + walls = load_wall_structures(project_root) + if not walls: + return 0 + attached = 0 + for section in cross_sections: + chainage = section.get("chainage_m") + if not isinstance(chainage, (int, float)): + continue + if section.get("culvert") or section.get("revetment"): + continue + for spec in walls: + if ( + spec["start_m"] - _EDGE_TOLERANCE_M + <= float(chainage) + <= spec["end_m"] + _EDGE_TOLERANCE_M + ): + section["revetment"] = spec + attached += 1 + break + if attached: + logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls)) + return attached diff --git a/B06_Section/B06_Section_Repository_Bulk.py b/B06_Section/B06_Section_Repository_Bulk.py new file mode 100644 index 00000000..c2a83e27 --- /dev/null +++ b/B06_Section/B06_Section_Repository_Bulk.py @@ -0,0 +1,124 @@ +"""측점 설계를 **여러 행 한 번에** 쓰는 자리. + +왜 (2026-09-06 실측) — [저장]이 측점마다 `update_cross_section_design` · +`merge_cross_section_design_patch` 를 불렀고, 그 하나가 `SELECT` + `UPDATE` 두 왕복이다. +DB 가 원격(`dsm.chemifactory.com`)이라 왕복 하나가 **약 12ms** 다. 22행이면 왕복 44번, +곧 **670ms**. 측점이 많은 프로젝트일수록 선형으로 늘어난다. + +여기서는 세 문장으로 끝낸다 — + ① 노선의 측점 행을 **한 번에** 읽고 + ② 파이썬에서 chainage 를 맞춰 JSON 을 합치고 + ③ `UPDATE … SET data = CASE id …` 한 문장으로 되돌려 쓴다(없는 행은 다중 INSERT). + +`B06_Section_Repository` 가 685줄이라 700줄 한계에 걸려 파일을 나눴다. 한 행짜리 함수는 +그쪽에 그대로 두고, 여러 행을 쓸 때만 이쪽을 쓴다. +""" + +from __future__ import annotations + +import json +from typing import Any +from uuid import UUID + +import aiomysql + +# 측점을 같은 자리로 볼 허용 오차(m) — 한 행짜리 함수와 같은 값을 쓴다. +_CHAINAGE_TOLERANCE_M = 0.01 + + +async def merge_cross_section_designs( + connection: aiomysql.Connection, + *, + route_id: int, + entries: list[tuple[float, dict[str, Any]]], + replace: bool, + project_id: UUID | None = None, +) -> int: + """측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다. + + `replace=True` 면 design 을 통째로 갈아 끼우고(`update_cross_section_design` 과 같은 뜻), + `False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻). + + 행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이 + 파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙). + """ + if not entries: + return 0 + + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT id, chainage_m, data FROM cross_sections WHERE route_id = %s ORDER BY id", + (route_id,), + ) + rows = await cursor.fetchall() + + existing: dict[int, dict[str, Any]] = {} + for row_id, _chainage, raw in rows: + data = json.loads(raw) if isinstance(raw, str) else raw + existing[int(row_id)] = data if isinstance(data, dict) else {} + # 같은 측점이 여러 행이면 뒤에 온 것(=큰 id)을 쓴다 — 한 행짜리 함수의 `ORDER BY id DESC`. + ordered = [(float(chainage), int(row_id)) for row_id, chainage, _ in rows] + + def find(chainage_m: float) -> int | None: + best: int | None = None + for value, row_id in ordered: + if abs(value - chainage_m) < _CHAINAGE_TOLERANCE_M and (best is None or row_id > best): + best = row_id + return best + + updates: list[tuple[int, str]] = [] + inserts: list[tuple[str, int, float, str]] = [] + for chainage_m, payload in entries: + if not payload and not replace: + continue + row_id = find(chainage_m) + if row_id is None: + if project_id is None: + continue + inserts.append( + ( + str(project_id), + route_id, + chainage_m, + json.dumps({"design": payload}, ensure_ascii=False), + ) + ) + continue + data = dict(existing[row_id]) + if replace: + data["design"] = payload + else: + design = data.get("design") + design = dict(design) if isinstance(design, dict) else {} + design.update(payload) + data["design"] = design + updates.append((row_id, json.dumps(data, ensure_ascii=False))) + + written = 0 + async with connection.cursor() as cursor: + if updates: + # 한 문장 — `CASE id WHEN … THEN …` 이라 왕복이 한 번이다. + cases = " ".join("WHEN %s THEN %s" for _ in updates) + params: list[Any] = [] + for row_id, blob in updates: + params.extend((row_id, blob)) + params.extend(row_id for row_id, _ in updates) + placeholders = ", ".join("%s" for _ in updates) + await cursor.execute( + f"UPDATE cross_sections SET data = CASE id {cases} END " + f"WHERE id IN ({placeholders})", + params, + ) + written += len(updates) + if inserts: + values = ", ".join("(%s, %s, %s, %s, 'DRAFT')" for _ in inserts) + flat: list[Any] = [] + for item in inserts: + flat.extend(item) + await cursor.execute( + "INSERT INTO cross_sections (project_id, route_id, chainage_m, data, status) " + f"VALUES {values}", + flat, + ) + written += len(inserts) + return written diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 9c7a7a16..60fb3384 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -15,23 +15,23 @@ from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( - cross_filename, prune_stale_cross_files, run_section_generation, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets -from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, delete_sections_for_route, - get_workflow_route_context, get_cross_section_designs, get_latest_section_options, get_longitudinal_section, get_project_standard_cross_section, get_route_generation_source, + get_workflow_route_context, insert_cross_sections, list_recent_company_projects, update_cross_section_design, @@ -40,21 +40,13 @@ from B06_Section.B06_Section_Router_Design import ( PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS, ) from B06_Section.B06_Section_Router_Design import ( - attach_default_designs as _attach_default_designs, - compute_default_designs as _compute_default_designs, - enforce_pavement_ranges as _enforce_pavement_ranges, - enforce_ford_surface_drops as _enforce_ford_surface_drops, - stored_standard_cross_section as _stored_standard_cross_section, - pavement_ranges as _pavement_ranges, - paved_at as _paved_at, - ford_surface_drops, + USER_TOUCHED_KEYS, ford_drop_at, + ford_surface_drops, + stored_berm, ) from B06_Section.B06_Section_Router_Design import ( - default_section_modes as _default_section_modes, -) -from B06_Section.B06_Section_Router_Design import ( - pavement_suggestions as _pavement_suggestions, + attach_default_designs as _attach_default_designs, ) from B06_Section.B06_Section_Router_Design import ( read_cross_design_inputs as _read_cross_design_inputs, @@ -62,6 +54,9 @@ from B06_Section.B06_Section_Router_Design import ( from B06_Section.B06_Section_Router_Design import ( recompute_designs_for_alignment as _recompute_designs_for_alignment, ) +from B06_Section.B06_Section_Router_Design import ( + stored_standard_cross_section as _stored_standard_cross_section, +) from B06_Section.B06_Section_Schema import ( CompanyStandardListResponse, CompanyStandardProject, @@ -78,14 +73,16 @@ from B06_Section.B06_Section_Schema import ( SectionSummaryResponse, ) from common_util.common_util_auth import verify_session -from common_util.common_util_route_profile import design_elevation_from_longitudinal from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state -from config.config_db import get_db_pool +from config.config_db import get_db_pool, run_with_connection from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, + FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT, + FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES, + FOREST_ROAD_CUT_SLOPE_LIMITS, FOREST_ROAD_MIN_WIDTH_M, NATURAL_SPOIL_MIN_GROUND_SLOPE, SECTION_VERTICAL_EXAGGERATION, @@ -101,20 +98,33 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) @router.get("/{project_id}/sections/context", response_model=SectionContextResponse) async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse: """최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다.""" - pool = get_db_pool() try: - async with pool.acquire() as connection: - # 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야 - # 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화). - route_context = await get_workflow_route_context(connection, project_id) - surface_params = await get_surface_confirmation_params(connection, str(project_id)) - # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. + # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. + async def _road_type(connection: aiomysql.Connection) -> str | None: async with connection.cursor() as cursor: await cursor.execute( "SELECT road_type FROM projects WHERE id = %s", (str(project_id),) ) row = await cursor.fetchone() - road_type = row[0] if row else None + return row[0] if row else None + + # 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야 + # 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화). + # 셋은 서로 기다릴 이유가 없다 — 원격 DB 왕복(약 12ms)이 더해지지 않게 같이 보낸다. + route_context, surface_params, road_type = await asyncio.gather( + run_with_connection(get_workflow_route_context, project_id), + run_with_connection(get_surface_confirmation_params, str(project_id)), + run_with_connection(_road_type), + ) + + # 저장된 표준 횡단면 — 브라우저 계산이 서버와 같은 값을 쓰게 함께 내려보낸다 + # (2026-09-07). 노선이 없으면 저장분도 없다. + stored_standard: dict[str, Any] | None = None + if route_context and route_context.get("route_id") is not None: + longitudinal_row = await run_with_connection( + get_longitudinal_section, project_id, int(route_context["route_id"]) + ) + stored_standard = _stored_standard_cross_section(longitudinal_row) defaults = SectionGenerationOptions() return SectionContextResponse( @@ -133,6 +143,12 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION, ), standard_cross_section=STANDARD_CROSS_SECTION, + stored_standard_cross_section=stored_standard, + cut_slope_limits={ + key: list(value) for key, value in FOREST_ROAD_CUT_SLOPE_LIMITS.items() + }, + cut_slope_class_default=dict(FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT), + cut_slope_exempt_grades=list(FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES), rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M, earthwork_conversion=EARTHWORK_CONVERSION_FACTORS, @@ -259,6 +275,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). attach_culvert_sets(root, cross_sections) + # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — + # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). + attach_wall_structures(root, cross_sections) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -290,17 +309,19 @@ async def get_section_detail( project_id: UUID, route_id: int ) -> SectionDetailResponse | JSONResponse: """경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.""" - pool = get_db_pool() try: - async with pool.acquire() as connection: - longitudinal = await get_longitudinal_section(connection, project_id, route_id) - if not longitudinal: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, - ) - stored_path = await get_project_storage_relative_path(connection, project_id) - designs = await get_cross_section_designs(connection, route_id) + # 서로 기다릴 이유가 없는 읽기 셋 — 원격 DB 라 순차로 내면 왕복이 그대로 더해진다 + # (질의 하나 약 12ms, 2026-09-06 실측). 같이 보내 가장 느린 하나의 시간만 쓴다. + longitudinal, stored_path, designs = await asyncio.gather( + run_with_connection(get_longitudinal_section, project_id, route_id), + run_with_connection(get_project_storage_relative_path, project_id), + run_with_connection(get_cross_section_designs, route_id), + ) + if not longitudinal: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) project_root = Path(resolve_stored_project_path(stored_path)) detail = await asyncio.to_thread( _read_section_detail, @@ -313,27 +334,14 @@ async def get_section_detail( if abs(float(section.get("chainage_m", 0.0)) - record["chainage_m"]) < 0.01: section["design"] = record["design"] break - # 포장 구간·물넘이 범위는 저장분이 비포장이어도 포장으로 맞춘다(2026-08-28). - # 표준횡단면은 확정 때 저장해 둔 사용자 값을 쓴다 — 없으면 config 기본값. + # 포장 구간·세월교 노면 하강 보정은 **저장 때**로 옮겼다(2026-09-06 사용자 확정: + # 「읽을 때는 영구저장소에서 가져오기만」). 조회는 저장분을 그대로 싣는다 — + # 보정은 `B06_Section_Server_Calc_Prebuild.recompute_server_side` 가 [저장]·[확정]과 + # 자동설계 체인에서 돌려 정본에 남긴다. standard = _stored_standard_cross_section(longitudinal) - await asyncio.to_thread( - _enforce_pavement_ranges, - detail["longitudinal"], - detail["cross_sections"], - project_root, - standard, - ) - # 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉는다 — 저장분이 옛 계획고면 - # 여기서 다시 계산한다(2026-08-30 사용자 확정). - await asyncio.to_thread( - _enforce_ford_surface_drops, - detail["longitudinal"], - detail["cross_sections"], - project_root, - standard, - ) # 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다. - # (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.) + # 저장분이 있으면 **아무것도 하지 않는다**(측점마다 design 유무만 본다) — 아직 + # 저장된 적 없는 비정규 측점만 이 폴백을 탄다. await asyncio.to_thread( _attach_default_designs, detail["longitudinal"], @@ -490,8 +498,9 @@ async def regenerate_sections( await connection.rollback() raise result = sections["result"] - # 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다. + # 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다. attach_culvert_sets(project_root, result["cross_sections"]) + attach_wall_structures(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) @@ -553,6 +562,7 @@ async def preview_cross_designs( request.standard_cross_section, request.rock_boundary_offsets, project_root, + request.berms, ) await asyncio.to_thread(rebuild) @@ -606,8 +616,18 @@ async def compute_cross_section_design( content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, ) stored_path = await get_project_storage_relative_path(connection, project_id) + # 저장분을 **계산 전에** 읽는다 — 소단은 사용자 조작값이면서 **기하 입력**이라, + # 나중에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 + # 서로 어긋난다(2026-09-07). 값을 나르는 다른 사용자 키와 다른 점이다. + stored_designs = await get_cross_section_designs(connection, route_id) + stored_design: dict[str, Any] | None = None + for record in stored_designs: + if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: + candidate = record.get("design") + stored_design = candidate if isinstance(candidate, dict) else None + break project_root = Path(resolve_stored_project_path(stored_path)) - samples, design_elevation, pavement_suggested = await asyncio.to_thread( + samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread( _read_cross_design_inputs, project_root, str(longitudinal["longitudinal_file_path"]), @@ -626,33 +646,22 @@ async def compute_cross_section_design( two_stage_slope=request.two_stage_slope, ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), + berm=stored_berm(stored_design or {}), + **curve_widening_args(cross_record), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" # 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다. design["pavement_suggested"] = pavement_suggested + # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 + # 지우지 않게 한다(2026-08-06). 목록을 여기 다시 적지 않는다 — 서버의 한 벌은 + # `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). 예전에는 여기에 + # 따로 적어 두어 `extra_spans` 가 빠져 있었다. + if stored_design is not None: + for key in USER_TOUCHED_KEYS: + if stored_design.get(key) is not None: + design[key] = stored_design[key] async with pool.acquire() as connection: - # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 - # 지우지 않게 한다(2026-08-06). - stored_designs = await get_cross_section_designs(connection, route_id) - for record in stored_designs: - if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: - stored_design = record.get("design") - if isinstance(stored_design, dict): - for key in ( - "display_half_width_m", - "inlet_structure", - "basin_adjust", - "ford_adjust", - "box_adjust", - "revet_adjust", - "extra_wall_counts", - "revet_link_detached", - "revet_follow_grade", - ): - if stored_design.get(key) is not None: - design[key] = stored_design[key] - break await connection.begin() try: updated = await update_cross_section_design( diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index 418f1039..e4755389 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -11,6 +11,7 @@ import asyncio import json import logging +import time from pathlib import Path from typing import Any from uuid import UUID @@ -20,6 +21,7 @@ from fastapi import APIRouter, Body from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B03_FileInput.B03_FileInput_Service_Chain import _log_steps from B05_Profile.B05_Profile_Repository import confirm_route as confirm_route_status from B05_Profile.B05_Profile_Router_Confirm import _merge_uphill_overrides_into_longitudinal from B06_Section.B06_Section_Repository import ( @@ -28,12 +30,16 @@ from B06_Section.B06_Section_Repository import ( get_cross_section_designs, get_cross_sections_missing_design_chainages, get_longitudinal_section, - merge_cross_section_design_patch, merge_longitudinal_section_data, merge_longitudinal_section_options, - update_cross_section_design, ) -from B06_Section.B06_Section_Router import _compute_default_designs +from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs + +# 원본은 `B06_Section_Router_Design` 이다 — `B06_Section_Router` 를 거쳐 들여오던 것을 +# 곧바로 잇는다(2026-09-06). 그 재수출이 없어지면서 서버가 뜨지 못했다. +from B06_Section.B06_Section_Router_Design import ( + compute_default_designs as _compute_default_designs, +) from B06_Section.B06_Section_Schema import ( SectionConfirmRequest, SectionConfirmResponse, @@ -90,14 +96,14 @@ async def _apply_section_edits( project_id: UUID | None = None, ) -> None: """임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다.""" - for chainage_m, design in default_designs: - await update_cross_section_design( - connection, - route_id=route_id, - chainage_m=chainage_m, - design=design, - project_id=project_id, - ) + # 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다. + await merge_cross_section_designs( + connection, + route_id=route_id, + entries=list(default_designs), + replace=True, + project_id=project_id, + ) if request and request.standard_cross_section: await merge_longitudinal_section_options( connection, @@ -111,39 +117,50 @@ async def _apply_section_edits( ) # 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합. if request and request.cross_patches: + patches: list[tuple[float, dict[str, Any]]] = [] for patch_item in request.cross_patches: - patch: dict[str, Any] = {} - if patch_item.rock_boundary_offset_m is not None: - patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m - if patch_item.display_half_width_m is not None: - patch["display_half_width_m"] = patch_item.display_half_width_m - if patch_item.inlet_structure is not None: - patch["inlet_structure"] = patch_item.inlet_structure - if patch_item.basin_adjust is not None: - patch["basin_adjust"] = patch_item.basin_adjust.model_dump() - # 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 남긴다(2026-08-24). - if patch_item.revet_adjust is not None: - patch["revet_adjust"] = { - role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items() - } - if patch_item.ford_adjust is not None: - patch["ford_adjust"] = patch_item.ford_adjust.model_dump() - if patch_item.box_adjust is not None: - patch["box_adjust"] = patch_item.box_adjust.model_dump() - if patch_item.extra_wall_counts is not None: - patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump() - if patch_item.extra_spans is not None: - patch["extra_spans"] = { - wall: span.model_dump() for wall, span in patch_item.extra_spans.items() - } - if patch_item.revet_link_detached is not None: - patch["revet_link_detached"] = patch_item.revet_link_detached - if patch_item.revet_follow_grade is not None: - patch["revet_follow_grade"] = patch_item.revet_follow_grade + # 필드를 손으로 나열하지 않는다(2026-09-07) — 나열이 네 곳에 흩어져 있어 새 값을 + # 더할 때 한 곳만 빠지면 그 값이 **조용히 사라졌다**(`extra_spans` 실사고 `b6941bd2`). + # 스키마가 곧 목록이므로 통째로 덤프하고 **최상위 None 만** 걷는다. + # (중첩 None 은 남긴다 — 기슭막이 4축의 `d: null` 은 「자동」이라는 뜻이다.) + dumped = patch_item.model_dump() + patch: dict[str, Any] = { + key: value + for key, value in dumped.items() + if key != "chainage_m" and value is not None + } if patch: - await merge_cross_section_design_patch( - connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch - ) + patches.append((patch_item.chainage_m, patch)) + # 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다. + await merge_cross_section_designs( + connection, route_id=route_id, entries=patches, replace=False + ) + + +async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None: + """정본을 **서버가 다시 계산**한다 — 사용자 편집이 들어간 **뒤**에 돈다. + + 포장 구간·세월교 노면 하강 보정에 더해 구조물 면적·유토곡선(배분·운반거리 포함)까지 + Node 진입점으로 새로 낸다. 브라우저가 보낸 값을 그대로 받아 적지 않는다. + + **왜 서버인가(2026-09-06 저녁 사용자 확정)** — 속도가 아니라 보안이다. 저장 경로가 + 브라우저 계산이면 유토 배분·운반거리 코드가 번들에 남아야 해서 화면에서 안 그려도 + 뺄 수 없다. 서버가 정본을 내면 그 몫이 번들에서 빠진다. 대가는 저장 대기가 + 850ms → 약 980ms 인데(Node 131ms) 기다리는 조작이라 허용한다. + + 사용자가 끌어 옮긴 balloon 위치는 서버가 만들지 않으므로 저장분에서 떼어 도로 붙인다 + (`B06_Section_Server_Calc_Prebuild`). 실패는 비치명적이다 — 저장은 그대로 남는다. + """ + try: + from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side + + await recompute_server_side(project_id, route_id) + except Exception: + logger.exception( + "저장분 서버 재계산 실패 (저장은 유지): project_id=%s route_id=%s", + project_id, + route_id, + ) @router.post("/{project_id}/sections/{route_id}/save", response_model=SectionConfirmResponse) @@ -200,6 +217,8 @@ async def save_sections( except Exception: await connection.rollback() raise + # 편집이 들어간 **뒤** 서버가 정본을 다시 낸다 — 바뀐 벽·측점이 면적·유토곡선에 실린다. + await _recompute_stored_designs(project_id, route_id) return SectionConfirmResponse( project_id=str(project_id), route_id=route_id, confirmed=False ) @@ -230,6 +249,7 @@ async def confirm_sections( 자동 계산 체인용 — 데이터만 확정하고 stage 3을 IN_PROGRESS(검토 대기)로 남긴다. """ pool = get_db_pool() + marks = [("시작", time.perf_counter())] try: async with pool.acquire() as connection: existing = await get_longitudinal_section(connection, project_id, route_id) @@ -242,6 +262,7 @@ async def confirm_sections( missing = await get_cross_sections_missing_design_chainages(connection, route_id) known = await get_cross_section_chainages(connection, route_id) + marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter())) project_root = Path(resolve_stored_project_path(stored_path)) # 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면 # 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24). @@ -263,6 +284,8 @@ async def confirm_sections( request.standard_cross_section if request else None, ) + marks.append(("기본설계 계산(미지정 측점)", time.perf_counter())) + async with pool.acquire() as connection: await connection.begin() try: @@ -284,6 +307,12 @@ async def confirm_sections( await connection.rollback() raise + marks.append(("확정 저장(트랜잭션)", time.perf_counter())) + + # 편집이 들어간 **뒤** 서버가 정본을 다시 낸다(임시저장과 같은 자리). + await _recompute_stored_designs(project_id, route_id) + marks.append(("서버 재계산", time.perf_counter())) + # 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7). # 파일 기반·비치명적: 실패해도 확정은 유지한다. try: @@ -308,6 +337,8 @@ async def confirm_sections( project_id, route_id, ) + marks.append(("측구 방향 B05 역반영", time.perf_counter())) + _log_steps("B06 확정", marks) return SectionConfirmResponse(project_id=str(project_id), route_id=route_id) except Exception: logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 9b67101e..f94d15e0 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -9,7 +9,13 @@ from B05_Profile.B05_Profile_Engine_Sections import cross_filename from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets -from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args +from common_util.common_util_cross_berm import ( + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, + BermSpec, +) from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M @@ -112,8 +118,16 @@ def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = return bool(stored) if isinstance(stored, bool) else False -# 사용자 조작값 — 포장 강제로 다시 계산해도 그대로 승계한다. -_USER_TOUCHED_KEYS = ( +# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.** +# +# 브라우저 쪽 짝은 `B06_Section_Cross_Refresh.ts` 의 `PRESERVED_KEYS` 다(+ `status`· +# `pavement_suggested`, 이 둘은 부르는 쪽이 따로 붙인다). 새 사용자 조작값을 만들면 +# **두 곳 모두**에 넣을 것 — 빠뜨리면 재계산이 조용히 지운다. +# +# ⚠ `extra_spans`(다단 구간값)가 실제로 그렇게 빠져 있었다(2026-09-07 발견). 브라우저는 +# 살렸는데 서버 두 경로(포장 강제·세월교 노면 하강)와 단측점 갱신은 안 살려, 그 측점이 +# 다시 계산되면 사용자가 넣은 단별 구간값이 사라졌다. +USER_TOUCHED_KEYS = ( "display_half_width_m", "inlet_structure", "basin_adjust", @@ -121,8 +135,11 @@ _USER_TOUCHED_KEYS = ( "ford_adjust", "box_adjust", "extra_wall_counts", + "extra_spans", "revet_link_detached", "revet_follow_grade", + # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", ) @@ -178,10 +195,12 @@ def enforce_pavement_ranges( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=stored_berm(design), + **curve_widening_args(section), ) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS): + for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): if design.get(key) is not None: recomputed[key] = design[key] section["design"] = recomputed @@ -228,10 +247,12 @@ def enforce_ford_surface_drops( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, + berm=stored_berm(design), + **curve_widening_args(section), ) except (ValueError, KeyError): continue - for key in ("status", "pavement_suggested", *_USER_TOUCHED_KEYS): + for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): if design.get(key) is not None: recomputed[key] = design[key] section["design"] = recomputed @@ -249,18 +270,37 @@ def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]: return mapping -def read_cross_design_inputs( - project_root: Path, longitudinal_file_path: str, chainage_m: float -) -> tuple[list[dict], float | None, bool]: +def resolve_longitudinal_path(project_root: Path, longitudinal_file_path: str) -> Path: + """종단 정본 파일 경로를 검증해 돌려준다 — 저장소 밖 경로를 막는다.""" root = project_root.resolve() - longitudinal_path = (root / longitudinal_file_path).resolve() - if root not in longitudinal_path.parents: + path = (root / longitudinal_file_path).resolve() + if root not in path.parents: raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") - if not longitudinal_path.is_file(): + if not path.is_file(): raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") - longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + return path + + +def read_cross_design_inputs( + project_root: Path, + longitudinal_file_path: str, + chainage_m: float, + preloaded: tuple[Path, dict[str, Any], dict[float, bool]] | None = None, +) -> tuple[list[dict], float | None, bool, dict[str, Any]]: + """(지반 샘플, 계획고, 포장 제안, 측점 기록) — 측점 기록은 곡선부 확폭 입력을 담고 있다. + + `preloaded` 는 (종단 경로, 종단 내용, 포장 제안표)다. 여러 측점을 잇달아 볼 때 + 종단 정본을 측점마다 다시 읽지 않게 넘긴다 — 그 재읽기가 측점당 13.8ms 였다 + (2026-09-06 실측, 측구 방향 역반영 루프). + """ + if preloaded is not None: + longitudinal_path, longitudinal, pavement = preloaded + else: + longitudinal_path = resolve_longitudinal_path(project_root, longitudinal_file_path) + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + pavement = pavement_suggestions(longitudinal) design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m) - suggested = pavement_suggestions(longitudinal).get(round(chainage_m, 3), False) + suggested = pavement.get(round(chainage_m, 3), False) cross_dir = longitudinal_path.parent.parent / "cross_sections" cross_path = (cross_dir / cross_filename(chainage_m)).resolve() if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file(): @@ -269,7 +309,7 @@ def read_cross_design_inputs( samples = cross.get("samples") if isinstance(cross, dict) else None if not isinstance(samples, list): raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.") - return samples, design_elevation, suggested + return samples, design_elevation, suggested, cross def attach_default_designs( @@ -298,6 +338,7 @@ def attach_default_designs( standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage, ford_drops), + **curve_widening_args(section), ) design.update(status="provisional", pavement_suggested=suggested) section["design"] = design @@ -305,6 +346,21 @@ def attach_default_designs( continue +def stored_berm(stored: dict[str, Any]) -> BermSpec | None: + """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" + spec = stored.get("berm") + if not isinstance(spec, dict): + return None + try: + return BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError): + return None + + def recompute_designs_for_alignment( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -312,6 +368,7 @@ def recompute_designs_for_alignment( standard: dict[str, Any] | None, rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, + berms: dict[str, dict[str, float]] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -327,6 +384,17 @@ def recompute_designs_for_alignment( session_offsets[round(float(raw_key), 3)] = float(offset) except (TypeError, ValueError): continue + # 측점별 소단 제원 — 값이 없는 측점은 소단 없음(종전 설계 그대로). + session_berms: dict[float, BermSpec] = {} + for raw_key, spec in (berms or {}).items(): + try: + session_berms[round(float(raw_key), 3)] = BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError, AttributeError): + continue for section in cross_sections: chainage = float(section.get("chainage_m", 0.0)) key = round(chainage, 3) @@ -349,6 +417,8 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=session_berms.get(key) or stored_berm(stored), + **curve_widening_args(section), ) except (ValueError, KeyError): continue @@ -408,6 +478,7 @@ def compute_default_designs( standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, surface_drop_m=ford_drop_at(chainage_m, ford_drops), + **curve_widening_args(cross), ) design["status"] = "provisional" design["pavement_suggested"] = suggested diff --git a/B06_Section/B06_Section_Router_HaulPlan.py b/B06_Section/B06_Section_Router_HaulPlan.py new file mode 100644 index 00000000..77c26186 --- /dev/null +++ b/B06_Section/B06_Section_Router_HaulPlan.py @@ -0,0 +1,73 @@ +"""유토 **배분(평형선·운반거리·장비)** 만 내주는 창구. + +왜 따로 있나(2026-09-06 사용자 확정) — 배분 산식은 노하우가 몰린 자리라 브라우저 번들에 +남기지 않는다. 화면은 누가토량까지만 스스로 내고(`common_util_mass_haul.ts`), 편집이 +멈추면 그 결과를 여기로 보내 배분을 받아 쥔다. 그래서 유토곡선 패널을 펼치는 순간이 +즉시가 된다(미리 받아 뒀으므로). + +**계산은 한 벌이다** — 화면이 쓰던 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`)으로 +그대로 돌린다(CLAUDE.md 5장). 파이썬으로 옮기면 저장 정본과 화면 값이 갈린다. + +정본을 만들지 않는다 — 이 응답은 **표시 전용**이다. 저장 정본은 [저장]·[확정] 뒤 +`recompute_server_side` 가 따로 낸다. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Body +from fastapi.responses import JSONResponse + +from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE, _mass_haul_context +from common_util.common_util_node_bundle import run_bundle_json + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) + +_NPM_SCRIPT = "build:server-calc" +# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다. +_MAX_POINTS = 5000 + + +@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None) +async def compute_haul_plan( + project_id: UUID, + route_id: int, + payload: dict[str, Any] = Body(...), +) -> JSONResponse: + """브라우저가 낸 누가토량 결과를 받아 **배분만** 돌려준다. + + 입력은 `common_util_mass_haul.computeMassHaul` 의 결과 한 벌이다(`points` 포함). + 출력은 `{"haul_plan": {...} | null}` — 화면이 그대로 그린다. + """ + result = payload.get("result") + points = result.get("points") if isinstance(result, dict) else None + if not isinstance(points, list) or not points: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "누가토량 결과가 비어 있습니다."}, + ) + if len(points) > _MAX_POINTS: + return JSONResponse( + status_code=400, + content={"status": "error", "message": "측점 수가 너무 많습니다."}, + ) + try: + output = await asyncio.to_thread( + run_bundle_json, + BUNDLE, + _NPM_SCRIPT, + {"haul_plan_for": result, "context": _mass_haul_context()}, + ) + except Exception: + logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "유토 배분 계산에 실패했습니다."}, + ) + plan = output.get("haul_plan") if isinstance(output, dict) else None + return JSONResponse(content={"status": "success", "haul_plan": plan}) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index f4d80979..c319150e 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -153,6 +153,21 @@ class CrossSectionPatch(BaseModel): # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. revet_link_detached: bool | None = None revet_follow_grade: bool | None = None + # 카드 버튼 선택(2026-09-06) — 예전에는 버튼을 누를 때 서버가 계산·저장했다. + # 이제 조작은 세션 초안에 쌓이고 [저장]·[확정]에서 이 patch 로만 나간다. + ground_type: str | None = None + section_mode: str | None = None + ditch_side: str | None = None + ditch_type: str | None = None + paved: bool | None = None + two_stage_slope: bool | None = None + # 구조물이 선 측점의 폐회로 절·성토 면적(㎡) — 브라우저가 계산해 보낸다 + # (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 서버는 초기값을 만들 때만 + # 같은 코드를 Node 로 돌린다(`B06_Section_Server_Calc_Node.ts`). + cut_area_m2: float | None = Field(default=None, ge=0) + fill_area_m2: float | None = Field(default=None, ge=0) + cut_soil_area_m2: float | None = Field(default=None, ge=0) + cut_rock_area_m2: float | None = Field(default=None, ge=0) class SectionConfirmRequest(BaseModel): @@ -223,6 +238,16 @@ class SectionContextResponse(BaseModel): defaults: SectionOptionDefaults # 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본. standard_cross_section: dict[str, Any] = Field(default_factory=dict) + # 이 프로젝트에 **저장된** 표준 횡단면(사용자가 고쳐 [확정]한 값). 없으면 None(=기본값 그대로). + # 2026-09-07 추가 — 예전에는 브라우저가 이 값을 받을 길이 없어, 세션이 빈 새 탭에서 + # **화면은 config 기본값으로, 서버는 저장분으로** 계산해 같은 측점이 갈렸다. + # 기존 `standard_cross_section`(기본값)은 그대로 두고 **한 칸만 더한다**. + stored_standard_cross_section: dict[str, Any] | None = None + # 절토 비탈 법정 기울기 범위(별표2)와 지반유형 → 별표2 줄 기본 매핑, 검사 제외 등급. + # 브라우저가 상수를 복제하지 않게 **서버가 준 값을 그대로 기억**한다(표준단면과 같은 방식). + cut_slope_limits: dict[str, list[float]] = Field(default_factory=dict) + cut_slope_class_default: dict[str, str] = Field(default_factory=dict) + cut_slope_exempt_grades: list[str] = Field(default_factory=list) # 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). rock_boundary_default_offset_m: float = -0.5 rock_boundary_step_m: float = 0.1 @@ -278,6 +303,10 @@ class CrossDesignPreviewRequest(BaseModel): # 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는 # 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다. rock_boundary_offsets: dict[str, float] | None = None + # 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로, + # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). + # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. + berms: dict[str, dict[str, float]] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 6464073f..b82cef1a 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -10,13 +10,27 @@ * 다음 그리기에서 그대로 본다 — 별도 동기화 코드가 필요 없다. * * ── 수명 규칙 ───────────────────────────────────────────────────── - * 키는 `projectId:routeId`. SPA 모듈 싱글턴이라 페이지를 오가도 살아 있고, 새로고침이면 - * 사라져 영구저장소에서 다시 받는다(영구저장소가 항상 정본). 서버가 파일을 통째로 다시 + * 키는 `projectId:routeId`. SPA 모듈 싱글턴이라 페이지를 오가도 살아 있다. 새로고침이면 + * 메모리가 비므로 **세션에도 한 벌 얹어 둔다**(④ 계산 결과, 2026-09-06 캐시·세션 일원화) + * — 새로고침 뒤 첫 화면이 서버를 기다리지 않는다. 세션 용량을 넘으면 조용히 건너뛰고 + * 예전처럼 영구저장소에서 다시 받는다(영구저장소가 항상 정본). 서버가 파일을 통째로 다시 * 쓰는 조작(재생성·계획선 편집 저장·확정)은 그 응답/재조회로 `replace`·`invalidate`한다. * ========================================================================== */ +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { + fetchStructureTypes, + readPendingStructures, +} from "../B05_Profile/B05_Profile_Api_Structures"; +import { + attachWallSpecs, + wallSpecsFrom, + type WallStructureInput, +} from "@util/common_util_structure_walls"; +import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; +import { USER_TOUCHED_KEYS } from "./B06_Section_Cross_Refresh"; const cache = new Map(); const pending = new Map>(); @@ -25,6 +39,49 @@ function keyOf(projectId: string, routeId: number): string { return `${projectId}:${routeId}`; } +/** + * 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다. + * + * 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의 + * 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다. + * 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다 + * (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`). + */ +async function withDraftWalls( + detail: SectionDetailResponse, + projectId: string, +): Promise { + const drafts = readPendingStructures(projectId); + if (!drafts) return withStructureAreas(detail); + const types = await fetchStructureTypes().catch(() => []); + const names = new Map( + types + .filter((type) => type.group === "C" && type.placement === "interval") + .map((type) => [type.type_id, type.name] as const), + ); + if (!names.size) return detail; + for (const section of detail.cross_sections) { + if (section.revetment) delete (section as { revetment?: unknown }).revetment; + } + attachWallSpecs( + detail.cross_sections as unknown as Array>, + wallSpecsFrom(drafts as unknown as WallStructureInput[], names), + ); + return withStructureAreas(detail); +} + +/** + * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. + * + * 카드는 자기 그림에서 면적을 고쳐 들지만 유토곡선은 카드보다 먼저 계산된다 — 여기서 + * 얹지 않으면 새로고침 직후 곡선만 옛 면적으로 남는다(2026-09-06 실측 553.4 vs 546.4). + * 순수 계산(6ms/67측점)이라 읽기 경로에 두어도 규칙에 어긋나지 않는다. + */ +function withStructureAreas(detail: SectionDetailResponse): SectionDetailResponse { + applyStructureAreaRows(detail.cross_sections, structureAreaRows(detail.cross_sections)); + return detail; +} + /** * 상세를 가져온다 — 캐시가 있으면 **같은 객체**를 즉시 돌려주고, 없으면 한 번만 fetch한다 * (동시 호출은 같은 Promise를 공유). `force`면 캐시를 버리고 다시 받는다. @@ -37,14 +94,21 @@ export async function loadSectionDetail( const key = keyOf(projectId, routeId); if (!options?.force) { const cached = cache.get(key); - if (cached) return cached; + if (cached) return withDraftWalls(cached, projectId); const inFlight = pending.get(key); if (inFlight) return inFlight; + // 새로고침으로 메모리가 빈 경우 — 세션에 얹어 둔 한 벌로 바로 선다. + const stored = readState("section-detail", projectId, routeId); + if (stored) { + cache.set(key, stored); + return withDraftWalls(stored, projectId); + } } const request = fetchSectionDetail(projectId, routeId) - .then((detail) => { + .then(async (detail) => { cache.set(key, detail); - return detail; + writeState("section-detail", detail, projectId, routeId); + return withDraftWalls(detail, projectId); }) .finally(() => { pending.delete(key); @@ -60,6 +124,7 @@ export function replaceSectionDetail( detail: SectionDetailResponse, ): void { cache.set(keyOf(projectId, routeId), detail); + writeState("section-detail", detail, projectId, routeId); } /** @@ -69,11 +134,14 @@ export function replaceSectionDetail( export function invalidateSectionDetail(projectId: string, routeId?: number): void { if (routeId !== undefined) { cache.delete(keyOf(projectId, routeId)); + clearState("section-detail", projectId, routeId); return; } const prefix = `${projectId}:`; for (const key of [...cache.keys()]) { - if (key.startsWith(prefix)) cache.delete(key); + if (!key.startsWith(prefix)) continue; + cache.delete(key); + clearState("section-detail", projectId, Number(key.slice(prefix.length))); } } @@ -93,21 +161,17 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti if (!design) continue; const patch: CrossSectionPatch = { chainage_m: section.chainage_m }; let touched = false; - const put = (key: K, value: CrossSectionPatch[K]): void => { - if (value === undefined || value === null) return; - patch[key] = value; + // 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고 + // 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이 + // 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`). + const source = design as unknown as Record; + const target = patch as unknown as Record; + for (const key of USER_TOUCHED_KEYS) { + const value = source[key]; + if (value === undefined || value === null) continue; + target[key] = value; touched = true; - }; - put("display_half_width_m", design.display_half_width_m); - put("inlet_structure", design.inlet_structure); - put("basin_adjust", design.basin_adjust); - put("revet_adjust", design.revet_adjust); - put("ford_adjust", design.ford_adjust); - put("box_adjust", design.box_adjust); - put("extra_wall_counts", design.extra_wall_counts); - put("extra_spans", design.extra_spans); - put("revet_link_detached", design.revet_link_detached); - put("revet_follow_grade", design.revet_follow_grade); + } if (touched) patches.push(patch); } return patches; diff --git a/B06_Section/B06_Section_Server_Calc_Node.ts b/B06_Section/B06_Section_Server_Calc_Node.ts new file mode 100644 index 00000000..69b54d4b --- /dev/null +++ b/B06_Section/B06_Section_Server_Calc_Node.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * B06_Section_Server_Calc_Node.ts + * 브라우저에서만 돌던 횡단 계산을 **서버가 한 번 돌리는** 진입점 — 구조물 폐회로 + * 절·성토 면적 + 그것을 쌓아 만든 유토곡선. + * + * 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 이 두 값은 지금까지 브라우저에서만 + * 나왔다. 사용자가 B06 을 한 번도 안 열면 값이 없고, 저장·확정 뒤 서버가 다시 계산하면 + * 구조물을 모르는 표준값으로 되돌아갔다. 코리도(`B05_Profile_Corridor_Node.ts`)와 같은 + * 방식으로 **브라우저가 쓰는 코드를 서버가 그대로 실행**한다 — 계산을 두 벌로 짜지 않는다. + * + * 순서가 중요하다: 면적 보정을 **먼저** 얹고 그 위에서 유토곡선을 쌓는다. 화면도 같은 + * 순서다(카드를 그리며 면적을 고친 뒤 유토곡선을 낸다). + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion, + * natural_spoil_min_ground_slope, haul_equipment_limits } } + * 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null } + * — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다. + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; +import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; + +interface ServerCalcInput { + detail?: SectionDetailResponse; + /** + * 유토 **배분만** 낼 때 쓰는 입력 — 브라우저가 누가토량(`computeMassHaul`)까지 내고 + * 그 결과를 보내면 여기서 배분·운반거리만 얹어 돌려준다(2026-09-06). + * 배분 코드를 브라우저 번들에서 빼기 위한 길이라, 이 갈래는 `detail` 을 안 받는다. + */ + haul_plan_for?: Parameters[0]; + context?: { + earthwork_conversion?: Parameters[1]; + natural_spoil_min_ground_slope?: number | null; + haul_equipment_limits?: Parameters[1]; + }; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput; + +// 배분만 내는 갈래 — 화면이 편집을 멈추면 조용히 물어보는 자리(유토곡선 배경 선반입). +if (input.haul_plan_for) { + // **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야 + // 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다. + const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits); + writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null })); + process.exit(0); +} + +const sections: CrossSection[] = input.detail?.cross_sections ?? []; + +const areas = structureAreaRows(sections); +// 보정값을 **자리에서** 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이게 한다. +applyStructureAreaRows(sections, areas); + +// 유토곡선 — balloon 위치는 사용자 화면값이라 서버가 만들지 않는다(파이썬이 보존). +const conversion = input.context?.earthwork_conversion; +const result = conversion + ? computeMassHaul( + sections, + conversion, + input.context?.natural_spoil_min_ground_slope ?? undefined, + ) + : null; +// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06). +const plan = result ? computeHaulPlan(result, input.context?.haul_equipment_limits) : null; +const massHaul = result + ? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null) + : null; + +writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul })); diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py new file mode 100644 index 00000000..fb9d7fb4 --- /dev/null +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -0,0 +1,201 @@ +"""브라우저에서만 돌던 횡단 계산을 **서버가** 돌려 정본에 남긴다(2026-09-06). + +대상 둘 — + ① 구조물이 선 측점의 절·성토 면적: 기슭막이·세월교·BOX암거가 서면 성토 사면이 벽에서 + 끊겨 지반선과 설계선이 이루는 폐회로가 달라진다. + ② 그 면적을 쌓아 만드는 유토곡선. + +왜 — 사용자가 B06 을 한 번도 안 열어도 **초기값**에는 이 값이 있어야 한다. + +**부르는 자리 둘** — 파일입력 자동설계 체인(초기값)과 [저장]·[확정](정본). +사용자가 화면을 만지는 **동안**은 브라우저 몫이지만(왕복 없이 즉시 따라와야 한다), +저장 시점은 서버가 다시 낸다(2026-09-06 저녁 사용자 확정). 이유는 속도가 아니라 +**보안**이다 — 저장 경로가 브라우저 계산이면 유토 배분·운반거리 코드가 번들에 남아야 +해서 화면에서 안 그려도 뺄 수 없다. 대가는 저장 대기 850ms → 약 980ms(Node 131ms). + +**계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`) +으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데 +수량은 저렇다」가 생긴다. + +실패는 비치명적이다 — 보정 전(표준) 값이 그대로 남고 화면은 예전처럼 스스로 고친다. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from pathlib import Path +from typing import Any +from uuid import UUID + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B03_FileInput.B03_FileInput_Service_Chain import _log_steps +from B06_Section.B06_Section_Repository import ( + get_longitudinal_section, + merge_longitudinal_section_data, +) +from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs +from common_util.common_util_node_bundle import run_bundle_json +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool, run_with_connection +from config.config_system import ( + EARTHWORK_CONVERSION_FACTORS, + EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, + NATURAL_SPOIL_MIN_GROUND_SLOPE, +) + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js" +_NPM_SCRIPT = "build:server-calc" +# 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다. +_AREA_KEYS = ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2") + + +def _mass_haul_context() -> dict[str, Any]: + """유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.""" + return { + "earthwork_conversion": EARTHWORK_CONVERSION_FACTORS, + "natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE, + "haul_equipment_limits": [ + {"key": key, "max_distance_m": limit} + for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M + ], + } + + +def _enforce_stored_designs( + longitudinal: dict[str, Any], + sections: list[dict[str, Any]], + project_root: Path, + standard: dict[str, Any] | None, +) -> None: + """저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강. + + 예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로). + 2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. + """ + from B06_Section.B06_Section_Router_Design import ( + enforce_ford_surface_drops, + enforce_pavement_ranges, + ) + + enforce_pavement_ranges(longitudinal, sections, project_root, standard) + enforce_ford_surface_drops(longitudinal, sections, project_root, standard) + + +async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: + """포장 구간·세월교 노면 하강 보정에 더해 구조물 면적·유토곡선까지 Node 로 만들어 저장한다. + + 부르는 자리 둘 — 파일입력 자동설계 체인(초기값)과 [저장]·[확정](정본). 저장 때도 + 서버가 내는 것으로 2026-09-06 저녁 되돌렸다(그 사이 잠깐 브라우저 계산이었다): + 이유는 속도가 아니라 **배분·운반거리 코드를 브라우저 번들에서 빼기 위해서**다. + """ + return await _recompute(project_id, route_id) + + +async def _recompute(project_id: UUID | str, route_id: int) -> int: + from B06_Section.B06_Section_Router import get_section_detail + + project_uuid = UUID(str(project_id)) + marks = [("시작", time.perf_counter())] + # 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다. + # 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측). + pool = get_db_pool() + response, stored_path, longitudinal_row = await asyncio.gather( + get_section_detail(project_uuid, route_id), + run_with_connection(get_project_storage_relative_path, project_uuid), + run_with_connection(get_longitudinal_section, project_uuid, route_id), + ) + marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter())) + payload = getattr(response, "model_dump", None) + if payload is None: # JSONResponse = 실패 + logger.warning("서버 재계산: 종횡단 상세를 못 받음 (route_id=%s)", route_id) + return 0 + detail = payload(mode="json") + sections = detail.get("cross_sections") or [] + project_root = Path(resolve_stored_project_path(stored_path)) + from B06_Section.B06_Section_Router_Design import stored_standard_cross_section + + standard = stored_standard_cross_section(longitudinal_row) + # 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다. + before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections] + await asyncio.to_thread( + _enforce_stored_designs, detail.get("longitudinal") or {}, sections, project_root, standard + ) + fixed = [ + item + for index, item in enumerate(sections) + if json.dumps(item.get("design"), sort_keys=True, default=str) != before[index] + ] + + marks.append(("포장·세월교 보정", time.perf_counter())) + + output = await asyncio.to_thread( + run_bundle_json, + BUNDLE, + _NPM_SCRIPT, + {"detail": detail, "context": _mass_haul_context()}, + ) + marks.append(("Node 번들(면적·유토곡선)", time.perf_counter())) + if not isinstance(output, dict): + output = {} + rows = output.get("areas") + mass_haul = output.get("mass_haul") + if not fixed and not rows and not mass_haul: + return 0 + + updated = 0 + async with pool.acquire() as connection: + # balloon 위치는 **사용자가 끌어 옮긴 화면값**이다 — 서버가 만들지 않으므로 + # 저장분에서 떼어 새 유토곡선에 도로 붙인다(2026-09-06). + if isinstance(mass_haul, dict): + existing = await get_longitudinal_section(connection, project_uuid, route_id) + stored = (existing or {}).get("data") or {} + offsets = (stored.get("mass_haul") or {}).get("balloon_offsets") + if offsets is not None: + mass_haul["balloon_offsets"] = offsets + + await connection.begin() + try: + # 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다. + await merge_cross_section_designs( + connection, + route_id=route_id, + entries=[(float(item.get("chainage_m") or 0.0), item["design"]) for item in fixed], + replace=True, + project_id=project_uuid, + ) + area_entries: list[tuple[float, dict[str, Any]]] = [] + for row in rows if isinstance(rows, list) else []: + patch: dict[str, Any] = { + key: float(row[key]) + for key in _AREA_KEYS + if isinstance(row.get(key), (int, float)) + } + if patch: + area_entries.append((float(row["chainage_m"]), patch)) + updated = await merge_cross_section_designs( + connection, route_id=route_id, entries=area_entries, replace=False + ) + if isinstance(mass_haul, dict): + await merge_longitudinal_section_data( + connection, route_id=route_id, data_patch={"mass_haul": mass_haul} + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + marks.append(("정본 저장", time.perf_counter())) + _log_steps("서버 재계산 내부", marks) + logger.info( + "서버 재계산: route_id=%s 설계 보정 %s곳, 구조물 면적 %s곳, 유토곡선 %s", + route_id, + len(fixed), + updated, + "갱신" if isinstance(mass_haul, dict) else "없음", + ) + return updated diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts new file mode 100644 index 00000000..ec4b2613 --- /dev/null +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * B06_Section_Structure_Layouts.ts + * 정본(`section.design`)만 읽어 **구조물 기하 한 벌**을 내는 자리 — 화면·조작 없이 돈다. + * + * 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 같은 기하를 세 곳이 쓴다: + * ① B06 횡단 카드(사용자 조작 중) ② B07 도면 작도 ③ 서버 초기 계산(Node 진입점). + * ①은 조작 제어기를 물고 돌아야 하고, ②·③은 저장된 값만 보면 된다. ②가 갖고 있던 + * 「제어기 흉내내기」를 여기로 옮겨 ③이 그대로 쓴다 — 기하를 두 벌로 만들지 않는다. + * + * DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다. + * ========================================================================== */ + +import { computeStructureAreas } from "@util/common_util_cross_structure_areas"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; +import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire"; +import type { + ExtraWallControl, + InletStructureControl, + RevetOffsetControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; +import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom"; +import { computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; + +/** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */ +const CHAINAGE_TOLERANCE_M = 0.02; + +function storedWallAdjust(section: CrossSection, role: string): WallAdjust { + const stored = section.design?.revet_adjust?.[role]; + return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; +} + +/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다. */ +const revetOffset: RevetOffsetControl = { + adjustFor: (section, role) => storedWallAdjust(section, role), + storedAdjustFor: (section, role) => + section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, + selectedFor: () => null, + highlightFor: () => null, + select: () => undefined, + syncApplied: () => undefined, + update: () => undefined, + reset: () => undefined, +}; + +const extraWalls: ExtraWallControl = { + countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, + setCount: () => undefined, + equalize: () => undefined, + consumeEqualize: () => false, + syncCount: () => undefined, +}; + +const inletStructure: InletStructureControl = { + valueFor: (section) => section.design?.inlet_structure ?? "auto", + adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), + set: () => undefined, + updateAdjust: () => undefined, + resetAdjust: () => undefined, +}; + +/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ +export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) { + const design = section.design; + if (!design) return null; + const designZAt = (chainageM: number): number | null => { + const found = sections.find( + (item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M, + ); + return found?.design?.design_elevation_m ?? null; + }; + const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt); + const culvert = computeCardCulvert( + section, + section.samples, + null, + revetOffset, + inletStructure, + extraWalls, + link, + ); + const box = computeBoxLayout(section, section.samples, { + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) }, + }); + const ford = computeFordLayout(section, section.samples, { + inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) }, + outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) }, + }); + // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. + const own = + !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; + return { design, link, culvert, box, ford, own }; +} + +export type StoredLayouts = NonNullable>; + +/** 실제로 그려지는 설계선의 트림 — B06 카드와 **같은 우선순위**로 고른다. */ +export function trimOfLayouts(layouts: StoredLayouts) { + return ( + layouts.culvert?.designTrim ?? + layouts.ford?.designTrim ?? + layouts.box?.designTrim ?? + layouts.own?.designTrim + ); +} + +/** 정본에 얹는 면적 키 — 이 넷만 오간다. */ +export const STRUCTURE_AREA_KEYS = [ + "cut_area_m2", + "fill_area_m2", + "cut_soil_area_m2", + "cut_rock_area_m2", +] as const; + +/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */ +function areaRowOf( + section: CrossSection, + sections: readonly CrossSection[], +): Record | null { + const layouts = computeStoredLayouts(section, sections); + if (!layouts) return null; + const trim = trimOfLayouts(layouts); + const design = layouts.design; + if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return null; + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const areas = computeStructureAreas({ + designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + rockBoundaryOffsetM: + typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, + }); + if (!areas) return null; + const round = (value: number): number => Number(value.toFixed(4)); + const row: Record = { + chainage_m: section.chainage_m, + cut_area_m2: round(areas.cutAreaM2), + fill_area_m2: round(areas.fillAreaM2), + }; + // 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다. + if (typeof design.cut_soil_area_m2 === "number") { + row.cut_soil_area_m2 = round(areas.cutSoilAreaM2); + row.cut_rock_area_m2 = round(areas.cutRockAreaM2); + } + return row; +} + +/** + * 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다. + * 화면 그리기(`applyStructureAreas`)와 같은 계산이며, [저장]·[확정]과 초기값 산출 + * (Node 진입점)이 이 한 벌을 함께 쓴다. + */ +export function structureAreaRows( + sections: readonly CrossSection[], +): Array> { + return sections + .map((section) => areaRowOf(section, sections)) + .filter((row): row is Record => !!row); +} + +/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */ +export function applyStructureAreaRows( + sections: readonly CrossSection[], + rows: ReadonlyArray>, +): void { + for (const row of rows) { + const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as + Record | undefined; + if (!design) continue; + for (const key of STRUCTURE_AREA_KEYS) { + if (typeof row[key] === "number") design[key] = row[key]; + } + } +} diff --git a/B06_Section/B06_Section_UI_Berm_Panel.ts b/B06_Section/B06_Section_UI_Berm_Panel.ts new file mode 100644 index 00000000..4ee00280 --- /dev/null +++ b/B06_Section/B06_Section_UI_Berm_Panel.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B06_Section_UI_Berm_Panel.ts + * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). + * + * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 + * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 + * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 + * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. + * + * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, + * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 + * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). + * ========================================================================== */ + +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; +import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; +import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; +import { buildGroup } from "./B06_Section_UI_Page_Common"; +import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; + +/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ +const DEFAULT_BEFORE_M = 5; +const DEFAULT_AFTER_M = 5; + +export interface BermPanelDeps { + /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ + getInterval: () => number; + /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ + target: () => { projectId: string; routeId: number } | null; + /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ + onChange: () => void; +} + +export interface BermPanel { + root: HTMLElement; + /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ + reload: () => void; +} + +function numberField( + label: string, + value: number, + step: string, +): ReturnType { + const field = createInputField({ label, type: "number" }); + field.input.step = step; + field.input.min = "0"; + field.input.value = String(value); + return field; +} + +export function createBermPanel(deps: BermPanelDeps): BermPanel { + const root = buildGroup("소단"); + + const anchor = stationFields("기준 측점", () => deps.getInterval()); + const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); + const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); + const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); + const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); + const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); + + const spanRow = document.createElement("div"); + spanRow.className = "b05-structure__grid"; + spanRow.append(beforeField.root, afterField.root); + + const specRow = document.createElement("div"); + specRow.className = "b05-structure__grid"; + specRow.append(widthField.root, intervalField.root); + + const slopeRow = document.createElement("div"); + slopeRow.className = "b05-structure__grid"; + slopeRow.append(slopeField.root); + + const list = document.createElement("ul"); + list.className = "b05-route__irregular-list"; + + let selected = -1; + + const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); + const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); + removeButton.classList.add("is-danger"); + removeButton.disabled = true; + const actions = document.createElement("div"); + actions.className = "b06-profile__field-row"; + actions.append(addButton, removeButton); + + root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); + + function spans(): BermSpan[] { + const target = deps.target(); + return target ? readBermSpans(target.projectId, target.routeId) : []; + } + + function save(next: BermSpan[]): void { + const target = deps.target(); + if (!target) return; + writeBermSpans(target.projectId, target.routeId, next); + render(); + deps.onChange(); + } + + function readNumber(field: { input: HTMLInputElement }, fallback: number): number { + const parsed = Number(field.input.value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + } + + function add(): void { + const interval = deps.getInterval(); + const chainage = anchor.read(interval, true); + if (chainage === null) { + showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); + return; + } + const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); + const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || gap <= 0) { + showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); + return; + } + const before = readNumber(beforeField, DEFAULT_BEFORE_M); + const after = readNumber(afterField, DEFAULT_AFTER_M); + save([ + ...spans(), + { + start_m: Math.max(chainage - before, 0), + end_m: chainage + after, + width_m: width, + interval_m: gap, + slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), + }, + ]); + selected = -1; + } + + function remove(): void { + if (selected < 0) return; + const next = spans().filter((_span, index) => index !== selected); + selected = -1; + save(next); + } + + function render(): void { + const interval = deps.getInterval(); + const current = spans(); + list.replaceChildren(); + removeButton.disabled = selected < 0 || selected >= current.length; + if (!current.length) { + const empty = document.createElement("li"); + empty.className = "b05-route__irregular-empty"; + empty.textContent = "놓은 소단이 없습니다."; + list.append(empty); + return; + } + current.forEach((span, index) => { + const item = document.createElement("li"); + item.className = "b05-route__irregular-item"; + item.classList.toggle("is-selected", index === selected); + const where = document.createElement("strong"); + where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; + const what = document.createElement("span"); + what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; + item.append(where, what); + item.addEventListener("click", () => { + selected = index === selected ? -1 : index; + render(); + }); + list.append(item); + }); + } + + render(); + + return { + root, + reload(): void { + selected = -1; + render(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index d7cd8c5c..f716468a 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -13,6 +13,7 @@ import { buildRockBoundaryControl, sectionModeLabel, } from "./B06_Section_UI_Cross_Design"; +import { limitLabel, violationsOf } from "./B06_Section_Cut_Slope_Check"; import { type FillSlopeLength, fillSlopeLengths } from "./B06_Section_UI_Cross_Fit"; import { type DesignChangeHandler, @@ -85,6 +86,26 @@ export function appendCardHeader( openSlope.title = L("B06_Cross_SlopeUnclosed_Tip"); meta.append(openSlope); } + // 절토 비탈 법정 기울기(별표2) 위반 — 성토사면·미폐합 경고와 같은 자리·같은 모양이다. + // 소단이 서면 실효 경사가 완만해져 위반이 사라진 것처럼 보이므로, 판정은 **구간별** + // 경사(`cut_slope_segments`)로 한다(2026-09-07). + const cutViolations = violationsOf(section); + if (cutViolations.length) { + const worst = cutViolations[0]; + const badge = document.createElement("span"); + badge.className = "b06-cross-card__warning"; + badge.textContent = `⚠ 절토 1:${worst.ratio.toFixed(2)}`; + badge.title = cutViolations + .map( + (item) => + `${item.side === "left" ? "좌" : "우"} 1:${item.ratio.toFixed(2)}` + + ` — ${limitLabel(item.limitKey)} 법정 1:${item.limit.min}~${item.limit.max}` + + ` 범위 밖(${item.kind === "steep" ? "너무 급함" : "너무 완만함"})` + + ` · ${item.startOffsetM.toFixed(2)}~${item.endOffsetM.toFixed(2)}m`, + ) + .join("\n"); + meta.append(badge); + } const structureName = section.structure; if (structureName) { const structure = document.createElement("span"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index be936d9b..94c24efe 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -12,6 +12,7 @@ import { REVET_EMBED_DEPTH_M, + FILL_SLOPE_RATIO_MIN, REVET_LEAN_RATIO, pipeWallThicknessM, revetHeightLimit, @@ -176,7 +177,14 @@ export function appendCulvertOverlay( (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) + - (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + + (wall.shiftBlockedM + ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` + : "") + + (wall.shiftFloorM + ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` + : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); revetShapes.set(wallKey, revetShape); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 55c5d595..3eb2a177 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -312,8 +312,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult ), limit, ); - /** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다. */ - const exposed = height - REVET_EMBED_DEPTH_M; + /** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다. + * 지형이 요청 높이만큼 안 나오면 아래 스캔 뒤 **낮춰서** 바닥을 지반에 앉힌다. */ + let exposed = height - REVET_EMBED_DEPTH_M; /** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */ const heightAt = (x: number): number => { @@ -416,26 +417,48 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가. appliedD = Math.round(appliedD * 10) / 10; appliedX = Math.round(appliedX * 10) / 10; - appliedAdjusts.push({ - x: appliedX, - d: appliedD, - h: adjust.h != null ? height : null, - m: adjust.m, - }); - const topElevation = autoTop - appliedD; const anchorX = autoOffset + outward * appliedX; - const base = topElevation - exposed; // 근입 0.5 위 기준선 + // 바닥을 **원지반에 앉힌다**(2026-09-06 사용자 지적: 연계 기슭막이가 사면과 안 맞음). + // 상단은 1:1.2 성토선에 붙어 있어야 하므로 자리는 그대로 두고 **키만** 지반까지 + // 맞춘다 — 지반이 낮으면 키우고(형태 한계까지), 높으면 줄인다(최소 높이까지). + // 예전에는 요청 높이를 그대로 써 바닥이 최대 0.87m 떠 있었다(실측 264.06·731.31). + const groundBase = groundAt(anchorX); + const fitted = Math.min( + Math.max(topElevation - groundBase, EXTRA_WALL_MIN_HEIGHT_M - REVET_EMBED_DEPTH_M), + limit - REVET_EMBED_DEPTH_M, + ); + const appliedExposed = Number.isFinite(fitted) ? fitted : exposed; + const base = topElevation - appliedExposed; // 근입 0.5 위 기준선 const wall = buildExtraWall( i, { offset: anchorX, elevation: base }, outward, - exposed, + appliedExposed, material, - Math.max(0, base - groundAt(anchorX)), + Math.max(0, base - groundBase), form, ); + // 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을 + // 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동). + const blocked = Math.max(0, (requestedX ?? 0) - appliedX); + if (blocked > 0.005) wall.shiftBlockedM = Math.round(blocked * 100) / 100; + // 하한에 눌려 요청이 통째로 무시된 경우 — 지형이 아니라 **선반 길이**가 원인이다 + // (2026-09-07 실측: d 2.6m 이면 x 하한이 3.12m 라 1.0m 요청이 아무 변화도 못 냄). + // 사용자가 실제로 넣었을 때만 알린다 — 요청이 없으면(0) 하한은 그냥 기본 자리라 + // 알릴 것이 없다(모든 벽에 뜨면 소리만 됨). + const floor = shelfFloor(appliedD); + if ((requestedX ?? 0) > 0.005 && floor > (requestedX ?? 0) + 0.005) { + wall.shiftFloorM = Math.round(floor * 100) / 100; + } walls.push(wall); + // 되받기 — 지반에 맞춰 조정한 값이 **실제 적용 높이**다(조정창이 그대로 받아 적는다). + appliedAdjusts.push({ + x: appliedX, + d: appliedD, + h: adjust.h != null ? appliedExposed + REVET_EMBED_DEPTH_M : null, + m: adjust.m, + }); // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. const shelfRun = Math.max(0, appliedX - FILL_SLOPE_RATIO_MIN * appliedD); const shelf: OffsetPoint | null = diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 6e1d1b33..6c484416 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -49,6 +49,7 @@ import { inletChoiceAvailability, resolveBasinChoice, } from "./B06_Section_UI_Cross_Culvert_Basin"; +import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra"; import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { @@ -644,6 +645,29 @@ export function computeCulvertLayout( trimMinSlope = { points: slope.points }; } } + // 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라 + // **물량이 안 바뀐다**(2026-09-07 사용자 확정). 기준벽 사면은 노견~벽 상단까지고, 그 + // 바깥은 지금까지 원지반으로 봐서 면적이 0이었다. 화면이 그리는 선(`outletFill.segments`)과 + // 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다. + const drawnFillPoints = (result: OutletExtrasResult): OffsetPoint[] => + result.segments + .filter((segment) => segment.kind !== "cut") + .flatMap((segment) => segment.points); + // ↓ 되돌릴 자리(2026-09-07) — 「단이 2개 이상일 때만 반영」으로 좁히려면 아래 두 줄의 + // `drawnFillPoints(extras)` 를 `extras.walls.length ? drawnFillPoints(extras) : []` 로 + // 바꾸면 된다(basinExtras 도 같은 꼴). 다만 그러면 화면이 그리는 성토부선과 면적이 + // 다시 어긋난다 — 사용자 판단 대기 중인 항목임(PLAN 3-5 ⓑ). + const extendTrimSlope = (points: OffsetPoint[], outward: number): void => { + if (!points.length) return; + if (outward > 0) { + trimMaxSlope = { points: [...(trimMaxSlope?.points ?? []), ...points] }; + } else { + trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] }; + } + }; + extendTrimSlope(drawnFillPoints(extras), outletInfo.outward); + extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward); + const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null; const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index a644fbe9..d431a628 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -71,6 +71,13 @@ export interface WallLayout { * 별도 지지 구조물로 메운다(추가 예정). 0이면 지반 위(근입 0.5m). */ floatGapM: number; + /** 사용자가 요청한 좌우 이동을 **지형이 막아 못 나간** 거리(m, 2026-09-06). + * 0보다 크면 그 자리에서 더 나가면 벽이 원지반에 묻힌다는 뜻이라 툴팁으로 알린다. */ + shiftBlockedM?: number; + /** 좌우 이동의 **하한**(m, 2026-09-07). 선반 길이가 음수가 되지 않게 x 는 최소 + * `1.2 × 상하 내림`까지 따라 나간다. 요청이 이 값보다 작으면 눌러도 벽이 안 움직이고, + * 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0으로 나온다 — 그 까닭을 툴팁으로 알린다. */ + shiftFloorM?: number; /** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */ material: RevetMaterial; outward: number; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 65969bc3..27c1484b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -152,6 +152,28 @@ export interface CulvertLink { * 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다. * 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다. */ +/** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다. + * 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */ +const extraLimitNotified = new Set(); + +/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다. + * 종전에는 `activeRevet` 이 그 벽일 때만 떠서, 다른 길로 단 수를 바꾸면 조용히 잘렸다 + * (2026-09-07). 문구는 종전 그대로 쓴다. */ +function noticeExtraLimit(chainageM: number, side: "outlet" | "basin", built: number): void { + const key = `${chainageM.toFixed(2)}|${side}|${built}`; + if (extraLimitNotified.has(key)) return; + extraLimitNotified.add(key); + showToast(L("B06_Cross_Extra_Limit").replace("{n}", String(built)), "info"); +} + +/** 요청대로 다 선 자리는 안내 기록을 지운다 — 다음에 또 모자라면 다시 알린다. */ +function clearExtraLimitNotice(chainageM: number, side: "outlet" | "basin"): void { + const head = `${chainageM.toFixed(2)}|${side}|`; + for (const key of [...extraLimitNotified]) { + if (key.startsWith(head)) extraLimitNotified.delete(key); + } +} + export function tierSpanOf(section: CrossSection, key: string): StructureSpan { const stored = section.design?.extra_spans?.[key]; if (stored) return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) }; @@ -470,24 +492,18 @@ export function computeCardCulvert( // 유입측(집수정 계류측·독립 기슭막이)도 유출측과 같은 규칙으로 알린다 — 종전에는 // 조용히 잘려서 +를 눌러도 아무 일이 없는 것처럼 보였다(2026-08-29 사용자 지적: // 우측 독립 기슭막이에서 추가2가 안 서는데 토스트가 없다). - if (activeRevet === "inlet" || activeRevet?.startsWith("bextra")) { - showToast( - L("B06_Cross_Extra_Limit").replace("{n}", String(layout.basinExtras.length)), - "info", - ); - } + noticeExtraLimit(section.chainage_m, "basin", layout.basinExtras.length); extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin"); + } else if (extraWalls) { + clearExtraLimitNotice(section.chainage_m, "basin"); } if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) { // 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토 // 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다. - if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) { - showToast( - L("B06_Cross_Extra_Limit").replace("{n}", String(layout.extraWalls.length)), - "info", - ); - } + noticeExtraLimit(section.chainage_m, "outlet", layout.extraWalls.length); extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet"); + } else if (extraWalls) { + clearExtraLimitNotice(section.chainage_m, "outlet"); } if (revetOffset) { const roles: Array<[RevetKey, WallAdjust]> = [ diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 6e0349aa..8e691cb0 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -31,6 +31,40 @@ export { const SVG_NS = "http://www.w3.org/2000/svg"; +/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ +interface ReflowCard { + reset(): void; + /** 패널로 옮길 개수. -1 이면 아직 자리를 안 잡아 건드리지 않는다. */ + measure(): number; + apply(moveCount: number): void; +} + +const reflowQueue = new Set(); +let reflowScheduled = false; + +/** + * 넘침 정리를 **한 프레임에 몰아** 돌린다 — 모든 카드의 쓰기를 먼저 끝내고, 그 다음 + * 읽기를 몰아서 하고, 마지막에 쓰기를 몰아서 한다. 강제 레이아웃이 카드 수만큼(67회) + * 나던 것이 프레임당 한 번으로 줄어든다. + */ +function scheduleReflow(card: ReflowCard): void { + reflowQueue.add(card); + if (reflowScheduled) return; + reflowScheduled = true; + requestAnimationFrame(() => { + reflowScheduled = false; + const cards = [...reflowQueue]; + reflowQueue.clear(); + for (const item of cards) item.reset(); + const counts = cards.map((item) => item.measure()); + cards.forEach((item, index) => item.apply(counts[index])); + }); +} + +/** 카드 버튼줄의 flex 간격(px) — 모든 카드가 같은 CSS 를 쓰므로 한 번만 잰다. + * `getComputedStyle` 도 강제 레이아웃을 부르므로 카드 67장마다 부르지 않는다. */ +let barGapPx: number | null = null; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -412,24 +446,52 @@ export function buildDesignControls( more.append(moreSummary, morePanel); bar.append(slopeDirSeg, ...moveable, more); - const reflow = (): void => { - // 후보 전부 인라인 복귀 → more 숨김 → 넘치면 뒤에서부터 패널로 이동. - for (const element of moveable) bar.insertBefore(element, more); - morePanel.replaceChildren(); - more.hidden = true; - if (bar.clientWidth <= 0) return; - for ( - let index = moveable.length - 1; - index >= 0 && bar.scrollWidth > bar.clientWidth + 1; - index -= 1 - ) { - more.hidden = false; - morePanel.insertBefore(moveable[index], morePanel.firstChild); - } + // 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply). + // 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을 + // 그때마다 강제로 다시 잰다. 게다가 한 번의 강제 레이아웃이 **그때까지 들어간 카드 + // 전부**를 다시 재므로 뒤로 갈수록 비싸진다(2026-09-06 CPU 프로파일: 진입에서 자기 + // 시간 1위). 그래서 `scheduleReflow` 가 67장을 모아 한 프레임에 묶어 돌린다. + const card: ReflowCard = { + reset() { + for (const element of moveable) bar.insertBefore(element, more); + morePanel.replaceChildren(); + more.hidden = false; // 폭을 재려면 자리에 있어야 한다. + }, + measure() { + const clientWidth = bar.clientWidth; + if (clientWidth <= 0) return -1; // 아직 자리를 안 잡았다 — 그대로 둔다. + if (barGapPx === null) barGapPx = Number.parseFloat(getComputedStyle(bar).gap) || 0; + const widths = moveable.map((element) => element.offsetWidth); + let overflow = bar.scrollWidth - clientWidth; + let moveCount = 0; + while (overflow > 1 && moveCount < moveable.length) { + overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; + moveCount += 1; + } + return moveCount; + }, + apply(moveCount) { + if (moveCount <= 0) { + more.hidden = true; + return; + } + for (let index = 0; index < moveCount; index += 1) { + morePanel.insertBefore(moveable[moveable.length - 1 - index], morePanel.firstChild); + } + }, }; - const overflowObserver = new ResizeObserver(() => reflow()); + // 첫 호출은 아래 `scheduleReflow` 가 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번 더 + // 불려 카드마다 두 번 돌았다. 창 크기가 바뀔 때만 그 카드 하나를 다시 넣는다. + let firstObservation = true; + const overflowObserver = new ResizeObserver(() => { + if (firstObservation) { + firstObservation = false; + return; + } + scheduleReflow(card); + }); overflowObserver.observe(bar); - requestAnimationFrame(reflow); + scheduleReflow(card); // 암 경계선 제어는 그래프 X축 제목 행으로 이동(E-7, Cross_View에서 배치). // 절·성토 면적 readout은 그래프 중상단 오버레이로 이동(E-4, Cross_View에서 배치). @@ -647,6 +709,19 @@ export function appendCrossDesignOverlay( tick.setAttribute("class", "b06-chart__carriageway-tick"); svg.append(tick); } + // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. + // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. + const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); + const standardWidth = design.carriageway_standard_width_m; + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2)); + label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6)); + label.setAttribute("class", "b06-chart__carriageway-label"); + label.textContent = + widened > 0.001 && typeof standardWidth === "number" + ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` + : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; + svg.append(label); } } diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 0985c12d..5c45ff3d 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -156,7 +156,17 @@ export function computeRevetmentLayout( const requestedHeight = Number(adjust?.h ?? spec.height_m); if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null; - const side: "left" | "right" = spec.side === "우" ? "right" : "left"; + // 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽 + // (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설 + // 자리가 없으므로 그리지 않는다(면적도 그대로). + const fillSide = (): "left" | "right" | null => { + if (design.section_mode === "left_cut") return "right"; + if (design.section_mode === "right_cut") return "left"; + if (design.section_mode === "both_fill") return "left"; + return null; + }; + const side = spec.side === "우" ? "right" : spec.side === "좌" ? "left" : fillSide(); + if (!side) return null; const outward = side === "left" ? 1 : -1; const edge = design.road_edges?.[side]; const groundAt = groundInterpolator(section.samples); @@ -230,6 +240,13 @@ export function computeRevetmentLayout( lengthM, floatGapM, }); + // 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07). + // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고, + // 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다. + const floorNow = xFloor(appliedDrop); + if (requestedX > 0.005 && floorNow > requestedX + 0.005) { + tier1.shiftFloorM = Math.round(floorNow * 100) / 100; + } // 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로. const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1)); @@ -353,7 +370,16 @@ export function appendRevetmentOverlay( (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) + - (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""); + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + + // 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다 + // (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다. + (wall.shiftBlockedM + ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` + : "") + + (wall.shiftFloorM + ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` + : ""); const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId); if (onSelect) shape.classList.add("is-selectable"); drawn.push(shape); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index fe7595aa..f2ac7b13 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -33,6 +33,7 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies"; import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; +import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -62,8 +63,12 @@ import { svgText, validElevation, } from "./B06_Section_UI_Section_Common"; -import { crossPlotMetrics, effectiveCardHalfWidth } from "./B06_Section_UI_Cross_View_Metrics"; -export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics"; +import { + type CrossPlotBase, + crossPlotFromBase, + crossPlotMetrics, + effectiveCardHalfWidth, +} from "./B06_Section_UI_Cross_View_Metrics"; /** * 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다 @@ -143,6 +148,8 @@ export function createCrossSectionCard( /** 세월교 측벽·BOX암거 구체 조작 제어(2026-08-25). */ ford?: FordControl, box?: BoxControl, + /** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */ + plotBase?: CrossPlotBase | null, ): CrossCardElement { // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 @@ -273,14 +280,18 @@ export function createCrossSectionCard( appendCardHeader(card, section, stationInterval, onDesignChange); - const metrics = crossPlotMetrics( - section, - verticalExaggeration, - widthPx, - effectiveHalfWidth, - designElevation, - forcedHeightPx, - ); + // 행 높이를 재며 만든 기하가 있으면 그대로 쓴다 — 같은 측점의 기하를 두 번 계산하던 + // 것이 B06 진입에서 `draw` 자기 시간의 대부분이었다(2026-09-06 CPU 프로파일). + const metrics = plotBase + ? crossPlotFromBase(plotBase, forcedHeightPx) + : crossPlotMetrics( + section, + verticalExaggeration, + widthPx, + effectiveHalfWidth, + designElevation, + forcedHeightPx, + ); if (!metrics) { card.append(emptyView(L("B06_Profile_View_NoCross"))); } else { @@ -437,17 +448,16 @@ export function createCrossSectionCard( if (highlightRevet === "own") setOwnActive(true); } } - appendCrossDesignOverlay( - plotLayer, - section.design, - x, - toDisplayY, - drawSamples, + const designTrim = culvertLayout?.designTrim ?? - fordLayout?.designTrim ?? - boxLayout?.designTrim ?? - ownDesignTrim, - ); + fordLayout?.designTrim ?? + boxLayout?.designTrim ?? + ownDesignTrim; + // 구조물이 선 측점의 절·성토 면적 — **그려지는 폐회로**의 넓이로 고쳐 든다 + // (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이 + // 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. + applyStructureAreas(section, designTrim); + appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim); // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( @@ -638,6 +648,7 @@ export function createCrossSectionCard( adjustOf, outwardOf, heightOfWall, + drawnWallKeys: () => [...culvertWallSpecs.keys()], formOfWall, appliedD: () => culvertAppliedD, pipeLengthM: () => culvertPipeLengthM, @@ -696,3 +707,37 @@ export function createCrossSectionCard( appendCardFooter(card, section, rockBoundary); return card; } + +/** + * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). + * + * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 + * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. + * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. + * + * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 + * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). + */ +function applyStructureAreas(section: CrossSection, trim: DesignTrim | undefined | null): void { + const design = section.design; + if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { + return; + } + const ground = section.samples + .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") + .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const areas = computeStructureAreas({ + designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + rockBoundaryOffsetM: + typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, + }); + if (!areas) return; + design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); + design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); + if (typeof design.cut_soil_area_m2 === "number") { + design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); + } +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts index 29721ee5..03c8a8a5 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Metrics.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Metrics.ts @@ -5,7 +5,7 @@ import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; const AREA_OVERLAY_HEADROOM_PX = 58; -interface CrossPlotMetrics { +export interface CrossPlotMetrics { sourceSamples: SectionSample[]; minOffset: number; maxOffset: number; @@ -16,14 +16,28 @@ interface CrossPlotMetrics { heightPx: number; } -export function crossPlotMetrics( +/** + * 강제 높이를 얹기 **전**까지의 값 — 카드 두 벌(행 높이 재기 / 실제 그리기)이 나눠 쓴다. + * 예전에는 같은 측점의 기하를 두 번 계산했고, 그 값이 B06 진입에서 `draw` 자기 시간 + * 213ms 의 대부분이었다(2026-09-06 CPU 프로파일, 측점 67곳 기준 3.2ms/장). + */ +export interface CrossPlotBase { + sourceSamples: SectionSample[]; + minOffset: number; + maxOffset: number; + elevationMid: number; + pixelsPerMeter: number; + /** 강제 높이가 없을 때 쓰는 높이(바닥 `CROSS_HEIGHT` 적용 전). */ + naturalHeight: number; +} + +export function crossPlotBase( section: CrossSection, verticalExaggeration: number, widthPx: number, crossHalfWidth?: number, designElevation?: number, - forcedHeightPx?: number, -): CrossPlotMetrics | null { +): CrossPlotBase | null { const sourceSamples = section.samples.filter( (sample) => crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, @@ -48,32 +62,44 @@ export function crossPlotMetrics( ); const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; - const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT); - const displaySpan = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / pixelsPerMeter; - const displayMax = elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter / 2; + return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight }; +} + +/** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */ +export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics { + const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT); + const displaySpan = + Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / base.pixelsPerMeter; + const displayMax = + base.elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / base.pixelsPerMeter / 2; return { - sourceSamples, - minOffset, - maxOffset, - elevationMid, + sourceSamples: base.sourceSamples, + minOffset: base.minOffset, + maxOffset: base.maxOffset, + elevationMid: base.elevationMid, displaySpan, displayMax, - pixelsPerMeter, + pixelsPerMeter: base.pixelsPerMeter, heightPx, }; } -export function crossCardNaturalHeight( +export function crossPlotMetrics( section: CrossSection, verticalExaggeration: number, widthPx: number, crossHalfWidth?: number, designElevation?: number, -): number { - return ( - crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation) - ?.heightPx ?? CROSS_HEIGHT + forcedHeightPx?: number, +): CrossPlotMetrics | null { + const base = crossPlotBase( + section, + verticalExaggeration, + widthPx, + crossHalfWidth, + designElevation, ); + return base ? crossPlotFromBase(base, forcedHeightPx) : null; } /** diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 3aff0cb9..bdfbe4af 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -59,6 +59,8 @@ export interface StructurePanelContext { /** 화면 좌(◀) 방향을 벽 기준 부호로 환산한다. */ outwardOf: (key: RevetKey) => number; heightOfWall: (key: RevetKey) => number; + /** 지금 이 카드가 그린 벽 목록 — 연동 해제 때 높이를 굳히는 데 쓴다(2026-09-06). */ + drawnWallKeys: () => RevetKey[]; formOfWall: (key: RevetKey) => string; /** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */ appliedD: () => Map; @@ -184,9 +186,27 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe const role = spanRoleOf(key, ctx.inletIsBasin()); if (role) ctx.structureSpan?.update(ctx.section, role, patch); }, + // 벽을 **그리지 않는 카드에는 [연동]을 내지 않는다**(2026-09-06). 링크 카드는 소유 + // 측점의 벽을 빌려 그리는데, 빌릴 벽이 없는 카드에도 버튼이 떠 눌러도 아무 일이 + // 없었다 — `revetlink` 에 `detached` 만 쌓였다(보조 창 실측: 0·20·40·60·80m 카드). linkState: () => - ctx.isLinked ? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true } : null, - setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked), + ctx.isLinked && ctx.drawnWallKeys().length + ? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true } + : null, + setLinked: (linked) => { + // 연동을 **푸는 순간** 지금 그려진 높이를 이 측점 값으로 굳힌다(2026-09-06 사용자 + // 지시). 예전에는 위치(4축)만 갈리고 높이는 소유 측점 값을 계속 따라가, 소유 + // 측점 높이를 바꾸면 푼 측점까지 같이 움직였다. 굳혀 두면 값이 튀지 않으면서 + // 이후에는 따로 논다. 단 수·형태는 소유 측점을 그대로 따른다(2026-08-30 확정 유지). + if (!linked) { + ctx.drawnWallKeys().forEach((key) => { + if (ctx.adjustOf(key).h !== null && ctx.adjustOf(key).h !== undefined) return; + const height = ctx.heightOfWall(key); + if (height > 0) ctx.revetOffset?.update(ctx.section.chainage_m, key, { h: height }); + }); + } + ctx.revetLink?.setLinked(ctx.section.chainage_m, linked); + }, followGrade: () => ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true, setFollowGrade: (follow) => { diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 58a27434..890019f0 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -1,7 +1,11 @@ +import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; +import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state"; +import { rememberCutSlopeCriteria } from "./B06_Section_Cut_Slope_Check"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -12,7 +16,6 @@ import { type WorkflowState, } from "../A00_Common/b_workflow_nav"; import { - computeCrossDesign, fetchSectionContext, getSections, type SectionContextResponse, @@ -21,10 +24,10 @@ import { } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; +import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { confirmCurrentSections, createRockBoundaryStore, - rockBoundarySessionKey, saveCurrentSections, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; @@ -57,6 +60,7 @@ import "./B06_Section_UI_Style_Cross.css"; import "./B06_Section_UI_Style_Cross_Controls.css"; import "./B06_Section_UI_Style_Cross_Areas.css"; import { loadSectionDetail } from "./B06_Section_Section_Store"; +import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common"; import "@util/common_util_mass_haul.css"; @@ -138,10 +142,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + let structureMarksSink: + ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). reveal: () => layout.setOptionsOpen(true), + // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). + // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. + onMarks: (structures, types) => structureMarksSink?.(structures, types), + // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 + // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). + onStructuresChanged: () => void refreshDetailForStructures(), detail: () => sectionDetail, // 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산). wallHeight: (chainageM, role) => { @@ -195,13 +207,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); + // 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한). + const bermPanel = createBermPanel({ + getInterval: () => stationInterval ?? 20, + target: () => + projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, + onChange: () => void reconcileStaleDesigns({ force: true }), + }); + leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); - // 측점별 최신 요청 시퀀스 — 늦게 도착한 옛 응답을 폐기해 경합을 방지한다. - const designRequestSeq = new Map(); - /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), * (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음. @@ -228,33 +244,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { sectionView.refreshCard(chainageM); } - // (2) 서버 계산 — 최신 요청만 반영. 암 경계 오프셋은 세션 우선값을 실어 2단계 무릎을 계산시킨다. - const seq = (designRequestSeq.get(chainageM) ?? 0) + 1; - designRequestSeq.set(chainageM, seq); - try { - const response = await computeCrossDesign(projectId, currentRouteId, { - chainage_m: chainageM, - ...change, - rock_boundary_offset_m: rockBoundaryControl.offsetFor(target), - standard_cross_section: standardPanel?.getValues(), - }); - if (designRequestSeq.get(chainageM) !== seq) return; - target.design = { - ...response.design, - inlet_structure: target.design?.inlet_structure, - basin_adjust: target.design?.basin_adjust, - revet_adjust: target.design?.revet_adjust, - extra_wall_counts: target.design?.extra_wall_counts, - extra_spans: target.design?.extra_spans, - revet_link_detached: target.design?.revet_link_detached, - revet_follow_grade: target.design?.revet_follow_grade, - }; - sectionView.refreshCard(chainageM); - } catch (error) { - if (designRequestSeq.get(chainageM) !== seq) return; - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`${L("B06_Design_Failed")}${detail}`, "error"); - } + // (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고, + // [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로 + // 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다. + writeCrossDesignChoice(projectId, currentRouteId, chainageM, { + ground_type: change.ground_type, + section_mode: change.section_mode, + ditch_side: change.ditch_side ?? null, + ditch_type: change.ditch_type, + paved: change.paved, + two_stage_slope: change.two_stage_slope, + }); + // (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다. + await reconcileStaleDesigns({ force: true }); } /** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */ @@ -294,7 +296,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { * 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형· * 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다. */ - async function reconcileStaleDesigns(): Promise { + async function reconcileStaleDesigns(options?: { force?: boolean }): Promise { if (!sectionDetail || !projectId || currentRouteId === null) return; const draft = readAlignmentDraft(currentRouteId); // 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 + @@ -304,7 +306,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에 // 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06 // 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다. - if (!draft && !hasStaleDesigns(sectionDetail)) return; + if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return; // 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다 // (CLAUDE.md 5장). try { @@ -335,6 +337,29 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ + async function refreshDetailForStructures(): Promise { + if (!projectId || currentRouteId === null) return; + try { + if (projectId && currentRouteId !== null) { + sectionDetail = await loadSectionDetail(projectId, currentRouteId); + } + // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 + // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측: 카드는 바뀌는데 + // 최종 누가토량이 그대로였음). + if (sectionDetail) { + applyStructureAreaRows( + sectionDetail.cross_sections, + structureAreaRows(sectionDetail.cross_sections), + ); + } + renderSectionDetail(); + } catch (error) { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error"); + } + } + const ensureSampledWidth = createSampleWidener({ target: () => projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, @@ -391,10 +416,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리). const rockStore = createRockBoundaryStore({ - sessionKey: () => - projectId && currentRouteId !== null - ? rockBoundarySessionKey(projectId, currentRouteId) - : null, + sessionKey: () => stateKey("rockb", projectId, currentRouteId), detail: () => sectionDetail, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), recompute: (chainageM) => recomputeIfRock(chainageM), @@ -403,8 +425,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const rockBoundaryControl = rockStore.control; const stationControls = createStationControls({ - sessionKey: (kind) => - projectId && currentRouteId !== null ? `b06:${kind}:${projectId}:${currentRouteId}` : null, + // 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다. + // 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다 + // (2026-09-06 `extraspan` 누락으로 B06 이 안 뜬 뒤 막음). + sessionKey: (kind) => stateKey(kind, projectId, currentRouteId), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), detail: () => sectionDetail, crossHalfWidth, @@ -430,6 +454,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.ford, stationControls.box, ); + // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). + structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, @@ -505,16 +531,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { /** 표시 반폭 세션 키 — 페이지를 떠났다 와도 조절값이 유지되게 한다. */ const displaySessionKey = (): string | null => - projectId && currentRouteId !== null - ? `b06:cross-display:${projectId}:${currentRouteId}` - : null; + stateKey("cross-display", projectId, currentRouteId); function persistDisplayHalfWidth(): void { const key = displaySessionKey(); const width = crossHalfWidth(); if (!key || width === undefined) return; try { - window.sessionStorage.setItem(key, String(width)); + writeByKey(key, String(width)); } catch { /* 무시 */ } @@ -526,13 +550,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } /** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼). - * 진입당 한 번만 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 튄다. 관 목록이 - * 늦게 오는 몫은 패널 load()가 스스로 다시 세운다. */ - let pickRestored = false; + * **넘김값이 바뀌었을 때만** 적용한다 — 재렌더마다 다시 돌면 사용자가 옮긴 스크롤이 + * 튀고, 진입당 한 번으로 막으면 자료가 늦게 온 경우 영영 안 열린다(2026-09-06). */ + let appliedPick: string | null = null; function restoreStructurePick(): void { - if (pickRestored || !sectionDetail) return; - pickRestored = true; - applyStructurePick(readStructurePick(projectId), sectionDetail, { + if (!sectionDetail) return; + const handoff = readStructurePick(projectId); + const signature = handoff ? `${handoff.at}|${handoff.key ?? ""}` : null; + if (signature === null || signature === appliedPick) return; + appliedPick = signature; + applyStructurePick(handoff, sectionDetail, { focusStation: (stationId) => sectionView.focusStation(stationId), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), @@ -629,6 +656,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll); // 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다. rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m); + // 절토 비탈 법정 기울기 기준(별표2)도 같은 방식으로 기억해 둔다 — 카드가 읽는다. + rememberCutSlopeCriteria(context); standardPanelSlot.append(standardPanel.root); if (context.route_id === null) { @@ -650,6 +679,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } // 공유 캐시 — B05가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다(두 페이지 싱크의 핵심). sectionDetail = await loadSectionDetail(projectId, context.route_id); + // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 + // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). + if (sectionDetail) { + applyStructureAreaRows( + sectionDetail.cross_sections, + structureAreaRows(sectionDetail.cross_sections), + ); + } // 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정 const summaryData = existing.longitudinal.data as { options?: { @@ -682,13 +719,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 세션에 보관된 표시 반폭이 있으면 그것이 우선한다(사용자가 마지막으로 지정한 값). const sessionDisplayKey = displaySessionKey(); if (sessionDisplayKey) { - const sessionDisplay = Number(window.sessionStorage.getItem(sessionDisplayKey)); + const sessionDisplay = Number(readByKey(sessionDisplayKey)); if (Number.isFinite(sessionDisplay) && sessionDisplay > 0) crossHalfWidthField.input.value = sessionDisplay.toFixed(1); } if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); + bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다. void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index a787fd17..9b2e21cd 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -8,6 +8,7 @@ * 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다. * ========================================================================== */ +import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; @@ -150,7 +151,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { const spec = sectionAt(chainageM)?.ford; if (spec) { // 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다. - if (patch.pipe_diameter_mm) spec.diameter_m = patch.pipe_diameter_mm / 1000; + if (patch.pipe_diameter_mm) spec.diameter_m = pipeDiameterM(patch.pipe_diameter_mm); if (patch.pipe_count) spec.pipe_count = patch.pipe_count; if (patch.pipe_kind) spec.pipe_kind = patch.pipe_kind; // 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리. @@ -168,9 +169,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { if (patch.height_m !== undefined) wing.height_m = patch.height_m; if (patch.length_m !== undefined) wing.length_m = patch.length_m; if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg; - wing.slab_extend_m = wing.installed - ? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0) - : 0; + wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg); } deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); @@ -346,7 +345,7 @@ export function createBoxControls(deps: FordControlDeps): { // 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다. if (patch.body_width_m) { spec.inner_width_m = patch.body_width_m; - spec.span_m = patch.body_width_m + 2 * spec.wall_thickness_m; + spec.span_m = boxSpanM(patch.body_width_m); } if (patch.body_height_m) spec.inner_height_m = patch.body_height_m; } diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1117d351..1f66dfc2 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -16,35 +16,86 @@ import { type SectionContextResponse, type SectionDetailResponse, } from "./B06_Section_Api_Fetch"; +import { invalidateSectionDetail } from "./B06_Section_Section_Store"; +import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; +import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; +import { + applyStructureAreaRows, + STRUCTURE_AREA_KEYS, + structureAreaRows, +} from "./B06_Section_Structure_Layouts"; +import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; -import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul"; -import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; /** - * 암 경계선 오프셋 세션 키 — 저장소와 재계산 창구가 **같은 자리**를 보게 정의처를 하나로 둔다. - * (`B06_Section_Cross_Refresh` 가 패널 없는 B05에서도 같은 값을 읽어 서버로 보낸다.) + * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. + * + * **등록표를 거쳐 읽는다**(2026-09-07 고침). 예전에는 옛 키 `b06:rockb:{p}:{r}` 를 날문자열로 + * 읽었는데, 쓰는 쪽(`B06_Section_UI_Page.ts` 의 `stateKey("rockb", …)`)은 새 키 + * `aislo:draft:rockb:{p}:{r}` 에 쓰고 있어 **읽는 쪽이 늘 빈 값**을 받았다. 그래서 + * 계획선을 고쳐 횡단을 다시 계산할 때(`B06_Section_Cross_Refresh`) 사용자가 옮긴 암 경계선이 + * 안 실려 나가 토사/암 나눔이 기본값으로 돌아갔다 — 수량이 갈리는 자리다. + * 실측(용화 5601e828, route 169): 경계선을 -0.5 → -0.8m 로 옮기니 새 키에만 `{"0.00":-0.8}` + * 가 쌓이고 옛 키는 아예 없었다. */ -export function rockBoundarySessionKey(projectId: string, routeId: number): string { - return `b06:rockb:${projectId}:${routeId}`; -} - -/** 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. */ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { - try { - const raw = window.sessionStorage.getItem(rockBoundarySessionKey(projectId, routeId)); - const parsed = raw ? (JSON.parse(raw) as unknown) : null; - return parsed && typeof parsed === "object" ? (parsed as Record) : {}; - } catch { - return {}; - } + const stored = readState>("rockb", projectId, routeId); + return stored && typeof stored === "object" ? stored : {}; +} + +/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ +export interface BermSessionSpec { + width_m: number; + interval_m: number; + slope_deg: number; +} + +/** + * 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원. + * + * 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」). + * 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로 + * 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다. + */ +export interface BermSpan extends BermSessionSpec { + start_m: number; + end_m: number; +} + +/** + * 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록. + * + * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 + * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). + */ +export function readBermSpans(projectId: string, routeId: number): BermSpan[] { + const stored = readState("berm", projectId, routeId); + return Array.isArray(stored) ? stored : []; +} + +export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void { + writeState("berm", spans, projectId, routeId); +} + +/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ +export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { + const found = spans.find( + (span) => + chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && + chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, + ); + return found + ? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg } + : null; } /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */ @@ -87,7 +138,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - window.sessionStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(offsets))); + writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -100,7 +151,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = window.sessionStorage.getItem(storageKey); + const raw = readByKey(storageKey); if (!raw) return; const parsed = JSON.parse(raw) as Record; Object.entries(parsed).forEach(([chainage, offset]) => { @@ -166,26 +217,54 @@ export function collectSectionEdits(ctx: SectionPersistContext): { massHaul: Record | undefined; } { const crossPatches = buildCrossPatches(ctx.patchSources()); - // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. const detail = ctx.detail(); - const context = ctx.context(); - const result = - detail && context?.earthwork_conversion - ? computeMassHaul( - detail.cross_sections, - context.earthwork_conversion, - context.natural_spoil_min_ground_slope ?? undefined, - ) - : null; + // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산해 싣는다 + // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). 카드 그리기가 고쳐 둔 값에 + // 기대면 화면에 안 뜬 측점이 표준값으로 남는다. + const byChainage = new Map(crossPatches.map((patch) => [patch.chainage_m, patch])); + const patchFor = (chainageM: number): CrossSectionPatch => { + const existing = byChainage.get(chainageM); + if (existing) return existing; + const created: CrossSectionPatch = { chainage_m: chainageM }; + byChainage.set(chainageM, created); + crossPatches.push(created); + return created; + }; + // 카드 버튼 선택(지반유형·단면유형·측구·포장·2단 비탈) — 세션 초안이 정본으로 나가는 + // 유일한 길이다(2026-09-06 사용자 확정: 버튼을 누를 때 서버가 저장하지 않는다). + crossDesignChoices(ctx.projectId, ctx.routeId()).forEach((choice, chainage) => { + const patch = patchFor(chainage); + patch.ground_type = choice.ground_type; + patch.section_mode = choice.section_mode; + if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side; + if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type; + if (choice.paved !== undefined) patch.paved = choice.paved; + if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope; + }); + if (detail) { + // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 + // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). + applyStructureAreaRows(detail.cross_sections, structureAreaRows(detail.cross_sections)); + // 면적은 **전 측점**을 싣는다 — 브라우저가 만든 값이 곧 작업본이다. 카드 버튼을 + // 바꾸면 구조물이 없는 측점의 면적도 달라지므로 구조물 측점만 보내면 수량이 어긋난다. + for (const section of detail.cross_sections) { + const design = section.design as Record | undefined; + if (!design) continue; + let patch: CrossSectionPatch | null = null; + for (const key of STRUCTURE_AREA_KEYS) { + if (typeof design[key] !== "number") continue; + patch = patch ?? patchFor(section.chainage_m); + patch[key] = design[key] as number; + } + } + } + // 유토곡선 **정본은 서버가 낸다**(2026-09-06 사용자 확정) — 저장 뒤 `recompute_server_side` + // 가 Node 로 다시 계산해 덮어쓴다. 그래서 여기서는 곡선도 배분도 만들지 않는다. + // 서버가 만들 수 없는 것 하나만 보낸다: 사용자가 끌어 옮긴 balloon 위치(화면값). + const offsets = balloonOffsetsPayload(); return { crossPatches, - massHaul: result - ? massHaulPayload( - result, - computeHaulPlan(result, context?.haul_equipment_limits), - balloonOffsetsPayload(), - ) - : undefined, + massHaul: offsets ? { balloon_offsets: offsets } : undefined, }; } @@ -194,9 +273,20 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await ctx.flushCulvertOptions(); + // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 + // 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다 + // (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래 + // 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다. + await flushUphillOverrides(projectId).catch(() => undefined); // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). + // B05 배수유역도에서 고친 관 목록(추가·이동·삭제)도 여기서 정본에 남긴다 — 예전에는 + // B05 [임시저장]에만 실려, B06 에서 저장하면 그 편집이 사라졌다(2026-09-06 대응표). + await flushPendingPipes(projectId).catch((error) => { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`배수관 저장에 실패했습니다.${detail}`, "error"); + }); await flushPendingStructures(projectId).catch((error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); @@ -218,6 +308,10 @@ export async function saveCurrentSections(ctx: SectionPersistContext): Promise Math.abs(section.chainage_m - handoff.at) < 0.01, + (section) => Math.abs(section.chainage_m - handoff.at) < 0.05, ); if (!target) return; // 조정창은 앞칸(측 이름)만 본다 — 뒤칸은 3D 강조를 부재 하나로 좁히는 몫이다. diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index e234da6b..a0152ed3 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -18,9 +18,11 @@ import { fetchStructures, fetchStructureTypes, readPendingStructures, + pipesToStructureMarks, structureAnchorM, writePendingStructures, type StructureInstance, + type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -62,6 +64,8 @@ export interface B06StructuresPanelDeps { focusChainage: (chainageM: number) => void; /** 바깥(횡단도·3D 넘김값)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ reveal?: () => void; + /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ + onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다 * (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */ queuePipeOptions?: (chainageM: number, patch: Record) => void; @@ -71,6 +75,9 @@ export interface B06StructuresPanelDeps { /** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가 * 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */ movePipe?: (fromChainageM: number, toChainageM: number) => void; + /** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로** + * 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */ + onStructuresChanged?: () => void; } export interface B06StructuresPanel { @@ -223,11 +230,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, ); + let markTypes: StructureType[] = []; + /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ + const pushMarks = (): void => + deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); const section = createStructuresSection({ onChange: (next) => { structures = withLocalIds(next); section.setStructures(structures); + pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); + deps.onStructuresChanged?.(); }, onSelect: (structure) => { if (structure) deps.focusChainage(structureAnchorM(structure)); @@ -251,6 +264,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit) hit.chainage_m = toChainageM; currentChainageM = toChainageM; section.setPipeFacilities(pipeFacilities); + pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); } else { showToast(PIPE_MOVE_GUIDE, "error"); @@ -309,11 +323,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const projectId = deps.projectId; try { const [types, stored, pipeResponse] = await Promise.all([ - fetchStructureTypes(), + // B06 은 **구조물 전체**를 넣을 수 있어야 한다(2026-09-07 사용자 지시 5). B05 목록에서 + // 빠져 있던 B군 종단배수·F군 생태/녹화도 여기서는 고를 수 있다 — 레지스트리 주석이 + // 「B06 개별 횡단도 옵션으로 재사용」이라 적어 둔 그 자리다. + fetchStructureTypes(true), fetchStructures(projectId), fetchDetailPipePoints(projectId), ]); section.setTypes(types); + markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); @@ -341,6 +359,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc )?.design_flow_m3s ?? null, })); section.setPipeFacilities(pipeFacilities); + // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. + pushMarks(); // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index d07d48f6..5f75c087 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -13,6 +13,7 @@ * 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다. * ========================================================================== */ +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; // 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS). @@ -34,10 +35,25 @@ import { type RevetOffsetControl, type RevetLinkControl, type StructureSpanControl, - crossCardNaturalHeight, type CrossCardElement, type StationWidthControl, } from "./B06_Section_UI_Cross_View"; +import { + type CrossPlotBase, + crossPlotBase, + effectiveCardHalfWidth, +} from "./B06_Section_UI_Cross_View_Metrics"; +import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { LONG_PAD } from "./B06_Section_UI_Section_Common"; +import { + buildStructureLane, + STRUCTURE_LANE_HEIGHT_PX, +} from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; @@ -46,10 +62,9 @@ import { visibleChainageRange, visibleElevationRange, } from "./B06_Section_UI_Section_View_Chart"; -import { applyLegendToggle } from "@util/common_util_mass_haul"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; -import { createMassHaulRenderer } from "./B06_Section_UI_Section_View_MassHaul"; +import { computeMassHaulSeries } from "@util/common_util_mass_haul"; +import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; import { applyElevationWindow, needsFullRedraw, @@ -59,14 +74,11 @@ import { import { BASE_PANEL_HEIGHT, chartHeights, - MASS_HAUL_VISIBLE_KEY, MAX_PANEL_HEIGHT_RATIO, MIN_LONG_HEIGHT, MIN_PANEL_HEIGHT, PANEL_CHROME_PX, - PANEL_COLLAPSED_KEY, PANEL_HEIGHT_KEY, - readVisibleSeries, } from "./B06_Section_UI_Section_View_Panel"; import { CROSS_GRID_GAP, @@ -112,6 +124,12 @@ export interface SectionViewController { * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; clear: () => void; + /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 + * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ + setStructureMarks: ( + structures: ReadonlyArray, + types: ReadonlyArray, + ) => void; dispose: () => void; } @@ -139,13 +157,12 @@ export function createSectionView( let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; - let currentHaulLimits: HaulEquipmentLimit[] | undefined; let currentNaturalSpoilSlope: number | undefined; + let markStructures: ReadonlyArray = []; + let markTypes: ReadonlyArray = []; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; - // 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다. - const visibleSeries = readVisibleSeries(); // 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다). let lastChartAvailable = 0; let chartFitScheduled = false; @@ -186,11 +203,11 @@ export function createSectionView( panelToggle.root.classList.add("b06-section__panel-toggle"); panel.append(panelHeader, panelBody, panelToggle.root); // 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙). - if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed"); + if (readStateRaw("section-panel-collapsed") === "true") panel.classList.add("is-collapsed"); panelToggle.setOpen(!panel.classList.contains("is-collapsed")); panelToggle.root.addEventListener("click", () => { const collapsed = panel.classList.toggle("is-collapsed"); - sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(collapsed)); + writeStateRaw("section-panel-collapsed", String(collapsed)); panelToggle.setOpen(!collapsed); // 펼칠 때는 레이아웃이 잡힌 **다음 프레임**에 그린다 — 그래프 몫을 실측으로 잡기 때문에 // 같은 프레임에 그리면 접혀 있던 0 높이를 읽는다(B05 하단 패널과 같은 처리). @@ -411,7 +428,11 @@ export function createSectionView( return planZ - (section.design?.surface_drop_m ?? 0); }; - const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement => + const buildCrossCard = ( + section: CrossSection, + forcedHeightPx?: number, + plotBase?: CrossPlotBase | null, + ): HTMLElement => createCrossSectionCard( section, section.station_id === selectedStationId, @@ -435,6 +456,7 @@ export function createSectionView( revetLink, ford, box, + plotBase, ); /** @@ -452,18 +474,10 @@ export function createSectionView( ); }; - /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ - const toggleSeries = (key: string): void => { - // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준으로 계산된 그래프만 보인다. - const next = applyLegendToggle(visibleSeries, key); - visibleSeries.clear(); - next.forEach((entry) => visibleSeries.add(entry)); - sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries])); - drawPanel(); - }; - /** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */ - let renderMassHaul: ((fromM: number, toM: number) => void) | null = null; + /** 최종 누가토량 배지 — 종단 그래프 좌측 상단(2026-09-06 사용자 지시). */ + const massBadge = createMassHaulBadge(); + panelBody.append(massBadge.root); /** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */ let updateChartWindow: (() => ElevationWindowResult | null) | null = null; @@ -479,10 +493,12 @@ export function createSectionView( // 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다. const measured = chartWrap.clientHeight; lastChartAvailable = - measured > 0 - ? measured - : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); - const heights = chartHeights(lastChartAvailable); + measured > 0 ? measured : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT); + // 알약 레인도 `chartWrap` 안에 서므로 잰 높이에 **이미 들어 있다**. 그 몫을 빼지 않으면 + // 다음 회차에 잰 값이 그만큼 더 커지고, 그 값으로 그래프를 키우면 또 커지는 되먹임이 + // 생겨 패널이 16,000px 까지 부푼다(2026-09-07 실측). 빼 두면 한 번에 수렴한다. + const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0; + const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT)); const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); const chartWidth = Math.max(renderWidth, minWidth); const chart = buildLongitudinalChart({ @@ -499,32 +515,56 @@ export function createSectionView( }); const longAxis = chart.axis; const nodes: Element[] = [chart.node]; - const { viewFromM, viewToM } = chart; + // 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다. + // 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). + if (markStructures.length && markTypes.length) { + nodes.push( + buildStructureLane({ + structures: markStructures, + types: markTypes, + x: chart.toX, + chainageAt: chart.toChainage, + maxChainageM: chart.maxChainageM, + widthPx: chartWidth, + axisWidthPx: LONG_PAD.left, + stationIntervalM: cachedStationInterval, + selectedId: null, + // B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + onSelect: (structureId) => { + if (!structureId) return; + const hit = markStructures.find((entry) => entry.structure_id === structureId); + if (!hit) return; + const at = structureAnchorM(hit); + // 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙. + let best: { id: string; gap: number } | null = null; + for (const section of currentDetail?.cross_sections ?? []) { + const gap = Math.abs(section.chainage_m - at); + if (!best || gap < best.gap) best = { id: section.station_id, gap }; + } + if (best) selectStation(best.id, true); + }, + // 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다. + onMove: () => undefined, + }), + ); + } chartWrap.replaceChildren(...nodes); - // 유토곡선 몫은 전용 모듈이 만들어 붙인다(2026-09-03 · 700줄 제한). 가로로 스크롤할 - // 때는 **이 함수만** 다시 불러 곡선의 세로 창을 따라오게 한다 — 종단면도는 건드리지 - // 않는다(2026-09-04: 상단 패널을 통째로 다시 그리면 화면이 한 번 끊긴다). - renderMassHaul = createMassHaulRenderer( - { - detail, - conversion: currentConversion, - haulLimits: currentHaulLimits, - naturalSpoilSlope: currentNaturalSpoilSlope, - visibleSeries, - selectedStationId, - stationInterval: cachedStationInterval, - chartWidth, - minWidth, - massHeight: heights.mass, - longHeight: heights.long, - selectStation: (stationId) => selectStation(stationId, true), - toggleSeries, - redraw: drawPanel, - }, - { chartWrap, panel, panelBody, statusNode: panelCount }, - ); - renderMassHaul(viewFromM, viewToM); + // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 + // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 + // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). + if (currentConversion) { + const series = computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + currentConversion, + currentNaturalSpoilSlope, + ); + const cross = series.find((entry) => entry.basis === "cross") ?? series[0]; + massBadge.set(cross ? badgeValuesFrom(cross.result) : null); + } else { + massBadge.set(null); + } // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). if (longAxis) { @@ -542,7 +582,6 @@ export function createSectionView( chartWrap.scrollLeft, chartWrap.clientWidth || chartWidth, ); - renderMassHaul?.(fromM, toM); return applyElevationWindow( chartWrap, visibleElevationRange(detail, fromM, toM) ?? undefined, @@ -609,16 +648,22 @@ export function createSectionView( if (detail.cross_sections.length) { // 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로. const sections = detail.cross_sections; - const naturalHeights = sections.map((section) => - crossCardNaturalHeight( + // 여기서 만든 기하를 2차에서 **그대로 넘긴다** — 예전에는 같은 측점을 두 번 계산했고 + // 그것이 진입에서 `draw` 자기 시간의 대부분이었다(2026-09-06 CPU 프로파일). + // 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰 + // 재 둔다 — 카드·행 높이가 어긋나지 않는다. + const plotBases = sections.map((section) => + crossPlotBase( section, currentExaggeration, cachedCardWidth, - // 개별 표시 반폭이 있으면 그 폭 기준으로 높이를 재야 카드·행 높이가 맞는다. - stationWidth?.widthFor(section) ?? currentCrossHalfWidth, + effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth), designElevationAt(detail.longitudinal.design_profiles, section.chainage_m), ), ); + const naturalHeights = plotBases.map((base) => + Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT), + ); // 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다. for (let start = 0; start < sections.length; start += columnCount) { const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount)); @@ -628,7 +673,7 @@ export function createSectionView( index += 1 ) { cachedRowHeight.set(sections[index].station_id, rowHeight); - grid.append(buildCrossCard(sections[index], rowHeight)); + grid.append(buildCrossCard(sections[index], rowHeight, plotBases[index])); } } } else { @@ -674,7 +719,9 @@ export function createSectionView( crossHalfWidth, stationInterval, earthworkConversion, - haulEquipmentLimits, + // 운반 장비 한계는 곡선(운반 블록)에만 쓰던 값이라 B06 에서는 더 받지 않는다 + // (2026-09-06 유토곡선 그래프 제거). 인자 자리는 호출부 호환을 위해 남긴다. + _haulEquipmentLimits, balloonScope, naturalSpoilMinSlope, ) { @@ -685,7 +732,6 @@ export function createSectionView( currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; if (earthworkConversion) currentConversion = earthworkConversion; - if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits; if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope; // balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다. configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined); @@ -703,6 +749,18 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setStructureMarks(structures, types) { + markStructures = structures; + markTypes = types; + // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 + // (`__corridorBuild` 등과 같은 용도). + (window as unknown as { __b06Marks?: unknown }).__b06Marks = { + structures: structures.length, + types: types.length, + drawn: !!currentDetail, + }; + if (currentDetail) drawPanel(); + }, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts index 468efe3c..7ab41541 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -37,6 +37,8 @@ export interface LongitudinalChartResult { axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ toChainage: (px: number) => number; + /** 누가거리(m) → 화면 x(px). 구조물 알약 레인이 그래프와 같은 자리를 쓰게 한다. */ + toX: (chainageM: number) => number; maxChainageM: number; viewFromM: number; viewToM: number; @@ -78,6 +80,8 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi const maxChainageM = longitudinalMaxChainage(detail.longitudinal); const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const toX = (chainageM: number): number => + LONG_PAD.left + (maxChainageM > 0 ? (chainageM / maxChainageM) * plotWidth : 0); const { fromM, toM } = visibleChainageRange( toChainage, maxChainageM, @@ -112,5 +116,5 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi visibleElevationRange(detail, fromM, toM) ?? undefined, ), ); - return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM }; + return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM }; } diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts deleted file mode 100644 index b28167fe..00000000 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ /dev/null @@ -1,236 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Section_View_MassHaul.ts - * B06 상단 패널의 **유토곡선 몫**만 떼어 낸 조립기 (2026-09-03 · 700줄 제한). - * - * 뷰 컨트롤러(`_UI_Section_View`)가 종단면도와 카드 그리드를 맡고, 곡선 계산 → 차트 → - * 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈 - * (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다. - * - * 낡음 판정(`hasStaleDesigns`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금 - * 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정: - * 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면 - * 사용자가 옛 그림을 본다. - * ========================================================================== */ - -import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; -import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch"; -import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { - computeMassHaulSeries, - MASS_HAUL_BALANCE_KEY, - type MassHaulSeries, -} from "@util/common_util_mass_haul"; -import { computeHaulPlan } from "@util/common_util_mass_haul_balance"; -import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { - createMassHaulChart, - createMassHaulLegend, - createMassHaulSummary, - createMassHaulWindowState, - scheduleMassHaulSettle, - type MassHaulWindowState, -} from "@util/common_util_mass_haul_view"; -import { - L, - longitudinalMaxChainage, - LONG_PAD, - hasStaleDesigns, -} from "./B06_Section_UI_Section_Common"; - -/** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */ -export interface MassHaulAxisTicks { - padLeft: number; - ticks: Array<{ y: number; label: string }>; -} - -export interface MassHaulPanelInput { - detail: SectionDetailResponse; - conversion: EarthworkConversion | undefined; - haulLimits: HaulEquipmentLimit[] | undefined; - naturalSpoilSlope: number | undefined; - visibleSeries: Set; - selectedStationId: string | null; - stationInterval: number; - chartWidth: number; - minWidth: number; - /** 유토곡선 몫 높이(px)와 그 위 종단면도 높이(px) — 범례·Y축 자리를 잡는 값. */ - massHeight: number; - longHeight: number; - selectStation: (stationId: string) => void; - toggleSeries: (key: string) => void; - /** 범례에서 도형 위치를 초기화한 뒤 패널을 다시 그린다. */ - redraw: () => void; - /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ - viewRange?: { fromM: number; toM: number }; - /** 세로창 버티기·부드러운 이동 상태(2026-09-04). */ - windowState?: MassHaulWindowState; -} - -export interface MassHaulPanelResult { - /** 차트 SVG — 곡선이 없으면 null(그 자리는 비운다). */ - chart: Element | null; - axis: MassHaulAxisTicks | null; - /** 범례·요약줄 — 패널 본문에 붙일 순서대로. */ - overlays: HTMLElement[]; - /** 곡선을 못 그린 이유(있으면 상태줄에 그대로 적는다). 그릴 수 있으면 빈 문자열. */ - statusText: string; -} - -/** - * 유토곡선 차트·범례·요약줄을 만든다. DOM 에 붙이는 것은 호출한 쪽 몫이다 — - * 차트는 종단면도와 **같은 부모의 형제**여야 하고(감싸는 상자가 하나라도 끼면 스크롤 - * 컨테이너 폭 계산이 어긋나 측점 세로선이 밀린다) 범례는 스크롤 컨테이너 **밖**이라 - * 붙일 자리가 서로 다르기 때문이다. - */ -export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { - const { detail, visibleSeries } = input; - const pendingRecalc = hasStaleDesigns(detail); - // 계산 결과를 아껴 두지 **않는다**. 횡단 설계는 같은 객체를 제자리에서 고치므로 - // (`refreshCrossDesigns`) 객체가 같은지로는 바뀐 것을 못 잰다 — 2026-09-04 에 캐시를 - // 넣었다가 계획고를 조절해도 횡단 기준 곡선이 그대로였다(사용자 보고). - const series: MassHaulSeries[] = - input.conversion && !pendingRecalc - ? computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - input.conversion, - input.naturalSpoilSlope, - ) - : []; - // 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을 - // 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다. - const bandedSeries = series.find((entry) => visibleSeries.has(entry.key)); - const haulPlan = - bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY) - ? computeHaulPlan(bandedSeries.result, input.haulLimits) - : null; - - let axis: MassHaulAxisTicks | null = null; - const chart = series.length - ? createMassHaulChart( - series, - visibleSeries, - detail.longitudinal, - { - maxChainageM: longitudinalMaxChainage(detail.longitudinal), - padLeft: LONG_PAD.left, - padRight: LONG_PAD.right, - viewRange: input.viewRange, - window: input.windowState, - }, - input.selectedStationId, - input.stationInterval, - input.chartWidth, - input.massHeight, - input.minWidth, - input.selectStation, - haulPlan, - (next) => { - axis = next; - }, - ) - : null; - - const overlays: HTMLElement[] = []; - if (series.length) { - // 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 세로 자리는 - // 종단면도 높이로 잡는다. - const legend = createMassHaulLegend(series, visibleSeries, input.toggleSeries, () => { - resetBalloonOffsets(); - input.redraw(); - }); - legend.style.top = `${input.longHeight + 6}px`; - overlays.push(legend); - } - // 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다. - if (bandedSeries) { - overlays.push(createMassHaulSummary(bandedSeries, haulPlan)); - return { chart, axis, overlays, statusText: "" }; - } - const statusText = pendingRecalc - ? L("B06_MassHaul_Recalculating") - : L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty"); - return { chart, axis, overlays, statusText }; -} - -/** 유토곡선을 붙일 자리 — 뷰 컨트롤러가 들고 있는 DOM 이다. */ -export interface MassHaulMountTargets { - /** 차트가 들어가는 가로 스크롤 컨테이너. 종단면도와 **같은 부모의 형제**여야 한다. */ - chartWrap: HTMLElement; - /** 옛 범례·요약줄을 찾아 지울 뿌리. */ - panel: HTMLElement; - /** 새 범례·요약줄을 붙일 자리(스크롤 컨테이너 밖). */ - panelBody: HTMLElement; - /** 곡선을 못 그린 이유를 적는 상태줄. */ - statusNode: HTMLElement; -} - -/** 붙여 둔 유토곡선 노드 — 다음 갱신에서 **제자리 교체**하는 데 쓴다. */ -export interface MountedMassHaul { - chart: Element | null; - axis: HTMLElement | null; -} - -/** - * 유토곡선을 만들어 붙인다. 이미 붙어 있으면 **그 자리에서 갈아 끼운다** — 종단면도는 - * 건드리지 않는다(2026-09-04). 가로로 스크롤할 때마다 곡선의 세로 창이 따라와야 하는데, - * 상단 패널을 통째로 다시 그리면 종단 그래프까지 새로 만들어져 화면이 한 번 끊긴다. - */ -export function mountMassHaulPanel( - input: MassHaulPanelInput, - targets: MassHaulMountTargets, - previous: MountedMassHaul, -): MountedMassHaul { - const built = buildMassHaulPanel(input); - let chart = previous.chart; - if (built.chart) { - if (chart?.isConnected) chart.replaceWith(built.chart); - else targets.chartWrap.append(built.chart); - chart = built.chart; - } else if (chart?.isConnected) { - chart.remove(); - chart = null; - } - - let axis = previous.axis; - if (built.axis) { - // 고정 Y축은 0크기 sticky 앵커라 **첫 자식**이어야 세로 기준이 컨테이너 상단이 된다. - // 안쪽(inner)은 종단 높이만큼 내려 자기 그래프 구간만 덮는다. - const overlay = buildStickyYAxis(built.axis, input.massHeight); - (overlay.firstElementChild as HTMLElement).style.top = `${input.longHeight}px`; - if (axis?.isConnected) axis.replaceWith(overlay); - else targets.chartWrap.prepend(overlay); - axis = overlay; - } else if (axis?.isConnected) { - axis.remove(); - axis = null; - } - - targets.panel.querySelector(".b06-masshaul__legend")?.remove(); - targets.panel.querySelector(".b06-masshaul__summary")?.remove(); - targets.panelBody.append(...built.overlays); - targets.statusNode.textContent = built.statusText; - return { chart, axis }; -} - -/** - * 「보이는 구간만 바꿔 다시 그리는」 함수를 만든다 — 스크롤 갱신이 부를 것이다. - * 붙여 둔 노드는 이 함수가 스스로 들고 있으므로 부르는 쪽은 구간만 넘기면 된다. - */ -export function createMassHaulRenderer( - base: Omit, - targets: MassHaulMountTargets, -): (fromM: number, toM: number) => void { - let mounted: MountedMassHaul = { chart: null, axis: null }; - const windowState = createMassHaulWindowState(); - const render = (fromM: number, toM: number): void => { - mounted = mountMassHaulPanel( - { ...base, viewRange: { fromM, toM }, windowState }, - targets, - mounted, - ); - // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더(2026-09-04 「부드럽게」). - scheduleMassHaulSettle(windowState, () => render(fromM, toM)); - }; - return render; -} diff --git a/B06_Section/B06_Section_UI_Section_View_Panel.ts b/B06_Section/B06_Section_UI_Section_View_Panel.ts index 021a86a1..421f0fb2 100644 --- a/B06_Section/B06_Section_UI_Section_View_Panel.ts +++ b/B06_Section/B06_Section_UI_Section_View_Panel.ts @@ -1,25 +1,25 @@ -import { - MASS_HAUL_BALANCE_KEY, - MASS_HAUL_DEFAULT_VISIBLE, - normalizeVisibleBasis, -} from "@util/common_util_mass_haul"; -import { MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view"; +import { MASS_HAUL_DEFAULT_VISIBLE, normalizeVisibleBasis } from "@util/common_util_mass_haul"; +import { readStateRaw, stateKey } from "../A00_Common/b_page_state"; import { LONG_HEIGHT } from "./B06_Section_UI_Section_Common"; export const PANEL_CHROME_PX = 102; -export const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX; +/* 유토곡선 그래프를 B06 에서 뺐다(2026-09-06 사용자 지시) — 패널 높이도 종단면도 몫만 + 잡는다. 곡선은 B05 유토곡선 패널에서 보고, 여기서는 좌측 상단 배지가 총괄값을 보인다. */ +export const BASE_PANEL_HEIGHT = LONG_HEIGHT + PANEL_CHROME_PX; export const MIN_LONG_HEIGHT = 110; -export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX; +export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + PANEL_CHROME_PX; export const MAX_PANEL_HEIGHT_RATIO = 0.8; -export const PANEL_HEIGHT_KEY = "b06:profile-panel-height"; -export const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed"; -export const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4"; +/* 패널 접힘·높이·유토곡선 범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. + 범례 키는 B05 와 **같은 값**이어야 한다(두 화면이 같은 그림의 두 창). */ +export const PANEL_HEIGHT_KEY = stateKey("section-panel-height") ?? ""; -const DEFAULT_VISIBLE_KEYS = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY]; +/** 처음 열 때는 **곡선만** — 토량 분배는 범례에서 켠다(2026-09-06 사용자 지시). + * B05 `_UI_Profile_MassHaul` 과 같은 값이어야 한다(두 화면 공용 키). */ +const DEFAULT_VISIBLE_KEYS = [...MASS_HAUL_DEFAULT_VISIBLE]; export function readVisibleSeries(): Set { try { - const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY); + const raw = readStateRaw("masshaul-visible"); if (!raw) return new Set(DEFAULT_VISIBLE_KEYS); const parsed: unknown = JSON.parse(raw); return Array.isArray(parsed) @@ -30,20 +30,12 @@ export function readVisibleSeries(): Set { } } +/** 종단면도 높이 — 유토곡선을 빼면서 남는 자리를 **전부** 종단이 쓴다(2026-09-06). */ export function chartHeights(availableHeightPx: number): { long: number; mass: number } { if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) { - return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT }; + return { long: LONG_HEIGHT, mass: 0 }; } - const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT); - if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) { - return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT }; - } - const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT); - const mass = Math.min( - Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)), - available - MIN_LONG_HEIGHT, - ); - return { long: available - mass, mass }; + return { long: Math.max(MIN_LONG_HEIGHT, availableHeightPx), mass: 0 }; } export function unwrapChart(node: HTMLElement): Element { diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index d2154fea..dc670c3d 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -17,8 +17,16 @@ * 카드에서 이뤄진다(여기서는 값만 보관). * ========================================================================== */ +import { + clearState, + readState, + readStateRaw, + writeState, + writeStateRaw, +} from "../A00_Common/b_page_state"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; +import { applyCutSlopeClassChoice } from "./B06_Section_Cut_Slope_Check"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; import { getCompanyStandard, @@ -32,18 +40,6 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const SESSION_PREFIX = "b06:std-cross:"; -/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */ -const DEFAULTS_PREFIX = "b06:std-cross-default:"; - -function sessionKey(projectId: string): string { - return `${SESSION_PREFIX}${projectId}`; -} - -function defaultsKey(projectId: string): string { - return `${DEFAULTS_PREFIX}${projectId}`; -} - /** * 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답). * @@ -52,47 +48,28 @@ function defaultsKey(projectId: string): string { * 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환). */ export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void { - try { - window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults)); - } catch { - /* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */ - } + writeState("std-cross-default", defaults, projectId); } /** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */ export function readStandardDefaults(projectId: string): StandardCrossSection | null { - try { - const raw = window.sessionStorage.getItem(defaultsKey(projectId)); - return raw ? (JSON.parse(raw) as StandardCrossSection) : null; - } catch { - return null; - } + return readState("std-cross-default", projectId); } -const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:"; - /** * 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다. * 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면 * 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다. */ export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void { - try { - window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM)); - } catch { - /* 세션 저장 실패는 무시. */ - } + writeStateRaw("rock-boundary-default", String(offsetM), projectId); } /** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */ export function readRockBoundaryDefault(projectId: string): number | null { - try { - const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`); - const parsed = raw === null ? Number.NaN : Number(raw); - return Number.isFinite(parsed) ? parsed : null; - } catch { - return null; - } + const raw = readStateRaw("rock-boundary-default", projectId); + const parsed = raw === null ? Number.NaN : Number(raw); + return Number.isFinite(parsed) ? parsed : null; } /** @@ -105,11 +82,7 @@ export function effectiveStandardCross(projectId: string): StandardCrossSection /** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */ export function clearStandardCrossSession(projectId: string): void { - try { - window.sessionStorage.removeItem(sessionKey(projectId)); - } catch { - /* 세션 접근이 막혀도 초기화는 계속한다. */ - } + clearState("std-cross", projectId); } /** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */ @@ -124,20 +97,11 @@ function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection { * 패널이 없는 화면에서도 **같은 표준 단면값**을 서버로 보내야 두 화면 결과가 같다. */ export function readStandardCrossSession(projectId: string): StandardCrossSection | null { - try { - const raw = window.sessionStorage.getItem(sessionKey(projectId)); - return raw ? (JSON.parse(raw) as StandardCrossSection) : null; - } catch { - return null; - } + return readState("std-cross", projectId); } function writeSession(projectId: string, value: StandardCrossSection): void { - try { - window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(value)); - } catch { - /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ - } + writeState("std-cross", value, projectId); } export interface StandardPanelController { @@ -433,8 +397,46 @@ export function createStandardPanel( } actions.append(resetButton); + // ── 절토 법정 기울기 판정 기준 (2026-09-07 사용자 확정) ────────────────── + // 별표2 는 암을 **경암·연암**으로 가르고 프로그램은 **리핑암·발파암**으로 가른다. 둘을 + // 잇는 문장이 법령·교본에 없어 **프로그램 설정**으로 두고 여기서 고르게 한다. 기본값은 + // 리핑암 → 연암 · 발파암 → 경암(자연스러운 읽기). 절토 경사비가 오는 자리 옆이라 여기 둔다. + const cutClassWrap = document.createElement("div"); + cutClassWrap.className = "b06-std__cutclass"; + const cutClassTitle = document.createElement("p"); + cutClassTitle.className = "b06-std__cutclass-title"; + cutClassTitle.textContent = "절토 법정 기울기 판정 기준 (별표2)"; + cutClassTitle.title = + "별표2: 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5. 작업임도는 규정 없음."; + cutClassWrap.append(cutClassTitle); + const CUT_CLASS_OPTIONS = [ + { value: "soft_rock", text: "연암 (1:0.5~1.2)" }, + { value: "hard_rock", text: "경암 (1:0.3~0.8)" }, + ]; + for (const [kind, label] of [ + ["ripping_rock", "리핑암"], + ["blasting_rock", "발파암"], + ] as const) { + const current = + state.cut_slope_class?.[kind] ?? (kind === "ripping_rock" ? "soft_rock" : "hard_rock"); + const field = createSelectField({ + label, + options: CUT_CLASS_OPTIONS, + value: current, + onChange: (value) => { + state.cut_slope_class = { ...(state.cut_slope_class ?? {}), [kind]: value }; + persist(); + applyCutSlopeClassChoice(state.cut_slope_class); + onApplyAll?.(); + }, + }); + cutClassWrap.append(field.root); + } + // 처음 세울 때도 고른 값을 검사에 반영한다(세션에 남아 있던 선택 포함). + applyCutSlopeClassChoice(state.cut_slope_class); + // 횡단 반폭은 사이드의 **별도 컨테이너**로 뺐다(2026-08-23) — 여기는 표준단면 설정만. - root.append(body, loader, actions); + root.append(body, cutClassWrap, loader, actions); return { root, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index a2c430b5..d6728a14 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -147,6 +147,14 @@ stroke-width: 1.2; } +/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */ +.b06-chart__carriageway-label { + fill: var(--color-royal-amethyst); + font-size: 9px; + text-anchor: middle; + pointer-events: none; +} + /* 암 경계선(설계선 복사 + 오프셋): 리핑암·발파암 구간 점선 */ .b06-chart__rock-boundary { fill: none; diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index e842eae7..34ab74a7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -77,10 +77,7 @@ export interface CrossDesignInfo { cross_slope_pct?: number; paved?: boolean; ditch: DitchSpec; - road_edges?: Record< - "left" | "right", - { offset_m: number; elevation_m: number } - >; + road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>; design_elevation_m: number; cut_area_m2: number; fill_area_m2: number; @@ -120,10 +117,7 @@ export interface DesignDrawingConfirmResponse { design?: CrossDesignInfo | null; } -async function requestJson( - path: string, - init: RequestInit = {}, -): Promise { +async function requestJson(path: string, init: RequestInit = {}): Promise { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { @@ -134,17 +128,14 @@ async function requestJson( signal: controller.signal, }); const payload = (await response.json()) as T & { message?: string }; - if (!response.ok) - throw new Error(payload.message ?? `HTTP ${response.status}`); + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } finally { window.clearTimeout(timeoutId); } } -export function fetchDesignDrawingList( - projectId: string, -): Promise { +export function fetchDesignDrawingList(projectId: string): Promise { return requestJson(`/projects/${projectId}/design-drawings`); } @@ -152,9 +143,7 @@ export function fetchDesignDrawing( projectId: string, drawingId: string, ): Promise { - return requestJson( - `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`, - ); + return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`); } export function confirmDesignDrawing( @@ -172,10 +161,7 @@ export function confirmDesignDrawing( ); } -export function invalidateDesignDrawing( - projectId: string, - drawingId: string, -): Promise { +export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise { return requestJson( `/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`, { method: "POST" }, @@ -189,24 +175,66 @@ export interface FrameTemplateResponse { drawing: CadDrawing; /** 회사가 고친 도각을 쓰고 있으면 true, 프로그램 기본 도각이면 false. */ customized: boolean; + /** 자리표에 보여 줄 실제 값 — 편집 화면 전용이고 저장값은 토큰 그대로다. */ + fields?: Record; } -export function fetchFrameTemplate( - projectId: string, -): Promise { +export function fetchFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`); } -export function saveFrameTemplate( - projectId: string, - drawing: CadDrawing, -): Promise { +export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise { return requestJson(`/projects/${projectId}/frame-template`, { method: "PUT", body: JSON.stringify({ drawing }), }); } +/** 외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 받는다 — 아직 저장하지 않는다. */ +export async function importFrameTemplate( + projectId: string, + file: File, +): Promise<{ drawing: CadDrawing; entity_count: number }> { + const form = new FormData(); + form.append("file", file); + // 파일 전송이라 requestJson(JSON 헤더·짧은 시한)을 쓰지 않는다. + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/frame-template/import`, { + method: "POST", + credentials: "include", + body: form, + }); + const payload = (await response.json()) as { + drawing: CadDrawing; + entity_count: number; + message?: string; + }; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return payload; +} + +/** 지금 도면을 DXF·DWG 파일로 받는다 (2026-09-06 사용자 지시) — 파일은 서버가 만든다. */ +export async function exportDrawing( + projectId: string, + drawing: CadDrawing, + fileFormat: "dxf" | "dwg", + name: string, +): Promise<{ blob: Blob; skipped: number }> { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drawing-export`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ drawing, file_format: fileFormat, name }), + }); + if (!response.ok) { + const detail = (await response.json().catch(() => ({}))) as { message?: string }; + throw new Error(detail.message ?? `HTTP ${response.status}`); + } + return { + blob: await response.blob(), + skipped: Number(response.headers.get("X-Aislo-Skipped") ?? 0), + }; +} + /** 회사 도각을 지우고 프로그램 기본 도각으로 되돌린다. */ export function resetFrameTemplate(projectId: string): Promise { return requestJson(`/projects/${projectId}/frame-template`, { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py new file mode 100644 index 00000000..535fdaf2 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -0,0 +1,170 @@ +"""B07 도면 내보내기 — 캐드 도면(JSON)을 DXF·DWG 파일로 바꾼다 (2026-09-06 사용자 지시). + +불러오기(`..._Engine_Frame_Import`)의 반대 방향이다. 도형은 ezdxf 로 DXF 를 쓰고, +DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불러 바꾼다 — 라이브러리로 +끌어안으면 GPL 이 이 프로그램까지 번진다. + +그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를 +건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다. +""" + +from __future__ import annotations + +import logging +import math +import os +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +import ezdxf +from ezdxf.enums import TextEntityAlignment + +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import bundled_tool + +logger = logging.getLogger(__name__) + +# 파일로 내려보내는 DXF 형식. DWG 로 갈 때는 LibreDWG 가 쓰는 R2004 로 맞춘다 — +# 더 새 형식을 주면 한글이 깨진 채로 DWG 에 박힌다(2026-09-06 실측). +_DXF_VERSION = "R2018" +_DWG_SOURCE_VERSION = "R2004" +_DEFAULT_TEXT_MM = 3.0 + + +def _xy(point: Any) -> tuple[float, float] | None: + if isinstance(point, dict) and isinstance(point.get("x"), (int, float)): + return float(point["x"]), float(point["y"]) + return None + + +def _layer_name(raw: Any) -> str: + """DXF 도면층 이름 규칙에 맞춘다 — 빈 이름과 금지 문자를 걸러 낸다.""" + name = str(raw or "0").strip() + for bad in '<>/\\":;?*|=`': + name = name.replace(bad, "_") + return name[:255] or "0" + + +def _add_entity( + space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int] +) -> None: + """도형 하나를 DXF 에 적는다. 자식이 있으면 자식까지 따라 내려간다.""" + if not isinstance(entity, dict): + return + layer = _layer_name(entity.get("layerId")) + if layer not in layers: + space.doc.layers.add(layer) + layers.add(layer) + attribs = {"layer": layer} + shape = entity.get("shapeData") or {} + kind = entity.get("type") + + if kind == "Image": + # 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다. + skipped["Image"] = skipped.get("Image", 0) + 1 + return + + start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) + if start and end: + space.add_line(start, end, dxfattribs=attribs) + elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str): + options = shape.get("options") or {} + height = float(options.get("fontSize") or _DEFAULT_TEXT_MM) + direction = _xy(options.get("textDirection")) or (1.0, 0.0) + rotation = math.degrees(math.atan2(direction[1], direction[0])) + text = space.add_text( + shape["label"], + dxfattribs={**attribs, "height": height, "rotation": rotation}, + ) + # 자리표는 칸 한가운데에 선다 — 내보낸 파일에서도 같은 자리에 오게 가운데 맞춤. + if options.get("boxWidth") and options.get("boxHeight"): + text.set_placement(base, align=TextEntityAlignment.MIDDLE_CENTER) + else: + text.set_placement(base) + elif point := _xy(shape.get("point")): + space.add_point(point, dxfattribs=attribs) + elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)): + radius = float(shape["radius"]) + start_angle = shape.get("startAngle") + end_angle = shape.get("endAngle") + if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)): + space.add_arc( + center, + radius, + math.degrees(float(start_angle)), + math.degrees(float(end_angle)), + dxfattribs=attribs, + ) + else: + space.add_circle(center, radius, dxfattribs=attribs) + else: + vertices = [xy for vertex in shape.get("points") or [] if (xy := _xy(vertex))] + if len(vertices) >= 2: + space.add_lwpolyline(vertices, close=True, dxfattribs=attribs) + + for child in entity.get("children") or []: + _add_entity(space, child, layers, skipped) + + +def drawing_to_dxf( + drawing: dict[str, Any], version: str = _DXF_VERSION +) -> tuple[bytes, dict[str, int]]: + """캐드 도면(JSON)을 DXF 바이트로. 건너뛴 도형 수를 함께 돌려준다.""" + entities = drawing.get("entities") + if not isinstance(entities, list) or not entities: + raise ValueError("내보낼 도형이 없습니다.") + document = ezdxf.new(version) + space = document.modelspace() + layers: set[str] = {layer.dxf.name for layer in document.layers} + skipped: dict[str, int] = {} + for entity in entities: + _add_entity(space, entity, layers, skipped) + with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary: + path = Path(temporary) / "drawing.dxf" + document.saveas(path) + return path.read_bytes(), skipped + + +def _dxf2dwg_path() -> str | None: + """LibreDWG 의 DWG 쓰기 도구 — `.env` 값 → 동봉본 → PATH 순으로 찾는다.""" + configured = os.getenv("LIBREDWG_DXF2DWG_PATH", "").strip() + if configured: + return configured if Path(configured).is_file() else None + return bundled_tool("dxf2dwg.exe") or shutil.which("dxf2dwg") + + +def dxf_to_dwg(dxf_bytes: bytes) -> bytes: + """DXF 를 DWG 로 바꾼다. 변환기가 없으면 「DXF 로 받으라」는 안내와 함께 실패.""" + converter = _dxf2dwg_path() + if not converter: + raise ValueError("이 서버는 아직 DWG 로 내보내지 못합니다. DXF 로 내려받아 주십시오.") + with tempfile.TemporaryDirectory(prefix="aislo-export-") as temporary: + work_dir = Path(temporary) + source = work_dir / "drawing.dxf" + target = work_dir / "drawing.dwg" + source.write_bytes(dxf_bytes) + try: + subprocess.run( + [converter, "-o", str(target), str(source)], + check=True, + timeout=180, + capture_output=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.info("B07 도면 DWG 내보내기 실패: %s", exc) + raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") from exc + if not target.is_file() or target.stat().st_size == 0: + raise ValueError("DWG 로 바꾸지 못했습니다. DXF 로 내려받아 주십시오.") + return target.read_bytes() + + +def export_drawing(drawing: dict[str, Any], file_format: str) -> tuple[bytes, dict[str, int]]: + """도면을 요청한 형식(dxf·dwg) 파일 바이트로 낸다.""" + if file_format == "dxf": + return drawing_to_dxf(drawing) + if file_format == "dwg": + dxf_bytes, skipped = drawing_to_dxf(drawing, _DWG_SOURCE_VERSION) + return dxf_to_dwg(dxf_bytes), skipped + raise ValueError("DXF 또는 DWG 로만 내보낼 수 있습니다.") diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py new file mode 100644 index 00000000..0cb7dd28 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -0,0 +1,280 @@ +"""B07 외부 도각 파일 불러오기 — DXF(및 변환된 DWG)를 도각 JSON 으로 바꾼다. + +고객이 내는 도각은 DWG 일 확률이 높지만 DWG 는 비공개 형식이라 파이썬이 바로 못 읽는다. +**LibreDWG 의 `dwg2dxf`** 로 바꿔 읽는다 (2026-09-06 사용자 확정). ODA File Converter 는 +비회원 무료 사용이 **비상업 용도로 제한**돼 이 프로그램에는 쓰지 않는다. + +LibreDWG 는 별도 실행 파일로만 부른다 — 라이브러리로 끌어안으면 GPL 이 이 프로그램까지 +번진다. 별도 프로세스 호출은 그 의무가 생기지 않는다. + +읽는 범위는 **R2018(AC1032) 까지**다. 그보다 새 형식이나 변환 실패는 「R2018 이하 또는 +DXF 로 저장해 달라」는 안내로 떨어진다. + +프로그램 값이 들어갈 자리는 여기서 알아맞히지 않는다 — 불러온 뒤 사용자가 자리표를 +직접 놓는다. 좌표는 파일에 있는 그대로 쓴다(도각은 실치수 1:1 mm 로 그린다). +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import tempfile +from math import cos, radians, sin +from pathlib import Path +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +import ezdxf +from ezdxf import colors as ezdxf_colors +from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode + +logger = logging.getLogger(__name__) + +_IMPORT_NS = uuid5(NAMESPACE_URL, "aislo/b07/frame-import") +_DEFAULT_COLOR = "#f5f7fa" +# 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다. +_FLATTEN_MM = 0.2 +_MAX_ENTITIES = 20000 + + +# 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`. +# 별도 실행 파일로만 부른다(라이브러리로 품지 않는다) — 자세한 것은 그 폴더의 README. +_BUNDLED_DIR = Path(__file__).resolve().parent / "openwebcad" / "tools" / "libredwg" + + +def bundled_tool(name: str) -> str | None: + """동봉한 변환기 경로. 없으면 None.""" + path = _BUNDLED_DIR / name + return str(path) if path.is_file() else None + + +def _entity_id(index: int) -> str: + return str(uuid5(_IMPORT_NS, str(index))) + + +def _color(entity: Any) -> str: + """DXF 색 번호를 화면 색으로. 도면층 색(BYLAYER)이면 기본색을 쓴다.""" + try: + aci = int(entity.dxf.color) + if aci in (0, 256): # BYBLOCK · BYLAYER + return _DEFAULT_COLOR + red, green, blue = ezdxf_colors.aci2rgb(aci) + return f"#{red:02x}{green:02x}{blue:02x}" + except Exception: + return _DEFAULT_COLOR + + +def _point(x: float, y: float) -> dict[str, float]: + return {"x": float(x), "y": float(y)} + + +def _base(index: int, entity: Any, kind: str) -> dict[str, Any]: + return { + "id": _entity_id(index), + "type": kind, + "lineColor": _color(entity), + "lineWidth": 1, + "layerId": str(getattr(entity.dxf, "layer", "0")), + } + + +def _line(index: int, entity: Any, start: Any, end: Any) -> dict[str, Any]: + return { + **_base(index, entity, "Line"), + "shapeData": { + "startPoint": _point(start[0], start[1]), + "endPoint": _point(end[0], end[1]), + }, + } + + +def _polyline(index: int, entity: Any, points: list[Any], closed: bool) -> dict[str, Any] | None: + """점 목록을 선분 묶음(PolyLine)으로 바꾼다. 점이 2개 미만이면 버린다.""" + vertices = [(float(p[0]), float(p[1])) for p in points] + if closed and len(vertices) > 2: + vertices.append(vertices[0]) + if len(vertices) < 2: + return None + children = [ + { + **_base(index, entity, "Line"), + "id": str(uuid5(_IMPORT_NS, f"{index}:{seq}")), + "shapeData": { + "startPoint": _point(*vertices[seq]), + "endPoint": _point(*vertices[seq + 1]), + }, + } + for seq in range(len(vertices) - 1) + ] + return {**_base(index, entity, "PolyLine"), "shapeData": None, "children": children} + + +def _plain(label: str) -> str: + """옛 DXF 는 한글을 유니코드 escape 로 적는다 — 글자로 되돌린다.""" + return decode_dxf_unicode(label) if has_dxf_unicode(label) else label + + +def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]: + rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) + return { + **_base(index, entity, "Text"), + "shapeData": { + "label": _plain(label), + "basePoint": _point(insert[0], insert[1]), + "options": { + "textDirection": _point(cos(radians(rotation)), sin(radians(rotation))), + "textAlign": "left", + "textColor": _color(entity), + "fontSize": float(height) or 3.0, + "fontFamily": "sans-serif", + }, + }, + } + + +def _flatten(entity: Any) -> list[Any] | None: + """원·호·타원·스플라인을 선분 점열로 편다. 못 펴면 None.""" + try: + return list(entity.flattening(_FLATTEN_MM)) + except Exception: + return None + + +def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: + kind = entity.dxftype() + if kind == "LINE": + return [_line(index, entity, entity.dxf.start, entity.dxf.end)] + if kind == "LWPOLYLINE": + shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed)) + return [shape] if shape else [] + if kind == "POLYLINE": + points = [vertex.dxf.location for vertex in entity.vertices] + shape = _polyline(index, entity, points, bool(entity.is_closed)) + return [shape] if shape else [] + if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"): + points = _flatten(entity) + if not points: + return [] + shape = _polyline(index, entity, points, kind in ("CIRCLE", "ELLIPSE")) + return [shape] if shape else [] + if kind == "POINT": + location = entity.dxf.location + return [ + { + **_base(index, entity, "Point"), + "shapeData": {"point": _point(location[0], location[1])}, + } + ] + if kind == "TEXT": + label = str(entity.dxf.text or "").strip() + if not label: + return [] + return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))] + if kind == "MTEXT": + label = str(entity.plain_text() or "").strip() + if not label: + return [] + height = float(entity.dxf.char_height or 3.0) + return [_text(index, entity, label, entity.dxf.insert, height)] + return [] + + +def _expand(entity: Any) -> list[Any]: + """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다.""" + if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"): + try: + return list(entity.virtual_entities()) + except Exception: + logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype()) + return [] + return [entity] + + +def dxf_to_entities(path: Path) -> list[dict[str, Any]]: + """DXF 파일을 도각 엔티티 목록으로. 지원 밖 도형(해치·솔리드 등)은 버린다.""" + document = ezdxf.readfile(str(path)) + entities: list[dict[str, Any]] = [] + index = 0 + for source in document.modelspace(): + for item in _expand(source): + entities.extend(_convert_entity(index, item)) + index += 1 + if len(entities) > _MAX_ENTITIES: + raise ValueError( + f"도형이 너무 많습니다({_MAX_ENTITIES}개 넘음)." + " 도각만 남겨 다시 저장해 주십시오." + ) + if not entities: + raise ValueError("읽을 수 있는 도형이 없습니다. 선·글자가 있는 도각인지 확인해 주십시오.") + return entities + + +# DWG 머리글의 형식 표시(앞 6바이트) — 읽을 수 있는 것과 사람이 읽을 이름. +_DWG_VERSIONS: dict[str, str] = { + "AC1014": "R14", + "AC1015": "2000", + "AC1018": "2004", + "AC1021": "2007", + "AC1024": "2010", + "AC1027": "2013", + "AC1032": "2018", +} +_SAVE_AS_GUIDE = ( + "캐드에서 「다른 이름으로 저장」으로 AutoCAD 2018 DWG 또는 DXF 를 골라 저장한 뒤 올려 주십시오." +) + + +def dwg_version(data: bytes) -> str | None: + """DWG 머리글에서 형식 이름을 읽는다. 우리가 아는 형식이 아니면 None.""" + return _DWG_VERSIONS.get(data[:6].decode("ascii", "ignore")) + + +def _dwg2dxf_path() -> str | None: + """LibreDWG 변환기(dwg2dxf) 자리 — `.env` 값 → 동봉본 → PATH 순으로 찾는다.""" + configured = os.getenv("LIBREDWG_DWG2DXF_PATH", "").strip() + if configured: + return configured if Path(configured).is_file() else None + return bundled_tool("dwg2dxf.exe") or shutil.which("dwg2dxf") + + +def _dwg_to_dxf(source: Path, work_dir: Path) -> Path: + """LibreDWG 로 DWG 를 DXF 로 바꾼다. 못 읽는 형식·변환기 없음은 안내와 함께 실패.""" + with source.open("rb") as handle: + version = dwg_version(handle.read(6)) + if version is None: + raise ValueError(f"이 DWG 는 R2018 이후이거나 알 수 없는 형식입니다. {_SAVE_AS_GUIDE}") + converter = _dwg2dxf_path() + if not converter: + raise ValueError(f"이 서버는 아직 DWG 를 바로 읽지 못합니다. {_SAVE_AS_GUIDE}") + target = work_dir / "converted.dxf" + try: + subprocess.run( + [converter, "-o", str(target), str(source)], + check=True, + timeout=180, + capture_output=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + logger.info("B07 도각 DWG 변환 실패(%s): %s", version, exc) + raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") from exc + if not target.is_file() or target.stat().st_size == 0: + raise ValueError(f"DWG({version}) 를 바꾸지 못했습니다. {_SAVE_AS_GUIDE}") + return target + + +def import_frame_file(filename: str, data: bytes) -> list[dict[str, Any]]: + """올린 도각 파일(DXF·DWG)을 도각 엔티티 목록으로 바꾼다.""" + suffix = Path(filename).suffix.lower() + if suffix not in (".dxf", ".dwg"): + raise ValueError("DXF 또는 DWG 파일만 올릴 수 있습니다.") + with tempfile.TemporaryDirectory(prefix="aislo-frame-") as temporary: + work_dir = Path(temporary) + source = work_dir / f"frame{suffix}" + source.write_bytes(data) + target = _dwg_to_dxf(source, work_dir) if suffix == ".dwg" else source + try: + return dxf_to_entities(target) + except ezdxf.DXFError as exc: + raise ValueError(f"DXF 를 읽지 못했습니다: {exc}") from exc diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py index 6708d945..e0e20440 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Template.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Template.py @@ -20,13 +20,12 @@ from pathlib import Path from typing import Any from uuid import NAMESPACE_URL, uuid5 -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines - from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _ENTITY_NS, DRAWING_FORMAT, FRAME_LAYER_ID, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Svg import fit_polylines, svg_polylines _TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "resources" / "template_2dDrawing" @@ -114,9 +113,14 @@ def frame_template_document(name: str = A1_TEMPLATE) -> dict[str, Any]: 1:1이라 편집 캔버스 좌표가 곧 템플릿 좌표다. 저장할 때 되돌릴 변환이 없다. """ + return frame_document(template_entities(name)) + + +def frame_document(entities: list[dict[str, Any]]) -> dict[str, Any]: + """엔티티 목록을 도각 편집 화면이 그대로 싣는 도면 한 장으로 감싼다 (실치수 1:1).""" return { "format": DRAWING_FORMAT, - "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in template_entities(name)], + "entities": [{**entity, "layerId": FRAME_LAYER_ID} for entity in entities], "layers": [{"id": FRAME_LAYER_ID, "name": "도각", "isVisible": True, "isLocked": False}], } diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index f4bb7d50..e81516c3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -27,10 +27,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( extract_quantity_table, ) from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( - clear_company_template, - company_template_path, - frame_template_document, - save_company_template, use_company_templates, use_title_fields, ) @@ -59,9 +55,6 @@ from B07_DesignDetail.B07_DesignDetail_Schema import ( DesignDrawingInvalidateResponse, DesignDrawingListResponse, DesignDrawingResponse, - FrameTemplateResponse, - FrameTemplateSaveRequest, - FrameTemplateSaveResponse, ) from common_util.common_util_drainage_context import load_drainage_context from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path @@ -121,7 +114,7 @@ def _asset_data_url(relative_path: str | None) -> str: return f"data:{mime};base64,{b64encode(blob).decode('ascii')}" -async def _title_block_fields(project_id: UUID) -> dict[str, str]: +async def title_block_fields(project_id: UUID) -> dict[str, str]: """도각 표제란에 채울 값. **DB가 아는 것만** 담고 나머지는 담지 않는다. 담지 않은 자리는 `_fill_placeholders`가 빈칸으로 지운다 — 도각 원본에 남의 값이 @@ -282,7 +275,7 @@ async def get_design_drawing( # 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다. use_company_templates(project_root.parent.parent) # 표제란 값도 같은 요청 문맥에 세운다 — 값이 없는 칸은 빈칸으로 나간다. - use_title_fields(await _title_block_fields(project_id)) + use_title_fields(await title_block_fields(project_id)) # 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다. design: dict[str, Any] | None = None source_design: Any = None @@ -562,71 +555,3 @@ async def invalidate_design_drawing( status_code=500, content={"status": "error", "message": "상세 설계 도면 상태를 되돌리지 못했습니다."}, ) - - -@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse) -async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse: - """도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본.""" - try: - company_dir = await _company_dir(project_id) - use_company_templates(company_dir) - return FrameTemplateResponse( - project_id=str(project_id), - drawing=frame_template_document(), - customized=company_template_path(company_dir).is_file(), - ) - except FileNotFoundError as exc: - return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B07 도각 조회 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "도각을 읽지 못했습니다."}, - ) - - -@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) -async def put_frame_template( - project_id: UUID, request: FrameTemplateSaveRequest -) -> FrameTemplateSaveResponse | JSONResponse: - """편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다. - - 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 - 다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정). - """ - try: - entities = request.drawing.get("entities") - if not isinstance(entities, list): - raise ValueError("도각 엔티티가 없습니다.") - company_dir = await _company_dir(project_id) - await asyncio.to_thread(save_company_template, company_dir, entities) - return FrameTemplateSaveResponse(project_id=str(project_id)) - except (FileNotFoundError, ValueError) as exc: - return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B07 도각 저장 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "도각을 저장하지 못했습니다."}, - ) - - -@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) -async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse: - """회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설). - - 되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. - 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다. - """ - try: - company_dir = await _company_dir(project_id) - removed = await asyncio.to_thread(clear_company_template, company_dir) - return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed) - except FileNotFoundError as exc: - return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) - except Exception: - logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id) - return JSONResponse( - status_code=500, - content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."}, - ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Frame.py b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py new file mode 100644 index 00000000..6ad39fab --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Frame.py @@ -0,0 +1,193 @@ +"""B07 도각·내보내기 라우터 (B07_DesignDetail_Router 에서 분리, 700줄 제한). + +도각을 읽고 고치고 되돌리는 길, 외부 도각 파일(DXF·DWG) 불러오기, 그리고 캐드 도면을 +DXF·DWG 파일로 내보내는 길을 한곳에 둔다. +""" + +import asyncio +import logging +import re +from pathlib import Path +from urllib.parse import quote +from uuid import UUID + +from fastapi import APIRouter, File, Response, UploadFile +from fastapi.responses import JSONResponse + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import export_drawing +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import import_frame_file +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + clear_company_template, + company_template_path, + frame_document, + frame_template_document, + save_company_template, + use_company_templates, + validate_template_entities, +) +from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields +from B07_DesignDetail.B07_DesignDetail_Schema import ( + DrawingExportRequest, + FrameTemplateImportResponse, + FrameTemplateResponse, + FrameTemplateSaveRequest, + FrameTemplateSaveResponse, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) + + +async def _company_dir(project_id: UUID) -> Path: + """프로젝트 저장 경로에서 회사 폴더를 얻는다 — `storage/{회사}/{사용자}/{프로젝트}`.""" + pool = get_db_pool() + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + root = Path(resolve_stored_project_path(stored_path)).resolve() + return root.parent.parent + + +@router.get("/{project_id}/frame-template", response_model=FrameTemplateResponse) +async def get_frame_template(project_id: UUID) -> FrameTemplateResponse | JSONResponse: + """도각 편집 화면이 실을 도각 한 장. 회사 도각이 있으면 그것, 없으면 프로그램 기본.""" + try: + company_dir = await _company_dir(project_id) + use_company_templates(company_dir) + # 편집 화면이 자리표에 실제 값을 보여 줄 수 있게 함께 넘긴다 — 도면마다 달라지는 + # 도면명·도면번호는 여기 없다(그 자리는 자리표 이름 그대로 보인다). + fields = await title_block_fields(project_id) + return FrameTemplateResponse( + project_id=str(project_id), + drawing=frame_template_document(), + customized=company_template_path(company_dir).is_file(), + fields=fields, + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각을 읽지 못했습니다."}, + ) + + +# 도각 파일 상한 — A1 도각 한 장은 보통 1MB 아래다. 큰 도면 전체를 올리는 실수를 막는다. +_FRAME_IMPORT_MAX_BYTES = 20 * 1024 * 1024 + + +@router.post("/{project_id}/drawing-export") +async def export_drawing_file(project_id: UUID, request: DrawingExportRequest) -> Response: + """캐드 화면의 도면을 DXF·DWG 파일로 내려보낸다 (2026-09-06 사용자 지시). + + DWG 는 LibreDWG 가 서버에 있을 때만 나간다 — 없으면 「DXF 로 받으라」는 안내로 떨어진다. + """ + try: + data, skipped = await asyncio.to_thread( + export_drawing, request.drawing, request.file_format + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도면 내보내기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도면을 내보내지 못했습니다."}, + ) + name = re.sub(r"[^0-9A-Za-z가-힣_.-]", "_", request.name or "drawing")[:120] or "drawing" + # 한글 파일 이름은 헤더에 그대로 못 싣는다(latin-1) — 옛 브라우저용 영문 이름과 + # UTF-8 이름을 함께 준다. + ascii_name = re.sub(r"[^0-9A-Za-z_.-]", "_", name) or "drawing" + encoded_name = quote(f"{name}.{request.file_format}") + return Response( + content=data, + media_type="application/octet-stream", + headers={ + "Content-Disposition": ( + f'attachment; filename="{ascii_name}.{request.file_format}"; ' + f"filename*=UTF-8''{encoded_name}" + ), + # 그림처럼 못 담은 도형 수 — 화면이 안내 문구를 띄우는 데 쓴다. + "X-Aislo-Skipped": str(sum(skipped.values())), + }, + ) + + +@router.post("/{project_id}/frame-template/import", response_model=FrameTemplateImportResponse) +async def import_frame_template( + project_id: UUID, file: UploadFile = File(...) +) -> FrameTemplateImportResponse | JSONResponse: + """외부 도각 파일(DXF·DWG)을 읽어 **편집 화면에 실을 도면**으로 돌려준다. + + 아직 저장하지 않는다 — 사용자가 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + """ + try: + data = await file.read() + if len(data) > _FRAME_IMPORT_MAX_BYTES: + raise ValueError("도각 파일이 너무 큽니다(20MB 넘음).") + entities = await asyncio.to_thread(import_frame_file, file.filename or "", data) + validate_template_entities(entities) + return FrameTemplateImportResponse( + project_id=str(project_id), + drawing=frame_document(entities), + entity_count=len(entities), + ) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 불러오기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각 파일을 읽지 못했습니다."}, + ) + + +@router.put("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) +async def put_frame_template( + project_id: UUID, request: FrameTemplateSaveRequest +) -> FrameTemplateSaveResponse | JSONResponse: + """편집한 도각을 회사 도각으로 저장한다. 프로그램 기본 도각은 그대로 둔다. + + 이미 확정한 도면은 저장본을 그대로 쓰므로 옛 도각을 유지한다 — 확정을 풀면 + 다음에 열 때 새 도각으로 다시 그려진다(2026-09-01 사용자 확정). + """ + try: + entities = request.drawing.get("entities") + if not isinstance(entities, list): + raise ValueError("도각 엔티티가 없습니다.") + company_dir = await _company_dir(project_id) + await asyncio.to_thread(save_company_template, company_dir, entities) + return FrameTemplateSaveResponse(project_id=str(project_id)) + except (FileNotFoundError, ValueError) as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 저장 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "도각을 저장하지 못했습니다."}, + ) + + +@router.delete("/{project_id}/frame-template", response_model=FrameTemplateSaveResponse) +async def delete_frame_template(project_id: UUID) -> FrameTemplateSaveResponse | JSONResponse: + """회사 도각을 지워 **프로그램 기본 도각으로 되돌린다** (2026-09-01 신설). + + 되돌릴 길이 없으면 회사 도각을 한 번 잘못 저장한 것만으로 도면이 열리지 않는다. + 확정한 도면은 저장본을 쓰므로 그대로고, 확정하지 않은 도면부터 기본 도각으로 나온다. + """ + try: + company_dir = await _company_dir(project_id) + removed = await asyncio.to_thread(clear_company_template, company_dir) + return FrameTemplateSaveResponse(project_id=str(project_id), customized=not removed) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B07 도각 되돌리기 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "기본 도각으로 되돌리지 못했습니다."}, + ) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index fed5bbbd..ef7bfcc3 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -9,7 +9,7 @@ import re from pathlib import Path from typing import Any -from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( DRAWING_FORMAT, build_cross_drawing, @@ -345,6 +345,7 @@ def _cross_design_line( design_elevation_from_longitudinal(longitudinal, float(source.get("chainage_m", 0.0))), ground_type="soil", section_mode="left_cut", + **curve_widening_args(source), ) return design["design_line"] except (ValueError, KeyError, OSError, json.JSONDecodeError): @@ -677,6 +678,7 @@ def _recompute_confirmed_design( ground_type=designation["ground_type"], section_mode=designation["section_mode"], ditch_side=designation.get("ditch_side"), + **curve_widening_args(source), ) design["status"] = "confirmed" return design diff --git a/B07_DesignDetail/B07_DesignDetail_Schema.py b/B07_DesignDetail/B07_DesignDetail_Schema.py index 53abb805..c129d9fc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Schema.py +++ b/B07_DesignDetail/B07_DesignDetail_Schema.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field class DesignDrawingItem(BaseModel): @@ -102,6 +102,27 @@ class FrameTemplateResponse(BaseModel): drawing: dict[str, Any] # 회사가 고친 도각을 쓰고 있으면 True, 프로그램 기본 도각이면 False. customized: bool = False + # 자리표에 **보여 줄** 실제 값 (2026-09-06 사용자 지시) — 편집 화면 전용이고 + # 저장값은 `{{키}}` 토큰 그대로다. 값이 없는 자리는 담기지 않는다. + fields: dict[str, str] = {} + + +class FrameTemplateImportResponse(BaseModel): + """외부 도각 파일(DXF·DWG)을 읽어 편집 화면에 실을 도면으로 바꾼 결과.""" + + status: str = "success" + project_id: str + drawing: dict[str, Any] + entity_count: int + + +class DrawingExportRequest(BaseModel): + """캐드 화면의 도면을 DXF·DWG 파일로 내보내는 요청 (2026-09-06 사용자 지시).""" + + drawing: dict[str, Any] + file_format: str = Field(default="dxf", pattern="^(dxf|dwg)$") + # 내려받을 파일 이름(확장자 제외). 비우면 도면 id 를 쓴다. + name: str | None = Field(default=None, max_length=120) class FrameTemplateSaveRequest(BaseModel): diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index fa0cef2d..6fcaff42 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -14,40 +14,19 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { + computeStoredLayouts as computeLayouts, + type StoredLayouts, +} from "../B06_Section/B06_Section_Structure_Layouts"; import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box"; -import { - computeBoxLayout, - DEFAULT_BOX_SIDE_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Box_Geom"; import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert"; -import { - DEFAULT_BASIN_ADJUST, - ZERO_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; -import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; -import { - computeCardCulvert, - culvertLinkFor, -} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; -import type { - ExtraWallControl, - InletStructureControl, - RevetOffsetControl, -} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford"; -import { - computeFordLayout, - DEFAULT_FORD_WALL_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Ford_Geom"; import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import { appendCrossDesignOverlay, appendPavementOverlay, } from "../B06_Section/B06_Section_UI_Cross_Design"; -import { - appendRevetmentOverlay, - computeRevetmentLayout, -} from "../B06_Section/B06_Section_UI_Cross_Revetment"; +import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment"; /** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ export interface CrossPlacement { @@ -440,77 +419,7 @@ function entityBox(entity: Record): number[] | null { return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null; } -// --------------------------------------------------------------------------- -// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다. -// --------------------------------------------------------------------------- -function storedWallAdjust(section: CrossSection, role: string): WallAdjust { - const stored = section.design?.revet_adjust?.[role]; - return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; -} - -const revetOffset: RevetOffsetControl = { - adjustFor: (section, role) => storedWallAdjust(section, role), - storedAdjustFor: (section, role) => - section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, - selectedFor: () => null, - highlightFor: () => null, - select: () => undefined, - syncApplied: () => undefined, - update: () => undefined, - reset: () => undefined, -}; - -const extraWalls: ExtraWallControl = { - countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, - setCount: () => undefined, - equalize: () => undefined, - consumeEqualize: () => false, - syncCount: () => undefined, -}; - -const inletStructure: InletStructureControl = { - valueFor: (section) => section.design?.inlet_structure ?? "auto", - adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), - set: () => undefined, - updateAdjust: () => undefined, - resetAdjust: () => undefined, -}; - -/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ -function computeLayouts(section: CrossSection, sections: CrossSection[]) { - const design = section.design; - if (!design) return null; - const designZAt = (chainageM: number): number | null => { - const found = sections.find( - (item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M, - ); - return found?.design?.design_elevation_m ?? null; - }; - const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt); - const culvert = computeCardCulvert( - section, - section.samples, - null, - revetOffset, - inletStructure, - extraWalls, - link, - ); - const box = computeBoxLayout(section, section.samples, { - left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) }, - right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) }, - }); - const ford = computeFordLayout(section, section.samples, { - inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) }, - outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) }, - }); - // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. - const own = - !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; - return { design, link, culvert, box, ford, own }; -} - -type Layouts = NonNullable>; +type Layouts = StoredLayouts; /** * 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts index 7a195985..a27b4cb1 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_FrameEdit.ts @@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { type CadDrawing, fetchFrameTemplate, + importFrameTemplate, resetFrameTemplate, saveFrameTemplate, } from "./B07_DesignDetail_Api_Fetch"; @@ -27,18 +28,28 @@ export interface FrameTemplateEditor { interface Options { projectId: string; - /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). */ - sendLoad: (drawing: CadDrawing, meta: null) => void; + /** CAD에 도면을 싣는다 (meta null이면 수량 패널을 숨긴다). + * frameEdit 을 켜면 캐드 안 자리표 패널이 함께 뜬다. */ + sendLoad: ( + drawing: CadDrawing, + meta: null, + frameEdit?: boolean, + frameFields?: Record, + ) => void; /** CAD에서 현재 편집본을 받아온다. */ requestCadDrawing: () => Promise; /** 편집을 마친 뒤 보던 도면으로 돌아간다. */ restoreDrawing: () => void; /** 도각이 바뀌었으니 받아 둔 도면 캐시를 버린다 — 안 버리면 옛 도각이 그대로 보인다. */ onSaved: () => void; + /** 지금 보던 도면의 이름·번호 — 자리표 미리보기에 도면명·도면번호로 보여 준다. */ + currentDrawingInfo: () => { label: string; number: string } | null; } export function createFrameTemplateEditor(options: Options): FrameTemplateEditor { let editing = false; + // 자리표에 보여 줄 실제 값 — 도각을 열 때 서버에서 받아 캐드에 함께 넘긴다. + let frameFields: Record = {}; const banner = document.createElement("div"); banner.className = "b07-frame-edit"; @@ -61,6 +72,23 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor variant: "ghost", onClick: () => void resetToDefault(), }); + /** + * 회사가 쓰던 도각을 파일로 들인다 (2026-09-06 사용자 지시). DWG 는 서버에 변환기가 + * 있을 때만 읽고, 없으면 「DXF 로 저장해 달라」는 안내가 뜬다. 불러온 도각은 아직 + * 저장되지 않는다 — 자리표를 놓고 [완료]를 눌러야 회사 도각이 된다. + */ + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".dxf,.dwg"; + fileInput.hidden = true; + fileInput.addEventListener("change", () => void importFile()); + + const importButton = createButton({ + label: "파일 불러오기", + variant: "ghost", + onClick: () => fileInput.click(), + }); + const cancelButton = createButton({ label: "취소", variant: "ghost", @@ -68,8 +96,8 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor }); const bannerButtons = document.createElement("div"); bannerButtons.className = "b07-frame-edit__buttons"; - bannerButtons.append(finishButton, resetButton, cancelButton); - banner.append(bannerButtons); + bannerButtons.append(finishButton, importButton, resetButton, cancelButton); + banner.append(bannerButtons, fileInput); const button = createButton({ label: "도각 편집", @@ -84,16 +112,42 @@ export function createFrameTemplateEditor(options: Options): FrameTemplateEditor options.restoreDrawing(); }; + async function importFile(): Promise { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + importButton.disabled = true; + try { + const response = await importFrameTemplate(options.projectId, file); + options.sendLoad(response.drawing, null, true, frameFields); + label.textContent = `${file.name} 을(를) 불러왔습니다 — 자리표를 놓고 [완료]를 누르십시오.`; + showToast(`도형 ${response.entity_count}개를 불러왔습니다.`, "success"); + } catch (error) { + showToast( + error instanceof Error ? error.message : "도각 파일을 불러오지 못했습니다.", + "error", + ); + } finally { + importButton.disabled = false; + } + } + async function enter(): Promise { try { const response = await fetchFrameTemplate(options.projectId); + // 도면명·도면번호는 도면마다 달라 서버가 담지 않는다 — 보던 도면 값을 견본으로 얹는다. + const info = options.currentDrawingInfo(); + frameFields = { + ...(response.fields ?? {}), + ...(info ? { 도면명: info.label, 도면번호: info.number } : {}), + }; editing = true; button.disabled = true; banner.hidden = false; label.textContent = response.customized ? "도각 편집 중 — 회사 도각을 고치고 있습니다." : "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다."; - options.sendLoad(response.drawing, null); + options.sendLoad(response.drawing, null, true, frameFields); } catch (error) { showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error"); } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 1615d984..3cb61056 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -35,6 +35,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmDesignDrawing, + exportDrawing, fetchDesignDrawing, fetchDesignDrawingList, invalidateDesignDrawing, @@ -82,6 +83,7 @@ const CAD_CHANGED_MESSAGE = "aislo:b08:drawing-changed"; const CAD_SAVE_REQUEST_MESSAGE = "aislo:b08:save-request"; const CAD_SAVE_RESPONSE_MESSAGE = "aislo:b08:save-response"; const CAD_NAVIGATE_MESSAGE = "aislo:b08:navigate"; +const CAD_EXPORT_MESSAGE = "aislo:b08:export-file"; /** CAD 앱 알림 — 프로젝트 공용 토스트로 띄운다(2026-08-30 사용자 지시). * CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */ const CAD_TOAST_MESSAGE = "aislo:b08:toast"; @@ -124,7 +126,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { cadHost.append(frame, license); let cadReady = false; - let pendingLoad: { drawing: CadDrawing; meta: DesignMeta | null } | undefined; + let pendingLoad: + | { + drawing: CadDrawing; + meta: DesignMeta | null; + frameEdit: boolean; + frameFields: Record; + } + | undefined; let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let currentConfirmed = false; @@ -205,11 +214,18 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { hasNext: index < drawings.length - 1, }); - const sendLoad = (drawing: CadDrawing, meta: DesignMeta | null) => { - pendingLoad = { drawing, meta }; + // frameEdit: 도각 편집으로 싣는 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다 + // (2026-09-06 사용자 지시로 패널을 캐드 안으로 옮김). + const sendLoad = ( + drawing: CadDrawing, + meta: DesignMeta | null, + frameEdit = false, + frameFields: Record = {}, + ) => { + pendingLoad = { drawing, meta, frameEdit, frameFields }; if (!cadReady) return; frame.contentWindow?.postMessage( - { type: CAD_LOAD_MESSAGE, drawing, meta }, + { type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields }, window.location.origin, ); pendingLoad = undefined; @@ -401,6 +417,34 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } } + /** + * 지금 보고 있는 도면을 DXF·DWG 파일로 내려받는다 (2026-09-06 사용자 지시). + * 파일 만들기는 서버가 한다 — 캐드는 도면만 넘긴다. + */ + async function exportDrawingFile(fileFormat: "dxf" | "dwg"): Promise { + showLoadingOverlay(); + try { + const { drawing } = await requestCadDrawing(); + const name = currentDrawing?.label ?? "도면"; + const result = await exportDrawing(projectId as string, drawing, fileFormat, name); + const link = document.createElement("a"); + link.href = URL.createObjectURL(result.blob); + link.download = `${name}.${fileFormat}`; + link.click(); + URL.revokeObjectURL(link.href); + showToast( + result.skipped > 0 + ? `${fileFormat.toUpperCase()} 로 내보냈습니다. 그림 ${result.skipped}개는 담기지 않았습니다.` + : `${fileFormat.toUpperCase()} 로 내보냈습니다.`, + "success", + ); + } catch (error) { + showToast(error instanceof Error ? error.message : "도면을 내보내지 못했습니다.", "error"); + } finally { + hideLoadingOverlay(); + } + } + const frameEditor = createFrameTemplateEditor({ projectId: projectId as string, sendLoad, @@ -409,6 +453,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (currentDrawing) void loadDrawing(currentDrawing, currentIndex); }, onSaved: () => drawingCache.clear(), + currentDrawingInfo: () => + currentDrawing ? { label: currentDrawing.label, number: String(currentIndex + 1) } : null, }); window.addEventListener("message", (event: MessageEvent) => { @@ -419,6 +465,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { drawing?: CadDrawing; quantityTable?: QuantityTable | null; direction?: "prev" | "next"; + fileFormat?: "dxf" | "dwg"; dirty?: boolean; kind?: string; text?: string; @@ -446,7 +493,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); } else if (message.type === CAD_READY_MESSAGE) { cadReady = true; - if (pendingLoad) sendLoad(pendingLoad.drawing, pendingLoad.meta); + if (pendingLoad) + sendLoad( + pendingLoad.drawing, + pendingLoad.meta, + pendingLoad.frameEdit, + pendingLoad.frameFields, + ); } else if (message.type === CAD_LOADED_MESSAGE) { cadHost.dataset.loading = "false"; } else if (message.type === CAD_ERROR_MESSAGE) { @@ -460,6 +513,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (!frameEditor.isEditing()) cadDirty = message.dirty !== false; } else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) { navigateDrawing(message.direction); + } else if (message.type === CAD_EXPORT_MESSAGE && message.fileFormat) { + void exportDrawingFile(message.fileFormat); } else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) { const resolve = resolveSave; resolveSave = undefined; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 8de0f675..05a5d1fd 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -8,10 +8,7 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import type { - CrossDesignInfo, - DesignDrawingItem, -} from "./B07_DesignDetail_Api_Fetch"; +import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -69,10 +66,7 @@ export function buildDrawingSidePanel( return panel; } - const drawingButton = ( - drawing: DesignDrawingItem, - label: string, - ): HTMLButtonElement => { + const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = "b07-drawing-button"; @@ -91,9 +85,7 @@ export function buildDrawingSidePanel( ? drawings.filter((item) => item.kind === group.kind) : group.idPrefix ? drawings.filter( - (item) => - item.id === group.idPrefix || - item.id.startsWith(`${group.idPrefix}_`), + (item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`), ) : drawings.filter((item) => item.id === group.blankId); // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. @@ -117,8 +109,7 @@ export function buildDrawingSidePanel( const button = drawingButton(drawing, group.label); // 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게. button.dataset.pending = String(drawing.kind === "blank"); - if (drawing.kind === "blank") - button.title = "준비 중 — 도각만 표시합니다"; + if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다"; panel.append(button); continue; } @@ -140,10 +131,7 @@ export function buildDrawingSidePanel( return panel; } -const GROUND_TYPE_LABEL: Record< - CrossDesignInfo["ground_type"], - keyof typeof ui_locales -> = { +const GROUND_TYPE_LABEL: Record = { soil: "B06_Design_Ground_Soil", ripping_rock: "B06_Design_Ground_Ripping", blasting_rock: "B06_Design_Ground_Blasting", @@ -165,8 +153,7 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean { /** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */ function ditchLabel(design: CrossDesignInfo): string { const ditch = design.ditch; - if (!ditch || ditch.type === "none" || design.ditch_enabled === false) - return "없음"; + if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음"; if (ditch.type === "l_type") return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`; @@ -202,23 +189,19 @@ export function buildDesignInfoPanel( const heading = document.createElement("div"); heading.className = "b07-info__heading"; const stationName = document.createElement("strong"); - const scopeLabel = - scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); + const scopeLabel = scope === "sheet" ? L("B07_Info_Sheet") : L("B07_Info_Station"); stationName.textContent = `${scopeLabel} ${title}`; const confirmed = design?.status === "confirmed"; const badge = document.createElement("span"); badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`; - badge.textContent = confirmed - ? L("B07_Info_Confirmed") - : L("B07_Info_Provisional"); + badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional"); heading.append(stationName, badge); panel.append(heading); if (!design) { const empty = document.createElement("p"); empty.className = "b07-info__empty"; - empty.textContent = - scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); + empty.textContent = scope === "sheet" ? L("B07_Info_SheetHint") : L("B07_Info_NoDesign"); panel.append(empty); return panel; } @@ -233,9 +216,7 @@ export function buildDesignInfoPanel( infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)), infoRow( L("B07_Info_DitchSide"), - design.ditch_side === "left" - ? L("B06_Design_Ditch_Left") - : L("B06_Design_Ditch_Right"), + design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"), ), ); @@ -245,10 +226,7 @@ export function buildDesignInfoPanel( planTitle.textContent = L("B07_Info_Plan_Title"); plan.append( planTitle, - infoRow( - L("B07_Info_DesignElevation"), - `${design.design_elevation_m.toFixed(2)}m`, - ), + infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`), infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`), infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`), infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`), diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index f69191bf..9d3fb9cb 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -942,3 +942,77 @@ body > canvas[data-id="canvas"] { color: var(--cad-text-dim); font-size: 11px; } + +/* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로 + 부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */ +.cad-frame-tokens { + position: fixed; + /* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */ + right: 12px; + bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px); + z-index: 3; + max-height: 46vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + width: 236px; + padding: 8px; + border: 1px solid var(--cad-line); + border-radius: 6px; + background: var(--cad-chrome-raised); + box-shadow: var(--shadow-lg); + color: var(--cad-text); +} + +.cad-frame-tokens__header { + color: var(--cad-text-dim); + font-size: 11px; + line-height: 1.4; +} + +.cad-frame-tokens__group { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.cad-frame-tokens__title { + width: 100%; + color: var(--cad-text-dim); + font-size: 11px; +} + +.cad-frame-tokens__button { + padding: 3px 7px; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome); + color: var(--cad-text); + font-size: 11px; + cursor: pointer; +} + +.cad-frame-tokens__button:hover { + background: var(--cad-accent-soft, var(--cad-chrome-raised)); +} + +/* 자리표 칸 크기 입력 (2026-09-06) */ +.cad-frame-tokens__size { + display: flex; + align-items: center; + gap: 4px; + color: var(--cad-text-dim); + font-size: 11px; +} + +.cad-frame-tokens__size input { + width: 64px; + padding: 2px 4px; + border: 1px solid var(--cad-line); + border-radius: 4px; + background: var(--cad-chrome); + color: var(--cad-text); + font-size: 11px; +} diff --git a/B07_DesignDetail/openwebcad/src/App.tsx b/B07_DesignDetail/openwebcad/src/App.tsx index d5f6c178..93ce68b1 100644 --- a/B07_DesignDetail/openwebcad/src/App.tsx +++ b/B07_DesignDetail/openwebcad/src/App.tsx @@ -1,5 +1,6 @@ import './App.css'; import { ToastContainer } from 'react-toastify'; +import { FramePlaceholderPanel } from './components/FramePlaceholderPanel.tsx'; import { QuantityPanel } from './components/QuantityPanel.tsx'; import { Toolbar } from './components/Toolbar.tsx'; @@ -8,6 +9,7 @@ function App() {
+
); diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index a1bc7986..c401ee26 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -3,6 +3,7 @@ import { toast } from 'react-toastify'; import { clearRecovery, restoreRecovery } from '../helpers/autosave'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; +import { requestDrawingExport } from '../integration/aislo-drawing-bridge'; import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png'; import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg'; import { redo, undo } from '../state'; @@ -44,6 +45,26 @@ export const FILE_COMMANDS: CadCommand[] = [ return 'JSON 내보내기'; }, }, + { + id: 'EXPORTDXF', + label: 'DXF 내보내기', + glyph: '📐', + hint: '지금 도면을 DXF 파일로 내려받는다', + run: () => { + requestDrawingExport('dxf'); + return 'DXF 내보내기'; + }, + }, + { + id: 'EXPORTDWG', + label: 'DWG 내보내기', + glyph: '📁', + hint: '지금 도면을 DWG 파일로 내려받는다 (서버에 변환기가 있을 때)', + run: () => { + requestDrawingExport('dwg'); + return 'DWG 내보내기'; + }, + }, { id: 'EXPORTSVG', label: 'SVG 내보내기', diff --git a/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx b/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx new file mode 100644 index 00000000..c98ff97c --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx @@ -0,0 +1,195 @@ +import { Point } from '@flatten-js/core'; +import { type FC, useCallback, useEffect, useState } from 'react'; +import { HtmlEvent } from '../App.types'; +import { ImageEntity } from '../entities/ImageEntity'; +import { TextEntity } from '../entities/TextEntity'; +import { + getActiveLayerId, + getEntities, + getFrameFields, + getScreenCanvasDrawController, + getSelectedEntities, + isFrameEditMode, + setEntities, +} from '../state'; + +/** + * 도각 자리표 패널 — 프로그램 값이 들어갈 자리를 사용자가 직접 놓는다 (2026-09-06 사용자 확정). + * + * 값을 알아맞히는 규칙은 만들지 않는다. 여기서 놓은 `{{키}}` 토큰을 도면 출력 때 서버의 + * 치환 엔진이 채운다. 자리표를 안 놓은 값은 빈칸으로 남는다. + * + * 도각 편집으로 도면을 실었을 때만 뜬다. 놓은 자리표는 화면 한가운데에 서고, 그 뒤 + * 캐드의 이동·크기 도구로 자리를 잡는다. + */ + +/** 글자 자리표 — 출력 때 표제란 값으로 바뀐다. */ +const TEXT_TOKENS = [ + '도면명', + '도면번호', + '공사명', + '위치', + '시행청', + '용역회사', + '연도기번', + '사업량', + '과업책임자', + '분야별책임자', + '설계자', + '설계일자', + '축척_A1', + '축척_A3', +] as const; + +/** 그림 자리표 — 회사 로고와 사람 서명. 값이 없으면 도면에서 그림째 빠진다. */ +const IMAGE_TOKENS = ['회사로고', '과업책임자서명', '분야별책임자서명', '설계자서명'] as const; + +const TEXT_SIZE_MM = 5; +// 자리표가 차지하는 칸 기본 크기(mm). 놓은 뒤 아래 「칸 크기」에서 고친다. +const TEXT_BOX_WIDTH_MM = 60; +const TEXT_BOX_HEIGHT_MM = 10; +const IMAGE_WIDTH_MM = 32; +const IMAGE_HEIGHT_MM = 16; + +/** 지금 보고 있는 화면의 한가운데 (도면 좌표). 자리표가 처음 서는 자리다. */ +function viewCenter(): Point { + const drawController = getScreenCanvasDrawController(); + const size = drawController.getCanvasSize(); + return drawController.targetToWorld(new Point(size.x / 2, size.y / 2)); +} + +function addTextPlaceholder(token: string): void { + const center = viewCenter(); + const entity = new TextEntity(getActiveLayerId(), `{{${token}}}`, center, { + fontSize: TEXT_SIZE_MM, + textAlign: 'center', + boxWidth: TEXT_BOX_WIDTH_MM, + boxHeight: TEXT_BOX_HEIGHT_MM, + }); + // 편집 중에는 실제 값을 보여 준다 — 저장값은 토큰 그대로다. + entity.previewLabel = getFrameFields()[token] ?? null; + setEntities([...getEntities(), entity], true); +} + +async function addImagePlaceholder(token: string): Promise { + const center = viewCenter(); + const halfWidth = IMAGE_WIDTH_MM / 2; + const halfHeight = IMAGE_HEIGHT_MM / 2; + const points = [ + { x: center.x - halfWidth, y: center.y - halfHeight }, + { x: center.x + halfWidth, y: center.y - halfHeight }, + { x: center.x + halfWidth, y: center.y + halfHeight }, + { x: center.x - halfWidth, y: center.y + halfHeight }, + ]; + // 자리표는 그림 주소가 아니라 토큰이라 fromJson 으로 만든다 — 그래야 원본 문자열이 + // 그대로 보존돼 저장 한 번에 주소로 굳지 않는다. + const entity = await ImageEntity.fromJson({ + id: crypto.randomUUID(), + type: 'Image', + lineColor: '#f5f7fa', + lineWidth: 1, + layerId: getActiveLayerId(), + shapeData: { points, imageData: `{{${token}}}` }, + } as Parameters[0]); + const preview = getFrameFields()[token]; + if (preview) entity.setPreviewImage(preview); + setEntities([...getEntities(), entity], true); +} + +/** 지금 고른 자리표 하나 — 칸 크기를 고칠 대상. 없으면 null. */ +function selectedPlaceholder(): TextEntity | ImageEntity | null { + const selected = getSelectedEntities(); + if (selected.length !== 1) return null; + const entity = selected[0]; + if (entity instanceof TextEntity && entity.getLabel().includes('{{')) return entity; + if (entity instanceof ImageEntity && entity.isPlaceholder()) return entity; + return null; +} + +function boxSizeOf(entity: TextEntity | ImageEntity): { width: number; height: number } { + const box = entity.getBoundingBox(); + return { width: Math.round(box.width * 10) / 10, height: Math.round(box.height * 10) / 10 }; +} + +export const FramePlaceholderPanel: FC = () => { + const [visible, setVisible] = useState(isFrameEditMode()); + const [picked, setPicked] = useState(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const refresh = useCallback(() => { + setVisible(isFrameEditMode()); + const entity = selectedPlaceholder(); + setPicked(entity); + if (entity) setSize(boxSizeOf(entity)); + }, []); + useEffect(() => { + window.addEventListener(HtmlEvent.UPDATE_STATE, refresh); + return () => window.removeEventListener(HtmlEvent.UPDATE_STATE, refresh); + }, [refresh]); + + const applySize = (width: number, height: number): void => { + if (!picked) return; + setSize({ width, height }); + picked.setBoxSize(width, height); + setEntities([...getEntities()], true); + }; + + if (!visible) return null; + + return ( +
+
+ 자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다 +
+
+ 글자 + {TEXT_TOKENS.map((token) => ( + + ))} +
+
+ 그림 (칸에 비율 그대로 들어감) + {IMAGE_TOKENS.map((token) => ( + + ))} +
+ {picked && ( +
+ 고른 자리표 칸 크기 (mm) + + +
+ )} +
+ ); +}; diff --git a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts index cca181dd..7606d0ef 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ImageEntity.ts @@ -1,7 +1,7 @@ import type * as Flatten from '@flatten-js/core'; import { type Box, Point, Polygon, Relations, type Segment, Vector } from '@flatten-js/core'; import { type Shape, type SnapPoint, SnapPointType } from '../App.types'; -import type { DrawController } from '../drawControllers/DrawController.ts'; +import { DEFAULT_TEXT_OPTIONS, type DrawController } from '../drawControllers/DrawController.ts'; import { twoPointBoxToPolygon } from '../helpers/box-to-polygon'; import { getExportColor } from '../helpers/get-export-color'; import { mirrorAngleOverAxis } from '../helpers/mirror-angle-over-axis.ts'; @@ -33,6 +33,22 @@ export class ImageEntity implements Entity { * 그렇게 손상됐다). 원본을 들고 있다가 그대로 돌려준다. */ private sourceData: string | null = null; + /** 저장값(그림 주소 또는 자리표 토큰). */ + public getSourceData(): string | null { + return this.sourceData; + } + + /** 자리표인가 — `{{회사로고}}` 처럼 토큰을 들고 있는 그림. */ + public isPlaceholder(): boolean { + return (this.sourceData ?? '').includes('{{'); + } + + /** 도각 편집에서만 쓰는 보여 주기용 그림. 저장값(sourceData)은 토큰 그대로 둔다. */ + public setPreviewImage(dataUrl: string): void { + const image = new Image(); + image.src = dataUrl; + this.imageElement = image; + } constructor( layerId: string, @@ -69,28 +85,69 @@ export class ImageEntity implements Entity { this.lineWidth, this.lineDash ); - // 테두리는 **집었을 때만** 그린다. 늘 그리면 도각의 로고·서명 자리에 흰 사각형이 - // 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02). - if (highlighted || selected) { + // 자리표(`{{회사로고}}` 등)는 그림이 없어 화면에 아무것도 안 보였다 — 도각 편집에서 + // 무엇을 어디에 놓았는지 알 수 없어, 자리표일 때는 테두리와 이름을 늘 그린다 + // (2026-09-06). 출력 때는 서버가 값으로 바꾸거나 엔티티째 빼므로 산출물에 안 실린다. + const placeholder = this.isPlaceholder() && !this.imageElement.src; + // 그 밖의 그림은 **집었을 때만** 테두리를 그린다. 늘 그리면 도각의 로고 자리에 흰 + // 사각형이 남고, 출력·내보내기가 같은 draw()를 타므로 산출물에도 실린다(2026-09-02). + if (highlighted || selected || placeholder) { for (const edge of polygonToSegments(this.polygon)) { drawController.drawLine(edge.start, edge.end); } } - const width = this.polygon.box.width; - const height = this.polygon.box.height; + if (placeholder) { + // 아직 보여 줄 그림이 없으면 이름표만 남긴다. + drawController.drawText(this.sourceData ?? '', this.polygon.box.center, { + ...DEFAULT_TEXT_OPTIONS, + textAlign: 'center', + fontSize: Math.max(this.polygon.box.height / 4, 2), + textColor: this.lineColor, + }); + return; // 그림이 없으니 그릴 것도 없다 + } + + // 칸 안에 **비율을 지켜** 넣는다 (2026-09-06 사용자 지시) — 칸을 늘렸다고 그림이 + // 늘어나면 로고·서명이 찌그러진다. 남는 자리는 비운다(가운데 맞춤). + const boxWidth = this.polygon.box.width; + const boxHeight = this.polygon.box.height; + const naturalWidth = this.imageElement.naturalWidth || boxWidth; + const naturalHeight = this.imageElement.naturalHeight || boxHeight; + const fit = Math.min(boxWidth / naturalWidth, boxHeight / naturalHeight); + const width = naturalWidth * fit; + const height = naturalHeight * fit; // Draw image drawController.drawImage( this.imageElement, - this.polygon.box.xmin, - this.polygon.box.ymin, + this.polygon.box.xmin + (boxWidth - width) / 2, + this.polygon.box.ymin + (boxHeight - height) / 2, width, height, this.angle ); } + /** 자리표 칸 크기(mm)를 바꾼다. 가운데는 그대로 두고 네 귀만 다시 잡는다. */ + public setBoxSize(width: number, height: number): void { + const center = this.polygon.box.center; + const halfWidth = Math.max(width, 1) / 2; + const halfHeight = Math.max(height, 1) / 2; + this.polygon = twoPointBoxToPolygon( + new Point(center.x - halfWidth, center.y - halfHeight), + new Point(center.x + halfWidth, center.y + halfHeight) + ); + } + + /** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */ + public setBoxFromCorners(a: Point, b: Point): void { + this.polygon = twoPointBoxToPolygon( + new Point(Math.min(a.x, b.x), Math.min(a.y, b.y)), + new Point(Math.max(a.x, b.x), Math.max(a.y, b.y)) + ); + } + public move(x: number, y: number) { this.polygon = this.polygon.translate(new Vector(x, y)); } diff --git a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts index 312209a2..6479e156 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TextEntity.ts @@ -8,6 +8,9 @@ import { getActiveLayerId, isEntityHighlighted, isEntitySelected } from '../stat import { type Entity, EntityName, type JsonEntity } from './Entity'; import type { LineEntity } from './LineEntity.ts'; +/** 글자를 집었을 때 두르는 외곽선 색 — 도면 선과 헷갈리지 않게 회색. */ +const TEXT_SELECTION_OUTLINE_COLOR = '#9aa0a6'; + export interface TextOptions { textDirection: Vector; textAlign: 'left' | 'center' | 'right'; @@ -17,6 +20,13 @@ export interface TextOptions { /** 굵게·기울임 (문자 편집기 기본 서식). 밑줄은 캔버스에 없어 넣지 않았다 */ bold?: boolean; italic?: boolean; + /** + * 도각 자리표의 칸 크기(mm). 있으면 `basePoint` 가 **칸의 한가운데**이고 글자는 + * 가로·세로 가운데 맞춤으로 그려진다 (2026-09-06 사용자 지시). 없으면 예전처럼 + * 글자 하나로만 산다. + */ + boxWidth?: number; + boxHeight?: number; } export class TextEntity implements Entity { @@ -29,6 +39,12 @@ export class TextEntity implements Entity { public opacity?: number; /** GROUP으로 묶인 객체가 공유하는 식별자 */ public groupId?: string; + /** + * 도각 편집에서만 쓰는 **보여 주기용 값** (2026-09-06 사용자 지시). 자리표 + * `{{공사명}}` 대신 실제 공사명을 그려 사용자가 어디에 무엇이 들어가는지 알게 한다. + * 저장값(`label`)은 토큰 그대로다 — 값이 아니라 연결을 저장한다. + */ + public previewLabel: string | null = null; private readonly options: TextOptions; constructor( @@ -49,14 +65,26 @@ export class TextEntity implements Entity { parentHighlighted?: boolean, parentSelected?: boolean ): void { - drawController.setLineStyles( - parentHighlighted ?? isEntityHighlighted(this), - parentSelected ?? isEntitySelected(this), - this.lineColor, - this.lineWidth, - this.lineDash - ); - drawController.drawText(this.label, this.basePoint, this.options); + const highlighted = parentHighlighted ?? isEntityHighlighted(this); + const selected = parentSelected ?? isEntitySelected(this); + drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash); + drawController.drawText(this.previewLabel ?? this.label, this.basePoint, this.options); + // 집었을 때만 회색 외곽선을 두른다 (2026-09-06 사용자 지시) — 글자는 선 모양이 + // 바뀌어도 티가 안 나 무엇을 골랐는지 보이지 않았다. 출력·내보내기는 선택 상태가 + // 없어 이 선이 실리지 않는다. + if (highlighted || selected) { + const box = this.getBoundingBox(); + const corners = [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; + drawController.setLineStyles(false, false, TEXT_SELECTION_OUTLINE_COLOR, 1, [4, 4]); + for (let index = 0; index < corners.length; index++) { + drawController.drawLine(corners[index], corners[(index + 1) % corners.length]); + } + } } public move(x: number, y: number) { @@ -82,12 +110,16 @@ export class TextEntity implements Entity { } public clone(): TextEntity { - return new TextEntity( + const copy = new TextEntity( getActiveLayerId(), this.label, this.basePoint.clone(), cloneDeep(this.options) ); + // 보여 주기용 값도 함께 옮긴다 — 안 옮기면 그립을 옮긴 순간 자리표가 다시 + // `{{도면명}}` 으로 보인다(2026-09-06 실측). + copy.previewLabel = this.previewLabel; + return copy; } public intersectsWithBox(box: Box): boolean { @@ -99,6 +131,16 @@ export class TextEntity implements Entity { } public getBoundingBox(): Box { + const { boxWidth, boxHeight } = this.options; + if (boxWidth && boxHeight) { + // 자리표는 칸이 곧 경계다 — basePoint 가 칸 한가운데다. + return new Box( + this.basePoint.x - boxWidth / 2, + this.basePoint.y - boxHeight / 2, + this.basePoint.x + boxWidth / 2, + this.basePoint.y + boxHeight / 2 + ); + } // TODO find better way of determining the text bounding box return new Box( this.basePoint.x, @@ -108,6 +150,23 @@ export class TextEntity implements Entity { ); } + /** 자리표 칸 크기(mm)를 바꾼다. 글자 크기는 그대로 둔다. */ + public setBoxSize(width: number, height: number): void { + this.options.boxWidth = Math.max(width, 1); + this.options.boxHeight = Math.max(height, 1); + } + + /** 마주 보는 두 모서리로 칸을 다시 잡는다 — 마우스로 끌어 크기를 바꿀 때 쓴다. */ + public setBoxFromCorners(a: Point, b: Point): void { + this.setBoxSize(Math.abs(b.x - a.x), Math.abs(b.y - a.y)); + this.basePoint = new Point((a.x + b.x) / 2, (a.y + b.y) / 2); + } + + /** 자리표 칸이 있는가 — 칸이 있으면 basePoint 가 칸 한가운데다. */ + public hasBox(): boolean { + return Boolean(this.options.boxWidth && this.options.boxHeight); + } + public getTextOptions(): TextOptions { return this.options; } @@ -181,6 +240,8 @@ export class TextEntity implements Entity { fontFamily: this.options.fontFamily, bold: this.options.bold, italic: this.options.italic, + boxWidth: this.options.boxWidth, + boxHeight: this.options.boxHeight, }, }, }; @@ -205,6 +266,8 @@ export class TextEntity implements Entity { fontFamily: jsonEntity.shapeData.options.fontFamily, bold: jsonEntity.shapeData.options.bold, italic: jsonEntity.shapeData.options.italic, + boxWidth: jsonEntity.shapeData.options.boxWidth, + boxHeight: jsonEntity.shapeData.options.boxHeight, } ); textEntity.id = jsonEntity.id; @@ -226,5 +289,8 @@ export interface TextJsonData { fontFamily: string; bold?: boolean; italic?: boolean; + /** 도각 자리표 칸 크기(mm) — basePoint 가 칸 한가운데다. */ + boxWidth?: number; + boxHeight?: number; }; } diff --git a/B07_DesignDetail/openwebcad/src/helpers/grips.ts b/B07_DesignDetail/openwebcad/src/helpers/grips.ts index ccfb3dda..3f00c182 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/grips.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/grips.ts @@ -2,11 +2,13 @@ * 그립 — 선택한 객체에 붙는 편집점. 집어서 다음 클릭 위치로 옮긴다. * 형상 필드가 전부 private이라 좌표를 고칠 때는 공개 생성자로 같은 객체를 다시 만들어 * 배열에서 바꿔 끼운다(id는 그대로 둬서 선택·그룹이 유지된다). - * ponytail: 호·해치·이미지·치수는 그립을 만들지 않는다 — 각각 각도·경계·비율·연관 규칙이 - * 따로 있어 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다. + * ponytail: 호·해치·치수는 그립을 만들지 않는다 — 각각 각도·경계·연관 규칙이 따로 있어 + * 점 하나를 옮기는 것으로 정의되지 않는다. 필요해지면 그때 붙인다. + * 그림과 「칸이 있는 글자」는 네 모서리를 끌어 **칸 크기**를 바꾼다 (2026-09-06 사용자 지시). */ import { type Circle, Point, type Polygon, type Segment } from '@flatten-js/core'; import { CircleEntity } from '../entities/CircleEntity'; +import { ImageEntity } from '../entities/ImageEntity'; import type { Entity } from '../entities/Entity'; import { LineEntity } from '../entities/LineEntity'; import { PointEntity } from '../entities/PointEntity'; @@ -124,6 +126,20 @@ export function getGrips(entity: Entity): Grip[] { } return grips; } + // 칸이 있는 글자·그림 — 네 모서리로 칸을 늘이고 줄인다. 가운데 그립은 옮기기. + if ( + (entity instanceof TextEntity && entity.hasBox()) || + entity instanceof ImageEntity + ) { + const box = entity.getBoundingBox(); + return [ + { point: new Point(box.xmin, box.ymin), kind: 'vertex', index: 0 }, + { point: new Point(box.xmax, box.ymin), kind: 'vertex', index: 1 }, + { point: new Point(box.xmax, box.ymax), kind: 'vertex', index: 2 }, + { point: new Point(box.xmin, box.ymax), kind: 'vertex', index: 3 }, + { point: new Point(box.center.x, box.center.y), kind: 'base', index: 0 }, + ]; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const point = entity.getFirstPoint(); return point ? [{ point, kind: 'base', index: 0 }] : []; @@ -188,6 +204,23 @@ export function applyGrip(entity: Entity, grip: Grip, target: Point): Entity | n copy.setRowHeight(grip.index, top - target.y); return copy; } + // 칸이 있는 글자·그림 — 모서리를 끌면 마주 보는 모서리를 붙박아 칸을 다시 잡는다. + if ((entity instanceof TextEntity && entity.hasBox()) || entity instanceof ImageEntity) { + const box = entity.getBoundingBox(); + if (grip.kind === 'base') { + return moveCopy(entity, target.x - box.center.x, target.y - box.center.y); + } + const corners = [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; + const opposite = corners[(grip.index + 2) % corners.length]; + const copy = inherit(entity, entity.clone()) as TextEntity | ImageEntity; + copy.setBoxFromCorners(target, opposite); + return copy; + } if (entity instanceof TextEntity || entity instanceof PointEntity) { const base = entity.getFirstPoint(); if (!base) return null; diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index e5d135b1..9f6cd23c 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -1,5 +1,6 @@ import { Point } from '@flatten-js/core'; import { type DesignMeta, HtmlEvent } from '../App.types.ts'; +import { ImageEntity } from '../entities/ImageEntity.ts'; import { TextEntity } from '../entities/TextEntity.ts'; import type { JsonDrawingFileSerialized } from '../helpers/import-export-handlers/export-entities-to-json.ts'; import { exportEntitiesAndLayersToJsonString } from '../helpers/import-export-handlers/export-entities-to-json.ts'; @@ -9,6 +10,7 @@ import { getCanvas, getDesignMeta, getEntities, + getFrameFields, getLayers, getScreenCanvasDrawController, isDrawingDirty, @@ -17,11 +19,13 @@ import { setActiveLayerId, setDesignMeta, setEntities, + setFrameEditMode, setLayers, } from '../state.ts'; import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; +import { registerBoxResizeDrag } from './box-resize-drag.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -31,11 +35,16 @@ export const AISLO_DRAWING_CHANGED_MESSAGE = 'aislo:b08:drawing-changed'; export const AISLO_DRAWING_SAVE_REQUEST_MESSAGE = 'aislo:b08:save-request'; export const AISLO_DRAWING_SAVE_RESPONSE_MESSAGE = 'aislo:b08:save-response'; export const AISLO_DRAWING_NAVIGATE_MESSAGE = 'aislo:b08:navigate'; +export const AISLO_DRAWING_EXPORT_MESSAGE = 'aislo:b08:export-file'; interface DrawingLoadMessage { type: typeof AISLO_DRAWING_LOAD_MESSAGE; drawing: JsonDrawingFileSerialized; meta?: DesignMeta | null; + /** 도각 편집으로 실은 도면인가 — 캐드 안 자리표 패널을 이때만 띄운다. */ + frameEdit?: boolean; + /** 자리표에 보여 줄 실제 값 (편집 화면 전용 — 저장값은 토큰 그대로). */ + frameFields?: Record; } interface DrawingSaveRequestMessage { @@ -58,6 +67,14 @@ export function requestDrawingNavigation(direction: 'prev' | 'next') { notifyParent(AISLO_DRAWING_NAVIGATE_MESSAGE, { direction }); } +/** + * 지금 도면을 DXF·DWG 파일로 내려받도록 부모에게 요청한다 (2026-09-06 사용자 지시). + * 캐드는 프로젝트를 모르므로 파일 만들기는 부모가 서버에 맡긴다. + */ +export function requestDrawingExport(fileFormat: 'dxf' | 'dwg') { + notifyParent(AISLO_DRAWING_EXPORT_MESSAGE, { fileFormat }); +} + /** * 수량 산출표 도면층 — 여기 글자는 앞 단계(B05·B06) 산출값이라 B07에서 고치지 않는다 * (2026-09-01 사용자 확정). 고치면 그림 글자만 바뀌고 저장되는 수량표는 그대로여서 @@ -113,6 +130,27 @@ function registerTextDoubleClickEdit() { }); } +/** + * 자리표에 실제 값을 입힌다 — 저장값은 그대로 두고 **보여 주기만** 바꾼다 + * (2026-09-06 사용자 지시). 편집 화면에서 어디에 무엇이 들어가는지 보이게 하는 것이 목적. + */ +function applyFramePreview(): void { + const fields = getFrameFields(); + if (Object.keys(fields).length === 0) return; + const token = /^\{\{(.+)\}\}$/; + for (const entity of getEntities()) { + if (entity instanceof TextEntity) { + const match = token.exec(entity.getLabel().trim()); + const value = match ? fields[match[1]] : undefined; + entity.previewLabel = value ?? null; + } else if (entity instanceof ImageEntity && entity.isPlaceholder()) { + const match = token.exec((entity.getSourceData() ?? '').trim()); + const value = match ? fields[match[1]] : undefined; + if (value) entity.setPreviewImage(value); + } + } +} + /** * B08 parent page와 CAD 앱 사이의 same-origin JSON 경계다. * DXF/DWG 파일이나 파서 객체는 이 경계를 통과하지 않는다. @@ -148,6 +186,8 @@ export function registerAisloDrawingBridge() { resetUndoBaseline(); // 설계 컨텍스트(제목·측점정보·확정상태·수량표)를 수량 패널에 반영 setDesignMeta(event.data.meta ?? null); + setFrameEditMode(event.data.frameEdit === true, event.data.frameFields ?? {}); + if (event.data.frameEdit) applyFramePreview(); // 앞 도면에서 켜 둔 그리기 도구를 내린다. 안 내리면 **확정한 도면 위에도** // 그 도구가 계속 그린다 — 읽기 전용은 새 명령만 막기 때문이다(2026-09-01 실측: // 확정본에서 클릭 두 번에 선 2개가 늘었다). 새 도면에서 앞 도면의 작도 도중 @@ -168,6 +208,7 @@ export function registerAisloDrawingBridge() { notifyParent(AISLO_DRAWING_CHANGED_MESSAGE, { dirty: isDrawingDirty() }); }); registerTextDoubleClickEdit(); + registerBoxResizeDrag(); notifyParent(AISLO_DRAWING_READY_MESSAGE); } diff --git a/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts b/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts new file mode 100644 index 00000000..f2700dfa --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/box-resize-drag.ts @@ -0,0 +1,119 @@ +import { Point } from '@flatten-js/core'; +import { HtmlEvent } from '../App.types.ts'; +import { ImageEntity } from '../entities/ImageEntity.ts'; +import { TextEntity } from '../entities/TextEntity.ts'; +import { + getCanvas, + getEntities, + getScreenCanvasDrawController, + getSelectedEntities, + isDrawingReadOnly, + setEntities, +} from '../state.ts'; + +/** + * 칸 모서리를 **끌어서** 크기를 바꾼다 (2026-09-06 사용자 지시). + * + * 대상은 「칸이 있는 글자(도각 자리표)」와 「그림」이다. 캐드 본래의 그립은 집었다 놓는 + * 방식이라 도각 칸을 맞출 때 손이 많이 갔다 — 끌기는 여기서 따로 받는다. + * + * 그리기 도구와 부딪히지 않게 **모서리를 집었을 때만** 이벤트를 가로챈다(그 밖에는 그대로 + * 흘려보낸다). 확정한 도면은 읽기 전용이라 손대지 않는다. + */ + +/** 모서리를 집었다고 볼 화면 거리(px). 그립 크기(8px)보다 조금 넉넉하게 잡는다. */ +const GRAB_PIXELS = 9; + +type Resizable = TextEntity | ImageEntity; + +function resizableSelection(): Resizable | null { + const selected = getSelectedEntities(); + if (selected.length !== 1) return null; + const entity = selected[0]; + if (entity instanceof TextEntity && entity.hasBox()) return entity; + if (entity instanceof ImageEntity) return entity; + return null; +} + +/** 마우스 위치(화면) → 도면 좌표. 캐드는 화면 y 를 아래에서 위로 잰다. */ +function worldAt(event: MouseEvent): Point | null { + const canvas = getCanvas(); + if (!canvas) return null; + const bounds = canvas.getBoundingClientRect(); + const screenPoint = new Point(event.clientX - bounds.left, bounds.bottom - event.clientY); + return getScreenCanvasDrawController().targetToWorld(screenPoint); +} + +function corners(entity: Resizable): Point[] { + const box = entity.getBoundingBox(); + return [ + new Point(box.xmin, box.ymin), + new Point(box.xmax, box.ymin), + new Point(box.xmax, box.ymax), + new Point(box.xmin, box.ymax), + ]; +} + +let dragging: { entity: Resizable; opposite: Point } | null = null; + +export function registerBoxResizeDrag(): void { + const canvas = getCanvas(); + if (!canvas) return; + + // 캡처 단계에서 먼저 받는다 — 모서리를 집은 경우에만 선택 도구로 넘어가지 않게 막는다. + window.addEventListener( + 'mousedown', + (event: MouseEvent) => { + if (event.button !== 0 || event.target !== canvas || isDrawingReadOnly()) return; + const entity = resizableSelection(); + const world = entity ? worldAt(event) : null; + if (!entity || !world) return; + const scale = getScreenCanvasDrawController().getScreenScale(); + const grabDistance = GRAB_PIXELS / scale; + const points = corners(entity); + let index = -1; + let best = grabDistance; + points.forEach((corner, seq) => { + const distance = corner.distanceTo(world)[0]; + if (distance <= best) { + best = distance; + index = seq; + } + }); + if (index < 0) return; + dragging = { entity, opposite: points[(index + 2) % points.length] }; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); + + window.addEventListener( + 'mousemove', + (event: MouseEvent) => { + if (!dragging) return; + const world = worldAt(event); + if (!world) return; + dragging.entity.setBoxFromCorners(world, dragging.opposite); + // 끄는 동안에는 되돌리기 스택에 쌓지 않는다 — 손을 뗄 때 한 번만 쌓는다. + setEntities([...getEntities()], false); + // 자리표 패널의 칸 크기 숫자도 따라 움직이게 알린다. + window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE)); + event.stopImmediatePropagation(); + }, + true + ); + + window.addEventListener( + 'mouseup', + (event: MouseEvent) => { + if (!dragging) return; + dragging = null; + setEntities([...getEntities()], true); + window.dispatchEvent(new Event(HtmlEvent.UPDATE_STATE)); + event.stopImmediatePropagation(); + event.preventDefault(); + }, + true + ); +} diff --git a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts index 487f2955..0ca203cd 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts +++ b/B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts @@ -198,7 +198,7 @@ export const RIBBON_TABS: RibbonTab[] = [ { label: '내보내기', big: ['EXPORT'], - commands: ['EXPORTSVG', 'EXPORTPNG', 'QSAVE'], + commands: ['EXPORTDXF', 'EXPORTDWG', 'EXPORTSVG', 'EXPORTPNG', 'QSAVE'], }, ], }, diff --git a/B07_DesignDetail/openwebcad/src/state.ts b/B07_DesignDetail/openwebcad/src/state.ts index d8b7d70d..1fcf3217 100644 --- a/B07_DesignDetail/openwebcad/src/state.ts +++ b/B07_DesignDetail/openwebcad/src/state.ts @@ -186,6 +186,10 @@ let snapTrackingEnabled = true; * 제목·측점정보·확정상태·수량표를 렌더한다. null이면 패널을 숨긴다. */ let designMeta: DesignMeta | null = null; +/** 도각 편집 모드인가 — 부모(B07 화면)가 도각을 실을 때 켠다. 자리표 패널이 이때만 뜬다. */ +let frameEditMode = false; +/** 자리표에 보여 줄 실제 값 — `{{공사명}}` → 공사명, `{{회사로고}}` → 그림 주소. */ +let frameFields: Record = {}; /** * 실은 뒤로 실제 편집이 있었는가. 도면을 바꾸기 전에 부모가 물어보는 근거다 — @@ -256,6 +260,8 @@ export const getSnapEnabled = () => snapEnabled; export const getGridEnabled = () => gridEnabled; export const getSnapTrackingEnabled = () => snapTrackingEnabled; export const getDesignMeta = (): DesignMeta | null => designMeta; +export const isFrameEditMode = (): boolean => frameEditMode; +export const getFrameFields = (): Record => frameFields; export const isDrawingDirty = () => drawingDirty; /** * 확정한 도면은 읽기 전용이다 — 그리기·수정·값 편집이 모두 막힌다(2026-09-01 사용자 @@ -486,6 +492,12 @@ export const setDesignMeta = (newMeta: DesignMeta | null) => { designMeta = newMeta; triggerReactUpdate(StateVariable.designMeta); }; +/** 도각 편집 모드 켜고 끄기 — 자리표 패널의 표시 여부를 가른다 (2026-09-06 사용자 지시). */ +export const setFrameEditMode = (enabled: boolean, fields: Record = {}) => { + frameEditMode = enabled; + frameFields = enabled ? fields : {}; + notifyWindow(HtmlEvent.UPDATE_STATE); +}; // 수량표는 앞 단계(B05·B06) 산출물이라 B07에서 고치지 않는다(2026-09-01 사용자 확정). // 값을 바꾸려면 횡단설계에서 고치고 돌아온다 — 여기 있던 setDesignQuantityTable은 // 어디서도 부르지 않으면서 "고칠 수 있는 값"으로 오해를 남겨 지웠다. diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/COPYING b/B07_DesignDetail/openwebcad/tools/libredwg/COPYING new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/B07_DesignDetail/openwebcad/tools/libredwg/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/README.md b/B07_DesignDetail/openwebcad/tools/libredwg/README.md new file mode 100644 index 00000000..cabd38ea --- /dev/null +++ b/B07_DesignDetail/openwebcad/tools/libredwg/README.md @@ -0,0 +1,37 @@ +# LibreDWG (DWG ↔ DXF 변환기) — 동봉본 + +B07 도각·도면의 **DWG 불러오기·내보내기**에 쓰는 외부 프로그램. Aislo 코드가 아니라 +GNU LibreDWG 프로젝트의 산출물을 그대로 담아 둔 것이다 (2026-09-06 사용자 확정). + +| 항목 | 값 | +|---|---| +| 버전 | 0.14.8594 (Windows 64비트 배포본) | +| 원본 | | +| 프로젝트 | | +| 라이선스 | GNU GPL v3 이상 (`COPYING`) | +| 읽는 범위 | DWG r13 ~ r2018. 그보다 새 형식은 화면이 「2018 DWG 또는 DXF 로 저장」 안내로 떨어진다 | + +## 담은 파일 + +`dwg2dxf.exe`(DWG→DXF) · `dxf2dwg.exe`(DXF→DWG) · `libredwg-0.dll` · +`libiconv-2.dll` · `libpcre2-8-0.dll` · `libpcre2-16-0.dll` + +배포본의 나머지(예제·문서·헤더·**파이썬 바인딩**)는 담지 않았다. 특히 파이썬 바인딩은 +**일부러 뺐다** — 파이썬에서 `import` 하면 라이브러리를 끌어안는 것이라 GPL 이 Aislo +코드까지 번진다. + +## 지키는 선 + +- Aislo 는 이 프로그램들을 **별도 실행 파일로만 부른다**(`subprocess`). 링크하거나 + 라이브러리로 품지 않는다 — 그래서 Aislo 코드에는 GPL 의무가 미치지 않는다. +- 프로그램을 고객에게 넘길 때는 이 폴더(실행 파일 + `COPYING` + 위 원본 주소)를 함께 + 넘긴다. 원본 소스는 위 주소에서 그대로 받을 수 있다. +- 이 파일들은 **고치지 않는다**. 새 버전으로 바꿀 때는 원본 배포본에서 같은 6개만 다시 + 담고 이 문서의 버전을 고친다. + +## 찾는 자리 + +서버 코드가 이 폴더를 먼저 본다. 다른 자리에 두려면 `.env` 에 경로를 적는다. + +- `LIBREDWG_DWG2DXF_PATH` — DWG 불러오기 +- `LIBREDWG_DXF2DWG_PATH` — DWG 내보내기 diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/dwg2dxf.exe b/B07_DesignDetail/openwebcad/tools/libredwg/dwg2dxf.exe new file mode 100644 index 00000000..c6935077 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/dwg2dxf.exe differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/dxf2dwg.exe b/B07_DesignDetail/openwebcad/tools/libredwg/dxf2dwg.exe new file mode 100644 index 00000000..11c3cf80 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/dxf2dwg.exe differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libiconv-2.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libiconv-2.dll new file mode 100644 index 00000000..3cace95e Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libiconv-2.dll differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-16-0.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-16-0.dll new file mode 100644 index 00000000..aab05da2 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-16-0.dll differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-8-0.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-8-0.dll new file mode 100644 index 00000000..25941164 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libpcre2-8-0.dll differ diff --git a/B07_DesignDetail/openwebcad/tools/libredwg/libredwg-0.dll b/B07_DesignDetail/openwebcad/tools/libredwg/libredwg-0.dll new file mode 100644 index 00000000..a8ac2e12 Binary files /dev/null and b/B07_DesignDetail/openwebcad/tools/libredwg/libredwg-0.dll differ diff --git a/common_util/common_util_audit.py b/common_util/common_util_audit.py new file mode 100644 index 00000000..d1214696 --- /dev/null +++ b/common_util/common_util_audit.py @@ -0,0 +1,151 @@ +"""시스템 로그 기록·정리 한 곳 (2026-09-06 사용자 확정). + +같은 INSERT 문이 여섯 자리에 흩어져 있었고, 대상(무엇에 한 일인가)과 접속 정보(어디서 +했는가)는 칸만 있고 값이 비어 있었다. 기록은 이 함수 하나로 모은다. + +보관 기간은 `AUDIT_LOG_RETENTION_DAYS`(기본 365일) — 사고 추적에 1년이면 충분하다는 +사용자 판단(2026-09-06). 임시 보관함 정리 루프가 돌 때 함께 지운다. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any + +from config.config_db import get_db_pool +from config.config_system import API_CALL_HOURLY_LIMIT, AUDIT_LOG_RETENTION_DAYS + +logger = logging.getLogger(__name__) + +# 브라우저 문자열은 길다 — 표 칸은 TEXT 지만 화면·로그가 감당할 만큼만 자른다. +_USER_AGENT_MAX = 300 + + +def request_origin(request: Any | None) -> tuple[str | None, str | None]: + """요청에서 접속 주소와 브라우저 문자열을 꺼낸다. 없으면 (None, None). + + 프록시 뒤에서는 `X-Forwarded-For` 의 **첫 주소**가 실제 사용자다. + """ + if request is None: + return None, None + try: + forwarded = request.headers.get("x-forwarded-for") + address = forwarded.split(",")[0].strip() if forwarded else None + if not address and request.client is not None: + address = request.client.host + agent = (request.headers.get("user-agent") or "")[:_USER_AGENT_MAX] or None + return address, agent + except Exception: # 기록이 본 작업을 막으면 안 된다. + return None, None + + +async def record_audit( + cursor: Any, + *, + actor_id: int, + action: str, + resource_type: str | None = None, + resource_ref: str | int | None = None, + request: Any | None = None, +) -> None: + """시스템 로그 한 줄을 적는다 — 호출부의 트랜잭션(cursor)에 얹는다. + + `resource_ref` 는 프로젝트 UUID 처럼 문자열이어도 되고 숫자여도 된다. 숫자면 옛 + `resource_id` 칸에도 같이 넣어 예전 기록과 같은 모양을 지킨다. + """ + reference = None if resource_ref is None else str(resource_ref) + numeric = int(resource_ref) if isinstance(resource_ref, int) else None + address, agent = request_origin(request) + await cursor.execute( + """INSERT INTO system_audit_logs + (user_id, action, resource_type, resource_id, resource_ref, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s, %s, %s)""", + (actor_id, action, resource_type, numeric, reference, address, agent), + ) + + +async def purge_expired_audit_logs() -> int: + """보관 기간이 지난 시스템 로그를 지운다. 지운 줄 수를 돌려준다.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, AUDIT_LOG_RETENTION_DAYS)) + pool = get_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("DELETE FROM system_audit_logs WHERE timestamp < %s", (cutoff,)) + removed = cursor.rowcount + if removed: + await connection.commit() + logger.info( + "시스템 로그 정리: %d건 삭제 (보관 %d일)", removed, AUDIT_LOG_RETENTION_DAYS + ) + return removed + except Exception: + logger.exception("시스템 로그 정리 실패") + return 0 + + +# ───────────────────────────────────────────────────────────────────────── +# 호출량 감시 (2026-09-06 사용자 지시 — 보안) +# ───────────────────────────────────────────────────────────────────────── +# 계산 결과는 화면에 나가도 된다는 것이 방침이므로, 남는 위험은 **입력을 바꿔가며 출력을 +# 긁어 모으는 것**이다. 사람이 화면을 쓰는 속도에는 한계가 있다 — 화면 한 번 여는 데 API +# 가 스무 번쯤 나가므로 한 시간에 수천 번을 넘으면 사람이 아니다. +# +# 막지는 않는다. 고객 화면을 끊을 위험이 있고, 어디서 끊을지는 실제 사용 기록을 본 뒤에 +# 정할 일이다. 지금은 **시스템 로그에 한 줄 남겨** 눈에 띄게만 한다(보관 1년). +_CALL_WINDOW_SECONDS = 3600 +_CALL_COUNTS: dict[tuple[str, int], int] = {} +_CALL_FLAGGED: set[tuple[str, int]] = set() + + +def _call_bucket(now: float) -> int: + return int(now // _CALL_WINDOW_SECONDS) + + +def note_api_call(session_id: str | None) -> bool: + """이 세션의 이번 시간대 호출을 하나 센다. 방금 상한을 넘었으면 True. + + True 는 **한 시간대에 한 번만** 나온다 — 로그가 넘치지 않게 한다. + """ + if not session_id: + return False + from time import time as _now + + bucket = _call_bucket(_now()) + key = (session_id, bucket) + count = _CALL_COUNTS.get(key, 0) + 1 + _CALL_COUNTS[key] = count + if count < API_CALL_HOURLY_LIMIT or key in _CALL_FLAGGED: + return False + _CALL_FLAGGED.add(key) + # 지난 시간대 기록은 버린다 — 오래 켜 둔 서버에서 사전이 무한정 자라지 않게. + for old in [k for k in _CALL_COUNTS if k[1] < bucket]: + _CALL_COUNTS.pop(old, None) + _CALL_FLAGGED.discard(old) + return True + + +async def record_call_burst(session_id: str, request: Any | None = None) -> None: + """상한을 넘은 세션을 시스템 로그에 남긴다 — 사람이 볼 수 있게만 하고 막지는 않는다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT user_id FROM sessions WHERE id = %s", (session_id,)) + row = await cursor.fetchone() + if not row: + return + await record_audit( + cursor, + actor_id=int(row[0]), + action="RATE_ANOMALY", + resource_type="api", + resource_ref=f"{API_CALL_HOURLY_LIMIT}+/h", + request=request, + ) + await connection.commit() + logger.warning( + "호출량 감시: 한 시간에 %d회를 넘은 세션이 있어 시스템 로그에 남겼습니다.", + API_CALL_HOURLY_LIMIT, + ) + except Exception: + logger.exception("호출량 감시 기록 실패") diff --git a/common_util/common_util_auth.py b/common_util/common_util_auth.py index e04d7d7c..0129110b 100644 --- a/common_util/common_util_auth.py +++ b/common_util/common_util_auth.py @@ -15,6 +15,7 @@ from config.config_system import ( DEVICE_TOKEN_COOKIE_NAME, EMAIL_REVERIFY_DAYS, PASSWORD_BCRYPT_ROUNDS, + SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS, SESSION_COOKIE_NAME, SESSION_COOKIE_SECURE, SESSION_IDLE_TIMEOUT_SECONDS, @@ -127,11 +128,17 @@ async def verify_session(request: Request) -> dict[str, Any]: await connection.commit() raise HTTPException(status_code=401, detail="세션이 만료되었습니다.") - await cursor.execute( - "UPDATE sessions SET last_activity_at = CURRENT_TIMESTAMP WHERE id = %s", - (session_id,), - ) - await connection.commit() + # `last_activity_at` 은 유휴 판정(기본 4시간)에만 쓰는 값이라 초 단위로 정확할 이유가 + # 없다. 그런데 **요청마다** 쓰고 있어 원격 DB 왕복이 UPDATE + commit 으로 붙었다 + # (2026-09-06 실측: verify_session 34.4ms = SELECT 10.8 + UPDATE 10.4 + commit 9.5). + # 인증이 걸린 모든 요청이 지나는 자리라, 화면 한 번 여는 데 API 가 180번 나가면 + # 그만큼 곱해진다. 1분에 한 번만 쓴다 — 4시간 판정에는 영향이 없다. + if (now - row[4]).total_seconds() >= SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS: + await cursor.execute( + "UPDATE sessions SET last_activity_at = CURRENT_TIMESTAMP WHERE id = %s", + (session_id,), + ) + await connection.commit() return { "session_id": row[0], "user_id": row[1], diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py new file mode 100644 index 00000000..02c69c73 --- /dev/null +++ b/common_util/common_util_cross_berm.py @@ -0,0 +1,166 @@ +"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9). + +짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며 +`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다. + +**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. +지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을 +겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신 +**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 +값이 나온다(거울 시험이 그것도 지킨다). + +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. + 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. +· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 + **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +""" + +import math +from typing import Callable, NamedTuple + +# 소단 기본값 — 근거는 위 모듈 설명. +BERM_DEFAULT_WIDTH_M = 0.5 +BERM_DEFAULT_INTERVAL_M = 3.0 +BERM_DEFAULT_SLOPE_DEG = 2.0 + +# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. +_STEP_M = 0.05 +_MAX_REACH_M = 200.0 + + +class BermSpec(NamedTuple): + """소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도).""" + + width_m: float = BERM_DEFAULT_WIDTH_M + interval_m: float = BERM_DEFAULT_INTERVAL_M + slope_deg: float = BERM_DEFAULT_SLOPE_DEG + + +def cut_profile_points( + start_dist: float, + start_z: float, + cut_ratio: float, + soil_cut_ratio: float, + rock_boundary_z: Callable[[float], float] | None, + berm: BermSpec | None, + max_reach_m: float = _MAX_REACH_M, + multi_knee: bool = False, +) -> list[tuple[float, float]]: + """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로. + + `rock_boundary_z` 가 None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다. + 있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번** + 꺾는다. + + `multi_knee` 가 거짓이면 **한 번만** 꺾는다(기존 규칙 그대로). 참이면 경계를 오갈 때마다 + 꺾는다. + + ⚠ **소단이 있으면 반드시 참이어야 한다.** 소단은 평탄한데 경계선은 지반을 따라 올라가므로, + 폭 0.5m 짜리 소단 하나만 지나도 설계선이 경계 **아래로 되돌아가는 일이 흔하다**(지반이 + 1:1 이면 경계는 0.5m 오르고 소단은 2°=0.017m 만 오른다). 한 번만 꺾으면 그 구간을 **암인데 + 토사 경사로** 그려 절토가 조용히 커진다 — 경고도 안 뜬다(2026-09-07 배분 창 지적으로 확인). + + 반대로 소단이 없을 때 참으로 두면 **지금 측점들의 설계가 같이 바뀐다**(실측: 물결 경계에서 + 0.0016m). 그래서 기본값은 거짓이고, 소단을 줄 때만 참으로 켠다. + + `berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다. + 평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다 + (물이 노면 쪽으로 흐르게 — 소단측구를 놓는 자리다). + + 꼭짓점만 돌려준다 — 경사가 바뀌는 점과 소단 모서리뿐이라 사이는 직선이다. + """ + points: list[tuple[float, float]] = [(start_dist, start_z)] + dist, elevation = start_dist, start_z + slant_since_berm = 0.0 + limit = start_dist + max_reach_m + berm_rise = ( + math.tan(math.radians(berm.slope_deg)) * berm.width_m + if berm is not None and berm.width_m > 0 + else 0.0 + ) + # 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist) + ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio + + while dist < limit: + rise = _STEP_M / ratio + slant = math.hypot(_STEP_M, rise) + + # ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m: + remain = max(berm.interval_m - slant_since_berm, 0.0) + run = remain / math.hypot(1.0, 1.0 / ratio) + dist += run + elevation += run / ratio + points.append((dist, elevation)) # 소단 안쪽 모서리 + dist += berm.width_m + elevation += berm_rise + points.append((dist, elevation)) # 소단 바깥 모서리 + slant_since_berm = 0.0 + continue + + next_dist = dist + _STEP_M + next_z = elevation + rise + + # ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. + # `multi_knee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). + if rock_boundary_z is not None and (multi_knee or not in_soil): + diff_now = elevation - rock_boundary_z(dist) + diff_next = next_z - rock_boundary_z(next_dist) + crossed = (diff_next >= 0) if not in_soil else (diff_next < 0) + if crossed: + span = diff_next - diff_now + share = (-diff_now) / span if abs(span) > 1e-12 else 0.0 + share = min(max(share, 0.0), 1.0) + knee_dist = dist + _STEP_M * share + knee_z = elevation + rise * share + slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation) + dist, elevation = knee_dist, knee_z + points.append((dist, elevation)) # 무릎 + in_soil = not in_soil + ratio = soil_cut_ratio if in_soil else cut_ratio + continue + + dist, elevation = next_dist, next_z + slant_since_berm += slant + + points.append((dist, elevation)) + return _dedupe(points) + + +def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게.""" + out: list[tuple[float, float]] = [] + for point in points: + if out and abs(point[0] - out[-1][0]) < 1e-9 and abs(point[1] - out[-1][1]) < 1e-9: + continue + out.append(point) + return out + + +def elevation_at(points: list[tuple[float, float]], dist: float) -> float: + """꼭짓점 목록에서 거리 하나의 표고 — 사이는 직선 보간, 끝은 마지막 경사 연장.""" + if not points: + return 0.0 + if dist <= points[0][0]: + return points[0][1] + for index in range(1, len(points)): + x0, z0 = points[index - 1] + x1, z1 = points[index] + if dist > x1 + 1e-12: + continue + span = x1 - x0 + if span <= 1e-12: + return z1 + return z0 + (z1 - z0) * ((dist - x0) / span) + # 끝을 넘어가면 마지막 두 점의 기울기로 잇는다. + x0, z0 = points[-2] if len(points) > 1 else points[-1] + x1, z1 = points[-1] + span = x1 - x0 + if span <= 1e-12: + return z1 + return z1 + (z1 - z0) / span * (dist - x1) diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts new file mode 100644 index 00000000..c4e0a192 --- /dev/null +++ b/common_util/common_util_cross_berm.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * common_util/common_util_cross_berm.ts + * 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9) + * + * ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다. + * 거울 시험: `tmp/tests/test_cross_berm_mirror.py` + * + * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. + * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. + * + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. + * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 + * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): + * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. + * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * ========================================================================== */ + +/** 소단 기본값 — 근거는 위 설명. */ +export const BERM_DEFAULT_WIDTH_M = 0.5; +export const BERM_DEFAULT_INTERVAL_M = 3.0; +export const BERM_DEFAULT_SLOPE_DEG = 2.0; + +/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ +const STEP_M = 0.05; +const MAX_REACH_M = 200.0; + +/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */ +export interface BermSpec { + widthM: number; + intervalM: number; + slopeDeg: number; +} + +export function bermSpec( + widthM = BERM_DEFAULT_WIDTH_M, + intervalM = BERM_DEFAULT_INTERVAL_M, + slopeDeg = BERM_DEFAULT_SLOPE_DEG, +): BermSpec { + return { widthM, intervalM, slopeDeg }; +} + +/** + * 짝: `cut_profile_points`. 절토 사면 꼭짓점 `[[거리, 표고], ...]` — 시작에서 바깥으로. + * + * `rockBoundaryZ` 가 null 이면 2단계 절토가 아니라 경사가 하나다. 있으면 경계를 만나는 + * 자리에서 암 → 토사로 **한 번** 꺾는다. + * + * ⚠ 한 번만 꺾는 것은 **기존 규칙을 그대로 지킨 것**이다. 여러 번 꺾게 바꾸면 소단이 없는 + * 지금 측점들의 설계도 같이 바뀌므로 별건으로 미룬다(2026-09-07). + */ +export function cutProfilePoints( + startDist: number, + startZ: number, + cutRatio: number, + soilCutRatio: number, + rockBoundaryZ: ((dist: number) => number) | null, + berm: BermSpec | null, + maxReachM: number = MAX_REACH_M, + multiKnee = false, +): Array<[number, number]> { + const points: Array<[number, number]> = [[startDist, startZ]]; + let dist = startDist; + let elevation = startZ; + let slantSinceBerm = 0; + const limit = startDist + maxReachM; + const bermRise = + berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0; + // 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다). + let inSoil = rockBoundaryZ === null || elevation >= rockBoundaryZ(dist); + let ratio = rockBoundaryZ !== null && inSoil ? soilCutRatio : cutRatio; + + while (dist < limit) { + const rise = STEP_M / ratio; + const slant = Math.hypot(STEP_M, rise); + + // ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다. + if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) { + const remain = Math.max(berm.intervalM - slantSinceBerm, 0); + const run = remain / Math.hypot(1, 1 / ratio); + dist += run; + elevation += run / ratio; + points.push([dist, elevation]); // 소단 안쪽 모서리 + dist += berm.widthM; + elevation += bermRise; + points.push([dist, elevation]); // 소단 바깥 모서리 + slantSinceBerm = 0; + continue; + } + + const nextDist = dist + STEP_M; + const nextZ = elevation + rise; + + // ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다. + // `multiKnee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙). + if (rockBoundaryZ !== null && (multiKnee || !inSoil)) { + const diffNow = elevation - rockBoundaryZ(dist); + const diffNext = nextZ - rockBoundaryZ(nextDist); + const crossed = !inSoil ? diffNext >= 0 : diffNext < 0; + if (crossed) { + const span = diffNext - diffNow; + let share = Math.abs(span) > 1e-12 ? -diffNow / span : 0; + share = Math.min(Math.max(share, 0), 1); + const kneeDist = dist + STEP_M * share; + const kneeZ = elevation + rise * share; + slantSinceBerm += Math.hypot(kneeDist - dist, kneeZ - elevation); + dist = kneeDist; + elevation = kneeZ; + points.push([dist, elevation]); // 무릎 + inSoil = !inSoil; + ratio = inSoil ? soilCutRatio : cutRatio; + continue; + } + } + + dist = nextDist; + elevation = nextZ; + slantSinceBerm += slant; + } + + points.push([dist, elevation]); + return dedupe(points); +} + +/** 같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게. */ +function dedupe(points: Array<[number, number]>): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const point of points) { + const last = out[out.length - 1]; + if (last && Math.abs(point[0] - last[0]) < 1e-9 && Math.abs(point[1] - last[1]) < 1e-9) { + continue; + } + out.push(point); + } + return out; +} + +/** 짝: `elevation_at`. 꼭짓점 목록에서 거리 하나의 표고(사이는 직선, 끝은 연장). */ +export function elevationAt(points: Array<[number, number]>, dist: number): number { + if (points.length === 0) return 0; + if (dist <= points[0][0]) return points[0][1]; + for (let index = 1; index < points.length; index += 1) { + const [x0, z0] = points[index - 1]; + const [x1, z1] = points[index]; + if (dist > x1 + 1e-12) continue; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z0 + (z1 - z0) * ((dist - x0) / span); + } + const [x0, z0] = points.length > 1 ? points[points.length - 2] : points[points.length - 1]; + const [x1, z1] = points[points.length - 1]; + const span = x1 - x0; + if (span <= 1e-12) return z1; + return z1 + ((z1 - z0) / span) * (dist - x1); +} diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 3aaeebee..83f060b0 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -23,9 +23,16 @@ * 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다. * ========================================================================== */ +import type { BermSpec } from "./common_util_cross_berm"; import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; // 단면 기하(노면·측구·사면 설계고)는 파일이 700줄을 넘어 떼어냈다(2026-09-04). -import { SectionGeometry, type ResolvedGroup } from "./common_util_cross_design_geometry"; +import { + CURVE_WIDENING_MAX_WIDTH_M, + type CutSlopeSegment, + SectionGeometry, + curveWideningM, + type ResolvedGroup, +} from "./common_util_cross_design_geometry"; /** 지반유형 → 표준단면 프리셋 키. 짝: config `SECTION_GROUND_TYPE_PRESET`. */ const GROUND_TYPE_PRESET: Record = { @@ -77,6 +84,14 @@ export interface CrossDesignOptions { ditchEnabled?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; + /** 이 측점의 평면 곡선반경(m) — 곡선부 확폭을 정하는 입력. 직선이면 null. */ + planRadiusM?: number | null; + /** 곡선 **바깥쪽**("left"/"right") — 확폭이 붙는 쪽(2026-09-06 사용자 확정). */ + curveOuterSide?: "left" | "right" | null; + /** 저장된 확폭량(m) — 곡선 앞뒤 테이퍼가 얹힌 값. 있으면 반경 표값 대신 쓴다. */ + curveWideningM?: number | null; + /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; } export interface CrossDesignEdge { @@ -96,6 +111,11 @@ export interface CrossDesignResult { fill_slope_ratio: number; roadbed_width_m: number; carriageway_width_m: number; + /** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */ + carriageway_standard_width_m?: number; + /** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */ + widening_left_m?: number; + widening_right_m?: number; cross_slope_pct: number; ditch: Record; ditch_enabled: boolean; @@ -112,6 +132,8 @@ export interface CrossDesignResult { fill_ground_slope: number | null; ditch_area_m2: number; design_line: CrossDesignEdge[]; + /** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */ + cut_slope_segments: CutSlopeSegment[]; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -254,8 +276,24 @@ export function computeCrossDesign( // 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다. const enableTwoStage = presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null; + // 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다. + // 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다. + const outerSide = options.curveOuterSide; + // 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다. + let widening = + outerSide === "left" || outerSide === "right" + ? typeof options.curveWideningM === "number" && Number.isFinite(options.curveWideningM) + ? options.curveWideningM + : curveWideningM(options.planRadiusM) + : 0; + if (widening > 0) { + widening = Math.min(widening, Math.max(CURVE_WIDENING_MAX_WIDTH_M - group.road_width_m, 0)); + } + const geometry = new SectionGeometry({ designElevationM: centerElevation, + wideningLeftM: outerSide === "left" ? widening : 0, + wideningRightM: outerSide === "right" ? widening : 0, group, sectionMode, ditchSide: resolvedDitchSide, @@ -266,6 +304,7 @@ export function computeCrossDesign( rockBoundaryOffsetM, twoStageSlope: enableTwoStage, ditchEnabled: options.ditchEnabled ?? null, + berm: options.berm ?? null, }); // 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). @@ -352,10 +391,18 @@ export function computeCrossDesign( ditch_type: geometry.hasDitch ? ditchType : null, cut_slope_ratio: round4(geometry.cutRatio), soil_cut_slope_ratio: round4(geometry.soilCutRatio), - two_stage_slope: geometry.twoStage, + // 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.twoStage`)가 + // 아니다(2026-09-07, 짝: `B06_Section_Engine_Design.py`). 적용 결과를 저장하면 토사 + // 측점처럼 못 쓰는 자리에서 false 가 저장되고, 그 false 가 다음 재계산 인자로 되먹여져 + // 사용자의 「켬」이 영구히 사라졌다. + two_stage_slope: options.twoStageSlope ?? true, fill_slope_ratio: round4(geometry.fillRatio), roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), - carriageway_width_m: round4(group.road_width_m), + carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), + // 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다(짝: 파이썬). + carriageway_standard_width_m: round4(group.road_width_m), + widening_left_m: round4(geometry.halfRoadLeft - geometry.halfRoad), + widening_right_m: round4(geometry.halfRoadRight - geometry.halfRoad), cross_slope_pct: round4(crossSlopePct), ditch: ditchSpec, ditch_enabled: geometry.hasDitch, @@ -372,12 +419,12 @@ export function computeCrossDesign( }, carriageway_edges: { left: { - offset_m: round4(geometry.halfRoad), - elevation_m: round4(geometry.roadZ(geometry.halfRoad)), + offset_m: round4(geometry.halfRoadLeft), + elevation_m: round4(geometry.roadZ(geometry.halfRoadLeft)), }, right: { - offset_m: round4(-geometry.halfRoad), - elevation_m: round4(geometry.roadZ(-geometry.halfRoad)), + offset_m: round4(-geometry.halfRoadRight), + elevation_m: round4(geometry.roadZ(-geometry.halfRoadRight)), }, }, design_elevation_m: round4(centerElevation), @@ -390,6 +437,7 @@ export function computeCrossDesign( fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), ditch_area_m2: round4(ditchArea), design_line: designLine, + cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 466ead9b..7e7f6d27 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -6,9 +6,26 @@ * `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다. * ========================================================================== */ +import { type BermSpec, cutProfilePoints, elevationAt } from "./common_util_cross_berm"; + +/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */ +export interface CutSlopeSegment { + side: string; + ratio: number; + rise_m: number; + run_m: number; + start_offset_m: number; + end_offset_m: number; + material: string | null; +} + +/** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */ +function round4(value: number): number { + return Math.round(value * 10000) / 10000; +} + /** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */ const MARCH_STEP_M = 0.05; -const KNEE_MAX_M = 200; const CROSS_MAX_M = 500; export interface ResolvedGroup { @@ -35,9 +52,42 @@ export function sideRole(sectionMode: string): [string, string] { throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`); } +/** + * 곡선부 너비 확폭표 — **짝: `config_system_design.CURVE_WIDENING_TABLE_M`** + * (별표2 Ⅰ.2.나.(4)). `[반경 하한, 반경 상한(미만), 확폭(m)]`이고 45m 이상은 확폭이 없다. + * 두 벌이 한 세트이므로 값을 고칠 때는 파이썬 쪽도 함께 고친다. + */ +const CURVE_WIDENING_TABLE_M: ReadonlyArray = [ + [10, 13, 2.25], + [13, 14, 2.0], + [14, 15, 1.75], + [15, 18, 1.5], + [18, 20, 1.25], + [20, 25, 1.0], + [25, 30, 0.75], + [30, 40, 0.5], + [40, 45, 0.25], +]; + +/** 확폭을 더한 뒤의 유효너비 상한(m) — 짝: `CURVE_WIDENING_MAX_WIDTH_M`. */ +export const CURVE_WIDENING_MAX_WIDTH_M = 5.0; + +/** 평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0. */ +export function curveWideningM(planRadiusM: number | null | undefined): number { + if (planRadiusM === null || planRadiusM === undefined || !Number.isFinite(planRadiusM)) return 0; + const found = CURVE_WIDENING_TABLE_M.find( + ([low, high]) => planRadiusM >= low && planRadiusM < high, + ); + return found ? found[2] : 0; +} + /** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */ export class SectionGeometry { + /** 규격 차도 반폭(확폭 전) — 수량·표기 기준. */ halfRoad: number; + /** 좌(+)·우(−) 차도 반폭 — 곡선부 확폭이 **한쪽에만** 붙어 좌우가 갈린다(2026-09-06). */ + halfRoadLeft: number; + halfRoadRight: number; leftExtent: number; rightExtent: number; zCenter: number; @@ -54,7 +104,9 @@ export class SectionGeometry { ditchPoints: Array<[number, number]> = []; private groundAt: ((offsetM: number) => number) | null; private rockOffset: number; - private rockKnee = new Map(); + /** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */ + berm: BermSpec | null = null; + private cutPointsCache = new Map>(); private cutCross = new Map(); private fillCross = new Map(); @@ -70,12 +122,19 @@ export class SectionGeometry { rockBoundaryOffsetM: number | null; twoStageSlope: boolean; ditchEnabled: boolean | null; + /** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */ + wideningLeftM?: number; + wideningRightM?: number; + /** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ + berm?: BermSpec | null; }) { const { group } = params; const halfRoad = group.road_width_m / 2; this.halfRoad = halfRoad; - this.leftExtent = halfRoad + group.shoulder_left_m; - this.rightExtent = halfRoad + group.shoulder_right_m; + this.halfRoadLeft = halfRoad + Math.max(params.wideningLeftM ?? 0, 0); + this.halfRoadRight = halfRoad + Math.max(params.wideningRightM ?? 0, 0); + this.leftExtent = this.halfRoadLeft + group.shoulder_left_m; + this.rightExtent = this.halfRoadRight + group.shoulder_right_m; this.zCenter = params.designElevationM; this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6); this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6); @@ -86,6 +145,7 @@ export class SectionGeometry { params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null, ); this.groundAt = params.groundAt; + this.berm = params.berm ?? null; this.rockOffset = params.rockBoundaryOffsetM ?? 0; this.ditchType = params.ditchType; // 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). @@ -165,50 +225,30 @@ export class SectionGeometry { return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset; } - /** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */ - knee(side: string): [number, number] | null { - if (!this.twoStage) return null; - const cached = this.rockKnee.get(side); + /** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */ + cutPoints(side: string): Array<[number, number]> { + const cached = this.cutPointsCache.get(side); if (cached !== undefined) return cached; const [startDist, startZ] = this.slopeStart(side); - let diffPrev = startZ - this.rockBoundaryZ(side, startDist); - let result: [number, number] | null; - if (diffPrev >= 0) { - result = [startDist, startZ]; // 시작부터 토사(경계 위) - } else { - result = null; - let distPrev = startDist; - let dist = startDist + MARCH_STEP_M; - while (dist <= startDist + KNEE_MAX_M) { - const zRock = startZ + (dist - startDist) / this.cutRatio; - const diff = zRock - this.rockBoundaryZ(side, dist); - if (diff >= 0) { - const span = diff - diffPrev; - const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0; - const kneeDist = distPrev + (dist - distPrev) * ratio; - const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio; - result = [kneeDist, kneeZ]; - break; - } - distPrev = dist; - diffPrev = diff; - dist += MARCH_STEP_M; - } - } - this.rockKnee.set(side, result); - return result; + const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null; + const points = cutProfilePoints( + startDist, + startZ, + this.cutRatio, + this.soilCutRatio, + boundary, + this.berm, + undefined, + // 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07). + this.berm !== null, + ); + this.cutPointsCache.set(side, points); + return points; } - /** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */ + /** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */ private cutSlopeZ(side: string, dist: number): number { - const [startDist, startZ] = this.slopeStart(side); - const knee = this.twoStage ? this.knee(side) : null; - if (knee !== null) { - const [kneeDist, kneeZ] = knee; - if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio; - return kneeZ + (dist - kneeDist) / this.soilCutRatio; - } - return startZ + (dist - startDist) / this.cutRatio; + return elevationAt(this.cutPoints(side), dist); } /** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */ @@ -312,15 +352,71 @@ export class SectionGeometry { return Math.max(startZ - run / this.fillRatio, groundM); } + /** + * 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사용. + * + * 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼 + * 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다. + */ + cutSlopeSegments(): CutSlopeSegment[] { + const segments: CutSlopeSegment[] = []; + for (const side of ["left", "right"]) { + const role = side === "left" ? this.leftRole : this.rightRole; + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + const points = this.cutPoints(side); + const sign = side === "left" ? 1 : -1; + for (let index = 1; index < points.length; index += 1) { + const [startD, startZ] = points[index - 1]; + let [endD, endZ] = points[index]; + if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + if (cross !== null && endD > cross) { + endZ = elevationAt(points, cross); + endD = cross; + } + const run = endD - startD; + const rise = endZ - startZ; + if (run <= 1e-9 || rise <= 1e-6) continue; + if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) { + const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM; + if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부) + } + // 재료는 **그 구간을 실제로 그린 경사비**로 가른다(짝 파이썬과 같은 까닭) — + // 경계선을 다시 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다. + let material: string | null = null; + if (this.twoStage && Math.abs(this.soilCutRatio - this.cutRatio) > 1e-9) { + const drawn = run / rise; + material = + Math.abs(drawn - this.soilCutRatio) < Math.abs(drawn - this.cutRatio) ? "soil" : "rock"; + } + segments.push({ + side, + ratio: round4(run / rise), + rise_m: round4(rise), + run_m: round4(run), + start_offset_m: round4(sign * startD), + end_offset_m: round4(sign * endD), + material, + }); + } + } + return segments; + } + /** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */ breakpoints(): number[] { const points = [0, this.leftExtent, -this.rightExtent]; for (const [offset] of this.ditchPoints) points.push(offset); - if (this.twoStage) { + // 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. + if (this.twoStage || this.berm !== null) { for (const side of ["left", "right"]) { const role = side === "left" ? this.leftRole : this.rightRole; - const knee = role === "cut" ? this.knee(side) : null; - if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]); + if (role !== "cut") continue; + const cross = this.cutCrossDist(side); + for (const [offset] of this.cutPoints(side)) { + if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다 + points.push(side === "left" ? offset : -offset); + } } } for (const side of ["left", "right"]) { diff --git a/common_util/common_util_cross_structure_areas.ts b/common_util/common_util_cross_structure_areas.ts new file mode 100644 index 00000000..f3ec9fc5 --- /dev/null +++ b/common_util/common_util_cross_structure_areas.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * common_util_cross_structure_areas.ts + * 구조물이 선 자리의 **절·성토 면적** — 지반선과 「실제로 그려지는 설계선」이 이루는 + * 폐회로의 넓이다(2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). + * + * 왜 따로 있나 — 기본 면적 계산(`common_util_cross_design.ts`)은 지반선과 **표준 설계선**의 + * 차이만 적분한다. 그 계산은 구조물이 있는지조차 모른다. 그런데 기슭막이·세월교·BOX암거가 + * 서면 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그린다 — 화면에 그려지는 + * 폐회로가 달라지므로 면적도 달라져야 한다. + * + * 여기서는 **그리는 쪽이 이미 만든 트림 값**(`designTrim`)을 그대로 받는다. 화면과 면적이 + * 같은 입력을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. + * + * 파이썬 짝은 만들지 않는다 — 서버도 **이 파일을 그대로 실행**한다 + * (`B06_Section_Structure_Areas_Node.ts` → 번들, 2026-09-06). 전처리 체인 끝과 + * [저장]·[확정] 때 서버가 돌려 정본에 얹으므로, 브라우저를 한 번도 안 열어도 값이 선다. + * ========================================================================== */ + +import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; + +/** 트림 경계 바로 바깥에 찍는 점의 간격(m) — 벽면을 수직으로 만들기 위한 값. */ +const BOUNDARY_EPS_M = 1e-6; + +/** 그리는 쪽이 넘겨 주는 트림 — 배수관·세월교·BOX 세트가 같은 모양으로 낸다. */ +export interface DesignTrim { + minOffset: number; + maxOffset: number; + minElevation?: number; + maxElevation?: number; + /** 트림 바깥(−offset 쪽)을 대신 그리는 폴리라인 — 노견에서 벽까지의 성토부선. */ + minSlope?: { points: Array<{ offset: number; elevation: number }> }; + /** 트림 바깥(+offset 쪽) 폴리라인. */ + maxSlope?: { points: Array<{ offset: number; elevation: number }> }; +} + +export interface StructureAreaInput { + /** 표준 설계선(측점 저장분) — 트림 안쪽은 이 선을 그대로 쓴다. */ + designLine: Array<{ offset_m: number; elevation_m: number }>; + /** 지반선 샘플(유효한 것만, 오프셋 오름차순). */ + ground: Array<{ offset: number; elevation: number }>; + trim: DesignTrim; + /** 암반 경계선 오프셋(m, 절대값). 있으면 절토를 토사/암으로 나눈다. */ + rockBoundaryOffsetM?: number | null; +} + +export interface StructureAreaResult { + cutAreaM2: number; + fillAreaM2: number; + cutSoilAreaM2: number; + cutRockAreaM2: number; +} + +/** 오프셋 오름차순 폴리라인의 선형 보간기. 범위 밖은 끝값을 문다. */ +function interpolator( + points: Array<{ offset: number; elevation: number }>, +): ((offset: number) => number) | null { + if (points.length === 0) return null; + const sorted = [...points].sort((a, b) => a.offset - b.offset); + return (offset: number): number => { + if (offset <= sorted[0].offset) return sorted[0].elevation; + const last = sorted[sorted.length - 1]; + if (offset >= last.offset) return last.elevation; + for (let index = 1; index < sorted.length; index += 1) { + const right = sorted[index]; + if (offset > right.offset) continue; + const left = sorted[index - 1]; + const span = right.offset - left.offset; + const ratio = span > 1e-9 ? (offset - left.offset) / span : 0; + return left.elevation + (right.elevation - left.elevation) * ratio; + } + return last.elevation; + }; +} + +/** + * 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이 + * 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라 + * 면적이 0이 된다). + */ +export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null { + const design = interpolator( + input.designLine.map((point) => ({ offset: point.offset_m, elevation: point.elevation_m })), + ); + const ground = interpolator(input.ground); + if (!design || !ground || input.ground.length < 2) return null; + + const { trim } = input; + const minSlope = trim.minSlope?.points?.length ? interpolator(trim.minSlope.points) : null; + const maxSlope = trim.maxSlope?.points?.length ? interpolator(trim.maxSlope.points) : null; + const minSlopeRange = trim.minSlope?.points?.length + ? [ + Math.min(...trim.minSlope.points.map((p) => p.offset)), + Math.max(...trim.minSlope.points.map((p) => p.offset)), + ] + : null; + const maxSlopeRange = trim.maxSlope?.points?.length + ? [ + Math.min(...trim.maxSlope.points.map((p) => p.offset)), + Math.max(...trim.maxSlope.points.map((p) => p.offset)), + ] + : null; + + /** 이 오프셋에서 **실제로 그려지는** 설계선 표고. 폐회로의 위쪽 경계다. */ + const drawnZ = (offset: number): number => { + if (offset < trim.minOffset) { + if (minSlope && minSlopeRange && offset >= minSlopeRange[0] && offset <= minSlopeRange[1]) { + return minSlope(offset); + } + // 구조물이 그리는 선이 닿지 않는 바깥 — 원지반 그대로(면적 0). + return ground(offset); + } + if (offset > trim.maxOffset) { + if (maxSlope && maxSlopeRange && offset >= maxSlopeRange[0] && offset <= maxSlopeRange[1]) { + return maxSlope(offset); + } + return ground(offset); + } + return design(offset); + }; + + // 적분 격자 = 지반 샘플 ∪ 설계선 꼭짓점 ∪ 트림 경계 ∪ 구조물 폴리라인 꼭짓점. + // 꺾이는 자리를 모두 넣어야 사다리꼴 적분이 모서리를 잘라먹지 않는다. + const lo = input.ground[0].offset; + const hi = input.ground[input.ground.length - 1].offset; + const grid = new Set(); + const add = (offset: number): void => { + if (offset >= lo && offset <= hi) grid.add(Math.round(offset * 1e6) / 1e6); + }; + input.ground.forEach((sample) => add(sample.offset)); + input.designLine.forEach((point) => add(point.offset_m)); + add(trim.minOffset); + add(trim.maxOffset); + // 트림 경계에서 그려지는 선은 **수직으로 끊긴다**(벽면). 경계 바로 바깥 점을 함께 넣어 + // 사다리꼴이 그 단차를 비스듬히 이어 붙이지 않게 한다 — 안 넣으면 격자 한 칸만큼 + // 없는 면적이 생긴다(실측: 벽 안쪽 6.25㎡ 가 7.5㎡ 로 잡혔다). + add(trim.minOffset - BOUNDARY_EPS_M); + add(trim.maxOffset + BOUNDARY_EPS_M); + trim.minSlope?.points.forEach((point) => add(point.offset)); + trim.maxSlope?.points.forEach((point) => add(point.offset)); + const offsets = [...grid].sort((a, b) => a - b); + if (offsets.length < 2) return null; + + const diffs = offsets.map((offset) => ground(offset) - drawnZ(offset)); + const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); + const rock = input.rockBoundaryOffsetM; + const [cutSoil, cutRock] = + typeof rock === "number" && Number.isFinite(rock) + ? splitCutAreas(offsets, diffs, Math.abs(rock)) + : [cutArea, 0]; + return { + cutAreaM2: cutArea, + fillAreaM2: fillArea, + cutSoilAreaM2: cutSoil, + cutRockAreaM2: cutRock, + }; +} diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts new file mode 100644 index 00000000..368d7af2 --- /dev/null +++ b/common_util/common_util_culvert_sets.ts @@ -0,0 +1,357 @@ +/* ============================================================================= + * common_util_culvert_sets.ts + * 배수관 세트(배관·기슭막이·보호공·세월교·BOX암거·물넘이포장) 제원 — **브라우저 몫**. + * + * ⚠ 파이썬 `B06_Section/B06_Section_Engine_Culvert.py` 와 **한 벌**이다(2026-09-06). + * 한쪽만 고치면 화면과 저장본이 갈린다. 거울 테스트: `tmp/tests/test_b06_culvert_sets_mirror.py` + * + * 왜 짝으로 두나(CLAUDE.md 5장 「계산 자리」) — 이 계산은 종횡단 **상세를 읽을 때마다** + * 서버에서 돈다. 매 요청 도는 자리라 Node 왕복(코리도·구조물 면적 방식)이 비싸다. + * 그래서 세 갈래 중 ①(파이썬·TS 짝 + 거울 테스트)을 골랐다. + * + * 값을 새로 만들지 않는다 — **정본(관 지점 옵션) + 레지스트리 기본값**만 조합한다. + * 상수 사본을 두면 B05 폼과 갈라지므로 기본값은 레지스트리에서만 꺼낸다. + * ========================================================================== */ + +/** 관 지점 정본 1건 — `pipe_points.json` 의 한 줄(브라우저는 API 로 같은 것을 받는다). */ +export interface CulvertPipePoint { + chainage_m: number; + facility?: string; + options?: Record | null; +} + +/** 레지스트리 타입별 옵션 기본값 — `type_id → { 옵션키: 기본값 }`. */ +export type CulvertRegistryDefaults = Record>; + +/** 세트 제원 — 파이썬이 내는 dict 와 같은 모양이라 키를 그대로 쓴다. */ +export type CulvertSetSpec = Record; + +/* ── 파이썬 짝과 같은 상수 (근거 주석은 파이썬 쪽에 있다) ───────────────── */ +const CHAINAGE_TOLERANCE_M = 0.02; +const SECTION_KEYS: Record = { + ford: "ford", + box: "box", + ford_pavement: "ford_pavement", +}; +/** 폭 절반만큼 옆 측점에도 걸치는 종류 — 물넘이포장만. */ +const SPAN_LINKED_TYPES = new Set(["ford_pavement"]); + +export const MIN_PIPE_COVER_M = 0.5; +export const APRON_LENGTH_FACTOR = 2.0; +export const APRON_THICKNESS_M = 0.45; +export const REVET_FACE_SLOPE = 0.3; +export const INLET_STRUCTURE_BASIN = "집수정"; +export const FORD_SLAB_THICKNESS_M = 0.3; +export const FORD_WALL_THICKNESS_M = 0.2; +export const FORD_DEFAULT_WIDTH_M = 10.0; +export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0; +export const BOX_COVER_M = 0.5; + +/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */ +const FACILITY_PIPE = "pipe"; +const FACILITY_BOX = "box_culvert"; +const FACILITY_FORD_PAVEMENT = "ford_pavement"; +const FACILITY_FORD_BRIDGE = "ford_bridge"; +const FACILITY_REVET = "revetment"; + +/** 숫자 옵션 하나를 정리한다. 문자열 저장분(관경 "1000")도 받는다 — 파이썬 `_number`. */ +function num(value: unknown, fallback: number | null): number | null { + if (typeof value === "boolean") return fallback; + if (typeof value === "number") return Number.isFinite(value) ? value : fallback; + if (typeof value === "string") { + const parsed = Number(value.trim()); + return value.trim() !== "" && Number.isFinite(parsed) ? parsed : fallback; + } + return fallback; +} + +/** 파이썬 `round(x, n)` 자리에 쓰는 반올림. */ +function round(value: number, digits: number): number { + return Number(value.toFixed(digits)); +} + +function text(value: unknown): string | null { + return value === null || value === undefined || value === "" ? null : String(value); +} + +/** + * 유입("inlet")·유출("outlet") 한쪽의 부속 제원. + * 구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 화면은 라벨만 쓴다. + */ +function sideSpec( + options: Record, + defaults: Record, + side: string, +): CulvertSetSpec { + const structure = text(options[`${side}_type`]) ?? text(defaults[`${side}_type`]) ?? "기슭막이"; + const spec: CulvertSetSpec = { role: side, structure }; + if (structure === INLET_STRUCTURE_BASIN) { + spec.basin_length_m = num( + options.inlet_basin_length_m, + num(defaults.inlet_basin_length_m, 2.0), + ); + spec.basin_before_m = num( + options.inlet_basin_before_m, + num(defaults.inlet_basin_before_m, null), + ); + spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null)); + return spec; + } + + const height = num( + options[`${side}_revet_height_m`], + num(defaults[`${side}_revet_height_m`], null), + ); + const length = num( + options[`${side}_revet_length_m`], + num(defaults[`${side}_revet_length_m`], null), + ); + const form = text(options[`${side}_revet_form`]) ?? text(defaults[`${side}_revet_form`]); + const before = num( + options[`${side}_revet_before_m`], + num(defaults[`${side}_revet_before_m`], null), + ); + const after = num(options[`${side}_revet_after_m`], num(defaults[`${side}_revet_after_m`], null)); + spec.revet_form = form; + spec.revet_height_m = height; + spec.revet_length_m = length; + spec.revet_before_m = before; + spec.revet_after_m = after; + spec.face_slope = REVET_FACE_SLOPE; + // 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다. + if (height !== null && height > 0) { + spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3); + spec.apron_thickness_m = APRON_THICKNESS_M; + } + return spec; +} + +/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공). */ +function culvertSet( + options: Record, + registry: CulvertRegistryDefaults, +): CulvertSetSpec { + const defaults = registry.pipe ?? {}; + const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0)); + return { + type: "pipe", + pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind), + diameter_m: pipeDiameterM(diameterMm), + min_cover_m: MIN_PIPE_COVER_M, + inlet: sideSpec(options, defaults, "inlet"), + outlet: sideSpec(options, defaults, "outlet"), + }; +} + +/** + * 독립 기슭막이 한쪽 벽 제원 — 배관 벽과 **같은 옵션 키**(`{role}_revet_*`)를 쓴다. + * 역할 키가 없으면 B05 폼이 한 벌로 담던 옛 키(`length_m`…)로 폴백한다. + */ +function revetSide(values: Record, role: string): CulvertSetSpec { + const height = num(values[`${role}_revet_height_m`], num(values.height_m, null)); + const form = text(values[`${role}_revet_form`]) ?? text(values.form); + const spec: CulvertSetSpec = { + role, + structure: "기슭막이", + revet_form: form, + revet_height_m: height, + revet_length_m: num(values[`${role}_revet_length_m`], num(values.length_m, null)), + revet_before_m: num(values[`${role}_revet_before_m`], num(values.before_m, null)), + revet_after_m: num(values[`${role}_revet_after_m`], num(values.after_m, null)), + face_slope: REVET_FACE_SLOPE, + }; + if (height !== null && height > 0) { + spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3); + spec.apron_thickness_m = APRON_THICKNESS_M; + } + return spec; +} + +/** 독립 기슭막이 1개소 — 배관 세트 모양이되 **관을 숨긴다**(hidden_pipe). */ +function revetSet(values: Record): CulvertSetSpec { + return { + type: "pipe", + hidden_pipe: true, + side: String(values.side ?? "양쪽"), + tiers: Math.trunc(num(values.tiers, 1.0) || 1), + pipe_kind: null, + diameter_m: 0.3, + min_cover_m: 0.0, + inlet: revetSide(values, "inlet"), + outlet: revetSide(values, "outlet"), + }; +} + +/** 관경(㎜) → 관 지름(m). 파이썬 `_culvert_set`·`_ford_set` 과 같은 반올림. */ +export function pipeDiameterM(diameterMm: number | null | undefined): number { + return round((num(diameterMm, 1000.0) ?? 1000.0) / 1000.0, 3); +} + +/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. */ +export function boxSpanM(innerWidthM: number): number { + return innerWidthM + 2 * FORD_WALL_THICKNESS_M; +} + +/** 날개벽이 만드는 바닥판 연장량 = 길이 × cos(벌어짐각). 안 세우면 0. */ +export function wingSlabExtendM( + installed: boolean, + lengthM: number | null | undefined, + angleDeg: number | null | undefined, +): number { + if (!installed) return 0; + const extend = (lengthM ?? 0) * Math.cos(((angleDeg ?? 45) * Math.PI) / 180); + return round(Math.max(extend, 0.0), 3); +} + +/** + * 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량. + * 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다. + */ +function wingSpec( + values: Record, + defaults: Record, + side: string, +): CulvertSetSpec { + const prefix = `wing_${side}`; + const install = values[prefix] ?? defaults[prefix]; + const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0)); + const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0)); + const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0)); + const installed = String(install ?? "").trim() !== "없음"; + return { + installed, + height_m: height, + length_m: length, + angle_deg: angle, + slab_extend_m: wingSlabExtendM(installed, length, angle ?? 0), + }; +} + +/** 세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). */ +function fordSet( + values: Record, + registry: CulvertRegistryDefaults, +): CulvertSetSpec { + const defaults = registry.ford_bridge ?? {}; + const diameterMm = num(values.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0)); + const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); + const count = num(values.pipe_count, num(defaults.pipe_count, null)); + const depth = num(values.ford_height_m, null); + return { + type: "ford", + pipe_kind: text(values.pipe_kind) ?? text(defaults.pipe_kind), + diameter_m: pipeDiameterM(diameterMm), + pipe_count: count ? Math.max(Math.trunc(count), 1) : 1, + span_m: width && width > 0 ? width : FORD_DEFAULT_WIDTH_M, + // 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다. 없으면 0 = 내리지 않는다. + overflow_depth_m: depth && depth > 0 ? depth : 0.0, + slab_thickness_m: FORD_SLAB_THICKNESS_M, + wall_thickness_m: FORD_WALL_THICKNESS_M, + min_cover_m: MIN_PIPE_COVER_M, + wing_in: wingSpec(values, defaults, "in"), + wing_out: wingSpec(values, defaults, "out"), + }; +} + +/** 물넘이포장 1개소 — 구조물이 아니라 **파인 노면**이라 형상이 다르다. */ +function fordPavementSet( + values: Record, + registry: CulvertRegistryDefaults, +): CulvertSetSpec { + const defaults = registry.ford_pavement ?? {}; + const width = num(values.ford_width_m, num(defaults.ford_width_m, null)); + const depth = num(values.ford_height_m, null); + return { + type: "ford_pavement", + span_m: width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, + // 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다). + depth_m: depth && depth > 0 ? depth : null, + slope_pct: num(values.ford_slope_pct, null), + }; +} + +/** BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). */ +function boxSet( + values: Record, + registry: CulvertRegistryDefaults, +): CulvertSetSpec { + const defaults = registry.box_culvert ?? {}; + const innerWidth = num(values.body_width_m, num(defaults.body_width_m, 2.0)); + const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0)); + const wall = FORD_WALL_THICKNESS_M; + const slab = FORD_SLAB_THICKNESS_M; + return { + type: "box", + inner_width_m: innerWidth || 2.0, + inner_height_m: innerHeight || 2.0, + wall_thickness_m: wall, + slab_thickness_m: slab, + top_thickness_m: slab, + cover_m: BOX_COVER_M, + span_m: boxSpanM(innerWidth || 2.0), + wing_in: wingSpec(values, defaults, "in"), + wing_out: wingSpec(values, defaults, "out"), + }; +} + +/** 관 지점 목록 → 누가거리(소수 2자리)별 세트 제원. */ +export function buildCulvertSets( + points: readonly CulvertPipePoint[], + registry: CulvertRegistryDefaults, +): Map { + const sets = new Map(); + for (const point of points) { + const chainage = num(point.chainage_m, null); + if (chainage === null) continue; + const values = (point.options ?? {}) as Record; + const facility = point.facility || FACILITY_PIPE; + let spec: CulvertSetSpec; + if (facility === FACILITY_FORD_BRIDGE) spec = fordSet(values, registry); + else if (facility === FACILITY_BOX) spec = boxSet(values, registry); + else if (facility === FACILITY_FORD_PAVEMENT) spec = fordPavementSet(values, registry); + else if (facility === FACILITY_REVET) spec = revetSet(values); + else spec = culvertSet(values, registry); + sets.set(round(chainage, 2), spec); + } + return sets; +} + +/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */ +export function attachCulvertSets( + sections: Array>, + sets: ReadonlyMap, +): number { + if (sets.size === 0) return 0; + let attached = 0; + for (const section of sections) { + const chainage = num(section.chainage_m, null); + if (chainage === null) continue; + for (const [pipeChainage, spec] of sets) { + // 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다. + let reach = CHAINAGE_TOLERANCE_M; + if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2; + if (Math.abs(chainage - pipeChainage) <= reach) { + section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = spec; + attached += 1; + break; + } + } + } + return attached; +} + +/** 레지스트리 응답(타입 목록) → 기본값 표. 옵션 정의가 유일한 기본값 출처다. */ +export function registryDefaults( + types: ReadonlyArray<{ + type_id: string; + options: ReadonlyArray<{ key: string; default: unknown }>; + }>, +): CulvertRegistryDefaults { + const table: CulvertRegistryDefaults = {}; + for (const type of types) { + const entry: Record = {}; + for (const option of type.options) entry[option.key] = option.default; + table[type.type_id] = entry; + } + return table; +} diff --git a/common_util/common_util_drainage_context.py b/common_util/common_util_drainage_context.py index 93ba4771..662be1ed 100644 --- a/common_util/common_util_drainage_context.py +++ b/common_util/common_util_drainage_context.py @@ -27,17 +27,17 @@ from B05_Profile.B05_Profile_Repository import ( get_surface_crs_epsg, ) from B06_Section.B06_Section_Repository import get_longitudinal_section +from common_util.common_util_crs import resolve_project_crs from common_util.common_util_route_geometry import ( RouteVertex, build_route_vertices, load_design_route, ) from common_util.common_util_route_profile import Z_SOURCE_CSV, resolve_route_profile -from common_util.common_util_crs import resolve_project_crs from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_surface_sampler import build_surface_sampler -from config.config_db import get_db_pool +from config.config_db import run_with_connection logger = logging.getLogger(__name__) @@ -63,37 +63,52 @@ class DrainageContext: surface_params: dict[str, Any] = field(default_factory=dict) +# 「자기 커넥션으로 하나씩 돌려 `gather` 로 묶는다」는 정의는 `config_db` 한 곳에 둔다. +_query = run_with_connection + + async def load_drainage_context(project_id: UUID) -> tuple[DrainageContext | None, str]: """노선·종단 Z·좌표계를 준비한다. 실패하면 (None, 사용자에게 보일 사유). B05 확정 경로는 **있으면 쓰고 없으면 넘어간다** — B04는 WF1 화면이라 아직 경로가 없다. """ - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - if not stored_path: - return None, "프로젝트 저장 경로가 없습니다." - route = await get_latest_route(connection, project_id) - route_points: list[dict[str, Any]] = [] - longitudinal: dict[str, Any] | None = None - if route: - route_points = await get_route_points(connection, int(route["id"])) - section = await get_longitudinal_section(connection, project_id, int(route["id"])) - longitudinal = (section or {}).get("data") - surface_model_id = (route or {}).get("surface_model_id") - db_epsg = await get_surface_crs_epsg( - connection, project_id, int(surface_model_id) if surface_model_id else 0 + # DB 가 원격이라 질의 하나가 곧 왕복 12ms 다(2026-09-06 실측: 6건 순차 130ms). + # 서로 기다릴 이유가 없는 것끼리 묶어 두 묶음으로 보낸다 — 값은 그대로고 왕복만 겹친다. + stored_path, route, surface_params = await asyncio.gather( + _query(get_project_storage_relative_path, project_id), + _query(get_latest_route, project_id), + _query(get_surface_confirmation_params, str(project_id)), + ) + if not stored_path: + return None, "프로젝트 저장 경로가 없습니다." + + route_points: list[dict[str, Any]] = [] + longitudinal: dict[str, Any] | None = None + surface_model_id = (route or {}).get("surface_model_id") + if route: + route_points, section, db_epsg = await asyncio.gather( + _query(get_route_points, int(route["id"])), + _query(get_longitudinal_section, project_id, int(route["id"])), + _query( + get_surface_crs_epsg, + project_id, + int(surface_model_id) if surface_model_id else 0, + ), ) - surface_params = await get_surface_confirmation_params(connection, str(project_id)) + longitudinal = (section or {}).get("data") + else: + db_epsg = await _query(get_surface_crs_epsg, project_id, 0) project_root = Path(resolve_stored_project_path(stored_path)) # 설계 계통과 **같은 노선**을 쓴다 — 지표면 밖 구간을 자른 뒤의 노선이다. 원본을 그대로 # 쓰면 유역·관이 확정 노선 밖에도 찍혀 종단 계획선이 그 관을 버린다(2026-09-01). - planned = await asyncio.to_thread(_read_planned_route, project_root, surface_params) + planned, sampler = await asyncio.gather( + asyncio.to_thread(_read_planned_route, project_root, surface_params), + asyncio.to_thread(_open_sampler, project_root, surface_params), + ) if planned is None or len(planned.vertices) < 2: return None, "계획노선을 읽지 못했습니다. B03에서 노선 파일을 확인하세요." - sampler = await asyncio.to_thread(_open_sampler, project_root, surface_params) vertices, z_source = await asyncio.to_thread( resolve_route_profile, planned.vertices, diff --git a/common_util/common_util_drainage_detail.py b/common_util/common_util_drainage_detail.py index 1b9413bb..8e2a2f40 100644 --- a/common_util/common_util_drainage_detail.py +++ b/common_util/common_util_drainage_detail.py @@ -69,6 +69,9 @@ from config.config_system import ( logger = logging.getLogger(__name__) +# 마지막으로 읽은 격자 산출물 — {npz 경로: (파일 자국, 읽은 결과)}. `read_road_routing` 참조. +_routing_cache: dict[str, tuple[tuple[tuple[float, int], ...], "RoadRouting"]] = {} + # 관 위치 선정 점수 배분 — 흐름 강도가 주, 종단 저점이 보조. _SCORE_WEIGHT_STRENGTH = 0.7 _SCORE_WEIGHT_SAG = 0.3 @@ -230,12 +233,21 @@ def build_detail( def read_road_routing(directory: Path) -> RoadRouting | None: - """`03_road_routing` 산출물을 읽는다. 없으면 None.""" + """`03_road_routing` 산출물을 읽는다. 없으면 None. + + 관을 하나 옮길 때마다 같은 파일(3.1MB)을 다시 읽어 45ms 를 썼다(2026-09-06 실측). + 격자는 B04 분석이 다시 돌 때만 바뀌므로 **파일이 그대로면 앞서 읽은 것을 그대로 쓴다** — + 파일 자국(수정시각·크기)이 열쇠라 분석이 다시 돌면 저절로 새로 읽는다. + """ prefix = STAGES["road_routing"] array_path = directory / f"{prefix}_road_routing.npz" if not array_path.exists(): logger.warning("배수유역: B04 분석 결과가 없습니다 (%s).", array_path) return None + cached = _routing_cache.get(str(array_path)) + stamp = _file_stamp(array_path, directory / f"{prefix}_road_routing.geojson") + if cached is not None and cached[0] == stamp: + return cached[1] try: with np.load(array_path, allow_pickle=False) as data: spec = GridSpec( @@ -266,9 +278,24 @@ def read_road_routing(directory: Path) -> RoadRouting | None: routing.road_cell_index.size, len(routing.base_pipes), ) + # 프로젝트를 오가도 자국이 다르면 새로 읽으므로 한 벌만 들고 있으면 충분하다. + _routing_cache.clear() + _routing_cache[str(array_path)] = (stamp, routing) return routing +def _file_stamp(*paths: Path) -> tuple[tuple[float, int], ...]: + """파일들의 (수정시각, 크기) — 하나라도 바뀌면 값이 달라진다. 없는 파일은 (0, 0).""" + marks = [] + for path in paths: + try: + info = path.stat() + marks.append((info.st_mtime, info.st_size)) + except OSError: + marks.append((0.0, 0)) + return tuple(marks) + + def _read_geometry(path: Path, routing: RoadRouting) -> None: """계획도로선·2차 유역 외곽선·기본 관을 GeoJSON에서 읽어 채운다.""" if not path.exists(): diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index b27ac82a..247572f4 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -160,6 +160,38 @@ def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) return points +# 같은 노선으로 볼 누가거리 어긋남의 한계(m). +# +# 지문(`route_signature`)은 좌표를 **0.01m 자리에서 끊어** 해시한다. 그런데 같은 노선이 +# `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한 +# 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 +# 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다. +# +# 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신 +# 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측 +# **최대 0.01m** 이라 다섯 배 여유를 두었고, 사람이 노선을 실제로 고치면 관은 **m 단위**로 +# 밀리므로 그것을 「같다」로 볼 위험은 없다. +ROUTE_MATCH_TOLERANCE_M = 0.05 + + +def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) -> float | None: + """저장된 관을 이 노선에 투영하면 누가거리가 최대 얼마나 움직이나 (**고치지 않고 잰다**). + + 좌표가 없는 관이 하나라도 있으면 잴 수 없어 None. + """ + if not vertices or not points: + return None + if any(point.x is None or point.y is None for point in points): + return None + line = LineString([(vertex.x, vertex.y) for vertex in vertices]) + if line.length <= 0: + return None + return max( + abs(float(line.project(Point(point.x, point.y))) - float(point.chainage_m)) + for point in points + ) + + def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다. @@ -214,10 +246,28 @@ def load_pipe_points_file( stored_signature = str(document.get("route_signature") or "") if stored_signature == signature: return points - if vertices and points and all(p.x is not None and p.y is not None for p in points): + # 지문이 다르다고 노선이 바뀐 것은 아니다 — 같은 노선을 두 파일이 0.5mm 다르게 담고 + # 있어 글자가 늘 어긋난다(위 `ROUTE_MATCH_TOLERANCE_M` 주석). 관이 실제로 얼마나 + # 밀리는지 **재 보고** 한계 안이면 저장분을 그대로 쓴다 — 건드리지 않는 것이 정답이다. + shift = max_projection_shift(points, vertices) if vertices else None + if shift is not None and shift <= ROUTE_MATCH_TOLERANCE_M: logger.info( - "배수유역: 노선이 바뀌어 관 지점 %d건을 좌표로 이월합니다 (%s).", + "배수유역: 지문은 다르나 같은 노선입니다 — 관 %d건 그대로 씁니다 " + "(최대 어긋남 %.4fm ≤ %.2fm, %s).", len(points), + shift, + ROUTE_MATCH_TOLERANCE_M, + path.name, + ) + return points + if vertices and points and all(p.x is not None and p.y is not None for p in points): + # ⚠ 이 줄이 찍히면 **투영 이월이 실제로 돈 것**이다. 한동안 0 인 것을 확인한 뒤에야 + # 이 가지를 지울 수 있다(계획서 0-7 — 먼저 지우면 관이 통째로 사라진다). + logger.warning( + "배수유역: 투영 이월 실행 — 노선이 바뀌어 관 지점 %d건을 좌표로 옮깁니다 " + "(최대 어긋남 %s, %s).", + len(points), + f"{shift:.3f}m" if shift is not None else "잴 수 없음", path.name, ) return project_pipe_points(points, vertices) diff --git a/common_util/common_util_json.py b/common_util/common_util_json.py index 912e59dc..23033eed 100644 --- a/common_util/common_util_json.py +++ b/common_util/common_util_json.py @@ -54,3 +54,37 @@ def atomic_write_json(path: str | Path, value: Any) -> None: finally: if temporary_path is not None: temporary_path.unlink(missing_ok=True) + + +# ───────────────────────────────────────────────────────────────────────── +# 내려보낼 때 자릿수 줄이기 (2026-09-06 사용자 지시) +# ───────────────────────────────────────────────────────────────────────── +# 저장된 값에는 뜻 없는 자릿수가 잔뜩 붙어 있다 — `1.4000000000000001` 꼴로 적혀 있어 +# 자릿수만 정리해도 등고선 도엽이 65.9MB → 36.8MB 로 줄었다(실측). 압축까지 얹으면 11.3MB. +# +# **저장 파일은 그대로 두고 내려보낼 때만** 줄인다 — 납품 수량·도면이 쓰는 정본은 손대지 않는다. +# +# 자릿수의 뜻이 파일마다 다르다: +# · 위경도(도 단위) 7자리 = 1.1cm — 등고선 도엽·배수유역 +# · 미터 6자리 = 0.001mm — 종횡단 상세 +# ⚠ **브라우저가 받아 다시 계산에 넣는 값은 줄이면 안 된다.** 종횡단 상세의 지반선이 그런 +# 자리다 — 반올림한 값으로 브라우저가 계산하면 파이썬 짝과 면적이 갈린다(거울 테스트 대상). +# 6자리는 무시할 만하지만 3자리(1mm)는 안 된다. +LONLAT_DIGITS = 7 +METRE_DIGITS = 6 + + +def round_floats(value: Any, digits: int) -> Any: + """중첩된 목록·사전 안의 실수를 모두 `digits` 자리로 반올림한 **새 값**을 돌려준다. + + 원본은 건드리지 않는다. 정수·문자열·None 은 그대로 지난다. + """ + if isinstance(value, float): + return round(value, digits) + if isinstance(value, list): + return [round_floats(item, digits) for item in value] + if isinstance(value, tuple): + return tuple(round_floats(item, digits) for item in value) + if isinstance(value, dict): + return {key: round_floats(item, digits) for key, item in value.items()} + return value diff --git a/common_util/common_util_mass_haul.css b/common_util/common_util_mass_haul.css index 138dd9da..74b2e127 100644 --- a/common_util/common_util_mass_haul.css +++ b/common_util/common_util_mass_haul.css @@ -371,3 +371,41 @@ border-top-color: var(--color-warning); border-top-style: dashed; } + +/* ── 최종 누가토량 배지 (2026-09-06 사용자 지시) ────────────────────────── + 종단 그래프 좌측 상단에 겹쳐 띄운다. 유토곡선을 접거나(B05) 아예 빼도(B06) + 이 값은 늘 보여야 한다. 그래프 조작을 막지 않도록 이벤트는 통과시키되, + 툴팁은 읽혀야 하므로 배지 자신만 이벤트를 받는다. */ +.mass-haul-badge { + position: absolute; + top: var(--mass-haul-badge-top, 6px); + /* Y축 띠(고정 축) 오른쪽에 놓는다 — 8px 로 두면 축 라벨 위에 겹쳐 값이 잘린다 + (2026-09-06 실측). 축 폭이 다른 화면은 이 변수를 덮어쓴다. */ + left: var(--mass-haul-badge-left, 86px); + z-index: 4; + display: inline-flex; + align-items: center; + gap: var(--spacing-4, 4px); + padding: 2px var(--spacing-8, 8px); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills, 999px); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); + color: var(--color-text-body); + font-size: var(--text-caption); + line-height: 1.3; + white-space: nowrap; + pointer-events: auto; +} + +.mass-haul-badge em { + color: var(--color-text-secondary); + font-style: normal; +} + +.mass-haul-badge strong { + font-weight: var(--font-weight-bold, 700); +} + +.mass-haul-badge.is-over strong { + color: var(--color-danger, #f43f5e); +} diff --git a/common_util/common_util_mass_haul.ts b/common_util/common_util_mass_haul.ts index 10272729..e152f45b 100644 --- a/common_util/common_util_mass_haul.ts +++ b/common_util/common_util_mass_haul.ts @@ -40,8 +40,6 @@ import type { MassHaulLongitudinal, MassHaulSection, } from "./common_util_mass_haul_types"; -import type { HaulPlan } from "./common_util_mass_haul_balance"; -import { haulPlanPayload } from "./common_util_mass_haul_balance"; /** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */ export interface GroundVolumes { @@ -445,15 +443,18 @@ export function computeMassHaulSeries( crossSections: MassHaulSection[], conversion: EarthworkConversion, naturalSpoilMinSlope?: number, + /** 낼 기준. 총괄값(배지)만 필요하면 `["cross"]` 로 불러 종단 기준 적분을 건너뛴다. */ + bases: readonly MassHaulBasis[] = SERIES_BASES, ): MassHaulSeries[] { const sections = designedSections(crossSections); - const sampleSets: Record = { - cross: crossAreaSamples(sections, naturalSpoilMinSlope), - longitudinal: longitudinalAreaSamples(longitudinal, sections), - }; + const samplesFor = (basis: MassHaulBasis): AreaSample[] => + basis === "cross" + ? crossAreaSamples(sections, naturalSpoilMinSlope) + : longitudinalAreaSamples(longitudinal, sections); const series: MassHaulSeries[] = []; for (const basis of SERIES_BASES) { - const result = integrate(sampleSets[basis], conversion); + if (!bases.includes(basis)) continue; + const result = integrate(samplesFor(basis), conversion); if (result) series.push({ key: basis, basis, result }); } return series; @@ -461,18 +462,21 @@ export function computeMassHaulSeries( /** * 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. - * 토량 분배(평형선)까지 냈으면 `haul_plan`으로 함께 실어 B08 수량·B09 견적이 되받게 한다. + * + * **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고 + * 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다. + * 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다. */ export function massHaulPayload( result: MassHaulResult, - haulPlan?: HaulPlan | null, + extra?: Record | null, balloonOffsets?: Record, ): Record { const round = (value: number): number => Math.round(value * 100) / 100; return { basis: "compacted", conversion: result.conversion, - ...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}), + ...(extra ?? {}), // 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다). ...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}), cut_natural_m3: { diff --git a/common_util/common_util_mass_haul_badge.ts b/common_util/common_util_mass_haul_badge.ts new file mode 100644 index 00000000..f8702813 --- /dev/null +++ b/common_util/common_util_mass_haul_badge.ts @@ -0,0 +1,86 @@ +/* ============================================================================= + * common_util_mass_haul_badge.ts + * 「최종 누가토량」 배지 — 종단 그래프 **좌측 상단에 겹쳐** 띄우는 한 줄. + * + * 왜 배지인가(2026-09-06 사용자 지시) — 유토곡선 그래프는 자리를 많이 먹는데, 계획선을 + * 만지며 늘 봐야 하는 값은 **마지막 지점의 누가토량 하나**다. 그래서 그 값만 그래프 위에 + * 겹쳐 두고, 곡선 자체는 B05 의 유토곡선 패널에서만 펼쳐 본다(B06 에서는 곡선을 뺐다). + * + * 기준은 **횡단**이다 — 종단 기준 곡선은 계획선만 보는 개략값이라 뒤에 없앨 예정이다 + * (2026-09-06 사용자). 값을 넣는 쪽에서 횡단 기준 계열을 골라 넘긴다. + * ========================================================================== */ + +/** 배지에 실리는 값 한 벌 — 유토곡선 계산 결과에서 뽑는다. */ +export interface MassHaulBadgeValues { + /** 마지막 측점의 누계 토량(㎥). 음수면 부족(토취). */ + finalM3: number; + cutNaturalM3: number; + cutCompactedM3: number; + fillCompactedM3: number; + surplusM3: number; + shortageM3: number; +} + +/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */ +function volume(value: number): string { + return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}㎥`; +} + +export interface MassHaulBadge { + /** 그래프 컨테이너(`position: relative`)에 붙일 요소. */ + root: HTMLElement; + /** 값을 갈아 끼운다. null 이면 배지를 숨긴다(아직 계산 못 한 상태). */ + set: (values: MassHaulBadgeValues | null) => void; +} + +/** 유토곡선 계산 결과 한 벌에서 배지 값을 뽑는다 — B05·B06 이 같은 규칙을 쓴다. */ +export function badgeValuesFrom(result: { + points: ReadonlyArray<{ cumulative_volume_m3: number }>; + cut_natural_m3: { soil: number; ripping_rock: number; blasting_rock: number }; + cut_compacted_m3: number; + fill_compacted_m3: number; + surplus_m3: number; + shortage_m3: number; +}): MassHaulBadgeValues { + const cut = result.cut_natural_m3; + const points = result.points; + return { + finalM3: points.length ? points[points.length - 1].cumulative_volume_m3 : 0, + cutNaturalM3: cut.soil + cut.ripping_rock + cut.blasting_rock, + cutCompactedM3: result.cut_compacted_m3, + fillCompactedM3: result.fill_compacted_m3, + surplusM3: result.surplus_m3, + shortageM3: result.shortage_m3, + }; +} + +/** 배지를 만든다. 붙이는 것은 부르는 쪽 몫이다(그래프 컨테이너 첫 자식). */ +export function createMassHaulBadge(): MassHaulBadge { + const root = document.createElement("span"); + root.className = "mass-haul-badge"; + root.hidden = true; + const caption = document.createElement("em"); + caption.textContent = "누가토량"; + const value = document.createElement("strong"); + root.append(caption, value); + return { + root, + set(values) { + if (!values) { + root.hidden = true; + return; + } + root.hidden = false; + root.classList.toggle("is-over", values.finalM3 < 0); + value.textContent = volume(values.finalM3); + root.title = [ + `절토(자연) ${volume(values.cutNaturalM3)} · 절토(다짐) ${volume(values.cutCompactedM3)}`, + `성토(다짐) ${volume(values.fillCompactedM3)}`, + values.shortageM3 > 0 + ? `부족 ${volume(values.shortageM3)}` + : `잉여 ${volume(values.surplusM3)}`, + "기준: 횡단", + ].join("\n"); + }, + }; +} diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 4ef8c6fc..06149fb2 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -51,6 +51,11 @@ export interface MassHaulAxis { viewRange?: { fromM: number; toM: number }; /** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */ window?: MassHaulWindowState; + /** + * 측점 세로선을 그리지 않는다. 곡선 전체를 한 화면에 눌러 담는 보기(토량 분배)에서는 + * 측점선이 촘촘해 곡선을 덮기만 하고 자리도 못 읽는다(2026-09-06 사용자 지시). + */ + hideStations?: boolean; } /** @@ -535,7 +540,7 @@ export function createMassHaulChart( // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화). // 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지). - for (const station of longitudinal.stations) { + for (const station of axis.hideStations ? [] : longitudinal.stations) { const stationX = x(station.chainage_m); const selected = station.station_id === selectedStationId; const marker = svgElement("g", { diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py new file mode 100644 index 00000000..4e85515c --- /dev/null +++ b/common_util/common_util_node_bundle.py @@ -0,0 +1,103 @@ +"""브라우저용 TS 를 **서버에서 그대로 실행**하기 위한 공통 배관(2026-09-06 분리). + +CLAUDE.md 5장 「계산 자리」 — 같은 계산을 파이썬으로 다시 짜지 않고, 화면이 쓰는 TS 를 +Node 진입점으로 감싸 서버가 부른다. 코리도(`B05_Profile_Corridor_Prebuild`)가 첫 사례고 +구조물 면적(`B06_Section_Structure_Areas_Prebuild`)이 뒤따르면서, 번들 빌드·실행 배관이 +두 벌이 되어 여기로 모았다. **계산은 이 파일에 없다** — 실행 껍데기만 있다. +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +# 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더. +SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util") +# 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. +BUILD_TIMEOUT_S = 300 +RUN_TIMEOUT_S = 600 + + +def node_env() -> dict[str, str]: + """config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약).""" + env = os.environ.copy() + node_modules = ROOT / "config" / "node_modules" + env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}" + env["NODE_PATH"] = str(node_modules) + return env + + +def bundle_stale(bundle: Path) -> bool: + """번들이 없거나 TS 원본보다 오래됐으면 참. + + 번들이 낡으면 서버와 화면이 **다른 값**을 만든다 — 이 판정이 그것을 막는 유일한 + 장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다. + """ + if not bundle.is_file(): + return True + built_at = bundle.stat().st_mtime + for directory in SOURCE_DIRS: + for path in (ROOT / directory).rglob("*.ts"): + if path.stat().st_mtime > built_at: + return True + return False + + +def build_bundle(npm_script: str) -> bool: + result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음 + f"npm run {npm_script}", + shell=True, + cwd=str(ROOT), + env=node_env(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=BUILD_TIMEOUT_S, + ) + if result.returncode != 0: + logger.error("Node 번들 빌드 실패(%s):\n%s", npm_script, result.stderr) + return False + return True + + +def run_node(bundle: Path, input_path: Path, output_path: Path) -> int: + result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐 + ["node", str(bundle), str(input_path), str(output_path)], + cwd=str(ROOT), + env=node_env(), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=RUN_TIMEOUT_S, + ) + if result.returncode != 0: + logger.warning( + "Node 실행 실패(%s, 끝 코드 %s): %s", bundle.name, result.returncode, result.stderr + ) + return result.returncode + + +def run_bundle_json(bundle: Path, npm_script: str, payload: dict[str, Any]) -> Any | None: + """입력을 JSON 으로 넘겨 실행하고 결과 JSON 을 돌려준다. 실패는 None. + + 결과가 작을 때만 쓸 것 — 코리도처럼 큰 산출물은 파일로 받아 그대로 옮겨야 한다. + """ + if bundle_stale(bundle) and not build_bundle(npm_script): + return None + with tempfile.TemporaryDirectory(prefix="node_bundle_") as workdir: + source = Path(workdir) / "input.json" + result = Path(workdir) / "output.json" + source.write_text(json.dumps(payload, default=float), encoding="utf-8") + if run_node(bundle, source, result) != 0: + return None + return json.loads(result.read_text(encoding="utf-8")) diff --git a/common_util/common_util_project_delete.py b/common_util/common_util_project_delete.py index 3008e4a2..7aa318bb 100644 --- a/common_util/common_util_project_delete.py +++ b/common_util/common_util_project_delete.py @@ -8,14 +8,16 @@ import logging import shutil +from typing import Any +from common_util.common_util_audit import record_audit from common_util.common_util_storage import resolve_project_root_for_delete from config.config_db import get_db_pool logger = logging.getLogger(__name__) -async def hard_delete_project(project_id: str, actor_id: int) -> bool: +async def hard_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool: """프로젝트를 DB와 영구저장소에서 완전히 지운다. 되돌릴 수 없다. 자식 테이블은 나열하지 않는다 — `projects.id`를 참조하는 테이블이 전부 @@ -60,10 +62,13 @@ async def hard_delete_project(project_id: str, actor_id: int) -> bool: await connection.rollback() return False # 감사 기록은 프로젝트가 사라진 뒤에도 남는다. resource_id는 FK가 없어 고아가 되지 않는다. - await cursor.execute( - """INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id) - VALUES (%s, 'PROJECT_HARD_DELETE', 'project', NULL)""", - (actor_id,), + await record_audit( + cursor, + actor_id=actor_id, + action="PROJECT_HARD_DELETE", + resource_type="project", + resource_ref=project_id, + request=request, ) await connection.commit() diff --git a/common_util/common_util_route_geometry.py b/common_util/common_util_route_geometry.py index aa1a297c..16de9b3a 100644 --- a/common_util/common_util_route_geometry.py +++ b/common_util/common_util_route_geometry.py @@ -212,6 +212,55 @@ def find_planned_route_file(input_dir: Path) -> Path | None: return None +def expected_route_csv_path(project_root: Path) -> Path: + """예상노선(원본) CSV 자리 — 자동 체인이 낸 노선을 **그대로** 보관한다. + + 초기값 스냅샷(`initial_snapshot/`) 안에도 같은 CSV가 있지만 그 폴더는 재확정 체인이 + 통째로 지운다(`discard_initial_snapshot`). 노선 초기화는 언제나 예상노선으로 돌아갈 수 + 있어야 하므로 스냅샷 **밖**에 한 벌 둔다(2026-09-06). + """ + return Path(project_root) / "B05_Profile" / "route" / "expected_route.csv" + + +def write_route_csv(path: Path, points: list[dict[str, float]]) -> None: + """노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.writer(file) + writer.writerow(("sequence", "x", "y")) + writer.writerows( + (index, round(float(point["x"]), 4), round(float(point["y"]), 4)) + for index, point in enumerate(points) + ) + + +def planned_route_working_path(project_root: Path) -> Path: + """계획노선(수정본) CSV 자리 — 사용자가 노선을 고치면 여기에 쓴다. + + 노선은 두 벌이다(2026-09-06 사용자 확정): **예상노선**(원본, + `initial_snapshot/planned_route.csv`)은 자동 체인이 한 번 쓰고 안 바뀌며, + **계획노선**(수정본)은 예상노선과 같은 값으로 시작해 사용자가 고쳐 쓴다. + 설계 계통은 수정본이 있으면 그것을 읽는다 — 노선 초기화는 이 파일을 지우는 것이며, + 그것이 곧 「원본을 수정본으로 복사」와 같다. + """ + return Path(project_root) / "B05_Profile" / "route" / "planned_route.csv" + + +def planned_route_initial_path(project_root: Path) -> Path: + """계획노선 **초기 폴리라인** 자리 — 예상노선(점 묶음)을 폴리라인으로 바꾼 한 벌. + + 왜 한 벌 더 두나(2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 **점 묶음**이라 + 그대로는 설계선이 못 된다. 계획노선은 「원본을 복사해 폴리라인으로 바꾼 것」이며 그것이 + **불변의 초기 데이터**다. 노선 초기화는 수정본을 지워 이 파일로 돌아가는 것이다. + + 세 벌의 관계 — + · `expected_route.csv` 예상노선(원본 점 묶음, 불변) + · `planned_route_initial.csv` 그것을 폴리라인화한 것(**불변 초기 데이터**) + · `planned_route.csv` 사용자가 고친 수정본(있으면 이것이 설계 노선) + """ + return Path(project_root) / "B05_Profile" / "route" / "planned_route_initial.csv" + + def load_design_route( project_root: Path, surface_params: dict[str, Any] | None = None, @@ -245,8 +294,17 @@ def load_design_route( # 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 — # surface_params 없음)은 여기를 타지 않는다. if surface_params: - master = design_route_csv_path(project_root) - if master.is_file(): + # 읽는 순서 — 수정본 → **초기 폴리라인** → 예상노선(점 묶음) → 초기값 스냅샷. + # 초기 폴리라인이 예상노선보다 앞선다: 예상노선은 점 묶음이라 그대로 이으면 + # 규칙 없는 선이 된다(2026-09-06 사용자 지시). + for master in ( + planned_route_working_path(project_root), + planned_route_initial_path(project_root), + expected_route_csv_path(project_root), + design_route_csv_path(project_root), + ): + if not master.is_file(): + continue stored = read_planned_route_csv(master) if stored is not None and len(stored.vertices) >= 2: return replace_vertices( diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py new file mode 100644 index 00000000..8224bc9d --- /dev/null +++ b/common_util/common_util_route_polyline.py @@ -0,0 +1,745 @@ +"""예상노선(점 묶음)을 **계획노선 폴리라인**으로 바꾸는 자리. + +왜 필요한가(2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 **점(포인트)으로 이뤄진 +데이터**이고 규칙 없는 폴리라인과도 맞지 않는다. 그래서 계획노선은 **원데이터를 복사한 뒤 +폴리라인으로 바꾼 것**이어야 하고, 그것이 **불변의 초기 데이터**가 된다. 유토곡선·3D 에 +투영되는 선도, 사용자가 노드를 잡아 고치는 대상도 이 폴리라인이다. + +**곡선 기준은 지식DB 값**(`resources/knowledge/technical_info/01_임도/02_상세설계/평면선형.md`, +근거는 산림자원법 시행규칙 별표2 Ⅰ.2.다) — 코드에서는 `config_system_design` 이 그대로 들고 있다. + · 최소곡선반지름 — 설계속도 40: 일반 60 / 특수 40 · 30: 30 / 20 · 20: 15 / 12 (중심선 기준) + · 배향곡선 중심선 반지름 10m 이상 + · **내각 155° 이상**(교각 25° 이하)이면 곡선을 두지 않을 수 있음 + +**하는 일은 「모양 정리」뿐이다** — 점을 옮기지 않는다. 꺾이는 점(IP)을 그대로 두고 그 자리에 +원호를 끼워 넣어 매끄럽게 잇는다. 원호가 들어갈 자리(접선 길이)가 모자라면 반지름을 줄여 +맞추고, 법정 하한 아래로 내려가면 **줄이되 위반으로 표시**한다 — 자동으로 점을 옮겨 +「고쳐 주지」 않는다(2026-09-06 사용자 확정: 자동 보정·차단은 하지 않고 경고만). +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +# 같은 자리로 볼 점 사이 거리(m) — 이보다 가까우면 뒤엣것을 버린다. 원본 점군에 중복· +# 미세 진동이 섞여 있으면 내각이 튀어 없는 곡선이 생긴다. +DUPLICATE_TOLERANCE_M = 0.5 + +# 꺾임점(IP)을 뽑는 단순화 허용오차(m). 예상노선은 격자 탐색이 낸 **조밀한 점군**이라 +# (용화 실측: 1,097m 에 331점 = 약 3.3m 간격) 점마다 곡선을 끼우면 접선 자리가 1.5m 밖에 +# 안 나와 반지름이 2~6m 로 뭉개진다. 격자 해상도(`ROUTE_GRID_RES_M` 2.0m)의 두 배로 잡아 +# 계단 모양만 걷어내고 실제 굴곡은 남긴다. +SIMPLIFY_TOLERANCE_M = 4.0 + +# 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m). +# +# 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고 +# 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아 +# 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다. +# 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만** +# 골라 허용오차를 절반으로 낮춰 다시 뽑는다. +MAX_NODE_SPACING_M = 100.0 + +# 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m). +MAX_REFINE_DEPTH = 3 + +# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다. +ARC_STEP_DEG = 5.0 + +# 별표2 가 「곡선을 두지 않을 수 있다」고 하는 내각(도) — **지금은 쓰지 않는다.** +# +# 왜 안 쓰나(2026-09-07 사용자 지시) — 「내각 155 이상이면 곡선 생략은 반영하지 말자. +# 사용자가 계획 평면 노선을 수정할 때 문제가 될 것 같아.」 곡선이 있다 없다 하면 잡는 +# 손잡이도 있다 없다 하고, 조금 폈다는 이유로 곡선이 사라지면 되돌릴 길이 없다. 그래서 +# **꺾이는 자리에는 늘 곡선을 둔다.** 값은 법정 근거를 잃지 않게 남겨만 둔다. +STRAIGHT_INNER_ANGLE_DEG = 155.0 + +# 한 곡선으로 **묶을지** 볼 때 쓰는 문턱(도) — 위 155° 와 쓰임이 다르다. +# +# 왜 나눴나(2026-09-07 실측) — 155° 를 묶기 판정에까지 쓰면 **완만한 곡선이 쪼개진다**. +# 반지름 40m 짜리 원호를 점으로 흉내 내 넣으니 꺾임점 셋(내각 156°·138°·154°)이 나왔는데 +# 양 끝 둘이 155° 위라 「직선」으로 갈려 가운데 하나만 곡선이 됐고, 그 짧은 현 사이에서는 +# 반지름을 키울수록 원본에서 멀어졌다(평균 1.75 → 2.19m). 별표2 의 155° 는 **그 곡선의 +# 교각**에 대한 규칙이지, 점군을 쪼갠 조각 하나하나에 대한 규칙이 아니다. +# 그래서 묶을 때는 「조금이라도 같은 쪽으로 돈다」로 모으고, 155° 는 **묶은 뒤 전체 교각**에 +# 적용한다. +CURVE_GROUP_INNER_ANGLE_DEG = 179.0 + +# 여러 꺾임을 한 곡선으로 묶을 때 **허용할 최대 벗어남**(m). 평균이 나아져도 한 자리가 +# 이보다 크게 벌어지면 안 묶고 낱개로 둔다. 꺾임점 뽑기 허용오차(4m)의 두 배 — 노드 자체가 +# 원본에서 4m 안에 있으므로, 곡선이 그보다 크게 벗어나면 다른 모양이 된 것이다. +MERGE_MAX_GAP_M = 8.0 + + +@dataclass +class RouteNode: + """사용자가 잡아 옮기는 제어점 하나 = 원본 꺾임점(IP).""" + + x: float + y: float + inner_angle_deg: float | None = None + """직전·직후 구간이 이루는 내각(도). 끝점은 None.""" + radius_m: float | None = None + """이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리는 None.""" + tangent_m: float | None = None + """접선 길이(m) = R·tan(교각/2). 곡선을 안 둔 자리는 None.""" + violations: list[str] = field(default_factory=list) + """법정 기준 위반 표시 — 값은 넣되 막지 않는다.""" + + def as_dict(self) -> dict[str, Any]: + return { + "x": round(self.x, 4), + "y": round(self.y, 4), + "inner_angle_deg": None + if self.inner_angle_deg is None + else round(self.inner_angle_deg, 2), + "radius_m": None if self.radius_m is None else round(self.radius_m, 2), + "tangent_m": None if self.tangent_m is None else round(self.tangent_m, 2), + "violations": list(self.violations), + } + + +@dataclass +class RouteCurve: + """계획노선의 **곡선 성분 하나** — 편집·저장·도면이 모두 이 값을 본다. + + 사용자 확정(2026-09-07) — 계획노선은 「직선 > 곡선 > 직선」이고, 사용자가 잡는 것은 + **곡선 시작·끝점**(직선이 곡선에 닿는 자리)이며 필요하면 반지름을 직접 바꾼다. + 그러려면 정점 목록만으로는 모자라 이 성분을 정본에 남겨야 한다. + + · `apex` — 앞뒤 직선을 늘려 만나는 자리(교각점). **반지름을 바꿔도 여기는 안 움직인다** + (「곡선 반지름 변경 시 주변 직선 각도 구속」이 그 뜻이다 — 직선이 고정이므로 접선점만 + 미끄러진다). + · `start`·`end` — 곡선 시작·끝점. 사용자가 잡는 손잡이다. 끌면 그쪽 직선 각도와 + 반지름이 함께 바뀐다. + """ + + apex: tuple[float, float] + radius_m: float + tangent_m: float + inner_angle_deg: float + start: tuple[float, float] + end: tuple[float, float] + node_first: int + """이 곡선이 대신하는 꺾임점 구간(첫·끝) — 편집이 어느 노드를 건드리는지 알려 준다.""" + node_last: int + violations: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + return { + "apex": [round(self.apex[0], 4), round(self.apex[1], 4)], + "radius_m": round(self.radius_m, 3), + "tangent_m": round(self.tangent_m, 3), + "inner_angle_deg": round(self.inner_angle_deg, 2), + "start": [round(self.start[0], 4), round(self.start[1], 4)], + "end": [round(self.end[0], 4), round(self.end[1], 4)], + "node_first": self.node_first, + "node_last": self.node_last, + "violations": list(self.violations), + } + + +@dataclass +class PlannedPolyline: + """폴리라인화 결과. `nodes` 는 편집 대상, `vertices` 는 그리고 계산에 쓰는 선.""" + + nodes: list[RouteNode] + vertices: list[tuple[float, float]] + curves: list[RouteCurve] = field(default_factory=list) + """직선 사이에 놓인 곡선 성분들 — 순서대로. 편집 손잡이가 이것을 그린다.""" + + @property + def curve_count(self) -> int: + return len(self.curves) + + @property + def violation_count(self) -> int: + return sum(1 for node in self.nodes if node.violations) + + +def _distance(a: tuple[float, float], b: tuple[float, float]) -> float: + return math.hypot(b[0] - a[0], b[1] - a[1]) + + +def dedupe_points( + points: list[tuple[float, float]], tolerance_m: float = DUPLICATE_TOLERANCE_M +) -> list[tuple[float, float]]: + """붙어 있는 점을 하나로 줄인다. 순서는 그대로 둔다.""" + cleaned: list[tuple[float, float]] = [] + for point in points: + if not cleaned or _distance(cleaned[-1], point) > tolerance_m: + cleaned.append(point) + return cleaned + + +def _perpendicular_distance( + point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] +) -> float: + """점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리.""" + dx, dy = end[0] - start[0], end[1] - start[1] + if dx == 0 and dy == 0: + return _distance(point, start) + return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot( + dx, dy + ) + + +def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]: + """남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다.""" + if len(points) < 3: + return list(range(len(points))) + keep = {0, len(points) - 1} + stack = [(0, len(points) - 1)] + while stack: + first, last = stack.pop() + if last <= first + 1: + continue + worst, worst_index = -1.0, first + for index in range(first + 1, last): + gap = _perpendicular_distance(points[index], points[first], points[last]) + if gap > worst: + worst, worst_index = gap, index + if worst > tolerance_m: + keep.add(worst_index) + stack.append((first, worst_index)) + stack.append((worst_index, last)) + return sorted(keep) + + +def simplify_to_nodes( + points: list[tuple[float, float]], + tolerance_m: float = SIMPLIFY_TOLERANCE_M, + max_spacing_m: float = MAX_NODE_SPACING_M, + depth: int = MAX_REFINE_DEPTH, +) -> list[tuple[float, float]]: + """조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기. + + 예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울 + 접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는 + 선에서 점을 걷어내므로 **모양은 그대로**다. + + 남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시 + 뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측). + """ + if len(points) < 3: + return list(points) + kept = _douglas_peucker(points, tolerance_m) + if depth <= 0 or max_spacing_m <= 0: + return [points[index] for index in kept] + + result: list[tuple[float, float]] = [points[kept[0]]] + for previous, current in zip(kept, kept[1:]): + if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1: + refined = simplify_to_nodes( + points[previous : current + 1], + tolerance_m / 2, + max_spacing_m, + depth - 1, + ) + result.extend(refined[1:]) + else: + result.append(points[current]) + return result + + +def _inner_angle_deg( + before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] +) -> float: + """세 점이 이루는 내각(도). 일직선이면 180.""" + ax, ay = before[0] - at[0], before[1] - at[1] + bx, by = after[0] - at[0], after[1] - at[1] + la, lb = math.hypot(ax, ay), math.hypot(bx, by) + if la <= 0 or lb <= 0: + return 180.0 + cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb))) + return math.degrees(math.acos(cosine)) + + +def _unit(from_point: tuple[float, float], to_point: tuple[float, float]) -> tuple[float, float]: + length = _distance(from_point, to_point) + if length <= 0: + return (0.0, 0.0) + return ((to_point[0] - from_point[0]) / length, (to_point[1] - from_point[1]) / length) + + +def _node_indices( + points: list[tuple[float, float]], nodes: list[tuple[float, float]] +) -> list[int] | None: + """노드가 원본 점 목록의 몇 번째인지 — 노드는 원본 자리 그대로라 순서대로 찾힌다. + + 사용자가 옮긴 노드처럼 원본에 없는 점이 섞이면 `None` 을 돌려준다(그때는 피팅을 건너뛴다). + """ + indices: list[int] = [] + cursor = 0 + for node in nodes: + while cursor < len(points) and points[cursor] != node: + cursor += 1 + if cursor >= len(points): + return None + indices.append(cursor) + cursor += 1 + return indices + + +def _point_to_segment_m( + point: tuple[float, float], start: tuple[float, float], end: tuple[float, float] +) -> float: + """점에서 선분까지 거리(m).""" + dx, dy = end[0] - start[0], end[1] - start[1] + if dx == 0 and dy == 0: + return _distance(point, start) + ratio = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy) + ratio = max(0.0, min(1.0, ratio)) + return _distance(point, (start[0] + dx * ratio, start[1] + dy * ratio)) + + +def _corner_deviation_m( + samples: list[tuple[float, float]], + before: tuple[float, float], + after: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], + center: tuple[float, float], + radius: float, +) -> float: + """예상노선 점들이 「직선-곡선-직선」에서 얼마나 벗어나는지 — **평균** 거리(m). + + 사용자 확정(2026-09-07): 반지름은 법정 하한을 지키되 **예정노선에 가장 가까운 값**을 고른다. + 그 「가까움」을 재는 자다. 원호 구간 밖의 점은 접선 두 개까지의 거리로 잰다. + + ⚠ 최대값이 아니라 **평균**을 쓴다(2026-09-07 실측) — 최대값은 반지름에 거의 반응하지 + 않는다. 작은 반지름이면 접선 두 개가 구간의 대부분을 덮어, 가장 먼 점은 어차피 꺾임점을 + 가로지르는 직선이 정하기 때문이다. 그래서 실제 원호 R 40m 짜리 곡선에서도 하한(12m)이 + 골라졌다. 평균으로 재면 「굴곡을 얼마나 잘 따라가나」가 값에 들어온다. + """ + if not samples: + return 0.0 + total = 0.0 + for sample in samples: + gap = min( + _point_to_segment_m(sample, before, start), + _point_to_segment_m(sample, end, after), + # 원호까지 거리 — 중심에서 잰 반지름 차. 원호 밖 각도면 끝점 거리가 더 가까워 + # 위 두 값이 이미 그것을 담는다. + abs(_distance(sample, center) - radius), + ) + total += gap + return total / len(samples) + + +def _arc_geometry( + before: tuple[float, float], + at: tuple[float, float], + after: tuple[float, float], + inner_deg: float, + radius: float, + half_tan: float, +) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None: + """반지름 하나에 대한 (접선시작, 접선끝, 중심). 원호를 못 끼우면 None.""" + tangent = radius * half_tan + to_before = _unit(at, before) + to_after = _unit(at, after) + start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent) + end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent) + bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1]) + bisector_length = math.hypot(*bisector) + if bisector_length <= 1e-9: + return None + center_distance = radius / math.sin(math.radians(inner_deg) / 2) + center = ( + at[0] + bisector[0] / bisector_length * center_distance, + at[1] + bisector[1] / bisector_length * center_distance, + ) + return start, end, center + + +def _fit_radius_m( + samples: list[tuple[float, float]], + before: tuple[float, float], + at: tuple[float, float], + after: tuple[float, float], + inner_deg: float, + half_tan: float, + min_radius_m: float, + max_radius_m: float, + steps: int = 48, +) -> float: + """법정 하한 이상에서 **예정노선과 가장 덜 벌어지는** 반지름을 고른다. + + 왜 하한 고정이 아닌가(2026-09-07 사용자 확정) — 「반지름은 법정 최소 값을 지키되 기존 + 예정노선에 가까운 폴리라인을 찾는 게 키」. 꺾임이 뚜렷한 자리는 하한이 가장 가깝지만 + (중앙종거 M = R(1/cos(Δ/2) − 1) 이라 R 이 클수록 꺾임점에서 멀어짐), **완만하고 긴 굴곡**은 + 반대로 큰 반지름이 원본을 잘 따라간다. 그래서 자리마다 재서 고른다. + """ + if not samples or max_radius_m <= min_radius_m: + return min_radius_m + best_radius = min_radius_m + best_gap = float("inf") + for step in range(steps + 1): + # 작은 반지름 쪽을 촘촘히 본다 — 하한 부근에서 값이 빠르게 변한다. + ratio = step / steps + radius = min_radius_m * (max_radius_m / min_radius_m) ** ratio + geometry = _arc_geometry(before, at, after, inner_deg, radius, half_tan) + if geometry is None: + continue + start, end, center = geometry + gap = _corner_deviation_m(samples, before, after, start, end, center, radius) + if gap < best_gap - 1e-9: + best_gap, best_radius = gap, radius + return best_radius + + +def _arc_points( + center: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], + clockwise: bool, +) -> list[tuple[float, float]]: + """중심과 두 끝점으로 원호 위 점을 찍는다(양 끝 포함하지 않음 — 부르는 쪽이 붙인다).""" + radius = _distance(center, start) + if radius <= 0: + return [] + start_angle = math.atan2(start[1] - center[1], start[0] - center[0]) + end_angle = math.atan2(end[1] - center[1], end[0] - center[0]) + sweep = end_angle - start_angle + if clockwise: + while sweep > 0: + sweep -= 2 * math.pi + else: + while sweep < 0: + sweep += 2 * math.pi + steps = max(1, int(abs(math.degrees(sweep)) / ARC_STEP_DEG)) + return [ + ( + center[0] + radius * math.cos(start_angle + sweep * step / steps), + center[1] + radius * math.sin(start_angle + sweep * step / steps), + ) + for step in range(1, steps) + ] + + +def _turn_sign( + before: tuple[float, float], at: tuple[float, float], after: tuple[float, float] +) -> int: + """도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0.""" + cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]) + if abs(cross) <= 1e-9: + return 0 + return 1 if cross > 0 else -1 + + +def _line_intersection( + a1: tuple[float, float], + a2: tuple[float, float], + b1: tuple[float, float], + b2: tuple[float, float], +) -> tuple[float, float] | None: + """두 **직선**(선분 아님)의 교차점. 나란하면 None.""" + dx1, dy1 = a2[0] - a1[0], a2[1] - a1[1] + dx2, dy2 = b2[0] - b1[0], b2[1] - b1[1] + denominator = dx1 * dy2 - dy1 * dx2 + if abs(denominator) <= 1e-12: + return None + t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator + return (a1[0] + dx1 * t, a1[1] + dy1 * t) + + +def _curve_runs( + cleaned: list[tuple[float, float]], +) -> list[tuple[int, int]]: + """**한 곡선으로 묶을 꺾임점 구간**을 고른다 — `[(첫 꺾임점, 끝 꺾임점)]`. + + 왜 묶나(2026-09-07 실측) — 꺾임점 하나마다 원호를 끼우면 **긴 완만한 곡선을 만들 수 없다**. + 실제로 반지름 40m 짜리 원호를 점으로 흉내 내 넣어 봤더니, 그 곡선이 꺾임점 세 개로 쪼개져 + 각각에 작은 원호가 들어갔고 반지름을 키울수록 원본에서 **멀어졌다**(평균 벗어남 + 1.56m → 1.89m). 한 곡선은 한 원호여야 한다 — 사용자가 말한 「직선 > 곡선 > 직선」이 그것이다. + + 묶는 규칙: 곡선 대상(내각이 기준 미만)이면서 **도는 방향이 같은** 꺾임점이 이어지면 한 묶음. + """ + runs: list[tuple[int, int]] = [] + index = 1 + while index < len(cleaned) - 1: + inner = _inner_angle_deg(cleaned[index - 1], cleaned[index], cleaned[index + 1]) + if inner >= CURVE_GROUP_INNER_ANGLE_DEG: + index += 1 + continue + sign = _turn_sign(cleaned[index - 1], cleaned[index], cleaned[index + 1]) + end = index + while end + 1 < len(cleaned) - 1: + following = end + 1 + inner_next = _inner_angle_deg( + cleaned[following - 1], cleaned[following], cleaned[following + 1] + ) + if inner_next >= CURVE_GROUP_INNER_ANGLE_DEG: + break + if ( + _turn_sign(cleaned[following - 1], cleaned[following], cleaned[following + 1]) + != sign + ): + break + end = following + # 곡선을 생략하지 않는다(2026-09-07 사용자 지시) — 꺾이는 자리는 전부 곡선이다. + runs.append((index, end)) + index = end + 1 + return runs + + +def _run_worst_gap_m( + first: int, + last: int, + cleaned: list[tuple[float, float]], + samples: list[tuple[float, float]], + min_radius_m: float, +) -> tuple[float, float]: + """그 묶음을 **한 곡선**으로 폈을 때 예정노선에서 벗어나는 (최대, 평균) 거리(m). + + 못 끼우면 (무한대, 무한대). + """ + entry_from = cleaned[first - 1] + exit_to = cleaned[last + 1] + apex = ( + cleaned[first] + if first == last + else _line_intersection(entry_from, cleaned[first], cleaned[last], exit_to) + ) + if apex is None: + return float("inf"), float("inf") + inner = _inner_angle_deg(entry_from, apex, exit_to) + half_tan = math.tan(math.radians(180.0 - inner) / 2) + available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2 + if half_tan <= 1e-9 or available <= 0: + return float("inf"), float("inf") + radius = min( + _fit_radius_m( + samples, entry_from, apex, exit_to, inner, half_tan, min_radius_m, available / half_tan + ), + available / half_tan, + ) + geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan) + if geometry is None: + return float("inf"), float("inf") + start, end, center = geometry + line = [ + entry_from, + start, + *_arc_points(center, start, end, _turn_sign(entry_from, apex, exit_to) < 0), + end, + exit_to, + ] + gaps = [ + min(_point_to_segment_m(sample, a, b) for a, b in zip(line, line[1:])) for sample in samples + ] + return max(gaps), sum(gaps) / len(gaps) + + +def _split_wide_runs( + runs: list[tuple[int, int]], + cleaned: list[tuple[float, float]], + deduped: list[tuple[float, float]], + node_indices: list[int], + min_radius_m: float, +) -> list[tuple[int, int]]: + """묶는 것이 **손해면 도로 쪼갠다** — 한 곡선으로 펴서 원본에서 더 멀어지면 안 묶는다. + + 왜(2026-09-07 실측) — 이어진 꺾임을 한 곡선으로 묶으면 완만한 굴곡은 잘 따라가지만 + (반지름 40m 짜리 시험 곡선: 평균 벗어남 0.385 → 0.250m), 성격이 다른 굴곡이 이어 붙은 + 자리를 통째로 묶으면 **최대 벗어남이 3.6m → 15.2m** 로 벌어졌다. 그래서 묶음마다 + 「묶었을 때」와 「낱개로 뒀을 때」를 재서 **원본에 더 가까운 쪽**을 고른다. + """ + result: list[tuple[int, int]] = [] + for first, last in runs: + if first == last: + result.append((first, last)) + continue + samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] + merged_max, merged_mean = _run_worst_gap_m(first, last, cleaned, samples, min_radius_m) + apart = [ + _run_worst_gap_m( + index, + index, + cleaned, + deduped[node_indices[index - 1] : node_indices[index + 1] + 1], + min_radius_m, + ) + for index in range(first, last + 1) + ] + apart_max = max(item[0] for item in apart) + apart_mean = sum(item[1] for item in apart) / len(apart) + # 평균이 나으면 묶는다 — 「직선 > 곡선 > 직선」이 사용자가 원한 모양이라 조각을 + # 늘리기보다 한 곡선을 우선한다. 다만 **한 자리라도 크게 벌어지면** 안 묶는다. + if merged_mean <= apart_mean and merged_max <= max(apart_max, MERGE_MAX_GAP_M): + result.append((first, last)) + else: + result.extend((index, index) for index in range(first, last + 1)) + return result + + +def build_planned_polyline( + points: list[tuple[float, float]], + *, + min_radius_m: float, + hairpin_min_radius_m: float = 10.0, + simplify: bool = True, + curve_flags: list[bool] | None = None, + radii: list[float | None] | None = None, +) -> PlannedPolyline: + """점 묶음을 계획노선 폴리라인으로 바꾼다. + + `min_radius_m` 은 설계속도·지형으로 고른 법정 최소곡선반지름이다 + (`config_system_design.FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]`). + + **먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면 + 곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m). + + `simplify=False` 는 **이미 꺾임점인 것을 넘길 때** 쓴다 — 사용자가 옮긴 노드가 그렇다. + ⚠ 이 갈래가 없으면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → + 119, 노드 22 → 21). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고, + 그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.** + + `curve_flags`·`radii` 는 **사용자 편집을 그대로 받는 자리**다(2026-09-07 사용자 지시: + 「r과 직선 삭제나 추가가 있어야 하지 않을까」). 점마다 짝을 이룬다. + · `curve_flags[i] = False` → 그 자리에 **곡선을 두지 않는다**(곡선 삭제). 직선이 그대로 + 꺾인다. 다시 True 로 주면 곡선이 돌아온다(곡선 추가). + · `radii[i]` → 그 곡선의 반지름을 **그 값으로 못박는다**(R 변경). 없으면 예정노선에 + 맞춰 고르거나 법정 하한을 쓴다. + · 직선을 지우거나 더하는 것은 **점 목록 자체**로 표현된다 — 점을 빼면 앞뒤 직선이 하나로 + 합쳐지고, 직선 위에 점을 더하면 둘로 갈리며 그 자리에 곡선이 생긴다. + 편집값이 주어지면 **묶지 않는다** — 사용자가 곡선 하나로 본 것을 임의로 합치면 손잡이가 + 사라지기 때문이다. + """ + # 편집값은 점과 짝이므로 **함께** 중복을 걸러야 어긋나지 않는다. + edited = curve_flags is not None or radii is not None + flags = list(curve_flags) if curve_flags is not None else [True] * len(points) + given = list(radii) if radii is not None else [None] * len(points) + flags += [True] * (len(points) - len(flags)) + given += [None] * (len(points) - len(given)) + deduped: list[tuple[float, float]] = [] + kept_flags: list[bool] = [] + kept_radii: list[float | None] = [] + for point, flag, radius_value in zip(points, flags, given): + if deduped and _distance(deduped[-1], point) <= DUPLICATE_TOLERANCE_M: + continue + deduped.append(point) + kept_flags.append(bool(flag)) + kept_radii.append(radius_value) + cleaned = simplify_to_nodes(deduped) if simplify else deduped + if len(cleaned) < 3: + nodes = [RouteNode(x=x, y=y) for x, y in cleaned] + return PlannedPolyline(nodes=nodes, vertices=list(cleaned), curves=[]) + + # 반지름을 예정노선에 맞추려면 **꺾임점 사이의 원본 점**이 있어야 한다. 사용자가 옮긴 + # 노드를 받은 경우(`simplify=False`)나 자리를 못 찾는 경우에는 피팅을 건너뛴다. + node_indices = _node_indices(deduped, cleaned) if simplify else None + + nodes = [RouteNode(x=x, y=y) for x, y in cleaned] + for index in range(1, len(cleaned) - 1): + nodes[index].inner_angle_deg = _inner_angle_deg( + cleaned[index - 1], cleaned[index], cleaned[index + 1] + ) + + if edited: + # 사용자가 손댄 목록 — 묶지 않고 자리마다 하나씩 본다. 곡선을 지운 자리는 뺀다. + runs = [(index, index) for index in range(1, len(cleaned) - 1) if kept_flags[index]] + else: + runs = _curve_runs(cleaned) + if node_indices is not None: + runs = _split_wide_runs(runs, cleaned, deduped, node_indices, min_radius_m) + vertices: list[tuple[float, float]] = [cleaned[0]] + curves: list[RouteCurve] = [] + cursor = 0 # 아직 선에 안 실은 첫 꺾임점 + + for first, last in runs: + # 곡선 앞뒤의 **직선**. 묶음 안 꺾임점은 그 곡선이 대신하므로 선에 넣지 않는다. + entry_from, entry_to = cleaned[first - 1], cleaned[first] + exit_from, exit_to = cleaned[last], cleaned[last + 1] + # 두 직선을 늘려 만나는 자리가 이 곡선의 꺾임점(IP)이다. 묶음이 하나면 그 노드 자신. + apex = ( + cleaned[first] + if first == last + else _line_intersection(entry_from, entry_to, exit_from, exit_to) + ) + node = nodes[first] + if apex is None: # 두 직선이 나란하다 — 곡선을 못 끼운다. + for index in range(cursor + 1, last + 1): + vertices.append(cleaned[index]) + cursor = last + continue + + inner = _inner_angle_deg(entry_from, apex, exit_to) + half_tan = math.tan(math.radians(180.0 - inner) / 2) + # 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2 + if half_tan <= 1e-9 or available <= 0: + for index in range(cursor + 1, last + 1): + vertices.append(cleaned[index]) + cursor = last + continue + + # 반지름은 **법정 하한 이상에서 예정노선에 가장 가까운 값**(2026-09-07 사용자 확정). + # 자리가 모자라면 하한 아래로 줄이되 **막지 않고 위반으로 표시**한다(기존 규칙). + radius = min_radius_m + forced = kept_radii[first] if edited and first == last else None + if forced is not None and forced > 0: + radius = float(forced) # 사용자가 못박은 반지름 — 맞추지 않고 그대로 쓴다. + elif node_indices is not None: + samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] + radius = _fit_radius_m( + samples, + entry_from, + apex, + exit_to, + inner, + half_tan, + min_radius_m, + available / half_tan, + ) + tangent = radius * half_tan + if tangent > available: + radius = available / half_tan + tangent = available + + geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan) + if radius <= 0 or geometry is None: + for index in range(cursor + 1, last + 1): + vertices.append(cleaned[index]) + cursor = last + continue + start, end, center = geometry + + if radius < min_radius_m: + node.violations.append(f"최소곡선반지름 미달({radius:.1f} < {min_radius_m:.1f}m)") + if radius < hairpin_min_radius_m: + node.violations.append( + f"배향곡선 하한 미달({radius:.1f} < {hairpin_min_radius_m:.1f}m)" + ) + node.radius_m = radius + node.tangent_m = tangent + curves.append( + RouteCurve( + apex=apex, + radius_m=radius, + tangent_m=tangent, + inner_angle_deg=inner, + start=start, + end=end, + node_first=first, + node_last=last, + violations=list(node.violations), + ) + ) + + # 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다(이 묶음 앞까지). + for index in range(cursor + 1, first): + vertices.append(cleaned[index]) + cursor = last + + clockwise = _turn_sign(entry_from, apex, exit_to) < 0 + vertices.append(start) + vertices.extend(_arc_points(center, start, end, clockwise=clockwise)) + vertices.append(end) + + for index in range(cursor + 1, len(cleaned)): + vertices.append(cleaned[index]) + return PlannedPolyline(nodes=nodes, vertices=vertices, curves=curves) diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py new file mode 100644 index 00000000..092ff85e --- /dev/null +++ b/common_util/common_util_structure_lengths.py @@ -0,0 +1,92 @@ +"""구조물 정본에서 **시설별 연장(m)** 을 낸다 — B군 배수시설 수량의 입력 (계획서 3-6). + +왜 연장만인가 — B군(측구·산마루측구·도수로·맹암거 등)의 수량 단위가 **전부 m** 이다 +(맹암거 12-10 · L형 측구 12-9-1 · 산마루측구 12-9-2 · 소단측구 12-9-3, 지식DB +`04_수량분석정보/배수공_수량.md`). 그래서 단면 기하 없이 구간 길이만으로 셈이 된다. +별표2의 「횡단면도 각 측점 기입 물량」 목록에도 B군은 없어 도면에 그릴 의무가 없다 +(2026-09-07 조사). + +**수량서 양식에 안 묶인다** — 여기서는 종류별 연장·개소만 내고, 어느 코드·어느 칸에 +넣을지는 B08 이 정한다. B08 은 실무 xlsx 양식으로 재작업 예정이라 그 사이에 두는 것이다. +""" + +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는 +# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정). +PENDING_TYPE_IDS = frozenset({"ditch_berm"}) + + +def _merge(spans: list[tuple[float, float]]) -> float: + """겹치는 구간을 합쳐 실제 덮인 길이를 낸다. + + 같은 시설을 겹치게 두 번 넣으면 단순 합은 그 구간을 **두 번 센다**. 연장은 「덮인 + 길이」라 겹침을 지우는 쪽이 맞다. 원래 합(`raw_length_m`)도 함께 내보내므로 입력이 + 겹쳤다는 사실은 숨지 않는다. + """ + total = 0.0 + current_start: float | None = None + current_end = 0.0 + for start, end in sorted(spans): + if current_start is None: + current_start, current_end = start, end + continue + if start <= current_end: + current_end = max(current_end, end) + continue + total += current_end - current_start + current_start, current_end = start, end + if current_start is not None: + total += current_end - current_start + return total + + +def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: + """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). + + 빼는 것 셋 — + · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. + · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 + 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). + · `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다. + + 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 + (`StructureInstance.validate_placement_fields`). + """ + types = structure_type_map() + spans: dict[str, list[tuple[float, float]]] = {} + counts: dict[str, int] = {} + + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.placement != "interval": + continue + if definition.managed_by or definition.design_owner: + continue + if structure.type_id in PENDING_TYPE_IDS: + continue + start, end = float(structure.start_m), float(structure.end_m) + counts[structure.type_id] = counts.get(structure.type_id, 0) + 1 + spans.setdefault(structure.type_id, []).append((min(start, end), max(start, end))) + + rows: list[dict[str, Any]] = [] + for type_id, count in counts.items(): + entries = spans[type_id] + rows.append( + { + "type_id": type_id, + "group": types[type_id].group, + "name": types[type_id].name, + "count": count, + # 겹침을 지운 실제 연장 — 수량서에 쓸 값. + "length_m": round(_merge(entries), 2), + # 입력한 구간 길이의 단순 합 — 위와 다르면 구간이 겹쳐 있다는 뜻. + "raw_length_m": round(sum(end - start for start, end in entries), 2), + } + ) + rows.sort(key=lambda row: (row["group"], row["name"])) + return rows diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts new file mode 100644 index 00000000..1047ed46 --- /dev/null +++ b/common_util/common_util_structure_walls.ts @@ -0,0 +1,115 @@ +/* ============================================================================= + * common_util_structure_walls.ts + * 구조물 정본(C군 사면안정 벽)을 횡단 측점 제원으로 바꾸는 자리 — + * 파이썬 `B06_Section_Engine_Structures_Wall.py` 의 **짝**이다(거울 테스트로 대조). + * + * 왜 두 벌인가(CLAUDE.md 5장) — 서버는 상세를 내려보낼 때 얹어야 하고(저장분 기준), + * 브라우저는 사용자가 **아직 저장하지 않은** 목록으로 즉시 얹어야 한다. 값을 만드는 + * 산식이 같아야 하므로 두 파일 머리에 짝임을 적고 거울 테스트를 둔다. + * + * 얹는 것은 제원뿐이다 — 도형·면적은 `B06_Section_UI_Cross_Revetment` 가 그린다. + * ========================================================================== */ + +/** 구조물 정본 한 건(필요한 칸만). `structures.json` 과 같은 이름을 쓴다. */ +export interface WallStructureInput { + structure_id?: string | null; + type_id: string; + placement?: string | null; + chainage_m: number; + start_m?: number | null; + end_m?: number | null; + options?: Record | null; +} + +/** 측점에 얹는 벽 제원 — `section.revetment` 와 같은 꼴. */ +export interface WallSpec { + structure_id: string | null; + type_id: string; + name: string; + start_m: number; + end_m: number; + anchor_m: number; + form: string | null; + height_m: number | null; + side: string | null; + tiers: number | null; + lift_m: number | null; + shift_m: number | null; +} + +/** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */ +const EDGE_TOLERANCE_M = 0.02; + +/** 구조물 종류 → 횡단 기하가 아는 형태 이름 — 파이썬 `_FORM_BY_TYPE` 와 같은 표. */ +export const FORM_BY_TYPE: Record = { + masonry_wet: "돌쌓기(찰)", + masonry_dry: "돌쌓기(메)", + boulder_masonry: "돌쌓기(메)", + retaining_wall: "콘크리트", + soil_guard: "통나무·목재틀", +}; + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** + * C군 벽 구조물을 제원 목록으로 바꾼다. 이름표(`names`)는 타입 레지스트리에서 온다. + * 구간(start·end)이 없는 항목은 건너뛴다 — 벽은 구간형이다. + */ +export function wallSpecsFrom( + structures: readonly WallStructureInput[], + names: ReadonlyMap, +): WallSpec[] { + const specs: WallSpec[] = []; + for (const structure of structures) { + const name = names.get(structure.type_id); + if (!name) continue; + const start = num(structure.start_m); + const end = num(structure.end_m); + if (start === null || end === null) continue; + const options = (structure.options ?? {}) as Record; + specs.push({ + structure_id: structure.structure_id ?? null, + type_id: structure.type_id, + name, + start_m: Math.min(start, end), + end_m: Math.max(start, end), + anchor_m: num(structure.chainage_m) ?? Math.min(start, end), + form: (options.form as string) || FORM_BY_TYPE[structure.type_id] || null, + height_m: num(options.height_m), + side: (options.side as string) ?? null, + tiers: num(options.tiers), + lift_m: num(options.lift_m), + shift_m: num(options.shift_m), + }); + } + return specs; +} + +/** + * 구간 안 측점에 제원을 얹는다(파이썬 `attach_wall_structures`). 얹은 개수를 돌려준다. + * 관 세트가 이미 붙은 측점은 건드리지 않는다 — 한 자리에 두 벽이 겹치면 읽히지 않는다. + */ +export function attachWallSpecs( + sections: Array>, + specs: readonly WallSpec[], +): number { + if (!specs.length) return 0; + let attached = 0; + for (const section of sections) { + const chainage = num(section.chainage_m); + if (chainage === null) continue; + if (section.culvert || section.revetment) continue; + for (const spec of specs) { + if ( + spec.start_m - EDGE_TOLERANCE_M <= chainage && + chainage <= spec.end_m + EDGE_TOLERANCE_M + ) { + section.revetment = spec; + attached += 1; + break; + } + } + } + return attached; +} diff --git a/common_util/common_util_temp_cleanup.py b/common_util/common_util_temp_cleanup.py index cc81af03..d864c547 100644 --- a/common_util/common_util_temp_cleanup.py +++ b/common_util/common_util_temp_cleanup.py @@ -14,6 +14,7 @@ from B03_FileInput.B03_FileInput_Repository_Temp import ( delete_temp_batch, list_expired_temp_batches, ) +from common_util.common_util_audit import purge_expired_audit_logs from common_util.common_util_storage import resolve_temp_batch_path, temp_upload_root from config.config_db import get_db_pool from config.config_system import ( @@ -81,4 +82,6 @@ async def cleanup_expired_temp_uploads_loop() -> None: removed = await cleanup_expired_temp_uploads() if removed: logger.info("임시 보관함 정리 완료: %d건 삭제", removed) + # 시스템 로그 보관 기간 정리도 같은 주기에 얹는다 — 루프를 따로 두지 않는다. + await purge_expired_audit_logs() await asyncio.sleep(interval_seconds) diff --git a/config/config_db.py b/config/config_db.py index 1fad1da8..f570c4da 100644 --- a/config/config_db.py +++ b/config/config_db.py @@ -5,7 +5,8 @@ config_db.py 비동기 연결 풀 생성 및 관리. """ -from typing import Optional +from collections.abc import Callable +from typing import Any, Optional import aiomysql @@ -58,3 +59,18 @@ def get_db_pool() -> aiomysql.Pool: if not db_pool: raise RuntimeError("DB pool not initialized. Call init_db_pool() first.") return db_pool + + +async def run_with_connection(repository_call: Callable[..., Any], *args: Any) -> Any: + """저장소 함수 하나를 **자기 커넥션**으로 실행한다 — `asyncio.gather` 로 묶기 위한 것. + + DB 가 원격이라 질의 하나가 곧 왕복 약 12ms 다(2026-09-06 실측). 서로 기다릴 이유가 없는 + 읽기를 한 커넥션에서 순차로 내면 그 왕복이 그대로 더해진다. 커넥션을 갈라 같이 보내면 + 가장 느린 하나의 시간만 든다. 풀 최대치는 `DB_POOL_MAX`(기본 20). + + ⚠ **읽기에만 쓸 것.** 순서가 필요한 쓰기(한 트랜잭션 안의 UPDATE 들)를 이걸로 묶으면 + 커넥션이 갈려 트랜잭션이 깨진다. + """ + pool = get_db_pool() + async with pool.acquire() as connection: + return await repository_call(connection, *args) diff --git a/config/config_system.py b/config/config_system.py index 701e782e..4a60494d 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -98,6 +98,13 @@ CHUNK_RETENTION_HOURS = int(os.getenv("CHUNK_RETENTION_HOURS", "24")) # 운영하며 조정할 값이라 여기서 관리한다(2026-08-08 사용자 지시). TEMP_UPLOAD_DIR_NAME = os.getenv("TEMP_UPLOAD_DIR_NAME", "tmp") TEMP_UPLOAD_RETENTION_DAYS = int(os.getenv("TEMP_UPLOAD_RETENTION_DAYS", "30")) +# 시스템 로그(누가 무엇을 했나) 보관 기간 — 사고 추적에 1년 (2026-09-06 사용자 확정). +AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS", "365")) + +# 한 세션이 한 시간에 부를 수 있는 API 횟수 — 넘으면 **막지 않고 시스템 로그에만** 남긴다 +# (2026-09-06 사용자 지시). 화면 한 번 여는 데 약 180회가 나가므로, 사람이 쉬지 않고 +# 화면을 열어도 한 시간에 수천 회다. 그 몇 배를 넘으면 사람이 아니라고 본다. +API_CALL_HOURLY_LIMIT = int(os.getenv("API_CALL_HOURLY_LIMIT", "20000")) TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS = int(os.getenv("TEMP_UPLOAD_CLEANUP_INTERVAL_HOURS", "6")) MERGE_TIMEOUT_SECONDS = int(os.getenv("MERGE_TIMEOUT_SECONDS", "3600")) SEND_ANALYSIS_COMPLETION_EMAIL = ( @@ -231,6 +238,12 @@ SESSION_COOKIE_NAME = os.getenv("SESSION_COOKIE_NAME", "session_id") DEVICE_TOKEN_COOKIE_NAME = os.getenv("DEVICE_TOKEN_COOKIE_NAME", "device_token") SESSION_MAX_AGE_SECONDS = int(os.getenv("SESSION_MAX_AGE_SECONDS", "43200")) SESSION_IDLE_TIMEOUT_SECONDS = int(os.getenv("SESSION_IDLE_TIMEOUT_SECONDS", "14400")) + +# `sessions.last_activity_at` 을 다시 쓰는 최소 간격(초). 유휴 판정이 4시간 단위라 초 단위로 +# 정확할 이유가 없고, 요청마다 쓰면 원격 DB 왕복이 약 20ms 씩 붙는다(2026-09-06 실측). +SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS = int( + os.getenv("SESSION_ACTIVITY_WRITE_INTERVAL_SECONDS", "60") +) SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "True").lower() == "true" PASSWORD_BCRYPT_ROUNDS = int(os.getenv("PASSWORD_BCRYPT_ROUNDS", "12")) OTP_VALID_MINUTES = int(os.getenv("EMAIL_OTP_VALID_MINUTES", "5")) diff --git a/config/config_system_design.py b/config/config_system_design.py index 881ce871..bfb4360d 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -46,6 +46,29 @@ ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} +# ───────────────────────────────────────────────────────────────────────── +# 절토 비탈 법정 기울기 (별표2) +# +# 근거 — 지식DB `resources/knowledge/technical_info/01_임도/02_상세설계/절토_비탈면.md` §1. +# 그 문서의 [구현] 줄대로 **판정은 별표2 범위로** 한다(실무 관행값은 후보일 뿐 기준이 아님). +# 값은 수직 1 에 대한 수평(1:n 의 n). +# ───────────────────────────────────────────────────────────────────────── +FOREST_ROAD_CUT_SLOPE_LIMITS = { + "hard_rock": (0.3, 0.8), # 암석지 — 경암 + "soft_rock": (0.5, 1.2), # 암석지 — 연암 + "soil": (0.8, 1.5), # 토사지역 +} +# 프로그램 지반유형(리핑암·발파암) → 별표2 줄(연암·경암). +# **기본값이며 사용자가 바꿀 수 있다**(2026-09-07 사용자 확정 — 「사용자가 제어할 수 있게」). +# 별표2 는 경암·연암으로 가르고 프로그램은 리핑암·발파암으로 가르는데, 둘을 잇는 문장이 +# 법령·교본에 없다. 그래서 법정 근거가 아니라 **프로그램 설정**으로 두고 화면에서 고르게 한다. +FOREST_ROAD_CUT_SLOPE_CLASS_DEFAULT = { + "ripping_rock": "soft_rock", + "blasting_rock": "hard_rock", +} +# 절토 기울기 규정이 없는 등급 — 작업임도(별표2 §1 「작업임도: 절토 기울기 표 규정 없음」). +FOREST_ROAD_CUT_SLOPE_EXEMPT_GRADES = ("work",) + # 대안(정속경사) 파라미터 ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) ROUTE_ALT_MAX_GRADE = float(os.getenv("ROUTE_ALT_MAX_GRADE", "0.14")) @@ -420,6 +443,43 @@ NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5 # 위 5-3의 경로탐색(평면) 기준(FOREST_ROAD_MAX_GRADE 등)과는 별개의 값이므로 # 서로 혼용하지 않는다. # ───────────────────────────────────────────────────────────────────────── +# ── 곡선부 너비 확폭 (별표2 Ⅰ.2.나.(4) / Ⅰ.3.다.(4)) ───────────────────── +# 평면 곡선반경 R(m) 구간별 **확대 기준(m)**. 원문은 "다음의 기준 이상으로 확대"라 이 값이 +# 하한이다. 45m 이상은 확폭하지 않는다. 경계는 "이상 ~ 미만". +# 확폭 방향 = **곡선 바깥쪽 편측**(2026-09-06 사용자 확정 — 회전 시 차량이 밀리는 쪽). +# 법령·교본에 방향 규정이 없어 사용자가 정한 값이며, 도면 실물로 재확인할 여지가 있다. +CURVE_WIDENING_TABLE_M: tuple[tuple[float, float, float], ...] = ( + (10.0, 13.0, 2.25), + (13.0, 14.0, 2.00), + (14.0, 15.0, 1.75), + (15.0, 18.0, 1.50), + (18.0, 20.0, 1.25), + (20.0, 25.0, 1.00), + (25.0, 30.0, 0.75), + (30.0, 40.0, 0.50), + (40.0, 45.0, 0.25), +) +# 확폭을 더한 뒤의 **유효너비 상한**(m, 별표2 Ⅰ.2.다.(1) 비고 — "최대 5미터까지"). +# 넘으면 그 자리에서 자르고 화면이 경고한다. +CURVE_WIDENING_MAX_WIDTH_M = 5.0 + +# 곡선 앞뒤에서 확폭을 0 → W 로 잇는 길이(m). 별표2에는 확폭량 표만 있고 붙이는 방식이 +# 없어, 실무 관행(곡선 시·종점 앞뒤 10m 직선 테이퍼)을 따른다(2026-09-06 사용자 지시). +# 측점 간격(기본 20m)이 이보다 넓으면 화면에서는 측점 간 보간이 대신 이어 준다. +CURVE_WIDENING_TAPER_M = 10.0 + + +def curve_widening_m(plan_radius_m: float | None) -> float: + """평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0.""" + if plan_radius_m is None: + return 0.0 + radius = float(plan_radius_m) + for low, high, widening in CURVE_WIDENING_TABLE_M: + if low <= radius < high: + return widening + return 0.0 + + FOREST_ROAD_PROFILE_CRITERIA = { # 설계속도(km/h)별 법정 기준 "design_speed": { @@ -442,6 +502,17 @@ FOREST_ROAD_PROFILE_CRITERIA = { "min_curve_length_m": 20.0, }, }, + # 평면 최소곡선반지름(m, 별표2 Ⅰ.2.다.(1)) — 설계속도·지형별. **경로탐색 제약이 아니라 + # 위반 표시 기준**이다(2026-09-06 사용자 확정: 자동탐색은 쓰지 않고 계획노선을 직접 + # 고친다). 탐색이 쓰는 등급별 상수(FOREST_ROAD_MIN_CURVE_R_M)와 혼용하지 않는다. + "min_plan_radius_m": { + 40: {"normal": 60.0, "special": 40.0}, + 30: {"normal": 30.0, "special": 20.0}, + 20: {"normal": 15.0, "special": 12.0}, + }, + # 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만** + # 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정). + "hairpin_min_radius_m": 10.0, # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 2d9c66bd..113e576e 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -14,6 +14,20 @@ MESH_SMOOTHING_ITERATIONS = int(os.getenv("MESH_SMOOTHING_ITERATIONS", "0")) SURFACE_LAS_CHUNK_SIZE = int(os.getenv("SURFACE_LAS_CHUNK_SIZE", "500000")) SURFACE_DEFAULT_RGB_VALUE = int(os.getenv("SURFACE_DEFAULT_RGB_VALUE", "128")) SURFACE_GRID_CELL_SIZE_M = float(os.getenv("SURFACE_GRID_CELL_SIZE_M", "2.0")) + +# 지형 파일 여러 장 병합 (2026-09-06 사용자 확정) +# ───────────────────────────────────────────────────────────────────────── +# 다른 사업지 파일이 섞여 들어오면 합친 범위가 통째로 어긋난다 — 어느 파일과도 이 +# 거리 안에서 만나지 않는 파일은 업로드에서 막는다. 임도는 길어도 2~3km 라 5km 면 +# 넉넉하다(2026-09-06 사용자 확정). 도엽이 나뉜 자료는 경계가 맞닿아 걸리지 않는다. +SURFACE_MERGE_MAX_GAP_M = float(os.getenv("SURFACE_MERGE_MAX_GAP_M", "5000")) +# 점이 이 수를 넘으면 아래 칸 크기로 씨닝한다(칸마다 최저점 하나). 넘지 않으면 원본 +# 그대로 쓴다 — 작은 자료의 결과는 바뀌지 않는다. 1억점 = 메모리 약 3.2GB. +SURFACE_THIN_TRIGGER_POINTS = int(os.getenv("SURFACE_THIN_TRIGGER_POINTS", "100000000")) +# 씨닝 칸 크기(m). 설계 격자 1m·지면필터 2m·CSF 천 1.5m 보다 촘촘해야 결과가 안 변한다. +SURFACE_THIN_CELL_SIZE_M = float(os.getenv("SURFACE_THIN_CELL_SIZE_M", "0.5")) +# 씨닝 격자가 이보다 많아지면 범위가 비정상이다(먼 파일이 섞였거나 좌표계 불일치). +SURFACE_THIN_MAX_CELLS = int(os.getenv("SURFACE_THIN_MAX_CELLS", "400000000")) SURFACE_GRID_HEIGHT_THRESHOLD_M = float(os.getenv("SURFACE_GRID_HEIGHT_THRESHOLD_M", "1.5")) # CSF (Cloth Simulation Filter) 지면 분류 파라미터 diff --git a/config/google_oauth.json b/config/google_oauth.json new file mode 100644 index 00000000..8661bf02 --- /dev/null +++ b/config/google_oauth.json @@ -0,0 +1 @@ +{"installed":{"client_id":"222243854357-eg5aquofa0epa3ri78k5ngpfclroc7rg.apps.googleusercontent.com","project_id":"gen-lang-client-0346212725","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}} \ No newline at end of file diff --git a/config/google_token.json b/config/google_token.json new file mode 100644 index 00000000..73d73937 --- /dev/null +++ b/config/google_token.json @@ -0,0 +1 @@ +{"token": "ya29.a0AdMD6EhuJehKjsVhSLOpAEGES3oFxVfoH561Xmota4ZrCMiZHRH_YikXuFepOlH0oxU2TxFifQY0a6V4TODKKv_zM5_nN3doOzIZ4BHokpes8hRB2zcic8M9_fjG_B0S1YgjNgB1UDM6v-HfoGsT_RJBF2AHXIBMCytswZBxHIrCPvc5VzBT51RVW3aClahyCe2TLUIaCgYKAfQSARMSFQHGX2MijKpjVLthV_s9Le7_B9DCyw0206", "refresh_token": "1//0eg8jt_r9Dr7jCgYIARAAGA4SNwF-L9IrAQ_ulD6kqQ_3M9SKvEuCvFjSu2LN8_q6K_AMPossxCKzXp7ycUzTTpHcdH_3vZJclSY", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "222243854357-eg5aquofa0epa3ri78k5ngpfclroc7rg.apps.googleusercontent.com", "client_secret": "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", "scopes": ["https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/script.projects"], "universe_domain": "googleapis.com", "account": "", "expiry": "2026-09-05T23:33:00Z"} \ No newline at end of file diff --git a/db_management/016_project_members.sql b/db_management/016_project_members.sql new file mode 100644 index 00000000..6f58721c --- /dev/null +++ b/db_management/016_project_members.sql @@ -0,0 +1,26 @@ +-- 016_project_members.sql +-- 프로젝트 참여자 (2026-09-06 사용자 확정) +-- +-- 도면 표제란에 실리는 이름은 한 사람뿐이지만(과업책임자·분야별책임자·설계자), 설계 +-- 과정에서 손을 대는 보조 인원은 여러 명일 수 있다. 그 사람들을 담는 표다. +-- +-- 참여자는 일반 사용자여도 그 프로젝트를 **수정할 수 있다**. 만든 사람은 등록 시점에 +-- 자동으로 참여자가 된다. + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS project_members ( + project_id CHAR(36) NOT NULL COMMENT 'projects.id', + user_id INT NOT NULL COMMENT 'users.id', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id), + KEY idx_project_members_user (user_id), + CONSTRAINT fk_project_members_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE, + CONSTRAINT fk_project_members_user FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='프로젝트 참여자'; + +-- 기존 프로젝트는 만든 사람을 참여자로 채워 둔다. +INSERT IGNORE INTO project_members (project_id, user_id) +SELECT id, user_id FROM projects WHERE deleted_at IS NULL; diff --git a/db_management/017_audit_log_detail.sql b/db_management/017_audit_log_detail.sql new file mode 100644 index 00000000..8afbbed2 --- /dev/null +++ b/db_management/017_audit_log_detail.sql @@ -0,0 +1,13 @@ +-- 017_audit_log_detail.sql +-- 시스템 로그에 「무엇에」 한 일인지와 「어디서」 했는지를 남긴다 (2026-09-06 사용자 확정). +-- +-- 기존 `resource_id` 는 INT 라 프로젝트 id(UUID 문자열)를 담지 못해 늘 NULL 로 들어갔다. +-- 문자 칸을 따로 두어 프로젝트·회사·사용자 어느 쪽이든 그대로 적는다. +-- IP·브라우저 칸(`ip_address`·`user_agent`)은 004 에서 이미 만들어 두었으나 값을 넣는 +-- 코드가 없었다 — 이 마이그레이션 뒤부터 기록한다. + +ALTER TABLE system_audit_logs + ADD COLUMN IF NOT EXISTS resource_ref VARCHAR(64) NULL + COMMENT '대상 식별자 (프로젝트 UUID 등 문자열). 숫자 대상은 resource_id 와 같이 채운다'; + +-- 보관 기간(기본 365일) 정리가 날짜로 훑으므로 인덱스는 004 의 timestamp 인덱스를 그대로 쓴다. diff --git a/db_management/018_user_ui_prefs.sql b/db_management/018_user_ui_prefs.sql new file mode 100644 index 00000000..d5eb0530 --- /dev/null +++ b/db_management/018_user_ui_prefs.sql @@ -0,0 +1,25 @@ +-- 018_user_ui_prefs.sql +-- 화면 취향을 계정에 붙인다 (2026-09-07 사용자 승인) +-- +-- 패널 높이·접힘, 유토곡선 열림·범례 같은 **배치 값**이다. 설계값은 담지 않는다 — +-- 설계 초안·계산 결과는 지금처럼 브라우저 세션에 두고 [저장]·[확정]에서만 정본으로 간다. +-- +-- 왜 필요한가 — 취향이 브라우저에만 있어 **PC 를 바꾸면 처음부터 다시 맞춰야 했다**. +-- 사용자가 노트북·데스크톱 두 대를 오가며 쓴다. (탭을 새로 열 때마다 초기화되던 것은 +-- 앞서 `localStorage` 로 옮겨 해결했고, 이 표는 그 위에 「다른 PC 에서도 같은 배치」를 얹는다.) +-- +-- 왜 칸을 나누지 않고 JSON 한 칸인가 — 취향은 화면이 늘 때마다 늘어난다. 칸으로 나누면 +-- 그때마다 마이그레이션이 또 필요하다. 값이 작고(수십 바이트) 검색 대상이 아니라 +-- 한 칸에 담는 편이 값싸다. 키 이름은 브라우저 등록표(`A00_Common/b_page_state.ts`)가 정본이다. + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS user_ui_prefs ( + user_id INT NOT NULL COMMENT 'users.id — 사용자당 한 줄', + prefs JSON NOT NULL COMMENT '화면 취향 묶음 {키: 값}. 키는 브라우저 등록표의 pref 이름', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id), + CONSTRAINT fk_user_ui_prefs_user FOREIGN KEY (user_id) + REFERENCES users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='사용자별 화면 취향 (배치·표시). 설계값은 담지 않는다'; diff --git a/main.py b/main.py index af402284..6088b1f7 100644 --- a/main.py +++ b/main.py @@ -15,11 +15,15 @@ import logging import os import signal import subprocess +import time from contextlib import asynccontextmanager +from datetime import datetime from pathlib import Path +from typing import Any from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from A06_Login.A06_Login_Router import router as a06_login_router @@ -43,13 +47,19 @@ from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watersh from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router +from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) +from B06_Section.B06_Section_Router_HaulPlan import ( + router as b06_section_haul_plan_router, +) from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router +from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router +from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, require_project_access, @@ -68,6 +78,7 @@ from config.config_system import ( LOG_LEVEL, SERVER_HOST, SERVER_PORT, + SESSION_COOKIE_NAME, STATIC_DIR, STATIC_URL, ) @@ -262,6 +273,17 @@ async def lifespan(app: FastAPI): # 시작 logger.info(f"[{ENVIRONMENT}] 앱 시작 중...") + # 지금 도는 코드가 언제 것인지 못박아 둔다 — `/api/health` 가 `stale` 로 알린다(7-2). + global _STARTED_AT, _CODE_MTIME_AT_START + _STARTED_AT = time.time() + if DEBUG: + _CODE_MTIME_AT_START = _newest_source_mtime() + logger.info( + "[reload] 감시 폴더 %d개, 소스 최신 수정 %s", + len(_reload_dirs()), + datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), + ) + # 프론트엔드 빌드 (필수) build_frontend() @@ -278,6 +300,16 @@ async def lifespan(app: FastAPI): await cursor.execute( "UPDATE users SET role = 'SYSTEM_ADMIN' WHERE email = %s", (ADMIN_EMAIL.lower(),) ) + # 시스템 관리 회사(관리자 계정이 속한 회사) 소속은 전원 시스템관리자다 + # (2026-09-06 사용자 확정) — 역할을 따로 고를 일이 없다. + await cursor.execute( + """UPDATE users u + JOIN users a ON a.email = %s AND a.deleted_at IS NULL + AND a.company_id IS NOT NULL + SET u.role = 'SYSTEM_ADMIN' + WHERE u.company_id = a.company_id AND u.deleted_at IS NULL""", + (ADMIN_EMAIL.lower(),), + ) await connection.commit() cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) @@ -317,6 +349,60 @@ app.add_middleware( allow_headers=["*"], ) +# ───────────────────────────────────────────────────────────────────────── +# 응답 압축 (2026-09-06 실측으로 도입) +# ───────────────────────────────────────────────────────────────────────── +# 화면 한 번 여는 데 수십 MB 가 오간다 — 종횡단 상세 1.56MB → 0.34MB(압축 13ms), +# 배수유역 151KB → 37KB, 지형 도엽 GeoJSON 은 훨씬 크다. 사무실 랜에서는 티가 덜 나지만 +# 현장에서 인터넷으로 열면 이 차이가 곧 대기 시간이다. +# +# 딱 하나 예외 — 3D 예상형상(코리도)은 숫자 배열이라 절반밖에 안 줄면서 압축에만 493ms 가 +# 든다(18.6MB → 9.4MB). 그 자리는 「덜 보내기」가 아니라 「안 보내기」로 푼다. +# +# 압축 강도는 **1**(가장 약하게). 기본값 9 로 두었더니 등고선 도엽 36.8MB 를 누르느라 +# 요청 하나가 9.4초 걸렸다(2026-09-06 실측). 1 로 낮춰도 줄어드는 양은 거의 같고 +# (66MB 기준 level 1 은 24.0MB, level 6 은 23.9MB) 시간만 1/3 이 된다. +_COMPRESS_MIN_BYTES = 1024 +_COMPRESS_LEVEL = 1 + + +def _skips_compression(path: str) -> bool: + """압축에서 뺄 경로인가 — 지금은 코리도 하나뿐.""" + return path.endswith("/corridor") + + +class _SelectiveGZipMiddleware(GZipMiddleware): + """경로 몇 개만 빼고 압축한다. 나머지 동작은 표준 미들웨어 그대로.""" + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + if scope["type"] == "http" and _skips_compression(scope.get("path", "")): + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + +app.add_middleware( + _SelectiveGZipMiddleware, + minimum_size=_COMPRESS_MIN_BYTES, + compresslevel=_COMPRESS_LEVEL, +) + + +# ───────────────────────────────────────────────────────────────────────── +# 호출량 감시 (2026-09-06 사용자 지시 — 보안) +# ───────────────────────────────────────────────────────────────────────── +# 계산 결과는 화면에 나가도 된다는 방침이라, 남는 위험은 입력을 바꿔가며 출력을 긁어 모으는 +# 것이다. **막지는 않고** 상한을 넘은 세션만 시스템 로그에 한 줄 남긴다 — 자세한 이유는 +# `common_util_audit.note_api_call` 머리 참조. 세는 값은 메모리에 있어 요청당 비용이 없다. +@app.middleware("http") +async def watch_call_volume(request, call_next): # type: ignore[no-untyped-def] + if request.url.path.startswith("/api/"): + session_id = request.cookies.get(SESSION_COOKIE_NAME) + if note_api_call(session_id) and session_id: + asyncio.create_task(record_call_burst(session_id, request)) + return await call_next(request) + + # ───────────────────────────────────────────────────────────────────────── # 정적 파일 서빙 (프론트엔드) # ───────────────────────────────────────────────────────────────────────── @@ -329,9 +415,25 @@ logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_D # B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_DesignDetail" / "openwebcad" / "dist") + + +class _NoCacheHtmlStatic(StaticFiles): + """`index.html` 만 캐시하지 않는다 (2026-09-06). + + 캐드를 새로 빌드해도 브라우저가 옛 `index.html` 을 들고 있어 **옛 화면이 그대로** + 남았다(파일 이름에 해시가 붙는 자바스크립트는 새 이름이라 문제가 없다). + """ + + def file_response(self, *args, **kwargs): # type: ignore[override] + response = super().file_response(*args, **kwargs) + if str(getattr(response, "path", "")).endswith(".html"): + response.headers["Cache-Control"] = "no-store" + return response + + app.mount( "/b07-cad", - StaticFiles(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), + _NoCacheHtmlStatic(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), name="b07-cad", ) logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}") @@ -351,10 +453,44 @@ async def root(): } +#: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다 +#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). +#: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가 +#: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2). +_STARTED_AT = 0.0 +_CODE_MTIME_AT_START = 0.0 + + +def _newest_source_mtime() -> float: + """감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0.""" + newest = 0.0 + for folder in _reload_dirs(): + for path in Path(folder).rglob("*.py"): + try: + newest = max(newest, path.stat().st_mtime) + except OSError: + continue + return newest + + @app.get("/api/health") async def health(): - """헬스 체크""" - return {"status": "ok", "environment": ENVIRONMENT} + """헬스 체크. + + 개발 모드에서는 **코드가 최신인지**도 함께 알린다 — `stale: true` 면 소스가 더 새로우니 + (자동 리로드가 안 붙은 것) 재시작 전에는 어떤 측정도 믿으면 안 된다. + """ + body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT} + if DEBUG: + newest = _newest_source_mtime() + body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds") + body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat( + timespec="seconds" + ) + body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds") + # 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게. + body["stale"] = newest > _CODE_MTIME_AT_START + 1.0 + return body # ───────────────────────────────────────────────────────────────────────── @@ -391,16 +527,73 @@ app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) +app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) +app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) +app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── # 앱 실행 # ───────────────────────────────────────────────────────────────────────── +def _reload_dirs() -> list[str]: + """자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다. + + uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면 + 현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`· + `node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고 + 있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이 + 회차마다 3배씩 흔들린 원인도 이것이다. + + 파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다. + """ + root = Path(__file__).resolve().parent + # ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로 + # 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는 + # `node_modules` 가 들어 있어 역시 무겁다. 그 대신 **`main.py` 와 `config/` 를 고칠 때는 + # 서버를 손으로 다시 띄울 것** (둘 다 자주 고치는 자리가 아니다). + watched: list[str] = [] + for entry in sorted(root.iterdir()): + if not entry.is_dir() or entry.name.startswith(".") or entry.name in _RELOAD_SKIP: + continue + # 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이 + # 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트, + # npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다. + # 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**. + # 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다. + if entry.is_symlink(): + continue + # 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다. + if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")): + continue + if any(entry.rglob("*.py")): + watched.append(str(entry)) + return watched + + +# 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업. +_RELOAD_SKIP = { + "storage", + "tmp", + "venv", + "node_modules", + "__pycache__", + "log", + "docs", + "resources", + "scratch", + "graphify-out", + "0_old", + "db_management", + "migrations", + "ui_template", +} + + if __name__ == "__main__": import uvicorn @@ -410,5 +603,7 @@ if __name__ == "__main__": host=SERVER_HOST, port=SERVER_PORT, reload=DEBUG, + # 폴더를 좁히지 않으면 리로더가 storage(27GB)까지 0.25초마다 훑는다 — 위 주석 참조. + reload_dirs=_reload_dirs() if DEBUG else None, access_log=False, ) diff --git a/requirements.txt b/requirements.txt index 741d7fc7..b185b994 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,6 +53,10 @@ trimesh==4.12.2 typing_extensions==4.16.0 tzdata==2026.2 uvicorn==0.24.0 +# 자동 리로드를 **이벤트 방식**으로 바꾼다(2026-09-07 사용자 승인). 없으면 우버콘이 +# StatReload 로 떨어져 0.25초마다 폴더를 통째로 훑고, 그러면 요청이 없어도 CPU 를 먹는다 +# (실측: 감시 폴더를 좁히기 전 70%, 좁힌 뒤 18%, 이 패키지 설치 뒤 0%대). +watchfiles==1.2.0 wheel==0.47.0 whitebox==2.3.6 pytest>=8 diff --git a/resources/knowledge/04_참조_법령기준_목록.md b/resources/knowledge/04_참조_법령기준_목록.md index d80036ca..6ec93081 100644 --- a/resources/knowledge/04_참조_법령기준_목록.md +++ b/resources/knowledge/04_참조_법령기준_목록.md @@ -331,6 +331,7 @@ - [임도기술교본 (2019)](original/임도기술교본/) — 임도 설계·시공 해설. §1~§4 등록표의 인용 출처 스캔 기반 - [사방기술교본 (2023)](original/사방기술교본/) — 사방 설계 해설 (md 47건) +- [산림과임업기술(임도) 발췌본]() — 인쇄면 371~546쪽. 발행정보 미확인·폐지 법령 수록 과거 자료로, 비교·연혁 확인에만 사용 ## 9. 포맷·실무 검증자료 (현행 기준 아님) diff --git a/resources/knowledge/README.md b/resources/knowledge/README.md index 60240e75..983344e7 100644 --- a/resources/knowledge/README.md +++ b/resources/knowledge/README.md @@ -30,6 +30,7 @@ ├─ _품질점검_보고서.md QC 이력·잔여 ├─ _이미지 목록(표 변환 검토용).md pic/ 이미지 164개 (표 변환 판단 체크리스트) ├─ 임도기술교본/ 2020년 발간 확정본. 갱신하지 않는다 + ├─ 산림과임업기술(임도)/ 출처 미상 과거 기술자료 발췌본. 비교·연혁 확인용 ├─ 사방기술교본/ 2023년 개정판. 원문 PDF + 한글교정 PDF + 장별 md 47건 + 그림 328건 │ └─ 원문/ 원본·한글교정 PDF, 글리프매핑 json, 작업가이드 — 갱신하지 않는다 ├─ 법률/ 행정규칙/ 표준시방서/ 명칭별 폴더 (사방사업법 3법·사방 고시 3건 포함) diff --git a/resources/knowledge/original/_pipeline/data/qc_report.json b/resources/knowledge/original/_pipeline/data/qc_report.json index 054c2a2d..70450d8b 100644 --- a/resources/knowledge/original/_pipeline/data/qc_report.json +++ b/resources/knowledge/original/_pipeline/data/qc_report.json @@ -1,4 +1,76 @@ [ + { + "file": "법률/사방사업법 시행령/별표/별표1_사방사업 타당성평가의 기준·방법·대상사업 등(제4조의2 관련).md", + "issues": { + "띄어쓰기": 0.566 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표10_기초번호판의 세부규격(제12조제2항 관련).md", + "issues": { + "띄어쓰기": 0.176 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표13_건물번호판의 구성(제17조제2항 관련).md", + "issues": { + "띄어쓰기": 0.671 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표14_건물번호판의 세부규격(제17조제2항 관련).md", + "issues": { + "띄어쓰기": 0.266 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표16_국가지점번호판의 표시사항 및 세부규격(제24조제1항 관련).md", + "issues": { + "띄어쓰기": 0.209 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표18_사물주소판의 구성(제26조제3항 관련).md", + "issues": { + "띄어쓰기": 0.577 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표1_도로명판의 크기(제4조제2항 관련).md", + "issues": { + "띄어쓰기": 0.415 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표22_내부도로 도로명판의 구성(제31조제1항제1호가목 관련).md", + "issues": { + "띄어쓰기": 0.476 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표24_내부도로 기초번호판의 구성(제31조제1항제1호나목 관련).md", + "issues": { + "띄어쓰기": 0.608 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표28_내부도로 사물주소판의 구성(제31조제1항제1호라목 관련).md", + "issues": { + "띄어쓰기": 0.634 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표2_도로명판의 구성(제5조제2항 관련).md", + "issues": { + "띄어쓰기": 0.743 + } + }, + { + "file": "법률/주소정보시설규칙/별표/별표3_도로명판의 세부규격(제5조제2항 관련).md", + "issues": { + "띄어쓰기": 0.273 + } + }, { "file": "행정규칙/임도 품셈 적용기준 (현 산림사업 표준품셈)/별표/별표0_산림사업 표준품셈.md", "issues": { diff --git a/resources/knowledge/original/_pipeline/pdf2md.py b/resources/knowledge/original/_pipeline/pdf2md.py index 8e7ea80e..898bcb01 100644 --- a/resources/knowledge/original/_pipeline/pdf2md.py +++ b/resources/knowledge/original/_pipeline/pdf2md.py @@ -84,25 +84,31 @@ MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘 def _images(page, pno, doc, picdir, stem): - """페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환.""" + """PDF 화면에 보이는 이미지 영역을 렌더링하고 (rect, ref) 리스트 반환. + + 원시 xref를 직접 저장하면 이미지 마스크가 검게 나오거나 이미지 위에 별도로 + 그려진 문자·선이 빠질 수 있다. 페이지의 실제 배치 사각형을 렌더링하여 화면과 + 같은 합성 결과를 보존한다. + """ out = [] idx = 0 for im in page.get_images(full=True): xref = im[0] rects = page.get_image_rects(xref) r = rects[0] if rects else pymupdf.Rect(0, 0, 0, 0) + if r.is_empty or r.is_infinite: + continue try: - px = pymupdf.Pixmap(doc, xref) + source = pymupdf.Pixmap(doc, xref) except Exception: continue - if px.width < MIN_IMG or px.height < MIN_IMG: + if source.width < MIN_IMG or source.height < MIN_IMG: continue idx += 1 picdir.mkdir(parents=True, exist_ok=True) fn = f"{stem}_p{pno + 1}_{idx}.png" try: - if px.n - px.alpha >= 4: # CMYK 등 → RGB - px = pymupdf.Pixmap(pymupdf.csRGB, px) + px = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), clip=r, alpha=False) px.save(str(picdir / fn)) except Exception: continue diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md new file mode 100644 index 00000000..d6b5f14d --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/1. 총론.md @@ -0,0 +1,136 @@ +# 1. 총론 + +### 가. 도로와 임도 + +도로(Highway, Road, Street)는 보행자와 차량의 통행을 위한 공공용 시설로서 철도와 같이 육상교통을 분담하는 중요한 교통시설이다. 교통(Transport)은 사람이나 물건이 한 장소에서 다른 장소로 이동하는데 필요한 편의를 제공하는 행위로서, 이용통로에 따라 육상교통, 수상교통, 항공교통으로 대별되며, 그 중 육상교통은 도로교통과 철도교통으로 구분된다. 도로법 제2조 제1항에서“도로라 함은 일반의 교통에 공용(供用)되는 도로로서 제11조에 열거한 것”이라고 하고, 농어촌도로는 농어촌도로정비법 제2조 제1항에서“농어촌도로라 함은 도로법에 규정되지 아니한 도로로서 농어촌지역 주민의 교통편익과 생산·유통활동 등에 공용(供用)되는 공로중 제4조에 열거되고 제6조의 규정에 의하여 고시된 도로”라고 대인과 대물을 위하여 제공되는 도로 임을 명시하고 있다. 반면에 임도는 산림법 제10조의 4에서“산림의 효율적인 개발·이용의 고도화 또는 임업의 기계화 등 임업의 생산기반 정비를 촉진하기 위하여 필요한 시설”이라고 대물을 위하여 제공되는 목적도의 개념을 밝히고 있다. 도로가 시설주체, 부지장소, 구조재료, 이용목적에 따라서 구분되는데 반하여 임도는 기능, 이용집약도, 설치위치와 목적에 따라서 구분하고 있다. 도로는 국토의 기능을 증대시키는 사회기반 시설로서 전국간선도로망에서부터 지역개발과 주변토지 이용을 활성화시키는 지역내의 도로망에 이르기까지 유기적인 망(網 : Network)을 이루어 타교통시설과 상호기능을 보완해 나간다. 따라서 도로는 이동의 신속성, 안전성, 쾌적성, 편리성 및 경제성을 도모하여 국토발전의 기반과 생활기반의 정비, 생활환경의 개선에 큰 역할을 하고 있다. 임도는 지역도로망의 한 부분으로서 산림경영의 합리화, 산림의 공익적 기능 증진 및 농산촌 진흥을 위한 기반시설로서 산림이라는 공간에 도로를 매개체로 시장 또는 생산·생활공간과 연결시켜 준다. + +### 나. 기능 + +임도의 기능은 크게 이동기능, 접근기능, 공간기능의 3가지로 구분된다. 이동기능은 교통류를 신속하고 원활하게 처리해 주는 기능으로서 임내 또는 주변 토지에서 생산된 물류를 신속하게 유통시키고, 사람들의 왕래와 여가활동을 위하여 신속성, 안정성, 편리성을 도모하는 것으로서 간선임도, 연결임도(Access Forest Road)가 여기에 해당된다. 접근기능은 임지이용의 활성화를 촉진시키는 기능으로서 임내의 구석구석까지 접근하여 산림 작업과 생산활동에 직접 이용되는 것으로서 지선임도, 경영임도(Management Forest Road)가 이에 해당된다. 공간기능은 제한된 공간을 갖는 집약적인 임업에서 집재, 집적, 주차 등의 공공용지나 휴양림에서 광장 등의 생활공간으로 사용된다. + +![총론_그림5-2-1_임도의기능과이용특성](<../pic/총론_그림5-2-1_임도의기능과이용특성.png>) + +그림 5-2-1. 임도의 기능과 이용특성 + +이동성과 접근성은 (그림 5-2-1)에서 보는 바와 같이 서로 상반되게 나타난다. 즉, 이동기능을 크게 할 경우에는 접근기능이 큰 임도에 비하여 통행량이 많고, 통행길이도 길며, 속도도 빨라져야 하므로, 임도의 구조와 규격을 상대적으로 양호하게 하여야 그 기능에 알맞는 교통류가 형성 될 것이다. 앞으로 지속적인 경제성장에 따라 국민소득과 생활수준이 향상되어 자동차대수는 계속 증가될 것이며 차량도 고속화, 대형화, 중량화 추세에 있으므로 임도의 질적 향상이 시급하고 또한 환경영향 평가 문제가 대두되고 있으므로 합리적인 임도계획을 수립함과 동시에 많은 기술향상이 요망된다. + +### 다. 효과와 경제성 + +임도는 연결되는 도로와 설치하는 산자락의 위치에 따라서 임지의 활용성, 경제성 및 관리성이 달라진다. 따라서 임도를 어떻게 시설하는 것이 가장 튼튼하고 경제성이 있는 임도가 될 것인가? 하는 것은 매우 중요한 일이다. + +#### 1) 설치위치에 따른 효율성과 경제성 + +우리나라의 임도는 시설당시의 정책적, 입지적 또는 사회적인 상황에 따라서 설치위치별로 많은 변화를 가져왔다. '70년대 이전에는 연기매각을 위한 수확의 목적으로 계곡부와 산기슭에 많이 분포되어 있던 임목들을 벌출하기 위하여 계곡부-산기슭에 임도를 주로 시설하였고, 80년대에는 임업기계훈련원을 통한 한독협력사업의 일환으로 서구식의 임도시설방법에 많은 영향을 받아 주로 산허리에 시설되었으며, 90년대에는 간선임도 위주 정책의 일환으로 연결임도를 많이 시설한 결과 산꼭대기에 임도가 많이 시설되었다. 따라서 우리나라 산야에는 산기슭에 위치하고 있는 임도가 약 27%, 산허리에 약 42%, 산정부에 약 31%로 분포되어 있지만 계곡부-산기슭-산허리-산꼭대기로 이어져 순환되는 노선이 대부분이다. 설치 위치에 따른 효율성은 입지적인 상태에 따라 다르지만 대체적으로 접근거리단축효과는 임도개설전을 기준(100%)으로 볼 때 계곡임도는 12~30%, 능선임도는 9~13%, 산복임도는 8~16%, 복합(계곡-능선)임도는 12~20%로서 산복임도와 능선임도가 다른 임도에 비하여 크게 나타났다. 임지의 이용면적 확대효과는 임도개설전을 기준으로 계곡임도는 9∼15배, 능선임도는 12∼19배, 산복임도는 13∼19배, 복합임도는 11∼16배로서 산복임도가 가장 효과가 큰 것으로 나타났다. ㎥당 임목수집비는 계곡임도가 14천원~21천원이 소요되는데 비하여 능선임도는 40-113%, 산 복임도는 53~77%, 복합임도는 55~136%가 소요되어 산복임도가 저렴하게 작업할 수 있었다. + +또한 ㎥당 운재비는 계곡임도가 1,300~1,600원이 소요되는데 비하여 능선임도는 107%까지 산복임도는 102%까지, 복합임도는 195%까지 증가하여 계곡임도가 운반비를 절감할 수 있는 시스템이었다. 따라서 임도는 산복부에 시설하는 것이 다른 위치에 시설하는 것보다 효율성과 경제성이 큰 것으로 나타났다. + +#### 2) 임도의 연결상태 + +모든 도로는 가급적 시장 또는 도시방향으로 가까이 갈수록 상급의 도로구조와 연결되는 것이 진·출입과 교통류의 흐름을 원활하게 할 수 있을 것이다. 그러나 현실적으로 임도와 연결되는 도로의 상태를 보면, 진입로는 공도가 34%, 농도 또는 마을도가 66%를 차지하고 있으나, 진출로는 공도가 19%, 농도 또는 마을도가 40%로서 59%는 순환이 가능하지만 나머지 41%는 타 도로와 연결되지 않고 임지내에서 끝난다. 이들 진출입에 연결되는 도로 가운데 공도와 농도는 비교적 구조가 양호하였지만 마을도의 경우에는 임도의 규격보다 열악한 구조를 가진 도로(진입로의 49%, 진출로의 61%)가 많은 것으로 나타났다. 이는 진·출입로가 병목현상에 의하여 원활한 교통 소통이 어려울 것으로 추정되므로 금후 임도의 구조·규격보다 열악한 진출입 구간에 대한 연결도로의 정비 또는 시공시 연결부분을 보완할 수 있는 제도적인 장치가 필요할 것으로 사료된다. + +#### 3) 임도의 활용형태 + +임도를 활용하는 차량의 주간 교통량은 노선당 평균 45.6회/주로서 활용형태를 살펴보면 일상 생활에 활용되는 경우가 34%, 농림업이 27%, 타산업이 9%로서 생활 및 작업과 관계되는 통행이 대부분을 차지하고 있었지만 휴양목적으로 이용되는 자동차의 비율이 22%나 나타난 것은 유의깊은 사안이다. 그러나 이용빈도는 200일이상 연중통행하는 것은 11%에 불과하였고 대부분이 50일 이내를 통행하였으며, 활용구간 또한 전구간을 통행하는 자동차는 17%에 불과하였고 대부분이 50%이내의 구간을 활용하고 있는 것으로 나타났다. 이는 임도의 시·종점 가까운 곳에 마을, 농경지, 축사 및 농공시설이 산재하여 있기 때문에 물류의 이동순로가 시·종점 연결방향으로 분산되기 때문이다. 통행하는 자동차의 종류는 화물차가 30%, 이륜차 14%, 경운기 4%로서 임도구조에 영향을 많이 받지 않는 작업에 관계되는 자동차의 통행이 약 1/2정도를 차지하고 있었으며, 또한 승용차의 통행량이 52%나 되어 마을주민들의 통행에도 많이 기여하고 있다. 또한 평일(10~13%)보다 주말(20~23%)에 활용이 더 많은 것은 앞에서 언급한 사안과 결부할 때 임도의 활용성면에서 주목할 만하다. 이와 같이 농어촌도로망과 연계되어 주민 교통편의의 목적으로 활용되는 구간은 임도의 구조를 다르게 적용하여 농어촌 도로망과 연계되는 하나의 교통망을 형성하게 할 수 있도록 조치하여야 할 것이다. + +### 라. 종류 +#### 1) 기능에 따른 구분 +- ① 지선임도(시업임도, 경영임도, Management Forest Road) : 조림, 육림, 수확 및 보호관리 등 임업경영의 목적으로 시설되는 임도 +- ② 간선임도(연결임도, 도달임도, Access Forest Road) : 산원까지 접근, 유역간의 연결, 농어촌도로망과 연계되어 지역경제활동에 기여하는 등 임업적 목적보다는 공익적 목적에 비중을 더 두고 시설되는 임도 +#### 2) 이용집약도에 따른 구분 +- ① 주임도(Main Forest Road) : 집재장 또는 부임도로부터 공도까지 연결되는 영구적인 임도 +- ② 부임도(Secondary Forest Road, Subsidiary Forest Road) : 집재장 또는 작업도로부터 주임도 또는 공도까지 연결되는 영구적인 임도 +- ③ 작업도(Skidding Road, Strip Road) : 임지 또는 운재로로부터 집재장, 부임도 또는 주임도까지 연결되는 일시적인 임도 +- ④ 운재로(Skidding Trail, Haul Road) : 임지에서부터 집재장 또는 작업도까지 연결되는 일시적인 임도로서 임목만 제거하고 대규모의 토양이동은 하지 않는다. +#### 3) 설치위치에 따른 구분 +- ① 주계곡임도(Main Vally Forest Road) +- ② 부계곡임도(Secondart Vally Forest Road) +- ③ 사면임도(Slope Forest Road) +- ④ 능선임도(Mountain Ridge Forest Road) +- ⑤ 산정임도(Mountain and Hill Tops Forest Road) +- ⑥ 분지임도(Vally Basins Forest Road) +#### 4) 규정에 의한 구분 +- ① 1급임도 : 유효너비 4m 이하 +- ② 2급임도 : 유효너비 3m 이하 +### 마. 노망의 운송체계 + +산림을 통과하고 있는 도로는 도로법에 의한 고속국도, 일반국도, 특별시도·광역시도, 지방도, 시도, 군도, 구도, 농어촌도로정비법에 의한 면도, 리도, 농도 등 여러 종류의 공도와 사도법에 의한 사도, 타 법령에 의하여 시설되는 목적도(目的道)가 있다. 임도는 산림내 또는 산림에 연결 시설하는 차도이므로 임내에 있을 수도 있고 임외에서 공도와 임지를 연결할 수도 있다. 따라서 임도망은 임산물이 임지에서 생산되어 공도를 경유하여 시장이나 제재공장 등 수요처까지 운반될 수 있는 연결노망의 일부분이라고 할 수 있을 것이다. 도로(공도)에서부터 임내까지 도달하는 것을 임내 접근수단이라고 하고, 산림내에서 생산되는 임산물이 공도를 거쳐 시장·공장 등 수요처까지 운반하는 것을 반출 또는 운송수단이라고 하며 그 운송체계는 (그림 5-2-2)와 같이 임산물이 생산된 위치와 임도망의 상태에 따라서 여러 가지의 경로로 가정할 수 있다. + +![총론_그림5-2-2_임내생산물의운송체계](<../pic/총론_그림5-2-2_임내생산물의운송체계.png>) + +운재로 작업도 임산물 집 재 + +- ② +- ① +- ③ 2급임도 +- ④ 공 도 +- ⑤ 그림 5-2-2의 ①과 같이 임산물이 지예집재·가선집재 등의 집재작업 과정을 + +통하여 간단하게 공도로 수송될 수 있는 산림이 있는가 하면 ⑤의 운송체계와 같이 집재, 운재로, 작업도, 2급임도, 1급임도를 거쳐야만 비로소 공도로 수송될 수 있는 산림이 있을 것이다. + +- ① , ②, ③과 같이 기존도로(임도)에 운재로와 작업도를 시설하여 반출하는 운송체계와 같은 + +노망형태를 세부노망형태라고 하며, 완구릉지 또는 산림내에 공도밀도가 높아서 간단한 시설로서 임목을 수송할 수 있는 지역에 해당된다. ④, ⑤와 같이 2급임도 또는 1급임도를 시설하여 반출하는 운송체계의 노망형태를 기본노망형태라고 하며, 오지 또는 산악림과 같이 공도밀도가 낮은 지역에 해당된다. 특히 ⑤와 같은 운송체계로서 시설하는 1급임도는 임업적 효과보다는 공익적 효과가 더 클 경우가 많다. 임도망을 편성할 경우에는 기본임도망의 단계만 취급하고 세부노망의 형태는 그 지역에서 시 업을 실행할 경우에 별도로 계획을 수립하여야 한다. 임도밀도가 점차적으로 높아져서 적정한 노망이 형성되게 되면 대부분의 임지가 ①∼③같은 운송체계로 변화하게 되므로 임내접근을 용이하게 함은 물론 임산물의 운송비를 저렴하게 하여 산림의 생산성을 더욱 향상시킬 수 있을 것이다. 임도망을 편성하고자 하는 대상지에 어떤 노망체계를 적용하는 것이 합리적일 것인가 하는 것은 대단히 어렵지만 대상지의 입지상태와 계획임도의 구조를 면밀히 검토하여 가장 합리적인 형태를 적용하여야 할 것이다. + +### 바. 임도망과 집·운재 + +집·운재방법은 그림 5-2-3과 같이 문화의 발달정도, 지형과 산림의 상태, 산림작업기술의 수준, 제품의 규격 및 판매기준 등에 따라서 많은 영향을 받게 된다. + +- 수운(Water Transportation) + - 관류(Flume): 철포 이용, 뚝(언) 이용 등 포함 + - 뗏목(편류, Rafting), 벌류(Floating) + - 선박(Shipping) +- 육운(Land Transportation) + - 활로(Chute): 토수라, 목수라, 플라스틱수라, 알루미늄수라 등 + - 지예(Skidding, Off Road): 인력, 동물, 임업용 트랙터, 트랙터 장치용 윈치류, 스키더류, 가선집재기 등 + - 도로(On Road): 인력 나르기, 우마차, 목마, 트럭류, 트랙터류, 포워더, 트레일러류 + - 궤도(Rail Way): 산림철도, 산림궤도 + - 삭도(Cable Way): 가선집재기류, 가공삭도, 철선운반 등 +- 공운(Air Transportation) + - 항공(Air Borne): 헬리콥터, 기구 + +그림 5-2-3. 집·운재방법과 종류 + +옛날에는 토수라, 목수라 등의 방법으로 집재하산하여 주로 관류, 뗏목, 벌류 등의 방법으로 운재하였다. 그러나 근래에는 도로교통과 자동차공업의 발달로 인하여 집재·운재방법 중 육운은 대부분 기계화 또는 자동차화되고 있고, 수운은 대부분 선박에 의존하고 있지만 앞으로 기구(氣球)나 헬리콥터에 의한 공운의 발달이 주목된다. + +육운의 방법으로 합리적인 임업경영을 하기 위한 필수적인 시설은 적정임도망이 되도록 노망 체계를 구축하는 것이다. 이는 산림작업 환경개선과 산림작업의 기계화에 의하여 노동력을 경감하고 노동의 질을 향상시켜 산림작업의 합리화와 능률화에 기여한다. 따라서 지형과 자연경관 및 산림작업성이 잘 조화될 수 있도록 노망을 계획하여야 하며 임도가 임지 경사의 상부에 배치되느냐 하부에 배치되느냐에 따라서 집재방법과 집재비용은 달라진다. 상부에 배치되면 상향집재방법을, 하부에 배치되면 하향집재방법을 채택한다. 주요 집재수단의 작업한계선은 표 5-2-1과 같다. + +표 5-2-1. 집재수단별 최대집재거리 및 작업안전 한계기울기 + +| 구분 | 집재수단 | 하향 최대거리(m) | 하향 한계기울기(%) | 상향 최대거리(m) | 상향 한계기울기(%) | 비고 | +|---|---|---:|---:|---:|---:|---| +| 인력 | 보조도구 이용 | 100(200) | 45(65) | | | | +| 인력 | 수라(Log-line) | 150(200) | 40(60) | | | | +| 자동차류 | 농용 트랙터 | 300(500) | 25(60) | 100 | 10(15) | | +| 자동차류 | 개조 트랙터 | 500(800) | 40(60) | 150 | 15 | | +| 자동차류 | 임업용 트랙터 | 800(1,000) | 45(60) | 200(250) | 20(25) | | +| 자동차류 | 포워더 | 2,000 | 35(40) | 1,500 | 20(25) | 습윤지역은 궤도식 이용(60%까지 가능) | +| 자동차류 | 프로세서·하베스터 | 50 | 30(40) | 50(50) | 10(15) | | +| 가선류 | 기계톱 윈치 | 50 | 35 | 50(100) | 45 | High-lead 방식 | +| 가선류 | 트랙터 윈치 | 150(200) | 100 | 150(200) | 120 | High-lead 방식 | +| 가선류 | 고정식 가선집재기 | 1,000(2,000) | 100 | 1,000(2,000) | 120 | Standing skyline 방식 | +| 가선류 | 타워형 가선집재기(단거리) | 300 | 100 | 400 | 120 | | +| 가선류 | 타워형 가선집재기(중거리) | 500 | 100 | 800 | 120 | | +| 가선류 | 타워형 가선집재기(장거리) | 500(800) | 100 | 1,000 | 120 | | + +※ 괄호 안 숫자는 특수한 경우의 최대치이다. + +자료: Sedlak(1985, 1987), Murphy(1979), Heinrich(1982), Trzesniowski(1985), FAO(1976), Studier and Binkley(1982), Sundberg and Silversides(1988), スリ-エ硏究會(1991), 김(1993). + +#### 1) 하향집재 +- ① 인력집재 : 수피가 있는 임목의 지예집재는 경사도가 80%이상, 수피가 없는 임목은 60%이상이면 가능하나 적설이나 지표가 젖어 있을 경우에는 32%에서도 가능하다. +- ② 동물집재 : 평탄지에서 가능하고 동물의 종류나 임목의 중량에 따라서 경사도 35%까지 가능하다. +- ③ 농용트랙터 : 경사도 30%까지 가능하고 바퀴에 체인을 감거나 안전탑과 같은 특수장치를 부착하면 40%까지 가능하다. +- ④ 스키더 : 바퀴에 체인을 감는 경우에는 경사도 50%까지, 단거리의 건조한 지반에서는 60%까지 가능하다. +- ⑤ 중력식 케이블크레인 시스템 : 상단부에 설치하여 작업할 경우에는 경사도 20%이상 지형에서도 가능하다. +#### 2) 상향집재 +- ① 인력집재 : 일반적으로 적용할 수 없다. +- ② 동물집재 : 가벼운 적재와 단거리 집재는 경사도 10%까지 가능하다. +- ③ 농용트렉터 : 경사도 20%까지 가능하다. +- ④ 스키더 : 경사도 25%까지 가능하다. +- ⑤ 케이블에 의한 지예집재 : 장애물이 없는 지형에서는 30m까지 가능하고 특별한 경우에는 100m, 쌍드럼으로 설치된 것은 300m까지도 가능하다. +- ⑥ 케이블크레인 시스템 : 상단부에 설치할 경우에는 경사도 20%까지 가능하다. + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 1~7쪽 (인쇄면 371~377쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md new file mode 100644 index 00000000..9bb8454c --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/2. 임도구조.md @@ -0,0 +1,336 @@ +# 2. 임도구조 + +임도의 구조(Forest Road Structure)를 계획하고자 할 때에는 토목적인 면, 경제적인 면 및 환경적인 면을 고려하여야 하며, 임산물의 종류, 집재방법, 수송시기 및 수량에 따라서도 구조나 규격이 달라질 수 있다. 임도상을 운행할 수 있는 차량의 제원(諸元)은 (표 5-2-2)와 같이 자동차의 종류에 따라 다르므로 이용목적에 따라 통행할 수 있는 자동차 가운데 최대의 규격을 적용한다. 표 5-2-2. 설계차량의 제원(단위:m) + +| 구 분 종 류 | 길이 | 너비 | 높이 | 앞내민거리 | 앞뒤바퀴 거 리 | 뒤내민거리 | 최소회전 반 지 름 | +|---|---|---|---|---|---|---|---| +| 소형 자동차 | 4.7 | 1.7 | 2.0 | 0.8 | 2.7 | 1.2 | 6.0 | +| 보통 자동차 | 13.0 | 2.5 | 4.0 | 2.5 | 6.5 | 4.0 | 12.0 | +| 세미트레일러 연결차 | 16.7 | 2.5 | 4.0 | 1.3 | 전: 4.2
후: 9.0 | 2.2 | 12.0 | + +### 가. 자동차 설계속도 + +설계속도(design speed)는 계획하는 노선의 경제성에 따라 결정되고 있으나 다음과 같이 설정되고 있는 것이 일반적이다. + +- ① 평지보다 산지인 경우를 낮게 한다. +- ② 장거리교통보다 단거리교통인 경우를 낮게 한다. +- ③ 교통량이 많은 노선보다 적은 노선인 경우를 낮게 한다. 자동차의 설계속도는 앞차 앞면과 후속차 앞면의 간격과 그 곳을 통행하는 교통량으로부터 + +산출되고 있지만, 임도는 보통 1차선으로 시설되고 있어 자동차의 교행이 어려우므로 대피소간의 왕복거리와 교통량으로 산출한다. + +$$V=\frac{N\cdot d}{1,000}$$ + +- $V$: 자동차의 주행속도 또는 설계속도(km/hr) +- $N$: 시간당 교통량(대/hr) +- $d$: 차두간격($4.5+0.186V+0.00154V^2$) 또는 대피소 간의 왕복거리(m) + +(예제) 서산군 관내에 시설되는 임도의 설계속도를 산출하기 위하여 주위의 실정을 검토한 바 대피소 간격은 300m, 시간당 교통량은 50대/hr로 나타났다. + +$$V=\frac{50\times(300\times2)}{1,000}=30\,\mathrm{km/hr}$$ +### 나. 차도폭 +#### 1) 1차선일 경우 +##### 가) 설계속도에 의할 경우 + +$$W=B+\frac{V}{50}+0.5$$ + +- $W$: 차도폭(m) +- $B$: 자동차의 폭(m) +- $V$: 설계속도(km/hr) + +(예제) 설계속도가 30km/hr이고 자동차폭이 2.5m일 때 차도폭은 다음과 같다. + +$$W=2.5+\frac{30}{50}+0.5=3.6\,\mathrm{m}$$ +##### 나) 길가(路端)와 자동차의 간격에 의할 경우 + +$$W=B+2(b-b')$$ + +- $b'=0.3$m: 자동차 바퀴와 가장자리의 간격 +- $b=K_1V$: 자동차 바퀴에서 길가까지의 간격($K_1=0.01411$) + +(예제) 자동차폭 2.5m, $b'=0.3$m, 설계속도 30km/hr일 때 $b=0.01411\times30=0.4233$m이다. +- ② 차도폭(W) = 2.5+2×(0.4233-0.3) = 2.75m +#### 2) 2차선일 경우 + +$$W=2(B+b)+b_0-2b'$$ + +- $b=K_1V$($K_1=0.01411$) +- $b'=0.3$m +- $b_0=K_2V^2$($K_2=0.00016$) + +(예제) 자동차폭 2.5m, $b'=0.3$m, 설계속도 40km/hr일 때: + +- ① $b=0.01411\times40=0.5644$m +- ② $b_0=0.00016\times40^2=0.256$m +- ③ 차도폭(W) = 2×(2.5+0.5644)+0.256-(2×0.3) = 5.78m +### 다. 곡선반지름(radius of curve) +#### 1) 운반되는 통나무의 길이에 의할 경우 + +곡선부를 통과하는 트럭의 앞바퀴와 적재된 화물의 끝부분은 노측구조의 장애로 인하여 교통에 저해받지 않을 만큼 충분한 여유노폭이 있어야 한다. + +$$R=\frac{\ell^2}{4B}$$ + +- $R$: 곡선반지름(m) +- $\ell$: 통나무길이(m) +- $B$: 노폭(m) + +(예제) 길이 20m의 전간목을 노폭 4m인 임도에서 운반할 때 $R=20^2/(4\times4)=25$m이다. + +#### 2) 원심력과 타이어 마찰계수에 의할 경우 + +곡선부를 주행하는 자동차에 가해지는 원심력은 횡방향력이 타이어와 노면의 마찰력에 대한 한계를 넘지 않아야 한다. + +$$R=\frac{V^2}{127(i+f)}$$ + +- $V$: 설계속도(km/hr) +- $i$: 곡선부 외쪽물매(%/100) +- $f$: 가로미끄러짐에 대한 노면과 타이어의 마찰계수 + +| 구 분 | 마찰계수 | 구 분 | 마찰계수 | +|---|---|---|---| +| 자 갈 도 | 0.4∼0.5 | 콘크리트 포장도 | 0.4∼0.6 | +| 빙 설 면 | 0.2∼0.3 | 아스팔트 포장도 | 0.4∼0.8 | +| ※ 임도의 설계속도가 40km/hr 이하일 때는 0.15를 적용 | | | | + +(예제) 설계속도 40km/hr, 외쪽물매 6%, $f=0.15$일 때: + +$$R=\frac{40^2}{127\times(0.06+0.15)}=60.0\,\mathrm{m}$$ + +표 5-2-3. 설계속도와 곡선반지름(m) + +| 설 계 속 도 (km/hr) | 외 쪽 물 매 (%) | | | | +|---|---|---|---|---| +| | 5 | 6 | 7 | 8 | +| 40 | 63 | 60 | 57 | 55 | +| 30 | 35 | 34 | 32 | 31 | +| 20 | 16 | 15 | 14 | 14 | +| ※ 가로미끄러짐 마찰계수 0.15 적용 | | | | | + +### 라. 곡선부 확폭 + +자동차의 뒷바퀴는 뒷차축에 직각으로 장치되어 있어 (그림 5-2-4)와 같이 항상 앞바퀴보다 안쪽으로 기울어져서 곡선부를 통과하므로 앞바퀴와 뒷바퀴는 각각 다른 궤도를 그리면서 주행한다. 따라서 곡선부의 내각이 예각일 경우에는 이러한 현상이 더욱 심하기 때문에 곡선부의 안쪽으로 그 만큼 더 확폭(Widening of Road)을 하여야 한다. + +#### 1) 트럭일 경우 + +$$\varepsilon=\frac{L^2}{2R}-0.5$$ + +![임도구조_그림5-2-4_자동차바퀴의구동형](<../pic/임도구조_그림5-2-4_자동차바퀴의구동형.png>) + +- $\varepsilon$: 확폭량(m) +- $R$: 중심선의 곡선반지름(m) +- $L$: 차량 앞면에서 뒷차축까지 거리(m, 8m 적용) + +(예제) 광릉시험림 내 임도의 최소곡선반지름이 20m일 때 굴곡부의 확폭량은 $\varepsilon=8^2/(2\times20)-0.5=1.1$m이다. +#### 2) 세미트레일러(semi-trailer) 연결차일 경우 + +$$\varepsilon=\varepsilon_1+\varepsilon_2=\frac{L_1^2}{2R}+\frac{L_2^2}{2R'}$$ + +- $R'=R-\varepsilon_1$ +- $L_1$: 세미트레일러 앞면에서 제2차축까지 거리(m) +- $L_2$: 제2차축에서 최후 차축까지 거리(m) + +(예제) $L_1=6.7$m, $L_2=11.5$m, $R=20$m일 때: + +- ① $\varepsilon_1=6.7^2/(2\times20)=1.12$m +- ② $R'=20-1.12=18.88$m, $\varepsilon_2=11.5^2/(2\times18.88)=3.50$m +- ③ 확폭량(ε) = ε1+ε2 = 1.12+3.50 = 4.62m +### 마. 완화구간 + +자동차의 원활한 통행을 위하여 다음 구간에 대하여는 (그림 5-2-5)와 같이 완화구간을 설치하지만, 이정량(移程量)이 20cm이하일 경우에는 설치하지 아니한다. + +- ① 직선부와 곡선부, 혹은 곡율이 다른 곡선부의 연결구간에 설치 +- ② 외쪽물매와 직선부의 횡단물매 또는 외쪽물매 상호간의 연결구간에 설치 +- ③ 곡선부·확폭구간과 직선부의 연결구간에 설치 + +$$L=\frac{0.036V^3}{R}$$ + +- $L$: 완화구간의 길이(m) +- $R$: 곡선반지름(m) +- $V$: 설계속도(km/hr) + +표 5-2-4. 설계속도별 완화구간의 길이(m) + +| 설 계 속 도(km/hr) | 20 | 30 | 40 | 50 | 60 | +|---|---|---|---|---|---| +| 완화구간의 길이(m) | 20 | 25 | 35 | 40 | 50 | + +![임도구조_그림5-2-5_완화구간의형태](<../pic/임도구조_그림5-2-5_완화구간의형태.png>) + + +### 바. 물매 + +물매의 표현방법은 다음과 같다. +- ① 각도 : 수평을 0°, 수직을 90°로 하여 그 사이를 90등분한 것 +- ② 1 : n 또는 1 / n : 높이 1에 대하여 수평거리 n으로 나눈 것 +- ③ n % : 수평거리 100에 대한 n의 고저차를 갖는 백분율 +- ④ n ‰ (per mill) : 수평거리 1000에 대한 n의 고저차를 갖는 천분율 +- ⑤ 비탈물매 : 수직높이 1에 대한 수평거리의 비(比)로서 하할법 또는 할푼법이라 함 ※ 15/100 = 15% = 150‰ = tan 8°31' 50.8" = 1/6.67 = 6할 7푼 +#### 1) 곡선부 외쪽물매(片勾配)의 산출 + +자동차가 원심력에 의하여 도로의 바깥쪽으로 뛰쳐나가려는 힘이 생기므로 이를 방지하기 위하여 곡선부에서는 외쪽물매(Super Elevation, Oneway Grade)를 설치한다. 외쪽물매는 노면 바깥쪽이 안쪽보다 높게 설치되도록 횡단선형을 조정한다. + +$$i=\frac{V^2}{127R}-f$$ + +- $i$: 곡선부의 외쪽물매(%/100) +- $V$: 설계속도(km/hr) +- $R$: 곡선반지름(m) +- $f$: 가로미끄러짐에 대한 노면과 타이어의 마찰계수 + +표 5-2-5. 설계속도별 곡선반지름별 한계 외쪽물매(%) + +| 설계속도(km/hr) | 곡 선 반 지 름(m) | | | | | | | +|---|---|---|---|---|---|---|---| +| | 12 | 15 | 20 | 30 | 35 | 40 | 45 | +| 20 30 40 | 8 | 3∼5 | 8 | 8 | 5∼6 | 3∼5 8 | 8 | + +(예제) 설계속도 40km/hr, 곡선반지름 50m, $f=0.15$이면 $i=40^2/(127\times50)-0.15\fallingdotseq0.102=10.2$%이다. 10.2%는 8%를 초과하므로 현지 여건에 따라 8% 이하가 되도록 곡선반지름을 키우거나 설계속도를 낮춰야 한다. + +#### 2) 횡단물매의 산출 +- ① 횡단물매(Cross Grade, Cross-Fall)의 결정은 노면배수와 교통안전의 두 가지 측면으로 고려할 수 있다. 노면배수의 측면에서 볼 때는 노면이 평활하면 배수상태가 불량하고 반대로 노정(路頂)을 높게 하거나 외쪽물매를 크게 하면 주행안전성에 영향을 미친다. +- ② 횡단물매는 포장의 재료, 노체의 종류 및 시공방법에 따라 다르지만 교통으로 인한 마모가 적은 재료를 사용한 노면은 내구력이 크므로 노정고(路頂高)가 낮아도 된다. +- ③ 횡단물매는 노정고와 차도폭의 1/2로서 산출하거나, 외쪽물매인 경우는 양 노단높이(路端高)의 차와 노폭으로 산출하며 백분율 또는 분수로 표시한다. +- ④ 횡단물매의 형상은 직선, 원호, 2차 포물선, 쌍곡선 등이 있으나, 아스팔트 포장도는 쌍곡선, 자갈도는 포물선, 콘크리트 포장도는 직선이 주로 사용되고 있다. +#### 3) 합성물매의 산출 + +종단물매와 외쪽물매 또는 횡단물매를 제곱하여 합한 값의 제곱근을 합성물매(合成勾配: Composite Gradient)라고 한다. 합성물매는 다음 식으로 산출한다. + +$$S=\sqrt{i^2+j^2}$$ + +- $S$: 합성물매(%) +- $i$: 외쪽물매 또는 횡단물매(%) +- $j$: 종단물매(%) + +대개 합성물매는 12% 이하로 하는 것이 좋으며 부득이한 경우에도 13∼15% 이하로 하는 것이 좋다. 합성물매로 산출한 종단물매는 표 5-2-6과 같다. + +표 5-2-6. 한계 합성물매와 외쪽물매에 의한 종단물매(%) + +| 한계합성물매(%) | 외쪽물매 또는 횡단물매(%) | | | | | | +|---|---|---|---|---|---|---| +| | 8 | 6 | 5 | 4 | 3 | 2 | +| 12 | 8.9 | 10.4 | 10.9 | 11.3 | 11.6 | 11.8 | +| 13 | 10.2 | 11.5 | 12.0 | 12.4 | 12.6 | 12.8 | +| 15 | 12.6 | 13.7 | 14.1 | 14.5 | 14.7 | 14.9 | + +(예제) 산악지 임도에서 종단물매 9%의 구간에 곡선부의 외쪽물매를 7%로 설치하고자 할 때 합성물매를 산출하여라. + +$$S=\sqrt{7^2+9^2}=11.4\%$$ + +#### 4) 종단물매의 산출 + +임도의 구조 중 노폭이나 곡선반지름 및 다른 구조시설은 임도의 시공후에도 개수나 보수에 의하여 구조변경이 가능하지만, 종단물매의 변경은 전 노선을 조정하여야 하는 재시공을 의미하기 때문에 임도의 축조 중 가장 중요한 부분은 종단물매(Longitudinal Grade, Gradient)라고 할 수 있다. 자동차의 종류에 따라서 종단물매를 오를 수 있는 능력의 차이가 크기 때문에 모든 자동차에 대하여 설계속도를 보장할 수 있는 일정한 기준을 세운다는 것은 사회적, 경제적인 견지에서 볼 때 불가능하다. 이 때문에 종단물매의 일반치는 승용차에서는 대개 설계속도 정도로, 보통자동차에서는 설계속도의 약 50∼80% 정도로 오를 수 있는 상태를 조건으로 설정하여 정한다. 노선계획시 물매를 높게 하면 임도우회율이 적어지므로 연장이 짧아져서 임도시설비가 감소될 수 있지만 자동차의 통행에 지장을 초래함은 물론 강우로 인한 피해가 많아져서 유지관리비가 증가한다. 반면에 물매를 낮게 하면 임도우회율이 커지므로 연장이 길어져서 시설비는 증가될 수 있지만 자동차 통행의 안정성이 도모되고 유지관리비가 감소하기 때문에 더 경제적일 수 있다. 노면배수가 불량하면 연약노면으로 변하여 노면의 형상이 변하기 쉽고, 차륜(車輪)에 의한 바퀴자국(軌跡)으로 인한 유로(流路)가 발생하여 노면재해를 가중시킨다. 이를 방지하기 위한 물 매를 최소물매라고 하며 3% 이상으로 설치하는 것이 좋다. 토사도 또는 사리도에서 노면배수, 노면의 안정성 등 자연재해예방을 위하여 4∼8% 정도로 설치하는 것이 바람직하고 부득이 하더라도 10%를 초과하지 않도록 한다. + +##### 가) 곡선부 적정종단물매의 산출 +- ① 외쪽물매 6%미만일 경우 : G - (80 / R) > S +- ② 외쪽물매 6% 이상일 경우: $G-(120/R)>S$ + +여기서 $G$는 설계기준에 의한 최대 종단물매(%), $R$은 곡선반지름(m), $S$는 완화구간을 포함한 곡선길이의 종단물매(%)이다. + +(예제) 종단물매 6%, 곡선반지름 50m, 외쪽물매 6%, 2급임도 표준물매 9%일 때 $9-(120/50)=6.6>6$%이므로 이 구간의 물매는 수정할 필요가 없다. +##### 나) 합성물매에서 곡선부 종단물매의 산출 + +$$j=\sqrt{S^2-i^2}$$ + +(예제) 광릉시험림 임도는 합성물매를 12% 이하로 설정한다. 외쪽물매를 6%로 적용할 때 종단물매는 다음과 같다. + +$$j=\sqrt{12^2-6^2}=10.4\%\fallingdotseq10\%$$ +##### 다) 물매곡율비의 산출 + +곡선부의 내각이 예각일 경우에는 급한 곡선이 설정되기 때문에 곡선부를 통과하는 자동차의 안정성에 영향을 크게 미치게 된다. 물매곡율비는 다음 식으로 산출한다. + +$$K=\frac{R}{I}$$ + +- $R$: 곡선반지름(m) +- $I$: 종단물매(%) +- $K$: 물매곡율비 + +| 구 분 | 물매곡율비 | 구 분 | 물매곡율비 | +|---|---|---|---| +| 평탄지 도로 | 7.5 이상 | 산악지 도로 | 4.0 이상 | +| 구릉지 도로 | 6.0 이상 | 임 도 | 3.0 이상 | + +일반적으로 외거(External Distance)는 교점(Intersection Point)에서 중곡점(Middle of Curve)까지의 거리로써, 그것의 크기는 내각의 크기에 반비례한다. 곡선반지름 10m 기준으로 볼 때 외거의 크기는 내각이 55°이면 11.657m, 105°는 2.605m, 135°는 0.824 m, 155°는 0.234m로서 내각이 클수록 교점과 중곡점은 가까워지므로 곡선반지름을 크게 적용할 수 있다. 따라서 산악지 지형조건에서는 내각이 예각에 가까울 경우에는 외거가 커지므로 곡선반지름을 크게 설치할 수 없기 때문에 구조를 보완하여 자동차 통행의 안정성이 보장될 수 있도록 물 매곡율비를 적용한다. 예를들면 물매곡율비(곡선반지름/종단물매)를 3.0이상으로 유지시키도록 설치·시공하려면 곡선반지름이 10m일 때 종단물매는 3%내외로 조정할 수 밖에 없고, 내각이 105°이상으로 클 경우에는 곡선반지름을 20m로 하여도 6%내외, 155°이상일 경우에는 직선부와 거의 같도록 설치하여도 된다. + +### 사. 가시거리 +#### 1) 제동정지 가시거리(stopping S.D.)의 산출 +##### 가) 대상물이 고정되어 있을 경우 + +$$S=0.694V+\frac{0.00394V^2}{f}$$ + +- $S$: 가시거리(m) +- $V$: 주행속도(km/hr) +- $f$: 타이어와 노면의 가로미끄러짐 마찰계수 + +| 노면상태 | 설계속도(km/hr) | | | +|---|---|---|---| +| | 30 | 35 | 50 | +| 건 조 | 0.65 | 0.64 | 0.61 | +| 습 윤 | 0.44 | 0.40 | 0.35 | + +※ 0.694는 운전수의 반응시간($t$)이 2.5초일 때 $t/3.6$으로 산출한 값이며, 0.00394는 중력가속도($g$)가 9.8m/sec²일 때 $1/(2g\times3.6^2)$으로 산출한 값이다. + +표 5-2-7. 제동정지 가시거리(m) + +| 노면상태 | 설계속도(km/hr) | V(km/hr) | f | $0.694V$ | $0.00394V^2/f$ | S(m) | +|---|---|---|---|---|---|---| +| 건조 | 30 | 30 | 0.65 | 21 | 5 | 26 | +| 건조 | 35 | 35 | 0.64 | 24 | 8 | 32 | +| 건조 | 50 | 50 | 0.61 | 35 | 16 | 51 | +| 습윤 | 30 | 28 | 0.44 | 19 | 7 | 26 | +| 습윤 | 35 | 33 | 0.40 | 23 | 11 | 34 | +| 습윤 | 50 | 46 | 0.35 | 32 | 24 | 56 | + +##### 나) 양쪽에서 마주오는 자동차가 동시에 정지할 경우 + +$$S=1.388V+\frac{0.00788V^2}{f}$$ + +(예제) 설계속도 40㎞/hr일 때 마찰계수($f$)=0.40이라면 제동정지 가시거리는 얼마인가? + +- ① 양방향에서 주행할 경우: $S=1.388\times40+(0.00788\times40^2)/0.40=87.40\,\mathrm{m}$ +- ② 대상물이 고정되어 있는 경우 : S = 0.694×40+0.00394×402/0.40 = 43.52m +#### 2) 곡선부 가시거리의 산출 + +(그림 5-2-6의 ㉠)과 같이 곡선부의 안쪽에 절토부가 있을 때에는 시야가 가려지므로 (그림 5-2-6의 ㉡)과 같이 층따기를 하여 시야를 넓혀야 할 필요가 있다. + +$$S=Q\cdot R=0.01754\theta R$$ + +$$d=R\left\{1-\cos\left(\frac{\theta}{2}\right)\right\}=R\left\{1-\cos\left(\frac{28.7S}{R}\right)\right\}$$ + +- $S$: 가시거리(m) +- $Q$: 호도법에 의한 중심각 +- $R$: 곡선반지름(m) +- $d$: 중심선에서 안쪽으로 층따기를 하여야 할 거리(m) + +![임도구조_그림5-2-6_굴곡부의가시거리확보방법_01](<../pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_01.png>) + +![임도구조_그림5-2-6_굴곡부의가시거리확보방법_02](<../pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_02.png>) + +그림 5-2-6. 굴곡부의 가시거리 확보방법 + +(예제) 산림경영시범단지 내의 임도시설 시 안전거리를 산출하려고 한다. 곡선의 내각이 45°, 반지름이 40m일 때 가시거리($S$)와 안쪽 층따기 거리($d$)를 구하여라. + +- ① $S=0.01754\times45\times40=31.57\,\mathrm{m}$ + +- ② d=40×{1-cos(45°/2)}=3.0m +### 아. 주요 국가의 임도구조 + +주요 국가의 임도규정에 규정된 구조는 (표 5-2-8)과 같다. 표 5-2-8. 각국의 임도구조 + +| 구분 | 단위 | 우리나라 | 일본 | 중국 | 미국 | 캐나다 | 독일 | 오지리 | 스위스 | 스웨덴 | 노르웨이 | 영국 | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 등급 구분 | | 2 | 3 | 4 | 4 | 6 | 3 | 2 | 3 | 6 | 4 | | +| 설계속도 | km/hr | 20∼40 | 20∼40 | 20∼50 | | 55∼79 | | | 30∼60 | 20∼60 | 25 | | +| 차도폭 | m | 3∼4 | 2∼4 | 3∼6.5 | 3.7∼5.5 | 6.1∼9.7 | 3∼9 | 4.5∼5.5 | 3∼3.6 | 4∼7 | 3∼4 | 3.2∼4.7 | +| 갓길폭 | m | 0.50 (0.25) | 0.50 (0.25) | | 0.60 | 0.90∼1.20 | 0.75∼1.00 | 0.75 | | | 0.25∼0.50 | 0.50 | +| 최소곡선반지름 | m | 15∼60 (12∼40) | 15∼60 (8∼40) | 15∼120 (20∼60) | 15.2∼30.5 | | 20∼50 (16) | 12∼16 | 13.5∼120 (8∼10) | 25∼150 | 20∼60 | 15∼45 | +| 최급종단물매 | % | 7∼9 (14) | 7∼9 (14) | 800-4
500-5
400-6
300-7
250-8
150-9
100-10 | A300-8
150-10
B450-10
150-12
C300-16 | 8∼9 (10) | 6∼8 (10) | 9∼12 (12∼16) | 3∼10 (12) | 8∼12 (14) | 10∼15 (11∼18) | 10 | +| 최저종단물매 | % | | | | 2 | | 2 | 2∼4 | 3 | | | | +| 최대역물매 | % | 6∼10 | | | A300-6
150-8
B450-8
150-10
C300-12 | 3∼12 | | 6∼10 | | | 7∼12 (8∼13) | | +| 횡단물매 | % | 3∼5 | 3∼5 | | | | | | 3 | | | | +| 안전시거 | m | 20∼40 | 20∼40 | 40∼230 (15∼100) | 76∼122 | | | | 28∼145 | 40∼130 (150) | 20∼50 | | +| 대피소 간격 | m | 300∼500 | 300∼500 | 200∼300 (500) | 230 | | | | 150∼250 | | 150∼400 | | +| 측구 등 | m | | | | 폭 0.9
깊이 0.5 | | 폭 0.4∼0.45 | 암거 최소경 0.3∼0.6 | | | 폭 0.5∼1.5
깊이 0.5∼0.8 | | +| ※ 자료: 피해 예방을 위한 임도의 구조 개선. 임연연보 50, 1994. | | | | | | | | | | | | | + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 8~18쪽 (인쇄면 378~388쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md new file mode 100644 index 00000000..d5158624 --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/3. 임도계획.md @@ -0,0 +1,664 @@ +# 3. 임도계획 + +임도의 계획은 임도망을 계획하고자 하는 구역 안에 임도를 어느 정도의 밀도로서 어떻게 배치하는 것이 경제적이고 이용효율성이 높은 임도를 시설할 수 있는가를 알기 위하여 필요하다. 임도의 계획은 계획기간에 따라서 장기계획, 단기계획, 시공계획의 단계로 구분된다. + +- ① 장기 계획 지역 전체를 대상으로 하는 5년 이상의 계획기간으로서 해당지역 내의 목표임도밀도에 해당 + +되는 모든 노선을 임도망으로 편성하며 각 임도간의 상대적인 타당성, 경제성 및 문제점의 크기에 따라 우선순위와 투자계획을 수립한다. + +- ② 단기 계획 해당 노선 또는 구간을 대상으로 하는 5년 이내의 계획기간으로서 사업실행을 주목적으로 하 + +는 타당성 조사가 이에 해당한다. + +- ③ 시공 계획 공사실행을 위한 개별 사업계획으로 계약서류와 공사설계도·서 작성을 위한 실시설계가 이 + +에 해당된다. + +### 가. 임도망 +#### 1) 임도망 계획시 검토사항 + +임도의 계획은 임도를 매개체로 통행되는 교통류와 개발되는 유역에 활동요인을 부과하는 것이므로 통행의 목적, 개발목적, 자연경관, 지형, 작업조건 및 사회적 환경조건과 조화를 이룰 수 있도록 하여야 한다. 따라서 각각의 노선을 결정하고 평가를 할 경우에는 각각의 대안노선에 대하여 사회적, 경제적, 기술적 요인 등 세가지 측면에서 검토하고 그 결과에 따라 가장 적정한 노선을 선정한다. + +##### 가) 사회적 요인 + +다음 항목을 충분히 조사하여 관계기관과 협의·조정하고 지역주민의 의견도 수렴한다. +- ① 산림경영의 현황과 금후계획 예측 +- ② 도시집락, 촌락, 경작지와 관계(노선통과에 따른 분석) +- ③ 주택, 식수 등의 주거환경과 관계(접근에 의한 소음, 오염 등의 피해, 경관저해 등의 환경 문제) +- ④ 유적, 매장문화재, 절, 묘지 등 민족유산과 관계(통과에 의한 파괴) +- ⑤ 자연경관, 자연생태계와 관계(자연환경의 파괴) +- ⑥ 자연조건의 변화(수리, 기상의 변화에 따른 수해, 냉해 등) +- ⑦ 지역의 장래 계획과 관계(타 개발사업계획 검토) +##### 나) 경제적 요인 + +임도계획의 타당성 여부를 공사비와 유지관리비 등의 투자적인 측면과 그 투자에 따라 얻어지는 경제적인 편익에 대해서 계량적으로 평가·검토한다. + +- ○ 개략 계획 단계에서 경제적 요인 검토 +- ① 노망 전체 또는 개개노선에 대해서 경제성 여부를 따져서 경제적 타당성을 판단한다. +- ② 편익에는 집·운재비 감소, 통근시간 단축, 임산물의 가치성 향상 등의 직접적인 편익과 주변지역에 미치는 영향 등의 간접적인 편익으로 구분한다. +- ○ 기본 계획 단계에서 경제적 요인 검토 +- ① 임도망 계획으로 설정된 각각의 노선 또는 하나의 노선내에 일부구간에 대한 비교노선이 있는 경우와 시점, 종점의 설치위치 등에 대하여 경제적 평가를 하거나, 주요구조물의 기본형식에 대해서도 공사비와 유지관리비 등의 경제성을 검토한다. +- ② 비교노선이 비교적 길어서 각 구간에 대한 이해관계가 다를 경우에는 각각에 대한 평가가 필요하다. 특히 장거리 노선의 통과위치가 다를 경우에는 개략계획과 마찬가지로 간접적인 편익과 그 이외의 사회적 효과도 포함하여 검토한다. +- ○ 실시 설계 단계에서 경제적 요인 검토 +- - 선형과 도로구조물의 설계에 대한 공사비와 유지관리비를 비교하는 것이 주체가 되는 것으로 기본계획 등에서 검토된 경제성 분석의 내용을 최종적으로 확인한다. +##### 다) 기술적 요인 +- ○ 교통 기술적 요인 교통의 안전성과 원활한 이동성의 관점에서 다음과 같이 검토 평가한다. +- ① 지역도로망의 연계성 검토 : 지역도로망으로서 적합성, 시·종점 교통처리방안, 병목현상(Bottle Neck) 방지 등을 검토한다. +- ② 설계속도와 선형설계의 검토 : 일정구간에 대한 동일한 설계속도의 적용이 가능한 선형, 직진성(Directness) 등을 검토한다. +- ③ 집재장, 회전장, 대피소 등의 검토 : 집재작업 등 산림작업의 수행 중 차량교행, 지체 등으로 인하여 교통이 지체되거나 중단되는 일이 없도록 간격과 구조를 검토한다. +- ④ 교통용량 및 활용수준을 분석한다. +- ○ 구조 기술적 요인 시공성과 안전성 및 유지관리상의 문제점을 다음과 같이 검토 평가한다. +- ① 지질, 토질 등 자연조건 : 시공중 또는 시공후의 유지관리측면을 고려하여 재해우려지역, 단층·파쇄대지역, 벼랑(Cliff, Bluff)지대, 대규모 절토지, 연약지반, 깊은 계곡부 등의 통과 여부를 검토한다. +- ② 하천, 큰 계곡의 도하지점 : 수리, 수문, 지질, 하폭, 여울목 등을 고려한다. +- ③ 타 도로 및 철도와의 접속 : 교차부의 선형 및 가시거리 등을 검토한다. +- ④ 대능선의 통과 : 우회, 절개 등에 대한 비교 검토를 실시한다. +- ○ 작업적 요인 작업성 및 활용성을 다음과 같이 검토 평가한다. +- ① 설치 위치 : 활용가능 자동차 및 기계장비의 종류, 작업방법 등을 검토한다. +- ② 산림생산성 향상성 : 지형(경사도, 기복도 등), 임상, 경급, ha당 재적 등을 검토한다. +#### 2) 노선 계획시 검토사항 + +노선계획은 임도계획의 기초를 이루는 가장 중요한 단계로서 당해노선이 통과하게 될 유역의 입지환경과 경제효과, 교통 및 구조기술상의 특질, 경제성 등의 요구조건에 가장 부합되도록하기 위한 과정으로서 개략계획-기본계획-실시설계의 순서로 실행된다. + +##### 가) 개략 계획 +- ① 개략계획은 임도망계획에서 구상된 어느 한 노선에 대한 현지 노선선정작업의 준비단계로서 1/50,000 또는 1/25,000 지형도에 주요 지역도로망과 구역내로 통과 또는 연접되는 농도, 마을도로, 경작지도로까지 확인하고 임산물의 반출순로와 주변 교통체계상의 특성을 비교·분석하여 각 노선의 통제점(Control Point)을 감안하는 예비노선대를 설정하고, 노선의 특성을 파악 분석하여 노선계획대를 결정한다. +- ② 평면선형은 설정된 설계속도에 대응하는 종단물매와 평면곡선반경 이상으로 설정될 수 있는지를 점검하여 볼 필요가 있다. 지형이 험준한 곳에서는 종단선형이 중요하므로 등고선의 높이를 읽어 종단계획도를 개략적으로 작성하여 개략적인 물매나 교량 설치위치와 길이, 토공의 난이도 등을 파악한다. +- ③ 각 노선별로 길이와 주요구조물이 확정되면 개략공사비를 산출하고 사회성, 경제성, 기술성 등을 종합평가하여 노선계획대를 결정하여 지형도에 디바이더를 이용하여 예비노선을 작도한다. +##### 나) 기본 설계 +- ① 개략계획단계에서 설정된 노선계획대에 따라 1/5,000 또는 1/1,200 지형도에서 예비노선에 대한 선형을 보다 세밀히 검토하여 공사비를 산출하고 경제성분석 등을 수행하여 최적노선을 결정한다. +- ② 지형도에 사회적조건과 자연적조건, 지질, 지역계획, 가옥, 묘지 등 각종 현황조사자료를 이용하여 세부적인 통제점을 표시하고, 평면선형을 그리고, 종단면도를 작성하여 종단선형의 상황에 따라서 평면선형을 수정한 후 평면도를 확정한다. 이렇게 입체적인 선형이 결정되면 100m마다 횡단면도를 작성하여 개략적인 임도를 설계한다. +- ③ 교량 등 주요 구조물의 규모와 대략적인 구조형식을 결정할 때에는 지질조사와 함께 시공상의 난이도를 검토하여 개략적인 공사비를 이 단계에서 적산한다. +- ④ 비교노선이 있는 경우에는 먼저 선형을 그려서 노선연장, 각종 지장물, 기하구조적 조건, 절·성토 높이 및 균형, 구조물 길이, 시공조건, 유지관리조건 등을 고려하여 2∼3개의 대안을 만들고, 공사비와 편익비 등을 계산하여 비교노선간의 우열을 판정한 후 최적노선을 결정한다. +##### 다) 실시 설계 + +기본설계의 결과를 기초로 통제점을 확인하고 각 구간마다 설계기준으로 제시된 기준에 맞게 설계한다. 중심선은 20m 간격으로 측점을 부설하여 체계적인 공사실행을 하기 위한 설계도·서를 작성하고, 용지폭을 결정하여 부지를 확정하며 공사비를 산정한다. + +### 나. 임도밀도 + +노망의 성숙도를 나타내는 양적지표로서 임도밀도(Forest Road Density)의 개념이 이용되고 있다. 이것은 산림의 단위면적당 임도연장(m/ha)으로 나타내며 산림의 개발정도와 사업의 집약 도를 나타내어 준다. 임도밀도를 산출하는 방법으로는 해석적인 방법과 경험적인 방법의 2가지로 크게 구분할 수 있다. 이들 두 방법의 공통점은 경제성을 기초하여 임업경영에 대한 지출을 최소화하는데 주안점을 두고 임도밀도를 산출하는 것이다. 일반적으로 임도밀도를 산출할 경우에는 구역내의 생산가능임지에 대한 면적만 적용하고 비생산임지의 면적은 제외시키며, 임도연장도 임업생산기능과 관계가 없는 산림구역외의 임도연장은 제외한다. 임도밀도는 기본임도밀도, 적정임도밀도, 임목생산 임도밀도, 노망시스템 임도밀도, 기계화 임도밀도 등 용도에 따라 여러가지로 산출하여 이용하고 있다. + +#### 1) 기본임도밀도 + +기본임도밀도(Minimum Forest Road Density)는 조림부터 수확까지 산림작업에 투입되는 노동 인력들이 작업장까지 왕복통근에 소요되는 보행경비, 즉 비생산노무경비를 임도시설에 전환하여 사회간접자본화하는 개념으로 Minami kata(南方)에 의하여 제창되었다. + +$$d_o=\sqrt{\frac{5\eta' C_wN_w}{V_wr_o}}$$ + +- $d_o$: 기본임도밀도(m/ha) +- $C_w$: 노동단가(원/hr) +- $N_w$: 조림부터 수확까지 투입 노동량(인/ha) +- $V_w$: 평균보행속도(km/hr) +- $r_o$: 임도개설비(원/m) +- $\eta'$: 보행우회계수(1.0∼1.5) + +(예제) $r_o=44,548$원/m, $C_w=5,000$원/hr, $\eta'=1.25$, $V_w=2$km/hr이고 노동투입량이 인공림 300인/ha, 천연림 200인/ha일 때: + +- ① 인공림: $d_o=\sqrt{(5\times1.25\times5,000\times300)/(2\times44,548)}=10.3\,\mathrm{m/ha}$ +- ② 천연림: $d_o=\sqrt{(5\times1.25\times5,000\times200)/(2\times44,548)}=8.4\,\mathrm{m/ha}$ +#### 2) 적정임도밀도 + +임도의 개설이 늘어감에 따라 임도밀도가 증가되어 집재비, 조재비, 관리비는 낮아지지만 임도개설비, 임도 유지관리비, 운재비는 증가한다. Matthews는 생산원가관리이론을 적용하여 임업 생산비 중에서 임도개설연장의 증감에 따라서 현격하게 변화되는 주벌의 집재비용과 임도개설 비의 합계를 가장 최소화시키는 적정임도밀도(Optimum Forest Road Density)와 적정임도간격(Optimum Forest Road Spacing)을 구명하였다. (그림 5-2-7)은 임도개설비와 집재비용의 합계 + +![임도계획_그림5-2-7_적정임도가격산출모식도](<../pic/임도계획_그림5-2-7_적정임도가격산출모식도.png>) + +합계비용 비용에 대한 손익분기점(Break-even Point)을 나집 재 비m3당타내고 있다. 즉, 임도간격이 크게 되면 단위재적 비당의 임도비용은 감소하지만 집재거리가 길어져용(원/m + +- 3) 임도간격이 넓어지면 단위재적당 집재비용은 증가한다. 임도개설비와 집재비용의 합계는 임도간격이 지나치게 좁거나 넓으면 증가하며, 어느 한 지점에서 최소가 된다. 이 지점이 적정임도간격이다. Matthews는 임도우회율과 집재거리우회율 등을 고려하여 다음 식으로 산출하였다. + +여 적정임도밀도를 산출하였다. + +$$d=\sqrt{\frac{VE\eta\eta'}{r}}$$ + +- $d$: 적정임도밀도(m/ha) +- $r$: 임도개설비(원/m) +- $E$: 집재비(원/m/m³) +- $V$: 생산예정재적(m³/ha) +- $\eta$: 임도우회계수(1.0∼2.0) +- $\eta'$: 집재우회계수(1.0∼1.5) + +기계화 집재장비를 사용할 경우 집재비는 $E=(c\,t\,1,000)/L$로 산출한다. 여기서 $c$는 장비운영비(원/분), $t$는 작업왕복시간(분/m), $L$은 장비의 평균적재량(m³)이다. + +(예제) 평균 임목축적 183m³/ha에 벌채율 70%, 조재율 80%를 적용하면 원목생산예정량은 $183\times0.7\times0.8=102.5$ m³/ha이다. 임도개설비 44,548원/m, 집재비 320원/m/m³일 때: + +$$d=50\sqrt{\frac{102.5\times320\times1.5\times1.25}{44,548}}=58.7\,\mathrm{m/ha}$$ + +※ 임목축적 증가에 따른 적정임도밀도의 변화: 그림 5-2-8과 같이 경북 봉화 소재 국유림과 광릉시험림을 대상으로 경제성을 감안한 ha당 임목축적별 적정임도밀도를 산출한 결과, 임목축적이 46, 60, 100, 150, 200m³/ha로 증가함에 따라 적정임도밀도는 각각 7.7, 10, 16, 23, 29m/ha까지 증가하는 것으로 나타났다. 임목축적이 60m³/ha가 될 때까지는 기본임도밀도인 10m/ha로 가능하지만, 그 이상 증가하면 적정임도밀도 수준까지 계획적으로 설치하여야 한다. + +![임도계획_그림5-2-8_ha당임목축적별적정임도밀도](<../pic/임도계획_그림5-2-8_ha당임목축적별적정임도밀도.png>) + +그림 5-2-8. ha당 임목축적별 적정임도밀도 + +#### 3) 지선임도밀도(Feeder Road Density) + +입지조건에 따라서 집재방법과 운재시스템이 다르기 때문에 임도의 효율성을 계수로서 정하고 이 계수와 현실적으로 그 산림에 적용될 수 있는 집재장비의 최대집재거리로서 경험적인 임도밀도를 산출하는 방법이다. D = a / s여기서, D : 지선임도밀도(m/ha) s : 평균집재거리(㎞)a : 임도효율계수 + +| 구 분 | 임도효율계수 | 구 분 | 임도효율계수 | +|---|---|---|---| +| 기복이약간있는평지 | 4∼5 | 경 사 지 | 7∼9 | +| 구 릉 지 | 5∼7 | 급 경 사 지 | 9 이상 | + +(예제 1) 경사지에서 트랙터 평균집재거리가 500m일 때 지선임도밀도는 $D=8/0.5=16$m/ha이다. + +(예제 2) 구릉지에서 임도밀도가 20m/ha일 때 적합한 집재장비의 평균집재거리는 $6/20=0.3$km, 즉 300m이다. + +#### 4) 기설임도의 임도밀도(Existing Road Density) 산정 + +$$D(\mathrm{m/ha})=\frac{\text{시설거리}(\mathrm{m})}{\text{구역 산림면적}(\mathrm{ha})}$$ + +(예제) 광릉시험림 내 1996년 말 임도시설거리가 45km이고 산림면적이 2,218ha이면 $D=45,000/2,218=20.3$m/ha이다. +#### 5) 임도간격, 집재거리, 평균집재거리의 산출 +##### 가) 적정임도간격의 산출 + +$$ORS=200\sqrt{\frac{RC}{Q\cdot EC}}$$ + +- $ORS$: 적정임도간격(m) +- $RC$: 임도개설비(원/m) +- $EC$: 집재비(원/m/m³) +- $Q$: 원목생산예정량(m³/ha) + +(예제) $RC=44,548$원/m, $EC=320$원/m/m³, $Q=183$m³/ha일 때 $ORS=200\sqrt{44,548/(183\times320)}\fallingdotseq174$m이다. +##### 나) 적정임도밀도에서 임도간격의 산출 + +$$RS=\frac{10,000}{ORD}$$ + +여기서 $RS$는 임도간격(m), $ORD$는 적정임도밀도(m/ha)이다. $ORD=58.7$m/ha이면 $RS=10,000/58.7=170.4$m이다. +##### 다) 적정임도밀도에서 집재거리(Skidding Distance)의 산출 + +$$SD=\frac{10,000/ORD}{2}=\frac{5,000}{ORD}$$ + +(예제) $ORD=58.7$m/ha이면 $SD=5,000/58.7=85.2$m이다. +##### 라) 적정임도밀도에서 평균집재거리(Average Skidding Distance)의 산출 + +$$ASD=\frac{10,000}{4ORD}=\frac{2,500}{ORD}$$ + +여기서 $ASD$는 평균집재거리(m), $ORD$는 적정임도밀도(m/ha)이다. + +(예제) $ORD=58.7$m/ha일 때 $ASD=2,500/58.7=42.6$m이다. + +【참고】임도간격, 집재거리, 평균집재거리의 관계 +- ① 임도간격은 임도와 임도사이의 거리로 표현된다. +- ② 집재거리는 양쪽의 임도에서 서로 집재작업이 실행되므로 평지림의 경우 임도간격의 1/2이 된다. +- ③ 평균집재거리는 임도변의 집재작업(최소집재거리)과 집재한계선(최대집재거리)까지 집재작업이 동일하게 실행되므로 평지림의 경우 집재거리의 1/2이 되고 임도간격의 1/4이 된 +- 다. +- ④ 기본 계산식은 평지림을 기준으로 정립된 것이므로 산악지에 적용할 경우에는 임도와 집 재우회계수(η, η′)를 계상하여야 한다. +##### 마) 지형조건별 임도간격 + +표 5-2-9. 오스트리아의 경험적 임도간격 + +(단위:m) + +| 산지경사 | 지 형 | 집 재 방 법 | 산림경 영규모(ha) | | | +|---|---|---|---|---|---| +| | | | 대 규 모 | 중 규 모 | 소 규 모 | +| | | | (2,000이상) | (200∼2,000) | (200이하) | +| 0∼15% | 평탄지 | 차량형집재기 상·하향집재 | 500∼600 | 400∼500 | 300∼400 | +| 15∼30% | 구릉지 | 차량형집재기 상·하향집재 | 500 | 300∼400 | 300 | +| 30∼60% | 구릉지 산악지 | 차량형집재기(집재로이용) 가선에의한상향집재 중력에의한하향집재 | 300∼400 | 300 | 200∼250 | +| 60% 이상 | 험준지 | 가선에의한상향집재 중력에의한하향집재 | 500 | 300∼400 | 300 | +| * 자료: Sedlak (1985, 1987) | | | | | | + +### 다. 임도규격과 비용 +#### 1) 적정임도규격(Optimum Road Standard)의 산출 + +$$ORST=\sqrt{\frac{Q\cdot L}{K}}$$ + +- $ORST$: 적정임도규격(km/hr) +- $Q$: 임도교통 운재량(m³) +- $L$: 트럭의 시간당 비용(원/hr/대) +- $K$: 트럭속도를 1km/hr 높이는 데 필요한 임도개설비 증가액(원/km) + +(예제) $Q=500\times102.5=51,250$m³, $L=7,750$원/hr, $K=1,000,000$원/km이면 $ORST=\sqrt{(51,250\times7,750)/1,000,000}\fallingdotseq20$km/hr이다. +#### 2) ㎥당 소요 임도비용(road cost)의 산출 +##### 가) 임도밀도가 적정치일 경우 + +R·ORD·(1+η)·(1+η′)RC =1,000V 여기서, RC : 임도비용(원/㎥) R : 임도개설비(원/km)ORD : 적정임도밀도(m/ha) V : 원목생산예정량(㎥/ha) +##### 나) 임도밀도가 적정치가 아닐 경우 + +$$RC=\frac{R\cdot RD\cdot(1+\eta)\cdot(1+\eta')}{1,000V}$$ + +여기서 $RD$는 실제 임도밀도(m/ha)이다. + +(예제) 앞 예제의 조건에서 $RC=(44,548,000\times58.7)/(1,000\times120.5)\fallingdotseq25,512$원/m³이다. +### 라. 임도망 편성 +#### 1) 설치위치별 임도망 + +임도망을 계획하고자 할 경우에는 입지조건에 따라서 여러가지의 노망형이 있을 수 있지만 다음과 같은 사항을 착안하여 합리적인 노망이 될 수 있도록 한다. + +- ① 항구성의 원칙 : 산림이 항구적으로 유지되는 것과 같이 임도도 항구적으로 유지될 수 있도록 견고하게 시설한다. +- ② 임지종속성의 원칙 : 입지에 따라서 평면선형, 종단선형, 횡단선형 등 여러 가지의 조건이 다르므로 국지적인 입지조건에 부합되도록 시설한다. +- ③ 다양성의 원칙 : 임업적·경제적 기능도 중요하지만 생태적, 환경적, 공익적인 기능도 함께 수용할 수 있도록 계획한다. +##### 가) 계곡임도형 + +임지는 하단부로부터 점차적으로 개발하는 것이 일반적이므로 계곡임도는 임지개발의 중추적인 역할을 한다. 홍수로 인한 유실을 방지하고 임도시설비용을 절감하기 위하여 계곡하단부에 설치하지 않고 약간 위인 산록부의 사면에 최대홍수위보다 10m 높게 설치한다. 횡단사면은 항상 높은 지지력을 유지하여야 하므로 비교적 간단한 사면처리방법으로도 설치할 수 있는 곳이 바람직하다. 그림 5-2-9는 계곡부에 급경사면이 있어 쉽게 산록부나 산복부로 노선을 이동시킬 수가 없거나 계상의 물매가 너무 급하여 계곡을 따라 계속적인 노선선정이 불가능한 경우에 흔히 설치할 수 있는 형으로서 배향곡선(Hairpin Curve)을 설치하여 상류 쪽으로 계속 진행한다. 그림 5-2-10은 큰 지류가 있는 계곡부에서 흔히 설치할 수 있는 일반적인 형태로서 주계곡의 계상물매가 한계 종단물매 이내이고 지류를 우회하여 노선을 설치할 수 있는 곳이다. 그림 5-2-11은 편평한 주계곡을 따라 주임도에서 분기하는 지선(부)임도의 연결형이다. 그림 5-2-11의 ㉠형은 소계곡의 계상물매가 주계곡과의 연결부위 물매가 완만한 경우에 설치할 수 있고, ㉡형은 연결부위 물매가 급한 경우에 주계곡 상단부에서 배향곡선으로 우회하여 소계곡으로 진입하는 형태이다. + +![임도계획_그림5-2-9_급경사지역의계곡임도](<../pic/임도계획_그림5-2-9_급경사지역의계곡임도.png>) + +그림 5-2-9. 급경사지역의 계곡임도(사행형) + +![임도계획_그림5-2-11_편평한주계곡의계곡임도](<../pic/임도계획_그림5-2-11_편평한주계곡의계곡임도.png>) + +![임도계획_그림5-2-10_중경사지역의계곡임도](<../pic/임도계획_그림5-2-10_중경사지역의계곡임도.png>) + +그림 5-2-10. 중경사지역의 계곡임도 + +그림 5-2-11. 편평한 주계곡의 계곡임도 + +##### 나) 사면임도 + +사면임도는 계곡임도에서 시작되어 산록부와 산복부에 설치하는 임도로서 노선선정은 하단부로부터 점차적으로 계획하여 진행한다. 동일한 사면에서 부득이 배향곡선(Hairpin Curve)을 설치하여야 할 경우에는 가능한 한 그 수를 최소한으로 줄여야 한다. 왜냐하면 배향곡선을 설치하면 산림생산면적이 감소되어 임도의 이용율을 저하시킬 뿐만 아니라 토사유출을 유발하여 임지훼손의 원인이 되므로 가능한 한 경사도가 40% 이하되는 사면에 설치하되 동일사면에 1개 이상 설치하지 아니하도록 한다. (그림 5-2-12)는 산지의 사면이 발달되고 완경사 지형인 평활한 사면형태에서 흔히 설치할 수 있는 임도망형으로서 노선을 일정한 간격으로 배치하여 서로 연결할 수 있도록 배치한다. (그림 5-2-13)은 사면은 발달되어 있으나 경사도가 급하고 사면장이 긴 지형에 흔히 설치할 수 있는 노선형으로서 사행형으로 되는 임도망형이며 노선의 배치형태를 보면 주임도는 계곡부에서 배향곡선을 이용하여 산복부로 진행하고 부임도는 배향곡선의 가장자리에서 각각 분지하여 설치된다. + +![임도계획_그림5-2-13_급경사긴사면의사행형](<../pic/임도계획_그림5-2-13_급경사긴사면의사행형.png>) + +![임도계획_그림5-2-12_완경사지형의평형노망](<../pic/임도계획_그림5-2-12_완경사지형의평형노망.png>) + + +##### 다) 능선임도 + +능선임도는 축조비용이 적고 토사 유출도 적지만 가선집재방법과 같은 상향집재시스템에 의하지 않고는 산림을 개발할 수 없다. 능선임도에서 중력식 집재방법을 이용하는 한가지 방법은 (그림 5-2-14)와 같이 어골형의 형태로서, 능선임도에서 사면의 하향으로 완물매 또는 등고선물매로서 임도를 구축하는 것이다. 만약, 계곡의 기부가 늪(沼)이나 험준한 암석지대로 인하여 접근할 수 없거나 또는 능선에 부락이 위치하고 있을 때는 (그림 5-2-15)와 같은 능선임도를 구축할 필요가 있다. + +![임도계획_그림5-2-14_능선임도의어골형](<../pic/임도계획_그림5-2-14_능선임도의어골형.png>) + +간선 능선 + +![임도계획_그림5-2-15_능선임도형](<../pic/임도계획_그림5-2-15_능선임도형.png>) + + +##### 라) 산정부 개발형 + +산정부의 개발은 산정부 주위를 순환하는 노망을 설치하는 것이 적절하게 산림을 개발할 수 있는 방법으로 이용될 수 있는 경우가 많다. 산정부의 사면이 발달된 곳에서 (그림 5-2-16)과 같은 순환임도를 설치하여 하향 또는 가선에 의한 상향집재 방법으로 수확작업의 실행이 가능하며, 특히 상단부 사면이 발달한 반면에 중복 부의 사면경사도가 급한 곳에 흔히 설치할 수 있는 노망형이다. 이때 순환임도의 시점과 종점은 능선부에 있는 안부(鞍部, Saddle)가 가장 적당한 곳이 된다. + +![임도계획_그림5-2-16_산정부순환임도형](<../pic/임도계획_그림5-2-16_산정부순환임도형.png>) + +그림 5-2-16. 산정부 순환임도형 + +##### 마) 계곡분지의 개발형 + +산지 계곡부의 상류에 위치하는 막장부의 분지는 (그림 5-2-17)과 같이 순환임도망의 설치방법에 의하여 산림을 개발하는 것이 적정한 경우가 많다. 이때에 설치하는 임도의 물매는 너무 급하지 않아야 한다. (그림 5-2-17)의 ㉠형은 계곡기부의 사면이 발달되어 부채모양으로 펼쳐진 곳으로서 사면의 경사도가 완만하고 편평한 곳에서 흔히 설치할 수 있는 노망형이며, (그림 5-2-17)의 ㉡형은 ㉠형과 같은 지형조건으로서 사면의 길이가 길고 하단부의 경사도가 급한 곳에서 흔히 설치할 수 있는 노망형이다. + +##### 바) 반대편 능선부 산림개발형 + +동일유역에서 통상적인 노망으로는 임도건설비가 과다하게 소요 되어 경제적인 타당성이 없기 때문에 계곡임도를 개설할 수 없는 경우의 임지에 대하여는 산림에서 생산되는 임목을 상향 수송이 가능하도록 다른 사면에 개설된 임도의 안부를 경유하여 완물매(역물매로 설치됨 : 6%이하)의 임도를 개설하지 않으면 안된다. 이러한 경우에서는 (그림 5-2-18)과 같이 계곡의 발달이 거의 없거나, 늪(沼) 또는 암석 급경사지 등의 원인으로서 경제적으로 임도설치가 곤란한 지역에서는 반대편에서 생산된 임목을 사면임도를 통하여 안부(鞍部 : Saddle)를 넘어 역물매로서 수송하여야 할 필요가 있는 곳에서 흔히 설치될 수 있는 노망형이다. 계류 + +![임도계획_그림5-2-18_반대사면으로부터의임도이용형](<../pic/임도계획_그림5-2-18_반대사면으로부터의임도이용형.png>) + +계곡 능선 급경사암석지형 + +![임도계획_그림5-2-17_계곡분지의순환임도형](<../pic/임도계획_그림5-2-17_계곡분지의순환임도형.png>) + + +#### 2) 양각기 계획법(Divider Step Method)에 의한 도상 임도망 편성 +##### 가) 작업원리 +- ① 양각기의 1폭(S)을 임도의 영선(Zero Line)에 대한 수평거리(D)로 하고 등고선간격(1/25,000지형도 : 10m, 1/50,000지형도 : 20m)을 높이(h)로 간주하여 종단물매(G)를 산출한 후 지형도상에서 적정한 노선을 선정하는 노망계획방법이다. +- ② 그림 5-2-19에서 수평거리 100m에 대한 높이 $p$m가 $G$%라면 다음과 같다. + +$$D:h=100:G,\qquad D=\frac{100h}{G}$$ + +- $D$: 양각기 1폭에 대한 실거리(m) +- $h$: 등고선 간격(m) +- $G$: 물매(%) +- 도상거리 $d$ = 실거리 $\ell$ ÷ 축척의 분모수 + +![임도계획_그림5-2-19_양각기에의한물매설정](<../pic/임도계획_그림5-2-19_양각기에의한물매설정.png>) + +그림 5-2-19. 양각기에 의한 물매 설정 + +- ③ 위 식에서 물매별 양각기의 1폭에 대한 실거리를 산출한 바 (표 5-2-10)과 같다. 표 5-2-10. 종단물매별 양각기의 폭(1/25,000지형도, 등고선간격=10m의 경우) + +| 종단물매 (G) | 실 거 리 (ℓ) | 도 상 거 리(d) | | | 종단물매 (G) | 실 거 리 (ℓ) | 도 상 거 리(d) | | | +|---|---|---|---|---|---|---|---|---|---| +| | | 1 폭 | ½ 폭 | ¼ 폭 | | | 1 폭 | ½ 폭 | ¼ 폭 | +| 2% | 500m | 20.0mm | 10.0mm | 5.0mm | 8% | 125m | 5.0mm | 2.5mm | 1.2mm | +| 3% | 333m | 13.3mm | 6.7mm | 3.3mm | 9% | 111m | 4.4mm | 2.2mm | 1.1mm | +| 4% | 250m | 10.0mm | 5.0mm | 2.5mm | 10% | 100m | 4.0mm | 2.0mm | 1.0mm | +| 5% | 200m | 8.0mm | 4.0mm | 2.0mm | 11% | 91m | 3.6mm | 1.8mm | | +| 6% | 167m | 6.7mm | 3.3mm | 1.7mm | 12% | 83m | 3.3mm | 1.7mm | | +| 7% | 143m | 5.7mm | 2.9mm | 1.4mm | 13% | 77m | 3.1mm | 1.5mm | | + +(예제) 1/25,000 지형도(등고선간격 ; 10m)에서 양각기 계획법으로 임도망을 편성하고자 한 + +- 다. 종단물매를 7%로 계획할 때 도상거리는 몇 m인가? (풀이) ① 실거리(ℓ) = (100×10) / 7 = 142.86m +- ② 1/25,000 지형도에서의 도상거리(d) = 142.86m/250 = 5.7mm +##### 나) 작업기준 +- ① 임산물은 근주로 부터 임도까지 직선거리로 가장 가까운 임도변에 집재되는 것으로 가정한다. +- ② 임도는 동일한 간격을 유지하고 평행하여 교차되지 아니하는 것으로 가정한다 +- ③ 임산물을 운송하기 위한 임도는 산림의 상단부에서 우회하여 계곡부로 내려오는 것이 정상이지만 급경사지 형에 설치되는 부임도는 능선에 설치될 수도 있다. +- ④ 일반적으로 임산물이 임도변까지 집재되는 과정은 집재비에 큰 영향을 미치기 때문에 상향, 하향 또는 양방향 등 집재방향을 먼저 검토하여야 한다. +- ⑤ 만약 가선이나 장비 등을 이용하는 집재방법으로 실행할 경우에는 실제 임목집재비 뿐만 아니라 그 장비투입비용도 포함하여 집재비용을 추정한다. +- ⑥ 임도는 가끔 임산물이나 노동력의 수송에 대한 편익 이외에도 또 다른 목적으로 이용될 수 있다. 예를 들면 산화관리, 수렵, 여행, 산촌주민의 편익 등으로서 이들의 편익을 금원화하기는 매우 어려울 것이나, 학자에 따라서는 이들 편익에 대한 대가로서 임도비용을 25∼50% 공제하거나 유지관리비를 부담하게 하기도 한다. +- ⑦ 임도의 시설이 주변환경을 해치게 될 경우에는 그 비용을 사정하여 임도비용에 부가할 수 임산물 운반을 고려한 일반적인 임도망은 산복을 완만하게 우회하면서 낮은 도 있다. 계곡부의 도로와 연결하는 것이 유리하다. 그러나 산복경사가 매우 급하면 부임도를 사면에 설치할 경우 절·성토와 붕괴위험이 커지므로, 지형조건에 따라 능선부에 부임도를 설치할 수도 있다. 이 경우 아래쪽 임목은 가선 등을 +##### 다) 작업방법 + +이용하여 능선임도로 상향집재해야 한다. +- ① 지형도(1/25,000, 1/5,000)를 준비하여 계획구역을 설정하고 생산임지와 비생산임지를 구분한 후 주요 사업계획지, 주교통방향이나 임목의 반출순로 등을 도시한다. +- ② 노선의 통제점(control point) 즉 유리점(시점, 종점, 배향곡선 설치가능지, 안부, 여울목 +##### 등) 과 불리점(늪, 불안정된 사면, 암석지, 홍수범람지, 소유경계 등) 및 역물매 지역을 도 + +시한다. +- ③ 각 노선에 대한 예비노선을 ①과 ②의 사항과 노선의 특성을 파악 분석하여 계획노선대를 도시한다. +- ④ 양각기의 폭(s)을 (표 5-2-10)을 참고하여 지형도의 축척에 알맞게 조정한다. 이때 양각기 1개로서 상황에 따라 조정 사용하면 오차의 발생이 크므로 5∼6개의 양각기를 준비하여 사용이 빈번한 물매에 해당되는 것은 그 폭을 고정시켜 사용하면 오차를 줄일 수 있으며 작업진행도 빠르고 편리하다. +- ⑤ 시점부와 종점부의 결정은 대단히 중요하므로 기설도로에 대한 절·성토사면형태, 평면선형 등에 대한 현지조건을 검토하고, 시점으로 부터 계획코자 하는 물매로 경유하여야 할 유리점은 통과하고 피하여야 할 불리점은 피하면서 한폭 한폭씩 (그림 5-2-20)과 같이 진행한다. + +![임도계획_그림5-2-20_양각기사용예](<../pic/임도계획_그림5-2-20_양각기사용예.png>) + +그림 5-2-20. 양각기 사용(예) + +- ⑥ 굴곡이 심한 지형에서는 계곡과 능선을 건너 뛰면 우회율이 너무 커져서 도상거리와 실거리의 오차가 커지므로 양각기의 폭을 (표 5-2-10)의 우단과 같이 1/2폭 또는 1/4폭으로 조정 작업하고, 등고선의 간격이 조밀하여 작업이 어려운 경우에는 확대경을 사용하면 편리하다. +- ⑦ 이와 같이 작업하여 통과 목표지점에 도달되지 아니하였거나 불리점을 피하지 못하여 노선의 계획이 미흡할 경우에는 그 구간 또는 전노선을 선형이 적정하게 될 때까지 되풀이하여 실행한다. +- ⑧ 이와같은 작업이 완료되면 작업과정을 노선별 내역서(표 5-2-11)에 기재하고 임도망 계획도(그림 5-2-21)에 도시한다. + +| 범 례 | | +|---|---| +| | 시험림경계 | +| | 기설임도 | +| | 시설계획 | +| | 지 방 도 | + +| 번호 | 노선명 | 연장 (km) | 비 고 | +|---|---|---|---| +| 1 | 육림로 | 6.7 | '65∼'66 시설 | +| 1-1 | (관찰로) | 1.1 | '90 시설 | +| 2 | 장현로 | 3.3 | 기설 운재로 개수 | +| 3 | 수목원로 | 2.8 | '83 시설 | +| 4 | 소리로 | 3.8 | '82∼'85 시설 | +| 5 | 능내로 | 3.4 | '84 시설 | +| 6 | 음현로 | 2.0 | '85 시설 | +| 7 | 접동로 | 4.1 | '85∼'86 시설 | +| 8 | 직동로 | 5.5 | '87∼'89 시설(4.5km) | +| 9 | 마명로 | 7.7 | '90∼'91, '95 시설 | +| 10 | 장승로 | 3.3 | '91 시설 | +| 11 | 거목로 | 1.8 | | +| 12 | 평화로 | 2.2 | | +| 13 | 폭포로 | 1.7 | | +| 14 | 천참로 | 3.1 | '92, '94 시설 | +| 15 | 소리2로 | 7.3 | '91∼'92 시설(4.1km) | +| 16 | 용암로 | 3.4 | | +| 17 | 민락로 | 1.9 | | +| 18 | 안말로 | 0.8 | | +| 계 | | 65.9 | (30m/ha) | +| 시 설 | | 44.8 | (20m/ha) | +| 계 획 | | 21.1 | (10m/ha) | + +![임도계획_그림5-2-21_임도망계획도](<../pic/임도계획_그림5-2-21_임도망계획도.png>) + +그림 5-2-21. 임도망계획도(예:광릉시험림) + +표 5-2-11. 노선별 내역서(예:광릉시험림) + +| 임 도 명 | 임반 번호 | 임도 종류 | 임도표고 | | | 구 간 | | 임도 연장 (km) | 비 고 | +|---|---|---|---|---|---|---|---|---|---| +| | | | 시점 | 최고점 | 종점 | 거리 | 종단물매 | | | +| | | | (m) | (m) | (m) | (m) | (%) | | | +| 직동로 계 | 11, 12 | 2급 | 500 | 570 | 570 | 770
200
계 970 | 9
5 | 0.97 | 직동로 '89 시설분 종점에서
마명로 종점(죽엽산 안부) | +| 마명로 계 | 1∼8 | 2급 | 210 | 570 | 570 | 170
200
2,760
1,820
200
계 5,150 | 3
5
7
9
5 | 5.15 | 접동로 정상에서 분지
직동로 종점(죽엽산 안부) 연결 | +| 거목로 계 | 26∼29 | 2급 | 160 | 170 | 135 | 330
1,330
165
계 1,825 | 3
-3
3 | 1.83 | 능내로에서 분지
장승로와 연결 | +| 평화로 계 | 38∼40
45 | 2급 | 120 | 160 | 120 | 1,170
1,000
계 2,170 | 3
-3 | 2.17 | 소리로에서 분지
육림로와 연결 | +| 폭포로 계 | 45
50∼51 | 2급 | 200 | 200 | 80 | 1,720
계 1,720 | -7 | 1.72 | 천참로에서 분지, 장현로와 연결 | +| 소리2로 계 | 43
46∼49 | 2급 | 385 | 420 | 240 | 700
400
1,330
770
계 3,200 | 5
-9
-5
-9 | 3.20 | 소리봉 아래에서 연장
천참로와 연결 | +| 용암로 계 | 58∼59
42
58∼62 | 2급 | 380 | 380 | 270 | 1,000
430
670
1,330
계 3,430 | -3
-7
9
-9 | 3.43 | 소리로에서 분지
용암리 지방도와 연결 | +| 민락로 계 | 33, 35
57 | 2급 | 300 | 340 | 340 | 570
1,320
계 1,890 | 7
3 | 1.89 | 육림로에서 분지
민락동 상단부 | +| 안말로 계 | 30∼31 | 2급 | 140 | 170 | 160 | 570
200
계 770 | 7
-5 | 0.77 | 육림로에서 분지
안말 상단부 | +| 합 계 | | | | | | | | 21.13 | | + +#### 3) 현지의 노선부설(Forest Road Alignment) 및 수정 +##### 가) 작업기준 + +도상계획에 의한 각 임도노선은 현지조사(답사, 예측)에 의하여 현지의 국지적인 입지조건에 부합되도록 수정한다. 이는 개개 노선에 대한 세부계획을 정확하고 자세하게 수립하므로서 측량·설계 작업을 보다 신속하고 편리하게 수행할 수 있게 한다. 따라서 노선계획을 적정하게 수행하여 합리적인 노선을 배치하기 위하여는 노선의 측량작업을 현지에서 직접 실행하는것 보다 도상계획에 의한 개략계획을 먼저 실행한 후 측량작업에 임하는 것이 훨씬 경제적이고 효과적이다. + +##### 나) 작업방법 +- ① 임도망 계획도·서, 고도계(Altimeter), 경사측정기(Clinometer), 표지테이프(MarkingTape), 줄자, 표지판(Clinometer Target), 지지봉(Clinometer Rod) 등을 준비한다. +- ② 구역이 너무 크면 효율적인 작업이 될 수 없으므로 작업이 가능할 정도의 소구역으로 분할하여 작업조별로 세부일정을 수립하며, 차량, 헬리콥터, 측정기구 등의 필요한 장비를 준비한다. +- ③ 노선내 주요 설치물(집재장, 배향곡선, 교량 등)의 설치가 가능한 완경사 지형, 안부, 여울목 등의 유리지점과 불리지점에 대한 현지 부합 상태를 조정하여 불합리한 선형은 수정한다. +- ④ 도상의 계획노선을 현지 지형상에 정확한 지점을 찾아 부설하는 식으로 작업하지 말고, 현지 지형에 따라서 적정하게 설치될 수 있는 노선을 우선하여 도상의 불합리한 계획노선을 수정한다는 생각으로 작업한다. +- ⑤ 노선은 가능한한 자연지형에 순응되도록 하되 토공량이 적고 절토량과 성토량이 짧은 구간내에서 균형이 이루어지도록 한다. +- ⑥ 최소 곡선반지름, 최소 및 최대 종단물매 등의 일정한 제한치를 정하고 그 범위내에서 작업하되, 가능한 한 종단물매가 완만하고 배수가 완전히 되도록 한다. +- ⑦ 노선의 부지는 구입조건이나 가격의 차이가 있으므로 가능한한 부지확보에 지장이 없도록 적정하게 노선을 선정하고 산간부는 가급적 양지쪽을 선택한다. +- ⑧ 표고상 계속 상향물매로 설치하여야 할 종단선형 구간에 하향물매 구간을 설치하거나 반대로 하향물매로 설치하여야 할 구간에 상향물매 구간을 가급적 설치하지 않는다. +- ⑨ 계곡을 건너야 할 경우에는 최대 홍수시에 범람이 되어도 하류쪽 노면이 유실되지 않도록도하구간에 凹형의 종단물매를 설치하여 그 구간내에서 유수단면적이 확보 되도록 한다. +##### 다) 세부작업방법 및 조정 +- (4) -(라)-3)-마)의 제1단계 작업과 제2단계 작업을 참고한다. +#### 4) 임도망계획서 작성 + +임도망이 편성되면 임도망계획서를 작성하여야 한다. 이 계획 안에는 임도계획시에 조사 분석한 내용과 임도망 편성시에 작성한 다음과 같은 사항을 포함하여야 한다. 또한 사항에 따라서 관계기관과 협의하여야 할 것이 있을 경우에는 사전에 협의하여 작성한다. + +- ① 사회적 요인 +- ② 경제적 요인 +- ③ 기술적 요인 +- ④ 작업적 요인 +- ⑤ 기본임도밀도 및 적정임도밀도의 산정 +- ⑥ 노선별 내역서 +- ⑦ 임도망의 평가내역 +- ⑧ 임도망 계획도 +- ⑨ 기타 관계되는 자료 +### 마. 임도망 평가 +#### 1) 연산작업 기초 +- ① 먼저 적당한 격자(Mesh)크기(100m×100m, 50m×50m 등)의 수치지형도(Digital TerrainModel)를 작성한다. +- ② 수치지형도의 인자는 표고, ha당 축적, 임상, 영급 등을 관련자료(산림조사부, 영림계획서 +##### 등) 에 의하여 격자내에서 평균값을 산출하여 작성한다. +##### 가) 생산예정재적의 산출 + +$$BV=\frac{V\cdot BR\cdot CR\cdot B}{10,000}$$ + +- $BV$: 생산예정재적(㎥/ha) +- $V$: 축적(㎥/ha) +- $BR$: 조재율 +- $CR$: 벌채율 +- $B$: 단위면적(㎡) + +(예제) 축적 498㎥/ha, 벌채율 100%, 조재율 90%, 단위면적 10,000㎡이면 $BV=498\times0.9\times1.0\times10,000/10,000=448.2$㎥/ha이다. +##### 나) 집재거리의 산출 + +집재거리는 임목이 서 있는 지점에서 임도변 집재장까지의 최단 직선거리로 한다. + +$$X_{ab}=B\sqrt{(I-M)^2+(J-N)^2}$$ + +- $X_{ab}$: a, b 간 집재거리(m) +- $I,J$: 임목이 서 있는 지점의 행·열 +- $M,N$: 임도변 집재장의 행·열 +- $B$: 격자간격(m) + +그림 5-2-22. 격자 설치 모형 + +(예제) 격자간격($B$)이 100m인 정방형 격자지에서 점 $a=(10,5)$의 임목을 점 $b=(6,7)$의 임도변까지 집재할 때 $X_{ab}=100\sqrt{(10-6)^2+(5-7)^2}=447.2$m이다. + +##### 다) 종단물매의 산출 + +$$G=\frac{E_{IJ}-E_{MN}}{L}\times100$$ + +(예제) 점 $c=(6,3)$의 표고가 227m, 점 $d=(5,6)$의 표고가 200m이고 격자간격이 100m이면 $L=100\sqrt{(6-5)^2+(3-6)^2}\fallingdotseq316.2$m이다. +- ② 종단물매(G) = (227-200)×100/316.2 ≒ 8.54% +##### 라) 집재작업량의 산출 + +$$W=BV\cdot CX\cdot G\cdot\eta'$$ + +여기서 $W$는 작업량(㎥·m), $BV$는 생산예정재적(㎥), $CX$는 집재거리(m), $G$는 지형조건에 따른 작업가산율(1.0∼1.2), $\eta'$는 집재우회율(1.0∼1.5)이다. + +(예제) $W=448.2\times447.2\times1.1\times1.2=264,574.3$㎥·m이다. +##### 마) 집재비의 산출 + +$$SC=a\sum W+b\sum V$$ + +여기서 $a$는 집재거리에 비례하는 유동비(원/m/㎥), $b$는 고정비(원/㎥), $W$는 작업량(㎥·m), $V$는 생산예정재적(㎥)이다. + +(예제) $SC=10\times264,574.3+3,500\times448.2=4,214,443$원이다. +##### 바) 임도비의 산출 + +$$RC=C\sum R\cdot\eta$$ + +여기서 $C$는 임도단가(원/m), $R$은 임도연장(m), $\eta$는 임도우회율(1.0∼2.0)이다. + +(예제) 계획거리 408km, 임도단가 52,400천원/km, 임도우회율 1.25이면 $RC=52,400\times408\times1.25=26,724,000$천원이다. +##### 사) 총비용의 산출 + +$$TC=\sum SC+\sum RC$$ +##### 아) 가중평균 집재거리의 산출 + +$$ASD=\frac{\sum(CX\cdot V)}{\sum V}$$ +##### 자) 산술평균 집재거리의 산출 + +$$ASD=\frac{\sum CX}{N}$$ +##### 차) 집재거리 표준편차의 산출 + +$$SD=\sqrt{\frac{\sum(CX-ASD)^2}{N-1}}$$ + +집재거리의 표준편차가 작을수록 임도가 계획구역 내에 균일하게 배치되어 임도망의 효율성이 좋아진다. + +| 행 | 열 | 집재예정재적(㎥) | 집재거리(m) | CX·V | (X - ASD)2 | +|---|---|---|---|---|---| +| 257 | 216 | 127 | 100 | 12,700 | 5,055.2 | +| 258 | 217 | 122 | 125 | 15,250 | 2,125.2 | +| 259 | 218 | 87 | 276 | 24,012 | 11,004.0 | +| 260 | 219 | 87 | 255 | 22,185 | 7,039.2 | +| 261 | 220 | 150 | 105 | 15,750 | 4,369.2 | +| 262 | 221 | 150 | 182 | 27,300 | 118.8 | +| 263 | 222 | 65 | 202 | 13,130 | 954.8 | +| 264 | 223 | 92 | 170 | 15,640 | 1.2 | +| 265 | 224 | 55 | 125 | 6,875 | 2,125.2 | +| 계 | | 935 | 1,540 | 152,842 | 32,792.8 | +| 평 균 | | 103.9 | 171.1 | | | + +(풀이) ① 가중평균집재거리: $ASD=152,842/935=163.5$m + +- ② 집재거리 표준편차: $SD=\sqrt{32,792.8/(9-1)}=64.0$m +#### 2) 개발지수 + +개발지수는 임도의 질적 기준과 배치효율을 나타내는 지표이다. + +$$I=\frac{ASD\times FRD}{2,500}$$ + +여기서 $I$는 개발지수, $ASD$는 평균집재거리(m), $FRD$는 임도밀도(m/ha)이다. 임도가 이론적으로 균일하게 배치되면 $ASD\times FRD=2,500$이므로 개발지수는 1.0이다. 그림 5-2-23의 ①·②처럼 균일하면 이용효율성이 높고, ④·⑧처럼 노선이 중첩될수록 이용효율은 각각 31%, 50% 정도 저하한다. 따라서 임도간격과 밀도가 같더라도 노망의 배치상태에 따라 이용효율은 크게 달라진다. + +![임도계획_그림5-2-23_노망배치형태별개발지수](<../pic/임도계획_그림5-2-23_노망배치형태별개발지수.png>) + +그림 5-2-23. 노망배치 형태별 개발지수 + +#### 3) 경제성 분석 + +공공투자사업의 경제성평가는 대안들의 비용과 편익을 분석하여 경제적 효율성과 공공투자의 당위성을 검토하는 것이다. 임도교통 비용은 임도비와 임도사용자비용으로 구별한다. 임도구조·규격을 향상시키면 차량의 연료비·오일비·타이어비·수선비 등이 절감되며, 이 절감액을 편익(Benefit)이라 한다. + +- 비용항목: 조사·계획비, 설계비, 보상비, 유지관리비, 임도운영비이며 과거 경험치나 개략설계 결과로 산출한다. +- 편익항목: 차량운행비(Vehicle Operating Costs), 시간비(Time Cost), 작업방법개선비로 구분한다. + +$$RTC=RC+RUC$$ + +- $RTC$: 임도운송비(Road Transportation Costs) +- $RC$: 임도비(Road Costs) +- $RUC$: 임도사용자비용(Road User Costs) + +경제성 분석의 궁극적인 목적은 임도운송비를 최소화하는 대안을 설정하는 것이다. 그림 5-2-24에서 임도비는 초기투자비(건설 + +##### 비) 가 대부분이기 때문에 시간의 경과에 따라 변동이 거의 없으므로 이를 고정비(Fixed Cost)로 + +볼 수 있으므로 운반량이 많을수록 ha당 임도비는 감소한다. 반면에 임도사용자비용은 시간이 경과함에 따라 점점 누증(累增)되는 변동비(Variable Cost)이기 때문에 ha당 임도사용자비용이 증가하게 된다. 따라서 임도운반비는 ha당 임도비와 생산량에 따라서 변화되며 어느 적정한계를 넘거나 모자라면 불리해진다. ha당 운반량이 많은 임도일수록 임도비보다 임도사용자비용이 임도운반비 중에 차지하는 비중이 높으므로 ha당 운반량이 많은 임도일수록 설계수준을 더 높게 책정할 수 있는 이론적 근거를 제시하여 준다. + +![임도계획_그림5-2-24_임도운반비의추정도](<../pic/임도계획_그림5-2-24_임도운반비의추정도.png>) + +그림 5-2-24. 임도운반비의 추정도 + +##### 가) 기본 자금 공식 + +임도운반비 중에서 임도비는 사업시행초기의 단기간 동안에 소요되는 것이지만, 유지관리비나 임도사용자비용은 개통후 장기간에 걸쳐 소요되므로 총소요비용을 기준연도의 금액으로 환산하거나 임도비를 내구연수 또는 건설기간 동안의 연간비용으로 환산할 필요가 있다. 이때 사용하는 환산법은 은행의 금전계산법과 같으며 일반적으로 다음과 같은 3가지 방법이 있다. + +- ○ 단순 지불(Single Payment)n 원리금 S로서 원금 P를 원금 P를 은행에 저금하여 이자율이 i 일 때 원리금 S = P·(1+i) + +구한다면 P = S / (1+i)n 이때 (1+i)n은 원금 P를 원리금 S로 환산하는 계수로서 원리금환산계수(CAF ; CompoundAmount Factor)라 하고, 1/(1+i)n은 원리금 S를 원금 P로 환산하는 계수로서 현재가격환산계수(PWF ; Present Worth Factor)라 한다. 즉, S = P × CAFsp,n,i P = S × PWFsp,n,i 여기서, sp : 단순지불 n : 기간 i : 이자율 + +- ○ 정기 불입 또는 적금(Sinking Fund)매 연말마다 정기적으로 일정금액 R을 적금형식으로 불입한다면 n-1 / i} +- ① 원리금의 합계 S = R × {(1+i)※ { }내는 원리금환산계수(CAF) +- ② 적금불입액 R = S × {i / (1+i)n-1}※ { }내는 적금액환산계수(SFF ; Sinking Fund Factor)따라서 S = R × CAFrp,n,i R = S × SFFn,i여기서, rp : 반복지불 n : 기간 i : 이자율 +- ○ 자본 회수(Capital Recovery) 또는 연금(Annuity)자본금 P를 불입한 후 매년마다 정기적으로 일정금액 R을 연금 형식으로 인출하여 n년 후에 + +잔고가 0이 되게 하는 경우 n-1 / {i·(1+i) + +- ① 자본금: $P=R\left\{\dfrac{(1+i)^n-1}{i(1+i)^n}\right\}=R\times PWF_{rd,n,i}$ +- ② 연금: $R=P\left\{\dfrac{i(1+i)^n}{(1+i)^n-1}\right\}=P\times CRF_{n,i}$ + +여기서 $rd$는 반복인출, $n$은 기간, $i$는 이자율이며 중괄호 안은 각각 현재가격환산계수(PWF)와 자본회수환산계수(CRF)이다. 3가지 은행거래방식에 따른 6가지 환산계수는 표 5-2-12와 같다. + +표 5-2-12. 복리환산계수표 + +| 이자율 $i$(%) | 기간 $n$ | CAFsp | PWFsp | CAFrp | SFF | PWFrd | CRF | +|---:|---:|---:|---:|---:|---:|---:|---:| +| 8 | 10 | 2.1589 | 0.4632 | 14.4866 | 0.0690 | 6.7101 | 0.1490 | +| 8 | 20 | 4.6600 | 0.2145 | 45.7620 | 0.0219 | 9.8181 | 0.1019 | +| 8 | 30 | 10.0627 | 0.0994 | 113.2832 | 0.0088 | 11.2578 | 0.0888 | +| 10 | 10 | 2.5937 | 0.3855 | 15.0374 | 0.0627 | 6.1446 | 0.1627 | +| 10 | 20 | 6.7275 | 0.1486 | 57.2750 | 0.0175 | 8.5136 | 0.1175 | +| 10 | 30 | 17.4494 | 0.0573 | 164.4940 | 0.0061 | 9.4265 | 0.1061 | +| 12 | 10 | 3.1058 | 0.3220 | 17.5487 | 0.0570 | 5.6502 | 0.1770 | +| 12 | 20 | 9.6463 | 0.1037 | 72.0524 | 0.0139 | 7.4694 | 0.1339 | +| 12 | 30 | 29.9599 | 0.0334 | 241.3327 | 0.0041 | 8.0552 | 0.1241 | +| 15 | 10 | 4.0456 | 0.2472 | 20.3037 | 0.0493 | 5.0188 | 0.1993 | +| 15 | 20 | 16.3665 | 0.0611 | 102.4436 | 0.0098 | 6.2594 | 0.1598 | +| 15 | 30 | 66.2118 | 0.0151 | 434.7452 | 0.0023 | 6.5660 | 0.1523 | +| 17 | 10 | 4.8068 | 0.2080 | 29.3931 | 0.0447 | 4.6586 | 0.2147 | +| 17 | 20 | 23.1056 | 0.0433 | 130.0329 | 0.0077 | 5.6278 | 0.1777 | +| 17 | 30 | 111.0647 | 0.0090 | 647.4391 | 0.0015 | 5.8994 | 0.1715 | +| 20 | 10 | 6.1917 | 0.1615 | 25.9587 | 0.0385 | 4.1925 | 0.2385 | +| 20 | 20 | 38.3376 | 0.0261 | 186.6880 | 0.0054 | 4.8696 | 0.2054 | +| 20 | 30 | 237.3703 | 0.0042 | 1,181.8816 | 0.0008 | 4.9789 | 0.2208 | + +(예제 1) 연간 유지관리비가 3백만원 절감되고 내구연수 10년, 이자율 10%일 때 공사비 한도는 다음과 같다. + +$$P=R\times PWF_{rd,10,10}=3,000,000\times6.1446=18,433,800\text{원}$$ + +(예제 2) 포장비가 350백만원이고 내구연수 10년, 이자율 15%일 때 필요한 연간 임도사용자비용 절감액과 최소 교통량은 다음과 같다. + +$$R=P\times CRF_{10,15}=350,000,000\times0.1993=69,755,000\text{원}$$ + +$$\text{교통량}=69,755,000\div1,000\div365\fallingdotseq191\text{대/일}$$ + +(예제 3) 2km 구간의 선형 개량으로 차량당 200원이 절감되고 교통량이 5,000대/일, 이자율이 15%, 내구연수가 무한대일 때: + +$$R=200\times5,000\times365=365,000,000\text{원/년}$$ + +$$PWF_{pp,\infty,15}=\frac{1}{i}=\frac{1}{0.15}=6.667$$ + +$$P=365,000,000\times6.667\fallingdotseq2,433\text{백만원}$$ + +(예제 4) 10년 거치 일시상환 임도공채 100억원, 공채이자율 10%, 은행이자율 8%일 때: + +$$S=P\times CAF_{sp,10,10}=10,000\times2.5937=25,937\text{백만원}$$ + +$$R=S\times SFF_{10,8}=25,937\times0.069=1,789.653\text{백만원}$$ + +##### 나) 경제성 분석방법 + +경제성분석방법으로 대표적인 것은 연간비용방법(Annual Cost Method), 현재가격방법(PresentWorth Method), 편익비용비방법(Benefit-Cost Ratio Method), 내부수익율방법(Internal Rate ofReturn Method)의 4가지를 들 수 있다. 이 때 경제성을 분석할 경우에는 임도투자를 전혀 하지 않고 현재의 상태를 그대로 유지하는 방안(Do-Nothing Alternative)도 하나의 대안으로 선정하여 다른 대안과 경제적 타당성을 비교하여야 한다. + +- ① 연간비용 방법 AC = (RC + RUC) × CRFn,i + M&O +- ② 현재가격 방법 PW = (RC + RUC) + M&O × PWFrd,n,i +- ③ 편익비용비 방법: $B/C=\dfrac{\Delta RUC\times CRF_{n,i}}{\Delta RC\times CRF_{n,i}+\Delta(M\&O)}$ +- ④ 내부수익률 방법: $\Delta RC\times CRF_{n,i}=\Delta RUC\times CRF_{n,i}+\Delta(M\&O)$ + +여기서 $RC$는 자본투자비인 임도비, $RUC$는 임도사용자비용, $M\&O$는 유지관리비이다. 연간비용방법과 현재가격방법은 각 대안의 비용을 각각 연간비용과 현재가치로 환산하여 비교한다. 편익비용비방법은 대안 간 사용자비용 절감액을 추가투자비의 연간비용으로 나눈다. 내부수익률방법은 비용 절감액과 추가투자비의 연간비용이 같아지는 이자율을 구하여 기준수익률과 비교한다. + +(예제) 경북 봉화 춘양 우구치 산림의 임도개설 전과 개설 후 대안 1·2의 자료는 표 5-2-13과 같다. + +표 5-2-13. 경제성분석 기초자료(단위: 천원) + +| 구 분 | 임도개설전 | 임도개설후 | | 비 고 | +|---|---|---|---|---| +| | | 대안노선 1 | 대안노선 2 | | +| 공사비(RC) | - | 311,660 | 623,311 | 57,080천원/km | +| 집재비(EC) | 635,400 | 114,220 | 34,970 | | +| 운재비(TC) | - | 2,940 | 2,750 | | +| 유지관리비(MC) | - | 18,700 | 37,400 | 연간 공사비의 6% | + +임도개설전에는 임도시설분이 없으므로 집재비만을 계상하고, 임도개설후에는 공사비, 집재비, 운재비, 유지관리비를 계상한다. 여기서 임도개설전에는 임도시점부근으로, 개설후에는 가장 가까운 임도변으로 집재되는 것을 기준으로 하고 유지관리비는 매년 시설비의 6%, 임도상각기간(n)은 20년, 이자율(i)는 12%라고 할 때 임도개설의 경제성을 분석하여라. (풀이) + +- ① 연간비용 방법에 의한 경제성분석 AC=(RC+RUC)×CRFn,i+M&O이고, CRF20,12는 0.1339(표 5-2-12 참조)이므로 +- - 개설전 : 635,400×0.1339=85,080천원/년 +- - 대안 1 : (311,660+114,220+2,940)×0.1339+18,700=76,119천원/년 +- - 대안 2 : (623,311+ 34,970+2,750)×0.1339+37,400=125,912천원/년 +- ② 현재가격 방법에 의한 경제성분석 PW=(RC+RUC)+M&O×PWFpp,n,i이고, PWFpp,20,12는 7.4694(표 5-2-12 참조)이므로 +- - 개설전 : 635,400천원 +- - 대안 1 : 311,660+114,220+2,940+18,700×7.4694=568,498천원 +- - 대안 2 : 623,311+ 34,970+2,750+37,400×7.4694=940,386천원 +- ③ 편익비용비 방법에 의한 경제성분석($CRF_{20,12}=0.1339$) + - 개설 전과 대안 1: $B/C=\dfrac{\{635,400-(114,220+2,940)\}\times0.1339}{311,660\times0.1339+18,700}=1.148$ + - 개설 전과 대안 2: $B/C=\dfrac{\{635,400-(34,970+2,750)\}\times0.1339}{623,311\times0.1339+37,400}=0.662$ + +이상의 결과를 종합하면 대안노선 1은 연간비용과 현재가격이 개설 전에 비하여 약 11% 절감되고 편익비용비도 1.148이므로 임도개설이 타당하다. 대안노선 2는 연간비용과 현재가격이 개설 전보다 약 48% 증가하고 편익비용비도 0.662이므로 타당하지 않은 것으로 판단할 수 있다. + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 19~47쪽 (인쇄면 389~417쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md new file mode 100644 index 00000000..36d74171 --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/4. 노선측량.md @@ -0,0 +1,825 @@ +# 4. 노선측량(Route Surveying) + +### 가. 기초조사 +#### 1) 현지조사 +##### 가) 지형 + +공사의 난이도를 판정하기 위하여 일반적인 지형상태를 조사한다 +- ① 토질, 지질의 상태 : 절토면, 채석장, 토취장, 산허리의 붕괴면 +- ② 지하수의 상황 : 우물, 용출수(湧出水), 습지대 +- ③ 특수지대 : 애추(崖錐), 선상지(扇狀地), 지붕(地崩), 단층(斷層) 및 파쇄대(破碎帶), 사구(砂丘), 천정천(天井川), 지질구조. +- ④ 기존시설의 상황 : 사면의 침투수상태, 침하상태, 배수공, 낙석방지공, 방설책 등의 형식과 효과 등 +##### 나) 성토부 +- ① 재료선정 : 보링(Boring)작업에 의하여 토취장이나 절취되는 흙이 성토의 재료로 적합한지를 조사하여 성토시공의 지침을 마련한다. +- ② 기초지반의 안정과 침하 : 보링(Boring)과 사운딩(Sounding)시험에 의하여 성토의 기초지반의 안정성과 침하에 대비한다. 느슨한 모래, 연약한 세립토에는 주의한다. +- ③ 비탈면 안정 : 공사의 완성 후 성토자체의 안정성을 검토하기 위하여 현장시험으로 흐트러지지 않는 시료를 채취하여 검토한다. +##### 다) 절토부 +- ① 절취비탈면에 대한 표토의 두께, 흙부분의 안정성, 암반의 층리(層理), 균열의 방향 및 비탈면 방향과 관련성을 검토한다. +- ② 애추·단층·파쇄대 등의 유무, 산사태·붕괴의 가능성, 지반내의 침투수와 그 처리 방법을 검토한다. +##### 라) 토공계획 +- ① 토공량의 추정 +- ② 시공기계, 시공방법의 선정, 다짐효과의 추정 등 +#### 2) 토질시험 +##### 가) 현장토질시험(원위치시험) +- ① 현장에서 간단하게 토질을 판정하기 위하여 자연상태의 흙으로 시험 +- ② 시험의 종류 ㉠ 탄성파 검사 : 지하의 지질상태를 시험 ㉡ 전기 탐사 : 지하수 조사 ㉢ 관입시험 : 현장에 있는 흙의 단위체적중량시험과 흙의 강도를 판정 ㉣ 베인(vane)시험 : 연한 점토 또는 실트의 전단강도 측정 ㉤ 평판재하시험 : 노상, 보조기층의 지반계수(지지력계수)의 측정과 시공관리 ㉥ 현장 투수시험 : 관정 등을 이용하여 투수계수 측정 등 +##### 나) 토질시험 +- ○ 흙의 판별 및 분류를 위한 시험 +- ① 흙의 물리적 성질을 구하여 그 흙이 지니고 있는 성상을 파악하기 위한 시험 - 흙의 함수량 시험, 흙입자의 비중시험, 입도시험, 컨시스턴시(consistency)시험(액성한계시험, 소성한계시험) 등 +- ② 흙의 분류방법 ㉮ 입경(粒徑)에 의한 토립자 구분 표 5-2-14. 입경에 의한 토립자의 구분(단위:mm) + +| 분류기관 | 토립자 구분과 입경 범위(mm) | +|---|---| +| 미국재료시험협회·일본공업규격 | colloid < 0.001; clay 0.001∼0.005; silt 0.005∼0.05; 세사 0.05∼0.25; 조사 0.25∼2.0; 력 > 2.0 | +| 미국도로관리국 | colloid < 0.001; clay 0.001∼0.005; silt 0.005∼0.074; fine sand 0.074∼0.42; coarse sand 0.42∼2.0; gravel 2.0∼20; boulder > 20 | +| 미국토성국 | colloid < 0.001; clay 0.001∼0.005; silt 0.005∼0.05; very fine sand 0.05∼0.1; fine sand 0.1∼0.25; medium sand 0.25∼0.5; coarse sand 0.5∼1.0; fine gravel 1.0∼2.0; gravel > 2.0 | +| 일본철도성 토질조사위원회 | colloid < 0.001; clay 0.001∼0.005; silt 0.005∼0.05; 미립사 0.05∼0.1; 세립사 0.1∼0.25; 중립사 0.25∼0.5; 조립사 0.5∼1.0; 세력 1.0∼2.0; 력 > 2.0 | + +㉯ 삼각좌표에 의한 구분: 3성분의 함유율 합계가 반드시 100%가 되어야 한다(그림 5-2-25 참조). + +㉰ 통일분류법: 통일분류법(Unified Classification)은 Casagrande가 개발한 콘시스턴시 시험에 의한 공학적 분류방법이다. 흙은 대부분 2개 문자 조합으로 표시하며 첫 문자는 흙의 형, 둘째 문자는 속성을 뜻한다. 흙은 조립토 8종, 세립토 6종, 유기질토 1종 등 15종으로 구분하며 SM, SC, ML, OL, MH, OH 등으로 표시한다(그림 5-2-26 참조). + +표 5-2-15. 통일분류법에 사용되는 기호와 의미 + +| 토질의형 | | 제1문자 | 토질의속성 | 제2문자 | +|---|---|---|---|---| +| 조 립 토 | 자 갈 (gravel) | G | (Well-Graded)입도 분포 양호 세 립 분 거의 없음 (0.075mm 이하5% 이하함유) | W | +| | 모 래 (sand) | S | (Poor-Graded)입도 분포 불량 세 립 분 거의 없음 | P | +| 세 립 토 | 실 트 (mo) | M | (Mo)세립분12% 이상함유A선아래, 소성지수4 이하 | M | +| | 점 토 (clay) | C | (Dlay-Binder)세립분12% 이상함유A선위, 소성지수7 이상 | C | +| | 유기질의실트및점토 (organic clay) | O | (Low Compressibility)압축성낮음WL 50 | L | +| 유기 질토 | 이 탄 (peat) | P1 | (High Compressibility)압축성높음WL 50 | H | +| 토 질 | | | 토질의속성 | 제3문자 | +| 조 립 토 | | | (Drained)액성한계WL≦28, 소성지수IP≦6 | D | +| | | | (Undrained)액성한계WL≦28 | U | +| 주) 제3문자는 도로 및 비행장에 사용되는 기호 | | | | | + +![노선측량_그림5-2-26_통일분류법을위한소성도](<../pic/노선측량_그림5-2-26_통일분류법을위한소성도.png>) + +![노선측량_그림5-2-25_삼각좌표에따른흙의분류도](<../pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png>) + +그림 5-2-25. 삼각좌표에 따른 흙의 분류도 + +그림 5-2-26. 통일분류법을 위한 소성도 + +표 5-2-16. 토공시험관리 기준 + +| 종 별 | 시 험 종 목 | 시 험 법 | 시 방 서 규 격 | 빈 도 | +|---|---|---|---|---| +| 노 체 부 | 흙의 함수량 시험 | KS F 2306 | 최적함수비의 90% 밀도 에해당하는 습윤측 함수비 | •200m마다 1회 •1,500㎡마다 1회 | +| | 다짐시험 | KS F 2312 | A-1 다짐 또는 D-1 다짐 | •15,000㎡마다 1회 •토질이 변할 때마다 •필요시 마다 | +| | 현장밀도시험 | KS F 2311 | 실내최대건조밀도의 90% 이상 | •200m마다 1회 •1,500㎡마다 1회 | +| | 흙의 분류 시험 | KS F 2324 또는 PRA | 소성한계 25 이상 액성한계 50 이상 | •10,000㎡마다 1회 •토취장 변경시마다 | +| | CBR 시험 | KS F 2320 | 2.5 이상 | •필요하다고 인정할 때 | +| | 평판재하시험 | KS F 2310 | | •2,000㎡마다 1회(다짐도 를측정할수없을때) | +| 노 상 부 | 흙의 함수량 시험 | KS F 2306 | 최적함수비의 ±2% 이내의 함수비 | •200m마다 1회 •1,000㎡마다 1회 •강우 후 1회 | +| | 흙의 분류 | KS F 2324 또는 PRA | PI<10 | •10,000㎡마다 1회 •토질이 변할 때마다 •필요시 마다 | +| | 다짐시험 | KS F 2312 | B-2 다짐 또는 D-2 다짐 | •5,000㎡마다 1회 •토질이 변할 때마다 •필요시 마다 | +| | 현장밀도시험 | KS F 2311 | 실내최대건조밀도의 95% 이상 | •200m마다 1회 •500㎡마다 1회 | +| | 프로후로링 | 승인된 타이어로 러(복륜하중 5톤 이상) 타이어 접지 압 5.6㎏/㎠이상 | 침하가 5㎜이면 재시공 | •노상 완성 후 전 구간 에 걸쳐 3회 이상 | +| | CBR 시험 | KS F 2320 | 10 이상 | •필요하다고 인정할 때 | +| | 평판재하시험 | KS F 2310 | | •100㎡마다 1회(다짐도 를 측정할 수 없을 때) | +| 구 조 물 접 속 부 | 흙의 함수량시험 | KS F 2306 | 1. B-2, D-2다짐 최적함수 비와 95%밀도에 대응하는 습윤측 함수비 사이(되메 우기의 경우) 2. 최적함수비의 ±2% 이 내(토취장인 경우) | •50㎡마다 1회 | +| | 흙의 분류시험 | KS F 2324 또는 PRA | | •500㎡마다 1회 •토취장 마다 1회 | +| | 현장밀도 | KS F 2311 | 실내최대건조밀도의 95% 이상 | •50㎡마다 1회 | +| | 다짐시험 | KS F 2312 | B-2다짐 또는 D-2다짐 | •흙분류 결과가 다를 때 마다 1회 •필요하다고 인정할 때 | + +- ○ 흙의 역학적 성질을 구하는 시험 +- ① 다짐특성, 특수성, 강도 및 압밀성상 등 토공설계에 필요한 흙의 정수를 측정 +- ② 다짐시험, CBR시험, 실내특수시험, 압밀시험, 전단시험(일축압축시험, 직접전단시험, 삼축 압축시험), 콘지지력시험, 표준관입시험 등 +### 나. 답사 +#### 1) 실행방법 +- ① 당해 노선을 임도망계획서를 참고하여 건설비, 유지비가 최소화될 수 있도록 그 지역에서 가장 적정한 지형도에 노선을 도시하여 답사도를 작성한다. +- ② 이 답사도를 이용하여 현장을 답사하고 현장여건과 도상의 추정상태가 동일한가? 또는 더 유리한 노선의 대안이 없는지를 조사 비교한다. +- ③ 두 노선의 우열을 검토하여 유리한 노선을 선정하되 우열이 곤란한 지역은 두 노선을 모두 선정하여 도시한다. +- ④ 측정방법 ㉮ 거리측정 : 목측, 보측 또는 테이프 등으로 약측 ㉯ 방향측정 : 핸드컴퍼스 ㉰ 고저차 : 핸드레벨, 경사측정기, 기압계 등 ㉱ 기타자재 : 쌍안경, 포올(pole) 등, 특히 목측으로 측정할 경우에는 시환(視幻)에 주의하여야 한다. ※ 시환이란? • 눈 앞의 직선은 길게 보이고, 먼거리에 있는 것은 짧게 보임. • 비탈진 지반에 서서 높은 곳을 보면 45°는 75°로, 약 60°는 거의 수직처럼, 1할 5부는 1할의 기울기로 보임. 특히 높은 곳에서 비탈진 아래로 보면 더 심함 • 덤불이 무성한 지역은 공사하기가 곤란하게, 반대로 고저기복이 심하지 않는 곳이나 기울기가 완만한 곳은 공사하기가 쉽게 보임 +- ⑤ 산정, 계곡, 지질, 가옥, 토취장, 토사장, 인부의 공급, 기타 경제적인 문제 등을 조사한다. +#### 2) 주요지점의 결정 +##### 가) 시·종점의 결정 +- ① 기설도로에서 진출입이 편리하고 교통에 안정성이 있는 곳이어야 한다. +- ② 공사비, 운반비 등에 현저한 영향을 미치므로 임도의 종류, 노면의 상태, 물매(勾配), 곡선 설치, 임도너비, 장래의 저목장(貯木場)의 역활 등을 참작하여야 한다. +- ③ 임도를 막장에서 끝내어야 할 경우에는 가능한한 완경사지를 선택하여 차돌림과 주차장, 산원 집재장, 산림작업시의 가설사무소와 숙소 등의 시설이 편리한 곳이어야 한다. +- ④ 장래 노선연장시설의 필요가 있을 경우에 연장선의 물매(勾配)와 곡선 설치에 지장을 주지 않는 곳이어야 한다. +##### 나) 주요 통과지의 결정 +- ① 교량, 석축, 옹벽 등의 구조물 시설이 적은 곳이어야 한다. +- ② 건조하고 양지바른 곳이어야 한다. +- ③ 암석지, 연약지반, 붕괴지역은 가능한한 피한다. +- ④ 너무 많은 흙깎기와 흙쌓기, 높고 긴 교량을 필요로 하는 곳은 되도록 우회한다. +- ⑤ 가교지점은 양안(兩岸)에 침식된 부분이 없는 곳을 선정하고 강중심(江心)에 대하여 되도록이면 직각으로 건너도록 한다. +- ⑥ 임도개설에 유리한 지점은 통과한다. 이와 같은 지점으로는 안부(鞍部 : Saddle), 여울목(Ford), 급경사지내의 완경사지(Hair Pin Curve, 집재장 시설지), 공사용 자재(골재, 석재 +##### 등) 의 매장지와 산재지 등이 있다. +- ⑦ 임도개설에 불리한 지점은 피한다. 이와 같은 지점으로는 늪(Swamp)과 같은 습지, 붕괴지·산사태지(山沙汰地)와 같은 지반이 불안정한 산지사면, 암석지, 홍수범람지역, 소유경계 등이 있다. +### 다. 예측 +- ① 답사에서 결정된 노선(1∼2개)을 따라 트래버어스 측량을 실행하여 노선의 상태를 시점, 종점, 교량의 가설지점, 임도의 분지점, 주요한 통과지점, 구간마다의 고저차와 개략적인 거리에 의한 종단물매를 산출하고 개략적인 공사비를 산출할 수 있는 자료를 조사한다. +- ② 노선을 횡단하는 도로, 하천, 부락, 토지의 경계 등을 기록하고, 지질, 경작상황, 절토의 난이, 기타 임도시설에 관계되는 사항을 조사한다. +- ③ 이와같은 조사사항을 정리하여 예측도를 작성한 후 개개 노선별로 대안비교법에 의하여 가장 타당한 노선을 결정한다. +- ④ 측정방법 ㉮ 거리 : 대자(竹尺), 테이프 또는 스타디아측량으로 측정(정도는 1/3,000)㉯ 각 : 트랜싯 또는 컴퍼스로서 편각이나 교각 또는 방위각을 측정 ㉰ 고저차 : 레벨, 핸드레벨 또는 경사측정기로서 측정 ㉱ 노선 양쪽 주위의 지형 : 스타디아측량, 핸드레벨, 경사측정기와 테이프, 포올의 횡단, 평판, 지거법에 의하여 측정 +### 라. 실측 + +예측의 결과에 의하여 노선을 현지지상에 측설하는 방법은 노선의 중심선(中心線 : CenterLine)을 기준으로 측량하는 경우와 영선(零線 : Zero Line)을 기준으로 측량하는 경우의 2가지 방법이 있다. 전자를 중심선측량법(中心線測量法 : Center Line Method)이라 하고 주로 평탄지와 완경사지에서 많이 이용되며, 후자를 영선측량법(零線測量法 : Zero Line Method)이라 하고 주로 산악지에서 많이 이용되고 있다. + +#### 1) 중심선법과 영선법의 차이점 +- ① (그림 5-2-27)과 같이 노폭의 1/2이 되는 지점을 중심점(中心點 : Center Point)이라하고 이 점을 연결한 노선의 종축을 중심선(中心線 : Center Line)이라 한다. 경사지에 설치하는 측점별로 임도에서 노면의 시공면(Road Plane)과 산지의 경사면이 만나는 점을 영점(零點 :Zero Point)이라하고 이 점을 연결한 노선의 종축을 영선(零線 : Zero Line)이라 한다. + +![노선측량_그림5-2-27_중심선과영선의위치비교](<../pic/노선측량_그림5-2-27_중심선과영선의위치비교.png>) + +그림 5-2-27. 중심선과 영선의 위치 비교 + +- ② 영선은 (그림 5-2-28)에서 보는 바와 같이 절토작업과 성토작업의 경계선이 되기도 한다. + +![노선측량_그림5-2-28_경사지임도의영선과기면](<../pic/노선측량_그림5-2-28_경사지임도의영선과기면.png>) + + +- ③ 중심선측량은 중심점을 기준으로 중심선을 따라 측정하고, 영선측량은 영점을 기준으로 영선을 따라 측정한다. +- ④ 중심선측량은 지반고(地盤高 : Ground Height)상태에서 측량하며 종단면도상에서 계획선을 설정하여 계획고(計劃高 : Formation Height)를 산출한 후 종단과 횡단의 형상이 결정되지만, +- ⑤ 영선측량은 시공기면(Zero Plane)의 시공선(Formation Line)을 따라 측량하므로 굴곡부를 제외하고는 계획고의 상태로 측량하며, 필요시 지반고를 유추 산정하여 종단과 횡단의 형상이 결정되고 노선은 영점과 중심점의 차이에 따라 조정된다. +- ⑥ 균일한 사면일 경우에는 중심선과 영선은 일치되는 경우도 있지만 대개 완전히 일치되지 않고 지반기울기가 급할수록 영선보다 중심선이 경사지의 안쪽에 위치하고, 약 45∼55%지형에서는 중심선과 영선이 거의 일치되다가 지반기울기가 완만할수록 중심선이 영선보다 바깥쪽에 위치한다. +- ⑦ 지형의 상태에 따라 중심선측량은 파상지형의 소능선과 소계곡을 관통하며 진행되고, 영선측량은 사형(蛇形)으로 우회하여 진행되기도 한다. +- ⑧ 중심선측량은 평면측량에서 중심선을 설정한 후 종단·횡단측량을 실행하지만, 영선측량은 종단측량에서 영선을 먼저 설정한 후 평면·횡단측량을 실행한다. +#### 2) 중심선 측량법 +##### 가) 평면선형측량 +- ① 노선의 방향이 바뀌는 점{交點 : Intersection Point(IP)}에는 교점말뚝을 박고 시점말뚝을 0으로 하여 교점의 일련번호를 기입한다. +- ② 교점말뚝 1의 중심점에 측각기구(트랫싯,컴퍼스 등)를 설치하여 시점(BP)를 시준한 후 교 점 2를 반복 시준하여 교각(IA)를 구한다. 교점은 중심선측량에서 매우 중요한 측점이며 시공측량이나 공사 완성 후 노선의 유지관리 및 개량시에도 필요하므로 교점말뚝은 잘 보존하여야 한다. +- ③ 노선의 시점을 기준으로 20m마다 측점말뚝(Station Peg)을 박은 후 시점말뚝으로부터 측 점번호를 기입한다. 지형상 종·횡단의 변화가 심한 지점, 구조물설치 지점, 곡선부의 주요점 등에는 보조말뚝(Reference Peg)을 설치하여 측점번호를 부여한다. 이때 측점간의 번호는 20m이내에서 조정된다. +- ④ 교점간의 내각이 155°이내일 경우에는 평면곡선을 삽입하여야 하며 곡선부에서도 연속적으로 곡선을 따라 20m 간격으로 측점말뚝을 박고, 곡선의 시곡점(BC), 중곡점(MC), 종곡점(EC)에도 말뚝을 박아 보조측점을 부여한다. +- ⑤ 때로는 주요말뚝, 교점말뚝의 좌우에 필요할 경우 보호말뚝을 설치하기도 한다. +- ⑥ 측량기구 및 방법은 제 4장 2 -(3), (5) 참조. +##### 나) 종단측량 +- ① 중심선측량이 완료되면 함척, 레벨, 경사측정기, 핸드레벨 등을 이용하여 종단측량을 실시하며, 기준지반고는 가장 가까운 삼각점이나 보조삼각점으로부터 측정하여 기점부근의 교량이나 암반 등 변용되지 않은 지점에 수준점{Bench Mark(B. M)}을 설치한다. +- ② 각 측점마다 종단측량을 실행하여 지반고를 산출하고, 종단면도(縱斷面圖 : Profile)를 작성한다. +- ③ 측량기구 및 방법은 제 4장 2-(4) 참조. +##### 다) 횡단측량 +- ① 종단측량이 완료되면 함척, 테이프, 경사측정기, 핸드레벨 등을 이용하여 각 측점마다 중심선의 직각 방향이 되도록 중심선 좌·우의 지형에 대한 변화 상태를 측정하고 특히, 지형이 급변하는 지점과 구조물 설치지점에는 현지지형을 충분히 측정하여 설계도 작성과 공사수량 산출에 지장이 없도록 한다. +- ② 노폭, 중심선, 종단면도의 지반고, 계획고 및 토성에 따른 안식각에 따라 각 측점별로 횡 단면도(橫斷面圖 : Cross Section)을 작성한다. +- ③ 측량방법은 제 4장 2-(4) 참조. +#### 3) 영선측량법 +##### 가) 영선설정의 원칙 +- ① 영선은 당해노선의 구조·규격에 따라 설치하고자 하는 적정 종단물매를 정확히 알고 있어야 설정이 가능하다. +- ② 두 측점사이의 종단물매는 일정하게 유지해야 하며 물매를 변환하여야 할 경우의 허용편차는 2∼3%를 넘지 않도록 한다. +- ③ 영선 설정시에는 평면측량시 설치될 중심선의 위치를 감안하여야 하며, 만약 능선, 소계곡, 배향곡선 등과 같이 영선과 중심선의 편차가 심하게 발생할 우려가 있는 곳에서는 시공후 노선의 선형을 감안하여 적정물매 이상이 되지 않도록 평균물매를 조정한다. +- ④ 항상 고정된 지점(기지점)에서 가변지점(미지점)으로 측정하여 진행하되, 측량작업은 측량자(기계수)가 다른 작업원보다 앞선 상태에서 즉, 보조자를 기지점에 표적판을 세우게하고 측량자는 적정한 물매에 맞는 미지점을 찾아 이동하면서 측점을 확정한다. +- ⑤ 동일지점에서 기지점과 미지점으로 한 측점씩 건너서 측량하는 단도선시준방법은 측정오차가 발생하기 쉬우므로 가급적 하지 않도록 한다. +- ⑥ 시준거리의 변화가 크면 내부오차 조정에 영향을 미치므로 너무 긴 시준거리의 측량은 하지 않도록 한다. 적정시준거리(측점거리)는 20∼30m가 좋으며 임목이 조밀하여 시야가 좋지 못한 유령임분에서는 10m 정도로 하는 것이 시야가 좋다. +- ⑦ 영선의 설정과 측량작업에 소요되는 품은 외업과 내업을 포함하여 km당 측량자 1∼1.5인(8∼12 hr/km/1인), 보조원 2.5∼4인(20∼30 hr/km/1인)이다. +##### 나) 사용기구 +- ① 측량자 : 경사측정기(Clinometer), 지지봉(Clinometer rod), 컴퍼스 또는 핸드컴퍼스, 고도계 +- ② 보조자 : 30m 줄자(Tape), 10m 토우로프(Tow Rope), 표지테이프(Marking Tape), 표적판(Clinometer Target), 유성펜 + +![노선측량_그림5-2-29_표적판과지지봉](<../pic/노선측량_그림5-2-29_표적판과지지봉.png>) + +##### 다) 기구제작방법 +- ① 표적판 : (그림 5-2-29)의 1과 같이 크기는 50∼70cm×20∼30cm의 판을 3cm×3cm×120∼150cm의 각재에 부착하고, 조도가 낮은 임내에서도 시준이 가능하도록 야광 또는 발광 페인트로서 표면에는 백색바탕에 적색띠를 세로로 3∼4선을 그리고, 뒷면은 동계 적설시에도 사용할 수 있도록 황색바탕으로 한다. +- ② 지지봉: 그림 5-2-29의 2와 같이 3cm×3cm×120∼150cm의 각재 또는 원형봉으로 한다. 경사측정기를 설치했을 때 시준선이 표적판 상단과 일치하도록 하며, 지지봉 길이와 경사측정기 시준선 높이(약 27mm)의 합이 표적판 상단 높이가 되게 한다. + +![노선측량_그림5-2-30_토우로프](<../pic/노선측량_그림5-2-30_토우로프.png>) + +- ③ 토우로프: 그림 5-2-30과 같이 총 길이 10m의 가벼운 로프 양쪽 끝에 고리를 만든다. 한쪽 고리는 말뚝에 걸고 다른 고리를 잡아 10m 길이를 측정한다. +##### 라) 측량방법 +- ○ 측점부설과 종단물매의 측정 +- ① 보조자 1은 시점에 표적판을 수직으로 세우고 줄자의 0점을 잡는다. +- ② 측량자는 지지봉을 수직으로 세우고 경사측정기를 지지봉의 상단에 얹은 후 표적판의 상단을 시준하여 계획하고자 하는 물매로 시준하기 좋은 지점을 찾아 이동하면서 계획된 물매가 확정되는 지점에 말뚝을 박고, 오차의 유무를 확인한 후 야장에 기재한다. +- ③ 보조자 2는 줄자를 끌어 측점거리를 m 단위로 소수점 1자리까지 반복 측정하여 측량자에게 읽어주면 측량자는 복창하고 야장에 기재한다. +- ④ 만약 2회 측정한 물매와 거리에 대한 측정값이 차이가 근소하면 그 평균치를 구하여 기재하고 차이가 크면 다시 반복 측정한다. +- ○ 방위각의 측정 +- ① 측량자는 종단물매를 측정한 후 지지봉위에 컴퍼스를 설치하고, 보조자 1인이 세우고 있는 표적판의 수직선을 시준하여 방위각을 측정한다. 이때의 방위각은 측량야장 정리시에 역방위각으로 환산하여야 한다. +- ② 항상 동일한 방향으로 측정하고 매 측정시마다 전시와 후시를 통하여 반복측정하며 단도 선시준방법은 피한다. +- ③ 가능한한 동일한 장비를 사용하고 장비를 바꿀 때나 너무 오래 사용한 것은 사전에 점검하여 오차가 있을 경우에는 조정한다. +- ④ 측량자는 국지인력(Local Attraction)에 영향을 미치는 방해 요인(스틸테이프, 기구, 포켓 칼, 철테안경, 우산 등)을 휴대하고 작업하지 말고 필요시에는 먼 곳에 두거나 보조자에게 맡긴다. +- ⑤ 측량방법은 제 4장 2-(3) 참조 +- ○ 횡단기울기의 측정 +- ① 측량자는 지형변동이 심한 경우에는 좌우 양쪽의 기울기를 측정하여 평균하고, 심하지 않을 경우에는 한 쪽만 측정하여 사용하여도 측량의 정도(精度)에는 거의 영향을 미치지 않는다. +- ② 보조자 3은 10m 토우로프로서 한쪽 끝은 측점 말뚝에 걸고 임도노선에 대하여 직각방향으로 다른 한 쪽 고리를 팽팽하게 잡아당겨 표적판을 수직으로 세우면, 측량자는 방위각을 측정한 후 지지봉 위에 경사측정기를 설치하고 횡단기울기를 측정한다. +##### 마) 세부측량 방법 및 조정 + +노선설치는 경사측정기와 컴퍼스에 의하여 측정한다. 측량자는 지형 조건, 조정하여야 할 지점, 각 구간의 종단물매에 대한 자료를 파악하고 있어야 한다. 작업방법은 다음과 같이 4단계 작업으로 구분하여 소요 시간과 노력을 최대한 절약하며 가장 적정한 노선이 될 수 있도록 한다. 여기서 제 1단계와 제 2단계 작업을 예측단계, 제 3단계와 제 4단계 작업을 실측단계라고도 할 수 있다. + +- ○ 제 1단계 작업 +- ① 보조자는 표지테이프를 갖고 시점에 똑바로 서고, 측량자는 경사측정기를 이용하여 계획한 종단물매로 보조자의 눈 높이를 시준(측량자의 눈높이와 같게 하기 위하여 보조자가 측량자보다 키가 크면 코 높이로, 보조자의 키가 작으면 이마 높이로 시준)하며 가능한한 영선의 시준거리가 긴 곳으로 가서 측점 1을 부설하여 표시한다. +- ② 보조자는 측점 1에 이동하여 측점 주위의 임목에 표지테이프를 돌려매어 표지한 후 측점 위에 똑바로 서고, 측량자는 다음 측점으로 이동하여 ①과 같은 방법으로 계획한 종단물 매에 맞는 영선을 설정하고 측점 2를 부설하여 표시한다. +- ③ 측점간의 거리는 줄자나 토우로프로서 개략적으로 측정한다. +- ④ 이와 같이 하여 이론적인 계획선이 현지조건에 잘 부합되고 노선선형이 적정하게 되면 제1단계 작업으로서 영선설정을 위한 예측작업이 완료된 것이므로 제 2단계 작업은 생략하고 바로 제 3단계 작업을 실행할 수 있으나, 그렇지 않을 경우에는 제 2단계 작업을 계속한다. +- ○ 제 2단계 작업 +- ① 제 1단계 작업에서 현지조건에 잘 부합되지 않거나 이론적인 계획선에 편차가 너무 클 경우에는 불부합구간 끝지점에서 되돌아 오거나 또는 조정하여야 할 구간을 (그림 5-2-31)과 같이 제 1단계의 작업과 같은 방법으로 되풀이 한다. + +![노선측량_그림5-2-31_개산물매와수정](<../pic/노선측량_그림5-2-31_개산물매와수정.png>) + + +- ② 이때 보조자는 제 1단계 작업의 계획선과 혼동을 피하기 위하여 다른 색의 표지테이프를 사용한다(수정하는 첫 측점과 끝 측점은 2색을 같이 묶어 수정되는 구간이 본 노선과 연속되게 한다). +- ③ 이와 같이 이론적인 계획선이 현지조건에 잘 부합되고 노선선형이 적정하게 되면 제 2단계 작업으로서 예측작업이 완료된 것이므로 제 3단계 작업을 실행하고, 그렇지 않을 경우에는 다시 되풀이한다. (예제) (그림 5-2-31)과 같이 제 1단계 작업시에 개략거리와 종단물매가 측정되었으나 이론적인 계획선에 미흡하였다. 적정하게 조정하여라. (풀이) No. 20에서 No. 50까지의 고저차 : 200×0.04+220×0.09-130×0.04 = 22.6m 평균종단물매 : 22.6 ÷ (200+220+130) = 4.1%따라서 No. 50에서 4∼5%의 종단물매로서 되돌아오면 부득이한 경우를 제외하고는 조정된다. ○제 3단계 작업 +- ① 보조자 1은 표적판을 시점에 수직으로 세우고, 측량자는 측점에 지지봉을 수직으로 세우고 그 위에 경사측정기를 올려 적정한 종단물매로 전방의 표적판을 반복 시준하여 오차가 없으면 (표 5-2-17)의 야장에 기재한다. +- ② 이때 구간마다 물매가 변하면 노선선형이 순조롭지 못하므로 물매변환구간까지는 동일한 물매로 작업하는 것이 좋다. +- ③ 보조자 2는 각 측점에 말뚝을 박고 일련번호를 기재한 후 제 1단계와 제 2단계 작업시 사용한 표지테이프와 색깔이 다른 테이프로서 측점주위의 임목에 묶어 표지하여 측점말뚝의 위치를 쉽게 알 수 있게 한다. +- ○ 제 4단계 작업 +- ① 측량자는 각 측점에 보조자 1이 세운 표적판의 중심선을 컴퍼스로 전시와 후시하여 방위각을 반복 측정하고 오차가 없으면 (표 5-2-17)의 야장에 기재한다. 표 5-2-17. 영선측량 야장 + +| 임도노선선정야장 | | | | | | | | | +|---|---|---|---|---|---|---|---|---| +| 위치 : 임도번호 : 측량자 : | | | | | | | | | +| 측 점 | | 물매 | 거리 | 방위각 | 지 반 | 암 석 | 표고 | 비고 및 견취도 | +| | | | | | 기울기 | 구성비 | | | +| 부터 | 까지 | (%) | (m) | (˚) | (%) | (%) | (m) | | + +- ② 보조자 2와 3은 줄자나 측승으로 측점간의 구간거리를 m 단위로 소수점 1자리 까지 반복 측정하여 측량자에게 읽어주면 측량자는 복창을 한 후 야장에 기재한다. +- ③ 측량자는 보조자 4가 각 측점의 횡단상에 세운 표적판을 시준하여 횡단기울기를 반복 측정하고 오차가 없으면 야장에 기재한다. +- ④ 측점, 거리, 종단물매, 지반기울기, 암석구성비율, 지형의 특수성, 암거 등 배수시설, 공사자재 분포유무 등의 주요사항을 야장에 기재하고 노선의 견취도를 그린다. +- ⑤ 측량자의 작업이 숙련되면 제 3단계와 제 4단계의 작업을 동시에 실행하여도 될 것이며 보조자도 연속된 작업과정은 동시 작업으로 실행할 수 있으므로 작업인원을 줄일 수 있을 것이다. +##### 바) 굴곡부 등의 측량 +- ① 영선이 계곡부나 능선부를 횡단할 경우와 지형조건이 불규칙하거나 불량할 경우에는 중심선측량법에 의하여 중심선을 표지한다. 이때 영선과 중심선 사이의 간격이 너무 떨어져 있을 경우에는 중심선물매까지 위치와 계획된 종단물매의 차이를 구하여 차이가 클 경우에는 (그림 5-2-32)와 같이 영선을 조정한다. + +![노선측량_그림5-2-32_굴곡부의영선과중심선](<../pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png>) + + +- ② 일반적으로 능선부나 계곡부를 횡단할 경우에는 (그림 5-2-33)과 같이 종단물매를 낮추거나 높혔다가 정점(頂點)이나 곡점(曲點)이 지나면 반대로 높히거나 낮춘다. 이때 물매의 편차폭도 2∼3%이다. + +![노선측량_그림5-2-33_능선부와계곡부의종단물매](<../pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png>) + +그림 5-2-33. 능선부와 계곡부에서 종단물매 설치방법 + +#### 4) 트래버어스측량 + +트래버어스측량(折線測量 : Traverse Survey)은 다각측량(多角測量 : Polygonal Survey)이라고도 하며, 연속된 측선의 거리와 방향을 차례로 측정하는 측량으로서 각(角)의 측정은 트랜싯 또는 컴퍼스 등으로 하는 것이 보통이다. 측정결과에 대해서는 경위거(經緯距 : Departure and Latitude)를 계산하고, 각 측점의 좌표를결정하여 트래버어스(折線 : Traverse)를 그리고 면적을 계산한다. + +##### 가) 트래버어스측량의 측각법 +- ① 방위각법(方位角法 : Azimuth Angle Method) +- ② 편각법(偏角法 : Deflection Angle Method) +- ③ 내각법(內角法 : Interior Angle Method) +- ○ 방위각법에 의한 트래버어스측량의 측각방위각(方位角 : Azimuth)이란 N, S를 기준으로 N에서 시계방향으로 측각하는 것으로서 측 + +각방법은 다음과 같다. + +- ① (그림 5-2-34)의 점 A에 트랜싯을 설치하여 분도원의 0°와 버어니어의 0을 일치시켜 상부고정나사를 잠그고, 자침을 늦추어 콤파스의 0°를 자침끝에 맞추어 정지시키고 하부고정나사를 잠근다. + +![노선측량_그림5-2-34_방위각법트래버어스측량](<../pic/노선측량_그림5-2-34_방위각법트래버어스측량.png>) + +그림 5-2-34. 방위각법에 의한 트래버어스 측량 + +- ② 상부고정나사를 풀어 기계를 시계방향으로 수평회전시켜 점 B를 시준하고 상부고정나사를 잠그고, 미동나사로 정준하여 AB의 방위각(67°)을 읽는다. +- ③ ②에서 측정한 방위각(67°)을 그대로 둔 채로 하부고정나사를 풀고, 망원경을 반위하여 점 B에 옮겨 설치한다. +- ④ 점 A를 시준하여 하부고정나사를 잠그고 망원경을 정위하여 점 B'방향으로 향하게 한 후 상부고정나사를 풀고, 기계를 시계방향으로 회전하여 점 C를 시준한 후 상부고정나사를 잠그고 BC의 방위각(90°)을 읽는다. +- ⑤ 이와 같이 계속하여 측정하고 출발점 A에 돌아와 AB의 방위각을 재측정하여 최초의 측정치와 동일하면 정상이다. +- ⑥ 만일 일치하지 않으면 오차가 발생한 것이므로 조정하거나 재측하여야 한다. 방위각법에의한 트래버어스측량의 야장은 (표 5-2-18)과 같다. 표 5-2-18. 방위각법 트래버어스측량의 야장기입법(예) + +| 측 선 | 거 리 | 방 위 각 | 방 위 | 비 고 | +|---|---|---|---|---| +| A B | 51.80m | 60°00' | | | +| B C | 41.55m | 90°00' | | | +| C D | 40.80m | 130°00' | | | + +- ○ 편각법에 의한 트래버어스측량의 측각편각(偏角 : Deflection Angle)이란 그 선의 연장선과 인접되는 선이 이루는 각을 말하며, 시 + +계방향으로 측각한 것을 (+), 시계반대방향으로 측각한 것을 (-)로 표시한다. 이 방법은 주로 도로, 철도, 수도 등의 노선측량에 많이 사용된다. 편각법에 의한 트래버어스측량의 야장(표5-2-19) 및 측각방법은 다음과 같다. 표 5-2-19. 편각법 트래버어스측량의 야장기입법(예) + +| 측 선 | 거 리 | 편 각 | | 비 고 | +|---|---|---|---|---| +| | | + | - | | +| A B B C C D | 54.35m 52.12m 48.40m | 90°17' 60°10' | 35°10' | | + +![노선측량_그림5-2-35_편각법트래버어스측량](<../pic/노선측량_그림5-2-35_편각법트래버어스측량.png>) + +그림 5-2-35. 편각법에 의한 트래버어스 측량 + +- ① (그림 5-2-35)에서 점 A에 트랜싯을 설치하고, 분도원 0°와 버어니어 0을 일치시켜 상부 고정나사를 잠근 후 망원경을 반위하여 점 F를 후시하고 하부고정나사를 잠근다. +- ② 망원경을 정위로 하고, 상부고정나사를 푼 후 기계를 수평으로 회전시켜 점 B를 전시하여 상부고정나사를 잠그고 각을 읽는다. +- ③ 이렇게 계속하며, 폐합트래버어스에서는 그 대수합이 360°가 되어야 한다. 편각법에 의한트래버어스측량에 있어서 첫 측점의 방위각은 실측하여 두는 것이 좋다(야장정리시에 방위각법으로 환산가능하기 때문임). +- ○ 내각법에 의한 트래버어스측량의 측각 서로 접하는 두 측선이 이루는 사이각인 내각(內角 : Interior Angle)을 측정하는 방법으로 측 + +각방법은 다음과 같다. + +- ① (그림 5-2-36)에서 점 A에 트랜싯을 설치하고 분도원의 0°와 버어니어의 0을 일치시켜 상부고정나사를 잠그고, 점 D를 후시하여 하부고정나사를 잠근다. +- ② 상부고정나사를 풀어 점 B를 전시하여 AB의 방위각 θ와 점 A의 내각(∠BAD)을 측정한다. +- ③ 점 B에 기계를 이동 설치하고 ①과 같은 방법으로 한다. 점 A를 후시하여 하부고정나사를 잠근 후, 상부고정나사를 풀고 점 C를 전시하고 점 B의 내각을 측정한다. +- ④ 이와 같이 계속 진행하며 n각형의 내각의 합은 180°×(n-2)를 만족시켜야 한다. + +![노선측량_그림5-2-36_내각법트래버어스측량](<../pic/노선측량_그림5-2-36_내각법트래버어스측량.png>) + + +##### 나) 트래버어스측량 측각치의 조정 +- ○ 트래버어스측량 측각오차의 처리트래버어스의 측각이 전부 끝나면 관측치가 기하학적으로 만족하여야 한다. 이때 각 조건과 + +비교해서 오차가 있는 경우에는 다음과 같이 처리한다. + +- ① 오차가 허용범위 내에 있는지를 조사해서 허용범위보다 클 경우에는 재측하여야 한다. +- ② 허용범위 내에 있을 경우에는 합리적으로 조정하여 기하학상의 조건을 만족시킨다. +- ③ n을 트래버어스의 변수(邊數)라 할 때, 트래버어스 측각오차의 허용범위는 다음과 같다. √㉮ 산림지, 복잡한 경사지 : 1.5 n (분)√√㉯ 평지, 보통지 : 1.0 n ∼ 0.5 n (분)√√㉰ 시가지, 중요한 곳 : 0.3 n ∼ 0.2 n (분) +- ④ 특별한 경우를 제외하고는 측정오차가 각 점의 각에서 평균되게 발생한다고 가정하여 각 점의 각에 가감한다. +- ○ 트래버어스 측정치의 충족조건 +- - 폐합트래버어스 측정치의 충족조건 변수(邊數)를 n, 측정각을 a1, a2, a3, …, 〔a〕= a1+a2+a3+ … 라 하면, 측정오차 Δa 는 다음식에 의하여 산출한다. +- ① 내각 측정인 경우 : Δa = 180°(n-2) -〔a〕 +- ② 외각 측정인 경우 : Δa = 180°(n+2) -〔a〕 +- ③ 편각 측정인 경우 : Δa = 360°- 〔a〕 +- - 결합트래버어스 측정치의 충족조건 +- ① (그림 5-2-37)의 (a) 인 경우: Δa = (Wa-Wb) +〔a〕- 180°(n+1) +- ② (그림 5-2-37)의 (b), (c) 인 경우: Δa = (Wa-Wb) +〔a〕- 180°(n-1) +- ③ (그림 5-2-37)의 (d) 인 경우: Δa = (Wa-Wb) +〔a〕- 180°(n-3)여기서, a1, a2, a3,…, an : 교각의 측정치, Wa, Wb : AL, BM의 방위각 + +![노선측량_그림5-2-37_트래버어스측정치조건](<../pic/노선측량_그림5-2-37_트래버어스측정치조건.png>) + +그림 5-2-37. 트래버어스 측정치의 조건 + +##### 다) 측정각들의 상호 환산법 +- ○ 교각에서 방위각을 구하는 계산 + +교각의 측정은 진행방향의 좌측각을 측각하는 경우와 우측각을 측각하는 경우의 두 가지가 있다. 좌측각은 그림 5-2-38(a)와 같이 $(+)$로, 우측각은 그림 5-2-38(b)와 같이 $(-)$로 나타낸다. + +$$\text{측선의 방위각}=\text{앞 측선의 방위각}+180^\circ\pm\text{교각}$$ + +구한 각이 $360^\circ$보다 크면 $360^\circ$를 빼고, $0^\circ$보다 작으면 $360^\circ$를 더한다. + +![노선측량_그림5-2-38_트래버어스방위각산출_01](<../pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png>) + +![노선측량_그림5-2-38_트래버어스방위각산출_02](<../pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png>) + + +- ○ 방위각, 편각, 내각에서 방위를 구하는 계산 +- - 방위각에서 방위를 구하는 계산(그림 5-2-39)과 같이 방위각을 측정하여 방위를 산출하는 방법은 (표 5-2-20)과 같다. + +![노선측량_그림5-2-39_방위각과방위의관계](<../pic/노선측량_그림5-2-39_방위각과방위의관계.png>) + +표 5-2-20. 방위각에서 방위를 산출하는 방법 + +| 방 위 각 | 방 위 | +|---|---| +| 0∼ 90° | N (방위각) E | +| 90∼180° | S (180°-방위각) E | +| 180∼270° | S (방위각-180°)W | +| 270∼360° | N (360°-방위각) W | + +- - 편각에서 방위를 구하는 계산(그림 5-2-40)과 같이 편각에서 방위를 구할 때에는 먼저 방위각을 구한 후, 방위각에서 방위를 구한다. +- ① 최초선의 방위각은 그선의 방위각이 된다. +- ② 임의의 측선의 방위각 = 앞 측선의 방위각 + 앞 측선과 이루는 편각(방위각을 구하려는 점의 편각)즉, β2 = β1 + α2 +- ③ 그러나, 측점이 최초의 점에 있지 않고, 그 측선이 반향(反向)하여 (-)인 음각(負角)이 생기는 경우(그림 5-2-40의 점 C)에는 측선의 방위각 = 앞 측선의 방위각 - 그 측선과 이루는 편각 즉 β3 = β2 -α3 + +![노선측량_그림5-2-40_편각과방위의관계](<../pic/노선측량_그림5-2-40_편각과방위의관계.png>) + +그림 5-2-40. 편각과 방위의 관계 + +- - 내각에서 방위를 구하는 계산(그림 5-2-41)과 같이 편각에서 방위를 구할 때에도 먼저 방위각을 구한 후, 방위각에서 방위를 구한다. +- ① 최초선의 방위각은 그대로 그선의 방위각이 된다. +- ② 임의의 측선의 방위각 = 앞 측선의 방위각 + 180°- 그 측선과 이루는 내각 단, 반대방향으로 계산할 경우에는 (+), (-)를 바꾸어 계산한다. +- ③ ②의 식에서 계산한 각이 360°이상일때는 그 각에서 360°를 뺀다. + +![노선측량_그림5-2-41_내각과방위의관계](<../pic/노선측량_그림5-2-41_내각과방위의관계.png>) + +그림 5-2-41. 내각과 방위의 관계 + +이상에서 설명한 방법에 의해 방위각, 편각, 내각에서 방위를 구하는 계산의 예를 살펴보면 표 5-2-21과 같다. + +표 5-2-21. 방위각, 편각, 내각에서 방위를 구하는 계산의 예 + +| 측점 | 내 각 | 편 각 | 방위각 | 방 위 | 비 고 | +|---|---|---|---|---|---| +| 1 | 95°58′ | 84°02′ | 29°12′ | N 29°12′ E | 최초 방위각 | +| 2 | 134°55′ | 45°05′ | 74°17′ | N 74°17′ E | | +| 3 | 93°46′ | 86°14′ | 160°31′ | S 19°29′ E | | +| 4 | 148°38′ | 31°22′ | 191°53′ | S 11°53′ W | | +| 5 | 111°31′ | 68°29′ | 260°22′ | S 80°22′ W | | +| 6 | 135°12′ | 44°48′ | 305°10′ | N 54°50′ W | | +| 계 | 720°00′ | 360°00′ | | | 내각은 우측정 | + +(계산 예) 각 측점의 편각은 $180°-$내각으로 구한다. 이후 방위각은 앞 측선의 방위각에 편각을 더하여 구하며, 180°를 넘는 방위각은 해당 사분면의 방위로 환산한다. 예를 들어 측점 3은 $74°17′+86°14′=160°31′$이므로 방위는 S 19°29′ E이다. + +##### 라) 위거 및 경거의 계산 + +- ○ 트래버어스측량에서 위거 및 경거의 이용 + +![노선측량_그림5-2-42_위거와경거의관계](<../pic/노선측량_그림5-2-42_위거와경거의관계.png>) + +그림 5-2-42에서 XY를 직교축이라고 할 때 종축 NS는 자오선, 횡축 EW는 위선이며 $\theta$는 방위각이다. 측선 AB가 NS축에 비치는 정사영거리 Ab를 위거(Latitude), EW축에 비치는 정사영거리 Aa를 경거(Departure), 측선 중점에서 NS선에 직각으로 그은 CC′를 자오선거(Meridian Distance)라 한다. + +- ① 오차의 합리적인 배분 +- ② 측정치의 제도 +- ③ 폐합트래버어스인 경우의 면적 계산 +- ④ 결측(缺測) 또는 오측(誤測)의 보정위거와 경거의 방향을 알기 위해 다음과 같이 (+), (-)의 부호를 붙인다. 표 5-2-22. 방위에 따른 경거 및 위거의 부호 + +| 방위각(θ) | 방 위 | 위 거 | 경 거 | 비 고 | +|---|---|---|---|---| +| 0°~ 90° | N θE | + | + | | +| 90°~180° | S θE | - | + | | +| 180°~270° | S θW | - | - | | +| 270°~360° | N θW | + | - | | + +○삼각함수에 의한 경위거의 계산 위거 및 경거는 삼각함수의 진수를 사용하여 다음식에 의하여 계산할 수 있다. + +- ① 측선 AB의 위거 = LAB = AB·cosθ +- ② 측선 AB의 경거 = DAB = AB·sinθ +- ③ 측선 AB의 자오선거 = CC′이다. 표 5-2-23. 삼각함수에 의한 경위거의 계산 (예) + +| 측 선 | 거 리 (m) | 방 위 각 | cosθ | sinθ | 위 거 | | | 경 거 | | +|---|---|---|---|---|---|---|---|---|---| +| | | | | | N(+) | S(-) | | E(+) | W(-) | +| AB | 67.30 | 23°55′40″ | 0.91406 | 0.40558 | 61.52 | | | 27.30 | | +| BC | 79.90 | 313°10′30″ | 0.68423 | 0.72927 | 54.67 | | | | 58.28 | +| CD | 63.37 | 16°39′10″ | 0.95806 | 0.28647 | 60.71 | | | 18.15 | | +| DE | 93.60 | 115°35′50″ | 0.43204 | 0.90185 | | 40.44 | | 84.41 | | + +(예제) AB=67.30m, $\theta=23°55′40″$일 때 위거는 $67.30\times0.91406=61.52$m, 경거는 $67.30\times0.40558=27.30$m이다. + +- - 대수에 의한 경위거의 계산 측선의 길이를 S, 방위각을 θ라 하면, 위거는 S·cosθ, 경거는 S·sinθ이므로 양변에 대수(對數 : log)를 취하면 다음과 같은 식이 성립한다. +- ① 위거의 대수 =log S + log cosθ +- ② 경거의 대수 =log S + log sinθ표 5-2-24. 대수에 의한 경위거의 계산 (예) + +| 측선 | 방위각 | 거리 (m) | 대 수 | | | | | 위 거 | | 경 거 | | +|---|---|---|---|---|---|---|---|---|---|---|---| +| | | | 측 선 | cos θ | sin θ | 위 거 | 경 거 | N(+) | S(-) | (+) | (-) | +| AB | 23°55′40″ | 67.30 | 1.828015 | 9.960974 | 9.608082 | 1.788989 | 1.436097 | 61.52 | | 27.30 | | +| BC | 313°10′30″ | 79.90 | 1.902547 | 9.835201 | 9.862886 | 1.739748 | 1.765334 | 54.67 | | | 58.28 | +| CD | 16°39′10″ | 63.37 | 1.801884 | 9.981399 | 9.457232 | 1.783283 | 1.259116 | 60.71 | | 18.15 | | +| DE | 115°35′50″ | 93.60 | 1.971276 | 9.635526 | 9.955136 | 1.606802 | 1.926112 | | 40.44 | 84.41 | | +주) $\sin$과 $\cos$ 값은 1보다 작으므로 그 대수는 음수이다. 계산 편의를 위해 대수표에는 10을 더한 값을 쓰고 최종 계산에서 10을 뺀다. 예를 들어 $\cos23°55′40″=0.91406$이고 $\log0.91406=-0.039026$이므로 대수표에는 $10-0.039026=9.960974$를 기재한다. + +(예제) AB=67.30m, $\theta=23°55′40″$일 때 $\log 67.30=1.828015$, $10+\log\cos\theta=9.960974$, $10+\log\sin\theta=9.608082$이다. 따라서 위거의 대수는 1.788989, 경거의 대수는 1.436097이며, 위거는 61.52m, 경거는 27.30m이다. + +- ○ 경위거표에 의한 경위거의 계산(표 5-2-25)와 같은 경위거표(經緯距表 : Traverse Table)란 측선 및 방위각의 여러 가지 값에 대하 + +여 경거 및 위거를 계산하는 것으로 곱셈의 필요없이 덧셈으로써만 계산하도록 되어 있는 표이다. (예제) 측선 AB의 거리 25.10m, 방위각 = 30°5′일 때 다음의 경위거표를 이용하여 위거 및경거를 구하여라. 표 5-2-25. 경위거표(예)a) 30°위거 + +| 분 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | | +|---|---|---|---|---|---|---|---|---|---|---| +| 0 | 0.8660 | 1.7320 | 2.5980 | 3.4641 | 4.3301 | 5.1961 | 6.0622 | 6.9282 | 7.7942 | 60 | +| 1 | 0.8659 | 1.7318 | 2.5976 | 3.4635 | 4.3294 | 5.1953 | 6.0612 | 6.9270 | 7.7929 | 59 | +| 2 | 0.8637 | 1.7315 | 2.5972 | 3.4629 | 4.3286 | 5.1944 | 6.0601 | 6.9258 | 7.7916 | 58 | +| 3 | 0.8656 | 1.7312 | 2.5968 | 3.4624 | 4.3279 | 5.1935 | 6.0591 | 6.9247 | 7.7903 | 57 | +| 4 | 0.8654 | 1.7309 | 2.5963 | 3.4618 | 4.3272 | 5.1926 | 6.0581 | 6.9235 | 7.7890 | 56 | +| 5 | 0.8653 | 1.7306 | 2.5959 | 3.4612 | 4.3265 | 5.1918 | 6.0571 | 6.9224 | 7.7877 | 55 | + +b) 30°경거 + +| 분 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | | +|---|---|---|---|---|---|---|---|---|---|---| +| 0 | 0.5000 | 1.0000 | 1.5000 | 2.0000 | 2.5000 | 3.0000 | 3.5000 | 4.0000 | 4.5000 | 60 | +| 1 | 0.5002 | 1.0005 | 1.5007 | 2.0010 | 2.5012 | 3.0015 | 3.5017 | 4.0020 | 4.5022 | 59 | +| 2 | 0.5005 | 1.0010 | 1.5015 | 2.0020 | 2.5025 | 3.0030 | 3.5035 | 4.0040 | 4.5045 | 58 | +| 3 | 0.5008 | 1.0015 | 1.5023 | 2.0030 | 2.5038 | 3.0046 | 3.5053 | 4.0061 | 4.5068 | 57 | +| 4 | 0.5010 | 1.0020 | 1.5030 | 2.0040 | 2.5050 | 3.0061 | 3.5071 | 4.0081 | 4.5091 | 56 | +| 5 | 0.5013 | 1.0025 | 1.5038 | 2.0050 | 2.5063 | 3.0076 | 3.5088 | 4.0101 | 4.5113 | 55 | + +(풀이) 표 5-2-25의 30° 위거란 5′ 행에서 1은 0.8653, 2는 1.7306, 5는 4.3265, 9는 7.7877이다. $25.10=2\times10+5+1/10$이므로: + +$$\text{위거}=(1.7306\times10)+4.3265+(0.8653/10)=21.71903\fallingdotseq21.72\,\mathrm{m}$$ + +같은 행의 경거값은 1이 0.5013, 2가 1.0025, 5가 2.5063, 9가 4.5113이다. + +$$\text{경거}=(1.0025\times10)+2.5063+(0.5013/10)=12.58143\fallingdotseq12.58\,\mathrm{m}$$ + +### 마. 평면곡선(horizontal curve)의 설치 +#### 1) 곡선의 종류 +- ① 단(원)곡선(Simple Curve) : 평형하지 않은 2개의 직선을 1개의 원곡선으로 연결하는 곡선{그림 5-2-43의 (a)} +- ② 반향곡선(Reversed Curve) : 방향이 다른 두 개의 원곡선이 직접 접속하는 곡선으로서 곡선의 중심이 서로 반대쪽에 위치한 곡선{그림 5-2-43의 (b)} +- ③ 복심곡선(Compound Curve) : 동일한 방향으로 굽고 곡률이 다른 2개 이상의 원곡선이 직접 접속되는 곡선{그림 5-2-43의 (c)} +- ④ 배향곡선(Hair Pin Curve) : 단곡선, 복심곡선, 반향곡선이 혼합되어 머리핀(Hair Pin)모양으로 된 곡선으로서 산복부에서 노선길이를 연장하여 종단물매를 완화하게 하거나 동일 사면에서 우회할 목적으로 설치되며 교각이 180°에 가깝게 됨{그림 5-2-43의 (d)} +- ⑤ 완화곡선(Transition Curve) {그림 5-2-43의 (e)}㉮ 3차포물선(Parabolic) : 접선장에 비례하여 Cant의 증가 또는 곡율반경이 감소하는 곡선 - 주로 철도에 이용 ㉯ 쌍곡선(Clothoid) : 완화곡선장에 비례하여 Cant의 증가 또는 곡율반경이 감소하는 곡선 - 주로 도로에 이용 ㉰ 연주곡선(Lemniscate) : 현장에 비례하여 Cant의 증가 또는 곡률반경이 감소하는 곡선 +- - 주로 입체교차로에 이용 + +![노선측량_그림5-2-43_곡선의종류_01](<../pic/노선측량_그림5-2-43_곡선의종류_01.png>) + +![노선측량_그림5-2-43_곡선의종류_02](<../pic/노선측량_그림5-2-43_곡선의종류_02.png>) + +그림 5-2-43. 곡선의 종류 + +#### 2) 곡선부의 명칭과 산출식 +- ① 곡선시점(Beginning of Curve : BC) = A +- ② 곡선종점(End of Curve : EC) = B +- ③ 교점(Intersection Point : IP) = V +- ④ 곡선반지름(Radius : R) = OA=OB +- ⑤ 곡선장(Curve Length : CL) = AHB = 0.017453 R·I°= 2π·R·I°/360° +- ⑥ 장현(Long Chord : C) = AB = 2 R·sin (I/2) +- ⑦ 절선장(Tangent Length : TL 혹은 T) = VA = VB = R·tan(I/2) +- ⑧ 외거(외할)(External Distance): $E=VH=R\{\sec(I/2)-1\}$ +- ⑨ 중앙종거(Middle Ordinate : M) = HF = R {1- cos(I/2)}= C2 / 8R +- ⑩ 교각(편각) = (Intersection Angle : IA 또는 I) = I + +![노선측량_그림5-2-44_곡선부의구조](<../pic/노선측량_그림5-2-44_곡선부의구조.png>) + +V + +| | I | +|---|---| +| H | | + + +#### 3) 교점의 설치와 교각의 측정 +- ① 시공측량이나 공사 완성후 장애물이 없는 지역에서는 두 절선의 교차에 의하여 교점과 교각을 쉽게 측정할 수 있지만 장애물이 있을 경우와 기설노선에서 교점을 구할 경우에는(그림 5-2-45)와 같이 한다. +- ② 이미 설치된 2∼3개 점의 지물로부터 그 위치를 측정하여 직선부의 방향을 정한 후 말뚝을 박고 두 직선의 교각(交角)을 측정한다. +- ③ 이때 장애물이나 지형 관계상 교점이 두 직선상에서 보이지 않거나 접근할 수 없는 경우에는 두 절선상(TL)에 임의의 두 점을 선택하여 ∠α와 ∠β를 측정하면 IA = ∠α+ ∠β에 의하여 구할 수 있다. + +![노선측량_그림5-2-45_두절선상교점설치법](<../pic/노선측량_그림5-2-45_두절선상교점설치법.png>) + + +#### 4) 편각에 의한 곡선의 설치 +- ① (그림 5-2-46)과 같이 교점(점 V)에 트랜싯을 세우고 교각(I)을 측정하여 절선장(VA 또는 VB = R·tan (I/2))만큼 떨어진 지점에 각각 곡선의 시점(BC)과 종점(EC)을 설정한다. + +![노선측량_그림5-2-46_편각에의한곡선설치법](<../pic/노선측량_그림5-2-46_편각에의한곡선설치법.png>) + +그림 5-2-46. 편각에 의한 곡선설치법 + +- ② BC점에 트랜싯을 옮겨 설치하고 ∠VAB = (I/2) 되는지를 검사한 다음 버어니어의 0을수평분도원의 0°에맞추고, 상부고정나사를 잠근다. +- ③ AV선에 망원경을 향하게 하여 하부고정나사를 잠그고, 상부고정나사를 푼 다음 다음 편각만큼 회전한다. + +$$\delta=\frac{\ell}{2R}\;(\mathrm{radian})=\frac{1,718.87'\times\ell}{R}$$ + +시준선상에 BC점에서 거리 $\ell$인 점 $P_1$을 정해 중심선을 확정한다. 일반도로에서는 보통 $\ell\le R/10$이고 대개 20m로 하지만, 임도에서는 지형에 따라 조정한다. 마지막 직선부 말뚝 D가 BC점과 일치하지 않을 때 $AP_1=\ell_1=\ell-DA$를 시단현이라 하며 $\delta_1=1,718.87'\ell_1/R$이다. +- ④ 상부고정나사가 풀어진 상태에서 수평각이 2δ(또는 δ1+δ)가 될 때까지 상부를 회전하고, 이 시준선상에 점 P1에서 거리 ℓ이 되는 곳에 점 P2를 설정하여 측점말뚝을 박고 못을 박아 중심선을 확정한다. ※ APn의 편각 = δ1+(n-1)×δ +- ⑤ 이와 같은 방법을 계속하여 측점말뚝을 설치한다. +- ⑥ 마지막 말뚝 $P_n$과 EC가 일치하지 않을 경우 $P_nB=\ell_2=AB-AP_n$를 종단현이라 하며, $BP_n$에 대한 편각은 $\delta_2=1,718.87'\ell_2/R$이다. + +(예제 1) $I=32°15′$, $R=200$m이고 노선 기점에서 IP 16까지 거리가 1,200.50m일 때 20m 간격으로 측점말뚝을 설치한다. + +- $TL=200\tan(32°15′/2)=57.82$m +- $E=200\{\sec(32°15′/2)-1\}=8.19$m +- $CL=0.017453\times200\times32°15′=112.57$m +- $BC=1,200.50-57.82=1,142.68$m +- $EC=1,200.50-57.82+112.57=1,255.25$m +- 시단현 $\ell_1=20-2.68=17.32$m +- 종단현 $\ell_2=1,255.25-(62\times20)=15.25$m +- $\delta=(20/200)\times1,718.87′=2°51′53″$ +- $\delta_1=(17.32/200)\times1,718.87′=2°28′51″$ +- $\delta_2=(15.25/200)\times1,718.87′=2°11′04″$ + +표 5-2-26. BC점에서 각 측점의 편각 + +| 기점에서부터의 거리(m) | 편각 | 비고 | +|---:|---:|---| +| 1,142.68 | | BC | +| 1,160 | 2°28′51″ | | +| 1,180 | 5°20′44″ | | +| 1,200 | 8°12′37″ | | +| 1,220 | 11°04′30″ | | +| 1,240 | 13°56′23″ | | +| 1,255.25 | 16°07′27″ | EC | + +(예제 2) $I=37°30′$, $R=300$m, IP까지 거리 1,150.70m일 때 20m 간격으로 단곡선 중심말뚝을 설치한다. 곡선함수표의 $R=100$m 기준값은 $TL=33.945$, $CL=65.450$, $E=5.604$이다. + +- ○ T. L = 33.945×300/100 = 101.835m +- ○ C. L = 65.450×300/100 = 196.350m +- ○ E = 5.604×300/100 = 16.812m +- ○ BC = IP-T. L = 1,150.70-101.835 = 1,048.865m +- ○ $EC=IP-TL+CL=1,150.70-101.835+196.350=1,245.215$m + +$R=300$m이고 $\ell=20$m일 때 편각 $\delta$는 1°54′35″이다. BC 다음의 20m 배수는 1,060m이고 EC 이전의 20m 배수는 1,240m이다. +- ○ 시단현 ℓ1 = 60-48.865 = 11.135m +- ○ 종단현 $\ell_2=45.215-40=5.215$m +- ○ 시단현 10m의 편각: 0°57′18″ +- ○ 편각 구성값: 5m=0°28′39″, 1m=0°05′44″, 0.2m=0°01′09″, 0.1m=0°00′34″, 0.01m=0°00′03″, 0.03m=0°00′10″ +- ○ $\delta_1=1°03′46″$, $\delta_2=0°29′51″$ + +각도를 초 단위까지 측정하면 비례보간법으로 계산한다. + +표 5-2-27. BC에서 각 측점의 편각 + +| 측점말뚝(m) | 편각 | 비고 | +|---:|---:|---| +| 1,048.865 | | BC | +| 1,060 | 1°3′46″ | | +| 1,080 | 2°58′21″ | | +| 1,100 | 4°52′56″ | | +| 1,120 | 6°47′31″ | | +| 1,140 | 8°42′06″ | | +| 1,160 | 10°36′41″ | | +| 1,180 | 12°31′16″ | | +| 1,200 | 14°25′51″ | | +| 1,220 | 16°20′26″ | | +| 1,240 | 18°15′01″ | | +| 1,245.215 | 18°44′52″ | EC | + +#### 5) 장애물이 있을 경우의 편각에 의한 곡선설치 +##### 가) 시준선상에 장애물이 있는 경우 + +(그림 5-2-47)의 점 A에서 4)항의 방법으로 점3까지 설치하고 점4의 위치를 설치하고자 할 때 장애물이 있어서 방향선을 정하지 못할 경우에는 다음의 방법으로 정한다. + +- ① (그림 5-2-47) 같이 점 EC에 트랜싯을 설치하고 점 5, 4, 3을 4)항의 방법으로 설치한다. (방법 1) +- ② 점 3에 트랜싯을 세우고, 수평분도원 0°와 버어니어 0을 일치시켜 점 A(BC)를 시준하고 상·하고정나사를 고정시킨 후 망원경을 반전한다. +- ③ 상부고정나사를 풀고 상부를 d3만큼 회전시키면 점 3의 접선이 되므로 4)항의 방법에 의하여 점 4, 5를 측정한다(방법 2). + +![노선측량_그림5-2-47_시준선장애물곡선설치법](<../pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png>) + + +##### 나) 교점(I.P)에 접근하지 못하는 경우 +- ① (그림 5-2-48)과 같이 점 P가 장애물 중에 있어 접근하지 못할 때에는 EP선 과는 DP선의 선상에 각각 임의의 점 a와 b를 정한다. + +![노선측량_그림5-2-48_교점접근불가곡선설치법](<../pic/노선측량_그림5-2-48_교점접근불가곡선설치법.png>) + + +- ② 점 a와 b에 트랜싯을 설치하여 각 θ1, θ2 및 ab의 거리를 측정하여 Aa와 Bb의 거리를 구한다. β1 = 180°- θ1, β2 = 180°- θ2, β3 = 180°- (β1+β +#### 2) + +I = 180°- β₃에서 Pa = (ab·sinβ2) / sinβ3Pb = (ab·sinβ1) / sinβ3Aa = T. L - Pa, Bb = T. L - Pb +- ③ BC와 EC가 결정되면 4)항의 방법에 의하여 곡선을 설치한다. +##### 다) BC와 EC가 모두 장애물 중에 있을 경우 +- ① (그림 5-2-49)와 같이 EP와 DP의 거리를 측정하여 곡선제원을 계산하고 트랜싯을 점 P에 설치하여 θ/2 를 측정하여 PG의 방향을 설정한다. +- ② PG선에 E = PG = R{(sec(I/2) - 1)}이 되도록 점 G를 설정한다. +- ③ 점 G에 트랜싯을 설치하여 PG에 대하여 직각방향 PGD′를 설정하면 GD′는 호의 접선이 되므로 BC와 EC방향의 곡선을 4)항의 방법으로 설정한다. 이때 BC = EP - TL이고,EC = BC + CL 이며, 점 G까지의 거리는 BC + (CL/2)이 된다. 그러므로 전후의 단현을 계산하고 양측의 곡선을 설정한다. + +![노선측량_그림5-2-49_BC와EC장애물곡선설치법](<../pic/노선측량_그림5-2-49_BC와EC장애물곡선설치법.png>) + +그림 5-2-49. BC와 EC에 장애물이 있을 경우의 곡선설치법 + +#### 6) 중앙종거에 의한 곡선설치 + +이 방법은 줄자만으로도 충분히 곡선설치가 가능한 방법으로서 정확을 요하지 않을 경우에 적용된다. + +- ① (그림 5-2-50)에서 교각 I를 측정하고 R을 가정하여 T = R·tan(I/2)로 계산한다. +- ② 점 P에서 TL을 측정하여 BC와 EC점을 결정하고, AB의 거리를 측정하거나 C = 2R·sin(I/2)에 의하여 계산한다. +- ③ M = C2/B을 계산하여 AB의 중점 E를 구하고 PE선에서 M을 측정하여 점 D를 정한다. +- ④ AD의 길이를 측정하여 그 중점 E1에서 직각선을 테이프로 측정하여 M′= M/4 만큼 되는 점 D1을 산정한다. +- ⑤ 다음에 AD1을 연결하여 그 중점에서 M"= M′/4 만큼 되는 점 D2를 측정하여 곡선을 설정한다. 이와 같은 방법으로 EC까지 계속한다. + +![노선측량_그림5-2-50_중앙종거곡선설치법](<../pic/노선측량_그림5-2-50_중앙종거곡선설치법.png>) + + +#### 7) 반향곡선(反向曲線 : Reversed Curve)의 설치 +##### 가) 평행한 2직선 구간을 반향곡선으로 연결할 경우 +- ① (그림 5-2-51)에서 AD//EB라고 하면 점D, E(두곡선의 IP)를 선정하고 DE상의 1점 C(반향점)를 설치하여 CD, CE를 측정한다. +- ② 이때는 이미 접선장(T. L)과 교각(I)를 알고 있으므로 곡선반지름 r과 R을 산출하여 단곡선 설치의 방법 중 한가지를 선택하여 곡선 AC와 곡선 BC를 설정한다. + +![노선측량_그림5-2-51_평행한두직선반향곡선](<../pic/노선측량_그림5-2-51_평행한두직선반향곡선.png>) + + +##### 나) 평행하지 않은 2직선구간을 반향곡선으로 연결할 경우 +- ① (그림 5-2-52)의 점 A에서 HA에 수선을 긋고 AE = R 되는 점 E를 구하고 점 E에서 BK의 연장선상에 그은 수선과 AC호의 연장과 만나는 교점을 점 D라고 하면 +- ② DACB 곡선은 DN선과 MK선이 평행하므로 √DB = a = 2 R·b 가 되고 +- ③ DB의 방향은 sin∠BDN = b / a 이므로 점 B와 점 C를 산출한다. +- ④ BD간을 연결하는 곡선을 단곡선 설치의 방법에 의하여 설치하면 점 A를 통과하는 소요 곡선이 된다. +- ⑤ ∠HAD = 1/2·K, AD = 2 R·sin(1/2·K)이므로 점 D를 설정하고, ∠ADN = 1/2·K이므로 DN의 방향선을 구한다. + +![노선측량_그림5-2-52_평행하지않은두직선반향곡선](<../pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png>) + +그림 5-2-52. 평행하지 않은 2직선 구간의 반향곡선 설치법 + +#### 8) 복심곡선(複心曲線 : Compound Curve)의 설치 +- ① (그림 5-2-53)의 점 C를 통과하고 주위의 지형에 적합한 임의의 직선 DCE를 설치하고, ∠PDE(θ)와 ∠PED(θ +#### 1) 의 각을 측정한다. +- ② AC구간 사이에 설치할 곡선의 절선 즉 접선 = DC = TL이라 하면 TL =DC와 θ는 실측에 의하여 값을 정하는 것이므로 R의 값은 R = AO = DO / tan(θ/ 2) = TL /tan(θ/ 2) = TL cot (θ/ 2) +- ③ TL과 R이 결정되면 AC구간은 단곡선으로서 설치될 수 있고, 다음에 TL =EC와 θ1을 실측하여 구하면, R′= BO′= EC / tan(θ1/2) = TL / tan(θ1/2) = TL cot(θ1/2) +- ④ R′를 계산하여 CB구간도 단곡선으로 설치할 수 있다. + +![노선측량_그림5-2-53_복심곡선설치법](<../pic/노선측량_그림5-2-53_복심곡선설치법.png>) + + +#### 9) 배향곡선의 설치 +- ① 배향곡선(背向曲線 : Hair Pin Curve)은 부임도나 작업도를 연결하기에 편리한 곳이지만, 토사유출과 임지내 생산면적의 감소 등으로 인하여 불합리하기 때문에 사면기울기가 40%이하이고 지반이 안정된 곳에 설치하고 동일사면에 1개 이상은 설치하지 않아야 한다. +- ② 곡선내부의 외쪽물매는 5%, 노폭은 6m 정도가 좋으며, 부득이 동일사면에 (그림 5-2-54)와 같이 1개 이상을 설치할 경우에는 다음식에 의하여 적정간격을 구한다. ※ 배향곡선의 적정간격(d) = 0.5·RS·gs / gr 여기서, RS : 적정임도간격(m)gs : 산지사면 기울기(%)gr : 종단물매(%) + +![노선측량_그림5-2-54_배향곡선의간격](<../pic/노선측량_그림5-2-54_배향곡선의간격.png>) + + +- ③ 배향곡선 역시 (그림 5-2-55)와 같이 단곡선의 연속이므로 각각의 접선과 반지름을 결정하여 단곡선의 설정법과 같이 곡선을 설치한다. ㉮ (그림 5-2-55)에서 배향곡선의 반경을 r로 하고, 지형조건에 맞게 점O에서 약 2r~2.5r 떨어진 곳(ℓm)에 점 P, S를 절선상에 설정한다. ㉯ 점 P에서 배향곡선을 둘러싸는 새로운 절선다각형 P, Q, R, S를 설치하면 + +![노선측량_그림5-2-55_배향곡선설치법](<../pic/노선측량_그림5-2-55_배향곡선설치법.png>) + +$$\beta=\cos^{-1}\left(\frac{r}{\ell}\right)$$ + +$$\gamma=\cos^{-1}\left(\frac{r}{\ell}\right)$$ + +$$d=\sqrt{\ell^2-r^2}+r\cot\left(\frac{\alpha+\beta}{2}\right)$$ + +$\ell=2r$로 하면 다음과 같다. + +$$\gamma=30^\circ,\qquad d=r\left\{\sqrt{3}+\cot\left(\frac{\alpha+\beta}{2}\right)\right\}$$ + +㉢ 배향곡선을 현지에 설치할 때에는 먼저 점 P와 S에서 $\gamma$°만큼 곡선 외측으로 비스듬하게 통과하는 선상에서 $d$m 떨어진 점을 구한다. + +㉣ 다음에 이 점들을 교점으로 한 반경 $r$의 원을 절선다각형에 내접시키는 동시에 교점 P와 S에도 약 $1.5r$의 원을 외접시키면 배향곡선이 된다. + +(예제) 광릉시험림 내 임도시설 예정지에서 동일 사면에 배향곡선 2개를 설치해야 한다. 임도간격 300m, 산지사면기울기 40%, 종단물매 9%일 때 적정간격은 $d=0.5\times300\times40/9=666\fallingdotseq700$m이다. + +### 바. 종단곡선의 설치 + +종단물매가 m, n인 두 기울기선이 교차하는 점에서는 물매가 급하게 변화되어 자동차의 안전 운행에 지장을 주게 되므로 그 수직면내에 적당한 곡선을 삽입하여 물매의 변화를 완만하게 하여야 한다. 이때 사용되는 곡선을 종단곡선(縱斷曲線 : Vertical Curve)이라 하며, 보통 포물선(抛物線 : Parabola)이나 반지름이 큰 원곡선(圓曲線)이 사용된다. + +#### 1) 물매의 변화량 산출 + +두 기울기선의 종단물매 대수차는 $|m-n|/100$이다. +#### 2) 종단곡선의 길이 산출 +##### 가) 종단면도에 의한 산출 +- ① 교점이 20m 종선에 있으면, 양 물매차를 5/100로 나눈 값에 가장 가까운 짝수에 20을 곱한다. 3/100과 17/100의 경우 $2.8$에 가장 가까운 짝수 2를 적용하여 $L=2\times20=40$m이다. +- ② 교점이 10m 종선에 있으면, 같은 값에 가장 가까운 홀수에 20을 곱한다. 위 조건이면 홀수 3을 적용하여 $L=3\times20=60$m이다. +- ③ 교점이 20m 종선 사이에 있으면 다음 순서로 정한다. + - 종곡점 위치: 교점에 $\{\text{양 물매차}/(5/100)\}\times10$을 더한 값에 가장 가까운 20m 배수 + - 종단곡선 길이: 종곡점과 교점 거리의 2배에 가장 가까운 20m 배수 + - 시곡점 위치: 종곡점 위치에서 종단곡선 길이를 뺀 값 + +(예제) 교점이 927m이고 물매가 3/100과 17/100이면 보정거리는 28m이다. 종곡점은 $(927+28)$에 가장 가까운 20m 배수인 960m, 곡선길이는 $(960-927)\times2$에 가장 가까운 20m 배수인 60m, 시곡점은 $960-60=900$m이다. +##### 나) 종단곡선의 길이를 주행속도로서 산출 + +$$L=|i_1-i_2|\frac{V^2}{360}$$ + +계산값에 가장 가까운 10m 배수를 취한다. 여기서 $V$는 주행속도(km/hr), $|i_1-i_2|$는 종단물매 대수차의 절대값(%), 360은 불쾌감을 주지 않는 충격변화율을 결정하는 계수이다. + +표 5-2-28. 설계속도별 최소종단곡선의 길이 + +| 설 계 속 도(km/hr) | 20 | 30 | 40 | 50 | +|---|---|---|---|---| +| 곡 선 길 이(m) | 20 | 25 | 35 | 40 | + +(예제) 안부의 물매가 +10%와 -10%, 설계속도가 30km/hr이고 산복구간의 물매가 5%와 9%, 설계속도가 40km/hr일 때: + +- ① 안부구간: $L=|10-(-10)|\times30^2/360=50$m +- ② 산복구간: $L=|5-9|\times40^2/360=17.8\fallingdotseq20$m + +※ 안부: 말안장을 의미한다. +##### 다) 가시거리로서 종단곡선의 길이 산출 +- ① 가시거리 $S>L$인 경우: $L=2S-\dfrac{2(\sqrt{h_1}+\sqrt{h_2})^2}{G}$ (그림 5-2-56 ⓐ) +- ② 가시거리 $S) + + +#### 3) 종단곡선의 반지름 산출 + +$$R\fallingdotseq\frac{100L}{|i_1-i_2|}$$ + +(예제) 앞 예제에서 $R=(100\times40)/|17-3|=285.7\fallingdotseq280$m이다. + +![노선측량_그림5-2-57_종단곡선길이별가시거리](<../pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png>) + +그림 5-2-57. 종단곡선길이(L)와 가시거리(S)의 관계(h1=1.4m, h2=0.5m인 경우) + +#### 4) 종거의 산출 + +$$Y_n=\frac{1}{2L}\cdot\frac{d}{100}\chi^2=\frac{|i_1-i_2|}{200L}\chi^2$$ + +- $Y_n$: 횡거 $\chi$에 대한 종거(m) +- $L$: 종단곡선의 길이(m) +- $d$: 인접한 두 기울기선의 물매차 절대값(%)인 $|i_1-i_2|$ +- $\chi$: 종단곡선의 시점 또는 종점에서 종거를 구하는 점까지의 횡거(m) + +![노선측량_그림5-2-58_종단곡선측점종횡거](<../pic/노선측량_그림5-2-58_종단곡선측점종횡거.png>) + +그림 5-2-58. 종단곡선상 각 측점의 종횡거 산출 + +(예제) 앞의 예제에서 종단곡선의 시곡점과 종곡점 사이에 위치한 각 측점의 종거를 구하여라. 두 기울기선의 물매차는 14%, 종단곡선의 길이는 60m이다. + +- ① 시곡점 : 810-(60/2) = 780m +- ② 종곡점 : 810+(60/2) = 840m +- ③ 그림 5-2-58을 참고하면 800m와 820m에 위치한 측점은 종단곡선의 시·종곡점으로부터의 횡거가 각각 20m이므로 종거가 동일하다. $Y_1=Y_3=(14\times20^2)/(200\times60)=0.47\,\mathrm{m}$ +- ④ 810m에 위치한 측점은 종단곡선 시곡점으로부터의 횡거가 30m이므로 $Y_2=(14\times30^2)/(200\times60)=1.05\,\mathrm{m}$ +#### 5) 종단곡선상의 표고 산출 + +$$H_n'=H_n\pm\frac{i}{100}\chi,\qquad H_n=H_n'\pm Y_n$$ + +- $H_n'$: B.C 또는 E.C에서 $\chi$만큼 떨어진 점의 표고(m) +- $H_n$: B.C 또는 E.C의 표고 또는 종단곡선상의 표고(m) +- $Y_n$: B.C 또는 E.C에서 $\chi$만큼 떨어진 점과 곡선과의 종거(m) + +(예제) 앞의 예제에서 각 점의 표고를 구하여라. 단, B.C의 표고는 100.57m이다. + +- ① 780m 지점: $H_0=100.57\,\mathrm{m}$ +- ② 800m 지점: $H_1'=100.57+(3/100)\times20=101.17\,\mathrm{m}$, $Y_1=0.47\,\mathrm{m}$, $H_1=101.64\,\mathrm{m}$ +- ③ 810m 지점(V): $H_2'=100.57+(3/100)\times30=101.47\,\mathrm{m}$, $Y_2=1.05\,\mathrm{m}$, $H_2=102.52\,\mathrm{m}$ +- ④ 820m 지점: $H_3'=101.47+(17/100)\times10=103.17\,\mathrm{m}$, $Y_3=0.47\,\mathrm{m}$, $H_3=103.64\,\mathrm{m}$ +- ⑤ 840m 지점(V+30m): $H_4'=101.47+(17/100)\times30=106.57\,\mathrm{m}$, $H_4=106.57\,\mathrm{m}$ +### 사. 구조물 및 지질조사 + +구조물(교량, 배수구, 석축 등)을 설치할 필요가 있는 개소의 위치, 지형, 자재 채취의 양부 등을 상세하게 조사하거나 측량을 실시하여 필요한 자료를 구한다. + +#### 1) 구조물 조사 +##### 가) 교량류 : 교량, 암거(BOX) 등 +- ① 중심선과 유수의 각도 및 방향 +- ② 유수량과 H. W. L, L. W. L +- ③ 교대 교각예정 위치 부근의 토질 +- ④ 유수의 유무 +- ⑤ 하천의 관리상태 +- ⑥ 교량의 형식 +- ⑦ 기타 필요사항 +##### 나) 구거류 : 배수관, 개거 등 +- ① 설치해야 할 위치 및 중심선과의 방향각도 +- ② 집수면적, 유수량 +- ③ 지반토질 +- ④ 기타 필요사항 +##### 다) 옹벽류 : 옹벽, 석축(찰쌓기,메쌓기) 등 +- ① 설치위치, 연장, 높이 +- ② 지반상황, 토압관계 +- ③ 유수, 수심 +- ④ 기타 필요사항 +#### 2) 공사재료조사 +- ① 공사재료로 채취할 경우에는 채취위치, 품질형상, 양, 운반거리 및 운반로상황, 소유자, 채취료 등 +- ② 또한 필요가 있을 때에는 공사재료로서의 적합성을 평가하기 위한 품질시험을 한다. +#### 3) 토취장, 토사장조사 +- ① 공사실행에 있어서 토취장, 토사장이 필요할 경우에는 위치, 운반거리, 운반로상황, 채취(또는 버릴)가능량, 소유자 등 +- ② 이 경우 토사장의 토사유출 방지시설을 필요로 할 때는 시설에 관한 충분한 조사를 한다. +#### 4) 장해물 조사 + +공사에 지장이 되는 가옥, 전주 등 구조물과 전답, 묘지, 구거, 용수로 등에 관해서는 명칭, 위치, 수량, 소유자 등에 대해서도 조사한다. + +### 아. 용지측량 +#### 1) 용지측량방법 +- ① 임도용지는 측점마다 횡단면의 중심선에서 경사면의 길이, 즉 절토사면의 머리위에서 성토사면의 끝아래까지 거리를 측정하고 측점마다 이 보다 약간의 여유(보통 1m 정도)를 더하여 측정한다. +- ② 현지에서 그 점에 말뚝을 박아 용지경계를 명시하고 지적도, 토지대장 등을 참조하여 임도부지에 대해 소유자별 지번별, 지목별 면적을 측정한다. +- ③ 사용기종 : 트란싯트 또는 평판 +#### 2) 용지측량시 조사사항 +- ① 해당용지의 소유자명, 지번, 지목 +- ② 해당용지의 지상권, 지역권(地域權), 저당권, 임대차에 따른 권리, 기타 제권리 등의 유무 +- ③ 해당용지의 경계에 관한 기록 및 도면 유무와 그 내용 +- ④ 해당용지 내의 도로, 하천, 용수로, 관거류 등의 부지형상, 노폭 등 +- ⑤ 용지도 작성의 기본이 되는 삼각점 기준점의 종류, 내용 +- ⑥ 행정구역계 및 인접지의 상태 +- ⑦ 지형지물 기타 필요한 사항 +- ⑧ 편입지의 범위 : 구조물 주위 1m 내외 + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 48~86쪽 (인쇄면 418~456쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md new file mode 100644 index 00000000..24837bba --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md @@ -0,0 +1,665 @@ +# 5. 설계 + +### 가. 설계서 + +광의의 설계서는 종합보고서, 설계서, 예산내역서, 단가산출서, 수량산출서 및 설계도면을 포함한다. + +#### 1) 종합보고서 +- ① 과업에 대한 개요 : 과업의 목적, 과업의 범위와 내용, 기타 지시사항 등 +- ② 기본계획에 대한 개요 +- ③ 환경에 미치는 영향과 대책 : 자연환경, 생활환경, 사회경제적 환경 +- ④ 기본조사와 측량내역 : 지역현황, 측량, 수문, 토질, 재료원, 구조물, 용지 및 지장물 +- ⑤ 실시설계 : 설계기준, 설계선형, 토공, 배수공, 포장, 부대시설 등 +- ⑥ 공사개요 : 주요자재, 주요공사수량, 공사비 산출 +- ⑦ 부록 : 측량기준점 조서, 수리계산서, 토질보고서 등 부속자료 +#### 2) 설계서 +- ① 설계설명서 : 공사목적, 공사내용(공사명, 위치, 연장), 공사개요, 소요자재, 골재원, 사용중기, 공사기간, 설계변경조건, 보안대책, 설계적용기준 등 +- ② 특별시방서 +- ③ 일반시방서 +- ④ 예정공정표 +- ⑤ 동원인원계획표 +### 나. 설계도면 +#### 1) 평면도 +##### 가) 축척과 위치 +- ① 축척 : 1/1,000 또는 1/1,200 +- ② 작성위치 : 종단면도의 상단여백에 도표 +##### 나) 중심선 제도법 +- ○ 합위거 및 합경거에 의한 트래버어스의 제도법 트래버어스 측량에서 임의의 한 측점을 원점(0, 0)으로 하고, 이 점을 지나는 자오선(NS선)과 + +동서선(EW선)을 직각좌표축(直角座標軸)으로 하여 이 좌표축에 대한 다른 측점의 좌표를 그측점의 위거 및 경거로 표시한 것을 합위거(合緯距 : Total Latitude) 및 합경거(合經距 : TotalDeparture)라 한다. 합위거 및 합경거에 의한 트래버어스의 제도법은 다음과 같은 장점이 있다. + +- ① 제도하기 전에 측량의 오차를 찾아서 이를 배분할 수 있다. +- ② 각 점의 위치가 합위거 및 합경거에 의하여 결정되어 있으므로 도면의 크기와 배치를 쉽게 할 수 있다. +- ③ 제도상의 오차가 누적하지 않고 정확하게 측점위치가 정해진다. + +합위거 및 합경거에 의한 트래버어스제도의 방법은 다음과 같다. + +- ㉮ 다음 식에 의해 각 측점의 합위거 및 합경거를 구한다. + - 각 측점의 합위거(합경거) = 원점부터 그 측점까지의 위거(경거)의 합 +- ㉯ 각 측점의 위거와 경거 중에서 최대 (+), (-)값을 찾아서 이를 기준으로 적당한 지점에 원점의 위치를 정한다. +- ④ 원점을 교점으로 하는 직각좌표축을 그린 후, 각 측점의 합위거 및 합경거를 도면에 전개시킨다. + +(예제) 트래버어스측량 결과를 표 5-2-29와 같이 얻었다. 이에 대한 폐합오차를 조정하고 그 결과를 이용하여 측점 A를 원점으로 하여 각 측점의 합위거와 합경거에 의해 제도하여라. + +표 5-2-29. 트래버어스측량 결과 및 조정 + +| 측선 | 거리 (m) | 위거 (+) | 위거 (-) | 위거 조정량 (m) | 경거 (+) | 경거 (-) | 경거 조정량 (m) | 조정위거 (+) | 조정위거 (-) | 조정경거 (+) | 조정경거 (-) | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| AB | 10.60 | 6.54 | | +0.03 | 8.38 | | -0.05 | 6.57 | | 8.33 | | +| BC | 4.10 | | 3.56 | -0.02 | 2.03 | | -0.01 | | 3.54 | 2.02 | | +| CD | 7.69 | | 6.54 | -0.03 | | 4.05 | +0.02 | | 6.51 | | 4.07 | +| DA | 7.13 | 3.46 | | +0.02 | | 6.24 | +0.04 | 3.48 | | | 6.28 | +| 합계 | 29.52 | 10.00 | 10.10 | +0.10 | 10.41 | 10.29 | -0.12 | 10.05 | 10.05 | 10.35 | 10.35 | +| 폐합차 | | | -0.10 | | +0.12 | | | | | | | + +#### 풀이 + +폐합오차와 폐합비는 다음과 같다. + +$$ +\text{폐합오차}=\sqrt{0.10^2+0.12^2}=0.16 +$$ + +$$ +\text{폐합비}=\frac{0.16}{29.52}=\frac{1}{185} +$$ + +- 위거 절대값의 총계: $6.54+3.56+6.54+3.46=20.10$ +- 경거 절대값의 총계: $8.38+2.03+4.05+6.24=20.70$ + +| 측선 | 위거의 조정량 | 경거의 조정량 | +|---|---:|---:| +| AB | $0.10\times6.54/20.10=0.03$ | $0.12\times8.38/20.70=0.05$ | +| BC | $0.10\times3.56/20.10=0.02$ | $0.12\times2.03/20.70=0.01$ | +| CD | $0.10\times6.54/20.10=0.03$ | $0.12\times4.05/20.70=0.02$ | +| DA | $0.10\times3.46/20.10=0.02$ | $0.12\times6.24/20.70=0.04$ | +| 합계 | 0.10 | 0.12 | + +※ 트렌싯법칙에 의한 트래버어스 폐합오차의 조정 + +트렌싯법칙(transit rule)은 각도는 트렌싯, 거리는 테이프를 써서 측정하는 경우로서 거리측정의 정도(精度)가 각 측정의 정도에 비하여 불완전할 때에 사용되는 방법으로서 폐합오차를 위거와 경거의 길이에 비례해서 배분하는 것이다. + +$$ +\text{임의 측선의 위거 조정량} +=\varepsilon_{\ell}\times +\frac{\text{해당 측선의 위거(측선의 길이)}}{\text{위거 절대값의 총계(측선 길이의 총계)}} +$$ + +$$ +\text{임의 측선의 경거 조정량} +=\varepsilon_d\times +\frac{\text{해당 측선의 경거(측선의 길이)}}{\text{경거 절대값의 총계(측선 길이의 총계)}} +$$ + +※ 괄호 안은 컴퍼스법칙을 사용할 경우이다. + +표 5-2-30. 트래버어스 폐합오차 조정결과 + +![설계_그림5-2-59_합경거와합위거](<../pic/설계_그림5-2-59_합경거와합위거.png>) + +| 측선 | 조정위거 (+) | 조정위거 (-) | 조정경거 (+) | 조정경거 (-) | 합위거 | 합경거 | +|---|---:|---:|---:|---:|---:|---:| +| AB | 6.57 | | 8.33 | | +6.57 | +8.33 | +| BC | | 3.54 | 2.02 | | +3.03 | +10.35 | +| CD | | 6.51 | | 4.07 | -3.48 | +6.28 | +| DA | 3.48 | | | 6.28 | 0.00 | 0.00 | + +그림 5-2-59. 합경거, 합위거에 의한 트래버어스제도(예) + +- ○ 분도기에 의한 트래버어스의 제도법 + +트래버어스의 제도법 중 가장 간단한 방법이지만, 아무리 주의하여도 최후에 폐합오차가 생기 + +므로 정밀을 요하지 않을 경우나 좌표계산을 할 시간이 없을 경우를 제외하고는 사용하지 않는 것이 좋다. + +- ① 원점 A에서 분도기로 방향을 결정하여 임의의 선 AB′를 긋고, 적정한 축척으로 거리를 재어 점 B를 도시한다. +- ② 점 B에서 다시 분도기로 방향을 결정하여 임의의 선 BC′를 긋고, 적정한 축척으로 거리를 재어 점 C를 도시하는 방법으로, 점 D, E, … 를 결정하여 제도해 나간다. +- ○ 탄젠트의 값에 의한 트래버어스의 제도법 + +분도기를 사용하지 않고 탄젠트의 값에 의해 제도할 수 있다. 예를 들어 AB선과 자오선(NS선)의 편각이 28°30′이고, BC선과 AB선의 편각이 35°23′이라면 제도방법은 다음과 같다. +- ① 그림 5-2-60과 같이 기준선 NS선상의 임의의 점 A에서 $Aa'=10\,\mathrm{cm}$가 되도록 점 $a'$를 정하고 수직선 $aa'$를 긋는다. +- ② AB선과 NS선의 편각에 대한 탄젠트 값을 구하고 이를 이용하여 점 $a$를 결정한다. 즉, $\tan 28^\circ30'=0.543=5.43/10$이므로 $aa'$의 길이는 5.43cm이다. + +![설계_그림5-2-60_탄젠트값트래버어스제도](<../pic/설계_그림5-2-60_탄젠트값트래버어스제도.png>) + +그림 5-2-60. 탄젠트값에 의한 트래버어스제도(예) + +- ③ 점 A와 $a$를 연결하면 NS선에 대하여 $28^\circ30'$인 직선을 구할 수 있으므로, $Aa$ 선상이나 연장선상에 적당한 축척의 AB 길이를 취하여 점 B를 정한다. +- ④ AB선을 연장하여 $Bb=10\,\mathrm{cm}$가 되도록 점 $b$를 정하고 수직선 $bb'$를 긋는다. BC선과 NS선의 편각에 대한 탄젠트 값($\tan 35^\circ23'=0.710$)의 10배인 7.10cm를 취하여 점 $b'$를 정한다. 점 B와 $b'$를 연결하면 AB에 대하여 $35^\circ23'$인 직선이 되므로, $Bb'$ 선상이나 연장선상에 적당한 축척의 BC 길이를 취하여 점 C를 정한다. +- ⑤ 이와 같은 방법을 반복한다. + +※ 방위각에서 편각을 구할 때는 다음 식을 이용한다. + +- ㉮ 편각 = 측선의 방위각 - 전 측선의 방위각 +- ㉯ 시계방향(+)이고 절대값이 180° 이상인 경우: 조정편각 = 360° - 편각 +- ㉰ 시계반대방향(-)이고 절대값이 180° 이상인 경우: 조정편각 = 360° + 편각 + +(예제) 측선 AB의 방위각이 26°16′30″이고 측선 BC의 방위각이 236°27′00″일 때 편각과 조정편각을 구하여라. + +- 편각 = 26°16′30″ - 236°27′00″ = -210°10′30″ +- 180° < 210°10′30″이고 부호가 (-)이므로 조정편각 = 360° - 210°10′30″ = 149°49′30″ + +- ○ 사인 및 코사인의 값에 의한 트래버어스의 제도법 + +사인 또는 코사인의 값으로 트래버어스를 제도할 수 있다. 예를 들어 기준선 PQ에서 35°20′의 각을 이루는 직선 AB를 그을 때 제도방법은 다음과 같다. + +- ① 그림 5-2-61과 같이 임의의 선 PQ상에 점 A를 설정한 후, PQ와 AB선이 이루는 각(35°20′)의 사인 및 코사인 값을 구한다. $\sin 35^\circ20'=0.578$, $\cos 35^\circ20'=0.816$ +- ② 점 A에서 코싸인 값의 10배, 즉 8.16 cm 와 같게 점 a′를 PQ선상에서 취한다. +- ③ 점 a'에서 PQ선에 수직선을 그어 그 길이를 싸인의 10배, 즉 5.78cm와 같게 점 a를 정한다. +- ④ 점 A와 $a$를 연결하면 $\angle aAa'=35^\circ20'$이므로, 그 연장선상에서 적당한 축척의 AB 길이를 취하여 점 B를 정한다. +- ⑤ 이와 같은 방법을 반복한다. + +![설계_그림5-2-61_삼각함수값트래버어스제도](<../pic/설계_그림5-2-61_삼각함수값트래버어스제도.png>) + +그림 5-2-61. sin, cos 값에 의한 트래버어스제도(예) + +##### 다) 측점과 곡선제도법 +- ① 트레버어스제도법으로 중심선의 골격이 제도되고 나면 (표 5-2-31)과 같이 각 교점(IP)마다 곡선제원을 계산하고 시점(BP)에서부터 측점말뚝은 일련번호(예 No.11)로 하고 보조 말뚝은 그 측점에서 떨어진 거리(예 No.11+13)로 표기하는 보조측점을 부여한다. + +표 5-2-31. 교점제원과 선형계산(예) + +| 교점 | 항목 | 값 | +|---|---|---:| +| BP | X좌표 | 470740.500 | +| BP | Y좌표 | 142679.000 | +| BP | 방위각 | 188°39′19.41″ | +| IP1(좌향) | 곡선종류 | 원곡선 | +| IP1(좌향) | X좌표 | 470697.8000 | +| IP1(좌향) | Y좌표 | 142672.5000 | +| IP1(좌향) | 제1 방위각 | 188°39′19.41″ | +| IP1(좌향) | 제2 방위각 | 111°55′46.52″ | +| IP1(좌향) | 원중심 X좌표 | 470720.5531 | +| IP1(좌향) | 원중심 Y좌표 | 142712.3783 | +| IP1(좌향) | IA | 76°43′32.90″ | +| IP1(좌향) | R | 36.0000 | +| IP1(좌향) | 거리 | 43.1919 | +| IP1(좌향) | TL | 28.4953 | +| IP1(좌향) | SL | 9.9128 | +| IP1(좌향) | CL | 48.2083 | + +측점별 위치 및 제원 + +| 구분 | 누가거리 | 측점(No.) | X좌표 | Y좌표 | 방위각 | +|---|---:|---|---:|---:|---:| +| BP | 0.0000 | 0+00.0000 | 470740.50 | 142679.00 | | +| BC | 14.6966 | 0+14.6966 | 470725.97 | 142676.79 | 188°39′19.41″ | +| | 20.0000 | 1+00.0000 | 470720.69 | 142676.38 | 180°12′53.06″ | +| | 40.0000 | 2+00.0000 | 470701.68 | 142681.72 | 148°23′01.50″ | +| | 60.0000 | 3+00.0000 | 470688.35 | 142696.29 | 116°33′09.94″ | +| EC | 62.9049 | 3+02.9049 | 470687.16 | 142698.93 | 111°55′46.52″ | + +- ② 교점에서 절선장(TL)을 끊어 시곡점{(BC), IP간의 거리(43.1919m) - TL(28.4953m) =BC(14.6966m)} 종곡점{(EC), BC(14.6966m) + CL(48.2083m) = EC(62.9049m)}의 측점을 부여하고 교점 내각의 2등분선 방향으로 외할장(E, SL)만큼 중곡점(MC)을 부여한다. +- ③ 시곡점과 종곡점에서 각 절선에 대한 수직선을 내려 만나는 점이 곡선의 중심(0)이 되며 그 길이를 곡선 반지름으로 하여 단곡선을 그리면 중심선의 곡선부가 된다. +- ④ 이를 계속하여 다음 교점까지 가서 ②와 ③의 작업을 반복하면 직선과 곡선이 어울려 조화를 이루는 하나의 평면 선형이 완성된다. +##### 라) 노폭, 용지폭, 현황 등 제도법 +- ① 평면선형이 완성되면 각 측점마다 노폭, 측구폭, 절토사면폭, 성토사면폭, 사면구조물폭 등의 시공폭 및 용지폭을 횡단면도를 참고하여 도시하고 연결하되 노폭을 진하게 하고 그 외의 선은 조금 가늘게 도시한다. +- ② 종단면도에서 계산된 지반고를 각 측점마다 부여한 후 평판과 트랜싯의 스타디아 측량에 의하여 현황측량을 실시하여 지형의 상태와 교량, 배수구, 옹벽, 석축, 경작지의 경계선, 가옥, 기설도로 등의 지물에 대한 위치와 크기, 등고선을 측정 도시한다. +- ③ 노선이 연속되어 도면이 나누어 질 경우에는 절취선이 겹쳐지게 하여 선형의 방향이 흐트러지지 않도록 하고, 시설 계획된 구조물을 시설위치마다 명칭, 치수 및 수량을 기재한다. +- ④ 각 교점에 대한 곡선제원표를 도면의 상단 또는 하단의 빈 공간에 기록한다. + +![설계_그림5-2-62_평면도작성예](<../pic/설계_그림5-2-62_평면도작성예.png>) + +그림 5-2-62. 평면도 작성(예) + +#### 2) 종단면도 +##### 가) 축척 : 수평 1/1,000 또는 1/1,200(평면도의 축적과 같게 한다), 수직 1/200 +##### 나) 제도방법 +- ① 1m/m 눈금의 방안지에 아래서부터 평면선형, 측점, 거리, 누가거리, 지반고, 계획고, 절토고, 성토고, 종단선형의 란으로 구분하고, 횡(수평)축을 거리(m)로 표시하고 종(수직)축은 높이(표고 : m)로 표시한다. +- ② 평면선형란에는 평면도에서 작성된 곡선방향을 진행방향으로 오른쪽으로 회전하면 위쪽이 볼록(凸)하게 왼쪽으로 회전하면 아래쪽이 볼록하게 도시하고, 앞과 뒤의 곡부는 BC,EC의 측점에 맞게 도시하여 교점번호(IP)를 기재하며 그외 곡선제원을 곡선형의 여백에 기재한다. +- ③ 측점은 평면도에서 작성된 측점번호와 같아야 하며, 거리는 측점간의 거리이고, 누가거리는 시점에서부터 누진되는 거리를 기록한다. +- ④ 지반고는 종단측량에서 측정계산된 현지의 지반고를 기재하고, 각 측점별로 종축의 높이에 맞게 막대그래프를 그려 중심선의 지반 선형을 나타낼 수 있도록 연결한다. +- ⑤ 계획고는 측점별 임도노면의 시공 높이가되며 이를 연결한 선형이 종단계획선이다. + +![설계_그림5-2-63_종단면도작성예](<../pic/설계_그림5-2-63_종단면도작성예.png>) + +그림 5-2-63. 종단면도 작성(예) + +계획선은 임도의 구조와 공사비에 직접적인 영향을 미치게 되므로 성토량과 절토량의 균형이 맞고 계획구배의 선형이 임도 내부에서 유로(流路) 방향이 된다는 것을 명심하여 알맞게 설정하여야 한다. 영선측량법에서는 측량자가 종단물매, 지형조건, 공사의 난이성, 경제성 등을 참고하여 종단계획선 위주로 측량하기 때문에 설계 시에 문제를 발생시키는 일이 거의 없다. 그러나 중심선측량법에서는 평면측량→평면도, 종단측량→종단면도, 횡단측량→횡단면도의 과정을 모두 마친 후 입체적으로 검토하여야 하므로 현지 지형조건과 각 선형조건에 부합되게 설정하기가 어렵다. + +평면선형과 종단선형으로 예를 들면 그림 5-2-64(a)와 같은 선형은 양호하나 (b)와 같은 선형은 불량한 선형계획이 될 것이다. 따라서 종단계획선의 설정 시에는 물매, 지형조건, 공사의 난이도, 경제성에 알맞도록 신중을 기하여야 한다. + +![설계_그림5-2-64_평면선형과종단선형](<../pic/설계_그림5-2-64_평면선형과종단선형.png>) + +그림 5-2-64. 평면선형과 종단선형의 결정 + +- ⑥ 성토고와 절토고란은 지반고>계획고일때 절토고에 그 차를 기재하고, 지반고<계획고이면성토고에 그 차를 기재한다. +- ⑦ 종단선형란은 계획선이 같은 물매로 되는 No. a와 No. b 구간에 대한 물매(%)를 말하며, 측점표고가 a>b일 경우에는 하향선(\), a 0 이므로 절토고 +#### 3) 횡단면도 +##### 가) 축척 : 1/100 +##### 나) 제도방법 +- ① 1mm 눈금크기의 방안지에 임도시점부터 진행방향으로 좌측 아래부터 적정한 간격을 유지하며 각 측점별로 횡단측량한 결과를 중심점을 기준으로 좌우의 지반선을 도시한다. +- ② 각 측점마다 종단면도에서 결정된 계획고에 따른 성토고, 절토고만큼 성토할 경우에는 중심점의 상단에, 절토를 할 경우에는 중심점의 하단에, 노면과 측구의 시공기면을 계획된 구조와 치수에 따라 도시한다. +- ③ 길끝(路端) 양편에서 토질과 공종에 따라 절취, 성토, 석축, 옹벽, 등의 사면 기울기에 따라 지반선과 만날때까지 선을 긋고 구조형태에 따라 축조모형을 도시한다. 만약 동일측점에서 보통토사, 견질토사, 보통암, 풍화암, 경암 등 토질이 다를 경우에는 토질구분선을 도시한다. +- ④ 도면작성이 완성되면 토질별 절취·성토단면적은 삼각형법 또는 구적기를 이용하여 단면적을 산출하고 석축, 옹벽, 떼 등은 공종별 길이를 측정한다. +- ⑤ 각 측점별로 횡단면도의 하단부에 측점(No), 지반고{Ground Height(GH)}, 계획고{Formation Height(FH)}, 절취고{Cutting Height(CH)}, 성토고{Banking Height(BH)}를 기재하고, 절토단면적, 절토사면장은 토성별로 기재하며 성토단면적, 성토사면장, 옹벽, 석축, 줄떼, 평떼, 종자뿌리기 등은 공종별로 수량을 기재한다. + +| STA NO. 3 | | | | | | | | | +|---|---|---|---|---|---|---|---|---| +| 지 반 고 | | 719.46 | 측구 터파기 | 토 사 | 0.5 | 노상 준비 | 기존도로 | | +| 계 획 고 | | 720.38 | | 리핑암 | | | 절토면 | | +| 성 토 고 | | 0.92 | | 발파암 | | 되메우기 | | | +| 절 토 고 | | | 측구흙쌓기 | | | 줄 떼 | | 4.9 | +| 흙 깍 기 | 토 사 | 4.2 | 답 표토 제거 | 절토부 | | 평 떼 | | 5.1 | +| | 리 핑 암 | | | 노 상 | | 벌개제근 | | 16.6 | +| | 리퍼 병행암 | | | 노 체 | | | | | +| | 편절 형암 | | 답외 표토 제거 | 절토부 | | | | | +| 흙 쌓 기 | 노 상 | 3.9 | | 노 상 | | 층 따 기 | 노 상 | 0.9 | +| | 노 체 | 3.1 | | 노 체 | | | 노 체 | | + +| STA NO. 11 | | | | | | | | | +|---|---|---|---|---|---|---|---|---| +| 지 반 고 | | 726.26 | 측 구 터 파 기 | 토 사 | 1.6 | 노 상 준 비 | 기존 도로 | | +| 계 획 고 | | 726.70 | | 리 핑 암 | | | 절 토 면 | 0.5 | +| 성 토 고 | | 0.44 | | 발 파 암 | | 되 메 우 기 | | | +| 절 토 고 | | | 측 구 흙 쌓 기 | | | 줄 떼 | | 3.8 | +| 흙 깍 기 | 토 사 | 0.9 | 답 표 토 제 거 | 절 토 부 | | 평 떼 | | | +| | 리 핑 암 | | | 노 상 | | 벌 개 제 근 | | 12.1 | +| | 리퍼 병행암 | | | 노 체 | | | | | +| | 편 절 형 암 | | 답 외 표 토 제 거 | 절 토 부 | | | | | +| 흙 쌓 기 | 노 상 | 3.0 | | 노 상 | | 층 따 기 | 노 상 | | +| | 노 체 | 0.9 | | 노 체 | | | 노 체 | | + +그림 5-2-65. 횡단면도 작성(예) + +#### 4) 구조물도 +- ① 임도의 시공기면은 노폭, 절취 및 성토상태에 따라 교량, 배수구, 석축, 옹벽 등의 보호시설이 필요할 경우가 있다. 이들 구조물은 정확하고 견고한 시공을 도모하기 위하여 공종별로 각각 그 내용을 명시한 정면도, 평면도, 측면도 등의 단면도를 임의의 축적으로 작성한다. +- ② 국부적으로 필요한 경우에는 그 부분을 확대한 상세도를 그리고 공종별로 재료별 수량과 규격을 도시한다. +- ③ 구조가 간단한 경우에는 규정도나 종·횡단면도에 명시한 것으로서 구조물도를 대체할 수 있으나 주요구조물에 대하여는 다음 사항을 도시 또는 기재한다. ㉠ 설계조건 ㉡ 소요재료표 및 수량조서 ㉢ 중심선 및 측점과의 관계 ㉣ 시공기면과의 관계 ㉤ L. W. L 및 H. W. L의 관계 - 수위와 관계가 있을 경우 ㉥ 각 부분의 형상, 방법, 물매 등 ㉦ 기타 필요한 사항 + +![설계_그림5-2-66_구조물도](<../pic/설계_그림5-2-66_구조물도.png>) + +그림 5-2-66. 구조물도 작성(예) + +#### 5) 표준도 +- ① 공사시공 기면에 대한 치수, 재료 및 형상 등을 명시한 것을 공사규정이라고 하고, 이를(그림 5-2-67)과 같이 일정하고 공통적인 사항에 대하여 도면으로 그 기준을 표시한 것을 표준도라고 한다. +- ② 다음 사항에 관하여 일정한 규정을 정한 경우에는 표준도를 작성한다. ㉠ 절취, 성토, 측구 등 시공기면의 조성폭과 기울기 ㉡ 암벽의 최소 덮은 두께 및 기초구조 등 ㉢ 석축공의 기울기, 공장도, 뒷채움 두께, 콘크리트 두께 등 ㉣ 콘크리트 옹벽공의 상부 두께 ㉤ 돌망태의 사면기울기, 뒷채움 두께 ㉥ 기타 필요한 사항 + +![설계_그림5-2-67_표준도작성예](<../pic/설계_그림5-2-67_표준도작성예.png>) + +그림 5-2-67. 표준도 작성(예) + +#### 6) 용지도 및 용지조서 +- ① 계획노선의 중심선, 시공부지의 경계선(일반적으로 절토사면 머리부터 성토사면 끝까지)을 지적도와 임야도를 기준으로 실시한 용지측량의 결과를 1/600 또는 법정도면의 축척으로 그림 5-2-68과 같이 용지도를 작성한다. + +![설계_그림5-2-68_용지도작성예](<../pic/설계_그림5-2-68_용지도작성예.png>) + +그림 5-2-68. 용지도 작성(예) +- ② 임야대장, 토지대장을 기준으로 택지, 농경지, 임야 등을 행정구역별, 소유별, 지목별, 소유자별로 면적을 산출하고 표 5-2-32와 같은 용지조서를 작성한다. + +표 5-2-32. 용지조서(예) (단위: m²) + +| 일 련 번 호 | 소 재 지 | | | | 공 부 상 | | 실 편 입 | | 소 유 자 | | | +|---|---|---|---|---|---|---|---|---|---|---|---| +| | | | | | | | | | | | 비고 | +| | 군 | 면 | 리 | 지 번 | 지 목 | 지 적 | 지 목 | 지 적 | 주 소 | 성 명 | | +| 1 | 홍천 | 내 | 자운 | 산50-1 | 임 | 4,064,436 | 임 | 61,299 | 국(산림청) | | | +| 2 | 〃 | 〃 | 〃 | 1177 | 임 | 1,491 | 임 | 974 | 홍천, 내, 자운, 126 | 박종식 | | +| 3 | 〃 | 〃 | 〃 | 1198-1 | 도 | 5,92 | 도 | 172 | 국 | | | +| 4 | 〃 | 〃 | 〃 | 1481 | 구 | 21,462 | 구 | 110 | 국(건설부) | | | + +#### 7) 위치도 +- ① 위치도에는 기설도로, 기설임도 등의 주요지형지물, 시설예정 임도의 위치, 이용구역, 토취 장, 토사장, 골재 및 자재채취장, 자재운반관계를 명시하여 1/25,000 또는 1/50,000 지형도에 작성한다. +- ② 계획노선은 기점과 종점, 시설예정길이, 공구가 구분될 경우에는 공구계, 계속공사일 경우에는 시설(예정)년도를 기재한다. +### 다. 공종과 수량산출 +#### 1) 공종구분 + +공사원가계산의 기본이 되는 공종은 공사의 종류에 따라 다르지만 일반적으로 공사발주기관에서 따로 정하지 아니하면 건설표준품셈표에 의하여 구분한다. + +#### 2) 공사수량의 산출 +##### 가) 체적과 면적의 산출 +- ① 공종별 공사수량은 구조가 간단한 것은 표준도나 종·횡단면도에 의하여 단면적(또는 높이)과 길이가 산출되지만 주요구조물은 구조물도의 재료표에 의하여 직접 그 수량을 적용한다. +- ② 종·횡단면도에 의하여 공사수량의 체적과 면적을 산출할 경우에는 일반적으로 평균단면적법과 평균거리법을 적용하고 있다. 전자는 당해측점의 공종수가 적을 경우에 편리하고 후자는 공종수가 많을 경우에 편리하나 그 값은 동일하게 산출된다. +- ③ 토취장 또는 토사장이 넓을 경우에는 점고법에 의하여 절취량 또는 성토가능량을 산출한다. +- ○ 평균단면적법 +- ① (표 5-2-33)의 토적계산표(예)에 종단면도에서 측점과 거리를 이기하고, 횡단면도에서 공 종별 단면적과 높이를 이기한다. +- ② 각 공종별로 앞 측점과 현 측점의 단면적 평균에 두 단면 간 거리를 곱하여 체적을 구한다. + + - No.1의 평균 절토단면적: $(0.92+4.16)/2=2.54$㎡ + - No.1의 절토체적: $2.54\times20.0=50.80$㎥ +- ③ 거리 및 각공종별로 체적(면적)을 합계하면 해당노선의 공사수량이 된다. 표 5-2-33. 토적계산표(평균단면적법)(예) (단위:m, m2, m +- 3) + +| 측점 | 거 리 | 절 토 | | | 성 토 | | | 평 떼 | | | 석 축 | | | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| | | 단면적 | 평균면적 | 체적 | 단면적 | 평균면적 | 체적 | 사면길이 | 평균길이 | 면적 | 높이 | 평균높이 | 면적 | +| BP | | 0.92 | | | | | | | | | | | | +| No.1 | 20.00 | 4.16 | 2.54 | 50.80 | | | | | | | | | | +| BC | 10.00 | 3.64 | 3.90 | 39.00 | 5.14 | 2.57 | 25.70 | 4.00 | 2.00 | 20.00 | | | | +| No.2 | 10.00 | 6.18 | 4.91 | 49.10 | 1.16 | 3.15 | 31.50 | 1.20 | 2.60 | 26.00 | | | | +| EC | 12.00 | 5.92 | 6.05 | 72.60 | 2.12 | 1.64 | 19.68 | 1.60 | 1.40 | 16.80 | | | | +| No.3 | 8.00 | | 2.96 | 23.68 | 7.64 | 4.88 | 39.04 | | 0.80 | 6.40 | 2.00 | 1.00 | 8.00 | +| No.4 | 20.00 | | | | 3.60 | 5.62 | 112.40 | | | | 1.40 | 1.70 | 34.00 | +| +10 | 10.00 | 3.62 | 1.81 | 18.10 | | 1.80 | 18.00 | | | | | 0.70 | 7.00 | +| No.5 | 10.00 | 2.26 | 2.94 | 29.40 | | | | | | | | | | +| 계 | 100.00 | | | 282.68 | | | 246.32 | | | 69.20 | | | 49.00 | + +- ○ 평균거리법 +- ① (표 5-2-34)의 토적계산표(예)에 종단면도에서 측점과 거리를, 횡단면도에서 공종별 단면적과 길이를 이기한다. +- ② 현재 측점거리와 다음 측점거리의 평균에 공종별 단면적(길이)을 곱하여 체적을 구한다. + + - BP의 평균거리: $(0.00+20.00)/2=10.00$m + - BP의 절토체적: $10.00\times0.92=9.20$㎥ +- ③ 거리 및 각 공종별로 체적(면적)을 합계하면 해당노선의 공사수량이 된다. 표 5-2-34. 토적계산표(평균거리법)(예) + +| 측 점 | 거리(m) | 평균 거리(m) | 절 토 | | 성 토 | | 평 떼 | | 석 축 | | +|---|---|---|---|---|---|---|---|---|---|---| +| | | | 단 면적(㎡) | 체적(㎥) | 단면적(㎡) | 체적(㎥) | 사면길이(m) | 면적(㎡) | 높이(m) | 면적(㎡) | +| BP | 0.00 | 10.00 | 0.92 | 9.20 | | | | | | | +| No.1 | 20.00 | 15.00 | 4.16 | 62.40 | | | | | | | +| BC | 10.00 | 10.00 | 3.64 | 36.40 | 5.14 | 51.40 | 4.00 | 40.00 | | | +| No.2 | 10.00 | 11.00 | 6.18 | 67.98 | 1.16 | 12.76 | 1.20 | 13.20 | | | +| EC | 12.00 | 10.00 | 5.92 | 59.20 | 2.12 | 21.20 | 1.60 | 16.00 | | | +| No.3 | 8.00 | 14.00 | | | 7.64 | 106.96 | | | 2.00 | 28.00 | +| No.4 | 20.00 | 15.00 | | | 3.60 | 54.00 | | | 1.40 | 21.00 | +| +10 | 10.00 | 10.00 | 3.62 | 36.20 | | | | | | | +| No.5 | 10.00 | 5.00 | 2.26 | 11.30 | | | | | | | +| 계 | 100.00 | | | 282.68 | | 246.32 | | 69.20 | | 49.00 | + +- ○ 점고법 +- ① 넓은 지역을 동일한 면적의 직(정)사각형 또는 직각삼각형(가급적 1변의 길이가 20m 이하)으로 구획하고 각 꼭지점의 높이(點高)를 측정한다. +- ② 각 구역을 사각(또는 직삼각)기둥으로 생각하여 각 구역의 면적과 평균높이를 구하는 방법으로 전체체적을 산출한다. +- ③ (그림 5-2-69)와 같이 각 꼭지점의 높이를 사용하는 회수에 따라 1번사용(H1), 2번사용(H2), 3번사용(H3), 4번사용(H4)으로 구분하여 다음식에 의하여 산출한다. + +![설계_그림5-2-69_사각주의점고](<../pic/설계_그림5-2-69_사각주의점고.png>) + +구역체적과 전체체적은 다음 식으로 산출한다. + +$$V_0=\frac{A(h_1+h_2+h_3+h_4)}{4}$$ + +$$V=\frac{A(\Sigma H_1+2\Sigma H_2+3\Sigma H_3+4\Sigma H_4)}{4}$$ + +- $A$: 한 구역의 수평 단면적(㎡) +- $\Sigma H_1$: 1회 사용된 지반고의 합 +- $\Sigma H_2$: 2회 사용된 지반고의 합 +- $\Sigma H_3$: 3회 사용된 지반고의 합 +- $\Sigma H_4$: 4회 사용된 지반고의 합 + +그림 5-2-69. 4각주의 점고 + +(예제) 다목적시범단지 내 어느 부지를 그림의 각 꼭지점에 나타낸 높이만큼 깎아 정지하고자 한다. 땅깎기할 토량과 시공면고를 구하여라. 단, 구역면적은 30㎡이다. + +$$ +V=\frac{30}{4}\{(0.2+0.4+0.4+0.2)+2(0.5+0.4+0.3+0.3+0.5+0.4+0.5+0.2+0.3+0.3)+3(0)+4(0.3+0.5+0.5+0.4+0.2+0.1)\} +$$ + +$$V=7.5\times(1.2+2\times3.7+0+4\times2.0)=124.5\,\mathrm{m^3}$$ + +$$H=\frac{\text{토적}}{\text{총면적}}=\frac{124.5}{12\times30}=0.346\,\mathrm{m}$$ + +※ 정지작업 시 시공면고(施工面高)와 성토고(盛土高) 및 절토고(切土高)의 관계 +- ① 시공면고보다 높은 곳은 그 차이만큼 흙깎기(切土)를 시행한다. +- ② 시공면고보다 낮은 곳은 그 차이만큼 흙쌓기(盛土)를 시행한다. +##### 나) 구조물도에 의한 수량산출(예) +- ① (그림 5-2-70)과 같이 Box 2련 2.0 × 1.5 × 6.03m 되는 구조물을 설치한다고 하자. +- ② 구체 콘크리트는 전체단면적{(0.3 + 2.0 + 0.3 + 2.0 + 0.3) × (0.25 + 1.5 + 0.25) = 9.80 ㎡}에서 유수구단면적{(2.0 × 1.5 × 2개) - (0.2 × 0.2 ÷ 2 × 8개) = 5.84㎡}을 뺀 값(3.96㎡)에 길이(6.03m)를 곱하면 23.88㎥가 된다. +- ③ 기초 콘크리트는 폭(5.1m) × 두께(0.10m) × 길이(6.03m)를 곱하면 3.08㎥가 된다. +- ④ 거푸집(합판 3회 사용)은 내벽, 외벽, 받침판으로 구분하여 좌우외벽(2.0m × 6.03m × 2장), 유수구 2개 단면{(1.5 - 0.2 × 2개) × 6.03m × 4장}, 받침판{(2.0 - 0.2 × 2개) × 6.03m × 2장} 및 유수구 귀면{$\sqrt{0.2^2+0.2^2}\times6.03m\times8장$}을 합하면 83.59㎡가 된다. + +![설계_그림5-2-70_Box단면도](<../pic/설계_그림5-2-70_Box단면도.png>) + +그림 5-2-70. Box(2.0×1.5×2련×6.03 m) 단면도 + +- ⑤ 동바리(강관)는 유수구 단면적(2.0 × 1.5m × 2개)에서 귀면(0.2 × 0.2 ÷ 2 × 8개)을 뺀 값(5.36㎡)에 길이(6.03m)를 곱하면 32.32㎡가 된다. +- ⑥ 철근은 구조물도의 철근상세도에서 각 철근의 길이와 개수를 산출하여 철근 규격별 m당 중량을 곱하면 총소요량이 된다. +- ⑦ 이와 같이 각 재료공종별(터파기, 구체콘크리트, 거푸집, 동바리, 되메우기 등) 재료별로 수량을 산출한다. +##### 다) 토량수급계획 +- ○ 해석법에 의한 방법 +- ① (표 5-2-33) 또는 (표 5-2-34)의 토적계산표에서 (표 5-2-35)와 같은 토량운반계획표의 측점, 거리, 총성토량 및 총절토량을 이기하고 성토량은 토량환산계수(다짐상태/자연상태)에 의한 보정토량을 산출 기재한다. 표 5-2-35. 토량운반 계획표 + +| 측점 | 거리 | 총절토량 | 총성토량 | | | 유용 토량 | 잔토량 | 부족 토량 | 도쟈운반성토 | | 덤프운반성토 | | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| | | | 자연 상태 | 토량환 산계수 | 보정 토량 | | | | 주는곳 | 토공량 | 주는곳 | 토공량 | +| BP | 0.00 | 9.20 | | | | | 9.20 | | | | | | +| No.1 | 20.00 | 62.40 | | | | 57.11 | 5.29 | | | | | | +| BC | 10.00 | 36.40 | 51.40 | 0.9 | 57.11 | | 36.40 | | | | | | +| No.2 | 10.00 | 67.98 | 12.76 | 0.9 | 14.17 | 14.17 | 53.81 | | No.2 | 41.31×20 = 826.2 | | | +| EC | 12.00 | 59.20 | 21.20 | 0.9 | 23.55 | 23.55 | 35.65 | | BC | 36.40×30 = 1,092.0 | | | +| No.3 | 8.00 | | 106.96 | 0.9 | 118.84 | | | 83.19 | No.1
BP | 5.29×40 = 211.6
0.19×60 = 11.4 | | | +| No.4 | 20.00 | | 54.00 | 0.9 | 60.00 | | | 23.80 | | | | | +| +10 | 10.00 | 36.20 | | | | 36.20 | | | No.5 | 11.30×20 = 226.0 | | | +| No.5 | 10.00 | 11.30 | | | | | 11.30 | | No.2 | 2.50×40 = 500.0 | | | +| 사토장 | 20.00 | | | | | | | | | | BP | 9.01×120 = 1,081.2 | +| 계 | 100.00 | 282.68 | 246.32 | | 273.67 | 166.68 | 116.00 | 106.99 | | 106.99 2,867.20 = 26.8 ≒27(m) | | 9.01 1,081.2 = 120(m) | +주) + +- ㉠ 토량환산계수: 자연상태 토량과 다짐상태 토량의 비율 +- ㉡ 보정토량: 자연상태 토량/토량환산계수 +- ㉢ 평균운반거리: 총작업량/총운반성토량 +- ㉣ 도저운반성토: 60m 이하의 토량(건설표준품셈 참조) +- ㉤ 덤프운반성토: 60m 초과의 토량(건설표준품셈 참조) + +- ② 유용토는 20m 미만의 총성토량 및 총절토량 중 적은 수량을 기재하고 총절토량 - 총성토량 > 0 일 경우에는 잔토란에, 총절토량 - 총성토량 < 0 일 경우에는 부족토란에 그 수량을 기재한다. ※ ㉠ BP에서 총절토량은 9.20㎥이나 20m미만 거리에 총성토량이 없으므로 잔토란에 9.20㎥가 되고, No.1의 총절토량 62.40㎥는 BC의 총성토량 57.11㎥가 20m 미만거리이므로 유용토로 57.11㎥를 우선 감하고 나머지 5.29㎥는 잔토란에 기재한다. ㉡ BC의 총절토량 36.40㎥는 다음 측점 No.2에서 총절토량 67.98㎥, 총성토량 14.17㎥이므로 그대로 BC의 잔토란에 36.40㎥를 기재하고, No.2의 유용토란에 14.17㎥, 잔토란에(67.98 - 14.17) 53.81㎥를 기재한다. ㉢ EC에서 총성토량 23.55㎥, 총절토량 59.20㎥이므로 23.55㎥는 유용토로 처리하고 나머지(59.20 - 23.55) 35.65㎥는 잔토로 처리하여야 하지만 No.3에서 총성토량이 118.84㎥이므로 EC에서 No.3 까지의 거리는 8m < 20m이므로 EC의 잔토량 35.65㎥는 No.3에서 유용이 가능하기 때문에 부족토량은(118.84 - 35.65) 83.19㎥가 된다. ㉣ No.4에서 총성토량이 60.00㎥, No.4 +10에서 총절토량이 36.20㎥이므로 유용토로 36.20 ㎥를 제하면 부족토는 (60.00 - 36.20) 23.80㎥가 된다. +- ③ 이렇게 하여 유용토량(166.68㎥) + 잔토량(116.00㎥) = 총절토량(282.68㎥), 유용토량(166.68㎥) + 부족토량(106.99㎥) = 총성토량(273.67㎥)이면 계산이 맞지만 다를 경우에는 다시 검산하여 맞도록 한다. +- ④ 유용토량 166.68㎥는 불도저의 절취작업시 삽날에 의하여 횡방향으로 집성토되는 것을 의미하여 무대로 처리하고, 부족토는 불도저 또는 덤프트럭운반으로서 흙쌓기를 하여야만 노체 또는 노상의 형성이 가능한 것을 의미한다. 따라서 부족토가 있는 측점을 기준으로 가장 가까운 곳에 있는 잔토부터 운반하여 60m이내에 있는 토량은 불도저운반거리로 계산하고 그 이상은 덤프트럭운반거리로 계획하여 계산한다. ※ ㉠ 부족토의 공급을 위하여 전체적인 잔토량을 살펴보면, No.3과 No.4에서는 흙이 모자라고 BP부터 N0.2까지는 흙이 남으므로 운반의 효율성으로 볼 때 운반형태가 교차되지 않도록 하기 위하여 No.4 부터 흙을 수급함이 타당하므로 No.4에서 가장 가까운 거리에 있는 No.5에서 부터 수급하면 작업량은 11.30㎥ × 20m = 226㎥·m가 되고 나머지 부족토(23.80-11.30) 12.5㎥는 그 다음 가까운 거리에 있는 No.2에서 운반하면 작업량은 12.5㎥ × 40m = 500㎥·m가 된다. ㉡ 그 다음 No.3의 부족토를 보면 가장가까운 거리에 있는 No.2의 잔토량(53.81-12.50)41.31㎥를 우선 수급하면 작업량은 41.31㎥ × 20m = 826.20㎥·m이고 나머지 부족토량(83.19-41.31) 41.88㎥ 중 36.40㎥는 BC에서 수급하면 작업량은 36.40㎥ ×30m = 1092.00㎥·m가 되고, 나머지 부족토량(83.19-41.31-36.40) 5.48㎥는 No.1과 BP에서 수급한다. ㉢ 이에 대한 운반량을 합계하면 106.99㎥이고, 작업량을 합하면 2,867.2㎥·m가 되므로 불도저 운반성토량의 가중평균운반거리는 2,867.2㎥·m ÷ 106.99㎥ = 26.8 ≒ 27m가 된다. ㉣ 덤프트럭 운반은 60m초과되는 거리를 기준으로 한다. 본표에서는 작업이 곤란하지만 사토(흙버리기:116.0-106.99=9.01㎥) 처리의 예로서 설명하면 다음과 같다. 사토장을 No.6되는 곳에 설치한다고 가정하면, BP의 잔토량(9.20-0.19)9.01㎥에 대한 작업량은 9.01㎥ × 120m = 1,081.2㎥·m가 된다. 이외에도 잔토량이 더 있을 경우에는 이와같이 계산하고 합하여 불도저 성토량의 평균운반거리와 같이 산출한다. +- ⑤ 이와 같이 계산하여 불도저운반 성토량(106.99) + 덤프트럭운반 성토량(0) = 부족토량(106.99㎥)이 되어야 하고, 부족토량(106.99) + 사토운반량(9.01) = 잔토량(116.0㎥)이 되어야 계산이 맞고 그렇지 않을 경우에는 다시 검산한다. +- ○ 유토(토량)곡선에 의한 방법 +- - 작성방법 +- ① (표 5-2-36)과 같이 토적계산표에서 측점, 거리, 총절토량, 총성토량을 이기한다. +- ② 토량환산계수를 적용하여 성토량을 실다짐상태에 필요한 자연상태의 토량으로 보정한다. ※ BC에서 성토량 51.40㎥는 다짐상태의 요구토량이지만 절취량은 자연상태의 토량이기 때문에 1㎥를 다지면 0.9㎥가 되므로 1.0 / 0.9 = 1.11㎥ 즉, 11%가 더 많은 57.11㎥의 흙이 필요함을 의미한다. 표 5-2-36. 토량유용 계산표 + +| 측점 | 거리 (m) | 절취량 (㎥) | 성 토 량(㎥) | | | 횡방향토량 | 잔토량 | 부족토량 | 누가토량 (㎥) | +|---|---|---|---|---|---|---|---|---|---| +| | | | 토 량 | 토량환산 계 수 | 보정토량 | | | | | +| BP | 0.00 | 9.20 | | | | | 9.20 | | 9.20 | +| No.1 | 20.00 | 62.40 | | | | | 62.40 | | 71.60 | +| BC | 10.00 | 36.40 | 51.40 | 0.9 | 57.11 | 36.40 | | 20.71 | 50.89 | +| No.2 | 10.00 | 67.98 | 12.76 | 0.9 | 14.17 | 14.17 | 53.81 | | 104.70 | +| EC | 12.00 | 59.20 | 21.20 | 0.9 | 23.55 | 23.55 | 35.65 | | 140.35 | +| No.3 | 8.00 | | 106.96 | 0.9 | 118.84 | | | 118.84 | 21.51 | +| No.4 | 20.00 | | 54.00 | 0.9 | 60.00 | | | 60.00 | -38.49 | +| No.4+10 | 10.00 | 36.20 | | | | | 36.20 | | -2.29 | +| No.5 | 10.00 | 11.30 | | | | | 11.30 | | 9.01 | +| 계 | 100.00 | 282.68 | 246.32 | | 273.67 | 74.12 | 208.56 | 199.55 | | + +- ③ 보정된 성토량을 (-)로 하고 절취량을 (+)로 하여 각 측점마다 누적대수화하여 누가토 량을 구한 후 횡축은 종단면도의 축척과 같이 거리별로 측점의 위치를 나타내고 종축은 각 측점까지의 누가토량을 (그림 5-2-71)과 같이 그린다. 이 곡선을 유토곡선(MassCurve)또는 토량곡선이라고 한다. + +![설계_그림5-2-71_유토곡선](<../pic/설계_그림5-2-71_유토곡선.png>) + +그림 5-2-71. 유토곡선(예) + +- - 유토곡선의 성질과 적용 +- ① 유토곡선이 상향인 구간(AB, CD)은 절토구간이고 하향인 구간(BC, DE)은 성토구간이며, 곡선의 곡점(점 C, 점 F)은 성토에서 절토로 정점(점 B, 점 D)은 절토에서 성토로 바뀌는 점이다. +- ② 곡선과 평행선이 교차하는 점(점 E, 점 G)은 절토량과 성토량이 거의 같은 평행상태를 나타내고 A∼E구간과 E∼G구간의 절토량과 성토량은 균형을 이룬다. +- ③ 평행선에서 곡선의 곡점과 정점까지의 높이(A∼E구간은 DD′, E∼G구간은 FF′)는 절 토에서 성토로 운반되는 전체의 토량을 나타내고 A∼H구간에서 사토량(捨土量)은 HH′(9.01㎥)가 되며, 토량과 운반거리는 토량유용계산표에서 직접 인용하고 만약 평형점이 측점과 측점의 중간에 위치할 경우에는 비례보간법에 의하여 토량을 구한다. +- ④ 절토와 성토의 평균운반거리는 유토곡선 토량의 1/2점(FF′/2)을 통과하는 길이(ab)가 되고, 평균운반거리는 절토의 중심(重心)과 성토의 중심(重心)간의 거리를 의미한다. ※ ㉠ 총토공작업량은 총토량을 평균운반거리 만큼 운반하는 것을 의미하므로 평균운반거리(m) = 총토공작업량(㎥·m)/총토량(㎥)㉡ 총토공작업량은 유토곡선과 평형선으로 둘러싸인 부분의 면적(프라니미터 또는 삼각형법에 의하여 산출한다)에 해당하며, 총토량은 유토곡선의 최대종거(縱距)를 의미하므로 평균운반거리(m) = 유토곡선과 평형선으로 둘러 쌓인 면적(㎡)/최대종거(m) +- ⑤ 유토곡선이 평형선보다 위에 있을 경우에는 절토에서 성토로 운반되는 작업방향은 좌에서 우로 이루어지고 아래에 있을 경우에는 우에서 좌로 이루어진다. +- ⑥ 평형선은 반드시 1개의 연속된 직선으로 설정할 필요는 없지만 유토곡선과 교차하는 측점에서 다른 평형선을 설정하여야 한다. 이때 2개의 평형선간의 상하간격은 보급토량 또는 사토량을 나타낸다. ※ (그림 5-2-71)의 절토구간 1+15∼EC와 성토구간 EC∼2+15(ℓ1∼ℓ2 구간의 평행선)및 절토구간 BC∼1+15와 성토구간 2+15∼2+18 (c∼c″구간의 평행선) +- ⑦ 절토는 양방향으로 운반될 수 있게 하는 것이 능률적이고 종단구배가 급한 구간에는 배수나 운반을 고려하여 종단곡선을 따라 내리막 구배로 굴착될 수 있도록 평형선을 긋는다. +- ⑧ 성토구간에 교량 등의 구조물이 있고 그 구조물 너머로 흙을 운반할 경우는 구조물까지 토량을 평형시키든지 또는 우회하여 운반계획을 세운다. (예제) (표 5-2-36)과 같은 토량유용계산표로서 유토곡선을 그리고 운반성토량을 산출하여라. (풀이) : 유토곡선은 (그림 5-2-71)과 같고 토량의 배분은 (표 5-2-37)과 같다. +- ① 유용토량은 운반거리가 20m미만이므로 ㉠ ABC구간 : 점 CC'의 평형선을 그으면 절토구간은 No.0 +13.5 ∼ No.1, 성토구간은 No.1∼BC로서 거리는 (30.0-13.5) 16.5m (평균거리 8.25m)가 되고 토공량은 BB1(71.6-50.89)= 20.71㎥이므로 작업량은 20.71㎥ × 8.25m = 170.8㎥·m가 된다. ㉡ CDE구간 : 평형선 ℓ1 ℓ2, 절토구간 No.1+15 ∼ EC, 성토구간은 EC ∼ No.2 +15으로서 거리는 20m, 토량 DD1 = [140.35-{50.89+(104.70-50.89)÷10×5}]62.55㎥, 작업량 62.55㎥ × 10m = 625.5㎥·m. ㉢ EFG구간 : 평형선 ℓ3 ℓ4, 절토구간 No.4 ∼ No.4+9, 성토구간은 No.3+9 ∼ No.4로서 거리 20m(평균거리 10.0m), 토량 FF1 =[38.49-2.29-{(38.49-2.29)÷10×1}] 32.58㎥, 작업량 32.58㎥ × 10m = 325.8㎥·m. ㉣ 이를 정리하면 (표 5-2-37)과 같다. +- ② 불도저운반성토 토량은 운반거리가 60m이하이므로 ①과 같은 방법으로 산출하여 운반토 량 71.40㎥, 작업량 2,573.7㎥·m 로서 평균운반거리는 36m이다. +- ③ 덤프트럭운반 성토량은 운반거리가 60m이상이므로 ①과 같은 방법으로 산출하여 운반성토량 12.31㎥, 작업량 769.4㎥·m로서 평균운반거리는 63m이고, 사토량은 2.43㎥이다. +- ④ 유용토량(115.84㎥), 불도저운반성토량(71.40㎥), 덤프트럭운반성토량(12.31㎥)을 합하면(표 5-2-36)의 부족토량(199.55㎥)과 같아야 하고, 여기에 사토량(9.01㎥)을 합하면 (표 5-2-36)의 잔토량(208.56㎥)과 같아야 한다. 표 5-2-37. 토량배분 산출표 + +| 구 분 | 절토구간 | 성토구간 | 토 량 (A) | 평균거리 (B) | 작업량 (A×B) | 비 고 | +|---|---|---|---|---|---|---| +| 유용토 | 0+13.5∼1 | 1∼BC | 20.71 | 8.25 | 170.8 | 71.60-50.89 | +| | 1+15∼EC | EC∼2+15 | 62.55 | 10.0 | 625.5 | 140.35-50.89-26.91 | +| | 4∼4+9 | 3+9∼4 | 32.58 | 10.0 | 325.8 | 38.49-2.29-3.62 | +| | 계 | | 115.84 | | 1122.1 | 9.70 ≒ 10m | +| 불도저 운 반 성 토 | BC∼1+15.0 | 2+15∼2+18 | 26.91 | 24.00 | 645.8 | 140.35-50.89-62.55 | +| | 4+9∼4+17 | 3+5∼3+9 | 15.11 | 26.00 | 392.8 | 38.49+9.2-32.58 | +| | 0+1.5∼0+13.5 | 2+18∼3+1.5 | 29.38 | 52.25 | 1535.1 | 140.35-62.55-26.91-21.51 | +| | 계 | | 71.40 | | 2573.7 | 36.9 ≒ 37m | +| 덤프운 반성토 | 0∼1.5 | 3+1.5∼3+5 | 12.31 | 62.50 | 769.4 | 140.35-62.55-26.91-29.38-9.2 | +| | 계 | | 12.31 | | 769.4 | 62.5 ≒ 63 | +| 사토 | | | 9.01 | | | | + +### 라. 공사원가 산출 + +각 공종별 공사수량산출이 완료되면 공사원가를 계산하기 위하여 각 공종별 단가를 산출한다. + +공사원가를 산출할 경우에는 관계규정과 행정지침을 참고하여 지역별 공사여건(자재채취 및 구입, 인력수급, 자재운반, 시공방법 등)에 따라 공사의 진행과정이 가장 합리적이고 현실적으로 실행될 수 있도록 한다. + +#### 1) 중기 사용료 +- ① 작업종류에 따라 사용할 건설기계의 종류와 규격을 건설표준품셈을 참고하여 가장 적정한 기종을 먼저 선정한다. +- ② 공사현장 조건이 할증율을 가산하여야 할 조건인지 아닌지를 검토한다. +- ③ 건설표준품셈을 참고하여 시간당 손료를 계산하고 중기가격(한화 또는 미화)을 적용하여 시간당 사용료를 산출한다. + +(예제) 임도시설공사의 절토에 사용할 굴삭기(유압식 백호우 0.7㎥)의 사용료를 산출하여라. + +(풀이) 가격 60,600천원, 시간당 손료 $2,148\times10^{-7}$이므로 다음과 같다. + +- ① 경비: $60,600,000\times2,148\times10^{-7}=13,016$원 +- ② 노무비 17,249원 조 종 원 1.0인 × 52,927원 × 1/8 × 25/20 × 16/12 = 11,026원조 수 0.5인 × 39,004원 × 1/8 × 25/20 × 16/12 = 4,062원 중기조장 0.2인 × 51,867원 × 1/8 × 25/20 × 16/12 = 2,161원 +- ③ 재료비 2,862원 주 연 료 10.5ℓ × 218.18원 = 2,290원 잡 품 25% × 2,290.89원 = 572원 +- ④ 계 33,127원/시간/대. +#### 2) 공종별 단가 +- ① 작업조건 및 토양의 종류에 따라 사용할 건설기계의 종류와 규격을 건설표준품셈을 참고하여 선정한다. +- ② 토사절취와 같이 1공종 1건설기계로 이루어지는 작업도 있지만 성토작업과 같이 2가지 이상의 건설기계가 조합되어 실행할 경우도 있다. 이때에는 기계별로 합리성과 경제성을 검토하여 가장 타당한 조합방법으로 계획한다. (예, 로우더 + 덤프트럭, 굴삭기 + 덤프트럭) +- ③ 사면 안정처리공사와 같이 여러가지의 공종이 결합되어 실행하거나 또는 동일공종이 기계와 인력이 서로 혼합(또는 비율구분)하여 실행할 경우에는 단계별 순서대로 공종이 서로 연결되도록 계획한다. +- ④ 구조물 공사와 같이 터파기, 버림콘크리트, 철근조립, 거푸집설치, 기초콘크리트, 구체콘크리트, 되메우기 등 여러개의 공종이 결합되어 공사가 진행되거나 동일공종안에서도 여러 가지의 자재와 건설기계가 투입될 경우에는 단계별 진행과정별로 공종이 연결되도록 한다. +- ⑤ 위의 사항을 참고하여 작성한 단가산출서의 예는 (표 5-2-38)과 같다. 표 5-2-38. 단가산출서(예) + +| 공종 | 산 출 내 역 | 계 | 재료비 | 노무비 | 경 비 | +|---|---|---|---|---|---| +| D00201
사토운반
(토사) | L = 2㎞(임도)/㎥
1. 적사(백호우 0.7㎥)
$q_o=0.7$, $K=0.9$, $f=1/1.325=0.75$
$E_o=0.65$, $C_M=21\,sec(135°)$
보통·흐트러진 상태·자갈 섞인 흙·점성토 기준
$Q=3,600q_oKfE_o/C_M=52.65㎥/hr$
재료비: $2,862/Q=54$
노무비: $17,249/Q=327$
경비: $13,016/Q=247$
소계 | 54
327
247
628 | 54
54 | 327
327 | 247
247 | +| | 2. 운반(덤프 10.5 ton)
$L=2.0㎞$, $E=0.9$, $V_1=7$, $V_2=10$
$q=10.5/1.7\times1.325=8.18$
$N=q/(q_oK)=12.98회$
$T_1=C_MN/(60E_o)=6.99$
$T_2=(L/V_1+L/V_2)\times60=29.14$
$T_3=0.8$, $T_4=0.42$
$C_m=T_1+T_2+T_3+T_4=37.35$
$Q=60qfE/C_m=8.87㎥/hr$
재료비: $5,905/Q=665$
노무비: $8,961/Q=1,010$
경비: $6,853/Q=772$
소계 | 665
1,010
772
2,447 | 665
665 | 1,010
1,010 | 772
772 | +| | 3. 사토장 정리(도쟈 19 ton)
$L=2.0m$, $f=1/1.325=0.75$
$E=0.75$ (양호·흐트러진 상태·자갈 섞인 흙·점성토 기준)
$V_1=75$, $V_2=98$, $q=3.2\times0.96=3.07$
$C_m=L/V_1+L/V_2+0.25=0.72분$
$Q=60qfE/C_m=143.91㎥/hr$
$Q_1=Q\times3=431.73㎥/hr$
재료비: $6,593/Q_1=15$
노무비: $17,249/Q_1=39$
경비: $17,389/Q_1=40$
소계 | 15
39
40
94 | 15
15 | 39
39 | 40
40 | +| | 계 | 3,169 | 734 | 1,376 | 1,059 | + +#### 3) 공사비 및 공사원가 계산서 +- ① 공종별 물량과 단가산출이 완료되면 서로 곱하여 합계, 노무비, 재료비, 경비로 구분하는(표 5-2-39)와 같은 공사비 내역서를 산출한다. 이때 산출되는 공사비를 순공사비라고 한다. 표 5-2-39. 공사비 내역서(예) + +| 공사명: 다목적 산림 경영 시범 단지 내임도 실시 설계 | | | | | | | | | | | | | | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| ITEM NO. | 명 칭 | 규 격 | 수 량 | 단위 | 총 액 | | 재 료 비 | | 노 무 비 | | 경 비 | | 비고 | +| | | | | | 단가 | 금 액 | 단가 | 금 액 | 단가 | 금 액 | 단가 | 금 액 | | +| 1. | 토 공 | | | | | 1,084,639,755 | | 215,571,814 | | 519,096,394 | | 349,934,047 | | +| 1) | 잡관목제거 | | 319,242 | ㎥ | 79 | 25,220,118 | | | 79 | 25,220,118 | | | D00024 | +| 2) | 흙깍기 | | | | | 266,974,149 | | 36,493,858 | | 129,334,586 | | 101,145,705 | | +| | 흙깍기(토사) | 도쟈 | 214,297 | ㎥ | 879 | 188,367,063 | 140 | 30,001,580 | 368 | 78,861,296 | 371 | 79,504,187 | D00020 | +| | 흙깍기(풍화암) | 발파 +브레카 | 10,629 | ㎥ | 7,234 | 76,890,186 | 594 | 6,313,626 | 4,638 | 49,297,302 | 2,002 | 21,279,258 | D00044 | +| | 흙깍기(발파암) | 발파+브레 카+크롤라 | 118 | ㎥ | 14,550 | 1,716,900 | 1,514 | 178,652 | 9,966 | 1,175,988 | 3070 | 362,260 | D00045 | +| 3) | 측구터파기 | | | | | 38,121,502 | | 735,722 | | 35,536,358 | | 1,849,422 | | +| | 측구터파기 | 토사 | 4,008 | ㎥ | 3,070 | 12,304,560 | 89 | 356,712 | 2,669 | 10,697,352 | 312 | 1,250,496 | D00165 | +| | 측구터파기 | 풍화암 | 746 | ㎥ | 33,635 | 25,091,710 | 493 | 367,778 | 32,359 | 24,139,814 | 783 | 584,118 | D00033 | +| | 측구터파기 | 발파암 | 12 | ㎥ | 60,436 | 725,232 | 936 | 11,232 | 58,266 | 699,192 | 1,234 | 14,808 | D00115 | +| 4) | 흙쌓기 | | | | | 24,894,518 | | 3,196,484 | | 12,080,704 | | 9,617,330 | | +| | 성토다짐 | 노상 | 36,082 | ㎥ | 360 | 12,989,520 | 47 | 1,695,854 | 174 | 6,278,268 | 139 | 5,015,398 | D00124 | +| | 성토다짐 | 노체 | 50,021 | ㎥ | 238 | 11,904,998 | 30 | 1,500,630 | 116 | 5,802,436 | 92 | 4,601,932 | D00099 | +| 5) | 흙운반 | | | | | 718,594,764 | | 174,672,998 | | 306,562,676 | | 237,359,090 | | +| | 유용토운반(도쟈) | 토사 | 11,184 | ㎥ | 827 | 9,249,168 | 132 | 1,476,288 | 346 | 3,869,664 | 349 | 3,903,216 | D00313 | +| | 유용토운반(도쟈) | 풍화암 | 748 | ㎥ | 1,391 | 1,040,468 | 222 | 166,056 | 582 | 435,336 | 587 | 439,076 | D00314 | +| | 유용토운반(덤프) | 토사 | 2,062 | ㎥ | 1,456 | 3,002,272 | 293 | 604,166 | 672 | 1,385,664 | 491 | 1,012,442 | D00315 | +| | 유용토운반(덤프) | 풍화암 | 132 | ㎥ | 1,792 | 236,544 | 192 | 25,344 | 911 | 120,252 | 689 | 90,948 | D00316 | +| | 사토운반(토사) | L=4KM (임도) | 128,896 | ㎥ | 5,082 | 655,049,472 | 1,254 | 161,635,584 | 2,165 | 279,059,840 | 1,663 | 214,354,048 | D00323 | +| | 사토운반(리핑암) | L=4KM (임도) | 8,040 | ㎥ | 6,221 | 50,016,840 | 1,339 | 10,765,560 | 2,698 | 21,691,920 | 2,184 | 17,559,360 | D00324 | +| 6) | 목책 | | 3,216 | m | 3,369 | 10,834,704 | 147 | 472,752 | 3,222 | 10,361,952 | | | D00304 | + +- ② 순공사비의 산출이 완료되면 관계규정 및 행정지침에 따라 간접노무비, 산재보험료, 고용 보험료, 기타경비, 안전관리비, 일반관리비, 수수료(이윤), 사급자재대, 부가가치세를 적용율에 따라 산출하고 관급자재대를 합하여 (표 5-2-40)과 같은 총공사원가를 산출한다. +- ③ 공사원가가 산출되면 당해기관의 재무관이 평정하는 평정가에 따라 공사가 발주된다. 표 5-2-40. 공사원가 계산서(예) + +| 공사명: 다목적 산림 경영 시범 단지 내임도 실시 설계 | | | | | | | +|---|---|---|---|---|---|---| +| 공종 | 명 칭 | 규격 | 계 | 재료비 | 노무비 | 경 비 | +| 1. | 토공 | | 1,467,028,107 | 226,174,708 | 890,844,352 | 350,009,047 | +| 2. | 배수공 | | 94,490,769 | 13,240,414 | 77,167,207 | 4,083,148 | +| 3. | 구조물공 | | 63,112,857 | 8,497,246 | 52,442,004 | 2,173,607 | +| 4. | 부대공 | | 7,311,490 | 4,505,075 | 1,806,425 | 999,990 | +| 가. | 순공사비계 | | 1,631,943,223 | 252,417,443 | 1,022,259,988 | 357,265,792 | +| | 1.간접노무비 | | 159,472,558 | 직접노무비×15.6% | | | +| | 2.산재보험료 | | 33,088,511 | (직접노무비+간접노무비)×2.8% | | | +| | 3.기타경비 | | 88,917,299 | (직접노무비+간접노무비+재료비)×6.2% | | | +| | 4.안전관리비 | | 33,993,906 | (직접노무비+재료비+관급자재대)×1.81%+3,294,000 | | | +| 나. | 소계 | | 1,947,415,497 | | | | +| | 5.일반관리비 | | 107,107,852 | 나. × 5.5% | | | +| 다. | 소계 | | 2,054,523,349 | | | | +| | 6.수수료 | | 270,241,337 | (다-재료비) × 15% = 270,315,885 | | | +| | 7.사급자재대 | | 6,035,314 | | | | +| 라. | 공급가액 | | 2,330,800,000 | | | | +| | 8.부가가치세 | | 233,080,000 | 라. 의10% | | | +| 마. | 도급공사비 | | 2,563,880,000 | | | | +| | 9.관급자재대 | | 421,450,000 | | | | + +### 마. 설계의 전산화 +#### 1) 임도계획(임도망 편성)의 전산화 + +합리적인 임도망 편성을 위해서는 대상지역의 산림과 주변지역에 대한 여러 가지 정보를 수집하여 다각적으로 분석하여야 하며, 이들 정보의 종류, 내용, 가중치(상대적인 중요도) 등은 계속 변화한다. 임도망 편성의 전산화는 주로 임도망 편성용 프로그램 개발·이용과 GIS 이용의 두가지 측면에서 진행되어 왔다. 현재까지 국내에서 개발된 임도망 편성용 프로그램은 산림청 행정전산망에 입력되어 있는"FRNET"를 비롯한 2종이다. 그러나 이러한 전용 프로그램을 이용하는 방법은 이미 프로그램 자체적으로 임도망 편성시에 고려하여야 할 정보의 종류, 내용, 가중치 등이 결정되어 있다. GIS를 이용하는 방법은 이용자가 상황에 따른 정보의 추가 및 가중치의 변화가 전용 프로그램을 이용하는 방법에 비하여 용이하다. 따라서 현재 GIS를 이용한 임도망 편성에 대하여 연구 검토가 진행되고 있으나 아직까지는 이용할 수 있는 정보가 충분하지 않다. 그러나 전분야에서 GIS 기초자료를 구축하고 있으므로 앞으로 임도망 편성 등 임도 계획에 있어서 활발히 이용될 것으로 전망된다. ※ FRNET를 이용한 임도망 편성 + +- ① 시스템 개요 ㉠ 전산기기 : PC 또는 단말기, 프린터 ㉡ 사용기종 : VAX System(산림청 행정전산망 운용기종)㉢ 운영방식 : 문답방식으로 담당자가 직접 제한조건의 값을 입력 ㉣ 특징 : +- - 제한조건의 변화에 따라 여러 형태의 임도망을 편성 +- - 선점(選點) 단계별로 분석결과를 제시 +- ② 기초자료 작성 및 입력 ㉠ 지형도 및 임상도에 격자망을 만들고 각 격자에 행열의 번호를 부여한다. ㉡ 각 격자의 행열번호, 표고(m), 산지경사(%), 임종(형태), 영급, ha당 임목축적(㎥/ha), 기설도로의 행열번호 조사 +- - 임종 : 인공침엽수림(1), 인공활엽수림(2), 인공혼효림(3), 천연침엽수림(4), 천연활엽수림(5), 천연혼효림(6) +- - 기설도로 : 1개 이상의 기설도로를 반드시 표시 ㉢ 입력내용 기설도로자료 : 통과 행열번호입지자료 : 표고, 경사도, 임종, 영급, ha당 축적기타 : 구역면적, 격자간격, 임도밀도, 한계물매, 임도 및 집재우회율, 집재단가, 임도단가 +- ③ 작업 흐름도 + +![설계_그림5-2-72_FRNET흐름도](<../pic/설계_그림5-2-72_FRNET흐름도.png>) + +- ④ 출력사항 ㉠ 초기화면 상에서 입력한 제한조건의 적용값과 기설도로의 행열번호 ㉡ 선점(選點) 단계별로 선점된 행열번호, 집재재적, 집재비용 및 임도시설비용, 임도연장, 종단물매, 평균집재거리, 선점순서, 가중평균집재거리, 집재거리 표준편차, 개발지수 ㉢ 도로(임도)의 기설·계획·총연장, 계획임도시설비, 적정임도밀도 +#### 2) 임도설계의 전산화 + +임도의 설계는 노선측량, 선형 결정·설계도 작성·공사수량 산정, 설계예산 산정의 과정으로 진행되며 각 과정은 서로 밀접한 관계가 있다. 즉 선형이 결정되어야만 설계도 작성 및 공사수량 산출과 설계예산 산정이 진행되지만, 공사수량 및 설계예산이 과다하면 선형은 변경되어야한다. 이러한 과정은 최종적인 선형이 결정될 때까지 반복되며 임도시공 중일지라도 시공대상지의 여건에 따라 설계가 변경되기도 한다. 임도 설계를 계산 및 도면작업을 동시에 수행하는 설계도 작성 및 공사수량 산출과 계산작업만을 수행하는 설계예산 산정의 두가지로 구분하여 전산화 현황을 살펴 보면 다음과 같다. + +##### 가) 설계도 작성 및 공사수량 산출의 전산화 + +임도분야의 경우 임협중앙회에서는 설계프로그램을 자체 개발하여 설계기초자료 산출 및 종단면도·횡단면도·구조물도 작성에 이용하고 있으며 평면도 작성 프로그램은 현재 개발 중에 있다. 일반도로 분야에서는 RP(Road Project), ROCAD 등 여러 종류의 설계도 작성 프로그램이 개발되어 설계기초자료 산출 및 평면도·종단면도·횡단면도·구조물도 작성 등 등고선 기입을 제외한 대부분의 설계도 작성과정이 전산화되었다. 또한 공종별 수량 산출에 있어서 토공량의 산출은 전산화되었으나, 구조물 등 기타 공종의 수량 산출은 스프레드시트를 비롯한 별도의 소프트웨어을 이용하거나 수계산한다. + +##### 나) 설계예산 산정의 전산화 + +설계예산 산정은 단순계산이 반복되는 과정이므로 전산화가 용이하였으며 시스템(프로그램)간의 자료의 호환성도 매우 높다. 현재 국내에서 개발 사용 중인 설계예산 산정 시스템은 수십 개에 달하며, 1996년 조달청에서는 18개사 36개의 프로그램을 선정하였다. 임도 설계예산 산정작업에는 이중 하나인 "STmate"를 주로 사용하고 있다. ※ 프로그램별 내용 + +- ○ STmate를 이용한 설계예산서 작성 +- ① 시스템 개요 ㉠ 전산기기 : PC(KS완성형 한글의 사용이 가능한 AT급 이상), 프린터 ㉡ 기초자료 : 물가자료(노임단가, 자재단가, 중기단가) 및 단가산출근거 ㉢ 운영방식 : 메뉴방식로 담당자가 직접 해당항목번호를 선택 ㉣ 특징 +- - 한글에 의한 검색으로 신속한 확인 및 입력 가능 +- - 자료변경시 관련항목전부에 대한 재계산 실행 +- - 타공사설계시 기설계자료의 계속적인 활용 가능 +- - 자료의 통합관리체제 하의 공사별 자료관리 가능 +- ② 전산처리 흐름도 + +![설계_그림5-2-73_STmate흐름도](<../pic/설계_그림5-2-73_STmate흐름도.png>) + +그림 5-2-73에서 보는 바와 같이 “STmate”는 자료관리에 있어서 일반적으로 “기초자료∼단가계산”의 과정은 모든 공사에 동일하게 적용되므로 공사종류에 관계없이 통합관리되고 있으며, “공사비계산∼간접비계산”의 과정은 해당공사별로 독립관리되고 있다. 그러나 작업을 완료한 후 출력 시에는 해당공사별로 출력이 가능하다. + +- ③ 기초자료작성 및 입력 ㉠ 초기화 : 환율, 노임단가, 소수점, 계산방식, 단가조사시점, 자재비교표 등. ㉡ 단가표입력 및 수정 : 노임, 자재, 경비, 복합단가 등. ㉢ 단가산출 : 일위대가표, 중기사용료 등의 입력 및 수정 +- ④ 출력사항 : 단가표, 단가산출서, 단가산출근거, 일위대가표, 중기대가표, 설계내역서, 공사 원가계산서 등 +- ○ 임협설계시스템을 이용한 설계도서 작성 +- ① 시스템 개요 ㉠ 전산기기 : PC(386급 이상), 플로터(A1 이상), 프린터(레이져), Auto-CAD(ver. 11 이상) ㉡ 사용언어 +- - Clipper(종단면도, 횡단면도, 내역서 등) +- - FORTRAN(토적수량계산서)㉢ 기초자료 : 물가자료(노임단가, 자재단가, 중기단가) 및 단가산출근거, 현지측량자료 및 구조규격 ㉣ 특징 +- - 종단면도, 횡단면도, 구조물도 등의 도화작업 가능(Spread Sheet 이용) +- - 입력, 수정, 자료의 연속성 등 사용이 쉽고 도화작업의 다양성이 있음 +- ② 전산처리 흐름도 + + - ㉠ 종단면도: 그림 5-2-74 + - ㉡ 횡단면도: 그림 5-2-75 + - ㉢ 토공수량산출: 그림 5-2-76 + - ㉣ 내역서 작성: 그림 5-2-77 + +![설계_그림5-2-74_종단면도흐름도](<../pic/설계_그림5-2-74_종단면도흐름도.png>) + +![설계_그림5-2-75_횡단면도흐름도](<../pic/설계_그림5-2-75_횡단면도흐름도.png>) + +![설계_그림5-2-76_토적계산흐름도](<../pic/설계_그림5-2-76_토적계산흐름도.png>) + +![설계_그림5-2-77_내역서흐름도](<../pic/설계_그림5-2-77_내역서흐름도.png>) + +- ③ 기초자료 및 입력 ㉠ 설계도화자료 : 지반고, 계획선, 횡단경사, 추정암선, 구조물의 종류·규격, 토성별횡단선형기울기, 노폭, 측구폭. ㉡ 금원화자료 : STmate와 같음 +- ④ 출력사항 ㉠ 도면 : 횡단면도, 종단면도, 구조물도 ㉡ 부속서류 : STmate와 같음. +- ○ Earth work를 이용한 토적설계 +- ① 시스템 개요 ㉠ 전산기기 : PC 또는 단말기, 프린터 ㉡ 사용기종 : VAX System(산림청 행정전산망 운용기종)㉢ 운영방식 : 문답방식으로 담당자가 직접 제한조건의 값을 입력 ㉣ 특징 : 현지측량인자를 입력하여 토적계산, 토량운용계획 제시 +- ② 기초자료 작성 및 입력 ㉠ 현지측량인자 : 측점, 토성, 경사도, 거리, 평균거리 ㉡ 종단면도인자 : 성·절토고 ㉢ 횡단선형인자 : 노폭, 측구폭, 절토사면기울기, 성토사면기울기 +- ③ 작업 흐름도 + +![설계_그림5-2-78_Earthwork흐름도](<../pic/설계_그림5-2-78_Earthwork흐름도.png>) + +- ④ 출력사항 + +- ④ 출력사항 ㉠ 토적사항 : 측점, 토성, 경사도, 거리, 평균거리, 절토단면적·입적, 성토단면적. 입적, 절토사면길이·면적, 성토사면길이·면적, 임도폭·면적 ㉡ 토량유용계획 : 측점, 거리, 절토입적, 토량환산입적, 유용토량, 잔토량, 부족토량, 누적토량 +##### 다) 임도시공 및 유지관리 분야의 전산화 + +임도 시공 및 유지관리 분야의 전산화는 산림청에서 기본적인 임도시설 현황자료를 수집 입력하고 있을 뿐 거의 전무한 실정이다. 그러나 최근 들어 집중호우로 인한 대규모의 임도 재해가 발생하고 있으므로 우리 나라에 적합한 임도시공 및 유지관리기술 개발에 대한 중요성이 점차 부각되고 있다. 따라서 임도시공 및 유지관리 정보의 데이터 베이스를 구축하는 것은 매우 시급히 이루어져야 할 것이다. + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 87~117쪽 (인쇄면 457~487쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md new file mode 100644 index 00000000..f987d8d9 --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/6. 시공.md @@ -0,0 +1,818 @@ +# 6. 시공 + +### 가. 토공사 +#### 1) 절토 +##### 가) 절토사면 기울기 +- ① 지질, 토질, 함수량의 변화, 지하수위, 용수의 상황, 풍화의 정도, 성층상태 등을 종합적으로 고려하여 절취고에 대한 충분한 안전성이 확보될 수 있도록 기울기를 설정한다. +- ② 표준기울기는 경험적으로 (표 5-2-41)과 같다. +##### 나) 절토사면의 형 + +사면의 형식은 (그림 5-2-79)와 같이 3가지로 구분할 수 있다. +- ① {그림 5-2-79(a)}는 단일 기울기로서 절취고 7∼10m 이하의 경암에 적합하고 +- ② {그림 5-2-79(b)}는 암질이 다른 층으로서 층마다 기울기를 다르게 적용한 것이며 +- ③ {그림 5-2-79(c)}는 절취고가 7∼10m를 초과하며 암질이 변화하는 경우에 적합한 공법인데 소단을 만들기 때문에 초기 공사비는 많이 소요되나 시공 후 유지비가 적게 소요되며 교통상으로 안전한 이점이 있고 +- ④ 소단의 폭은 보통1.5m가 표준이고, 소단에는 5∼10%의 횡단물매를 주며 설치높이의 간격은 절취고에 따라 다르나 5∼10m 간격으로 한다. + +![시공_그림5-2-79_본바닥상태와절취사면](<../pic/시공_그림5-2-79_본바닥상태와절취사면.png>) + +표 5-2-41. 절취사면의 표준기울기 + +| 본바닥의 토질 및 지질 | | 절취 고(m) | 사면기울기(할) | +|---|---|---|---| +| 경 암 | | | 0.3∼0.8 | +| 연 암 | | | 0.5∼1.2 | +| 모 래 | | | 1.5∼ | +| 사 질 토 | 다져진것 | 5 이하 | 0.8∼1.0 | +| | | 5∼10 | 1.0∼1.2 | +| | 느슨한것 | 5 이하 | 1.0∼1.2 | +| | | 5∼10 | 1.2∼1.5 | +| 력(礫) 질 토 암괴또는조약돌 섞 인 사질토 | 다져진것또는입도 분포가 양호한 것 | 10 이하 | 0.8∼1.0 | +| | | 10∼15 | 1.0∼1.2 | +| | 다져지지않은것또는입도 분포가 나쁜 것 | 10 이하 | 1.0∼1.2 | +| | | 10∼15 | 1.2∼1.5 | +| 점 토, 점질토 | | 10 이하 | 0.8∼1.2 | +| 암괴또는조약돌섞인 점질토, 점토 | | 5 이하 | 1.0∼1.2 | +| | | 5∼10 | 1.2∼1.5 | +| 주) 위표는 식생 등으로 적절한 보호를 할 경우에 적용할 수 있다. | | | | + +##### 다) 절토사면 조성시 주의사항 + +자연상태의 본 바닥을 절취할 경우에는 비탈면 붕괴가 우려되므로 설계시에 토질조사 또는 지질조사를 실시하여 사면기울기에 대한 타당성과 보호방법 등을 검토한다. 특히 주의할 지역은 다음과 같다. + +- ① 지하수위가 높고 사면에 용수의 우려가 있는 곳 +- ② 사층 등의 투수층과 점토층 등의 불투수성이 교대로 층을 이루고 있으며 그 경계면의 경사도가 절취면의 경사도와 동일한 방향으로 구성되어 있는 곳 +- ③ 수성암의 경사층이 절취면과 동일한 방향으로 경사진 곳 +- ④ 사문암, 혈암, 전판암 등의 변질암이 있는 곳 +- ⑤ 산사태 또는 산허리 붕괴의 위험이 있는 곳 +- ⑥ 단층 또는 단층의 영향을 받고 있는 곳 +- ⑦ 물을 함유한 세립분이 많은 사층 +- ⑧ 연한 점토, 경면표상(鏡面表狀), 모상균열(毛狀龜裂)이 있는 경점토가 있는 곳 +##### 라) 암반절취방법 +- ① 암반은 폭약을 천공속에 장진하여 파쇄하는 방법과 폭약없이 기계력, 수력, 열력, 전기력에 의한 절취방법이 있다. +- ② 방법의 선택은 경연성, 풍화정도, 균열상태 등 암질로서 정하는 경우와 진동, 소음, 비산 등 공해와 안정성 등 현장조건으로 정하는 경우가 있다. +#### 2) 성토 +##### 가) 기초지반 +- ① 성토(Banking)의 기초지반은 성토, 포장의 중량 및 교통하중에 침하하지 않고 안전하게 지지하도록 한다. +- ② 연약지반 위에 성토를 할 경우에는 기초지반이 옆으로 유동되거나 압밀침하를 일으킬 우려가 있으므로 충분히 검토한다. +- ③ 성토고 1m이하로 낮게 성토를 할 경우에는 교통하중에 대한 영향력이 기초지반까지 직접 전달되기 때문에 기초지반에 있는 수분이 성토 상부까지 영향을 미칠 우려가 있으므로 기초지반을 부분적으로 제거하거나 투수성이 좋은 성토재료로 교체하는 것을 검토한다. +- ④ 연약지반 개량공법으로는 치환공법, 압성토(Preloading)공법, 웰 포인트(Well Point) 공법, 샌드드레인(Sand Drain)공법, 약액 주입공법 등이 있다. +##### 나) 성토재료 +- ① 성토재료는 시공의 난이도와 역학적인 성질을 좌우하게 되므로 양질의 재료를 선택하도록 한다. ㉠ 양질재료 : 시공이 용이하고 전단강도가 크며 압축성이 작은 성질을 가진 흙 ㉡ 불량재료 : 흡수성과 압축성이 크거나 부식물이 많이 함유된 흙 +- ② 재료속에 입도가 큰 력석(礫石)이 포함되어 있으면 시공이 곤란하고 다짐작업이 불충분하여 지지력이 불균일 하게 된다. +- ③ 1회 다짐두께는 20∼30cm로 하고 양질의 재료일 수록 포장면에 가까운 윗층부에 포설하도록 하며 이때 포함되는 력석의 최대치수는 20∼30cm 정도로 한다. +##### 다) 다짐 +- ① 다짐의 목적은 성토된 노체, 노상 및 사면 등을 안정된 상태로 유지하고, 교통하중을 지지할 수 있는 내압(耐壓)강도를 향상시킴은 물론 성토 그 자체의 압밀침하를 줄이기 위하여 실시한다. +- ② 성토작업방법은 수평층으로 한층 한층 순차적으로 다지면서 작업을 하며, 다짐상태의 정도를 추정하기 위하여 건조밀도, 포화도 또는 공기공극율, 강도특성 중 하나를 측정하도록 규정하는 방법과 다짐기종별, 다짐회수를 규정하는 방법이 있다. +##### 라) 성토사면의 기울기 +- ① 성토사면 기울기의 설정은 현장의 지형, 토질, 기상조건, 인접하는 물건, 사면 보호공의 종류, 시공법 등을 고려한 안정계산을 실시하여 성토의 안정성이 충분히 보장될 수 있도록한다. +- ② 성토재료의 종류와 성토높이에 따라 정한 경험적인 기울기는 (표 5-2-42)와 같다. 표 5-2-42. 성토사면의 표준기울기 + +| 성 토 재 료 | 성토높이(m) | 사면기울기(할) | +|---|---|---| +| 입도분포가 좋은 모래 | 0∼5 | 1.5∼1.8 | +| 입도분포가 좋은 역질토 | 5∼15 | 1.8∼2.0 | +| 입도분포가 나쁜 모래 | 0∼10 | 1.8∼2.0 | +| 암괴, 조약돌 | 0∼10 10∼20 | 1.5∼1.8 1.8∼2.0 | +| 사질토 | 0∼5 | 1.5∼1.8 | +| 경점질토, 경점토 | 5∼10 | 1.8∼2.0 | +| 연점질토, 연점토 | 0∼5 | 1.8∼2.0 | + +##### 마) 성토사면 조성시 주의사항 +- ① 시공시 안정성검토가 필요한 지역은 다음과 같다. ㉠ 성토높이가 10m를 초과할 경우 ㉡ 높은 함수비의 점성토와 같이 전단강도가 낮은 경우 ㉢ 연약지반 위에 성토할 경우 ㉣ 산사태나 사면붕괴 등을 일으킬 염려가 있는 불안정한 지반 또는 급한 사면에 성토할 경우 +- ② 성토를 높게 시공할 경우에는 그림 5-2-80(a)와 같이 사면 중간에 소단(berme)을 설치한다. 소단은 성토의 안정성을 높이고, 사면 유수로 인한 침식을 방지하며, 유지보수 작업원의 발판으로 이용할 수 있다. +- ③ 소단의 폭은 1∼2m, 높이의 간격은 6m로서 5∼10%의 횡단물매를 주며 필요에 따라 식생이나 배수구를 설치하여 소단면을 보호한다. +- ④ {그림 5-2-80(b)}는 용지면적이나 토량을 절약하기 위하여 하부의 기울기를 표준보다 더 완만하게 하고 그 위에 성토하는 방법이다. +#### 3) 편절·성토 및 절·성토부의 접합부분 처리 +##### 가) 편절·성토 접속부 +- ① 임도 횡단면의 토공이 편절·성토(Cut and Fill)로 시공되는 구간에는 그 접속단에 1 : 4 정도의 물매로 완화구간을 설치하여 노상지지력이 경사방향으로 급변하는 것을 피한다. +- ② 용수나 배수를 위하여 절취면 끝 접속부에 맹구(용수가 많은 경우 구멍 뚫린 관(有孔菅))를 설치하는 것이 좋다. +- ③ 원지반의 지표횡단물매가 1 : 4 이상으로 급할 경우에는 표토를 제거한 후 (그림 5-2-81)과 같이 성토부분의 기초를 계단식 층따기(Bench Cut)로 한다. + +![시공_그림5-2-81_편절토편성토접속횡단면](<../pic/시공_그림5-2-81_편절토편성토접속횡단면.png>) + +| 층따기 치수(cm) | | | +|---|---|---| +| 토성 | 깊이 | 폭 | +| 토사 암 | 50 40 이상 | 100 이상 | + +그림 5-2-81. 편절토, 편성토 접속부분의 횡단면 + +##### 나) 절·성토 접속부 + +절토구간과 성토구간의 종단방향 접속부에는 (그림 5-2-82)와 같이 접속구간을 설치하여 노상지지력의 불연속성을 보강한다. + +![시공_그림5-2-82_절토성토접속종단면](<../pic/시공_그림5-2-82_절토성토접속종단면.png>) + +그림 5-2-82. 절토, 성토 접속부분의 종단면 + +### 나. 사면 보호공사 + +절토, 성토의 사면은 오랜동안 방치하면 강우로 인한 침수와 풍화작용으로 붕괴하게 되므로 현장조건에 적합한 방법으로 보호하여 안정을 유지하여야 한다. + +#### 1) 사면파괴의 원인 +- ① 빗물, 눈 기타의 하중 +- ② 함수량의 증가 +- ③ 식물의 뿌리 등 +- ④ 온도변화에 의한 신축 +- ⑤ 동결과 융해의 반복 +- ⑥ 지진 또는 발파에 의한 충격 +- ⑦ 인장응력에 의한 균열 +- ⑧ 균열 중의 수압 +- ⑨ 함수비에 의한 팽창 +- ⑩ 공극수압의 증가 +- ⑪ 조직의 파괴 +- ⑫ 점착력이 약해질 때 등 +#### 2) 사면파괴의 모형과 원인 +- ① 사면밑 붕괴원(崩壞圓) : 연한 점토성 사면의 길이가 비교적 높을 경우 +- ② 사면 붕괴원(崩壞圓) : 사면의 기울기가 비교적 급할 경우 +- ③ 중앙점(中央點) 붕괴원 : 활면(滑面)이 굳은 층과 접하고 있을 경우 +#### 3) 사면 보호공의 종류 +- ① 식물에 의한 보호공법 : 떼붙이기공, 식생공, 식수공, 파종공 등 +- ② 구조물에 의한 보호공법 : 콘크리트 붙이기공, 돌쌓기공, 돌 및 블록붙이기공, 뿜어붙이기 공, 콘크리트틀공, 돌망태공 등. ※ 공사비, 경관 등으로는 ①의 방법이 유리하나 시공시기, 지질, 토질, 기울기, 용수의 상황 등으로 ①의 방법이 곤란할 때에는 ②의 방법을 적용하거나 또는 ①과 ②의 방법을 혼용한다. +#### 4) 공종별 시공방법 +##### 가) 떼붙이기공 +- ① 줄떼공 : 성토면에 주로 사용하며 폭 10㎝로 잘라 30㎝간격으로 수평으로 붙인다. +- ② 평떼공 : 절취면에 주로 사용하며 떼(30㎝×20㎝)를 비탈면 전체에 떼붙임꽂이로서 사면에 붙인다. +##### 나) 식생공 +- ① 흙, 퇴비, 비료 등의 혼합체와 소량의 물을 섞어 볏짚에 바른 다음 종자를 붙이고 식생판을 만들어 꽂이로 사면에 붙인다. +- ② 연암, 자갈섞인 흙 등의 급사면에 적합하며 식생판공, 식생포대공, 식생구멍공, 식생매트공 등이 있다. +- ③ 그 지역 재래 초본종자인 쑥, 새류, 목초, 목본종자인 싸리나무 등을 혼파한다. +##### 다) 식수공 +- ① 사면에 말뚝을 박아 울타리를 만들고 그 위에 비료를 뿌리고 묘목을 심거나, 사면에 식혈을 파서 흙과 비료를 넣고 식수한다. +- ② 떼붙이기공이나 식생공만으로는 붕괴의 우려가 있는 사면에 이용된다. ※ 관목류로서는 병꽃나무, 개나리, 철쭉류, 조릿대, 싸리류, 국수나무 등이 이용된다. +##### 라) 파종공 +- ① 종자(위핑 그래스, 크로버 등), 비료, 안정제, 양생제, 흙 등을 혼합하여 압력으로 뿜어 붙인다. +- ② 넓은 지역의 사면녹화에 적합하다. +##### 마) 콘크리트 붙이기공 +- ① 연암 등의 사면에 낙석의 우려가 있고 용수가 적은 경우에는 철근 콘크리트, 무근 콘크리트, 아스팔트 콘크리트 붙이기공법을 사용한다. +- ② 철근 콘크리트 붙이기공 : 두께 15cm의 기초호박돌을 깔고, 2m간격의 활동방지공이나 앵커설치 후 철망 또는 철근 조립 콘크리트를 시공한다. +- ③ 무근 콘크리트 붙이기공 : 최소 20cm두께로 시공한다. +- ④ 아스팔트 콘크리트 붙이기공 : 사면의 기울기가 완만한 경우에 시공한다. +##### 바) 돌쌓기와 돌붙이기공 +- ① 돌쌓기공과 블록쌓기공은 사면기울기가 1할 이상으로 급할 경우에 이용된다. +- ② 1할 이하로 완만할 경우에는 돌붙이기공, 블록붙이기공을 이용한다. +##### 사) 뿜어붙이기공 +- ① 시멘트나 아스팔트를 혼합하여 기계적으로 압송하여 뿜어 붙이며, 시멘트 몰탈 뿜어붙이기, 시멘트 콘크리트 뿜어붙이기, 아스팔트 몰탈 뿜어붙이기가 있다. +- ② 사면에 용수가 없고 현재는 붕괴되지 않지만 추후에 풍화낙석이 우려되는 경우의 풍화하기 쉬운 암, 전석, 조약돌 섞인 사면 등에 이용된다. +##### 아) 틀공 +- ○ 콘크리트와 PY틀공 +- ① 높은 사면이나 표준기울기 보다 급한 성토 사면, 용수가 있는 절토사면 등의 식생이 부적합한 곳 또는 식생이 적정해도 표면이 붕괴할 우려가 있는 곳에 이용된다. +- ② 현장타설이나 프리케스트 제품으로 틀의 교차부에 활동방지 말뚝 등을 설치한다. +- ③ 틀안에는 좋은 흙을 되메우고 식생을 하는 것이 좋으나 기울기가 급한 곳, 용수가 많은 곳, 좋은 흙을 얻기가 어려운 곳, 식생으로는 사면유출이 우려되는 곳 등은 틀안에 호박 돌, 큰자갈 등으로 찰임 또는 메붙임을 한다. +- ○ 편책과 목책공 +- ① 편책공은 부식토층을 만들어 식물을 보호하고 강우로 인한 사면 토사의 유출을 방지하며 흙막이로 낙석을 방지할 수 있다. +- ② 나무말뚝을 박고 섶가지나 대나무 등을 엮어 흙막이를 하여 사면을 안정시키며 말뚝의 깊이는 비탈면 동결깊이 보다 깊게 한다. 지장목과 간벌재가 많은 곳은 섶가지나 대나무 대신에 통나무를 이용할 수 있으며 이를 목책공이라고 한다. +- ③ 초·목본류를 혼생시켜 한쪽의 생육이 불량하여도 다른쪽의 생육이 양호하도록 하거나, 초기는 초본류가 그후는 목본류가 사면을 안정토록 하기 위하여 여러종을 혼식 혼파함이 좋다. +##### 자) 돌망태공 +- ① 돌망태는 신축 변형되므로 절취사면에서 용수에 의하여 내부의 토사가 유실되어도 붕괴가 일어나지 않기 때문에 매우 효과적이며, 버드나무 등의 식수도 가능하다. +- ② 비탈면 돌망태(원형, 타원형, 반원형)와 방석돌망태(직사각형)이 있다. +### 다. 포장공사 +#### 1) 포장의 종류와 기능 +##### 가) 포장의 종류 + +포장은 실용적인 면에서 크게 아스팔트콘크리트 포장{可撓性(軟性)鋪裝 : Flexible Pavement}과 시멘트콘크리트 포장(剛性鋪裝 : Rigid Pavement)으로 대별할 수 있다. + +##### 나) 포장층의 구성과 기능 +- ○ 아스팔트콘크리트 포장 +- ① 노상위에 동상방지층, 보조기층, 기층, 중간층, 표층의 순으로 {그림 5-2-83(a)}와 같이 구성되며 +- ② 노상이 연약한 경우에는 노상토가 보조기층으로 침입하는 것을 방지할 목적으로 하천사 또는 양질의 산사를 사용하여 차단층을 시공하고, 한냉지에서는 동상의 위험이 있을 경우에는 모래, 막자갈, 슬래그 등 선택된 재료로서 동상방지용 선택층을 시공한다. + +- ③ 표층위에 미끄럼 방지와 마모저항을 높이기 위하여 3㎝ 내외의 마모층을 시공하는 경우에도 있으나 이 경우의 마모층은 포장구조계산에는 포함시키지 않는다. +- ○ 시멘트콘크리트 포장 노상위에 보조기층, 기층(별도로 두지 않을 경우도 있음), 시멘트 콘크리트 표층으로 {그림 5- + +2-83(b)}와 같이 구성된다 + +#### 2) 포장의 선택 +##### 가) 입지조건 +- ① 교통조건 : 계획기간의 교통량, 교통의 구성(특히 중교통량), 교통증가의 추이 등 +- ② 토질의 조건으로서 노선의 지반상태 : 지지력의 크기, 노상 및 보조기층 재료의 구득성, 지하수위 상태, 동상조건 +- ③ 기후·기상조건에 따른 포장의 영향 : 강우, 강설, 기온의 변동 등 +- ④ 지형, 특히 산악지역의 편절·편성구간의 비율, 부등침하요소 등. +##### 나) 경제성 +- ① 초기 건설비, 유지관리비, 보수공사에 따른 교통지체비용, 도로이용자 비용, 덧씌우기 또는 재포장비용과 잔존가치 등의 총비용을 비교한다. +- ② 초기 건설비는 시멘트 콘크리트 포장이 10∼20%정도 비싸지만 유지보수비는 아스팔트 포장이 높다. +##### 다) 항목별 비교 + +표 5-2-43. 아스팔트 포장과 시멘트 콘크리트 포장의 비교 + +| 항 목 | | 아스팔트포장 | 시멘트콘크리트포장 | +|---|---|---|---| +| 구 조 | 설계법의신뢰성 | 아스팔트 재료의 역학적 특성이 복잡하 기 때문에 불 학 실한 부분이 많다. | 콘크리트는 재료역학적으로 해명되 어 있기 때문에 아스팔트 포장보다 신뢰성이큰설계가가능 | +| | 적용범위 | 간단한 구조에서 고급인 것에 까지 폭 넓게적용가능 | 간단한 구조에도 이용하고 있으나 일반적으로 고급 포장에 이용된다. | +| 강 도 | 내용년수 | 설계의 목표는 10년, 유지수선에 따라 내용년수를연장할수있다. | 설계의목표는20년 | +| | 내변형, 내마모성 | 변형하여 자국이 생기기 쉽다. 스파이크 타이어에 대한 내 마모성이 적다. | 자국과 같은 변형을 일으키고 내마 모성도일반적으로크다. | +| 표 면 성 상 | 미끄럼저항 | 시공의 양부에 따라 변화가 크고 미끄 럼이쉬운경우가있다. | 미끄럼 저항의 변동이 적고 일반적 으로미끄럼이적다. | +| | 평탄성 | 고급인 포장에는 콘크리트 포장 보다 양호하다. | 줄눈이 있기 때문에 아스팔트 포장 과 동 등의 평탄성은 얻기 어렵다. | +| | 소음, 진동 | 콘크리트 포장에 비하여 소음, 진동이 적은것이많다. | 줄눈에 따라 진동, 거친면에 따라 소음이 아스팔트 포장 보다 일반적 으로크다. | +| | 명색성 | 노면반사가약하고, 운전의피로도는적 으나 야간 주행성에는 떨어진다. | 야간이나 터널내 등에서 밝으나 노 면반사가크다. | +| 시공성 | 시공속도와양생 | 일반적으로 시공속도가 빠른 양생이 불 필요하기 때문에 교통에 개방할 수 있 는시기가빠르다. | 시공속도가 늦기 때문에 대형의 시 공기계가 필요하고 양생기간이 있 기 때문에 교통 개방이 늦어진다. | +| 유 지 수 선 | 유지수선 | 간단한 공법으로 유지수선이 가능하다. 그러므로 지하 매설 물의 설치에 적당하다. | 비교적 복잡한 공법을 채용하지 않 으면안된다. | +| 경제성 | 건설비와유지비 | 건설비는 콘크리트 포장에 비하여 저가 이나 유지수선을 자주 행할 필요가 있 으므로 20년간의 비교에는 비교적 고가 이다. | 유지수 선의 횟수가 적어 좋으나20년 간의 총비용은 낮아진다. 단, 보수의 경우에는 아스팔트 포장보다 높다. | + +#### 3) 아스팔트 포장 +##### 가) 노상 +- ① 노상은 포장체를 통하여 전달되는 분산하중을 충분히 지지하는 동시에 시공중에는 장비와 공사용 차량 등의 하중에 견딜 수 있는 흙부분이다. +- ② 성토부에서는 성토마무리면으로 부터, 절토부에서는 굴착면으로 부터 약 1m 아래부분을 노상이라고 하고 노상의 지지력과 강도는 포장의 두께와 보조기층 및 기층의 공법을 결정하는 중요한 인자가 된다. +- ③ 노상은 충분한 지지력을 갖추고 반복되는 교통하중에 의하여 발생되는 노상토의 압축침 하에 대하여 저항하며 물에 의하여 약화되지 않는 역학적 성질이 요구된다. +- ④ 노상토에 요구되는 품질과 시공기준은 (표 5-2-44)와 같다. 표 5-2-44. 노상토의 품질과 시공기준 + +| 층 별 | 품 질 규 정 | | | | 시 공 기 준 | | | +|---|---|---|---|---|---|---|---| +| | 최 대 치 수 | 입 도 | 소성지수 | 수 침 CBR | 다 짐 도 | 시 공 시 함 수 비 | 1층완성 두 께 | +| 상부노상 | 100㎜ | 4.76㎜이하 : 25-100% 0.074㎜이하 : 0-25% | 10% 이하 | 10% 이상 | 흙의 다짐시험에 의한 γd max의 95% 이상 | 최적함수비를 원칙으로 함. | 20㎝ 이하 | +| 하부노상 | 150㎜ | 4.76㎜이하 : 95%이상 0.074㎜이하 : 5%이하 | 30% 이하 | 5% 이상 | 흙의 다짐시험에 의한 γd max의 90% 이상 | 수정 CBR 5 이상으로 되는 함수비 | 20㎝ 이하 | + +##### 나) 배수 +- ① 포장의 파괴에 직·간접적으로 영향을 크게 미치는 것이 물이므로 포장의 수명은 배수에 달려 있다고 하여도 과언이 아니므로 배수를 잘하면 공사의 효율성도 증진되며 강우에 대한 피해도 예방할 수 있다. +- ② 배수에는 포장표면의 배수와 지하배수로 나눌 수 있고 표면배수는 임도의 선형설계와 밀접한 관계가 있다. +- ③ 임도로 침입하는 물은 인접지대나 노면으로 부터 침투하는 것과 지하면에서 상승하는 경우가 있으며 이러한 침투수를 빨리 배제하기 위하여 침입하기 전에 차단한다. +- ④ 지하수위가 높은 곳과 용수가 많은 장소에서는 노상에 침투하는 지하수를 차단하거나 지하수를 노면 아래 50∼100㎝이하로 저하시키기 위하여 지하배수구를 설치할 필요가 있다. +##### 다) 보조기층과 기층 +- ① 기층과 보조기층은 표층으로부터 전달된 교통하중에 충분히 견디고 그 하중을 분산하여 노상에 과도한 응력을 주지 않으며 노상부나 주변으로부터 물의 침입을 방지하는 중요한 역할을 한다. +- ② 보조기층은 그 두께에 의한 하중분산효과를 기대하여 재료의 질적인 면보다는 경제성에 더 중점을 두기 때문에 현장부근에서 구득할 수 있는 재료를 이용하여 입상재료공법, 시멘트 및 석회 등의 첨가제를 가하여 강도를 개량하는 안정처리공법을 사용한다. +- ③ 기층은 표층 바로 아래에 위치하므로 하중조건도 혹심하고 역학적으로도 안정된 층이 요구되므로 입도조정공법, 아스팔트 안정처리공법, 시멘트 안정처리공법을 사용한다. +- ④ 기층과 보조기층이 구비하여야 할 성질과 기준은 (표 5-2-45)와 같다. 표 5-2-45. 기층 및 보조기층이 구비하여야 할 성질과 기준 + +| 층별 | 물리적인규정 | | | 강도규정 | 시공규정 (1층완성두께) | +|---|---|---|---|---|---| +| | 최대입경 | 입 도 | 소성지수 | | | +| 보 조 기 층 | 50㎜이하1 층완성두께의 1/2이하로 100㎜까지허 용 | 특별한 규 정 없음. | 입도조정: 6 이하 시멘트안정처리 : 9 이하 석회안정처리 : 6∼18 이하 | •수침CBR값>30 •일축압축강도 시멘트안정처리 : 10kg/㎠ 석회안정처리 : 7kg/㎠ | •입상재료20㎝ 이하 •안정처리15-20㎝ | +| 기 층 | 40㎜ 이하 1 층완성두께의 1/2이하 | 별도있음. | 입도조정: 4 이하 시멘트안정처리 : 9 이하 석회안정처리 : 6∼18 이하 역청안정처리 : 9 이하 | •수침CBR값>80 •일축압축강도 시멘트안정처리 : 30kg/㎠ 석회안정처리 : 10kg/㎠ 안정도350kg이상 | 15㎝이하 10-20㎝ 10㎝이하 | +주요 공법은 다음과 같다. + +- ㉠ 입상재료공법: 막자갈·막부순돌을 그대로 사용하여 마무리하며 보조기층에 많이 사용한다. +- ㉡ 입도조정공법: 2종 이상의 재료를 혼합해 입도를 조정하며 노상혼합방식과 중앙혼합방식이 있다. +- ㉢ 시멘트 안정처리공법: 현지재료 또는 보충재에 시멘트를 첨가하여 강도와 내구성을 높이고 함수량 변화를 막는다. +- ㉣ 가열아스팔트 안정처리공법: 현지재료 또는 보충재를 아스팔트로 가열처리하며 중교통 도로 기층에 많이 이용한다. +- ㉤ 상온아스팔트 안정처리공법: 유화아스팔트나 커트백 아스팔트 같은 저점도 역청재료를 첨가·혼합하며 경교통 도로 기층에 주로 사용한다. +- ㉥ 마카담공법: 한 층 마무리 두께와 비슷한 단일 입경의 주골재를 포설·전압한 뒤 채움골재를 살포하여 간극에 전압·역입시킨다. +- ㉦ 침투식공법: 포설 골재에 역청재료를 살포·침투시켜 골재 맞물림과 역청재의 접착성·점성으로 안정된 층을 형성한다. + +##### 라) 표층 +- ① 표층은 교통하중이나 기상작용의 영향을 가장 많이 받는 부분으로 좋은 입도의 조·세골재 및 필러의 아스팔트를 첨가한 가연 아스팔트 혼합물을 충분히 다져서 사용한다. +- ② 가연혼합물의 포설은 균일하게 하고 고온인 동안에 전압하여야만 소정의 밀도와 안정도가 얻어진다. +- ○ 아스팔트혼합 +- ① 재료의 가열, 계량, 혼합의 각 작업이 연속적으로 이루어지는 아스팔트 프랜트(AsphaltPlant)에서 제조 - 덤프트럭으로 현장에 운반 - 아스팔트 피니셔(Asphalt Finisher)로 부설 +- - 롤러(roller)로 전압 시공. +- ② 혼합시간은 골재가 아스팔트에 의해 균일하게 피복되는데 소요되는 최소시간(약 45∼60 +##### 초) 이 좋고 혼합온도는 160∼170℃가 좋다. +- ○ 포설 및 다짐 +- ① 혼합물을 포설하는 노면이나 기층면에는 프라임 고팅(Prime Coating)나 택고팅(TackCoating)를 실행한다. ㉠ 프라임 코트 : 커트백 아스팔트(MC-0, MC-1, MC-2)나 아스팔트 유제{RS(C)-3} 등의 프라이머(primer)를 1∼2ℓ/㎡살포한다. 목적은 •입상재료층면에 침수시켜 표면을 안정시킨다. •보조기층 또는 기층의 방수성을 높인다. (수분의 모착상승 차단)•윗층에 포설하는 아스팔트층과의 부착성을 좋게한다. ㉡ 택코트 : 프라임코트를 한 후 교통을 허용하여 표면이 더렵혀지면 기층의 표면에 아스팔트 유제를 0.4∼0.8ℓ/㎡살포하고 그 위의 혼합물층과 접착을 잘 시킨다. +- ② 아스팔트 피니셔로 부설된 혼합물은 110∼140℃로 소정의 밀도(95% 이상)가 되도록 롤러(머캐덤롤러, 탠덤롤러, 타이어롤러 및 진동롤러 등)에 의하여 전압한다. +#### 4) 시멘트 콘크리트 포장 +##### 가) 포장 종류 +- ① 보통콘크리트 포장(무근콘크리트 포장, 줄눈을 둔 콘크리트 포장) : 철근의 보강없이 줄 눈을 배치하여 균열을 허용치 않는 포장, 균열의 발생과 그 확산을 방지하기 위하여 철망(Wire Mesh)을 삽입하는 경우도 이에 속한다. +- ② 철근콘크리트 포장 : 콘크리트 슬래브 단면의 상하를 복철근으로 배치 보강하여 줄눈을 두며 균열발생을 허용하는 포장 - 교대배면, 횡단구조물 접속부, 절·성토 경계부위 등의부등침하로 포장슬래브 자체에 하중집중현상이 일어날 수 있는 곳에 시공하는 방법이다. +- ③ 연속철근 콘크리트 포장 : 보통 콘크리트 포장의 줄눈부는 포장손상의 주원인이 되 고 특히 횡방향 줄눈은 슬래브의 불연속성을 초래하여 주행성에 나쁜 영향을 미친다. 이와 같이 줄눈의 취약점을 근본적으로 개선하기 위하여 콘크리트 슬래브의 단면적의 0.6∼0.8%정도의 철근으로 보강하여 줄눈의 설치없이 미세균열의 발생을 허용하는 포장으로서 줄 눈이 없는 구조이므로 주행감이 크게 개선되고 유지관리비가 거의 소요되지 않는다. +- ④ 프리스트레스트(Pre-stresed) 콘크리트 포장 : 슬래브내에 강선을 배치하여 프리스트레스를 도입하고 줄눈을 두며 균열발생을 허용하는 포장방법이다. +##### 나) 포장 시공 +- ○ 노상조건 +- ① 강성포장에서는 아스팔트 포장의경우보다 상재하중은 매우 넓은 면적에 확산분포되므로 노상의 강도는 매우 적게된다. +- ② 따라서 강성포장의 노상은 큰 지지력은 필요없으나 지지도의 변화가 없이 균등하여야 하는 것이 중요하다. +- ③ 노상의 부분적인 약화를 방지하기 위하여 지하배수를 철저히 하여 노상 마무리면에서 60 ㎝깊이까지는 수위의 상승을 막아야 한다. +- ○ 보조기층 +- ① 슬라브의 지면으로서의 균등성, 안정성, 지지력을 가진 평탄한 접촉면(Interface)을 제공한다. +- ② 노상의 지지력을 증대시키고 포장의 구조적 강성을 증대시킨다. +- ③ 노상이나 차단층의 손상을 방지한다. +- ④ 동상의 영향을 극소화한다. +- ⑤ 줄눈, 균열, 슬래브 단부에서 펌핑현상을 방지한다. +- ⑥ 균열발생과 슬래브 단층의 방지역활을 한다. ※ 펌핑(pumping)현상 : 연약화한 보조기층이 슬래브의 상하 움직임에 따라 작은 공동을 발생시켜 그 부위에 실트질이나 점토분이 모여 줄눈부에 차륜이 재하될 때 물과 함께 뻘이슬래브틈으로 솟아오르는 것. +- ○ 보조기층의 두께 +- 지지력계수에 의한 설계에서는 보조기층의 지지력계수 $k_{30}=20$kg/㎠가 되게 하는 것이 좋다. 그렇지 못할 경우 보조기층 두께는 $k_1/k_2$에 따라 정한다. + +$$\text{지지력계수}=\text{지지력계수 평균값}-\frac{\text{최대치}-\text{최소치}}{C}$$ + +| 개소수 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 이상 | | +|---|---|---|---|---|---|---|---|---|---| +| C | 1.91 | 2.24 | 2.48 | 2.67 | 2.84 | 2.96 | 3.08 | 3.18 | | +| ※ 개소수는 동일한 재료의 노상시공구간에서 절토구간 3개소, 성토구간 3개소 이상 실측치. | | | | | | | | | | + +(예제) 노상의 지지력계수를 6개소에서 6.5, 6.0, 5.6, 4.7, 4.5, 4.4로 측정하였다. + +- ① 노상의 지지력계수: $5.3-(6.5-4.4)/2.67=4.5$kg/㎠ +- 보조기층의 지지력계수비: $20/4.5=4.4$. 그림 5-2-84의 A선에 따르면 보조기층 두께는 55cm이다. +- ② 상층 15cm를 시멘트 안정처리할 경우 B선에서 $k_1/k_2=2.5$이므로 지지력계수는 $20/2.5=8$kg/㎠이다. 하층의 지지력계수비는 $8/4.5=1.8$이고, C선에서 입상재료 약 18cm가 필요하다. 따라서 입상재료 18cm와 시멘트 안정처리재료 15cm를 합한 33cm가 필요하다. + +- CBR에 의한 보조기층 두께 설계: + +$$\text{설계 CBR}=\text{CBR 평균}-\frac{\text{CBR 최대치}-\text{CBR 최소치}}{d}$$ + +표 5-2-46. 노상토의 설계 CBR과 보조기층 두께의 관계(도로포장설계·시공지침) + +| 노상토의 설계 CBR | 2 | 2.5 | 3 | 4 | 5 | 10 이상 | +|---|---:|---:|---:|---:|---:|---:| +| 입상재료만 사용하는 경우(cm) | 60 | 50 | 40 | 35 | 25 | 20 | +| 시멘트 안정처리의 경우(cm) | 20(30) | 20(20) | 20(15) | 15(10) | 15 | 15 | + +주) 설계 CBR이 4 이하인 경우 시멘트 안정처리는 입상재료와 병용하며, 입상재료 두께는 괄호 안의 수치를 취한다. 설계 CBR이 2 미만이면 연약한 노상토로 취급하여 특별히 설계한다. + +표 5-2-47. 설계 CBR 계산용 계수 $d$ + +| 개소수($n$) | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 이상 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| $d$ | 1.41 | 1.91 | 2.24 | 2.48 | 2.67 | 2.84 | 2.96 | 3.08 | 3.18 | + +![시공_그림5-2-84_보조기층두께설계곡선](<../pic/시공_그림5-2-84_보조기층두께설계곡선.png>) + +- ○ 포장슬라브의 두께 두께는 교통하중의 양과 크기, 보조기층의 성질, 기상조건, 재료 및 시공법 등에 따라 다르다. 표 5-2-48. 콘크리트판의 두께 + +| 교통량의 구분 | 대형교통량(대/일, 1방향) | 콘크리트판 두께(㎝) | +|---|---|---| +| L 교통 | 100미만 | 15(20) | +| A 교통 | 100이하 250미만 | 20(25) | +| B 교통 | 250이상 1,000미만 | 25 | +| C 교통 | 1,000이상 3,000미만 | 28 | +| D 교통 | 3,000이상 | 30 | +| 주) ( )안은 L, A교통으로, 휨강도를 40㎏/㎠로 한 경우이다. | | | + +- ○ 줄눈 콘크리트판에는 팽창, 수축, 굽힘 등을 어느 정도 허용하고 온도, 습도 등에 따른 응력을 줄일 + +수 있도록 줄눈(Joint)을 설치한다. 줄눈은 그 위치, 기능, 구조에 따라 (표 5-2-49)와 같이 분류하고, 그 모형은 (그림 5-2-85)와 같다. 가로팽창줄눈 + +![시공_그림5-2-85_콘크리트포장줄눈](<../pic/시공_그림5-2-85_콘크리트포장줄눈.png>) + +그림 5-2-85. 콘크리트 포장의 줄눈 + +- - 세로줄눈 +- ① 주로 휘어짐응력(Warping Stress)을 감소시켜서 종방향의 균열발생을 방지하기 위하여 설치한다. +- ② 4.5m를 넘지 않는 것이 좋고, 맹줄눈이나 맞댄줄눈의 구조로서 타이바(Tie Bar)로 보강한다. ※ 타이바 : 줄눈을 가로질러 콘크리트판에 삽입하는 이형봉강으로 줄눈이 벌어지거나 단층이 생기지 않도록 한다. 직경 16㎜, 길이 800㎜, 간격 750㎜로 사용한다. +- ③ 폭은 6∼13㎜, 깊이는 단면의 1/3, 채움부의 최소길이는 10㎜이상을 원칙으로 한다. +- - 가로줄눈 • 가로팽창 줄눈 +- ① 교량접속부, 포장구조가 변하는 위치, 교차접속부에 설치하며, 콘크리트판 구조물에 대한 영향이나 온도상승에 의한 블로우업(blow up)을 방지하기 위함. ※ 블로우업 : 콘크리트의 팽창으로 인하여 팽창줄눈의 틈사이가 없어지고 콘크리트판이 좌굴을 일으켜서 부분적으로 떠오르는 현상. +- ② 주입줄눈재와 줄눈판을 상하에 병용하는 구조로 하고 주입줄눈재는 줄눈의 수밀성을 보존하기 위함이며 주입깊이는 20∼40㎜정도이다. +- ③ 다웰바(dewel bar)는 판의 두께에 따라 직경 25∼32㎜, 길이 500㎜의 것을 배치한다. 표 5-2-49. 콘크리트판 줄눈의 종류 + +| 장소에 의한 분류 | 움직임에 의한 분류 | 구조에 의한 분류 | +|---|---|---| +| 세로줄눈 (longitudinal joint) | 팽창줄눈(expansion joint) 휨줄눈(warp joint) | 팽창줄눈(expansion joint) 타이바를 사용한 맞댄 줄눈(butt joint) 타이바를 사용한 맹줄눈(dummy joint) | +| 가로줄눈 (transversal joint) | 팽창줄눈(expansion joint) 수축줄눈(contraction joint) 휨줄눈(warp joint) | 팽창줄눈(expansion joint) 스립바를 사용한 맹줄눈 스립바를 사용한 맞댄줄눈 타이바를 사용한 맞댄 줄눈 타이바를 사용한 맹줄눈 | +| 주) ㉠ 수축줄눈 : 수축줄눈 또는 맹줄눈은 수분, 온도, 마찰에 의해 발생하는 인장력을 완화시 | | | +| 켜 균열을 억제하기 위하여 설치한다. 수축줄눈이 없으면 포장표층에는 불규칙한 균열이 | | | +| 생긴다. | | | +| ㉡ 팽창줄눈 : 팽창줄눈의 주요기능은 포장이 팽창할 수 있는 공간을 설치함으로서 포장좌굴 | | | +| 의 원인이 될 수 있는 압축응력의 발생을 방지한다. | | | +| ㉢ 시공줄눈 : 시공줄눈은 시공성을 고려하여 설치하며 세로줄눈 사이의 간격은 포장장비의 | | | +| 폭과 포장두께에 따라 결정한다. | | | +| ㉣ 맹줄눈 : 콘크리트 상부에 깊이 6㎝정도의 홈을 만들어 줄눈재를 주입한 것으로서 콘크리 | | | +| 트판단면의 일부가 줄어짐으로서 판이 수축하려는 인장응력이 생겼을 때 균열이 이부분에 | | | +| 생기게 한 것이다. 맹줄눈이 있는 부분에 금이 생겼을 경우는 이것이 수축줄눈의 작용을 | | | +| 한 것이다. | | | +| ㉤ 맞댄줄눈 : 콘크리트판에 절단면을 만들어 이웃하는 콘크리트판을 마주 보게 한 구조로서 | | | +| 수축작용을 미리 막는다. | | | + +• 가로수축 줄눈 + +- ① 콘크리트판의 수축에 의하여 생기는 응력을 경감시키기 위하여 설치한다. +- ② 철망을 사용하지 않은 무근 콘크리트 포장에서 가로 수축줄눈 간격은 6m이하로 하되 다웰바를 삽입하는 것이 바람직하다. +- ③ 철근 또는 철망을 사용하는 콘크리트 포장에서 가로수축 줄눈의 간격은 발생된 균열폭이 넓게 발전되지 않도록 효과적으로 처리하기 위해서는 철근중량과 관련된다. 이 경우 가로수축 줄눈의 간격은 판두께가 25㎝미만인 경우 8m, 25㎝이상의 경우 10m를 표준으로 한다. +#### 5) 콘크리트 블록 포장 +- ① 콘크리트 블록포장(Concrete Block Pavement)은 적당한 재료로 적당한 모양과 크기로 공장에서 대량 생산하여 노면에 포설하는 포장방법이다. +- ② 판석이나 벽돌포장, 목괴포장이 옛날부터 사용되어 왔으나 현재에는 교면포장용으로 아스팔트 블록, 등판로에 소포석, 보도포장에 판석 등이 사용된다. +- ③ 최근에는 서로 맞물려서 교통하중을 분산시키는 포장구조로서 유효한 기능을 가지게 하는 인터록킹 블록(Interlocking Block)을 사용한다. +- ④ 포장의 단면은 (그림 5-2-86)과 같이 블록표층, 안정층(Cushion), 보조기층, 노상의 4단계층으로 구성하고 시공법은 노상 약 1m두께로서 CBR 25이상, 기층(Base Course) 두께는 15cm 정도, 블록과 블록 사이의 간격은 2∼3mm로서 줄눈은 모래로 채우고 횡단물매는 2∼3%로 하고 노측 가장자리에 연석을 설치하여 포장을 보호한다. + +![시공_그림5-2-86_블록설치방법](<../pic/시공_그림5-2-86_블록설치방법.png>) + +그림 5-2-86. 블록설치방법 + +- ⑤ 블록포장의 장점은 다음과 같다. ㉠ 블록은 정확한 규격과 강도에 견디는 재료로서 값싸게 생산이 가능하다. ㉡ 포설이 용이하고 간단한 장비만으로 가능하고 시공즉시 교통을 개방할 수 있다. ㉢ 지형이 불리한 곳(급경사지, 산악지도로)에도 포장이 가능하다. ㉣ 줄눈으로 인하여 표층의 균열이 없고 파손부위를 신속하고 간단하게 보수할 수 있으며 유지보수비가 저렴하다. ㉤ 침하가 큰곳에 대해서는 기존 강성포장 보다 적응성이 우수하고 미관이 수려하다. ㉥ 중차량의 가속, 회전, 정지 등에 의한 횡방향 전단(剪斷)에 높은 저항을 가진다. +#### 6) 자갈도 +- ① 표면에 자갈, 부순돌, 슬래그(Slag) 등에 적당량의 모래와 점토를 혼합한 결합재로 다짐하여 안정을 유도시키며 노면두께는 15∼25cm로 한다. +- ② 자갈도에 요구되는 조건은 다음과 같다. ㉠ 교통하중에 견딜 것 ㉡ 마모 및 凹凸발생에 저항이 클 것 ㉢ 먼지가 나지 않을 것 ㉣ 배수가 양호할 것 +- ③ 표층부의 자갈이 갖추어야 할 성질은 다음과 같다. ㉠ 교통하중으로 파쇄, 마멸되지 않을 것 ㉡ 풍화작용에 대한 저항력이 클 것 ㉢ 입도가 적당하여 공극을 극대화할 수 있는 배합이 될 것 +- ④ 시공법은 다음과 같다. (그림 5-2-87 참조)㉠ 표면공법(表面工法) : 표층저면을 수평으로 2∼3층 나누어 시공한다. ㉡ 상굴공법(箱掘工法) : 동일두께 단면으로 2∼3층 나누어 하층은 대립(12∼40mm), 상층은 소립(3∼12mm)의 자갈을 사용한다. + +![시공_그림5-2-87_자갈도시공법](<../pic/시공_그림5-2-87_자갈도시공법.png>) + +그림 5-2-87. 자갈도의 시공법 + +- ⑤ 노면의 저헝력이 약하므로 다음과 같이 수시 보수를 한다. ㉠ 노면의 평활성 유지(횡단물매 3∼5%)㉡ 바퀴자국의 되메우기 ㉢ 손실재료의 보충 +### 라. 구조물 공사 +- ① 임도의 구조물이란 흙이외의 재료(콘크리트, 강재, 석재 등)을 이용하여 노체 및 임도공간을 유지하기 위한 시설로서 교량, 터널, 옹벽, 배수공, 사면 등 주요 구조물과 임도부속물인 방호책, 낙석방지공 등이 있다. +- ② 구조물은 시설하는데 많은 비용이 소요되고 한 번 파손되면 교통에 많은 지장을 줌은 물론 다시 보수한다는 것은 쉬운 일이 아니므로 임도의 계획, 설계시에 그 필요성, 위치, 효과 등을 면밀히 조사 검토하여야 한다. +#### 1) 다리 +##### 가) 상부구조(Super Structure) +- ① 다리의 주체를 이루고 있는 부분으로서 다리를 통과하는 차량 등 통과물의 하중을 하부에 전달해지는 역할을 한다. +- ② 하중의 전달방법에 따라 항(桁), 트러스, 아아치, 라멘, 현수 Cable 등의 형태를 갖고 있고. +- ③ 바닥판, 바닥틀, 브레이싱 등으로 구성되어 있다. ㉠ 바닥판(Bridge Floor):교통하중을 직접받는 부분으로서 교면(橋面)과 그 밑의 슬라브로 되어 있다. ㉡ 바닥틀(Floor System):바닥을 지지하며 바닥에 가해지는 교통하중을 트러스에 전달하는 역할을 한다. 가로항(橫桁, 床桁)과 세로항(縱桁)으로 구성되어 있다. ㉢ 주트러스(Main Truss):상부 구조의 주체를 이루는 양쪽의 삼각형 형상으로 된 트러스로서 상부구조의 하중을 지점(支點)에 전달하는 역할을 한다. ㉣ 브레이싱(Bracing): 좌우의 트러스를 연결하여 수평하중에 대하여 저항하는 구조 부분이다. •수평 브레이싱(Lateral Bracing) : 횡하중을 지점에 전달하는 역할을 한다. 위치에 따라 상부, 하부 수평 브레이싱으로 나눈다. •수직 브레이싱(Sway Bracing) : 주 트러스의 마주보는 수직재를 연결하는 구조재로서 위치에 따라 중간수직 브레이싱, 단(橋端)수직 브레이싱이 있다. ㉤ 받침부(Bearing) : 상부와 하부를 연결하는 구조부분으로서 상부구조로 부터 모든 하중이 이곳을 통하여 하부로 전달된다. •가동받침(Movable Bearing) : 받침면을 따라 움직일 수 있는 받침으로서 받침면에 수직한 힘에만 저항할 수 있다. •힌지받침(Hinge Bearing) : 이동할 수는 없으나 회전이 가능한 받침으로서 받침을 통과하는 임의의 방향력에 저항할 수 있다. •고정받침(Fixed Bearing) : 이동이나 회전을 할 수 없는 받침으로서 임의의 방향에 대한 반력(反力)과 모우멘트에 저항할 수 있다. +##### 나) 하부구조(Under Structure) +- ① 일반적으로 교대와 교각의 조합으로 이루어져 있다. ㉠ 교대 : 교량양단에 설치되는 구조물로서 교대배면(橋臺背面)의 토압과 상부로 부터의 연직하중을 기초지반에 전달한다. ㉡ 교각 : 상부구조가 2경간(徑間)이상일 경우 교량양단의 교대사이에 있는 교항을 지지하기 위한 각주(脚柱)로서 상부로 부터 하중을 기초지반에 전달한다. + +![시공_그림5-2-88_교대와교각구조_01](<../pic/시공_그림5-2-88_교대와교각구조_01.png>) + +![시공_그림5-2-88_교대와교각구조_02](<../pic/시공_그림5-2-88_교대와교각구조_02.png>) + + +- ② 지상에 돌출되어 나와 있는 부분을 구체(軀體)라고 하고 지반에 접하는 부분을 기초(基礎)라고 한다. +- ③ 기초는 눈에 보이지 않지만 매우 중요한 구조재로서 지반의 성질에 따라 직접기초, 말뚝기초, 우물통기초로 구분된다. +- ○ 교대(Abutment) +- ① 교대의 구조는 (그림 5-2-88(a))와 같이 보를 지지하는슈(shoe)가 위치하는 교좌부(A),a-a선 상단에 토압이나 상재하중으로 부터 오는 압력을 지지하는 흉벽 또는 파라피트(B), 상부에서 전달되는 하중과 배면토압을 지지하는 몸체(C), 하중을 지반으로 전달하는 기초(D)로 이루어진다. +- ② 교대의 종류는 (그림 5-2-89)와 같으며 적정설치높이는 지반 지지력이나 응력에 따라 차이가 있다. + +![시공_그림5-2-89_교대의종류](<../pic/시공_그림5-2-89_교대의종류.png>) + +그림 5-2-89. 교대의 종류 + +- ③ 교대의 안정 ㉠ 연직력 •보, 트러스, 교상, 포장, 난간 등과 같은 상부구조의 중력에 의한 지점의 사하중 •활하중에 의한 지점의 최대하중 •충력하중 •교대의 자중 및 기초 위의 토사중량 ㉡ 수평력 •교대배면의 토사 및 상재하중에 의한 토압 •교량에 자동차가 통과할 때 다리축 방향에 작용하는 견인 및 제동력 •교량위에서 궤도가 곡선을 이룰 때 일어나는 원심력 •풍하중 ㉢ 안정검토 교대는 연직 및 수평하중을 받는 일종의 옹벽이므로 토압론에 의하여 토압을 계산하고 안정의 3조건 즉, 전도(Over Turning), 활동(Sliding), 침하(Settlement)에 대한 안정을 만족하도록 검토한다. +- ○ 교각(Pier) +- ① 교각은 (그림 5-2-88(b))와 같이 상부구조의 지점이 되는 슈(Shoe)를 앉히는 교좌부(A), 상부하중을 기초로 전달하는 몸체(B) 및 기초(C)로 구분된다. +- ② 교각의 종류는 (그림 5-2-90)과 같다. + +![시공_그림5-2-90_교각의종류_01](<../pic/시공_그림5-2-90_교각의종류_01.png>) + +![시공_그림5-2-90_교각의종류_02](<../pic/시공_그림5-2-90_교각의종류_02.png>) + +⒞ 구주식 ⒟ T형 ⒜ 중력식 ⒝ 벽식 + +![시공_그림5-2-90_교각의종류_03](<../pic/시공_그림5-2-90_교각의종류_03.png>) + +![시공_그림5-2-90_교각의종류_04](<../pic/시공_그림5-2-90_교각의종류_04.png>) + + +- ③ 교각의 안정 ㉠ 연직력 : 교각의 자중, 상부구조물의 중량, 통과할 하중 및 충격하중 등 ㉡ 수평력 : 활하중의 견인력, 풍압, 유수압, 지진력, 유수, 유목 및 선박 등에 의한 충격력 등 ㉢ 풍압, 유수압 ㉣ 안정검토 : 활동 또는 전도에 대한 안정, 재료의 파괴에 대한 안정, 유수의 집중에 의한 세굴에 대한 안정을 만족하도록 한다. +##### 다) 교량의 종류 +- ① 용도에 따라 - 도로교, 철도교, 인도교 +- ② 교면의 위치에 따라 - 상로교(Deck Bridge), 중로교(Half-Through Bridge), 하로교(Through Bridge), 2층교(2-Storied Bridge) +- ③ 구조형식에 따라 ㉠ 판구조 : 슬래브교(Slab Bridge)㉡ 보구조 : 판항교(Plate Girder Bridge), I항교, Box항교, 합성항교 등 + +| (a) 상로교 (b) 중로교 (c) 하로교 (d) 이층교 | (a) 하프형 사장교 | +|---|---| +| | (b) 방사형 사장교 | + +![시공_그림5-2-91_교량의종류_01](<../pic/시공_그림5-2-91_교량의종류_01.png>) + +![시공_그림5-2-91_교량의종류_02](<../pic/시공_그림5-2-91_교량의종류_02.png>) + +![시공_그림5-2-91_교량의종류_03](<../pic/시공_그림5-2-91_교량의종류_03.png>) + +㈀ 교면위치에 따른 교량 ㈁ 케이블형상에 따른 사장교 + +![시공_그림5-2-91_교량의종류_04](<../pic/시공_그림5-2-91_교량의종류_04.png>) + +![시공_그림5-2-91_교량의종류_05](<../pic/시공_그림5-2-91_교량의종류_05.png>) + +(a) 단순항교(a)(b)(b) 연속항교 ㈃ 랭거교(c) 게르바항교 ㈂ 지지조건에 따른 항교 ㈄ 로제교 + +| (a) | (a) 진정식 현수교 | +|---|---| +| (b) | (b) 자정식 현수교 | +| (c) | ㈆ 현수교 | +| | (a) | +| | (b) | +| | ⒪ 타이드 아치교 | +| ㈅ 각종형식의 라멘교 | | + +![시공_그림5-2-91_교량의종류_06](<../pic/시공_그림5-2-91_교량의종류_06.png>) + +![시공_그림5-2-91_교량의종류_07](<../pic/시공_그림5-2-91_교량의종류_07.png>) + +![시공_그림5-2-91_교량의종류_08](<../pic/시공_그림5-2-91_교량의종류_08.png>) + +![시공_그림5-2-91_교량의종류_09](<../pic/시공_그림5-2-91_교량의종류_09.png>) + +그림 5-2-91. 교량의 종류 + +##### 라) 하중 +- ○ 하중의 종류 +- ① 주하중 : 사하중, 활하중, 충격, 프리스트레스, 콘크리트 크라프의 영향, 콘크리트 건조수축의 영향, 토압, 수압, 부력 또는 양압력 등 +- ② 부하중 : 풍하중, 온도변화의 영향, 지진의 영향 등 +- ③ 특수하중 : 설하중, 지반변동의 영향, 지점이동의 영향, 파압, 원심하중, 제동하중, 가설시하중, 충돌하중, 기타 +- ○ 사하중(死荷重) 교량의 자중 및 교량에 부과되는 물체들의 중량 표 5-2-50. 재료의 단위중량(㎏/㎥) + +| 재 료 | 단 위 중 량 | 재 료 | 단 위 중 량 | +|---|---|---|---| +| 강재·주강·단강 | 7,850 | 콘크리트 | 2,350 | +| 주철 | 7,250 | 시멘트 모르터 | 2,150 | +| 알미늄 | 2,800 | 목재 | 800 | +| 철근콘크리트 | 2,500 | 역청재(방수용) | 1,100 | +| 프리스트레스트 콘크리트 | 2,500 | 아스팔트 포장 | 2,300 | + +- ○ 활하중(活荷重)차량, 군중 등 교량위를 이동하는 하중으로서 자동차 하중은 표준트럭하중(DB하중)과 차선하 + +중(DL하중)으로 구분된다. 표 5-2-51. DB 하중 + +| 교량등급 | 하 중 W(t) | 총중량 1.8W(t) | 전륜하중 0.1W(kg) | 후륜하중 0.4W(kg) | +|---|---|---|---|---| +| 1등교 | DB-24 | 43.2 | 2,400 | 9,600 | +| 2등교 | DB-18 | 32.4 | 1,800 | 7,200 | +| 3등교 | DB-13.5 | 24.3 | 1,350 | 5,400 | +| 주) 1등교 : 고속도로, 국도 | | | | | +| 2등교 : 국도일부, 지방도, 특별시도 | | | | | +| 3등교 : 군도, 비법정도이나 근래는 거의 2등교 수준이상으로 설계 | | | | | + +- - 보도하중 +- ① 바닥판과 바닥틀 설계시 : 500㎏/㎥ +- ② 주항설계시는 (표 5-2-52)와 같다. 표 5-2-52. 보도 등에 재하하는 등분포 하중 + +| 지 간 장 L(m) | L≤80 | 80≤L≤130 | L>130 | +|---|---|---|---| +| 하 중(㎏/㎡) | 350 | 430-L | 300 | + +- - 충격하중: 교면의 요철, 차량의 가속과 감속, 전후차량들의 협동작용 등의 이유로 정하중(靜荷重)보다 큰 동적영향을 교량에 미치는데 이를 충격하중이라고 하고 활하중에 충격계수를 곱하여 구한다. + +$$ +I=\frac{15}{40+L}\leq0.3 +$$ + +단, $L$은 활하중이 재하된 지간(支間) 부분의 길이(m)이다. +#### 2) 옹벽 +##### 가) 옹벽의 종류 + +옹벽(retaining wall)은 절토·성토를 사면의 기울기가 흙의 안식각보다 클 경우에 토압에 저항하여 흙의 붕괴를 방지하기 위하여 시설하는 구조물로서 형상이나 역학적 특성에 따라 (그림5-2-92)와 같이 구분한다. + +![시공_그림5-2-92_옹벽의종류](<../pic/시공_그림5-2-92_옹벽의종류.png>) + +그림 5-2-92. 옹벽의 종류 + +##### 나) 옹벽의 적용기준과 경제성 + +표 5-2-53. 옹벽 선택의 일반적인 기준 + +| 종 류 | 형 상 | 옹벽높이 | 특 징 | 채용상의유의점 | 경제성 | +|---|---|---|---|---|---| +| 블록쌓기 (돌쌓기) 옹 벽 | | •공적: 3m 이하 •찰쌓기뒤채움 콘크리트없음 : 5m 이하 뒷채움콘크리트 있음 : 7m 이하 | •경사면 기울기, 경사길이 및 평면선형 등을 자유로이 변화 시킬수있음 | •사면의보호 •토압이 작은 경우(내면의 원지반이 단 단한 경우나 배면토가 양 호한 경우 등) | •다른형식에 비교하여 경 제적임 | +| 중력식옹벽 | | •5m 이하 | •콘크리트 옹벽 가운데에는 시 공이 가장 용 이함 | • 기초지반이 좋은 경우 (저면반력이 크다) • 말뚝기초가 되는 경우는 부적당 | •높이가 낮은 경우는 경제 적임 •높이가 4m이 상의 경우는 비경제적임 | +| 반중력식 옹 벽 | | •10m이하가 많 음 •15m까지 이용 되는 예는 많 다. | •산악임도의 확 폭등에유리 •자립되지 않으 므로 시공상 주의를요함 | • 기초지반이 견고한장소 | •비교적 경제 적임 | +| 켄티레버식 옹 벽 (역T형,L 형) | | •3m∼10m | •뒤꿈치판상의 흙중량을 옹벽 의 안정에 이 용함 | •보통기초지반 이상이 바람직 함 •기초지반이좋지 않은경우에이 용되는예는있 음(저면반력은 비교적적다) | •비교적경제적 임 | +| 버팀벽식 옹 벽 (부벽식) | | •6m이상 | •구체의 콘크리 트량은 켄티레 버식 옹벽에 비하여 적을 수도 있으나 시공에 난점이 있음 | • 기초지반이 좋지 않은 경 우에 이용되 는 예는 있음 (저면반력은 비교적 적음 다) | •높이,기초의 조건에 따라 경제성이 좌 우됨 | +| 기타의옹벽 (특수옹벽) | 지형조건, 지반조건, 환경 조건 및 각종의 제약 조건 등에 따라 적당히 채용됨 | | | | | + +![시공_옹벽하중도_01](<../pic/시공_옹벽하중도_01.png>) + +![시공_옹벽하중도_02](<../pic/시공_옹벽하중도_02.png>) + +![시공_옹벽하중도_03](<../pic/시공_옹벽하중도_03.png>) + +![시공_옹벽하중도_04](<../pic/시공_옹벽하중도_04.png>) + +![시공_옹벽하중도_05](<../pic/시공_옹벽하중도_05.png>) + +##### 다) 하중 + +옹벽에 작용하는 하중은 자중, 상재하중, 토압, 수압, 부력, 설하중, 지진하중 등이 있다. +- ① 상시 : 사하중 + 활하중(상재하중) + 토압 +- ② 지진시 : 사하중 + 지진시토압 + 지진력 +##### 라) 옹벽의 안정 + +옹벽외력에 대하여 안정하기 위하여 안정의 3조건 즉, 전도 및 활동을 하지 말아야 하고, 침하를 일으키지 않아야 한다. 이들 안정은 실하중(Service Load)에 의하여 검사한다. + +#### 3) 배수시설 +- ① 임도는 일반적으로 표고가 높은 산간부에 설치되고 있으므로 습도가 높고 온도가 낮음은 물론 강우량도 많고 강우빈도가 높은 것이 보통이다. 일조시간에 제약을 받기 때문에 습윤하여 시공상 부적당한 토질로 되어 있는 경우도 많으며, 특히 북사면은 이와 같은 영향이 현저하다. +- ② 이와 같은 좋지 않은 조건에서는 대개 물에 의하여 영향을 받을 우려가 많으므로 시공중에는 물론이고 시공후에도 배수에 충분히 유의하는 것이 필요하다. +- ③ 따라서 배수는 임도의 수명과 기능을 유지하기 위한 중요한 인자이기 때문에 설계나 시공 시에 지형, 토질, 기상, 지하수의 상황 등을 충분히 검토하여 그 위치, 형식, 수량을 적정하게 결정하여야 한다. +- ④ 임도의 배수는 (그림 5-2-93)과 같이 대상구역에 따라 표면배수, 지하배수, 임도용지 외의배수로 구분할 수 있다. +##### 가) 표면배수시설 +- ① 노면배수시설 : 길어깨 배수시설, 중앙분리대 배수시설 +- ② 사면배수시설 : 사면끝 배수시설, 도수로 배수시설(세로 배수시설), 소단 배수시설(가로 배수시설) +##### 나) 지하배수시설 +- ① 땅깍기 구간의 지하 배수시설 : 가로지하 배수구(맹암거 등), 세로지하 배수구(횡단배수구) +- ② 흙쌓기 구간의 지하 배수시설 +- ③ 절·성 경계부의 지하 배수시설 +##### 다) 임도 인접지 배수시설 +- ① 사면어깨(산마루) 배수시설 : 산마루 측구, 감쇄공(energy dissipater) 등의 배수시설 +- ② 배수구 및 배수관 : 집수정, 배수구, 배수관 및 맨홀 등의 배수시설 + +![시공_그림5-2-93_임도배수의종류](<../pic/시공_그림5-2-93_임도배수의종류.png>) + +그림 5-2-93. 임도배수의 종류 + +##### 라) 강우유출량 추정 + +배수 구조물의 단면을 결정하기 위한 유출량(설계유량, 계획홍수량)을 결정하는 방법에는 합리식, 표면유출법, 수문곡선 추적법 등이 있다. + +합리식(Lauterburg 식)은 다음과 같다. + +$$Q=\frac{1}{3.6}rfA$$ + +- $Q$: 유출량(㎥/sec) +- $r$: 도달시간 강우강도(㎜/hr, 최근 30년간 최대 시우량) +- $f$: 유출계수(표 5-2-54 참조) +- $A$: 집수면적(㎢) + +표 5-2-54. 유출계수($f$) + +| 표면상태와 지역상태 | 유출계수(f) | 지역의 상태 | 유출계수(f) | +|---|---|---|---| +| 아스팔트 포장 | 0.92∼0.98 | 급한 산지 | 0.75∼0.90 | +| 콘크리트 포장 | 0.85∼0.95 | 3기층의 산악 | 0.70∼0.80 | +| 자갈도 | 0.30∼0.40 | 기복 있는 토지 및 수림 | 0.50∼0.60 | +| 공원 잔디 | 0.30∼0.40 | 평탄한 경지 | 0.45∼0.60 | +| 수림 | 0.40 | 관개의 답 | 0.70∼0.80 | +| 도심지구 | 0.90∼0.95 | 산지의 계천 | 0.75∼0.85 | +| 주택지역 | 0.70∼0.80 | 평지의 계천 | 0.45∼0.75 | +| 공장지역 | 0.60∼0.75 | 임목이 많은 곳 | 0.35∼0.45 | +| 운동장·공원·공지 | 0.40∼0.60 | 임목이 적은 곳 | 0.45∼0.55 | +| | | 독나지 | 0.55∼0.60 | + +표 5-2-55. 집수유역 크기별 최대유출량 + +| 집수면적 ha | 유출량(Q) ㎥/sec | 최대유출량(Q) 1 ㎥/sec | 비고 | +|---|---|---|---| +| 0.5 | 0.048 | 0.058 | Lauterburg식 이용
$a$: 유출계수(0.5)
$A$: 면적(㎡)
$h$: 최대시우량(70㎜/hr) | +| 1.0 | 0.097 | 0.110 | | +| 1.5 | 0.146 | 0.175 | | +| 2.0 | 0.194 | 0.233 | | +| 2.5 | 0.234 | 0.292 | | +| 3.0 | 0.292 | 0.349 | | +| 3.5 | 0.340 | 0.408 | | +| 4.0 | 0.389 | 0.467 | | +| 4.5 | 0.437 | 0.524 | | +| 5.0 | 0.486 | 0.583 | | +| 10.0 | 0.972 | 1.167 | | +| 20.0 | 1.944 | 2.333 | | +| 30.0 | 2.916 | 3.499 | | +| 40.0 | 3.888 | 4.667 | | +| 50.0 | 4.861 | 5.833 | | + +##### 마) 배수시설의 설계 +- ① 측구 및 배수시설의 배수유량은 Manning 식으로 계산한다. + +$$Q=AV,\qquad V=\frac{1}{n}R^{2/3}I^{1/2}$$ + +- $Q$: 배수유량(㎥/sec) +- $n$: 조도계수(표 5-2-56 참조) +- $A$: 측구단면적(㎡) +- $R$: 경심 또는 동수반경(m) +- $V$: 평균유속(m/sec) +- $I$: 측구의 물매 +- ② 조도계수는 (표 5-2-56)과 같고 막파기 측구에서는 유수로 인한 세굴이 없으야 하므로(표 5-2-57) 이하로 유속을 제한하여야 한다. 표면이 평활한 측구 또는 수로일지라도 유속은 4m/sec를 넘지 않아야 한다. 표 5-2-56. 조도계수 n의 값 + +| 배수로표면의 재료 | n의 값 | 배수로표면의 재료 | n의 값 | +|---|---|---|---| +| 보 통 흙 | 0.02 | 굵 은 돌 찰 쌓 기 | 0.025 | +| 떼로 덮힌 흙(수심15cm이하) | 0.04 | 아 스 팔 트 콘 크 리 트 | 0.02 | +| 떼로 덮힌 흙(수심15cm이상) | 0.06 | 거 친 콘 크 리 트 | 0.02 | +| 밀 생 한 풀 로 덮 힌 흙 | 0.1 | 매 끈 한 콘 크 리 트 | 0.015 | +| 다 져 진 역 질 토 | 0.04 | 흄 관 | 0.012 | +| 굵 은 돌 메 쌓 기 | 0.03 | 콜 게 이 트 파 이 프 | 0.02 | + +표 5-2-57. 세굴을 막기 위한 최대한도의 유속 + +| 표 면 상 태 | 허용할 수 있는 최대유속(m/sec) | +|---|---| +| 노출된 흙 | | +| 점토를 품지 않은 가는 모래 또는 실트 | 0.5 | +| 단단한 로움 | 0.8 | +| 단단한 점토 | 1.2 | +| 잔돌 섞인 점토 | 1.2 | +| 거친 잔돌 | 1.2 | +| 연한 혈암 | 1.5 | +| 단단한 가지로 덮힌 흙 | 0.8 | +| 풀 사이에 흙이 노출하고 있을 때 | 1.5 | +| 목초처럼 약한 잎을 가진 풀로 덮힌 흙 | 1.5 | +| 양질토가 떼로 덮혀 있을 때 | 2.0 | + +(예제) 그림 5-2-94와 같은 막파기 사다리꼴형 측구의 배수가능 유량과 안전도를 검토한다. $n=0.02$, $I=4\%$, 산악지 집수면적 10.0ha, 강우강도 40mm/hr, 배수관 간격 200m, 자갈도 전 노폭은 5m이다. + +단면적과 윤변장은 다음과 같다. + +$$A=\frac{1.7+0.5}{2}\times0.3=0.33\,\mathrm{m^2}$$ + +$$P=0.5+2\sqrt{0.6^2+0.3^2}=1.84\,\mathrm{m},\qquad R=\frac{A}{P}=0.179\,\mathrm{m}$$ + +Manning 공식으로 구한 평균유속과 배수가능 유량은 다음과 같다. + +$$V=\frac{1}{n}R^{2/3}I^{1/2}=50\times0.3176\times0.20=3.176\,\mathrm{m/sec}$$ + +$$Q=AV=0.33\times3.176=1.05\,\mathrm{m^3/sec}$$ + +자갈도 유출계수 0.35, 산악지 유출계수 0.825, 노면 집수면적 0.005km², 산악지 집수면적 0.10km²를 적용하면 예상 유출량은 다음과 같다. + +$$Q=\frac{1}{3.6}\times40\times(0.005\times0.35+0.10\times0.825)=0.936\,\mathrm{m^3/sec}$$ + +안전율 25%를 적용한 필요 유량은 $0.936\times1.25=1.17$ m³/sec이다. 따라서 $1.05<1.17$ m³/sec이므로 이 측구 단면은 유출량을 감당하기 어렵다. + +표 5-2-58. Manning 공식을 이용한 흄·토관 크기별 유량 + +| 토 관 크 기 | 유 량 | +|---|---| +| 직경 30㎝ ($r=15$㎝) | $Q=0.278$㎥/sec | +| 직경 40㎝ ($r=20$㎝) | $Q=0.599$㎥/sec | +| 직경 50㎝ ($r=25$㎝) | $Q=1.087$㎥/sec | +| 직경 60㎝ ($r=30$㎝) | $Q=1.766$㎥/sec | +| 직경 70㎝ ($r=35$㎝) | $Q=2.667$㎥/sec | +| 직경 80㎝ ($r=40$㎝) | $Q=3.806$㎥/sec | +| 직경 90㎝ ($r=45$㎝) | $Q=5.207$㎥/sec | +| 직경 100㎝ ($r=50$㎝) | $Q=6.899$㎥/sec | + +표 5-2-59. 집수면적 매설물매에 대한 관경 + +| 매설물매 집수면적ha | 콘크리트관경(단위 ㎝) | | | | +|---|---|---|---|---| +| | 5%이상 10%미만 | 10%이상 15%미만 | 15%이상 20%미만 | 20% 이상 | +| 0.6 미만 | 30 | 30 | 30 | 30 | +| 0.6∼0.8 | 40 | 30 | 30 | 30 | +| 0.8∼1.0 | 40 | 40 | 30 | 30 | +| 1.0∼2.0 | 50 | 50 | 40 | 40 | +| 2.0∼3.0 | 60 | 50 | 50 | 50 | +| 3.0∼4.0 | 80 | 60 | 50 | 50 | +| 4.0∼5.0 | 80 | 60 | 60 | 60 | +| 5.0∼6.0 | 80 | 80 | 60 | 60 | +| 6.0∼7.0 | 80 | 80 | 80 | 60 | +| 7.0∼8.0 | 80 | 80 | 80 | 80 | +| 8.0∼11.0 | 100 | 80 | 80 | 80 | +| 11.0∼14.0 | 100 | 100 | 80 | 80 | +| 14.0∼16.0 | | 100 | 100 | 80 | +| 16.0∼18.0 | | 100 | 100 | 100 | +| 18.0∼20.0 | | 100 | 100 | 100 | +| 20.0∼25.0 | | | 100 | 100 | +| 25.0 이상 | | | | | + +##### 바) 표면배수 +- ① 측구측구(Roadside Drain ; Side Ditch)는 노면과 인접된 사면의 물을 배수하기 위하여 임도의 종 + +단방향에 따라 설치하는 배수구로서, 측구의 형상과 구조는 배수량, 경제성, 교통에 대한 안정성 등에 따라 정한다. + +![시공_그림5-2-94_막파기측구](<../pic/시공_그림5-2-94_막파기측구.png>) + + +- ○ 막파기 측구 : +- - (그림 5-2-94)와 같이 V형과 사다리꼴형이 있고, 비탈면에 설치할 경우에는 비탈기슭에 폭 50cm 정도의 소단을 설치하여 비탈의 토사유입을 막는다. +- - V형은 그레이더로 성형하고 유지관리에 편리하며, 사다리꼴형은 배수량이 큰 이점이 있다. +- - 폭과 깊이는 배수량과 관계가 깊으므로 단면의 10∼20% 정도는 토사의 퇴적을 예상하여 미리 여유있게 설계한다. +- - 종단구배가 급한 곳, 모래, 실트와 같이 침식의 우려가 있는 곳에서는 사용하지 않는 것이 좋다. +- ○ 붙임측구 +- - (그림 5-2-95)와 같이 측구바닥의 세굴을 방지하기 위하여 떼, 조약돌 등을 편평한 곡면으로 붙이거나, 바닥과 측면 또는 측면을 돌쌓기, 블록쌓기로 V형이나 사다리꼴형으로 설치한다. + +![시공_그림5-2-95_붙임측구](<../pic/시공_그림5-2-95_붙임측구.png>) + + +- - 떼, 조약돌을 이용한 편평한 곡면은 배수량이 그다지 많지 않은 곳에 설치하고, 돌쌓기 블록쌓기의 사다리꼴형은 배수량이 많은 곳 또는 종단구배가 약간 급한 곳에 설치한다. +- ○ 콘크리트 측구 +- - (그림 5-2-96)과 같이 L형과 U형의 무근 또는 철근 콘크리트제로서 프리케스트(Precast)형과 현장타설형이 있다. +- - 프리케스트 제품은 (그림 5-2-96)의 우측표와 같이 U형(KS 4016, 4017), L형(KSF 4005)으로서 한국공업규격으로 시판되고 있다. +- - L형은 하중으로 손상되기 쉬우므로 측구 밑을 충분히 다지거나 필요에 따라서는 콘크리트기초공을 하여야 하고, U형 측구는 측압에 대한 저항성이 적으므로 측압이 있는 곳에서는 사용에 주의를 하여야 한다. + +![시공_그림5-2-96_콘크리트측구](<../pic/시공_그림5-2-96_콘크리트측구.png>) + +그림 5-2-96. 콘크리트 측구(단위: mm) + +- ○ 횡단배수구 +- - 암거 +- ① 노면에서 상당한 깊이에 설치하는 것으로 +- ② 종류 : 석재, 콘크리트관, 흄관, 코루게이트(Corrugate)관, T. H. P관, BOX 등이 있다. +- - 개거 +- ① 노면에 드러나 있는 배수구 +- ② 종류 : 석재, 콘크리트, 목재 등이 이용되고 있다. +- ③ 개거의 기능은 경사진 임도에서 우수가 노면수 형태로서 노면을 따라 계속 흐르면 심한 노면침식이 발생할 우려가 있으므로 이를 예방하기 위하여 노면수의 유하거리를 차단하여 노면외부로 유출시키기 위함이다. +- ④ 가장 간단한 개거의 설치방법은 말구가 약 10cm 내외의 중경목 통나무 2개를 꺽쇠와 말뚝으로서 고정시키며, 이 통나무 사이의 폭은 통나무 하나 크기 정도로 한다. 이때 이 사이에 들어가는 통나무는 임도가 이용되지 아니할 경우에는 제거하여 임도 노견 위에 두도록 한다. 최근에는 조립식이나 규격화된 횡단구가 일반화되고 있다. +- ⑤ 개거를 설치할 경우에는 임도를 통행하는 차량이 안전하고 통행차량에 의하여 파손되지 않음은 물론 유수를 안전한 곳으로 배수할 수 있어야 한다. + +![시공_그림5-2-97_횡단배수구와측구배치](<../pic/시공_그림5-2-97_횡단배수구와측구배치.png>) + + +![시공_그림5-2-98_암거의모형](<../pic/시공_그림5-2-98_암거의모형.png>) + + +![시공_그림5-2-99_개거의종류와모형1](<../pic/시공_그림5-2-99_개거의종류와모형1.png>) + +그림 5-2-99. 개거의 종류와 모형(1) + +![시공_그림5-2-100_개거의종류와모형2](<../pic/시공_그림5-2-100_개거의종류와모형2.png>) + +그림 5-2-100. 개거의 종류와 모형(2) + +- ③ 세월공 도로를 횡단하여 설치하는 호상(弧狀)의 유로로서 가능한 한 호의 길이를 길게 하여 차량의 + +통행이 편리하게 하고, 또한 수로면은 돌붙임 콘크리트(찰붙임) 또는 콘크리트를 타설한다. 특히, 세월공은 다음과 같은 곳에 주로 설치한다. + +- ○ 선상지, 벼랑 등을 횡단할 때 +- ○ 황폐계류를 횡단할 때 +- ○ 관거 등에서 복토할 흙이 부족할 때 +- ○ 계상물매가 급하여 노면 상부로부터 유입하는 형태가 될 때 ○평시에는 유수가 없고 홍수시에만 물이 많이 흐르는 계곡 +- ④ 사면 배수 +- ○ 절취부와 성취부의 사면에 유하수에 의한 침식으로 사면이 붕괴되어 임도에 피해를 유발시키는데 이를 방지하기 위하여 비탈면 보호공과 함께 비탈면배수시설을 설치하여 사면 침식을 방지한다. +- ○ 비탈면의 배수에는 강수, 표면유수가 비탈면에 들어오지 못하도록 하는 것(산마루 측구)과 비탈면을 흐르는 물과 비탈면 속의 지하수를 안전하게 비탈면 밖에 있는 배수시설을 유도하기 위한 것(소단배수구, 종배수구, 맹거 등)이 있다. +- ○ 산마루 측구와 소단배수구는 유수량이 적고 물의 침투로 인하여 배수구의 측면파괴 우려가 있는 곳에서는 막파기를 하여도 좋지만 유량이 많을 때에는 소일시멘트(SoilCcement), 콘크리트 등으로 저면과 측면을 보호하는 것이 좋다. +- ○ 종배수구는 산마루의 측구, 소단배수구에서 차단된 우수를 비탈면에 피해를 주지 않도록 종단방향으로 설치되며 철근 콘크리트 U형, 반원흄관, 철근콘크리트관 등이 사용된다. +- ○ 맹거(盲渠)는 지표면에 가까운 지하 침투수를 집수하여 배수하기 위한 구조물로서 가운데는 조립(粗粒)재료, 바깥으로는 세립재료로서 토사의 유입을 막아 투수성이 좋도록 한다. +- ○ 맹암거(盲暗渠)는 용수량이 많은 곳이나 여러개의 맹거가 합류된 곳에 구멍 뚫린 관을 넣어 집수배수하는 구조물로서 배치형태는 W형과 화살형이 있다. +- ○ 맹거대용으로 돌망태, 포러스(Porus)콘크리트관, 합성수지, 네트(Net)관 등이 있으나 현지에 적합한 것을 선정한다. + +![시공_그림5-2-101_사면배수시설](<../pic/시공_그림5-2-101_사면배수시설.png>) + +그림 5-2-101. 사면배수시설 + +##### 사) 지하배수 +- ① 보조기층 및 노상의 지하배수 +- ○ 노상의 투수계수가 비교적 작고 인접지역으로 부터 침투수가 보조기층내로 유입할 우려가 있는 곳, 지하수가 보조기층까지 오를 우려가 있는 곳, 한랭지의 동상을 입을 우려가 있는 곳에 필요하다. +- ○ 보조기층의 배수가 불충분하면 보조기층, 노상의 지지력이 감소하고 한랭지에는 동상현상이 조장된다. 콘크리트 포장에서는 공동(空洞)이 생기고 분니현상(噴泥現象 : Pumping)이 일어나고, 역청포장에서는 재료의 분리가 촉진되어 표층파괴의 원인이 된다. +- ○ 보조기층 지하배수구는 측구 밑에 구멍뚫린 관(내경 20-30cm), 구멍 수 50개 이상(관둘레 면적 1㎡당)이 좋고, 맹거를 설치할 경우에는 상폭 30-60cm, 하폭 30cm로 굴착하고 입도가 큰 자갈이나 부순돌을 넣어 그 둘레는 모래로 채운다. +- ② 비탈면 용수의 지하배수 +- ○ 비탈면의 용수는 비탈면의 침식과 용수의 유출지층에 따라 활동붕괴의 우려가 있으며, 보통 절토부와 성토부의 경계는 용출수의 통로가 되어 지하수위가 높고, 또 외부로부터 침투수를 유도하여 용수량을 증가시킨다. +- ○ 지하배수(Subsurface Drainage)에는 비탈면돌망태, 맹거, 수평배수공, 수평배수층 등의 시설이 있다. 돌망태배수는 용수가 많은 비탈면에 (그림 5-2-102)와 같이 맹거와 공용하여 비탈면 침식과 비탈면 토사유출 및 붕괴방지에 사용하며 원형, 편평형, 이불형 등이 있다. +- ③ 옹벽뒷채움의 배수 +- ○ 옹벽뒷면에 물이 고이면 흙의 함수량이 증가하고 단위중량이 증가하여 내부 마찰각과 점착력이 감소하며, 점성토에서는 함수팽창을 일으키고 토압이 증대함은 물론 기초판 저면에서는 지반지지력이 저하된다. + +![시공_그림5-2-102_돌망태사용예](<../pic/시공_그림5-2-102_돌망태사용예.png>) + +그림 5-2-102. 돌망태의 사용(예) + +- ○ 이를 막기 위하여 옹벽뒷면에 호박돌 또는 깬 호박돌 등을 채워 배수층을 만들고 여기에 집수된 물을 비닐관 등으로 만든 수발공에 따라 옹벽전면으로 배수한다. +- ○ 수발공은 무근, 철근콘크리트 옹벽에서는 뒷면 뒷채움돌 하단에 약 5m 간격으로 지그재그(Zigzag)식으로 설치하고, 돌쌓기와 콘크리트 브록쌓기에서는 2∼3㎡에 1개소씩 설치한다. +- ○ 뒷채움 호박돌층 두께는 절토의 경우 상하부 모두 30∼40cm의 동일 두께로 하지만, 성토의 경우는 옹벽의 상부에서 20∼40cm로 하고 하부로 갈수록 두껍게 한다. + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 118~154쪽 (인쇄면 488~524쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/2. 임도/7. 유지보수.md b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/7. 유지보수.md new file mode 100644 index 00000000..e5e41920 --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/2. 임도/7. 유지보수.md @@ -0,0 +1,39 @@ +# 7. 유지보수 + +### 가. 사리도 +- ① 사리도의 정상적인 노면을 유지하기 위하여 가장 중요한 부분은 배수다. 횡단구배 5∼6%정도로서 노면의 배수와 종단구배방향의 배수를 측구로 유도하여 노외로 배수하게 된다. +- ② 노면의 정지는 가능한한 비가 온 후 노면이 습윤한 상태에서 실시한 것이 좋다. +- ③ 방진처리는 물, 염화칼슘, 폐유, 타르, 아스팔트 유제 등이 사용된다. +- ④ 갓길이나 노측이 교통으로 인하여 노면 보다 높아 노면배수가 잘 되지 않을 경우에는 그 레이더로서 정형하고 로울러로 다지며 제초나 예불을 1년에 1번 이상 실시한다. +### 나. 사면 + +지형이 험준하고 강수량이 일시적 집중적이고 사면유지에 각별한 관심이 있어야 한다. +#### 1) 식생 사면 +- ① 나무가 너무 크게 되면 풍우에 넘어져서 비탈면 붕괴의 원인이 되기도 하고 가시거리에 지장을 주므로 교통의 장애가 될 수 있으므로 적당한 시기에 가지치기를 한다. +- ② 떼붙임을 한 사면의 유지는 떼를 잘 생육시키는데 있으므로 1년에 1∼2회 정도 예취하여 다른 식물의 생장을 막아 주어야 하지만, 파종공, 식생판공은 예취할 필요가 있으므로 유지에 간단하다. +- ③ 사면구배, 토질, 배수상황을 고려하고 강우량과 사면은 관계가 깊으므로 강우시 각별한 대책을 강구하고 사면으로 직접물이 흐르지 않도록 배수에 충분한 주의를 요한다. +#### 2) 뿜어 붙이기 사면 +- ① 토질, 용수의 유무, 뿜어 붙이기 두께, 보강철근의 유무 등에 의하여 결함부는 균열이 점점 확대되어 그 수명은 달라진다. +- ② 용수나 토질에 의한 결함부 - 뿜어 붙이기 비탈면 보수공은 적합치 않다. +- ③ 보강철강이 없는 결함부 - 그 부분을 제거한 후 보강철강을 넣고 앵커용 볼트로 본바닥에 고정시킨 후 재시공한다. +#### 3) 사면 구조물 + +구조물은 수시 점검하여 결함부의 원인을 제거하고 보강하거나 보수한다. +- ① 옹벽 : 전도, 활동, 침하, 균열 등에 의하여 파손되기 때문에 특히 주의할 것은 성토지반 옹벽기초부의 세굴이다. +- ② 돌쌓기, 돌붙이기 콘크리트 붙임공 : 균열 또는 침하로 파손되기 때문에 기초침하, 용수의 내압에 주의하고 줄눈의 콘크리트 두께가 얇아 초목이 붙어살면 조기제거하여 줄눈을 봉한다. +- ③ 돌메쌓기 : 바닥세굴, 기초부침하, 배면의 토압과 수압 증대, 용수, 돌사이의 초목 등이 원인으로서 균열을 발견하기 어려우므로 배불림에 주의한다. +- ④ 틀종류 : 용수가 있거나 토질이 연약한 사면에서 효과적이지만 본바닥이 약하기 때문에 배불림, 균열, 틀안에 메운 돌의 이탈 등을 주의한다. +### 다. 배수공 + +배수구는 항상 설계시의 유수단면적이 유지될 수 있도록 급한 종단구배로 인한 유토, 지조와 낙엽 등에 의하여 유수단면적이 적어질 우려가 있으므로 수시점검하여 보수한다. + +- ① 측구, 개거 : 노면배수를 유출하기 위하여 설계되었기 때문에 산지부 등의 표면수가 유입되지 않도록 한다. +- ② 관거, 암거 : 막힘으로 인하여 유수능력이 저하되어 물이 넘쳐 노면이나 성토사면에 재해를 일으키기 때문에 인력, 진공흡입기, 기계 등에 의하여 청소하거나 잘 막히는 곳은 개조한다. 종단관거는 수시로 퇴적물을 청소할 수 있도록 20∼30m 간격으로 물받이를 설치하며, 특히 개거 또는 측구와 연결되는 관거는 물받이를 설치하여 이어지도록 한다. +### 라. 노면관리 +- ① 제설(Snow Removed) : 적은 강설에는 체인 또는 스노우 타이어로서 자동차의 통행이 가능하지만 10cm 이상에서는 통행이 곤란하므로 제설판(Snow-Plough) 또는 그레이더(Grader)로 제설한다. ㉠ 밀어내기 제설 : 덤프트럭, 모터그레이더, 불도저, 휠트랙터 등에 제설판을 달거나, 불도 져, 그레이더의 토공판을 그대로 이용한다. ㉡ 뿜어내기 제설 : 전용기계나 자동화 또는 트랙터에 달아서 기계전면부분의 눈을 뿜어내기 장치(Blower)로 10∼40m 측면으로 날려보낸다. ㉢ 반출제설 : 트럭 등에 싣고 사설장(捨雪場)에 운반하는 방법이며 트랙트 셔블과 스노우로더(Snow Loader)등이 이용된다. +- ② 결빙 : 적설한랭지에서 기온이 0℃ 이하로 되면 노면이 동결된다. 특히 곡선부나 급구배지 등에서는 위험율이 커지므로 마찰저항이 증대되는 모래, 부순돌, 석탄재, 염화칼슘, 소금 등을 준비하여 뿌린다. + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 155~156쪽 (인쇄면 525~526쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md b/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md new file mode 100644 index 00000000..8108701a --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/3. 훼손지복구/1. 토석채취지복구.md @@ -0,0 +1,394 @@ +# 1. 토석채취지복구 + +토석채취지 복구라 함은 채석(석재 채취) 작업이 종료된 직후에 채석적지(채석장, 採石跡地) + +를 식생녹화공사에 적합하도록 잘 정리하고, 붕괴방지를 위한 사면안정공사를 실시한 후 가급적 경관적이며 자연 친화적인 식생으로 최선의 기술로 훼손된 임지를 빠른 시일내에 다시 녹화시키는 일이라 하겠다. + +### 가. 채석적지(採石跡地) 유형 + +채석적지의 유형구분은 구분 목적에 따라 다음과 같이 구분할 수 있다. +#### 1) 채석적지 유형분류 기준 +##### 가) 용도 및 암종에 따른 구분 +- ○ 건축재 : 내·외장 벽재 및 바닥재 +- - 조립질(중립질) 회백색 화강암류 및 편마암류(포천, 거창, 익산, 남원, 원주, 제천, 천안, 영주지역 석재) +- - 조립질 담홍색 화강암(문경, 상주, 진안, 무주, 운천지역 석재) +- - 거정질 화강암질 편마암류[점촌(목화석), 하동(설화석) 등] +- - 암회색 섬록암류[영천, 함양(마천), 고흥(금산), 담양지역 석재] +- ○ 공예재 : 묘비석, 기념비, 조각재, 가구재, 생활용품재 +- - 중(세)립질 회백색–청회색 화강암류(강화, 원주, 제천, 영주, 천안, 익산, 남원지역 석재) +- - 세립질 흑색사암 및 셰일류[충남 보령(웅천), 보은 지역 석재] +- - 화강암질 편마암류[점촌, 하동, 중원(엄정석) 지역 석재] +- - 화산암류(현무암 : 철원, 제주지역 석재. 유문암류 : 청송, 삼랑진, 영덕지역 석재) +- ○ 산림골재 +- - 아스콘 : 아스팔트용 쇄골재 +- - 레미콘 : 콘크리트용 쇄골재 +- - 토목용 : 방조제 제방 매립재, 도로 로반재 +##### 나) 암질(암석의 풍화정도)에 의한 구분 +- ○ 경암(硬岩) : 암석의 압축강도 500 ㎏/㎠ 이상이고, 겉보기 비중이 약 2.7–2.5 g/㎥ 이상인 암석 +- ○ 준경암(準硬岩) : 암석의 압축강도 100–500 ㎏/㎠이고, 겉보기 비중이 약 2.5–2.0 g/㎥ 인 암석 +- ○ 연암(軟岩) 및 파쇄암(破碎岩) : 암석의 압축강도 100 ㎏/㎠ 미만이고, 겉보기 비중이 약 2 g/㎥ 미만인 암석은 연암, 파쇄성이 많은 암석은 파쇄암 표 5-3-1. 암질별 암석의 물리적 성질 + +| 종 류 | 압축강도(㎏/㎠) | 흡수율(%) | 겉보기비중(g/㎥) | +|---|---|---|---| +| 경 암(석) | 500 이상 | 5 미만 | 약 2.7–2.5 | +| 준경암(석) | 100 – 500 | 5 – 15 | 약 2.5–2 | +| 연 암(석) | 100 미만 | 15 이상 | 약 2 미만 | + +자료 : 채석적지 복구공사의 계획·시공 및 준공검사에 관한 기준개발연구, 1993. + +##### 다) 채석방식에 의한 구분 +- ○ 경사면 채석방식 : 잔벽면에 계단을 설치하지 아니하고 채석함 +- ○ 계단식 채석방식 : 잔벽면에 계단을 설치하고 채석함 +- ○ 수직벽식 채석방식 및 기타 채석방식 : 건축용원석·규격 석재의 채석방식·수직벽형성, 기타 +##### 라) 채석 잔벽(殘壁)의 경사도에 의한 구분 +- ○ 절벽지 : 1 : 0.6 (60°) 이상 +- ○ 중절벽지 : 1 : 0.6–0.8 (51°–59°) +- ○ 급경사지 : 1 : 0.8 (50°) 이하 +##### 마) 채석허가면적 및 잔벽사면장의 규모에 의한 구분 +- ○ 소규모 채석장 : 허가면적이 2.0ha 이하 +- ○ 중규모 채석장 : 허가면적이 2.1ha∼5.0ha +- ○ 대규모 채석장 : 허가면적이 5.1ha 이상 +##### 바) 암반구조에 의한 구분 +- ○ 다절리성 암반 : 절리·습곡·단층·편리 등 암반구조상 균열부분이 많은 암구 +- ○ 중절리성 암반 : 균열부분이 중간 정도의 구조 +- ○ 소절리성 암반 : 균열부분이 적은 구조 (절리가 적은 잔벽은 녹화 곤란함)※ 절리 : 암반 중에 뚜렷하게 나타나는 균열로서 채굴에 있어서 발파의 효과, 파쇄암석의 크 + +기, 채석 후 잔벽의 녹화공사 등에 큰 영향을 미치게 된다. + +#### 2) 채석적지의 부위별 명칭 + +일반적으로 채석허가면적은 채석면적(채석장, 산물처리장, 기타)과 부대시설면적(진입로, 관리사, 기타)으로 구성되며, 채석적지에서의 각 부위별 명칭은 다음과 같다 + +![복구_그림5-3-1_채석적지부위별명칭](<../pic/복구_그림5-3-1_채석적지부위별명칭.png>) + +- A: 벌채구역(허가구역에 포함) +- B: 돌림배수로 +- C: 절암(절취)벽면 중앙부의 비탈어깨(法肩) +- D: 절암벽면의 비탈밑(法尻) +- D′: 본래 사면의 비탈밑(法尻) +- E: 절암벽면의 비탈길이(사면장) +- E′: 절암벽면의 수직길이(수직에 가까울 때) +- F: 절암벽면 수직부위의 밑점 +- G: 절암벽면의 수직높이 +- H: 절암벽사면(잔벽사면·채석적지 사면)의 사면적 +- I: 절암벽 수직면의 단면적(거의 수직으로 채석할 때) +- J: 절암벽 사면·수직면·밑면이 형성하는 채취 후 잔치된 토석체적 계산용 단면적 +- K: 본래 산지표면 +- L: 표토 제거 후 암반면 +- M: 표토 제거깊이 +- N: 채취(채석장)부위 밑면길이(편평해진 지역) +- O: 토사채취량 계산용 구적도 단면적 +- $\alpha$: 채취 후 절암벽면과 밑면(수평선)이 형성하는 잔벽 경사도 +- $\beta$: 본래 산지표면의 경사도 +- P: 절암벽면(E)과 수직벽면이 형성하는 삼각형의 밑면길이 +- Q: P 부분의 평면적($P\,\mathrm{m}\times S\,\mathrm{m}=Q\,\mathrm{m^2}$) +- R: N 부분의 평면적($N\,\mathrm{m}\times S\,\mathrm{m}=R\,\mathrm{m^2}$) +- S: 비탈밑(D) 부위의 좌우 전체길이 +- T: 산물처리장의 비탈끝 +- U: 산물처리장의 퇴적부위 길이 +- V: 산물처리장의 평면적($U\,\mathrm{m}\times S\,\mathrm{m}=V\,\mathrm{m^2}$) +- W: 퇴적토사 단면적(폐석 또는 퇴적토사) +- X: 산물처리장 퇴적토사지의 사면적 + +#### 3) 채석적지 유형 + +채석적지 복구를 위한 유형 구분은 앞에서 설명한 채석적지 유형 구분 기준(암종, 암질, 암반 구조, 석재용도, 채석방식, 채석잔벽의 경사도, 채석장규모)에 따라 구분할 수 있으나 여기서는 최대한 간편화 하기 위하여 석재용도와 채석방식에 의하여 구분('93. 산림청)하였다. 채석적지는 쇄골재 채석장, 석재 채석장과 특수석재 채석장으로 3구분할 수 있으며 석재 채석장은 다시 건축용 석재 채석장과 토목용 석재 채석장으로, 특수석재 채석장은 갱굴식, 굴하식채굴지와 석회석 채굴지로 구분할 수 있다. + +표5-3-2. 채석적지 표준유형 + +| 기본유형번호 | 기본유형 | 용도유형 | 구분기준 | +|---|---|---|---| +| 1 | 쇄 골 재 채 석 장 | 1-0 쇄골재 채석장 | 쇄골재 생산시설이 있는 채석장 (경사면식+소단형성식 채굴) | +| 2 | 석 재 채 석 장 | 2-1 건축용 석재 채석장 (수출용 원석 포함) | 수직벽식+소단형성식 채굴 | +| | | 2-2 토목용 석재 채석장 (매립용 석재 포함) | 경사면식+소단형성식 채굴 | +| 3 | 특수석재 채 석 장 | 3-1 특수 채석장 | 갱굴식·굴하식 채굴 | +| | | 3-2 석회석 채굴장 | 석회석 채굴(대규모) | + +자료 : 채석적지 복구공사의 계획·설계·시공 및 준공검사에 관한 기준개발연구, 1993. + +### 나. 채석지 복구공사 +#### 1) 복구공사 계획 + +채석사업 종료기간이 가까워지면 채석적지에 대하여 비탈다듬기공사와 산물처리장 부지정리 공사를 실시한 후 안정·녹화공사를 위한 공사일정계획, 공정계획을 수립하여 설계서작성, 승인, 시공 및 준공에 대한 세부계획을 수립해야한다. + +#### 2) 복구공사설계 + +채석적지에 대한 복구공사설계는 다음순서에 의하여 실시한다. +##### 가) 설계자료조사 +- ○ 최대 강우강도 및 강우량, 집수면적 및 적정수로규격, 공사용 재료 등 수로설계자료 +- ○ 채석잔벽의 형태 및 지질(토질)별 면적 +- ○ 객토 재료의 양부·적부 및 소요량 +- ○ 각종 공종의 시공재료의 구득난이도 여부, 운반거리, 신소재 재료·신공법 적용여부 +- ○ 설계내역작성에 필요한 단가 +##### 나) 현지조사 +- ○ 채석적지 현황 및 붕괴위험도 조사 채석적지의 입지조건과 붕괴위험도 구분을 위하여 다음 사항을 조사한다(부록1. 급경사지 붕 + +괴위험도 조사 야장 참조). + +- - 지형요인 •사면의 경사도 •사면 방향 •사면의 규모 : 사면의 길이, 높이 •종단면형 : 사면의 종단 형태로 ①돌출부, ②사면 상부 凹凸, ③사면 하부 凹凸, ④사면 전체 凹凸, ⑤직선사면 등으로 구분 조사한다. •사면형상 : 사면의 횡단형상(직선형, 미근형, 곡형)과 종단형상(凸형사면, 직선사면, 凹형사 +##### 면) 을 조합·구분한 것으로 +- ① 凸形尾根型사면 +- ④ 凸형직선사면 +- ⑦ 凸형谷型사면 +- ② 직선미근형사면 +- ⑤ 직선직선사면 +- ⑧ 직선곡형사면 +- ③ 凹형미근형사면 +- ⑥ 凹형직선사면 +- ⑨ 凹형곡형사면 등으로 구분 조사한다. •변각점 : 사면경사의 변환점으로 ① 확실, ② 명료, ③ 불 명료 등으로 구분한다. +- - 지질 및 토질요인 •암석 : 지질도 및 현지 확인조사에 의한다. •표토의 깊이 : 뿌리가 많이 분포하는 지점까지의 깊이(Rooting depth)를 조사한다. •기반암의 풍화상태 : 암석의 풍화정도에 의하여 ① 풍화토(RS), ② 미 풍화토(CW), ③ 연암(HW), ④ 보통암(MW), ⑤ 경암(SW)으로 구분 조사한다. •기암의 균열간격 : 암석의 절리 또는 틈의 간격으로 ① 10cm 이하, ② 11∼30cm, ③ 31∼50cm, ④ 51cm 이상으로 구분 조사한다. •불연속면(절리)의 크기 : 절리의 틈새(mm)와 길이(m)를 조사한다. •불연속면의 충진상태 : 암석의 절리 등 균열부위의 상태로 ① 개구(開口) ; 비어있는 상태, ② 협재 ; 진흙 등 타 물질로 채워져 있는 상태, ③ 밀착 ; 가는 틈만 있는 상태, 등으로 구분 조사한다. •불연속면의 방향수 : 절리의 방향수를 조사한다(보통 1∼3 방향 임). •사면과 불연속면의 경사관계 : 암석의 절리방향과 사면의 경사방향과의 관계로 7구분 조사한다(부록1. 급경사지 붕괴위험도 조사 야장 참조). •단층의 유무 +- - 환경요인 •사면 또는 주위의 식생 : 수종, 수고, 경급, 피도(%)를 조사한다. •붕괴 이력 : 사면 또는 인접사면에서의 붕괴 이력으로 ① 있다(상부, 중부, 하부, 사면전 +##### 체) 와 ② 없다로 구분 조사한다. + +•용수상태 : 사면상의 용출수의 유무상태로 ① 항시 존재, ② 강우시 존재, ③ 없다 로 구분한다. •대책공 상황 : 사면에 기 설치된 안정 및 녹화공사 시설물이 있을 경우 공종 및 파손상태 등을 기술한다. •토지이용상황 : 사면의 상·하부의 토지이용상태를 조사한다. +- ○ 채석지 부위별 면적측량 채석지복구 공종배치도 작성을 위하여 채석부위(보전구역, 채석잔벽, 산물처리장 및 퇴적구역, + +진입로 및 기타구역)별 평판측량으로 평면도(축척 1 : 1,200)를 작성한다. 채석잔벽은 일반적으로 평균 비탈물매가 경사도 60°이상으로 급하기 때문에 평면적 뿐만 아니라 사면적도 계산해야 한다. 잔벽의 사면적은 분사식 씨뿌리기 또는 종비토 뿜어붙이기공사에 활용된다. + +- ○ 현지설계 기초조사 +- - 공작물 배치도 작성 설계서작성에 필요한 설계기초조사로 채석적지의 부위별 면적과 규모, 경사도, 길이, 너비, 소 + +단길이 등을 확인하고 수로의 위치, 소단의 위치, 각종 공종의 배치상태를 도시한 견취도(見取圖)를 작성한다. + +- - 공종별 치수(크기)조사 견취도상에 배치된 공종별크기(상장, 하장, 높이, 경사도, 비탈높이, 면적)를 조사한다. +- - 현지채취재료의 운반량조사 현지채취재료의 운반거리와 운반조건 등을 건설표준품셈에 의하여 운반량을 산출한다. +##### 다) 설계서작성 + +설계서는 공사설명서, 일반시방서, 특별시방서, 예정공정표, 예산내역서, 일위대가표, 단가산출 서, 각 공종별 경비계산서, 공종별 수량계산서, 각종 소요자재 총괄표, 토적표, 산출기초 순으로 작성한다. + +- ○ 설계에 필요한 재료 설계에 필요한 주요 재료에는 석재, 목재, 철재, 철사돌망태, 시멘트, 골재, 콘크리트블록, 떼, + +종자, 묘목, 비료, 토양안정제 등이 있다. + +- - 석재 : 자연석 : 전석, 야면석, 호박돌, 사석, 잡석, 조약돌, 자갈 가공석 : 마름돌, 견치돌, 막깬돌, 깬잡석, 깬자갈, 깬조약돌 +- - 목재 : 통나무, 우듬지(초두목), 가지, 바자, 판재 +- - 철재 : 철근, 철판, 철사, 철관, 와이어 로프 +- - 철사 돌망태 +- - 시멘트, 골재(잔 골재, 굵은 골재) +- - 시멘트 몰터, 콘크리트, 철근 콘크리트 +- - 콘크리트블록(쌓기블록, 붙이기블록), 콘크리트 관, 콘크리트 격자블록, 벽돌 (인조목, 의목) +- - 합성재 블록, 합성재 관, 합성재 격자 블록(합성재 : PVC, PE, PET, PP, HDPE, FRP,GRT, RTR, RPM) +- - 낙석방지망 : 철사망, 합성재 망 +- - 떼 : 풀포기·새류, 자연생 떼, 재배 떼 +- - 묘목 : 내환경수종, 환경녹화수종, 녹화수종, 사방수종, 교목류, 관목, 저목 +- - 종자 : 초류종자, 재래초류종자, 외래초류종자, 목본류종자 +- - 종비토 : 종자+비료+흙+유기질+안정제+물+… +- - 식생대류 : 식생반(판), 식생자루, 식생대, 식생벨트, 토목섬유, 기타 +- - 피복재 : 철사망, 거적, 망(합성망, 유기재망…), 비닐, 기타 +- - 비료 : 유안, 요소, 초안, 과석, 증과석, 산림용 고형복합비료 +- ○ 설계에 필요한 공종 설계에 필요한 안정·녹화공사용 주요 공종(공법)은 계간 안정공사, 산지비탈 안정·녹화공 + +사, 절·성토비탈 안정·녹화공사, 기타 공사로 구별할 수 있으며, 각 공사별 주요 공종(공법)은 다음과 같다. + +- - 계간 안정공사 •사방 댐 •수제 •구곡막이(골막이) •보막이 •바닥막이 •둑쌓기 •낮은 바닥막이(대공) •밑막이 •기슭막이 •계간 수로내기 +- - 산지비탈 안정·녹화공사 •비탈다듬기 •단쌓기 •단끊기 •선떼 붙이기 •땅속 흙막이 •조공 •누구막이 •줄떼 다지기 •흙막이 •평떼 붙이기 •돌쌓기·블록쌓기 •띠떼 심기 •돌 붙이기 •새심기 •축대벽 •비탈덮기 •수로내기 •등고선구공법 •속도랑내기(암거공사) •씨 뿌리기 •울짱얽기 •나무심기 +- - 절·성토비탈 안정·녹화공사 •힘줄박기 •식생공법 •격자틀 붙이기 •분사식 씨뿌리기 •앵커박기 •종비토 뿜어붙이기 •주입공사 •새집공법 •낙석방지망 덮기 •식생상공법 •낙석저지책 세우기 •소단상 객토식수공법 •콘크리트 뿜어붙이기 •차폐수벽공법 +- ○ 공종(공법)별 부호견취도 작성시 공종표시 부호는 다음과 같이 표시한다. + +![복구_그림5-3-2_주요재료의부호](<../pic/복구_그림5-3-2_주요재료의부호.png>) + +그림 5-3-2. 주요 재료의 부호 + +![복구_그림5-3-3_주요공종의부호_01](<../pic/복구_그림5-3-3_주요공종의부호_01.png>) + +![복구_그림5-3-3_주요공종의부호_02](<../pic/복구_그림5-3-3_주요공종의부호_02.png>) + +![복구_그림5-3-3_주요공종의부호_03](<../pic/복구_그림5-3-3_주요공종의부호_03.png>) + +그림 5-3-3. 주요 공종(공법)의 부호 + +### 다. 복구방법 + +채석지 안정·녹화공사는 복구준비 조치공사와 복구공사로 구분 실시한다. +#### 1) 복구준비 조치공사 + +채석지 안정·녹화공사를 가능하게 하기 위하여 복구준비 조치공사로 비탈다듬기공사와 잔벽소단설치공사를 한다. + +##### 가) 보전구역의 비탈다듬기공사 + +채석장과 인접지와의 사이에 있는 보전구역이 붕괴되지 않도록 비탈면 다듬기공사와 흙막이 공사를 실시해야 한다. + +- ○ 비탈면이 토사인 경우에는 사면상부에서 하부를 향하여 뜬돌(浮石) 등을 제거하여 비탈면을 편평하게 한다. +- ○ 비탈면이 암석인 경우에는 곡괭이, 빅 햄머(Big hammer) 또는 소형 착암기에 의하여 암석을 삭박하거나 낙석위험 암반은 제거한다. 끝 손질한 비탈면의 요철에 대해서는 암질에 따라 다르지만 요철높이 30cm 정도까지 다듬기공사를 하는 것이 좋다. +##### 나) 잔벽의 비탈다듬기 공사 + +노천채굴이 끝난 채석장의 잔벽은 잔벽면의 붕괴를 방지하기 위하여 암석의 풍화정도와 암석의 불연속면(절리, 균열부 등)의 방향 등에 따라 적당한 높이와 너비를 가진 소단(小段)을 설치해야 하며 적당한 기울기가 유지되도록 사면을 다듬는다. + +- ○ 채석장 잔벽의 비탈다듬기공사는 높이 10m 마다 2m 이상의 너비를 갖는 소단을 계단식으로 설치하며 잔벽의 경사도는 60°를 초과하지 않는 것을 원칙으로 하나 암질(암석의 풍화 정도)에 따라 다음과 같이 다르게 설치한다. 표 5-3-3. 암질에 따른 비탈경사와 소단폭 + +| 암질 (암석풍화정도) | 비탈 경사(°) | 소단폭(m) | 소단간 사거리(m) | +|---|---|---|---| +| 경 암 | 80 이하 | 2 이상 | 20 이하 | +| 보통암 | 75 이하 | 2 이상 | 20 이하 | +| 연 암 | 70 이하 | 2 이상 | 15 이하 | +| 토 사 | 45 이하 | 1∼2 | 5 이하 | + +- ○ 하부에 설치하는 소단의 너비는 상부에 설치하는 소단의 너비보다 넓게 해서 상부의 물매보다 하부의 물매를 완만하게 하며 잔벽의 높이가 높을 경우 상부로 갈수록 소단간 높이를 하부에서 보다 낮게 조성하는 것이 안정녹화 공사시공에 효과적이다. + +![복구_그림5-3-4_쇄골재채석장잔벽모형](<../pic/복구_그림5-3-4_쇄골재채석장잔벽모형.png>) + +잔벽의 평균 기울기 그림 5-3-4. 쇄골재 및 토목·매립용 채석장(계단식 채굴)에서의 잔벽설정 모형도 + +![복구_그림5-3-5_원석채석장잔벽모형](<../pic/복구_그림5-3-5_원석채석장잔벽모형.png>) + +잔벽의 평균 기울기 그림 5-3-5. 건축 및 수출용 원석 채석장(수직벽 및 계단식 채굴)에서의 잔벽설정 모형도 + +#### 2) 복구공사 + +채석적지를 ①보전구역, ②채굴잔벽, ③산물처리장 및 퇴적장구역과 ④진입로 등 기타구역으로 구분하여 안정공사를 실시한다. + +##### 가) 보전구역 안정·녹화공사 + +채석잔벽의 상부에 있는“보전구역”에는 주로 돌림수로와 흙막이공사로 사면을 안정시키고 새심기, 씨 뿌리기와 나무심기 등으로 녹화한다. 절개지 상단부에는 낙상 및 붕괴예방을 위하여 위해방지시설(철책) 및 산비탈 돌쌓기를 설치한다. + +##### 나) 채굴잔벽의 안정·녹화공사 + +채굴잔벽은 채석 후 암반절개 사면으로서“복구준비 조치공사”가 되어있지 않은 지역에서는 비탈다듬기공사를 통하여 소단을 설치하고 잔벽높이를 조성하여 안정·녹화공사에 기반을 조성한다. 소단상에는 객토 식수공법에 의하여 수목을 식재 녹화시키고 내측에 횡수로를 설치한다. 소단 간 사면에는 위치에 따라 종수로를 설치하고 암반사면의 요·철정도에 따라 새집공법이나 식생상공법에 의하여 녹화시키며 연약지반에 대하여는 돌 단쌓기로 사면을 안정시킨다. 이상의 공법으로 녹화가 불가능한 경질의 암반사면은 분사식 씨뿌리기나 종비토 뿜어붙이기에 의하여 사면을 녹화시킨다. 잔벽 하부에 낙석저지책 또는 차폐수벽공법이 적용되기도 한다. + +- ○ 소단의 높이는 토질에 따라 절개비탈면이 암반으로 형성된 지역은 직고 20m 이하 간격으로 하되 기슭막이 또는 산비탈돌쌓기·파식공·덩굴식물 등 녹화공법이 가능하도록 폭 2m 이상으로 소단을 설치한다. +- ○ 소단에는 식생의 녹화를 위하여 최소한 70cm 이상 객토를 실시한다. +- ○ 기슭막이 또는 산비탈 돌쌓기는 붕괴위험을 방지하기 위하여 야면석 또는 채석장의 폐석(L : 30∼45cm)으로 찰쌓기를 실시한다. +- ○ 소단내 객토의 유출방지를 위하여 소단내측에 암·명거시설을 하거나 찰쌓기 또는 혼합(떼+돌)쌓기 등으로 시설한다. +- ○ 절개비탈면의 표면에 요철이 많아 시공하기 쉬운 부분은 새집 공법, 식생상 공법 또는 종 비토 뿜어붙이기 공법으로 시공한다. +- ○ 안정된 암벽 절개지역은 절리방향이 비탈면방향과 거의 직각일 때는 토양이 붙을 수 있도록하고 종자 뿜어붙이기 또는 종비토 뿜어붙이기 공법으로 시공한다. +- ○ 절개사면·암반의 절리방향이 사면방향과 동일한 지역은 앵커박기 또는 록볼트공으로 암석붕괴를 방지한다. +##### 다) 산물처리장 및 퇴적장구역의 안정·녹화공사 + +채굴적지의 평탄부분에 대해서는 70cm 이상으로 객토한 후 파식에 의하여 녹화시키며 퇴적장의 불안정한 사면은 수로내기, 축대벽(옹벽), 기슭막이, 흙막이공사에 의하여 사면을 안정시킨다. + +- ○ 급경사지 (30°∼45°) +- - 직고 5m 이하 간격으로 심줄박기나 폭 1∼2m의 소단을 설치하고 단상에는 산비탈돌쌓기 또는 선떼붙이기를 시공한 후 씨뿌리기 또는 줄떼다지기로 피복한다. +- - 지표수 용출로 인한 침식이 우려될 때에는 소단상 후면에 수로내기(반원관등)를 하고 사면 길이가 20m 이상으로 긴 지역은 종수로를 설치하여 안전하게 유출시킨다. +- - 단간 비탈면에는 줄떼다지기·씨 뿌리기·새심기 등 조기피복하여 토사유출을 방지한다. +- - 최하단부는 콘크리트 축대벽·돌기슭막이 등을 시공하여 토압을 지탱시킨다. +- - 성토면적이 넓거나 성토량이 많은 경우에는 중복부에 땅속흙막이나 심줄박기를 알맞게 시공한다. +- - 암괴나 토사로 성토되었을 때는 점토로 복토하고 파식을 하거나 구덩이를 파고 충분한 객 토를 한 후 식재한다. +- - 단상에는 1.5m 간격으로 대묘(60cm 이상)를 식재하고 ha당 5,000본의 사방수종 또는 적지적수를 식재한다. +- ○ 완경사지 (30°미만) +- - 직고 5m 이하 간격으로 폭 1∼2m의 소단을 설치하고 단상에 흙막이 또는 선떼붙이기를 시공한 후 파종 또는 식재한다. +- - 흙쌓기 비탈면 길이가 길어 토압이 클때는 중복부위에 땅속흙막이, 심줄박기 등 알맞는 공법을 적용한다. +- - 단간 비탈면에는 줄떼다지기, 씨뿌리기, 새심기 등을 하여 피복한다. +- - 성토지 하단부는 토압지탱상 필요한 때는 콘크리트 축대벽·기슭막이·산비탈돌쌓기 등을 시공한다. +- - 암괴나 사토로 성토되었을 때는 점토로 복토하고 파식을 하거나 구덩이를 파고 충분한 객 토를 한 후 식재한다. +- - 평탄한 지역의 끝부분에는 둑을 1∼2m 높이로 설치하여 지표수가 성토지 법면으로 흐르지 않도록 한다. +- - 평탄지역은 사방 50m 간격으로 수로를 설치하고 수로합류지점은 누구막이를 시공한 후 하류로 원활하게 배수되도록 돌 수로를 시공한다. +- - 상록대묘(60cm 이상)를 ha당 5,000본의 사방수종 또는 적지적수를 식재한다. +##### 라) 진입로 기타구역에 대한 안정·녹화공사 + +주로 진입로등이 포함되는 완경사지로 계곡부의 붕괴우려지에는 계간부에 기슭막이, 골막이 등을 시공하여 계상과 산각을 고정하고 토·사력이 많이 유출될 우려가 있을 경우에는 사방댐을 시공하여 토석류를 유치 고정한다. 그외 사면에는 새심기, 줄 씨뿌리기와 나무심기로 녹화시킨다. 표 5-3-4. 경사도별 퇴적지(성토지) 안정·녹화공사 + +| 구 분 | 30°미만 | 30°∼45° | +|---|---|---| +| 상단부 | ·땅속흙막이 ·단끊기 ·돌림수로 ·선떼 붙이기 ·흙막이 ·조공 ·줄떼 다지기 ·새심기 ·파식 | ·땅속흙막이 ·단끊기 ·돌림수로 ·선떼 붙이기 ·흙막이 ·조공 ·줄떼 다지기 ·파식 | +| 중복부 | ·땅속흙막이 ·단끊기 ·돌림수로 ·선떼 붙이기 ·흙막이 ·조공 ·줄떼 다지기 ·새심기 ·파식 | ·심줄심기 ·땅속흙막이 ·단끊기 ·돌림수로 ·산비탈 돌쌓기 ·흙막이 ·선떼 붙이기 ·조공 ·줄떼 다지기 ·새심기 ·파식 | +| 하단부 | ·기슭막이 ·산비탈 돌쌓기 ·수로공 ·선떼붙이기 ·흙막이 ·줄떼 다지기 ·새심기 ·파식 | ·기슭막이 또는 축대벽 ·산비탈 돌쌓기 ·수로공 ·선떼 붙이기 ·흙막이 ·줄떼 다지기 ·새심기 ·파식 | + +![복구_그림5-3-6_채석잔벽안정녹화정면도](<../pic/복구_그림5-3-6_채석잔벽안정녹화정면도.png>) + +그림 5-3-6. 채석잔벽 안정·녹화공사 개념도 (정면도) + +![복구_그림5-3-7_채석잔벽안정녹화측면도](<../pic/복구_그림5-3-7_채석잔벽안정녹화측면도.png>) + +그림 5-3-7. 채석잔벽 안정·녹화공사 개념도 (측면도) + +표 5-3-5. 채석적지 표준유형에 대한 안정·녹화공사 표준공종(공법) 배치기준 + +| 구역 | 공종(공법) 및 규격 | 단위 | 수량 | +|---|---|---:|---:| +| 잔벽주위 토석구역(보전구역 600㎡) | 산비탈 돌쌓기(길이 4m×6개소, 높이 0.5m) | m | 24 | +| | 돌 수로내기 소형(0.3×0.9×0.3m) | m | 120 | +| | 새심기(새·솔새 등의 풀포기) | ㎡ | 100 | +| | 녹화식수공법(교목 수고 1m 이상) | 본 | 250 | +| 채굴잔벽비탈구역(사면적 3,500㎡, 평면적 1,700㎡) | 비탈 다듬기(3,500㎡×0.15m) | ㎥ | 520 | +| | 돌 단쌓기(높이 1m) | m | 100 | +| | 돌 수로내기 소형(0.3×0.9×0.3m, 종배수로) | m | 100 | +| | 새집공법(길이 3m×높이 1m 돌쌓기 구조) | 개소 | 50 | +| | 새집공법 녹화식수 | ㎥(객토)/본 | (45)/(300) | +| | 소단상 객토식수공법(소단연장 230m×너비 1m×두께 0.3m) | ㎥(객토)/본 | 70/(500) | +| | 객토자루새심기(20ℓ×150개) | ㎥(객토)/(자루·주) | 3/(600) | +| | 덩굴식물녹화공법(담쟁이·등나무 등) | 본 | (500) | +| | 차폐수벽공법(가로수 규격 대묘) | m | 50 | +| | * 분사식 씨뿌리기공법 | ㎡ | | +| | * 종비토 뿜어붙이기공법 | ㎡ | | +| 산물처리장·퇴적장구역(6,800㎡) | 비탈 다듬기(1,000㎡×0.2m) | ㎥ | 200 | +| | 돌 단쌓기(높이 1m) | m | 100 | +| | 산비탈 돌쌓기(높이 0.5m, 길이 4m×25개소) | m | 100 | +| | 옹벽(석축 높이 2m·대석 사용) | m | 30 | +| | 돌 수로내기 소형(0.3×0.9×0.3m) | m | 400 | +| | 돌 수로내기 중형(0.5×1.5×0.5m) | m | 50 | +| | 돌 조공(잡석 사용) | m | 300 | +| | 새심기(새·솔새 등의 풀포기) | ㎡ | 100 | +| | 줄 떼심기(반떼 사용) | m | 300 | +| | 줄 씨뿌리기(줄너비 0.2m×3,000m=600㎡) | ㎡ | 600 | +| | 녹화식수공법(교목 수고 1m 이상 대묘) | 본 | 3,490 | +| | 차폐수벽공법(가로수 규격 대묘) | m | 30 | +| | * 객토공사(6,800㎡×0.1m=680㎥/2) | ㎥ | 340 | +| 진입로·기타구역(900㎡) | 비탈 다듬기(900㎡×0.05m) | ㎥ | 50 | +| | 산비탈 돌쌓기(높이 0.5m, 길이 4m×8개소) | m | 32 | +| | 돌 수로내기 소형(0.3×0.9×0.3m) | m | 30 | +| | 새심기(새·솔새 등의 풀포기) | ㎡ | 50 | +| | 줄 씨뿌리기(줄너비 0.2m×300m) | ㎡ | 60 | +| | 녹화식수공법(교목 수고 1m 이상 대묘) | 본 | 300 | +| 합계 | 1ha당 묘목 식재본수(덩굴식물 제외) | 본 | (5,000) | + +※ `*` 공종은 도시지역 또는 특수한 경우에 한한다. + +※ 채석적지 면적 1ha 기준 + +#### 3) 공종(법)별 시공요령 + +채석적지 안정·녹화공사로 계간안정공사, 산지비탈 안정·녹화공사와 절·성토 안정·녹화공사가 있으며 이들 공사에 따라 많은 공종과 공법이 있으나“사방”공종·공법과 중복되므로 여기서는 채석지 안정·녹화공사에 주요 공종(공법)인 비탈 다듬기, 단끊기, 울짱얽기, 분사식 씨 뿌리기, 종비토 뿜어붙이기, 새집공법, 차폐수벽공법, 소단상 객토식수공법, 콘크리트 뿜어붙이기, 앵커박기, 낙석방지망 덮기, 낙석저지책 세우기 등에 대한 시공요령을 기술코자 한다. + +##### 가) 비탈다듬기 (뭉기기, 뭉개기, 整度工, 法切工) + +비탈다듬기는 불규칙한 사면 또는 사면의 불안정한 토석층을 완화하여 안정된 비탈면을 조성할 목적으로 시공하는데 경사가 심한 비탈면을 일정한 경사도로 유지하도록 땅깎기를 하며 깊은 곳은 메우는 공사를 말한다. 일반적으로 비탈다듬기 기울기는 토사의 안식각(安息角)을 한계점으로 하는 것이 안전하다. + +- ○ 시공장소 기복이 심한 산복비탈 또는 절·성토사면, 암반 절개사면 +- ○ 시공요령 +- - 사면기울는 대상지의 종단면도를 작성하여 결정하되 지질, 경사, 주변의 지형과 공법 등에 따라 조화있게 결정한다. +- - 사면기울가 급한지역은 선 떼붙이기 및 산비탈 돌쌓기 등으로 조정한다. +- - 비탈다듬기는 산꼭대기부터 시작하여 산아래로 진행하며 부토가 많은 지역은 속도랑공사 및 땅속흙막이 공사를 시공한 후 비탈 다듬기를 하는 것이 효과적이다. +- - 비탈 다지기공사후에는 뜬 흙이 비탈에 안착할 때까지 일정기간은 비 바람에 노출되어야하며 그 후에 다른 공종을 시공한다. +- - 비탈 다지기공사에 의해서 잡목이나 그루터기가 매몰되는 경우에는 비탈다듬기 공사전에 이것을 미리 정리하여 매몰되지 않도록 시공한다. +- - 채굴공사 마지막 단계에 이를 때에는 비탈다듬기공사를 동시에 진행하면서 채굴하여 채굴 작업 이후의 비탈 녹화공사를 위한 비탈다듬기 공사가 용이하게 진행될 수 있도록 한다. 채굴면의 凹凸면에 대개 30cm 이내로 비교적 평편하게 다듬는다. +- - 채석지 보전구역과 퇴적장 비탈면에 대해서도 가급적 최대로 안정 경사도가 되도록 균질하게 다듬고, 수로공사의 위치 등에 대해서는 미리 터 파기공사를 한다. +- - 기계에 의한 비탈다듬기공사는 토사의 절취량이 많고, 지형적으로도 기계에 의한 시공이 가능한 경우에 채용한다. 주요한 사용기계에는 불도져, 백호우, 트랙터셔블, 굴착기 등이 있으며, 암반에서는 착암기 등 + +으로 다듬어야 한다. 기종 및 규격은 시공지의 지질, 지형에 적합한 것을 선정해야 한다. + +##### 나) 단끊기 (비탈단끊기, 계단끊기, 階段工, 段切工) + +비탈다듬기 공사를 실시한 사면에 수평단을 끊고 초·목본류를 파식하여 황폐된 나지에 식생을 조성하려는 기초공사이다. 산복사면 길이를 줄이고 수평면을 유지케 하므로써 사면에 유하 되는 토사를 저지하고 유수를 분산시키므로 침식을 방지하는 동시 식생조성에 필요한 기반조성을 위한 것으로 선떼붙이기나 조공, 흙포대 흙막이 및 씨뿌리기를 병행 실시하는 공종이다. 수토보전(水土保全) 공사로서도 계획·시공한다. + +- ○ 시공장소 비탈다듬기 공사가 끝난 산복사면 +- ○ 시공요령 +- - 단끊기는 상부로부터 하부로 향하여 시공하며 단상(段上)에는 될 수 있는 대로 재래의 표토를 존치하도록 한다. +- - 단 끊기는 수평으로 실시하며 단폭(계단나비)은 일반적으로 50∼70cm으로 하지만 비탈면의 기울기가 급할 때에는 계단폭을 좁게하여 상하 계단간의 비탈면 기울기를 완만하게 한다. +- - 단끊기에 의하여 생산되는 토사의 처리는 시공경비와 관계가 많으므로 절취토사의 이동은 최소한도로 한다. +##### 다) 단쌓기(段積工) + +경사가 급한지역에서 비탈다듬기 공사나 단끊기 공사로 생산된 토사가 많은 사면을 조기에 안정·녹화하기 위하여 높이와 너비가 일정한 계단을 연속적으로 붙여 구축하는 비탈안정 녹화 공종으로 사용재료에 따라 떼, 돌, 돌·떼, 짚망, 흙포대, 합성재 단쌓기가 있다. + +- ○ 시공장소급경사지로 부토(浮土)가 많은 사면 +- ○ 시공요령 +- - 용수로 인하여 붕괴위험성이 있는 장소에서는 속도랑내기를, 시공높이가 높은경우에는 땅 속흙막이 또는 흙막이 공작물을 겸하여 시공한다. +- - 떼단쌓기(段積立芝工, Stepped mini-terrace sodding works)는 단끊기 및 떼붙이는 요령은 선떼붙이기(7급)와 같으며 떼단의 높이와 너비는 30cm 정도로 한다. 동일 비탈면에 연속적으로 시공하는 떼단의 수는 5단을 초과하지 않도록 해야하며 5단 이상 + +일 경우에는 5단을 쌓은 후 돌쌓기와 같은 보다 견고한 흙막이 공작물을 시공하고 떼단의 나비보다 더 넓은 정도의 작은단(小段)을 설치한 다음 그위에 떼단쌓기를 계속한다. + +- - 돌이 많은 지역에서는 떼대신 돌(잡석)을 이용하여 돌 단쌓기(段積石工)를 한다. +- - 석재가 많은 지역에서는 떼대신 석재로 단(段)을 쌓으면서 돌 사이에 떼를 넣어 돌과 떼가 일체가 되도록“돌·떼단쌓기"를 시공하는 것이 경제적이고 효과적이다. +- - 떼를 구하기 어려운 장소에서 떼 대신 볏짚을 위 아래로 구부려 넣고 단 위에는 묘목을 심고 풀씨를 파종하는“볏짚 단쌓기"를 한다. + +![복구_그림5-3-8-9_떼단및볏짚단쌓기](<../pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png>) + +<측면도> <측면도>그림 5-3-8. 떼단쌓기 그림 5-3-9. 볏짚 단쌓기 + +##### 라) 조공(條工, 筋工) + +황폐사면(각종 붕괴지 절·성토사면)에 나무와 풀을 파식하기 위하여 산복비탈면에 수평으로 계단을 끊고 앞면에는 떼, 새포기, 잡석 등으로 낮게 쌓아 계단을 보호하며 뒷면에는 흙을 채워서 파식상을 조성한 후 파식하는 산복녹화공법이다. + +--- + +- 기준 파일: `산림과임업기술(임도).pdf` +- 원문 위치: PDF 157~176쪽 (인쇄면 527~546쪽) diff --git a/resources/knowledge/original/산림과임업기술(임도)/_index.md b/resources/knowledge/original/산림과임업기술(임도)/_index.md new file mode 100644 index 00000000..e643e35e --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/_index.md @@ -0,0 +1,16 @@ +# 산림과임업기술(임도) — 자료 목록 + +> 출처 미상 과거 기술자료다. 현행 설계 기준이 아니며, 사용 제한과 품질점검 결과는 [_meta.md](<_meta.md>)를 먼저 확인한다. + +- [원본 PDF](<원본/산림과임업기술(임도).pdf>) +- **2. 임도** + - [1. 총론](<2. 임도/1. 총론.md>) — PDF 1~7쪽, 인쇄면 371~377쪽 + - [2. 임도구조](<2. 임도/2. 임도구조.md>) — PDF 8~18쪽, 인쇄면 378~388쪽 + - [3. 임도계획](<2. 임도/3. 임도계획.md>) — PDF 19~47쪽, 인쇄면 389~417쪽 + - [4. 노선측량](<2. 임도/4. 노선측량.md>) — PDF 48~86쪽, 인쇄면 418~456쪽 + - [5. 설계](<2. 임도/5. 설계.md>) — PDF 87~117쪽, 인쇄면 457~487쪽 + - [6. 시공](<2. 임도/6. 시공.md>) — PDF 118~154쪽, 인쇄면 488~524쪽 + - [7. 유지보수](<2. 임도/7. 유지보수.md>) — PDF 155~156쪽, 인쇄면 525~526쪽 +- **3. 훼손지복구** + - [1. 토석채취지복구](<3. 훼손지복구/1. 토석채취지복구.md>) — PDF 157~176쪽, 인쇄면 527~546쪽 +- [추출 그림](pic/) diff --git a/resources/knowledge/original/산림과임업기술(임도)/_meta.md b/resources/knowledge/original/산림과임업기술(임도)/_meta.md new file mode 100644 index 00000000..79cd7c43 --- /dev/null +++ b/resources/knowledge/original/산림과임업기술(임도)/_meta.md @@ -0,0 +1,38 @@ +# 산림과임업기술(임도) + +> **구조 교정 완료 (2026-09-06)**: 수식의 분수·위첨자, 표의 병합 셀, 그림 범위와 링크를 원문과 대조해 교정했다. 전체 페이지 이미지는 Markdown에 넣지 않고 필요한 도면·그래프 범위만 수록했다. + +- 원본 파일: `원본/산림과임업기술(임도).pdf` +- 수록 범위: PDF 176쪽, 인쇄면 371~546쪽 +- 확인된 장절: `2. 임도` 7개 절과 `3. 훼손지복구`의 `토석채취지복구` +- 발행처·발행연도·저자: **근거 미확인** — 제공된 발췌본에 표지·판권·서지정보 없음 +- 성격: 과거 임도 기술 해설 발췌본(보조 참고) +- 변환본 목록: [_index.md](<_index.md>) + +## 적용 제한 + +- 폐지된 「산림법」 조문, 1·2급 임도 구분, VAX·FRNET 사례 등이 수록되어 현행 기준으로 사용할 수 없다. +- 수치·공식·시설기준은 현행 법령·행정규칙과 `technical_info/`를 우선 확인한다. +- 이 문서의 값은 비교·연혁 확인용 후보일 뿐 프로그램 기본값이나 확정값으로 채택하지 않는다. +- 발행정보가 확인되기 전에는 다른 문서의 직접 근거로 승격하지 않는다. + +## 변환 및 품질점검 (2026-09-06) + +- `original/_pipeline/pdf2md.py`로 1차 변환한 뒤 원문의 장·절에 따라 Markdown 8개로 분할하고 제목 계층을 복원했다. +- 각 PDF 쪽의 인쇄면 번호(371~546)와 정확히 일치하는 단독 숫자만 본문에서 제외했다. 문장·수식·표 안의 숫자는 제거하지 않았다. +- 정규화한 원문 3,079개 검증 줄의 앞 12자를 분할본 전체와 대조한 결과 누락 0개(커버리지 100%)다. +- 도면·그래프 127개를 추출했으며 Markdown 링크 누락과 미참조 파일은 각각 0개다. +- PDF 글자의 실제 좌표 간격으로 원문 어절 공백을 복원했다. PDF 줄 끝 경계는 임시 표식으로 보존한 뒤 한국어 형태소 분석으로 공백 유무만 판정했으며, 기존 어절 내부의 공백은 임의 변경하지 않았다. +- 띄어쓰기 복원 전후에 공백을 제외한 문자열이 동일한지 검사해 본문 문자 변경이 없음을 확인했다. +- 일부 PDF 색상 프로필 오류 메시지가 있었으나 본문 추출과 그림 저장은 완료됐다. +- 각 문서 끝에 들어갔던 PDF 전체 176쪽의 통페이지 이미지 부록은 제거했다. 도면·그래프만 내용 경계로 잘라 의미를 나타내는 파일명으로 바꾸고 해당 본문 위치에 배치했다. + +### 구조 대조 결과와 사용법 + +- PDF의 2차원 배치 때문에 선형화됐던 주요 분수·위첨자·병합 표를 원문 구조에 맞춰 복원했다. +- 수식 조각을 제목으로 오인한 9개 항목을 일반 본문으로 바로잡고, `표 5-2-2`의 앞·뒤 바퀴거리 값을 한 셀 안에서 구분했다. +- 인쇄면 458~459쪽의 트래버어스 계산을 원문과 직접 대조하여 `표 5-2-29`, 폐합오차·폐합비 식, 측선별 위거·경거 조정량, `표 5-2-30`을 행별 구조로 복원했다. +- `5. 설계.md` 정밀 점검에서 `표 5-2-32`~`표 5-2-36`의 합쳐진 측점 데이터를 행별로 복원하고, 점고법 체적식·시공면고식·중기 손료의 지수·Box 귀면의 제곱근을 원문 배치에 따라 교정했다. 벡터 구조도·전산처리 흐름도를 표로 오인한 블록은 제거하고 도면 범위만 잘라 배치했다. +- 원문의 구식 이중선 등호 글리프가 Markdown 194줄에 498개 남아 있음을 확인하여 모두 일반 등호 `=`로 통일했다. +- 수식·표의 의미는 원본 PDF와 직접 대조하며, Markdown에는 통페이지가 아닌 필요한 표·수식·도면 범위만 사용한다. +- 최종 구조 검사에서 Markdown 표 열 수 오류 0개, 그림 링크 오류 0개, 미참조·중복 그림 0개, 금지 등호 글리프(`〓`, `=`)와 잘못 인식된 각도 마침표(`。`) 0개를 확인했다. 의미와 배치는 원본 PDF를 최종 근거로 삼는다. diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png new file mode 100644 index 00000000..920fb364 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-25_삼각좌표에따른흙의분류도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-26_통일분류법을위한소성도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-26_통일분류법을위한소성도.png new file mode 100644 index 00000000..32125ba4 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-26_통일분류법을위한소성도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-27_중심선과영선의위치비교.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-27_중심선과영선의위치비교.png new file mode 100644 index 00000000..00f3caf9 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-27_중심선과영선의위치비교.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-28_경사지임도의영선과기면.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-28_경사지임도의영선과기면.png new file mode 100644 index 00000000..fc13bd6b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-28_경사지임도의영선과기면.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-29_표적판과지지봉.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-29_표적판과지지봉.png new file mode 100644 index 00000000..f1cbd166 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-29_표적판과지지봉.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-30_토우로프.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-30_토우로프.png new file mode 100644 index 00000000..75488458 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-30_토우로프.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-31_개산물매와수정.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-31_개산물매와수정.png new file mode 100644 index 00000000..2e0d65d3 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-31_개산물매와수정.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png new file mode 100644 index 00000000..2ac0e342 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-32_굴곡부의영선과중심선.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png new file mode 100644 index 00000000..d7a2a468 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-33_능선부와계곡부의종단물매.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-34_방위각법트래버어스측량.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-34_방위각법트래버어스측량.png new file mode 100644 index 00000000..24395d65 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-34_방위각법트래버어스측량.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-35_편각법트래버어스측량.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-35_편각법트래버어스측량.png new file mode 100644 index 00000000..97a6fe34 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-35_편각법트래버어스측량.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-36_내각법트래버어스측량.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-36_내각법트래버어스측량.png new file mode 100644 index 00000000..8eeb7b41 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-36_내각법트래버어스측량.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-37_트래버어스측정치조건.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-37_트래버어스측정치조건.png new file mode 100644 index 00000000..03c515a4 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-37_트래버어스측정치조건.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png new file mode 100644 index 00000000..ac493210 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png new file mode 100644 index 00000000..4fed8da9 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-38_트래버어스방위각산출_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-39_방위각과방위의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-39_방위각과방위의관계.png new file mode 100644 index 00000000..943f73e3 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-39_방위각과방위의관계.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-40_편각과방위의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-40_편각과방위의관계.png new file mode 100644 index 00000000..63f411e9 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-40_편각과방위의관계.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-41_내각과방위의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-41_내각과방위의관계.png new file mode 100644 index 00000000..242cd40b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-41_내각과방위의관계.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-42_위거와경거의관계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-42_위거와경거의관계.png new file mode 100644 index 00000000..a3e9f82d Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-42_위거와경거의관계.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_01.png new file mode 100644 index 00000000..d77de968 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_02.png new file mode 100644 index 00000000..e3a2c02e Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-43_곡선의종류_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-44_곡선부의구조.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-44_곡선부의구조.png new file mode 100644 index 00000000..d3a9c5e3 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-44_곡선부의구조.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-45_두절선상교점설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-45_두절선상교점설치법.png new file mode 100644 index 00000000..126d6415 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-45_두절선상교점설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-46_편각에의한곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-46_편각에의한곡선설치법.png new file mode 100644 index 00000000..88acfb75 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-46_편각에의한곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png new file mode 100644 index 00000000..92430abf Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-47_시준선장애물곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-48_교점접근불가곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-48_교점접근불가곡선설치법.png new file mode 100644 index 00000000..89f2cc94 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-48_교점접근불가곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-49_BC와EC장애물곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-49_BC와EC장애물곡선설치법.png new file mode 100644 index 00000000..4c4b6fff Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-49_BC와EC장애물곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-50_중앙종거곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-50_중앙종거곡선설치법.png new file mode 100644 index 00000000..419eba53 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-50_중앙종거곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-51_평행한두직선반향곡선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-51_평행한두직선반향곡선.png new file mode 100644 index 00000000..74180c8e Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-51_평행한두직선반향곡선.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png new file mode 100644 index 00000000..45a44ec7 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-52_평행하지않은두직선반향곡선.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-53_복심곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-53_복심곡선설치법.png new file mode 100644 index 00000000..d78de18a Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-53_복심곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-54_배향곡선의간격.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-54_배향곡선의간격.png new file mode 100644 index 00000000..78ac3fb7 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-54_배향곡선의간격.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-55_배향곡선설치법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-55_배향곡선설치법.png new file mode 100644 index 00000000..4b7cfe2f Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-55_배향곡선설치법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png new file mode 100644 index 00000000..dd031f61 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-56_종단곡선길이와가시거리.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png new file mode 100644 index 00000000..024030a3 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-57_종단곡선길이별가시거리.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-58_종단곡선측점종횡거.png b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-58_종단곡선측점종횡거.png new file mode 100644 index 00000000..ac783576 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/노선측량_그림5-2-58_종단곡선측점종횡거.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-1_채석적지부위별명칭.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-1_채석적지부위별명칭.png new file mode 100644 index 00000000..4eda46c2 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-1_채석적지부위별명칭.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-2_주요재료의부호.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-2_주요재료의부호.png new file mode 100644 index 00000000..c3ef5b4f Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-2_주요재료의부호.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_01.png new file mode 100644 index 00000000..c3ba97c9 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_02.png new file mode 100644 index 00000000..83a7b78a Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_03.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_03.png new file mode 100644 index 00000000..8386310e Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-3_주요공종의부호_03.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-4_쇄골재채석장잔벽모형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-4_쇄골재채석장잔벽모형.png new file mode 100644 index 00000000..ed11700b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-4_쇄골재채석장잔벽모형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-5_원석채석장잔벽모형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-5_원석채석장잔벽모형.png new file mode 100644 index 00000000..beb4d9ae Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-5_원석채석장잔벽모형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-6_채석잔벽안정녹화정면도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-6_채석잔벽안정녹화정면도.png new file mode 100644 index 00000000..48a862e7 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-6_채석잔벽안정녹화정면도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-7_채석잔벽안정녹화측면도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-7_채석잔벽안정녹화측면도.png new file mode 100644 index 00000000..7a3433ab Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-7_채석잔벽안정녹화측면도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png new file mode 100644 index 00000000..54b58194 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/복구_그림5-3-8-9_떼단및볏짚단쌓기.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-59_합경거와합위거.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-59_합경거와합위거.png new file mode 100644 index 00000000..353b24a0 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-59_합경거와합위거.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-60_탄젠트값트래버어스제도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-60_탄젠트값트래버어스제도.png new file mode 100644 index 00000000..473bed92 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-60_탄젠트값트래버어스제도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-61_삼각함수값트래버어스제도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-61_삼각함수값트래버어스제도.png new file mode 100644 index 00000000..b9f4e54b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-61_삼각함수값트래버어스제도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-62_평면도작성예.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-62_평면도작성예.png new file mode 100644 index 00000000..0d67ddd8 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-62_평면도작성예.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-63_종단면도작성예.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-63_종단면도작성예.png new file mode 100644 index 00000000..48e7c6c4 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-63_종단면도작성예.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-64_평면선형과종단선형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-64_평면선형과종단선형.png new file mode 100644 index 00000000..714fe08b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-64_평면선형과종단선형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-66_구조물도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-66_구조물도.png new file mode 100644 index 00000000..4df18b71 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-66_구조물도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-67_표준도작성예.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-67_표준도작성예.png new file mode 100644 index 00000000..67876fb2 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-67_표준도작성예.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-68_용지도작성예.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-68_용지도작성예.png new file mode 100644 index 00000000..aa00ed55 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-68_용지도작성예.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-69_사각주의점고.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-69_사각주의점고.png new file mode 100644 index 00000000..fc388b02 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-69_사각주의점고.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-70_Box단면도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-70_Box단면도.png new file mode 100644 index 00000000..ec53b2bc Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-70_Box단면도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-71_유토곡선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-71_유토곡선.png new file mode 100644 index 00000000..c80b08ac Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-71_유토곡선.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-72_FRNET흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-72_FRNET흐름도.png new file mode 100644 index 00000000..bc6ca0bc Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-72_FRNET흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-73_STmate흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-73_STmate흐름도.png new file mode 100644 index 00000000..5051e5dc Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-73_STmate흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-74_종단면도흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-74_종단면도흐름도.png new file mode 100644 index 00000000..d2e8f24f Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-74_종단면도흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-75_횡단면도흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-75_횡단면도흐름도.png new file mode 100644 index 00000000..d54b1243 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-75_횡단면도흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-76_토적계산흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-76_토적계산흐름도.png new file mode 100644 index 00000000..1bfead59 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-76_토적계산흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-77_내역서흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-77_내역서흐름도.png new file mode 100644 index 00000000..35f6133b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-77_내역서흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-78_Earthwork흐름도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-78_Earthwork흐름도.png new file mode 100644 index 00000000..6030af97 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/설계_그림5-2-78_Earthwork흐름도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-100_개거의종류와모형2.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-100_개거의종류와모형2.png new file mode 100644 index 00000000..0d57ac1b Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-100_개거의종류와모형2.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-101_사면배수시설.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-101_사면배수시설.png new file mode 100644 index 00000000..24daf317 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-101_사면배수시설.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-102_돌망태사용예.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-102_돌망태사용예.png new file mode 100644 index 00000000..bc08eb68 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-102_돌망태사용예.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-79_본바닥상태와절취사면.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-79_본바닥상태와절취사면.png new file mode 100644 index 00000000..2decd8b0 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-79_본바닥상태와절취사면.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-81_편절토편성토접속횡단면.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-81_편절토편성토접속횡단면.png new file mode 100644 index 00000000..0640fd0c Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-81_편절토편성토접속횡단면.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-82_절토성토접속종단면.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-82_절토성토접속종단면.png new file mode 100644 index 00000000..9b596c01 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-82_절토성토접속종단면.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-84_보조기층두께설계곡선.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-84_보조기층두께설계곡선.png new file mode 100644 index 00000000..94b41f47 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-84_보조기층두께설계곡선.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-85_콘크리트포장줄눈.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-85_콘크리트포장줄눈.png new file mode 100644 index 00000000..e40e1891 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-85_콘크리트포장줄눈.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-86_블록설치방법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-86_블록설치방법.png new file mode 100644 index 00000000..f5ec6a46 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-86_블록설치방법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-87_자갈도시공법.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-87_자갈도시공법.png new file mode 100644 index 00000000..561dce30 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-87_자갈도시공법.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_01.png new file mode 100644 index 00000000..fdaab4c0 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_02.png new file mode 100644 index 00000000..bf7bb72a Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-88_교대와교각구조_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-89_교대의종류.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-89_교대의종류.png new file mode 100644 index 00000000..6d9650e4 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-89_교대의종류.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_01.png new file mode 100644 index 00000000..af89cdd8 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_02.png new file mode 100644 index 00000000..de5cb703 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_03.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_03.png new file mode 100644 index 00000000..7a9a978e Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_03.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_04.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_04.png new file mode 100644 index 00000000..87289c5d Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-90_교각의종류_04.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_01.png new file mode 100644 index 00000000..41da4dae Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_02.png new file mode 100644 index 00000000..251e258d Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_03.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_03.png new file mode 100644 index 00000000..1888d337 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_03.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_04.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_04.png new file mode 100644 index 00000000..f898fe98 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_04.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_05.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_05.png new file mode 100644 index 00000000..602a4bf6 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_05.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_06.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_06.png new file mode 100644 index 00000000..4f3ece95 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_06.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_07.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_07.png new file mode 100644 index 00000000..55d68478 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_07.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_08.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_08.png new file mode 100644 index 00000000..87fcf703 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_08.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_09.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_09.png new file mode 100644 index 00000000..d111ee20 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-91_교량의종류_09.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-92_옹벽의종류.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-92_옹벽의종류.png new file mode 100644 index 00000000..693d124c Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-92_옹벽의종류.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-93_임도배수의종류.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-93_임도배수의종류.png new file mode 100644 index 00000000..18826be1 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-93_임도배수의종류.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-94_막파기측구.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-94_막파기측구.png new file mode 100644 index 00000000..02a676c6 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-94_막파기측구.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-95_붙임측구.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-95_붙임측구.png new file mode 100644 index 00000000..121155bd Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-95_붙임측구.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-96_콘크리트측구.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-96_콘크리트측구.png new file mode 100644 index 00000000..089fe589 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-96_콘크리트측구.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-97_횡단배수구와측구배치.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-97_횡단배수구와측구배치.png new file mode 100644 index 00000000..ac456219 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-97_횡단배수구와측구배치.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-98_암거의모형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-98_암거의모형.png new file mode 100644 index 00000000..8a0f3edc Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-98_암거의모형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-99_개거의종류와모형1.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-99_개거의종류와모형1.png new file mode 100644 index 00000000..50ef21bb Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_그림5-2-99_개거의종류와모형1.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_01.png new file mode 100644 index 00000000..052b2174 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_02.png new file mode 100644 index 00000000..d76a9f29 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_03.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_03.png new file mode 100644 index 00000000..779b1073 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_03.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_04.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_04.png new file mode 100644 index 00000000..04d0248c Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_04.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_05.png b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_05.png new file mode 100644 index 00000000..9a8f43da Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/시공_옹벽하중도_05.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-10_중경사지역의계곡임도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-10_중경사지역의계곡임도.png new file mode 100644 index 00000000..a9e7e894 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-10_중경사지역의계곡임도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-11_편평한주계곡의계곡임도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-11_편평한주계곡의계곡임도.png new file mode 100644 index 00000000..fdb24186 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-11_편평한주계곡의계곡임도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-12_완경사지형의평형노망.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-12_완경사지형의평형노망.png new file mode 100644 index 00000000..609067cc Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-12_완경사지형의평형노망.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-13_급경사긴사면의사행형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-13_급경사긴사면의사행형.png new file mode 100644 index 00000000..dd261810 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-13_급경사긴사면의사행형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-14_능선임도의어골형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-14_능선임도의어골형.png new file mode 100644 index 00000000..dad5df92 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-14_능선임도의어골형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-15_능선임도형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-15_능선임도형.png new file mode 100644 index 00000000..d0daba1c Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-15_능선임도형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-16_산정부순환임도형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-16_산정부순환임도형.png new file mode 100644 index 00000000..7f5c1d47 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-16_산정부순환임도형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-17_계곡분지의순환임도형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-17_계곡분지의순환임도형.png new file mode 100644 index 00000000..5d43a8c1 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-17_계곡분지의순환임도형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-18_반대사면으로부터의임도이용형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-18_반대사면으로부터의임도이용형.png new file mode 100644 index 00000000..dcc96f03 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-18_반대사면으로부터의임도이용형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-19_양각기에의한물매설정.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-19_양각기에의한물매설정.png new file mode 100644 index 00000000..9e2cc557 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-19_양각기에의한물매설정.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-20_양각기사용예.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-20_양각기사용예.png new file mode 100644 index 00000000..c393e8b1 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-20_양각기사용예.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-21_임도망계획도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-21_임도망계획도.png new file mode 100644 index 00000000..22570013 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-21_임도망계획도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-23_노망배치형태별개발지수.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-23_노망배치형태별개발지수.png new file mode 100644 index 00000000..7efa64a1 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-23_노망배치형태별개발지수.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-24_임도운반비의추정도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-24_임도운반비의추정도.png new file mode 100644 index 00000000..32831bd6 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-24_임도운반비의추정도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-7_적정임도가격산출모식도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-7_적정임도가격산출모식도.png new file mode 100644 index 00000000..01a327d7 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-7_적정임도가격산출모식도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-8_ha당임목축적별적정임도밀도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-8_ha당임목축적별적정임도밀도.png new file mode 100644 index 00000000..ab6c9fc2 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-8_ha당임목축적별적정임도밀도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-9_급경사지역의계곡임도.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-9_급경사지역의계곡임도.png new file mode 100644 index 00000000..de004fd8 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도계획_그림5-2-9_급경사지역의계곡임도.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-4_자동차바퀴의구동형.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-4_자동차바퀴의구동형.png new file mode 100644 index 00000000..aecb0063 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-4_자동차바퀴의구동형.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-5_완화구간의형태.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-5_완화구간의형태.png new file mode 100644 index 00000000..89d1cb39 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-5_완화구간의형태.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_01.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_01.png new file mode 100644 index 00000000..854124a4 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_01.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_02.png b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_02.png new file mode 100644 index 00000000..e70cbf50 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/임도구조_그림5-2-6_굴곡부의가시거리확보방법_02.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-1_임도의기능과이용특성.png b/resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-1_임도의기능과이용특성.png new file mode 100644 index 00000000..f3ad88f2 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-1_임도의기능과이용특성.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-2_임내생산물의운송체계.png b/resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-2_임내생산물의운송체계.png new file mode 100644 index 00000000..d94d82c2 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/pic/총론_그림5-2-2_임내생산물의운송체계.png differ diff --git a/resources/knowledge/original/산림과임업기술(임도)/원본/산림과임업기술(임도).pdf b/resources/knowledge/original/산림과임업기술(임도)/원본/산림과임업기술(임도).pdf new file mode 100644 index 00000000..7f3d2e28 Binary files /dev/null and b/resources/knowledge/original/산림과임업기술(임도)/원본/산림과임업기술(임도).pdf differ diff --git a/resources/template_2dDrawing/00_template_A1.json b/resources/template_2dDrawing/00_template_A1.json index 5ccc8f9c..d0b83d57 100644 --- a/resources/template_2dDrawing/00_template_A1.json +++ b/resources/template_2dDrawing/00_template_A1.json @@ -1,1352 +1,1400 @@ { - "format": 6, - "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", - "entities": [ - { - "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{도면명}}", - "basePoint": { - "x": 728.9614, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#f5f7fa", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "1ab288ea-51c9-538f-987b-f9000f22b5a3", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{도면번호}}", - "basePoint": { - "x": 794.15392, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#f5f7fa", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "b9379457-d6ba-5e08-b41d-9ca7e5922fd1", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{공사명}}", - "basePoint": { - "x": 172.993229, - "y": 26.184925 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dee4b013-13c4-56fb-9bb6-0b0bff0389cc", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.884559874108106, - "y": 566.9570935802498 - }, - "endPoint": { - "x": 811.8687906157556, - "y": 566.9570935802498 - } - } - }, - { - "id": "56890fb0-b2f1-53c1-9c8e-7d983b5d54dd", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ + "format": 6, + "source": "00_templete_A1.dxf (남의 프로젝트 자료 제거 · 플레이스홀더화)", + "entities": [ { - "id": "f2cc70a6-3acd-5128-9623-57c9330c09cb", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 811.868791, - "y": 16.957051 - }, - "endPoint": { - "x": 811.868791, - "y": 566.957094 + "id": "4afa84ae-9c15-50ec-8a76-db87d04d6311", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{도면명}}", + "basePoint": { + "x": 728.9113785811373, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 90.0, + "boxHeight": 22.0 + } } - } }, { - "id": "e490918e-af37-5fe6-abf9-37919db79b53", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 811.868791, - "y": 566.957094 - }, - "endPoint": { - "x": 41.868791, - "y": 566.957094 + "id": "1ab288ea-51c9-538f-987b-f9000f22b5a3", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{도면번호}}", + "basePoint": { + "x": 792.890084790497, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 37.957, + "boxHeight": 22.0 + } } - } }, { - "id": "d8f3f7f4-d904-56f7-a649-80ea78af8428", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 566.957094 - }, - "endPoint": { - "x": 41.868791, - "y": 16.957051 + "id": "b9379457-d6ba-5e08-b41d-9ca7e5922fd1", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{공사명}}", + "basePoint": { + "x": 116.86879080885097, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 150.0, + "boxHeight": 22.0 + } } - } }, { - "id": "ea23e6e1-d7f6-55e0-8d4f-8a10e74bdbf0", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 16.957051 - }, - "endPoint": { - "x": 811.868791, - "y": 16.957051 + "id": "dee4b013-13c4-56fb-9bb6-0b0bff0389cc", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.884559874108106, + "y": 566.9570935802498 + }, + "endPoint": { + "x": 811.8687906157556, + "y": 566.9570935802498 + } } - } - } - ] - }, - { - "id": "9b11da77-7091-5651-86cc-b8cc903e5b8a", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868790618179396, - "y": 38.95705092559213 - }, - "endPoint": { - "x": 811.8845598717162, - "y": 38.95705092559213 - } - } - }, - { - "id": "05bdef94-27ad-5efe-a790-1dc4bf7d5483", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ - { - "id": "5f5bac49-6b35-58a5-96fb-0e7823ac9d5c", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 41.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 191.868791, - "y": 46.957051 - } - } }, { - "id": "68f2df6f-bbe5-5686-bfd4-f7d83674c9f7", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 191.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 311.868791, - "y": 46.957051 - } - } + "id": "56890fb0-b2f1-53c1-9c8e-7d983b5d54dd", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "f2cc70a6-3acd-5128-9623-57c9330c09cb", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 811.868791, + "y": 16.957051 + }, + "endPoint": { + "x": 811.868791, + "y": 566.957094 + } + } + }, + { + "id": "e490918e-af37-5fe6-abf9-37919db79b53", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 811.868791, + "y": 566.957094 + }, + "endPoint": { + "x": 41.868791, + "y": 566.957094 + } + } + }, + { + "id": "d8f3f7f4-d904-56f7-a649-80ea78af8428", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 566.957094 + }, + "endPoint": { + "x": 41.868791, + "y": 16.957051 + } + } + }, + { + "id": "ea23e6e1-d7f6-55e0-8d4f-8a10e74bdbf0", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 16.957051 + }, + "endPoint": { + "x": 811.868791, + "y": 16.957051 + } + } + } + ] }, { - "id": "b171f15b-9fef-51bb-a8ab-a8b34e1d3b69", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 311.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 431.868791, - "y": 46.957051 + "id": "9b11da77-7091-5651-86cc-b8cc903e5b8a", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868790618179396, + "y": 38.95705092559213 + }, + "endPoint": { + "x": 811.8845598717162, + "y": 38.95705092559213 + } } - } }, { - "id": "e65dc083-9a58-565c-9646-8c42ea6374f4", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 431.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 481.868791, - "y": 46.957051 - } - } + "id": "05bdef94-27ad-5efe-a790-1dc4bf7d5483", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "5f5bac49-6b35-58a5-96fb-0e7823ac9d5c", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 41.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 191.868791, + "y": 46.957051 + } + } + }, + { + "id": "68f2df6f-bbe5-5686-bfd4-f7d83674c9f7", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 191.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 311.868791, + "y": 46.957051 + } + } + }, + { + "id": "b171f15b-9fef-51bb-a8ab-a8b34e1d3b69", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 311.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 431.868791, + "y": 46.957051 + } + } + }, + { + "id": "e65dc083-9a58-565c-9646-8c42ea6374f4", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 431.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 481.868791, + "y": 46.957051 + } + } + }, + { + "id": "b4af8196-f773-5019-acd8-47ec851924e0", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 481.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 531.868791, + "y": 46.957051 + } + } + }, + { + "id": "b55bb02e-7383-5761-96b3-e0c5fab6be84", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 581.868791, + "y": 46.957051 + } + } + }, + { + "id": "812c9829-0bf3-5977-b63c-dc79aaa05345", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 581.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 631.868791, + "y": 46.957051 + } + } + }, + { + "id": "441bcd6a-6952-5e5e-a0e5-cb271ee3a74e", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 631.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 681.868791, + "y": 46.957051 + } + } + }, + { + "id": "72edfb24-244a-58a0-b73d-61515cfa2260", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 681.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 771.868791, + "y": 46.957051 + } + } + }, + { + "id": "97fe4eec-8e51-570c-a55f-609112757912", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 771.868791, + "y": 46.957051 + }, + "endPoint": { + "x": 811.88456, + "y": 46.957051 + } + } + } + ] }, { - "id": "b4af8196-f773-5019-acd8-47ec851924e0", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 481.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 531.868791, - "y": 46.957051 + "id": "a9bd2909-9679-51cf-b059-9f033e6343a8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "공 사 명", + "basePoint": { + "x": 116.86879080885097, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 150.0, + "boxHeight": 8.0 + } } - } }, { - "id": "b55bb02e-7383-5761-96b3-e0c5fab6be84", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 581.868791, - "y": 46.957051 + "id": "39c4b453-9139-5abc-bbc0-dcba99b7a3dd", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "시 행 청", + "basePoint": { + "x": 251.8687906175109, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 8.0 + } } - } }, { - "id": "812c9829-0bf3-5977-b63c-dc79aaa05345", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 581.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 631.868791, - "y": 46.957051 + "id": "0294bb9d-5715-590c-8f92-0023891244f1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 191.86879061770193, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 191.86879061770193, + "y": 16.9570509256849 + } } - } }, { - "id": "441bcd6a-6952-5e5e-a0e5-cb271ee3a74e", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 631.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 681.868791, - "y": 46.957051 + "id": "67e43a5c-8da3-56a7-adb2-1b137e3a7cba", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 311.8687906173199, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 311.8687906173199, + "y": 16.9570509256849 + } } - } }, { - "id": "72edfb24-244a-58a0-b73d-61515cfa2260", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 681.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 771.868791, - "y": 46.957051 + "id": "e545c507-25b7-5cc4-a203-22cb016cb568", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.8687906166197, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 683.9113785812806, + "y": 42.9570509255885 + } } - } }, { - "id": "97fe4eec-8e51-570c-a55f-609112757912", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 771.868791, - "y": 46.957051 - }, - "endPoint": { - "x": 811.88456, - "y": 46.957051 + "id": "70e1f8cd-7698-5543-83ef-e6867e3930db", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "축 척", + "basePoint": { + "x": 456.8687906168583, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 8.0 + } } - } - } - ] - }, - { - "id": "a9bd2909-9679-51cf-b059-9f033e6343a8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "공 사 명", - "basePoint": { - "x": 63.850772, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "39c4b453-9139-5abc-bbc0-dcba99b7a3dd", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "시 행 청", - "basePoint": { - "x": 206.931974, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0294bb9d-5715-590c-8f92-0023891244f1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 191.86879061770193, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 191.86879061770193, - "y": 16.9570509256849 - } - } - }, - { - "id": "67e43a5c-8da3-56a7-adb2-1b137e3a7cba", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 311.8687906173199, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 311.8687906173199, - "y": 16.9570509256849 - } - } - }, - { - "id": "e545c507-25b7-5cc4-a203-22cb016cb568", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.8687906166197, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 683.9113785812806, - "y": 42.9570509255885 - } - } - }, - { - "id": "70e1f8cd-7698-5543-83ef-e6867e3930db", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "축 척", - "basePoint": { - "x": 440.340788, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a5adc701-cb7d-58b9-9200-a73f4f81f9c8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "용 역 회 사", - "basePoint": { - "x": 328.367088, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dbdfc682-1db2-5d4e-b2b1-93a32c896ed4", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 431.86879061693793, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 431.86879061693793, - "y": 16.9570509256849 - } - } - }, - { - "id": "7e9e6bc8-a0b8-557f-87f8-3e22105d62c3", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "설 계 일 자", - "basePoint": { - "x": 485.229485, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "3ee8471c-b208-5162-a423-00d56c4c66ca", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 481.8687906167787, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 481.8687906167787, - "y": 16.9570509256849 - } - } - }, - { - "id": "5dab22cd-17ea-5f73-9c91-972d3bfcc45d", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 531.8687906166197, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 531.8687906166197, - "y": 16.9570509256849 - } - } - }, - { - "id": "a1d36121-bb45-5d55-a39a-794cbe406a45", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "과 업 책 임 자", - "basePoint": { - "x": 541.919243, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "4f4dd651-62d1-583e-8950-5af2df12e8c8", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "과 업 참 여 자", - "basePoint": { - "x": 576.555284, - "y": 43.614023 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0875af88-a1da-544e-aeea-2009e0abfdf1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 633.9113785814397, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 633.9113785814397, - "y": 16.9570509256849 - } - } - }, - { - "id": "2270dc15-3aa3-543f-9994-eb8ad03f6e48", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 583.9113785815989, - "y": 42.9570509255885 - }, - "endPoint": { - "x": 583.9113785815989, - "y": 16.9570509256849 - } - } - }, - { - "id": "88bed02f-726d-5240-ab3a-7e75f3103b5f", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "분 야 별 책 임 자", - "basePoint": { - "x": 592.856439, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "192fbaba-663c-5621-b4be-8fd026441fc6", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 683.9113785812806, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 683.9113785812806, - "y": 16.9570509256849 - } - } - }, - { - "id": "730ab3b1-fb49-525e-bbcb-1bfa7c1e4b38", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "설 계", - "basePoint": { - "x": 644.130922, - "y": 39.966633 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 2.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "91bc7cf0-9342-513f-a2d5-fb1d3d07ac27", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "도 면 명", - "basePoint": { - "x": 696.657727, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "544e258c-6582-5a9d-b7b3-fc4ad66d1a40", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 773.9113785809941, - "y": 46.95705092559395 - }, - "endPoint": { - "x": 773.9113785809941, - "y": 16.9570509256849 - } - } - }, - { - "id": "1cf72e22-1d48-5040-b743-60d7d4e831fd", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "도 면 번 호", - "basePoint": { - "x": 777.837719, - "y": 40.37382 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "405c6bca-5474-5c97-ba3b-f050bb937028", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": -5.042927745980399 - } - } - }, - { - "id": "60b1cfbb-b83a-58fe-b479-e6952e257bd5", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": 588.9570722521242 - } - } - }, - { - "id": "790eb8e0-9235-594f-bb0a-b4143c77de3e", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": 588.9570722521242 - } - } - }, - { - "id": "34034223-62f9-5ef4-9441-cb2e12ac1cc9", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": -5.042927745980399 - } - } - }, - { - "id": "ba26e5e2-7bc5-508f-8dc6-bd6fe45a8cfc", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{분야별책임자}}", - "basePoint": { - "x": 602.801356, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "e527186e-631a-58d6-94fc-7c2c58c0f83a", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{과업책임자}}", - "basePoint": { - "x": 551.777083, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a064bcaf-45b7-5593-9f80-e3efc198e12a", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{설계자}}", - "basePoint": { - "x": 651.777083, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "b13ad090-bc44-5ccf-b304-6eb1c67d529f", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{설계일자}}", - "basePoint": { - "x": 507.61607, - "y": 27.994425 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "37e7e47d-58ca-5947-b317-d0a9d3fbdfd7", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": 588.9570722521242 - } - } - }, - { - "id": "794735d9-a50c-5a08-a20c-3dd7a214098b", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": 834.9487901252105, - "y": -5.042927745980399 - } - } - }, - { - "id": "266db3e6-4103-586c-b200-92b56b7b5bc5", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": -5.042927745980399 - } - } - }, - { - "id": "746a96e6-c65e-51bb-a694-2f73d3f6955a", - "type": "Point", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "point": { - "x": -5.051209872110173, - "y": 588.9570722521242 - } - } - }, - { - "id": "a2c8ae54-0aca-59c5-9768-f5f0a7869ad6", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{용역회사}}", - "basePoint": { - "x": 380.474721, - "y": 28.814653 - }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "89422706-3f58-52af-8730-5d535e4b1d72", - "type": "PolyLine", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": null, - "children": [ - { - "id": "822c8fe7-c92b-5b49-81d3-f76eb9daa60b", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": -5.05121, - "y": -5.042928 - }, - "endPoint": { - "x": 834.94879, - "y": -5.042928 - } - } }, { - "id": "c41de656-f300-5bc8-83a2-60d11e94ee04", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 834.94879, - "y": -5.042928 - }, - "endPoint": { - "x": 834.94879, - "y": 588.957072 + "id": "a5adc701-cb7d-58b9-9200-a73f4f81f9c8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "용 역 회 사", + "basePoint": { + "x": 371.8687906171289, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 8.0 + } } - } }, { - "id": "0c87cdfc-00d0-55c0-b3ce-f9c004a66e5b", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": 834.94879, - "y": 588.957072 - }, - "endPoint": { - "x": -5.05121, - "y": 588.957072 + "id": "dbdfc682-1db2-5d4e-b2b1-93a32c896ed4", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 431.86879061693793, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 431.86879061693793, + "y": 16.9570509256849 + } } - } }, { - "id": "4e989434-b862-5526-b497-1e4e77f999a1", - "type": "Line", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "startPoint": { - "x": -5.05121, - "y": 588.957072 - }, - "endPoint": { - "x": -5.05121, - "y": -5.042928 + "id": "7e9e6bc8-a0b8-557f-87f8-3e22105d62c3", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "설 계 일 자", + "basePoint": { + "x": 506.8687906166992, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 8.0 + } } - } - } - ] - }, - { - "id": "9f90034d-aa58-52e3-ba60-c64f0ed5bcd9", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "{{시행청}}", - "basePoint": { - "x": 263.627137, - "y": 28.814653 }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 5.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "9ed851f0-304d-52fa-8da6-bf5383c854e9", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "A1 = 1 :", - "basePoint": { - "x": 449.778169, - "y": 31.289636 + { + "id": "3ee8471c-b208-5162-a423-00d56c4c66ca", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 481.8687906167787, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 481.8687906167787, + "y": 16.9570509256849 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "62ba7317-ee9c-51e7-8f24-23a9601fa1e6", - "type": "Text", - "lineColor": "#ff7f00", - "lineWidth": 1, - "layerId": "-00.기본BOX", - "shapeData": { - "label": "A3 = 1 :", - "basePoint": { - "x": 449.778169, - "y": 25.208603 + { + "id": "5dab22cd-17ea-5f73-9c91-972d3bfcc45d", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 531.8687906166197, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 531.8687906166197, + "y": 16.9570509256849 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "center", - "textColor": "#ff7f00", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "8cc71001-6a4c-55e2-91c2-3171256650f4", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{축척_A1}}", - "basePoint": { - "x": 462.443604, - "y": 29.323307 + { + "id": "a1d36121-bb45-5d55-a39a-794cbe406a45", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "과 업 책 임 자", + "basePoint": { + "x": 557.8900845991093, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 52.043, + "boxHeight": 4.0 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 4.0, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "2472aea6-efac-52fb-9fb9-f266b8fa184e", - "type": "Text", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "label": "{{축척_A3}}", - "basePoint": { - "x": 462.443604, - "y": 23.242274 + { + "id": "4f4dd651-62d1-583e-8950-5af2df12e8c8", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "과 업 참 여 자", + "basePoint": { + "x": 607.8900845989501, + "y": 44.95705096279425 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 152.043, + "boxHeight": 4.0 + } + } }, - "options": { - "textDirection": { - "x": 1.0, - "y": 0.0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 4.0, - "fontFamily": "sans-serif" + { + "id": "0875af88-a1da-544e-aeea-2009e0abfdf1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 633.9113785814397, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 633.9113785814397, + "y": 16.9570509256849 + } + } + }, + { + "id": "2270dc15-3aa3-543f-9994-eb8ad03f6e48", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 583.9113785815989, + "y": 42.9570509255885 + }, + "endPoint": { + "x": 583.9113785815989, + "y": 16.9570509256849 + } + } + }, + { + "id": "88bed02f-726d-5240-ab3a-7e75f3103b5f", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "분 야 별 책 임 자", + "basePoint": { + "x": 608.9113785815193, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 4.0 + } + } + }, + { + "id": "192fbaba-663c-5621-b4be-8fd026441fc6", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 683.9113785812806, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 683.9113785812806, + "y": 16.9570509256849 + } + } + }, + { + "id": "730ab3b1-fb49-525e-bbcb-1bfa7c1e4b38", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "설 계", + "basePoint": { + "x": 658.9113785813602, + "y": 40.95705092559031 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 2.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 4.0 + } + } + }, + { + "id": "91bc7cf0-9342-513f-a2d5-fb1d3d07ac27", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "도 면 명", + "basePoint": { + "x": 728.9113785811373, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 90.0, + "boxHeight": 8.0 + } + } + }, + { + "id": "544e258c-6582-5a9d-b7b3-fc4ad66d1a40", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 773.9113785809941, + "y": 46.95705092559395 + }, + "endPoint": { + "x": 773.9113785809941, + "y": 16.9570509256849 + } + } + }, + { + "id": "1cf72e22-1d48-5040-b743-60d7d4e831fd", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "도 면 번 호", + "basePoint": { + "x": 792.890084790497, + "y": 42.95705096279606 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 37.957, + "boxHeight": 8.0 + } + } + }, + { + "id": "405c6bca-5474-5c97-ba3b-f050bb937028", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": -5.042927745980399 + } + } + }, + { + "id": "60b1cfbb-b83a-58fe-b479-e6952e257bd5", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": 588.9570722521242 + } + } + }, + { + "id": "790eb8e0-9235-594f-bb0a-b4143c77de3e", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": 588.9570722521242 + } + } + }, + { + "id": "34034223-62f9-5ef4-9441-cb2e12ac1cc9", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": -5.042927745980399 + } + } + }, + { + "id": "ba26e5e2-7bc5-508f-8dc6-bd6fe45a8cfc", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{분야별책임자}}", + "basePoint": { + "x": 608.9113785815193, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "e527186e-631a-58d6-94fc-7c2c58c0f83a", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{과업책임자}}", + "basePoint": { + "x": 557.8900845991093, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 52.043, + "boxHeight": 22.0 + } + } + }, + { + "id": "a064bcaf-45b7-5593-9f80-e3efc198e12a", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{설계자}}", + "basePoint": { + "x": 658.9113785813602, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "b13ad090-bc44-5ccf-b304-6eb1c67d529f", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{설계일자}}", + "basePoint": { + "x": 506.8687906166992, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "37e7e47d-58ca-5947-b317-d0a9d3fbdfd7", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": 588.9570722521242 + } + } + }, + { + "id": "794735d9-a50c-5a08-a20c-3dd7a214098b", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": 834.9487901252105, + "y": -5.042927745980399 + } + } + }, + { + "id": "266db3e6-4103-586c-b200-92b56b7b5bc5", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": -5.042927745980399 + } + } + }, + { + "id": "746a96e6-c65e-51bb-a694-2f73d3f6955a", + "type": "Point", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "point": { + "x": -5.051209872110173, + "y": 588.9570722521242 + } + } + }, + { + "id": "a2c8ae54-0aca-59c5-9768-f5f0a7869ad6", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{용역회사}}", + "basePoint": { + "x": 371.8687906171289, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "89422706-3f58-52af-8730-5d535e4b1d72", + "type": "PolyLine", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": null, + "children": [ + { + "id": "822c8fe7-c92b-5b49-81d3-f76eb9daa60b", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": -5.05121, + "y": -5.042928 + }, + "endPoint": { + "x": 834.94879, + "y": -5.042928 + } + } + }, + { + "id": "c41de656-f300-5bc8-83a2-60d11e94ee04", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 834.94879, + "y": -5.042928 + }, + "endPoint": { + "x": 834.94879, + "y": 588.957072 + } + } + }, + { + "id": "0c87cdfc-00d0-55c0-b3ce-f9c004a66e5b", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": 834.94879, + "y": 588.957072 + }, + "endPoint": { + "x": -5.05121, + "y": 588.957072 + } + } + }, + { + "id": "4e989434-b862-5526-b497-1e4e77f999a1", + "type": "Line", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "startPoint": { + "x": -5.05121, + "y": 588.957072 + }, + "endPoint": { + "x": -5.05121, + "y": -5.042928 + } + } + } + ] + }, + { + "id": "9f90034d-aa58-52e3-ba60-c64f0ed5bcd9", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "{{시행청}}", + "basePoint": { + "x": 251.8687906175109, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 5.0, + "fontFamily": "sans-serif", + "boxWidth": 120.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "9ed851f0-304d-52fa-8da6-bf5383c854e9", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "A1 = 1 :", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "62ba7317-ee9c-51e7-8f24-23a9601fa1e6", + "type": "Text", + "lineColor": "#ff7f00", + "lineWidth": 1, + "layerId": "-00.기본BOX", + "shapeData": { + "label": "A3 = 1 :", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#ff7f00", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "8cc71001-6a4c-55e2-91c2-3171256650f4", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{축척_A1}}", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "2472aea6-efac-52fb-9fb9-f266b8fa184e", + "type": "Text", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "label": "{{축척_A3}}", + "basePoint": { + "x": 456.8687906168583, + "y": 27.957050962796064 + }, + "options": { + "textDirection": { + "x": 1.0, + "y": 0.0 + }, + "textAlign": "center", + "textColor": "#f5f7fa", + "fontSize": 4.0, + "fontFamily": "sans-serif", + "boxWidth": 50.0, + "boxHeight": 22.0 + } + } + }, + { + "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 316.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 20.0 + }, + { + "x": 348.0, + "y": 36.0 + }, + { + "x": 316.0, + "y": 36.0 + } + ], + "imageData": "{{회사로고}}" + } + }, + { + "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 638.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 17.5 + }, + { + "x": 680.0, + "y": 25.5 + }, + { + "x": 638.0, + "y": 25.5 + } + ], + "imageData": "{{설계자서명}}" + } + }, + { + "id": "335bfca6-01e8-50a9-bd3d-367bc1f78e7d", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 536.89, + "y": 17.5 + }, + { + "x": 578.89, + "y": 17.5 + }, + { + "x": 578.89, + "y": 25.5 + }, + { + "x": 536.89, + "y": 25.5 + } + ], + "imageData": "{{과업책임자서명}}" + } + }, + { + "id": "d404c499-15dc-528f-a3bd-821cefa41961", + "type": "Image", + "lineColor": "#f5f7fa", + "lineWidth": 1, + "layerId": "-00.기본BOX TEXT", + "shapeData": { + "points": [ + { + "x": 587.91, + "y": 17.5 + }, + { + "x": 629.91, + "y": 17.5 + }, + { + "x": 629.91, + "y": 25.5 + }, + { + "x": 587.91, + "y": 25.5 + } + ], + "imageData": "{{분야별책임자서명}}" + } } - } - }, - { - "id": "e2f12542-1cbd-5c65-b95d-7c11c63b25ca", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 316.0, - "y": 20.0 - }, - { - "x": 348.0, - "y": 20.0 - }, - { - "x": 348.0, - "y": 36.0 - }, - { - "x": 316.0, - "y": 36.0 - } - ], - "imageData": "{{회사로고}}" - } - }, - { - "id": "9452b160-d6c4-5437-8a47-7824b6df62ea", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 638.0, - "y": 17.5 - }, - { - "x": 680.0, - "y": 17.5 - }, - { - "x": 680.0, - "y": 25.5 - }, - { - "x": 638.0, - "y": 25.5 - } - ], - "imageData": "{{설계자서명}}" - } - }, - { - "id": "335bfca6-01e8-50a9-bd3d-367bc1f78e7d", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 536.89, - "y": 17.5 - }, - { - "x": 578.89, - "y": 17.5 - }, - { - "x": 578.89, - "y": 25.5 - }, - { - "x": 536.89, - "y": 25.5 - } - ], - "imageData": "{{과업책임자서명}}" - } - }, - { - "id": "d404c499-15dc-528f-a3bd-821cefa41961", - "type": "Image", - "lineColor": "#f5f7fa", - "lineWidth": 1, - "layerId": "-00.기본BOX TEXT", - "shapeData": { - "points": [ - { - "x": 587.91, - "y": 17.5 - }, - { - "x": 629.91, - "y": 17.5 - }, - { - "x": 629.91, - "y": 25.5 - }, - { - "x": 587.91, - "y": 25.5 - } - ], - "imageData": "{{분야별책임자서명}}" - } - } - ], - "layers": [ - { - "id": "-00.기본BOX TEXT", - "name": "-00.기본BOX TEXT", - "isVisible": true, - "isLocked": false - }, - { - "id": "-00.기본BOX", - "name": "-00.기본BOX", - "isVisible": true, - "isLocked": false - } - ] -} + ], + "layers": [ + { + "id": "-00.기본BOX TEXT", + "name": "-00.기본BOX TEXT", + "isVisible": true, + "isLocked": false + }, + { + "id": "-00.기본BOX", + "name": "-00.기본BOX", + "isVisible": true, + "isLocked": false + } + ] +} \ No newline at end of file diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index af121860..69fe4d01 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -125,7 +125,6 @@ export const ui_locales_b1 = { B01_Dashboard_SaveProfile: ["기본정보 저장", "Save profile"], B01_Dashboard_Table_Project: ["프로젝트명", "Project"], B01_Dashboard_Table_Region: ["지역", "Region"], - B01_Dashboard_Table_Progress: ["진행도", "Progress"], B01_Dashboard_Table_Workflow: ["워크플로우", "Workflow"], B01_Dashboard_Table_Updated: ["수정일", "Updated"], B01_Dashboard_Table_Email: ["이메일", "Email"], @@ -137,12 +136,17 @@ export const ui_locales_b1 = { B01_Dashboard_Table_Company: ["회사명", "Company"], B01_Dashboard_Table_Requested: ["신청일", "Requested"], B01_Dashboard_Table_Action: ["관리", "Action"], + B01_Dashboard_Table_Event: ["동작", "Event"], + B01_Dashboard_Table_Target: ["대상", "Target"], + B01_Dashboard_Table_Origin: ["접속 주소", "From"], + B01_Dashboard_Table_When: ["일시", "When"], B01_Dashboard_Table_Owner: ["소유자", "Owner"], B01_Dashboard_Field_BusinessNumber: ["사업자등록번호", "Business number"], B01_Dashboard_Field_Address: ["주소", "Address"], B01_Dashboard_Field_Owner: ["대표자명", "Owner"], B01_Dashboard_Field_Search: ["검색어", "Search"], B01_Dashboard_Field_MemberEmail: ["팀원 이메일", "Member email"], + B01_Dashboard_Field_Email: ["이메일", "Email"], B01_Dashboard_Metric_Cpu: ["CPU", "CPU"], B01_Dashboard_Metric_Memory: ["메모리", "Memory"], B01_Dashboard_Metric_Disk: ["디스크", "Disk"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 607c6093..3fe0881b 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -2,7 +2,10 @@ import "./ui_template_overlay.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { makePanelDraggable } from "./ui_template_overlay_drag"; -import { fetchProjectWorkflowState } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 +// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. +import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; @@ -82,7 +85,7 @@ export function createWorkflowPanelHandle( } function readOpenState(key: string): boolean { - return sessionStorage.getItem(key) !== "false"; + return readByKey(key) !== "false"; } /** @@ -124,7 +127,7 @@ function createProjectNameTag(): HTMLElement { tag.className = "ui-workflow-overlay__project"; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return tag; - void fetchProjectWorkflowState(projectId) + void fetchWorkflowState(projectId) .then((state) => { const name = state.project_name ?? ""; tag.textContent = name; @@ -189,7 +192,7 @@ function createPanel( toggle.setAttribute("aria-label", toggle.title); toggle.setAttribute("aria-expanded", String(isOpen)); } - sessionStorage.setItem(storageKey, String(isOpen)); + writeByKey(storageKey, String(isOpen)); // 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다. if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh()); onOpenChange?.(isOpen); diff --git a/ui_template/ui_template_overlay_drag.ts b/ui_template/ui_template_overlay_drag.ts index adb456bd..35e205cc 100644 --- a/ui_template/ui_template_overlay_drag.ts +++ b/ui_template/ui_template_overlay_drag.ts @@ -11,6 +11,8 @@ * 자리는 세션에만 남긴다(5장 데이터 3층 — 화면 조작값은 캐시 몫). */ +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; + const DRAG_THRESHOLD_PX = 4; /** 상단 공용 헤더 높이(--spacing-64) — 그 위로는 못 올라간다. */ const MIN_TOP_PX = 64; @@ -28,7 +30,7 @@ interface PanelPosition { } function readPosition(key: string): PanelPosition | null { - const raw = sessionStorage.getItem(key); + const raw = readByKey(key); if (!raw) return null; try { const parsed = JSON.parse(raw) as Partial; @@ -92,7 +94,7 @@ export function makePanelDraggable( root.style.bottom = "auto"; root.style.top = `${next.top}px`; } - sessionStorage.setItem(storageKey, JSON.stringify(next)); + writeByKey(storageKey, JSON.stringify(next)); } function swallowNextClick(): void { diff --git a/ui_template/ui_template_resizer.ts b/ui_template/ui_template_resizer.ts index cd788b09..a25a8436 100644 --- a/ui_template/ui_template_resizer.ts +++ b/ui_template/ui_template_resizer.ts @@ -1,10 +1,11 @@ import "./ui_template_resizer.css"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; /* ============================================================================= * ui_template_resizer.ts * 공통 스플리터 — 패널 경계를 끌어 크기를 조절한다. * - * 조절값은 `sessionStorage`에만 남긴다. 같은 브라우저 세션에서는 페이지를 오가도 + * 조절값은 등록표가 정한 저장소에 남긴다(취향은 브라우저, 나머지는 세션 — 2026-09-07). 같은 브라우저 세션에서는 페이지를 오가도 * 유지되고, 브라우저를 다시 열면 기본값으로 돌아간다(2026-08-01 사용자 지시). * 사용자별 영구 저장은 나중에 따로 검토한다. * ========================================================================== */ @@ -61,20 +62,20 @@ export function createPanelResizer(options: PanelResizerOptions): PanelResizer { function apply(size: number, persist: boolean): void { const next = clamp(size); target.style.setProperty(cssVar, `${Math.round(next)}px`); - if (persist && storageKey) sessionStorage.setItem(storageKey, String(Math.round(next))); + if (persist && storageKey) writeByKey(storageKey, String(Math.round(next))); onResize?.(next); } function restore(): void { if (!storageKey) return; - const saved = Number(sessionStorage.getItem(storageKey)); + const saved = Number(readByKey(storageKey)); // 저장한 뒤 창 크기가 바뀌었을 수 있으니 복원할 때도 상·하한을 다시 씌운다. if (Number.isFinite(saved) && saved > 0) apply(saved, false); } function reset(): void { target.style.removeProperty(cssVar); - if (storageKey) sessionStorage.removeItem(storageKey); + if (storageKey) writeByKey(storageKey, null); onResize?.(axis === "vertical" ? target.clientHeight : target.clientWidth); } diff --git a/vite.config.ts b/vite.config.ts index 2dbf28fe..c7e98dd9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,12 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); -const apiTarget = `http://localhost:${process.env.AISLO_API_PORT ?? "8000"}`; +// ⚠ `localhost` 로 두지 말 것 — Node 18+ 는 그것을 **IPv6(::1) 먼저** 물고, 백엔드는 +// `SERVER_HOST=0.0.0.0`(IPv4 전용)로 뜬다. 그러면 프록시가 `[::1]:<포트>` 로 붙으려다 +// 실패해 화면의 **모든 `/api` 요청이 「Failed to fetch」** 가 된다 — 서버는 멀쩡한데 +// 저장이 실패하고, 부팅 때 죽으면 화면이 통째로 백지가 된다(2026-09-07 실측: +// `127.0.0.1:8001` 200 · `[::1]:8001` 연결 실패). 숫자로 박아 그 갈림을 없앤다. +const apiTarget = `http://127.0.0.1:${process.env.AISLO_API_PORT ?? "8000"}`; /** * Vite 설정 — 임도 설계 웹앱 (Vanilla TS + Three.js)